Compare commits

...
24 Commits
Author SHA1 Message Date
liwei 4840c4b8a4 ci: fix build env 2023-11-09 18:49:35 +08:00
Levi e72b097aa2 release: v1.4.4 2023-11-09 17:09:15 +08:00
Levi af52c4ce7e feat: support create task directly (#262) 2023-11-09 16:52:02 +08:00
Levi aae79c2449 feat: running foreground service on mobile (#261) 2023-11-07 17:52:10 +08:00
Levi 83838afce9 feat: support more linux distributions (#254) 2023-11-01 14:54:24 +08:00
Levi 4690107e32 fix: gopeed cli BT download (#258) 2023-10-31 10:16:28 +08:00
liwei 74b08b3631 chore: build locales.g.dart 2023-10-31 09:24:55 +08:00
Kiber_kotan 953d348c0c feat: improve Russian translation (#257) 2023-10-30 11:27:43 +08:00
Levi 00cea68b6e feat: add extension intro links (#252) 2023-10-26 18:35:22 +08:00
Levi daa9d4c707 release: v1.4.3 2023-10-24 16:17:48 +08:00
liwei 6bc587752a chore: update introduction 2023-10-24 16:16:16 +08:00
Levi 11befc09bd refractor: downloader task action dispatch (#249) 2023-10-24 16:02:39 +08:00
liwei ff6c0da262 chore: locales build 2023-10-23 13:55:13 +08:00
Levi aab4a37a7e feat: adapt mobile extension list (#242) 2023-10-21 16:36:22 +08:00
Levi 74c9491893 fix: linux system tray tooltip (#243) 2023-10-21 16:29:15 +08:00
Levi 256faf2cbd feat: add extension setting tooltip (#247) 2023-10-21 16:16:46 +08:00
Om Jogani 32e3620f5e feat: Implemented History Panel for reusing previously used Magnet & Torrent Links (#245) 2023-10-21 15:52:34 +08:00
Levi 39202b44a1 fix: http retry strategy (#240) 2023-10-20 12:15:21 +08:00
Levi 457d9e85d6 release: v1.4.2 2023-10-19 18:36:46 +08:00
Levi 858ad74339 feat: extension on start event (#239) 2023-10-19 18:08:27 +08:00
Levi 755d7ce814 fix: add system tray tooltip (#237) 2023-10-19 14:00:15 +08:00
Levi 854f600d3f test: add extension script error case (#238) 2023-10-19 13:59:59 +08:00
Levi 28da89f08c release: v1.4.1 2023-10-18 11:10:04 +08:00
Levi dddefb68a2 feat: support storage state on extension (#235) 2023-10-18 10:59:51 +08:00
60 changed files with 1948 additions and 519 deletions

No files matched your search

+7 -1
View File
@@ -11,6 +11,8 @@ categories:
- 'fix' - 'fix'
- 'bugfix' - 'bugfix'
- 'bug' - 'bug'
- title: '🧪 Tests'
label: 'test'
- title: '🧰 Maintenance' - title: '🧰 Maintenance'
label: 'chore' label: 'chore'
- title: '📖 Document' - title: '📖 Document'
@@ -30,6 +32,10 @@ version-resolver:
- 'patch' - 'patch'
default: patch default: patch
template: | template: |
## Changes # Changes
$CHANGES
# 更新日志
$CHANGES $CHANGES
+161 -6
View File
@@ -2,6 +2,11 @@ name: build
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
platform:
description: 'Build platform'
required: true
default: 'all'
env: env:
GO_VERSION: "1.19" GO_VERSION: "1.19"
@@ -24,6 +29,7 @@ jobs:
myToken: ${{ github.token }} myToken: ${{ github.token }}
build-windows: build-windows:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
runs-on: windows-latest runs-on: windows-latest
needs: [ get-release ] needs: [ get-release ]
steps: steps:
@@ -44,7 +50,7 @@ jobs:
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter cd ui/flutter
flutter build windows flutter build windows
$mingw = "C:\ProgramData\Chocolatey\lib\mingw\tools\install\mingw64\bin" $mingw = "C:\Program Files\Git\mingw64\bin"
$system = "C:\Windows\System32" $system = "C:\Windows\System32"
$release = "build\windows\runner\Release\" $release = "build\windows\runner\Release\"
cp $mingw\libstdc++-6.dll $release cp $mingw\libstdc++-6.dll $release
@@ -124,6 +130,7 @@ jobs:
asset_path: ui/flutter/build/windows/Output/* asset_path: ui/flutter/build/windows/Output/*
overwrite: true overwrite: true
build-macos-arm64-lib: build-macos-arm64-lib:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ get-release ] needs: [ get-release ]
steps: steps:
@@ -134,13 +141,14 @@ jobs:
- name: Build - name: Build
run: | run: |
go install src.techknowlogick.com/xgo@latest go install src.techknowlogick.com/xgo@latest
xgo -go go-1.19.x --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed . xgo -go go-$GO_VERSION.x --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed .
mv libgopeed-*.dylib libgopeed.dylib mv libgopeed-*.dylib libgopeed.dylib
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: macos-arm64-lib name: macos-arm64-lib
path: libgopeed.dylib path: libgopeed.dylib
build-macos: build-macos:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: macos-latest runs-on: macos-latest
needs: [ get-release, build-macos-arm64-lib ] needs: [ get-release, build-macos-arm64-lib ]
steps: steps:
@@ -155,7 +163,9 @@ jobs:
with: with:
flutter-version: ${{ env.FLUTTER_VERSION }} flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install appdmg - name: Install appdmg
run: npm install -g appdmg run: |
python3 -m pip install setuptools
npm install -g appdmg
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
name: macos-arm64-lib name: macos-arm64-lib
@@ -197,6 +207,7 @@ jobs:
asset_path: ui/flutter/build/macos/Build/Products/Release/dist/* asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*
overwrite: true overwrite: true
build-linux: build-linux:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ get-release ] needs: [ get-release ]
steps: steps:
@@ -210,6 +221,10 @@ jobs:
- run: | - run: |
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
- name: Setup LXD
uses: canonical/[email protected]
with:
channel: latest/stable
- name: Build - name: Build
env: env:
VERSION: ${{ needs.get-release.outputs.tag_name }} VERSION: ${{ needs.get-release.outputs.tag_name }}
@@ -217,13 +232,20 @@ jobs:
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter cd ui/flutter
flutter build linux flutter build linux
mkdir -p build/dist
# Build portable
tar -czaf build/dist/Gopeed-$VERSION-linux-amd64-portable.tar.gz build/linux/x64/release/bundle
# Build deb package
mkdir -p debian/gui mkdir -p debian/gui
cp assets/icon/icon_1024.png debian/gui/gopeed.png cp assets/icon/icon_1024.png debian/gui/gopeed.png
cat>debian/debian.yaml<<EOF cat>debian/debian.yaml<<EOF
flutter_app: flutter_app:
command: gopeed command: gopeed
arch: x64 arch: x64
parent: /usr/local/lib parent: /usr/local/bin
control: control:
Package: gopeed Package: gopeed
Version: ${VERSION:1} Version: ${VERSION:1}
@@ -241,6 +263,8 @@ jobs:
Name=Gopeed Name=Gopeed
GenericName=Gopeed GenericName=Gopeed
Comment=High speed downloader that supports all platforms. Comment=High speed downloader that supports all platforms.
Exec=gopeed
Icon=gopeed.png
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Utility Categories=Utility
@@ -249,14 +273,142 @@ jobs:
dart pub global activate flutter_to_debian dart pub global activate flutter_to_debian
flutter_to_debian flutter_to_debian
mv debian/packages/gopeed*.deb debian/packages/Gopeed-$VERSION-linux-amd64.deb mv debian/packages/gopeed*.deb build/dist/Gopeed-$VERSION-linux-amd64.deb
# Build AppImage
sudo apt-get -y install libfuse2
wget -O appimage-builder https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage
chmod +x appimage-builder
mkdir AppDir
cp -r build/linux/x64/release/bundle/* AppDir
mkdir -p AppDir/usr/share/icons
cp assets/icon/icon_1024.png AppDir/usr/share/icons/gopeed.png
cat>AppImageBuilder.yml<<EOF
version: 1
AppDir:
path: ${PWD}/AppDir
app_info:
id: com.gopeed.gopeed
name: Gopeed
icon: gopeed.png
version: ${VERSION:1}
exec: gopeed
exec_args: \$@
apt:
arch: amd64
allow_unauthenticated: true
sources:
- sourceline: deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy main restricted
universe multiverse
- sourceline: deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-updates main
restricted universe multiverse
- sourceline: deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-backports
main restricted universe multiverse
- sourceline: deb http://security.ubuntu.com/ubuntu/ jammy-security main restricted
universe multiverse
include: []
files:
include: []
exclude:
- usr/share/man
- usr/share/doc/*/README.*
- usr/share/doc/*/changelog.*
- usr/share/doc/*/NEWS.*
- usr/share/doc/*/TODO.*
test:
fedora-30:
image: appimagecrafters/tests-env:fedora-30
command: ./AppRun
debian-stable:
image: appimagecrafters/tests-env:debian-stable
command: ./AppRun
archlinux-latest:
image: appimagecrafters/tests-env:archlinux-latest
command: ./AppRun
centos-7:
image: appimagecrafters/tests-env:centos-7
command: ./AppRun
ubuntu-xenial:
image: appimagecrafters/tests-env:ubuntu-xenial
command: ./AppRun
AppImage:
arch: x86_64
update-information: guess
EOF
./appimage-builder --skip-tests --recipe AppImageBuilder.yml
mv Gopeed-*-x86_64.AppImage build/dist/Gopeed-$VERSION-linux-amd64.AppImage
# Build snap
sudo snap install snapcraft --classic
rm -rf build/linux
mkdir -p snap/gui
cp assets/icon/icon_1024.png snap/gui/gopeed.png
cat>snap/snapcraft.yaml<<EOF
name: gopeed
version: ${VERSION:1}
summary: A modern download manager
description: High speed downloader that supports all platforms.
confinement: strict
base: core22
grade: stable
architectures:
- build-on: amd64
build-for: amd64
slots:
dbus-gopeed:
interface: dbus
bus: session
name: com.gopeed.gopeed
apps:
gopeed:
command: gopeed
extensions: [gnome]
plugs:
- network
slots:
- dbus-gopeed
parts:
gopeed:
source: .
plugin: flutter
stage-packages:
- libayatana-appindicator3-dev
flutter-target: lib/main.dart
EOF
cat>snap/gui/gopeed.desktop<<EOF
[Desktop Entry]
Version=${VERSION:1}
Name=Gopeed
GenericName=Gopeed
Comment=High speed downloader that supports all platforms.
Exec=gopeed
Icon=\${SNAP}/meta/gui/gopeed.png
Terminal=false
Type=Application
Categories=Utility
Keywords=Flutter;Downloader;
EOF
snapcraft --use-lxd
# Snapcraft login
export SNAPCRAFT_STORE_CREDENTIALS=${{ secrets.SNAP_STORE_LOGIN }}
snapcraft upload --release=stable gopeed_${VERSION:1}_amd64.snap
- name: Upload - name: Upload
uses: shogo82148/actions-upload-release-asset@v1 uses: shogo82148/actions-upload-release-asset@v1
with: with:
upload_url: ${{ needs.get-release.outputs.upload_url }} upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/debian/packages/* asset_path: ui/flutter/build/dist/*
overwrite: true overwrite: true
build-android: build-android:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ get-release ] needs: [ get-release ]
steps: steps:
@@ -294,6 +446,7 @@ jobs:
asset_path: ui/flutter/dist/* asset_path: ui/flutter/dist/*
overwrite: true overwrite: true
build-ios: build-ios:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
runs-on: macos-latest runs-on: macos-latest
needs: [ get-release ] needs: [ get-release ]
steps: steps:
@@ -326,6 +479,7 @@ jobs:
asset_path: ui/flutter/dist/* asset_path: ui/flutter/dist/*
overwrite: true overwrite: true
build-web: build-web:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ get-release ] needs: [ get-release ]
steps: steps:
@@ -373,6 +527,7 @@ jobs:
asset_path: dist/zip/* asset_path: dist/zip/*
overwrite: true overwrite: true
build-docker: build-docker:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'docker' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ get-release ] needs: [ get-release ]
steps: steps:
+2 -1
View File
@@ -13,7 +13,8 @@
## Introduction ## Introduction
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html).
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Develop Docs](https://docs.gopeed.com) Visit ✈ [Official Website](https://gopeed.com) | 📖 [Develop Docs](https://docs.gopeed.com)
+1 -1
View File
@@ -13,7 +13,7 @@
## はじめに ## はじめに
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。 Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com) 見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com)
+1 -1
View File
@@ -13,7 +13,7 @@
## 介绍 ## 介绍
Gopeed(全称 Go Speed),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。 Gopeed(全称 Go Speed),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [开发文档](https://docs.gopeed.com/zh/) 访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [开发文档](https://docs.gopeed.com/zh/)
+14 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/GopeedLab/gopeed/pkg/protocol/http"
@@ -47,16 +48,28 @@ func main() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
printProgress(emptyTask, "downloading...")
wg.Wait() wg.Wait()
} }
var ( var (
lastLineLen = 0 lastLineLen = 0
sb = new(strings.Builder) sb = new(strings.Builder)
emptyTask = &download.Task{
Progress: &download.Progress{},
Meta: &fetcher.FetcherMeta{
Res: &base.Resource{},
},
}
) )
func printProgress(task *download.Task, title string) { func printProgress(task *download.Task, title string) {
rate := float64(task.Progress.Downloaded) / float64(task.Meta.Res.Size) var rate float64
if task.Meta.Res.Size <= 0 {
rate = 0
} else {
rate = float64(task.Progress.Downloaded) / float64(task.Meta.Res.Size)
}
completeWidth := int(progressWidth * rate) completeWidth := int(progressWidth * rate)
speed := util.ByteFmt(task.Progress.Speed) speed := util.ByteFmt(task.Progress.Speed)
totalSize := util.ByteFmt(task.Meta.Res.Size) totalSize := util.ByteFmt(task.Meta.Res.Size)
+11 -4
View File
@@ -87,7 +87,6 @@ func (f *Fetcher) Create(opts *base.Options) (err error) {
if f.meta.Res != nil { if f.meta.Res != nil {
torrentDirMap[f.meta.Res.Hash] = f.meta.FolderPath() torrentDirMap[f.meta.Res.Hash] = f.meta.FolderPath()
} }
f.progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles))
return nil return nil
} }
@@ -101,6 +100,14 @@ func (f *Fetcher) Start() (err error) {
ft.setTorrentDir(f.meta.FolderPath()) ft.setTorrentDir(f.meta.FolderPath())
} }
files := f.torrent.Files() files := f.torrent.Files()
// If the user does not specify the file to download, all files will be downloaded by default
if len(f.meta.Opts.SelectFiles) == 0 {
f.meta.Opts.SelectFiles = make([]int, len(files))
for i := range files {
f.meta.Opts.SelectFiles[i] = i
}
}
f.progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles))
if len(f.meta.Opts.SelectFiles) == len(files) { if len(f.meta.Opts.SelectFiles) == len(files) {
f.torrent.DownloadAll() f.torrent.DownloadAll()
} else { } else {
@@ -134,7 +141,7 @@ func (f *Fetcher) safeDrop() {
func (f *Fetcher) Wait() (err error) { func (f *Fetcher) Wait() (err error) {
for { for {
if f.torrentReady.Load() { if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
done := true done := true
for _, selectIndex := range f.meta.Opts.SelectFiles { for _, selectIndex := range f.meta.Opts.SelectFiles {
file := f.torrent.Files()[selectIndex] file := f.torrent.Files()[selectIndex]
@@ -154,7 +161,7 @@ func (f *Fetcher) Wait() (err error) {
} }
} }
if !selected { if !selected {
util.SafeRemove(filepath.Join(f.meta.Opts.Path, file.Path())) util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))
} }
} }
break break
@@ -195,7 +202,7 @@ func (f *Fetcher) updateRes() {
Size: file.Length(), Size: file.Length(),
} }
} }
res.CalcSize() res.CalcSize(nil)
f.meta.Res = res f.meta.Res = res
if f.meta.Opts != nil { if f.meta.Opts != nil {
f.meta.Opts.InitSelectFiles(len(res.Files)) f.meta.Opts.InitSelectFiles(len(res.Files))
+48 -18
View File
@@ -18,6 +18,7 @@ import (
"os" "os"
"path" "path"
"strconv" "strconv"
"time"
) )
type RequestError struct { type RequestError struct {
@@ -37,6 +38,8 @@ type chunk struct {
Begin int64 Begin int64
End int64 End int64
Downloaded int64 Downloaded int64
retryTimes int
} }
func newChunk(begin int64, end int64) *chunk { func newChunk(begin int64, end int64) *chunk {
@@ -255,6 +258,7 @@ func (f *Fetcher) fetch() {
func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) { func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
chunk := f.chunks[index] chunk := f.chunks[index]
chunk.retryTimes = 0
httpReq, err := buildRequest(ctx, f.meta.Req) httpReq, err := buildRequest(ctx, f.meta.Req)
if err != nil { if err != nil {
@@ -263,16 +267,34 @@ func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
var ( var (
client = buildClient() client = buildClient()
buf = make([]byte, 8192) buf = make([]byte, 8192)
maxRetries = 3
) )
// 重试5次 // retry until all remain chunks failed
for i := 0; i < 5; i++ { for true {
// if chunk is completed, return // if chunk is completed, return
if f.meta.Res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1 { if f.meta.Res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1 {
return return
} }
if chunk.retryTimes >= maxRetries {
if !f.meta.Res.Range {
return
}
// check if all failed
allFailed := true
for _, c := range f.chunks {
if chunk.Downloaded < chunk.End-chunk.Begin+1 && c.retryTimes < maxRetries {
allFailed = false
break
}
}
if allFailed {
return
}
}
var ( var (
resp *http.Response resp *http.Response
retry bool
) )
if f.meta.Res.Range { if f.meta.Res.Range {
httpReq.Header.Set(base.HttpHeaderRange, httpReq.Header.Set(base.HttpHeaderRange,
@@ -292,36 +314,46 @@ func (f *Fetcher) fetchChunk(index int, ctx context.Context) (err error) {
return nil return nil
}() }()
if err != nil { if err != nil {
// retry request // If canceled, do not retry
if errors.Is(err, context.Canceled) {
return
}
// retry request after 1 second
chunk.retryTimes = chunk.retryTimes + 1
time.Sleep(time.Second)
continue continue
} }
// 请求成功就重置错误次数,连续失败5次才终止 // Http request success, reset retry times
i = 0 chunk.retryTimes = 0
retry, err = func() (bool, error) { err = func() error {
defer resp.Body.Close() defer resp.Body.Close()
for { for {
n, err := resp.Body.Read(buf) n, err := resp.Body.Read(buf)
if n > 0 { if n > 0 {
_, err := f.file.WriteAt(buf[:n], chunk.Begin+chunk.Downloaded) _, err := f.file.WriteAt(buf[:n], chunk.Begin+chunk.Downloaded)
if err != nil { if err != nil {
return false, err return err
} }
chunk.Downloaded += int64(n) chunk.Downloaded += int64(n)
} }
if err != nil { if err != nil {
if err != io.EOF { if err == io.EOF {
return true, err return nil
} }
break return err
} }
} }
return false, nil
}() }()
if !retry { // if chunk is completed, reset other not complete chunks retry times
// 下载成功,跳出重试 if err == nil {
break for _, c := range f.chunks {
if c != chunk && c.retryTimes > 0 {
c.retryTimes = 0
} }
} }
}
break
}
return return
} }
@@ -438,9 +470,7 @@ func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
fetcher.meta = meta fetcher.meta = meta
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req) base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
base.ParseOptsExtra[fhttp.OptsExtra](fetcher.meta.Opts) base.ParseOptsExtra[fhttp.OptsExtra](fetcher.meta.Opts)
if len(fd.Chunks) == 0 { if len(fd.Chunks) > 0 {
fetcher.chunks = fetcher.splitChunk()
} else {
fetcher.chunks = fd.Chunks fetcher.chunks = fd.Chunks
} }
return fetcher return fetcher
+15 -6
View File
@@ -76,7 +76,7 @@ func testResolve(startTestServer func() net.Listener, path string, want *base.Re
func TestFetcher_DownloadNormal(t *testing.T) { func TestFetcher_DownloadNormal(t *testing.T) {
listener := test.StartTestFileServer() listener := test.StartTestFileServer()
defer listener.Close() defer listener.Close()
// 正常下载
downloadNormal(listener, 1, t) downloadNormal(listener, 1, t)
downloadNormal(listener, 5, t) downloadNormal(listener, 5, t)
downloadNormal(listener, 8, t) downloadNormal(listener, 8, t)
@@ -86,7 +86,7 @@ func TestFetcher_DownloadNormal(t *testing.T) {
func TestFetcher_DownloadContinue(t *testing.T) { func TestFetcher_DownloadContinue(t *testing.T) {
listener := test.StartTestFileServer() listener := test.StartTestFileServer()
defer listener.Close() defer listener.Close()
// 暂停继续
downloadContinue(listener, 1, t) downloadContinue(listener, 1, t)
downloadContinue(listener, 5, t) downloadContinue(listener, 5, t)
downloadContinue(listener, 8, t) downloadContinue(listener, 8, t)
@@ -96,7 +96,7 @@ func TestFetcher_DownloadContinue(t *testing.T) {
func TestFetcher_DownloadChunked(t *testing.T) { func TestFetcher_DownloadChunked(t *testing.T) {
listener := test.StartTestCustomServer() listener := test.StartTestCustomServer()
defer listener.Close() defer listener.Close()
// chunked编码下载
downloadNormal(listener, 1, t) downloadNormal(listener, 1, t)
downloadNormal(listener, 2, t) downloadNormal(listener, 2, t)
} }
@@ -104,24 +104,33 @@ func TestFetcher_DownloadChunked(t *testing.T) {
func TestFetcher_DownloadPost(t *testing.T) { func TestFetcher_DownloadPost(t *testing.T) {
listener := test.StartTestPostServer() listener := test.StartTestPostServer()
defer listener.Close() defer listener.Close()
// post下载
downloadPost(listener, 1, t) downloadPost(listener, 1, t)
} }
func TestFetcher_DownloadRetry(t *testing.T) { func TestFetcher_DownloadRetry(t *testing.T) {
listener := test.StartTestRetryServer() listener := test.StartTestRetryServer()
defer listener.Close() defer listener.Close()
// chunked编码下载
downloadNormal(listener, 1, t) downloadNormal(listener, 1, t)
} }
func TestFetcher_DownloadError(t *testing.T) { func TestFetcher_DownloadError(t *testing.T) {
listener := test.StartTestErrorServer() listener := test.StartTestErrorServer()
defer listener.Close() defer listener.Close()
// chunked编码下载
downloadError(listener, 1, t) downloadError(listener, 1, t)
} }
func TestFetcher_DownloadLimit(t *testing.T) {
listener := test.StartTestLimitServer()
defer listener.Close()
downloadNormal(listener, 1, t)
downloadNormal(listener, 2, t)
downloadNormal(listener, 8, t)
}
func TestFetcher_DownloadResume(t *testing.T) { func TestFetcher_DownloadResume(t *testing.T) {
listener := test.StartTestFileServer() listener := test.StartTestFileServer()
defer listener.Close() defer listener.Close()
+77 -1
View File
@@ -9,6 +9,9 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"strconv"
"strings"
"sync/atomic"
"time" "time"
) )
@@ -81,7 +84,7 @@ func StartTestRetryServer() net.Listener {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
counter++ counter++
if counter != 1 && counter < 5 { if counter != 1 && counter < 2 {
writer.WriteHeader(500) writer.WriteHeader(500)
return return
} }
@@ -134,6 +137,79 @@ func StartTestErrorServer() net.Listener {
}) })
} }
// StartTestLimitServer connections limit server
func StartTestLimitServer() net.Listener {
var connections atomic.Int32
return startTestServer(func() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
defer func() {
connections.Add(-1)
}()
connections.Add(1)
if connections.Load() > 4 {
writer.WriteHeader(403)
return
}
r := request.Header.Get("Range")
if r == "" {
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
writer.WriteHeader(200)
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
} else {
// split range
s := strings.Split(r, "=")
if len(s) != 2 {
writer.WriteHeader(400)
return
}
s = strings.Split(s[1], "-")
if len(s) != 2 {
writer.WriteHeader(400)
return
}
start, err := strconv.ParseInt(s[0], 10, 64)
if err != nil {
writer.WriteHeader(400)
return
}
end, err := strconv.ParseInt(s[1], 10, 64)
if err != nil {
writer.WriteHeader(400)
return
}
if start < 0 || end < 0 || start > end {
writer.WriteHeader(400)
return
}
if end >= BuildSize {
end = BuildSize - 1
}
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
writer.Header().Set("Accept-Ranges", "bytes")
writer.WriteHeader(206)
file, err := os.Open(BuildFile)
if err != nil {
writer.WriteHeader(500)
return
}
defer file.Close()
file.Seek(start, 0)
io.CopyN(writer, file, end-start+1)
}
})
return mux
})
}
func startTestServer(serverHandle func() http.Handler) net.Listener { func startTestServer(serverHandle func() http.Handler) net.Listener {
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
+14 -3
View File
@@ -3,12 +3,22 @@ package base
import ( import (
"fmt" "fmt"
"github.com/GopeedLab/gopeed/pkg/util" "github.com/GopeedLab/gopeed/pkg/util"
"golang.org/x/exp/slices"
) )
// Request download request // Request download request
type Request struct { type Request struct {
URL string `json:"url"` URL string `json:"url"`
Extra any `json:"extra"` Extra any `json:"extra"`
// Labels is used to mark the download task
Labels map[string]string `json:"labels"`
}
func (r *Request) Validate() error {
if r.URL == "" {
return fmt.Errorf("invalid request url")
}
return nil
} }
// Resource download resource // Resource download resource
@@ -36,14 +46,15 @@ func (r *Resource) Validate() error {
} }
} }
return nil return nil
} }
func (r *Resource) CalcSize() { func (r *Resource) CalcSize(selectFiles []int) {
var size int64 var size int64
for _, file := range r.Files { for i, file := range r.Files {
if len(selectFiles) == 0 || slices.Contains(selectFiles, i) {
size += file.Size size += file.Size
} }
}
r.Size = size r.Size = size
} }
+234 -148
View File
File diff suppressed because it is too large. Load diff
+204 -28
View File
@@ -33,6 +33,7 @@ type ActivationEvent string
const ( const (
EventOnResolve ActivationEvent = "onResolve" EventOnResolve ActivationEvent = "onResolve"
EventOnStart ActivationEvent = "onStart"
//EventOnError ActivationEvent = "onError" //EventOnError ActivationEvent = "onError"
//EventOnDone ActivationEvent = "onDone" //EventOnDone ActivationEvent = "onDone"
) )
@@ -47,7 +48,7 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten
return nil, err return nil, err
} }
// if dev mode, don't copy the extension to the extensions' directory // if dev mode, don't copy to the extensions' directory
if devMode { if devMode {
ext.DevMode = true ext.DevMode = true
ext.DevPath, _ = filepath.Abs(path) ext.DevPath, _ = filepath.Abs(path)
@@ -229,23 +230,70 @@ func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
} }
func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) { func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
doTrigger(d,
EventOnResolve,
req,
&OnResolveContext{
Req: req,
},
func(ext *Extension, gopeed *Instance, ctx *OnResolveContext) {
// Validate resource structure
if ctx.Res != nil && len(ctx.Res.Files) > 0 {
if err := ctx.Res.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
return
}
ctx.Res.CalcSize(nil)
}
res = ctx.Res
},
)
return
}
func (d *Downloader) triggerOnStart(task *Task) (req *base.Request) {
doTrigger(d,
EventOnStart,
task.Meta.Req,
&OnStartContext{
Task: task,
},
func(ext *Extension, gopeed *Instance, ctx *OnStartContext) {
// Validate request structure
if ctx.Task.Meta.Req != nil {
if err := ctx.Task.Meta.Req.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] request invalid", ext.buildIdentity())
return
}
req = ctx.Task.Meta.Req
}
},
)
return
}
func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, ctx T, handler func(ext *Extension, gopeed *Instance, ctx T)) {
// init extension global object // init extension global object
gopeed := &Instance{ gopeed := &Instance{
Events: make(InstanceEvents), Events: make(InstanceEvents),
} }
ctx := &Context{
Req: req,
}
var err error var err error
for _, ext := range d.extensions { for _, ext := range d.extensions {
if ext.Disabled { if ext.Disabled {
continue continue
} }
for _, script := range ext.Scripts { for _, script := range ext.Scripts {
if script.match(EventOnResolve, req.URL) { if script.match(event, req) {
gopeed.Info = NewExtensionInfo(ext)
gopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger) gopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger)
gopeed.Settings = parseSettings(ext.Settings)
gopeed.Storage = &ContextStorage{
storage: d.storage,
identity: ext.buildIdentity(),
}
scriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry) scriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry)
if _, err = os.Stat(scriptFilePath); os.IsNotExist(err) { if _, err = os.Stat(scriptFilePath); os.IsNotExist(err) {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] script file not exist", ext.buildIdentity())
continue continue
} }
func() { func() {
@@ -256,13 +304,16 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
return return
} }
defer scriptFile.Close() defer scriptFile.Close()
ctx.Settings = parseSettings(ext.Settings)
var scriptBuf []byte var scriptBuf []byte
scriptBuf, err = io.ReadAll(scriptFile) scriptBuf, err = io.ReadAll(scriptFile)
if err != nil { if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] read script file failed", ext.buildIdentity()) gopeed.Logger.logger.Error().Err(err).Msgf("[%s] read script file failed", ext.buildIdentity())
return return
} }
// Init request labels
if req.Labels == nil {
req.Labels = make(map[string]string)
}
engine := engine.NewEngine() engine := engine.NewEngine()
defer engine.Close() defer engine.Close()
err = engine.Runtime.Set("gopeed", gopeed) err = engine.Runtime.Set("gopeed", gopeed)
@@ -275,21 +326,13 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity()) gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity())
return return
} }
if fn, ok := gopeed.Events[EventOnResolve]; ok { if fn, ok := gopeed.Events[event]; ok {
_, err = engine.CallFunction(fn, ctx) _, err = engine.CallFunction(fn, ctx)
if err != nil { if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", EventOnResolve, ext.buildIdentity()) gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", ext.buildIdentity(), event)
return return
} }
// calculate resource total size handler(ext, gopeed, ctx)
if ctx.Res != nil && len(ctx.Res.Files) > 0 {
if err = ctx.Res.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
return
}
ctx.Res.CalcSize()
}
res = ctx.Res
} }
}() }()
} }
@@ -378,8 +421,50 @@ func (e *Extension) update(newExt *Extension) error {
e.Homepage = newExt.Homepage e.Homepage = newExt.Homepage
e.Repository = newExt.Repository e.Repository = newExt.Repository
e.Scripts = newExt.Scripts e.Scripts = newExt.Scripts
// don't override settings // merge settings
//e.Settings = newExt.Settings // if new setting not exist in old settings, append it
for _, newSetting := range newExt.Settings {
var exist bool
for _, setting := range e.Settings {
if setting.Name == newSetting.Name {
exist = true
break
}
}
if !exist {
e.Settings = append(e.Settings, newSetting)
}
}
// if old setting not exist in new settings, remove it
for i := 0; i < len(e.Settings); i++ {
var exist bool
for _, setting := range newExt.Settings {
if setting.Name == e.Settings[i].Name {
exist = true
break
}
}
if !exist {
e.Settings = append(e.Settings[:i], e.Settings[i+1:]...)
i--
}
}
// if new setting exist in old settings, update it
for _, newSetting := range newExt.Settings {
for _, setting := range e.Settings {
if setting.Name == newSetting.Name {
setting.Title = newSetting.Title
setting.Description = newSetting.Description
setting.Options = newSetting.Options
// if type changed, reset value
if setting.Type != newSetting.Type {
setting.Type = newSetting.Type
setting.Value = newSetting.Value
}
break
}
}
}
e.UpdatedAt = time.Now() e.UpdatedAt = time.Now()
return nil return nil
} }
@@ -392,30 +477,46 @@ type Repository struct {
type Script struct { type Script struct {
// Event active event name // Event active event name
Event string `json:"event"` Event string `json:"event"`
// Matches match request url pattern list // Match rules
Matches []string `json:"matches"` Match *Match `json:"match"`
// Entry js script file path // Entry js script file path
Entry string `json:"entry"` Entry string `json:"entry"`
} }
func (s *Script) match(event ActivationEvent, url string) bool { func (s *Script) match(event ActivationEvent, req *base.Request) bool {
if s.Event == "" { if s.Event == "" {
return false return false
} }
if s.Event != string(event) { if s.Event != string(event) {
return false return false
} }
if len(s.Matches) == 0 { if s.Match == nil || (len(s.Match.Urls) == 0 && len(s.Match.Labels) == 0) {
return false return false
} }
for _, match := range s.Matches {
if util.Match(match, url) { // match url
for _, url := range s.Match.Urls {
if util.Match(url, req.URL) {
return true
}
}
// match label
for _, label := range s.Match.Labels {
if _, ok := req.Labels[label]; ok {
return true return true
} }
} }
return false return false
} }
type Match struct {
// Urls match expression, refer to https://developer.chrome.com/docs/extensions/mv3/match_patterns/
Urls []string `json:"urls"`
// Labels match request labels
Labels []string `json:"labels"`
}
type SettingType string type SettingType string
const ( const (
@@ -442,10 +543,13 @@ type Option struct {
Value any `json:"value"` Value any `json:"value"`
} }
// Instance inject to js context // Instance inject to js context when extension script is activated
type Instance struct { type Instance struct {
Events InstanceEvents `json:"events"` Events InstanceEvents `json:"events"`
Info *ExtensionInfo `json:"info"`
Logger *InstanceLogger `json:"logger"` Logger *InstanceLogger `json:"logger"`
Settings map[string]any `json:"settings"`
Storage *ContextStorage `json:"storage"`
} }
type InstanceEvents map[ActivationEvent]goja.Callable type InstanceEvents map[ActivationEvent]goja.Callable
@@ -458,6 +562,10 @@ func (h InstanceEvents) OnResolve(fn goja.Callable) {
h.register(EventOnResolve, fn) h.register(EventOnResolve, fn)
} }
func (h InstanceEvents) OnStart(fn goja.Callable) {
h.register(EventOnStart, fn)
}
//func (h InstanceEvents) OnError(fn goja.Callable) { //func (h InstanceEvents) OnError(fn goja.Callable) {
// h.register(HookEventOnError, fn) // h.register(HookEventOnError, fn)
//} //}
@@ -466,6 +574,24 @@ func (h InstanceEvents) OnResolve(fn goja.Callable) {
// h.register(HookEventOnDone, fn) // h.register(HookEventOnDone, fn)
//} //}
type ExtensionInfo struct {
Identity string `json:"identity"`
Name string `json:"name"`
Author string `json:"author"`
Title string `json:"title"`
Version string `json:"version"`
}
func NewExtensionInfo(ext *Extension) *ExtensionInfo {
return &ExtensionInfo{
Identity: ext.buildIdentity(),
Name: ext.Name,
Author: ext.Author,
Title: ext.Title,
Version: ext.Version,
}
}
type InstanceLogger struct { type InstanceLogger struct {
identity string identity string
devMode bool devMode bool
@@ -506,10 +632,13 @@ func newInstanceLogger(extension *Extension, logger *logger.Logger) *InstanceLog
} }
} }
type Context struct { type OnResolveContext struct {
Req *base.Request `json:"req"` Req *base.Request `json:"req"`
Res *base.Resource `json:"res"` Res *base.Resource `json:"res"`
Settings map[string]any `json:"settings"` }
type OnStartContext struct {
Task *Task `json:"task"`
} }
func parseSettings(settings []*Setting) map[string]any { func parseSettings(settings []*Setting) map[string]any {
@@ -547,3 +676,50 @@ func tryParse(val any, settingType SettingType) any {
return nil return nil
} }
} }
type ContextStorage struct {
storage Storage
identity string
}
func (s *ContextStorage) Get(key string) any {
raw := s.getRawData()
if v, ok := raw[key]; ok {
return v
}
return nil
}
func (s *ContextStorage) Set(key string, value string) {
raw := s.getRawData()
raw[key] = value
s.storage.Put(bucketExtensionStorage, s.identity, raw)
}
func (s *ContextStorage) Remove(key string) {
raw := s.getRawData()
delete(raw, key)
s.storage.Put(bucketExtensionStorage, s.identity, raw)
}
func (s *ContextStorage) Keys() []string {
raw := s.getRawData()
keys := make([]string, 0)
for k := range raw {
keys = append(keys, k)
}
return keys
}
func (s *ContextStorage) Clear() {
s.storage.Delete(bucketExtensionStorage, s.identity)
}
func (s *ContextStorage) getRawData() map[string]string {
var data map[string]string
s.storage.Get(bucketExtensionStorage, s.identity, &data)
if data == nil {
data = make(map[string]string)
}
return data
}
+133 -3
View File
@@ -1,10 +1,12 @@
package download package download
import ( import (
"errors"
"github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"os" "os"
"testing" "testing"
"time"
) )
func TestDownloader_InstallExtensionByFolder(t *testing.T) { func TestDownloader_InstallExtensionByFolder(t *testing.T) {
@@ -93,6 +95,15 @@ func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
} }
func TestDownloader_UpgradeExtension(t *testing.T) { func TestDownloader_UpgradeExtension(t *testing.T) {
getSetting := func(settings []*Setting, name string) *Setting {
for _, setting := range settings {
if setting.Name == name {
return setting
}
}
return nil
}
setupDownloader(func(downloader *Downloader) { setupDownloader(func(downloader *Downloader) {
installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/update", false) installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/update", false)
if err != nil { if err != nil {
@@ -120,14 +131,116 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
t.Fatal("extension update fail") t.Fatal("extension update fail")
} }
// check setting update
s1 := getSetting(upgradeExt.Settings, "s1")
if s1.Title == "S1 old" {
t.Fatal("setting update fail")
}
// check setting type update
s2 := getSetting(upgradeExt.Settings, "s2")
if s2.Type == "number" {
t.Fatal("setting type update fail")
}
// check setting remove
d1 := getSetting(upgradeExt.Settings, "d1")
if d1 != nil {
t.Fatal("setting remove fail")
}
// check setting add
s3 := getSetting(upgradeExt.Settings, "s3")
if s3 == nil {
t.Fatal("setting add fail")
}
rr, err := downloader.Resolve(&base.Request{ rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases", URL: "https://test.com",
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(rr.Res.Files) == 1 { if rr.Res.Name != "test" {
t.Fatal("resolve error") t.Fatal("script update fail")
}
})
}
func TestDownloader_ExtensionByOnStart(t *testing.T) {
downloadAndCheck := func(req *base.Request) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
errCh <- event.Err
}
})
id, err := downloader.CreateDirect(req, nil)
if err != nil {
t.Fatal(err)
}
select {
case err = <-errCh:
break
case <-time.After(time.Second * 10):
err = errors.New("timeout")
}
if err != nil {
panic("extension on start download error: " + err.Error())
}
task := downloader.GetTask(id)
if task.Meta.Req.URL != "https://github.com" {
panic("extension on start modify url error")
}
if task.Meta.Req.Labels["modified"] != "true" {
panic("extension on start modify label error")
}
})
}
// url match
downloadAndCheck(&base.Request{
URL: "https://github.com/gopeed/test/404",
})
// label match
downloadAndCheck(&base.Request{
URL: "https://xxx.com",
Labels: map[string]string{
"test": "true",
},
})
}
func TestDownloader_Extension_Errors(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/script_error", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 2 {
t.Fatal("script error catch failed")
}
})
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/function_error", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 2 {
t.Fatal("function error catch failed")
} }
}) })
} }
@@ -170,6 +283,23 @@ func TestDownloader_Extension_Settings(t *testing.T) {
}) })
} }
func TestDownloader_ExtensionStorage(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/storage", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
}
func TestDownloader_SwitchExtension(t *testing.T) { func TestDownloader_SwitchExtension(t *testing.T) {
setupDownloader(func(downloader *Downloader) { setupDownloader(func(downloader *Downloader) {
installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/basic", false) installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/basic", false)
-2
View File
@@ -22,7 +22,6 @@ type Task struct {
Meta *fetcher.FetcherMeta `json:"meta"` Meta *fetcher.FetcherMeta `json:"meta"`
Status base.Status `json:"status"` Status base.Status `json:"status"`
Progress *Progress `json:"progress"` Progress *Progress `json:"progress"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
fetcherBuilder fetcher.FetcherBuilder fetcherBuilder fetcher.FetcherBuilder
@@ -50,7 +49,6 @@ func (t *Task) clone() *Task {
Meta: t.Meta, Meta: t.Meta,
Status: t.Status, Status: t.Status,
Progress: t.Progress, Progress: t.Progress,
Size: t.Size,
CreatedAt: t.CreatedAt, CreatedAt: t.CreatedAt,
} }
} }
+3
View File
@@ -222,6 +222,9 @@ func (b *BoltStorage) Pop(bucket string, key string, v any) error {
if err != nil { if err != nil {
return err return err
} }
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, v) return json.Unmarshal(data, v)
} }
+3
View File
@@ -6,6 +6,9 @@ gopeed.events.onResolve(async function (ctx) {
size: 1024, size: 1024,
req: { req: {
url: ctx.req.url + "/" + i, url: ctx.req.url + "/" + i,
labels:{
"from": gopeed.info.name,
}
} }
}), }),
), ),
+4 -2
View File
@@ -5,9 +5,11 @@
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"urls": [
"*://github.com/*" "*://github.com/*"
], ]
},
"entry": "index.js" "entry": "index.js"
} }
], ],
+18
View File
@@ -0,0 +1,18 @@
gopeed.events.onResolve(async function (ctx) {
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
extra: {
headers: {
'User-Agent': ctx.settings.ua,
},
}
}
}),
),
};
});
+22
View File
@@ -0,0 +1,22 @@
{
"name": "extra",
"title": "gopeed extension extra test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"matches": [
"*://github.com/*"
],
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string",
"value": "gopeed"
}
]
}
@@ -0,0 +1,16 @@
gopeed.events.onResolve(async function (ctx) {
const aaa = {};
gopeed.logger.info(aaa.bbb.ccc);
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
}
}),
),
};
});
@@ -0,0 +1,23 @@
{
"name": "function-error",
"title": "gopeed extension function error test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"match": {
"urls": [
"*://github.com/*"
]
},
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string"
}
]
}
+5
View File
@@ -0,0 +1,5 @@
gopeed.events.onStart(async function (ctx) {
ctx.task.meta.req.url = "https://github.com";
ctx.task.meta.req.labels['modified'] = 'true';
});
+19
View File
@@ -0,0 +1,19 @@
{
"name": "on-start",
"title": "gopeed extension on start event test",
"version": "0.0.1",
"scripts": [
{
"event": "onStart",
"match": {
"urls": [
"*://github.com/*"
],
"labels": [
"test"
]
},
"entry": "index.js"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
const aaa = {};
gopeed.logger.info(aaa.bbb.ccc);
gopeed.events.onResolve(async function (ctx) {
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
}
}),
),
};
});
@@ -0,0 +1,23 @@
{
"name": "script-error",
"title": "gopeed extension script error test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"match": {
"urls": [
"*://github.com/*"
]
},
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string"
}
]
}
+9 -9
View File
@@ -1,31 +1,31 @@
gopeed.events.onResolve(async function (ctx) { gopeed.events.onResolve(async function (ctx) {
if (ctx.settings.string != null) { if (gopeed.settings.string != null) {
throw new Error("string is not null"); throw new Error("string is not null");
} }
if (ctx.settings.number != null) { if (gopeed.settings.number != null) {
throw new Error("number is not null"); throw new Error("number is not null");
} }
if (ctx.settings.boolean != null) { if (gopeed.settings.boolean != null) {
throw new Error("boolean is not null"); throw new Error("boolean is not null");
} }
if (ctx.settings.stringDefault !== "default") { if (gopeed.settings.stringDefault !== "default") {
throw new Error("string default value is incorrect"); throw new Error("string default value is incorrect");
} }
if (ctx.settings.numberDefault !== 1) { if (gopeed.settings.numberDefault !== 1) {
throw new Error("number default value is incorrect"); throw new Error("number default value is incorrect");
} }
if (ctx.settings.booleanDefault !== true) { if (gopeed.settings.booleanDefault !== true) {
throw new Error("boolean default value is incorrect"); throw new Error("boolean default value is incorrect");
} }
if (ctx.settings.stringValued !== "valued") { if (gopeed.settings.stringValued !== "valued") {
throw new Error("string value is incorrect"); throw new Error("string value is incorrect");
} }
if (ctx.settings.numberValued !== 1.1) { if (gopeed.settings.numberValued !== 1.1) {
throw new Error("number value is incorrect"); throw new Error("number value is incorrect");
} }
if (ctx.settings.booleanValued !== true) { if (gopeed.settings.booleanValued !== true) {
throw new Error("boolean value is incorrect"); throw new Error("boolean value is incorrect");
} }
@@ -5,9 +5,11 @@
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"urls": [
"*://*/*" "*://*/*"
], ]
},
"entry": "index.js" "entry": "index.js"
} }
], ],
+1 -1
View File
@@ -1,5 +1,5 @@
gopeed.events.onResolve(async function (ctx) { gopeed.events.onResolve(async function (ctx) {
if (Object.keys(ctx.settings).length > 0){ if (Object.keys(gopeed.settings).length > 0){
throw new Error("settings is not empty"); throw new Error("settings is not empty");
} }
@@ -5,9 +5,11 @@
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"urls": [
"*://*/*" "*://*/*"
], ]
},
"entry": "index.js" "entry": "index.js"
} }
] ]
+49
View File
@@ -0,0 +1,49 @@
gopeed.events.onResolve(async function (ctx) {
const key = "key"
const value1 = "value1", value2 = JSON.stringify({a: 1, b: "2"})
if (gopeed.storage.get(key) !== null) {
throw new Error("storage get null error")
}
gopeed.storage.remove(key)
if(gopeed.storage.keys().length !== 0) {
throw new Error("storage keys null error")
}
gopeed.storage.set(key, value1)
if (gopeed.storage.get(key) !== value1) {
throw new Error("storage put1 error")
}
gopeed.storage.set(key, value2)
if (gopeed.storage.get(key) !== value2) {
throw new Error("storage put2 error")
}
if(gopeed.storage.keys().length !== 1) {
throw new Error("storage keys error")
}
gopeed.storage.remove(key)
if (gopeed.storage.get(key) !== null) {
throw new Error("storage delete error")
}
gopeed.storage.set(key, value1)
gopeed.storage.clear()
if (gopeed.storage.get(key) !== null) {
throw new Error("storage clear error")
}
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
}
}),
),
};
});
+23
View File
@@ -0,0 +1,23 @@
{
"name": "storage",
"title": "gopeed extension storage test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"match": {
"urls": [
"*://*/*"
]
},
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string"
}
]
}
+33 -7
View File
@@ -1,21 +1,47 @@
{ {
"name": "github-release", "name": "extension-test",
"author": "gopeed", "author": "gopeed",
"title": "Github Release", "title": "Gopeed Extension Test",
"description": "Download github release assets", "description": "Test extension settings and upgrade",
"version": "0.0.1", "version": "0.0.1",
"homepage": "https://gopeed.com", "homepage": "https://gopeed.com",
"repository": { "repository": {
"url": "https://github.com/GopeedLab/gopeed-extension-samples", "url": "https://github.com/GopeedLab/gopeed-extension-samples",
"directory": "github-release-sample" "directory": "extension-test"
}, },
"scripts": [ "scripts": [
{ {
"event": "onResolve", "event": "onResolve",
"matches": [ "match": {
"*://github.com/*/releases/*" "urls": [
], "*://*/*"
]
},
"entry": "index.js" "entry": "index.js"
} }
],
"settings": [
{
"name": "s1",
"title": "S1 old",
"description": "Test setting update",
"type": "string",
"required": true
},
{
"name": "s2",
"title": "s2 number old",
"description": "Test setting type update",
"type": "number",
"required": true,
"value": 1
},
{
"name": "d1",
"title": "Delete test",
"description": "Test setting delete",
"type": "string",
"required": true
}
] ]
} }
-3
View File
@@ -204,19 +204,16 @@ func TestPauseAllAndContinueALLTasks(t *testing.T) {
// continue all // continue all
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil) httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil)
time.Sleep(time.Millisecond * 100)
tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusRunning), nil) tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusRunning), nil)
if len(tasks) != cfg.MaxRunning { if len(tasks) != cfg.MaxRunning {
t.Errorf("ContinueAllTasks() got = %v, want %v", len(tasks), cfg.MaxRunning) t.Errorf("ContinueAllTasks() got = %v, want %v", len(tasks), cfg.MaxRunning)
} }
// pause all // pause all
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/pause", nil) httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/pause", nil)
time.Sleep(time.Millisecond * 500)
tasks = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusPause), nil) tasks = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusPause), nil)
if len(tasks) != total { if len(tasks) != total {
t.Errorf("PauseAllTasks() got = %v, want %v", len(tasks), total) t.Errorf("PauseAllTasks() got = %v, want %v", len(tasks), total)
} }
}) })
} }
+1 -1
View File
@@ -47,7 +47,7 @@ android {
applicationId "com.gopeed" applicationId "com.gopeed"
// 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 21
targetSdkVersion 29 targetSdkVersion 29
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
@@ -72,6 +72,12 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Add android:stopWithTask option only when necessary. -->
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:stopWithTask="true" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
+11 -4
View File
@@ -5,13 +5,14 @@
"confirm": "Confirm", "confirm": "Confirm",
"cancel": "Cancel", "cancel": "Cancel",
"selectAll": "Select All", "selectAll": "Select All",
"task": "Task", "task": "Tasks",
"downloading": "downloading", "downloading": "downloading",
"downloaded": "downloaded", "downloaded": "downloaded",
"setting": "Setting", "setting": "Settings",
"donate": "Donate", "donate": "Donate",
"exit": "Exit", "exit": "Exit",
"create": "Create Task", "create": "Create Task",
"directDownload": "Direct Download",
"advancedOptions": "Advanced Options", "advancedOptions": "Advanced Options",
"downloadLink": "Download Link", "downloadLink": "Download Link",
"downloadLinkValid": "Please enter the download link", "downloadLinkValid": "Please enter the download link",
@@ -65,6 +66,12 @@
"extensionInstallSuccess": "Installed successfully", "extensionInstallSuccess": "Installed successfully",
"extensionUpdateSuccess": "Updated successfully", "extensionUpdateSuccess": "Updated successfully",
"extensionDelete": "Delete Extension", "extensionDelete": "Delete Extension",
"extensionAlreadyLatest": "It's already the latest version" "extensionAlreadyLatest": "It's already the latest version",
"extensionFind": "Find Extensions",
"extensionDevelop": "Develop Extensions",
"history": "History",
"clearHistory": "Clear History",
"noHistoryFound": "No History Found",
"serviceTitle": "Download Service",
"serviceText": "Running"
} }
+16 -4
View File
@@ -12,14 +12,16 @@
"donate": "Пожертвовать", "donate": "Пожертвовать",
"exit": "Выход", "exit": "Выход",
"create": "Создать задачу", "create": "Создать задачу",
"downloadLink": "Ссылка для скачивание", "advancedOptions": "Расширенные опции",
"downloadLink": "Ссылка на скачивание",
"downloadLinkValid": "Пожалуйста, введите ссылку для скачивания", "downloadLinkValid": "Пожалуйста, введите ссылку для скачивания",
"downloadLinkHit": "Пожалуйста, введите ссылку для скачивания, HTTP/HTTPS/MAGNET supported@append", "downloadLinkHit": "Пожалуйста, введите ссылку для скачивания, HTTP/HTTPS/MAGNET поддерживаются@append",
"downloadLinkHitDesktop": ", или перетащите сюда торрент-файл", "downloadLinkHitDesktop": ", или перетащите сюда торрент-файл",
"download": "Скачать", "download": "Скачать",
"noFileSelected": "Файл не выбран", "noFileSelected": "Файл не выбран",
"noStoragePermission": "Требуется доступ к хранилищу", "noStoragePermission": "Требуется доступ к хранилищу",
"selectFile": "Выберать файл", "selectFile": "Выбрать файл",
"rename": "Переименовать",
"basic": "Основные параметры", "basic": "Основные параметры",
"advanced": "Расширенные", "advanced": "Расширенные",
"general": "Общие", "general": "Общие",
@@ -57,5 +59,15 @@
"delete": "Удалить", "delete": "Удалить",
"newVersionTitle": "Обнаружена новая версия @version", "newVersionTitle": "Обнаружена новая версия @version",
"newVersionUpdate": "Обновить", "newVersionUpdate": "Обновить",
"newVersionLater": "позже" "newVersionLater": "позже",
"extensions": "Расширения",
"extensionInstallSuccess": "Установка завершена",
"extensionUpdateSuccess": "Обновление завершено",
"extensionDelete": "Удалить расширение",
"extensionAlreadyLatest": "Это последняя версия",
"extensionFind": "Найти расширения",
"extensionDevelop": "Разработка расширений",
"history": "История",
"clearHistory": "Очистить историю",
"noHistoryFound": "История не найдена"
} }
+9 -1
View File
@@ -12,6 +12,7 @@
"donate": "打赏", "donate": "打赏",
"exit": "退出", "exit": "退出",
"create": "创建任务", "create": "创建任务",
"directDownload": "直接下载",
"advancedOptions": "高级选项", "advancedOptions": "高级选项",
"downloadLink": "下载链接", "downloadLink": "下载链接",
"downloadLinkValid": "请输入下载链接", "downloadLinkValid": "请输入下载链接",
@@ -65,5 +66,12 @@
"extensionInstallSuccess": "安装成功", "extensionInstallSuccess": "安装成功",
"extensionUpdateSuccess": "更新成功", "extensionUpdateSuccess": "更新成功",
"extensionDelete": "删除扩展", "extensionDelete": "删除扩展",
"extensionAlreadyLatest": "已经是最新版本" "extensionAlreadyLatest": "已经是最新版本",
"extensionFind": "获取扩展",
"extensionDevelop": "开发扩展",
"history": "历史记录",
"clearHistory": "清空历史记录",
"noHistoryFound": "暂无历史记录",
"serviceTitle": "下载服务",
"serviceText": "运行中"
} }
+9
View File
@@ -33,6 +33,15 @@ import Libgopeed
}) })
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback(registerPlugins)
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
} }
func registerPlugins(registry: FlutterPluginRegistry) {
GeneratedPluginRegistrant.register(with: registry)
}
@@ -1 +1,2 @@
#import "GeneratedPluginRegistrant.h" #import "GeneratedPluginRegistrant.h"
#import <flutter_foreground_task/FlutterForegroundTaskPlugin.h>
+2
View File
@@ -16,6 +16,7 @@ class Extension {
List<Setting>? settings; List<Setting>? settings;
bool disabled; bool disabled;
bool devMode; bool devMode;
String devPath;
Extension({ Extension({
required this.identity, required this.identity,
@@ -29,6 +30,7 @@ class Extension {
required this.repository, required this.repository,
required this.disabled, required this.disabled,
required this.devMode, required this.devMode,
required this.devPath,
}); });
factory Extension.fromJson(Map<String, dynamic> json) => factory Extension.fromJson(Map<String, dynamic> json) =>
@@ -20,6 +20,7 @@ Extension _$ExtensionFromJson(Map<String, dynamic> json) => Extension(
: Repository.fromJson(json['repository'] as Map<String, dynamic>), : Repository.fromJson(json['repository'] as Map<String, dynamic>),
disabled: json['disabled'] as bool, disabled: json['disabled'] as bool,
devMode: json['devMode'] as bool, devMode: json['devMode'] as bool,
devPath: json['devPath'] as String,
)..settings = (json['settings'] as List<dynamic>?) )..settings = (json['settings'] as List<dynamic>?)
?.map((e) => Setting.fromJson(e as Map<String, dynamic>)) ?.map((e) => Setting.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
@@ -46,6 +47,7 @@ Map<String, dynamic> _$ExtensionToJson(Extension instance) {
writeNotNull('settings', instance.settings?.map((e) => e.toJson()).toList()); writeNotNull('settings', instance.settings?.map((e) => e.toJson()).toList());
val['disabled'] = instance.disabled; val['disabled'] = instance.disabled;
val['devMode'] = instance.devMode; val['devMode'] = instance.devMode;
val['devPath'] = instance.devPath;
return val; return val;
} }
+2
View File
@@ -6,10 +6,12 @@ part 'request.g.dart';
class Request { class Request {
String url; String url;
Object? extra; Object? extra;
Map<String, String>? labels = {};
Request({ Request({
required this.url, required this.url,
this.extra, this.extra,
this.labels,
}); });
factory Request.fromJson(Map<String, dynamic> json) => factory Request.fromJson(Map<String, dynamic> json) =>
+4
View File
@@ -9,6 +9,9 @@ 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'], extra: json['extra'],
labels: (json['labels'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
); );
Map<String, dynamic> _$RequestToJson(Request instance) { Map<String, dynamic> _$RequestToJson(Request instance) {
@@ -23,6 +26,7 @@ Map<String, dynamic> _$RequestToJson(Request instance) {
} }
writeNotNull('extra', instance.extra); writeNotNull('extra', instance.extra);
writeNotNull('labels', instance.labels);
return val; return val;
} }
-2
View File
@@ -12,7 +12,6 @@ class Task {
Meta meta; Meta meta;
Status status; Status status;
Progress progress; Progress progress;
int size;
DateTime createdAt; DateTime createdAt;
Task({ Task({
@@ -20,7 +19,6 @@ class Task {
required this.meta, required this.meta,
required this.status, required this.status,
required this.progress, required this.progress,
required this.size,
required this.createdAt, required this.createdAt,
}); });
-2
View File
@@ -11,7 +11,6 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
meta: Meta.fromJson(json['meta'] as Map<String, dynamic>), meta: Meta.fromJson(json['meta'] as Map<String, dynamic>),
status: $enumDecode(_$StatusEnumMap, json['status']), status: $enumDecode(_$StatusEnumMap, json['status']),
progress: Progress.fromJson(json['progress'] as Map<String, dynamic>), progress: Progress.fromJson(json['progress'] as Map<String, dynamic>),
size: json['size'] as int,
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: DateTime.parse(json['createdAt'] as String),
); );
@@ -20,7 +19,6 @@ Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'meta': instance.meta.toJson(), 'meta': instance.meta.toJson(),
'status': _$StatusEnumMap[instance.status]!, 'status': _$StatusEnumMap[instance.status]!,
'progress': instance.progress.toJson(), 'progress': instance.progress.toJson(),
'size': instance.size,
'createdAt': instance.createdAt.toIso8601String(), 'createdAt': instance.createdAt.toIso8601String(),
}; };
@@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -79,21 +80,18 @@ class AppController extends GetxController with WindowListener, TrayListener {
@override @override
void onReady() { void onReady() {
super.onReady(); super.onReady();
try {
_initDeepLinks(); _initDeepLinks().onError((error, stackTrace) =>
} catch (e) { logger.w("initDeepLinks error", error, stackTrace));
logger.w("initDeepLinks error", e);
} _initWindows().onError((error, stackTrace) =>
try { logger.w("initWindows error", error, stackTrace));
_initWindows();
} catch (e) { _initTray().onError(
logger.w("initWindows error", e); (error, stackTrace) => logger.w("initTray error", error, stackTrace));
}
try { _initForegroundTask().onError((error, stackTrace) =>
_initTray(); logger.w("initForegroundTask error", error, stackTrace));
} catch (e) {
logger.w("initTray error", e);
}
} }
@override @override
@@ -202,10 +200,50 @@ class AppController extends GetxController with WindowListener, TrayListener {
onClick: (menuItem) => {windowManager.destroy()}, onClick: (menuItem) => {windowManager.destroy()},
), ),
]); ]);
if (!Util.isLinux()) {
// Linux seems not support setToolTip, refer to: https://github.com/GopeedLab/gopeed/issues/241
await trayManager.setToolTip('Gopeed');
}
await trayManager.setContextMenu(menu); await trayManager.setContextMenu(menu);
trayManager.addListener(this); trayManager.addListener(this);
} }
Future<void> _initForegroundTask() async {
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'gopeed_service',
channelName: 'Gopeed Background Service',
channelImportance: NotificationChannelImportance.LOW,
showWhen: true,
priority: NotificationPriority.LOW,
iconData: const NotificationIconData(
resType: ResourceType.mipmap,
resPrefix: ResourcePrefix.ic,
name: 'launcher',
)),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: true,
playSound: false,
),
foregroundTaskOptions: const ForegroundTaskOptions(
interval: 5000,
isOnceEvent: false,
autoRunOnBoot: true,
allowWakeLock: true,
allowWifiLock: true,
),
);
if (await FlutterForegroundTask.isRunningService) {
FlutterForegroundTask.restartService();
} else {
FlutterForegroundTask.startService(
notificationTitle: "serviceTitle".tr,
notificationText: "serviceText".tr,
);
}
}
Future<void> _toCreate(Uri uri) async { Future<void> _toCreate(Uri uri) async {
final path = uri.scheme == "magnet" final path = uri.scheme == "magnet"
? uri.toString() ? uri.toString()
@@ -10,8 +10,9 @@ class CreateController extends GetxController
final RxList fileInfos = [].obs; final RxList fileInfos = [].obs;
final RxList openedFolders = [].obs; final RxList openedFolders = [].obs;
final selectedIndexes = <int>[].obs; final selectedIndexes = <int>[].obs;
final isResolving = false.obs; final isConfirming = false.obs;
final showAdvanced = false.obs; final showAdvanced = false.obs;
final directDownload = false.obs;
late TabController advancedTabController; late TabController advancedTabController;
final oldUrl = "".obs; final oldUrl = "".obs;
final fileDataUri = "".obs; final fileDataUri = "".obs;
@@ -4,10 +4,10 @@ import 'package:file_picker/file_picker.dart';
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 'package:gopeed/app/modules/history/controller/history_controller.dart';
import 'package:gopeed/app/modules/history/views/history_view.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:rounded_loading_button/rounded_loading_button.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';
@@ -24,14 +24,18 @@ import '../../app/controllers/app_controller.dart';
import '../controllers/create_controller.dart'; import '../controllers/create_controller.dart';
class CreateView extends GetView<CreateController> { class CreateView extends GetView<CreateController> {
final _resolveFormKey = GlobalKey<FormState>(); final _confirmFormKey = GlobalKey<FormState>();
final _urlController = TextEditingController(); final _urlController = TextEditingController();
final _renameController = TextEditingController();
final _connectionsController = TextEditingController();
final _pathController = TextEditingController();
final _confirmController = RoundedLoadingButtonController(); final _confirmController = RoundedLoadingButtonController();
final _httpUaController = TextEditingController(); final _httpUaController = TextEditingController();
final _httpCookieController = TextEditingController(); final _httpCookieController = TextEditingController();
final _httpRefererController = TextEditingController(); final _httpRefererController = TextEditingController();
final _btTrackerController = TextEditingController(); final _btTrackerController = TextEditingController();
final _historyController = HistoryController();
final _availableSchemes = ["http:", "https:", "magnet:"]; final _availableSchemes = ["http:", "https:", "magnet:"];
@@ -39,6 +43,17 @@ class CreateView extends GetView<CreateController> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appController = Get.find<AppController>();
if (_connectionsController.text.isEmpty) {
_connectionsController.text = appController
.downloaderConfig.value.protocolConfig.http.connections
.toString();
}
if (_pathController.text.isEmpty) {
_pathController.text = appController.downloaderConfig.value.downloadDir;
}
final String? filePath = Get.rootDelegate.arguments(); final String? filePath = Get.rootDelegate.arguments();
if (_urlController.text.isEmpty) { if (_urlController.text.isEmpty) {
if (filePath?.isNotEmpty ?? false) { if (filePath?.isNotEmpty ?? false) {
@@ -90,13 +105,15 @@ class CreateView extends GetView<CreateController> {
onTap: () { onTap: () {
FocusScope.of(context).requestFocus(FocusNode()); FocusScope.of(context).requestFocus(FocusNode());
}, },
child: SingleChildScrollView(
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Form( child: Form(
key: _resolveFormKey, key: _confirmFormKey,
autovalidateMode: AutovalidateMode.onUserInteraction, autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(children: [ child: Column(
children: [
Row(children: [ Row(children: [
Expanded( Expanded(
child: TextFormField( child: TextFormField(
@@ -145,14 +162,111 @@ class CreateView extends GetView<CreateController> {
_urlController.text = pr.files[0].name; _urlController.text = pr.files[0].name;
controller.setFileDataUri(pr.files[0].bytes!); controller.setFileDataUri(pr.files[0].bytes!);
} }
}), },
]
//.where((e) => e != null).map((e) => e!).toList(),
), ),
Obx(() => Visibility( IconButton(
icon: const Icon(Icons.history_rounded),
onPressed: () async {
List<String> resultOfHistories =
await _historyController.getAllHistory();
// reversing: display last entered history first
List<String> reverseResultOfHistories =
resultOfHistories.reversed.toList();
// show dialog box to list history
if (context.mounted) {
showGeneralDialog(
barrierColor: Colors.black.withOpacity(0.5),
transitionBuilder: (context, a1, a2, widget) {
return Transform.scale(
scale: a1.value,
child: Opacity(
opacity: a1.value,
child: HistoryView(
isHistoryListEmpty:
reverseResultOfHistories.isEmpty,
historyList: ListView.builder(
itemCount:
reverseResultOfHistories.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
_urlController.text =
reverseResultOfHistories[
index];
Navigator.pop(context);
},
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
padding:
const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
margin:
const EdgeInsets.symmetric(
horizontal: 10.0,
vertical: 8.0,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.background,
borderRadius:
BorderRadius.circular(
10.0),
),
child: Text(
reverseResultOfHistories[
index]
.toString(),
),
),
),
);
},
),
),
),
);
},
transitionDuration:
const Duration(milliseconds: 250),
barrierDismissible: true,
barrierLabel: '',
context: context,
pageBuilder: (context, animation1, animation2) {
return const Text('PAGE BUILDER');
},
);
}
},
),
]),
Padding(
padding: const EdgeInsets.only(left: 40),
child: Column(children: [
TextField(
controller: _renameController,
decoration: InputDecoration(labelText: 'rename'.tr),
),
TextField(
controller: _connectionsController,
decoration: InputDecoration(
labelText: 'connections'.tr,
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
NumericalRangeFormatter(min: 1, max: 256),
],
),
DirectorySelector(
controller: _pathController,
),
Obx(
() => Visibility(
visible: controller.showAdvanced.value, visible: controller.showAdvanced.value,
child: Padding(
padding: const EdgeInsets.only(left: 40, top: 15),
child: Column( child: Column(
children: [ children: [
TabBar( TabBar(
@@ -202,23 +316,42 @@ class CreateView extends GetView<CreateController> {
], ],
) )
], ],
).paddingOnly(top: 16),
),
),
]),
), ),
))),
Center( Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 15), padding: const EdgeInsets.only(top: 15),
child: Column( child: Column(
children: [ children: [
Padding( Row(
padding: const EdgeInsets.only(right: 10), mainAxisSize: MainAxisSize.min,
child: TextButton( children: [
TextButton(
onPressed: () {
controller.directDownload.value =
!controller.directDownload.value;
},
child: Row(children: [
Obx(() => Checkbox(
value:
controller.directDownload.value,
onChanged: (bool? value) {
controller.directDownload.value =
value ?? false;
},
)),
Text('directDownload'.tr),
]),
),
TextButton(
onPressed: () { onPressed: () {
controller.showAdvanced.value = controller.showAdvanced.value =
!controller.showAdvanced.value; !controller.showAdvanced.value;
}, },
child: Row( child: Row(children: [
mainAxisSize: MainAxisSize.min,
children: [
Obx(() => Checkbox( Obx(() => Checkbox(
value: controller.showAdvanced.value, value: controller.showAdvanced.value,
onChanged: (bool? value) { onChanged: (bool? value) {
@@ -229,20 +362,26 @@ class CreateView extends GetView<CreateController> {
Text('advancedOptions'.tr), Text('advancedOptions'.tr),
]), ]),
), ),
],
), ),
SizedBox( SizedBox(
width: 150, width: 150,
child: RoundedLoadingButton( child: RoundedLoadingButton(
color: Get.theme.colorScheme.secondary, color: Get.theme.colorScheme.secondary,
onPressed: _doResolve, onPressed: _doConfirm,
controller: _confirmController, controller: _confirmController,
child: Text('confirm'.tr), child: Text('confirm'.tr),
), ),
), ),
], ],
), ),
)), ),
]))), ),
],
),
),
),
),
), ),
), ),
); );
@@ -262,43 +401,54 @@ class CreateView extends GetView<CreateController> {
} }
} }
Future<void> _doResolve() async { Future<void> _doConfirm() async {
if (controller.isResolving.value) { if (controller.isConfirming.value) {
return; return;
} }
controller.isResolving.value = true; controller.isConfirming.value = true;
try { try {
_confirmController.start(); _confirmController.start();
if (_resolveFormKey.currentState!.validate()) { if (_confirmFormKey.currentState!.validate()) {
// check if is multi line urls
final urls = Util.textToLines(_urlController.text);
ResolveResult rr;
bool isMuiltiLine = false;
if (urls.length > 1) {
isMuiltiLine = true;
rr = ResolveResult(
res: Resource(
files: urls
.map((u) => FileInfo(
name: u,
req: Request(url: u, extra: parseReqExtra(u))))
.toList()));
} else {
final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty
? controller.fileDataUri.value ? controller.fileDataUri.value
: _urlController.text; : _urlController.text;
rr = await resolve(Request(
final urls = Util.textToLines(submitUrl);
// Add url to the history
for (final url in urls) {
_historyController.addHistory(url);
}
/*
Check if is direct download, there has two ways to direct download
1. Direct download option is checked
2. Muli line urls
*/
final isMultiLine = urls.length > 1;
final isDirect = controller.directDownload.value || isMultiLine;
if (isDirect) {
await Future.wait(urls.map((url) {
return createTask(CreateTask(
req: Request(url: url, extra: parseReqExtra(url)),
opt: Options(
name: isMultiLine ? "" : _renameController.text,
path: _pathController.text,
selectFiles: [],
extra: parseReqOpts())));
}));
Get.rootDelegate.offNamed(Routes.TASK);
} else {
final rr = await resolve(Request(
url: submitUrl, url: submitUrl,
extra: parseReqExtra(_urlController.text), extra: parseReqExtra(_urlController.text),
)); ));
await _showResolveDialog(rr);
} }
await _showResolveDialog(rr, isMuiltiLine);
} }
} catch (e) { } catch (e) {
showErrorMessage(e); showErrorMessage(e);
} finally { } finally {
_confirmController.reset(); _confirmController.reset();
controller.isResolving.value = false; controller.isConfirming.value = false;
} }
} }
@@ -321,6 +471,13 @@ class CreateView extends GetView<CreateController> {
return reqExtra; return reqExtra;
} }
Object? parseReqOpts() {
return _connectionsController.text.isEmpty
? null
: (OptsExtraHttp()
..connections = int.parse(_connectionsController.text));
}
String _hitText() { String _hitText() {
return 'downloadLinkHit'.trParams({ return 'downloadLinkHit'.trParams({
'append': 'append':
@@ -328,14 +485,8 @@ class CreateView extends GetView<CreateController> {
}); });
} }
Future<void> _showResolveDialog(ResolveResult rr, bool isMuiltiLine) async { Future<void> _showResolveDialog(ResolveResult rr) async {
final appController = Get.find<AppController>();
final createFormKey = GlobalKey<FormState>(); final createFormKey = GlobalKey<FormState>();
final nameController = TextEditingController();
final connectionsController = TextEditingController();
final pathController = TextEditingController(
text: appController.downloaderConfig.value.downloadDir);
final downloadController = RoundedLoadingButtonController(); final downloadController = RoundedLoadingButtonController();
return showDialog<void>( return showDialog<void>(
context: Get.context!, context: Get.context!,
@@ -354,31 +505,7 @@ class CreateView extends GetView<CreateController> {
child: Form( child: Form(
key: createFormKey, key: createFormKey,
autovalidateMode: AutovalidateMode.always, autovalidateMode: AutovalidateMode.always,
child: Column( child: FileListView(files: rr.res.files)),
children: [
Expanded(child: FileListView(files: rr.res.files)),
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'rename'.tr,
),
),
TextFormField(
controller: connectionsController,
decoration: InputDecoration(
labelText: 'connections'.tr,
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
NumericalRangeFormatter(min: 1, max: 256),
],
),
DirectorySelector(
controller: pathController,
),
],
)),
); );
}, },
), ),
@@ -414,25 +541,19 @@ class CreateView extends GetView<CreateController> {
showMessage('tip'.tr, 'noFileSelected'.tr); showMessage('tip'.tr, 'noFileSelected'.tr);
return; return;
} }
Object? optExtra = connectionsController.text.isEmpty final optExtra = parseReqOpts();
? null
: (OptsExtraHttp()
..connections =
int.parse(connectionsController.text));
if (createFormKey.currentState!.validate()) { if (createFormKey.currentState!.validate()) {
if (rr.id.isEmpty) { if (rr.id.isEmpty) {
// create task batch, there has two ways to batch create task // from extension resolve result
// 1. from multi line urls
// 2. from extension resolve result
await Future.wait( await Future.wait(
controller.selectedIndexes.map((index) { controller.selectedIndexes.map((index) {
final file = rr.res.files[index]; final file = rr.res.files[index];
return createTask(CreateTask( return createTask(CreateTask(
req: file.req!, req: file.req!,
opt: Options( opt: Options(
name: isMuiltiLine ? "" : file.name, name: file.name,
path: path.join( path: path.join(
pathController.text, rr.res.name), _pathController.text, rr.res.name),
selectFiles: [], selectFiles: [],
extra: optExtra))); extra: optExtra)));
})); }));
@@ -440,8 +561,8 @@ class CreateView extends GetView<CreateController> {
await createTask(CreateTask( await createTask(CreateTask(
rid: rr.id, rid: rr.id,
opt: Options( opt: Options(
name: nameController.text, name: _renameController.text,
path: pathController.text, path: _pathController.text,
selectFiles: controller.selectedIndexes, selectFiles: controller.selectedIndexes,
extra: optExtra))); extra: optExtra)));
} }
Loaded 50 of 60 files, more files were not shown because too many files have changed in this diff. Show more