Compare commits

...
18 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
83 changed files with 2416 additions and 968 deletions

No files matched your search

+21 -8
View File
@@ -268,6 +268,7 @@ jobs:
gomobile init 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 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 cd ui/flutter
echo $APK_KEYSTORE | base64 -d > android/app/upload-keystore.jks
flutter build apk flutter build apk
mkdir dist mkdir dist
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk
@@ -324,13 +325,25 @@ jobs:
needs: [get-release] needs: [get-release]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Build - 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: env:
VERSION: ${{ needs.get-release.outputs.tag_name }} VERSION: ${{ needs.get-release.outputs.tag_name }}
run: | with:
tag=liwei2633/gopeed:${VERSION:1} push: true
docker build -t $tag . platforms: |
docker login -u liwei2633 -p ${{ secrets.DOCKER_PASSWORD }} linux/386
docker push $tag linux/amd64
docker tag $tag liwei2633/gopeed:latest linux/arm64
docker push liwei2633/gopeed:latest tags: |
liwei2633/gopeed:latest
liwei2633/gopeed:${{ env.VERSION }}
+1
View File
@@ -11,6 +11,7 @@ on:
- "pkg/**" - "pkg/**"
- "ui/**" - "ui/**"
- ".github/workflows/release.yml" - ".github/workflows/release.yml"
- ".github/release-drafter.yml"
- "go.mod" - "go.mod"
- "go.sum" - "go.sum"
- "Dockerfile" - "Dockerfile"
+27
View File
@@ -37,10 +37,36 @@ go install github.com/monkeyWie/gopeed/cmd/gopeed
### Docker ### Docker
#### Start directly
```bash ```bash
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed 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`. 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. > Tip: Modify the download path to `/download` on the setting page to access the downloaded files on the host.
@@ -126,6 +152,7 @@ command:
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
```` ````
+27
View File
@@ -37,10 +37,36 @@ go install github.com/monkeyWie/gopeed/cmd/gopeed
### Docker ### Docker
#### 直接运行
```bash ```bash
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed 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 页面。 当 docker 容器运行时,可以通过 `http://localhost:9999` 访问 web 页面。
> 提示:在设置页面把下载路径修改为 `/download` 以便在宿主机访问下载完的文件。 > 提示:在设置页面把下载路径修改为 `/download` 以便在宿主机访问下载完的文件。
@@ -126,6 +152,7 @@ Web 端直接与后端 http 服务通讯,不需要额外准备环境。
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
``` ```
+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)
}
}
+1 -1
View File
@@ -25,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)
+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)
}
})
}
}
+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 -22
View File
@@ -1,17 +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:gopeed/util/util.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;
@@ -20,25 +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) {
: (Util.isWeb() if (Util.isWeb()) {
? "" baseUrl = kDebugMode ? 'http://127.0.0.1:9999' : '';
: 'http://${LibgopeedBoot.instance.config.address}'); } 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;
}; };
@@ -48,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,
@@ -57,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;
} }
@@ -65,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);
} }
} }
@@ -77,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);
@@ -103,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:0",
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': '删除',
}
};
+7 -64
View File
@@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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) { Locale toLocale(String key) {
final arr = key.split('_'); final arr = key.split('_');
@@ -11,72 +15,11 @@ String getLocaleKey(Locale locale) {
} }
final messages = _Messages(); final messages = _Messages();
const mainLocale = Locale('zh', 'CN');
const fallbackLocale = Locale('en', 'US'); const fallbackLocale = Locale('en', 'US');
class _Messages extends Translations { class _Messages extends Translations {
// just include available locales here
@override @override
Map<String, Map<String, String>> get keys => { Map<String, Map<String, String>> get keys => {...zhCN, ...enUS, ...ruRU};
'zh_CN': {
'error': '错误',
'confirm': '确认',
'cancel': '取消',
'home.task': '任务',
'home.setting': '设置',
'create.title': '创建任务',
'create.downloadLink': '下载链接',
'create.downloadLinkValid': '请输入下载链接',
'create.downloadLinkHit': '请输入下载链接,支持 HTTP/HTTPS/MAGNET@append',
'create.downloadLinkHitDesktop': ',也可以直接拖拽种子文件到此处',
'create.download': '下载',
'create.error.noStoragePermission': '需要开启存储权限',
'create.selectDir': '选择目录',
'setting.title': '设置',
'setting.basic': '基础设置',
'setting.theme': '主题',
'setting.themeSystem': '跟随系统',
'setting.themeLight': '明亮主题',
'setting.themeDark': '暗黑主题',
'setting.downloadDir': '下载目录',
'setting.downloadDirValid': '请选择下载目录',
'setting.connections': '连接数',
'setting.locale': '语言',
'setting.locale.zh_CN': '中文(简体)',
'setting.locale.en_US': '英文(美国)',
'task.deleteTask': '删除任务',
'task.deleteTaskTip': '保留已下载的文件',
'task.delete': '删除',
},
'en_US': {
'error': 'Error',
'confirm': 'Confirm',
'cancel': 'Cancel',
'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.selectDir': 'Select Directory',
'setting.title': 'Setting',
'setting.basic': 'Basic',
'setting.theme': 'Theme',
'setting.themeSystem': 'System',
'setting.themeLight': 'Light',
'setting.themeDark': 'Dark',
'setting.downloadDir': 'Download Directory',
'setting.downloadDirValid': 'Please select the download directory',
'setting.connections': 'Connections',
'setting.locale': 'Language',
'setting.locale.zh_CN': 'Chinese(Simplified)',
'setting.locale.en_US': 'English(US)',
'task.deleteTask': 'Delete Task',
'task.deleteTaskTip': 'Keep downloaded files',
'task.delete': 'Delete',
},
};
} }
+54 -18
View File
@@ -1,33 +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 'i18n/messages.dart'; import 'pages/app/app_controller.dart';
import 'routes/router.dart'; import 'pages/app/app_view.dart';
import 'setting/setting.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);
translations: messages, }
locale: toLocale(Setting.instance.locale),
fallbackLocale: fallbackLocale, try {
getPages: Routes.routes, 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,
);
}
}
+20 -18
View File
@@ -1,27 +1,26 @@
import 'package:desktop_drop/desktop_drop.dart'; 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) {
@@ -103,9 +102,11 @@ class CreateView extends GetView<CreateController> {
} }
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);
@@ -169,19 +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('error'.tr, // if (!await Permission.storage.request().isGranted) {
'create.error.noStoragePermission'.tr); // Get.snackbar('error'.tr,
return; // '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
+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,12 +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;
void clearTapStatus() { // set all tap status to false
tapStatues.clear(); void clearTap() {
tapStatues.updateAll((key, value) => false);
}
// set one tap status to true
void onTap(String key) {
clearTap();
tapStatues[key] = true;
} }
} }
+489 -150
View File
@@ -1,207 +1,546 @@
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 'package:gopeed/i18n/messages.dart'; import 'package:intl/intl.dart';
import 'package:gopeed/util/util.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../setting/setting.dart';
import '../../widget/directory_selector.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>(); var completer = Completer<void>();
timer?.cancel(); timer?.cancel();
timer = Timer(const Duration(milliseconds: 500), () { timer = Timer(const Duration(milliseconds: 500), () {
Setting.instance appController
.save() .saveConfig()
.then(completer.complete) .then(completer.complete)
.onError(completer.completeError); .onError(completer.completeError);
if (needRestart) {
Get.snackbar('tip'.tr, 'setting.effectAfterRestart'.tr);
}
}); });
return completer.future; return completer.future;
} }
return Scaffold( // download basic config items start
appBar: AppBar( final buildDownloadDir = _buildConfigItem(
title: Text('setting.title'.tr), '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('setting.basic'.tr, 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) {
'setting.theme'.tr, btExtConfig.trackerSubscribeUrls = value;
() => _getThemeName(controller.setting.value.themeMode),
() => DropdownButton<ThemeMode>(
value: controller.setting.value.themeMode,
isDense: true,
onChanged: (value) async {
controller.setting.value.themeMode = value!;
controller.clearTapStatus();
Get.changeThemeMode(value);
await debounceSave(); debounceSave();
}, },
items: ThemeMode.values ),
.map((e) => DropdownMenuItem<ThemeMode>( ),
value: e, _padding,
child: Text(_getThemeName(e)), Row(
)) children: [
.toList(), OutlinedButtonLoading(
)), onPressed: () async {
_ConfigItem('setting.downloadDir'.tr, trackerUpdateController.start();
() => controller.setting.value.downloadDir, () { try {
final downloadDirController = TextEditingController( await appController.trackerUpdate();
text: controller.setting.value.downloadDir); } catch (e) {
downloadDirController.addListener(() async { Get.snackbar('error'.tr, 'setting.subscribeFail'.tr);
if (downloadDirController.text != } finally {
controller.setting.value.downloadDir) { trackerUpdateController.stop();
controller.setting.value.downloadDir = }
downloadDirController.text; },
if (Util.isDesktop()) { controller: trackerUpdateController,
controller.clearTapStatus(); child: Text('setting.update'.tr),
} ),
Expanded(
await debounceSave(); child: SwitchListTile(
} controlAffinity: ListTileControlAffinity.leading,
}); value: true,
return DirectorySelector( onChanged: (bool value) {},
controller: downloadDirController, title: Text('setting.updateDaily'.tr)),
showLabel: false, ),
); ],
}), ),
_ConfigItem('setting.connections'.tr, Text('setting.lastUpdate'.trParams({
() => controller.setting.value.connections.toString(), 'time': btExtConfig.lastTrackerUpdateTime != null
() { ? DateFormat('yyyy-MM-dd HH:mm:ss')
final connectionsController = TextEditingController( .format(btExtConfig.lastTrackerUpdateTime!)
text: controller.setting.value.connections.toString()); : ''
connectionsController.addListener(() async { })),
if (connectionsController.text.isNotEmpty &&
connectionsController.text !=
controller.setting.value.connections.toString()) {
controller.setting.value.connections =
int.parse(connectionsController.text);
await debounceSave();
}
});
return TextField(
controller: connectionsController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
NumericalRangeFormatter(min: 1, max: 256),
],
);
}),
_ConfigItem(
'setting.locale'.tr,
() => _getLocaleName(controller.setting.value.locale),
() => DropdownButton<String>(
value: controller.setting.value.locale,
isDense: true,
onChanged: (value) async {
controller.setting.value.locale = value!;
controller.clearTapStatus();
Get.updateLocale(toLocale(value));
await debounceSave();
},
items: _getLocales()
.map((e) => DropdownMenuItem<String>(
value:
e.substring(_settingLocaleKey.length),
child: Text(e.tr),
))
.toList(),
)),
]),
))),
], ],
).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 'setting.themeSystem'.tr;
case ThemeMode.light: case ThemeMode.light:
return 'setting.themeLight'.tr; return 'setting.themeLight'.tr;
case ThemeMode.dark: case ThemeMode.dark:
return 'setting.themeDark'.tr; return 'setting.themeDark'.tr;
default:
return 'setting.themeSystem'.tr;
} }
} }
final _settingLocaleKey = 'setting.locale.';
List<String> _getLocales() {
return messages.keys[getLocaleKey(fallbackLocale)]!.entries
.where((e) => e.key.startsWith(_settingLocaleKey))
.map((e) => e.key)
.toList();
}
String _getLocaleName(String locale) {
final localeKey = '$_settingLocaleKey${locale.toString()}';
if (messages.keys[locale]?.containsKey(localeKey) ?? false) {
return localeKey.tr;
}
return '$_settingLocaleKey$locale'.tr;
}
} }
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;
}
} }
+73 -72
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;
@@ -78,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(
@@ -144,18 +95,32 @@ 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: Text('task.deleteTask'.tr), title: Text('task.deleteTask'.tr),
content: Obx(() => CheckboxListTile( content: Obx(() => CheckboxListTile(
value: keep.value, value: keep.value,
title: Text('task.deleteTaskTip'.tr, title: Text('task.deleteTaskTip'.tr,
style: Get.textTheme.bodyText1), style: context.textTheme.bodyText1),
onChanged: (v) { onChanged: (v) {
keep.value = v!; keep.value = v!;
})), })),
@@ -182,16 +147,52 @@ class Item extends StatelessWidget {
], ],
)); ));
} }
}
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 'package:gopeed/i18n/messages.dart';
import '../api/api.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:ui' as ui;
import '../api/model/server_config.dart';
import '../util/util.dart';
class Setting {
String host = "127.0.0.1";
int port = 0;
int connections = 0;
late String downloadDir;
late ThemeMode themeMode;
late String locale;
// singleton pattern
static Setting? _instance;
static Setting get instance {
_instance ??= Setting._internal();
_instance!.themeMode = ThemeMode.system;
_instance!.locale = getLocaleKey(ui.window.locale);
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.values.byName(themeMode);
}
final locale = config.extra?['locale'];
if (locale != null) {
this.locale = locale;
}
if (Util.isAndroid()) {
downloadDir =
'${await ExternalPath.getExternalStoragePublicDirectory(ExternalPath.DIRECTORY_DOWNLOADS)}/com.gopeed';
return;
}
if (downloadDir.isEmpty) {
if (Util.isWeb()) {
downloadDir = './';
}
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,
'locale': locale,
},
);
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),
);
}),
),
],
));
}
}
@@ -1,9 +1,9 @@
import 'package:file_selector/file_selector.dart'; import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:gopeed/util/util.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;
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../util/util.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;
@@ -30,7 +30,7 @@ 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('create.selectDir'.tr, Text('create.selectFile'.tr,
style: TextStyle(color: themeData.hintColor)), style: TextStyle(color: themeData.hintColor)),
Expanded( Expanded(
child: Container( child: Container(
@@ -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;
}
}
+6
View File
@@ -6,6 +6,8 @@ PODS:
- 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):
@@ -18,6 +20,7 @@ DEPENDENCIES:
- 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`)
@@ -31,6 +34,8 @@ EXTERNAL SOURCES:
: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:
@@ -43,6 +48,7 @@ SPEC CHECKSUMS:
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
+20 -43
View File
@@ -197,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:
@@ -309,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
@@ -376,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"
@@ -494,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:
@@ -550,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:
+5 -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,10 +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 desktop_drop: ^0.3.3
intl: ^0.17.0
package_info_plus: ^3.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: