diff --git a/README.md b/README.md index 8a78037b..bcbcbead 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ command: cd ui/flutter flutter build web cd ../../ +rm -rf cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/monkeyWie/gopeed/cmd/web ```` diff --git a/README_zh-CN.md b/README_zh-CN.md index 3052a382..ba68aee3 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -152,6 +152,7 @@ Web 端直接与后端 http 服务通讯,不需要额外准备环境。 cd ui/flutter flutter build web cd ../../ +rm -rf cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/monkeyWie/gopeed/cmd/web ``` diff --git a/cmd/api/banner.txt b/cmd/api/banner.txt new file mode 100644 index 00000000..d667ca0f --- /dev/null +++ b/cmd/api/banner.txt @@ -0,0 +1,7 @@ + + _______ ______ .______ _______ _______ _______ + / _____| / __ \ | _ \ | ____|| ____|| \ +| | __ | | | | | |_) | | |__ | |__ | .--. | +| | |_ | | | | | | ___/ | __| | __| | | | | +| |__| | | `--' | | | | |____ | |____ | '--' | + \______| \______/ | _| |_______||_______||_______/ diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 00000000..4bcb7381 --- /dev/null +++ b/cmd/api/main.go @@ -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) + } +} diff --git a/cmd/gopeed/flags.go b/cmd/gopeed/flags.go index cb87fb3b..7acaf9dd 100644 --- a/cmd/gopeed/flags.go +++ b/cmd/gopeed/flags.go @@ -25,7 +25,7 @@ func parse() *args { if len(t) > 0 { args.url = t[0] } 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") os.Exit(1) } diff --git a/cmd/gopeed/main.go b/cmd/gopeed/main.go index 4b41de95..ee457082 100644 --- a/cmd/gopeed/main.go +++ b/cmd/gopeed/main.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/download" + "github.com/monkeyWie/gopeed/pkg/protocol/http" "github.com/monkeyWie/gopeed/pkg/util" "strings" "sync" @@ -39,8 +40,8 @@ func main() { } }). Create(&base.Options{ - Path: *args.dir, - Connections: *args.connections, + Path: *args.dir, + Extra: http.OptsExtra{Connections: *args.connections}, }) if err != nil { panic(err) diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 5912a6cf..c33d2c72 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -6,19 +6,29 @@ import ( "path/filepath" ) -type Controller interface { - Touch(name string, size int64) (file *os.File, err error) +type Controller struct { + GetConfig func(v any) (bool, error) + FileController //ContextDialer() (proxy.Dialer, error) } -type DefaultController struct { +type FileController interface { + Touch(name string, size int64) (file *os.File, err error) } -func NewController() *DefaultController { - return &DefaultController{} +type DefaultFileController struct { } -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) if err = os.MkdirAll(dir, os.ModePerm); err != nil { return diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 0d4ad2f8..28eb9006 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -5,40 +5,46 @@ import ( "github.com/monkeyWie/gopeed/pkg/base" ) -// 对应协议的下载支持 +// Fetcher defines the interface for a download protocol. 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) - // 创建任务 + // Create ready to download, but not started Create(res *base.Resource, opts *base.Options) (err error) Start() (err error) Pause() (err error) Continue() (err error) Close() (err error) - // 获取任务各个文件下载进度 + // Progress returns the progress of the download. Progress() Progress - // 该方法会一直阻塞,直到任务下载结束 + // Wait for the download to complete, this method will block until the download is done. Wait() (err error) } +// FetcherBuilder defines the interface for a fetcher builder. type FetcherBuilder interface { + // Schemes returns the schemes supported by the fetcher. Schemes() []string + // Build returns a new fetcher. Build() Fetcher - // Store 存储任务 + // Store fetcher Store(fetcher Fetcher) (any, error) - // Restore 恢复任务 + // Restore fetcher Restore() (v any, f func(res *base.Resource, opts *base.Options, v any) Fetcher) } type DefaultFetcher struct { - Ctl controller.Controller + Ctl *controller.Controller 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.DoneCh = make(chan error, 1) return @@ -48,10 +54,10 @@ func (f *DefaultFetcher) Wait() (err error) { return <-f.DoneCh } -// 获取任务中各个文件的已下载字节数 +// Progress is a map of the progress of each file in the torrent. type Progress []int64 -// TotalDownloaded 获取任务总下载字节数 +// TotalDownloaded returns the total downloaded bytes. func (p Progress) TotalDownloaded() int64 { total := int64(0) for _, d := range p { diff --git a/internal/protocol/bt/config.go b/internal/protocol/bt/config.go new file mode 100644 index 00000000..04a107dd --- /dev/null +++ b/internal/protocol/bt/config.go @@ -0,0 +1,5 @@ +package bt + +type config struct { + Trackers []string `json:"trackers"` +} diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 35b31050..c1b79f5e 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -17,7 +17,7 @@ import ( var client *torrent.Client type Fetcher struct { - Ctl controller.Controller + Ctl *controller.Controller torrent *torrent.Torrent res *base.Resource @@ -29,7 +29,11 @@ type Fetcher struct { 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 var once sync.Once once.Do(func() { @@ -105,9 +109,7 @@ func (f *Fetcher) Resolve(req *base.Request) (res *base.Resource, err error) { Name: f.torrent.Name(), Range: true, Files: make([]*base.FileInfo, len(f.torrent.Files())), - Extra: map[string]any{ - "infoHash": f.torrent.InfoHash().String(), - }, + Hash: f.torrent.InfoHash().String(), } for i, file := range f.torrent.Files() { 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 extra, ok := res.Extra.(map[string]any); ok { - f.torrentPaths[extra["infoHash"].(string)] = f.opts.Path - } + f.torrentPaths[res.Hash] = f.opts.Path } + f.progress = make(fetcher.Progress, len(f.opts.SelectFiles)) f.ready.Store(false) return nil @@ -192,6 +193,18 @@ func (f *Fetcher) addTorrent(url string, resolve bool) (err error) { if err != nil { 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() if resolve { return diff --git a/internal/protocol/bt/fetcher_test.go b/internal/protocol/bt/fetcher_test.go index 9b012a44..7fbfde70 100644 --- a/internal/protocol/bt/fetcher_test.go +++ b/internal/protocol/bt/fetcher_test.go @@ -1,15 +1,24 @@ package bt import ( + "encoding/json" "github.com/monkeyWie/gopeed/internal/controller" "github.com/monkeyWie/gopeed/internal/fetcher" + "github.com/monkeyWie/gopeed/internal/test" "github.com/monkeyWie/gopeed/pkg/base" "reflect" "testing" ) 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{ URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent", }) @@ -28,9 +37,7 @@ func TestFetcher_Resolve_Torrent(t *testing.T) { Size: 1466714112, }, }, - Extra: map[string]any{ - "infoHash": "8a55cfbd5ca5d11507364765936c4f9e55b253ed", - }, + Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", } if !reflect.DeepEqual(want, res) { t.Errorf("Resolve() got = %v, want %v", res, want) @@ -42,3 +49,21 @@ func buildFetcher() fetcher.Fetcher { fetcher.Setup(controller.NewController()) 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 +} diff --git a/internal/protocol/http/config.go b/internal/protocol/http/config.go new file mode 100644 index 00000000..78772eaf --- /dev/null +++ b/internal/protocol/http/config.go @@ -0,0 +1,5 @@ +package http + +type config struct { + Connections int `json:"connections"` +} diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 064f2d4b..f699f76d 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/pkg/base" + fhttp "github.com/monkeyWie/gopeed/pkg/protocol/http" "github.com/monkeyWie/gopeed/pkg/util" "golang.org/x/sync/errgroup" "io" @@ -34,6 +35,21 @@ func (re *RequestError) Error() string { 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 { *fetcher.DefaultFetcher @@ -48,7 +64,14 @@ type Fetcher struct { pauseCh chan interface{} } +func (f *Fetcher) Name() string { + return "http" +} + 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) if err != nil { return nil, err @@ -120,6 +143,29 @@ func (f *Fetcher) Create(res *base.Resource, opts *base.Options) error { f.res = res f.opts = opts 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.ReqExtra{} + } + 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 } @@ -131,7 +177,7 @@ func (f *Fetcher) Start() (err error) { return err } f.status = base.DownloadStatusRunning - f.chunks = splitChunk(f.res, f.opts) + f.chunks = f.splitChunk() f.fetch() return } @@ -188,7 +234,7 @@ func (f *Fetcher) filepath() string { func (f *Fetcher) fetch() { f.ctx, f.cancel = context.WithCancel(context.Background()) 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 eg.Go(func() error { return f.fetchChunk(i) @@ -301,19 +347,20 @@ func (f *Fetcher) fetchChunk(index int) (err error) { return } -func splitChunk(res *base.Resource, opts *base.Options) (chunks []*chunk) { - if res.Range { +func (f *Fetcher) splitChunk() (chunks []*chunk) { + if f.res.Range { + connections := f.opts.Extra.(*fhttp.OptsExtra).Connections // 每个连接平均需要下载的分块大小 - chunkSize := res.Size / int64(opts.Connections) - chunks = make([]*chunk, opts.Connections) - for i := 0; i < opts.Connections; i++ { + chunkSize := f.res.Size / int64(connections) + chunks = make([]*chunk, connections) + for i := 0; i < connections; i++ { var ( begin = chunkSize * int64(i) end int64 ) - if i == opts.Connections-1 { + if i == connections-1 { // 最后一个分块需要保证把文件下载完 - end = res.Size - 1 + end = f.res.Size - 1 } else { end = begin + chunkSize - 1 } @@ -351,7 +398,7 @@ func buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request if req.Extra == nil { method = http.MethodGet } else { - extra := req.Extra.(extra) + extra := req.Extra.(*fhttp.ReqExtra) if extra.Method != "" { method = extra.Method } else { @@ -414,8 +461,10 @@ func (fb *FetcherBuilder) Restore() (v any, f func(res *base.Resource, opts *bas fetcher.res = res fetcher.opts = opts fetcher.status = base.DownloadStatusPause + base.ParseReqExtra[fhttp.ReqExtra](res.Req) + base.ParseOptsExtra[fhttp.OptsExtra](opts) if len(fd.Chunks) == 0 { - fetcher.chunks = splitChunk(res, opts) + fetcher.chunks = fetcher.splitChunk() } else { fetcher.chunks = fd.Chunks } diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index f4515ade..bd703c57 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -1,11 +1,13 @@ package http import ( + "encoding/json" "fmt" "github.com/monkeyWie/gopeed/internal/controller" "github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/internal/test" "github.com/monkeyWie/gopeed/pkg/base" + "github.com/monkeyWie/gopeed/pkg/protocol/http" "net" "reflect" "testing" @@ -111,8 +113,29 @@ func TestFetcher_DownloadResume(t *testing.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) { - 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{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }) @@ -120,9 +143,11 @@ func downloadReady(listener net.Listener, connections int, t *testing.T) (fetche t.Fatal(err) } opts = &base.Options{ - Name: test.DownloadName, - Path: test.Dir, - Connections: connections, + Name: test.DownloadName, + Path: test.Dir, + Extra: http.OptsExtra{ + Connections: connections, + }, } err = fetcher.Create(res, opts) if err != nil { @@ -150,7 +175,7 @@ func downloadNormal(listener net.Listener, connections int, t *testing.T) { func downloadPost(listener net.Listener, connections int, t *testing.T) { fetcher, _, _ := downloadReady(listener, connections, t) - fetcher.(*Fetcher).res.Req.Extra = extra{ + fetcher.(*Fetcher).res.Req.Extra = &http.ReqExtra{ Method: "POST", Header: map[string]string{ "Authorization": "Bearer 123456", @@ -249,3 +274,19 @@ func buildFetcher() *Fetcher { fetcher.Setup(controller.NewController()) 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 +} diff --git a/internal/protocol/http/model.go b/internal/protocol/http/model.go deleted file mode 100644 index af58c452..00000000 --- a/internal/protocol/http/model.go +++ /dev/null @@ -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 -} diff --git a/internal/test/util.go b/internal/test/util.go index 84501269..b32f4728 100644 --- a/internal/test/util.go +++ b/internal/test/util.go @@ -49,3 +49,7 @@ func ToJson(v interface{}) string { buf, _ := json.Marshal(v) return string(buf) } + +func JsonEqual(v1 any, v2 any) bool { + return ToJson(v1) == ToJson(v2) +} diff --git a/pkg/base/error.go b/pkg/base/error.go new file mode 100644 index 00000000..919f7465 --- /dev/null +++ b/pkg/base/error.go @@ -0,0 +1,8 @@ +package base + +import "errors" + +var ( + NotFound = errors.New("not found") + BadParams = errors.New("bad params") +) diff --git a/pkg/base/model.go b/pkg/base/model.go index f41b10c3..2ca21ec8 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -1,11 +1,13 @@ package base +import "github.com/monkeyWie/gopeed/pkg/util" + // 下载请求 type Request struct { // 下载链接 URL string `json:"url"` // 附加信息 - Extra interface{} `json:"extra"` + Extra any `json:"extra"` } // 资源信息 @@ -19,8 +21,8 @@ type Resource struct { Range bool `json:"range"` // 资源所包含的文件列表 Files []*FileInfo `json:"files"` - // 附加信息 - Extra interface{} `json:"extra"` + // 资源hash值 + Hash string `json:"hash"` } type FileInfo struct { @@ -35,8 +37,38 @@ type Options struct { Name string `json:"name"` // 保存目录 Path string `json:"path"` - // 并发连接数 - Connections int `json:"connections"` // 选择下载的文件下标列表 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 } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index f9251938..1c62f092 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -4,8 +4,6 @@ import ( "errors" "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" "github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/util" "strings" @@ -32,7 +30,6 @@ type Progress struct { } type Downloader struct { - controller *controller.DefaultController fetchBuilders map[string]fetcher.FetcherBuilder storage Storage tasks []*Task @@ -43,34 +40,6 @@ type Downloader struct { 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 { if cfg == nil { cfg = &DownloaderConfig{} @@ -78,7 +47,6 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader { cfg.Init() d := &Downloader{ - controller: cfg.Controller, fetchBuilders: make(map[string]fetcher.FetcherBuilder), refreshInterval: cfg.RefreshInterval, storage: cfg.Storage, @@ -166,12 +134,20 @@ func (d *Downloader) buildFetcher(url string) (fetcher.FetcherBuilder, fetcher.F } if ok { fetcher := fetchBuilder.Build() - fetcher.Setup(d.controller) + d.setupFetcher(fetcher) return fetchBuilder, fetcher, nil } 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) { _, fetcher, err := d.buildFetcher(req.URL) if err != nil { @@ -189,15 +165,21 @@ func (d *Downloader) Create(res *base.Resource, opts *base.Options) (taskId stri if opts == nil { opts = &base.Options{} } - if !res.Range || opts.Connections < 1 { - opts.Connections = 1 - } if len(opts.SelectFiles) == 0 { opts.SelectFiles = make([]int, 0) for i := range res.Files { 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) if err != nil { @@ -380,14 +362,36 @@ func (d *Downloader) GetTasks() []*Task { return d.tasks } -func (d *Downloader) GetConfig(v any) (bool, error) { - return d.storage.Get(bucketConfig, "config", v) +func (d *Downloader) GetConfig() (bool, *DownloaderStoreConfig, error) { + 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) } +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) { err := task.fetcher.Wait() if err != nil { @@ -435,9 +439,9 @@ func (d *Downloader) restoreFetcher(task *Task) error { } if task.fetcher == nil { 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) } return nil diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index f36cace6..9dfb6b31 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -3,6 +3,7 @@ package download import ( "github.com/monkeyWie/gopeed/internal/test" "github.com/monkeyWie/gopeed/pkg/base" + "github.com/monkeyWie/gopeed/pkg/protocol/http" "reflect" "sync" "testing" @@ -68,9 +69,11 @@ func TestDownloader_Create(t *testing.T) { } }) _, err = downloader.Create(res, &base.Options{ - Path: test.Dir, - Name: test.DownloadName, - Connections: 4, + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, }) if err != nil { t.Fatal(err) @@ -103,9 +106,11 @@ func TestDownloader_StoreAndRestore(t *testing.T) { } id, err := downloader.Create(res, &base.Options{ - Path: test.Dir, - Name: test.DownloadName, - Connections: 4, + Path: test.Dir, + Name: test.DownloadName, + Extra: http.OptsExtra{ + Connections: 4, + }, }) if err != nil { t.Fatal(err) @@ -148,3 +153,88 @@ func TestDownloader_StoreAndRestore(t *testing.T) { downloader.Clear() } + +func TestDownloader_Handle(t *testing.T) { + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Clear() + + ret, err := downloader.Handle("bt", "resolve", "https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt") + if err != nil { + t.Fatal(err) + } + list, ok := ret.([][]string) + if !ok { + t.Errorf("Handle() got = %T, want %v", ret, "[][]string") + } + if len(list) == 0 { + t.Errorf("Handle() got = %v, want %v", len(list), ">0") + } + + _, err = downloader.Handle("bt", "no", nil) + if err != base.NotFound { + t.Errorf("Handle() got = %v, want %v", err, base.NotFound) + } + + _, err = downloader.Handle("http", "no", nil) + if err != base.NotFound { + t.Errorf("Handle() got = %v, want %v", err, base.NotFound) + } +} + +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)) + } +} diff --git a/pkg/download/model.go b/pkg/download/model.go new file mode 100644 index 00000000..08a9733d --- /dev/null +++ b/pkg/download/model.go @@ -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 = 1000 + } + 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 +} diff --git a/pkg/protocol/http/model.go b/pkg/protocol/http/model.go new file mode 100644 index 00000000..726a0d08 --- /dev/null +++ b/pkg/protocol/http/model.go @@ -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"` +} diff --git a/pkg/rest/api.go b/pkg/rest/api.go index ffcb373f..ddc00a43 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -1,12 +1,15 @@ package rest import ( + "compress/gzip" "github.com/gorilla/mux" "github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/download" "github.com/monkeyWie/gopeed/pkg/rest/model" "github.com/monkeyWie/gopeed/pkg/rest/util" + "io" "net/http" + "net/url" "strings" ) @@ -25,12 +28,6 @@ func Resolve(w http.ResponseWriter, r *http.Request) { func CreateTask(w http.ResponseWriter, r *http.Request) { var req model.CreateTask 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) if err != nil { 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) { - var cfg model.ServerConfig + var cfg download.DownloaderStoreConfig if util.ReadJson(w, r, &cfg) { if err := Downloader.PutConfig(&cfg); err != nil { 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) } -func getServerConfig() *model.ServerConfig { - var cfg model.ServerConfig - Downloader.GetConfig(&cfg) - return &cfg +func DoProxy(w http.ResponseWriter, r *http.Request) { + target := r.Header.Get("X-Target-Uri") + if target == "" { + 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 } diff --git a/pkg/rest/config.go b/pkg/rest/config.go new file mode 100644 index 00000000..fddff6b7 --- /dev/null +++ b/pkg/rest/config.go @@ -0,0 +1,6 @@ +package rest + +type Config struct { + Host string `json:"host"` + Port int `json:"port"` +} diff --git a/pkg/rest/model/action.go b/pkg/rest/model/action.go new file mode 100644 index 00000000..ff29473e --- /dev/null +++ b/pkg/rest/model/action.go @@ -0,0 +1,7 @@ +package model + +type Command struct { + Protocol string `json:"protocol"` + Action string `json:"action"` + Params any `json:"params"` +} diff --git a/pkg/rest/model/server.go b/pkg/rest/model/server.go index 3459ec9e..bc685af2 100644 --- a/pkg/rest/model/server.go +++ b/pkg/rest/model/server.go @@ -15,6 +15,7 @@ type StartConfig struct { Storage Storage `json:"storage"` StorageDir string `json:"storageDir"` RefreshInterval int `json:"refreshInterval"` + ApiToken string `json:"apiToken"` WebEnable bool WebFS fs.FS @@ -24,6 +25,9 @@ func (cfg *StartConfig) Init() *StartConfig { if cfg.Network == "" { cfg.Network = "tcp" } + if cfg.Address == "" { + cfg.Address = "127.0.0.1:0" + } if cfg.Storage == "" { cfg.Storage = StorageBolt } @@ -32,27 +36,3 @@ func (cfg *StartConfig) Init() *StartConfig { } 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 -} diff --git a/pkg/rest/server.go b/pkg/rest/server.go index 43e9bec8..a24e1675 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -6,10 +6,10 @@ import ( "github.com/gorilla/mux" "github.com/monkeyWie/gopeed/pkg/download" "github.com/monkeyWie/gopeed/pkg/rest/model" + restUtil "github.com/monkeyWie/gopeed/pkg/rest/util" "github.com/monkeyWie/gopeed/pkg/util" "net" "net/http" - "strconv" ) var ( @@ -63,22 +63,18 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error } else { downloadCfg.Storage = download.NewMemStorage() } + downloadCfg.Init() downloadCfg.RefreshInterval = startCfg.RefreshInterval - Downloader = download.NewDownloader(downloadCfg.Init()) + Downloader = download.NewDownloader(downloadCfg) if err := Downloader.Setup(); err != nil { return nil, nil, err } - serverConfig := getAndPutServerConfig(startCfg) - var address string if startCfg.Network == "unix" { - address = startCfg.Address - util.SafeRemove(address) - } else { - address = net.JoinHostPort(serverConfig.Host, strconv.Itoa(serverConfig.Port)) + util.SafeRemove(startCfg.Address) } - listener, err := net.Listen(startCfg.Network, address) + listener, err := net.Listen(startCfg.Network, startCfg.Address) if err != nil { 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/config").HandlerFunc(GetConfig) r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig) + r.Path("/api/v1/proxy").HandlerFunc(DoProxy) if startCfg.WebEnable { 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( - handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"}), - handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), + handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}), + handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), handlers.AllowedOrigins([]string{"*"}), )(r)} 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 -} diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index 63c53be8..cfe4cdf6 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -2,6 +2,7 @@ package rest import ( "bytes" + "crypto/md5" "encoding/json" "errors" "fmt" @@ -22,7 +23,15 @@ import ( var ( restPort int - taskReq = &base.Request{} + taskReq = &base.Request{ + Extra: map[string]any{ + "method": "", + "header": map[string]string{ + "User-Agent": "gopeed", + }, + "body": "", + }, + } taskRes = &base.Resource{ Name: test.BuildName, Req: taskReq, @@ -39,9 +48,11 @@ var ( createReq = &model.CreateTask{ Res: taskRes, Opts: &base.Options{ - Path: test.Dir, - Name: test.DownloadName, - Connections: 2, + Path: test.Dir, + Name: test.DownloadName, + Extra: map[string]any{ + "connections": 2, + }, }, } ) @@ -49,8 +60,8 @@ var ( func TestResolve(t *testing.T) { doTest(func() { resp := httpRequestCheckOk[*base.Resource](http.MethodPost, "/api/v1/resolve", taskReq) - if !reflect.DeepEqual(taskRes, resp) { - t.Errorf("Resolve() got = %v, want %v", resp, taskRes) + if !test.JsonEqual(taskRes, resp) { + 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) { doTest(func() { - cfg := httpRequestCheckOk[*model.ServerConfig](http.MethodGet, "/api/v1/config", nil) - - cfg.Port = 8888 - cfg.Connections = 32 + cfg := httpRequestCheckOk[*download.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil) cfg.DownloadDir = "./download" cfg.Extra = map[string]any{ + "serverConfig": &Config{ + Host: "127.0.0.1", + Port: 8080, + }, "theme": "dark", } httpRequestCheckOk[any](http.MethodPut, "/api/v1/config", cfg) - newCfg := httpRequestCheckOk[*model.ServerConfig](http.MethodGet, "/api/v1/config", nil) - if !reflect.DeepEqual(newCfg, cfg) { - t.Errorf("GetAndPutConfig() got = %v, want %v", newCfg, cfg) + newCfg := httpRequestCheckOk[*download.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil) + if !test.JsonEqual(cfg, newCfg) { + t.Errorf("GetAndPutConfig() got = %v, want %v", test.ToJson(newCfg), test.ToJson(cfg)) } }) } +func 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()) { testFunc := func(storage model.Storage) { - fileListener := doStart(storage) + var cfg = &model.StartConfig{} + cfg.Init() + cfg.Storage = storage + fileListener := doStart(cfg) defer func() { if err := fileListener.Close(); err != nil { panic(err) @@ -221,10 +274,8 @@ func doTest(handler func()) { testFunc(model.StorageBolt) } -func doStart(storage model.Storage) net.Listener { - port, err := Start((&model.StartConfig{ - Storage: storage, - }).Init()) +func doStart(cfg *model.StartConfig) net.Listener { + port, err := Start(cfg) if err != nil { panic(err) } @@ -232,32 +283,50 @@ func doStart(storage model.Storage) net.Listener { return test.StartTestFileServer() } -func httpRequest[T any](method string, path string, req any) (int, *model.Result[T]) { - var body io.Reader - if req != nil { - buf, _ := json.Marshal(req) - body = bytes.NewBuffer(buf) +func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) { + var reader io.Reader + if body != nil { + buf, _ := json.Marshal(body) + reader = bytes.NewBuffer(buf) } - request, err := http.NewRequest(method, fmt.Sprintf("http://127.0.0.1:%d%s", restPort, path), body) + request, err := http.NewRequest(method, fmt.Sprintf("http://127.0.0.1:%d%s", restPort, path), reader) if err != nil { panic(err) } + if headers != nil { + for k, v := range headers { + request.Header.Set(k, v) + } + } response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() - - var r model.Result[T] - if err := json.NewDecoder(response.Body).Decode(&r); err != nil { + respBody, err := io.ReadAll(response.Body) + if err != nil { panic(err) } - return response.StatusCode, &r + return response.StatusCode, respBody } -func httpRequestCheckOk[T any](method string, path string, req any) T { - code, result := httpRequest[T](method, path, req) +func doHttpRequest[T any](method string, path string, headers map[string]string, body any) (int, *model.Result[T]) { + 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) return result.Data } diff --git a/pkg/rest/util/util.go b/pkg/rest/util/util.go index d80f16bc..8097df64 100644 --- a/pkg/rest/util/util.go +++ b/pkg/rest/util/util.go @@ -8,18 +8,18 @@ import ( func ReadJson(w http.ResponseWriter, r *http.Request, v any) bool { 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 true } 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) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } diff --git a/pkg/util/json.go b/pkg/util/json.go new file mode 100644 index 00000000..081feecc --- /dev/null +++ b/pkg/util/json.go @@ -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) +} diff --git a/pkg/util/url_test.go b/pkg/util/url_test.go new file mode 100644 index 00000000..e3ffd852 --- /dev/null +++ b/pkg/util/url_test.go @@ -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) + } + }) + } +} diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index a6d1138e..e7b5a0c8 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -1,17 +1,16 @@ -import 'dart:convert'; import 'dart:io'; import 'package:dio/adapter.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/request.dart'; import 'model/resource.dart'; import 'model/result.dart'; import 'model/task.dart'; -import '../core/libgopeed_boot.dart'; -import 'model/server_config.dart'; +import 'model/downloader_config.dart'; class _Client { static _Client? _instance; @@ -20,25 +19,35 @@ class _Client { _Client._internal(); - factory _Client() { + factory _Client(String network, String address, String apiToken) { if (_instance == null) { _instance = _Client._internal(); var dio = Dio(); - final isUnixSocket = LibgopeedBoot.instance.config.network == 'unix'; - dio.options.baseUrl = isUnixSocket - ? 'http://127.0.0.1' - : (Util.isWeb() - ? "" - : 'http://${LibgopeedBoot.instance.config.address}'); + final isUnixSocket = network == 'unix'; + var baseUrl = 'http://127.0.0.1'; + if (!isUnixSocket) { + if (Util.isWeb()) { + baseUrl = kDebugMode ? 'http://127.0.0.1:9999' : ''; + } else { + baseUrl = 'http://$address'; + } + } + dio.options.baseUrl = baseUrl; + dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) { + if (apiToken.isNotEmpty) { + options.headers['X-Api-Token'] = apiToken; + } + handler.next(options); + })); + _instance!.dio = dio; if (isUnixSocket) { (_instance!.dio.httpClientAdapter as DefaultHttpClientAdapter) .onHttpClientCreate = (client) { client.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) { - var address = InternetAddress(LibgopeedBoot.instance.config.address, - type: InternetAddressType.unix); - return Socket.startConnect(address, 0); + return Socket.startConnect( + InternetAddress(address, type: InternetAddressType.unix), 0); }; 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 _parse( Future Function() fetch, @@ -57,7 +70,7 @@ Future _parse( try { var resp = await fetch(); if (fromJsonT != null) { - return Result.fromJson(jsonDecode(resp.data), fromJsonT).data as T; + return Result.fromJson(resp.data, fromJsonT).data as T; } else { return null as T; } @@ -65,8 +78,7 @@ Future _parse( if (e.response == null) { throw Exception("Server error"); } - throw Exception( - Result.fromJson(jsonDecode(e.response?.data), (_) => null).msg); + throw Exception(Result.fromJson(e.response?.data, (_) => null).msg); } } @@ -77,7 +89,6 @@ Future resolve(Request request) async { } Future createTask(CreateTask createTask) async { - print(jsonEncode(createTask)); return _parse( () => _client.dio.post("/api/v1/tasks", data: createTask), (data) => data as String); @@ -103,11 +114,20 @@ Future deleteTask(String id, bool force) async { () => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null); } -Future getConfig() async { +Future getConfig() async { return _parse(() => _client.dio.get("/api/v1/config"), - (data) => ServerConfig.fromJson(data)); + (data) => DownloaderConfig.fromJson(data)); } -Future putConfig(ServerConfig config) async { +Future putConfig(DownloaderConfig config) async { return _parse(() => _client.dio.put("/api/v1/config", data: config), null); } + +Future> proxyRequest(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); +} diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart new file mode 100644 index 00000000..264d8097 --- /dev/null +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -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 json) => + _$DownloaderConfigFromJson(json); + Map toJson() => _$DownloaderConfigToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ProtocolConfig { + HttpConfig http = HttpConfig(); + BtConfig bt = BtConfig(); + + ProtocolConfig(); + + factory ProtocolConfig.fromJson(Map json) => + _$ProtocolConfigFromJson(json); + Map toJson() => _$ProtocolConfigToJson(this); +} + +@JsonSerializable() +class HttpConfig { + int connections = 0; + + HttpConfig(); + + factory HttpConfig.fromJson(Map json) => + _$HttpConfigFromJson(json); + Map toJson() => _$HttpConfigToJson(this); +} + +@JsonSerializable() +class BtConfig { + List trackers = []; + + BtConfig(); + + factory BtConfig.fromJson(Map json) => + _$BtConfigFromJson(json); + Map toJson() => _$BtConfigToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ExtraConfig { + String themeMode = ''; + String locale = ''; + ExtraConfigBt bt = ExtraConfigBt(); + + ExtraConfig(); + + factory ExtraConfig.fromJson(Map json) => + _$ExtraConfigFromJson(json); + Map toJson() => _$ExtraConfigToJson(this); +} + +@JsonSerializable() +class ExtraConfigBt { + List trackerSubscribeUrls = []; + List subscribeTrackers = []; + DateTime? lastTrackerUpdateTime; + + List customTrackers = []; + + ExtraConfigBt(); + + factory ExtraConfigBt.fromJson(Map json) => + _$ExtraConfigBtFromJson(json); + Map toJson() => _$ExtraConfigBtToJson(this); +} diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart new file mode 100644 index 00000000..7e941e60 --- /dev/null +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -0,0 +1,93 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'downloader_config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DownloaderConfig _$DownloaderConfigFromJson(Map json) => + DownloaderConfig() + ..downloadDir = json['downloadDir'] as String + ..protocolConfig = ProtocolConfig.fromJson( + json['protocolConfig'] as Map) + ..extra = ExtraConfig.fromJson(json['extra'] as Map); + +Map _$DownloaderConfigToJson(DownloaderConfig instance) => + { + 'downloadDir': instance.downloadDir, + 'protocolConfig': instance.protocolConfig.toJson(), + 'extra': instance.extra.toJson(), + }; + +ProtocolConfig _$ProtocolConfigFromJson(Map json) => + ProtocolConfig() + ..http = HttpConfig.fromJson(json['http'] as Map) + ..bt = BtConfig.fromJson(json['bt'] as Map); + +Map _$ProtocolConfigToJson(ProtocolConfig instance) => + { + 'http': instance.http.toJson(), + 'bt': instance.bt.toJson(), + }; + +HttpConfig _$HttpConfigFromJson(Map json) => + HttpConfig()..connections = json['connections'] as int; + +Map _$HttpConfigToJson(HttpConfig instance) => + { + 'connections': instance.connections, + }; + +BtConfig _$BtConfigFromJson(Map json) => BtConfig() + ..trackers = + (json['trackers'] as List).map((e) => e as String).toList(); + +Map _$BtConfigToJson(BtConfig instance) => { + 'trackers': instance.trackers, + }; + +ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig() + ..themeMode = json['themeMode'] as String + ..locale = json['locale'] as String + ..bt = ExtraConfigBt.fromJson(json['bt'] as Map); + +Map _$ExtraConfigToJson(ExtraConfig instance) => + { + 'themeMode': instance.themeMode, + 'locale': instance.locale, + 'bt': instance.bt.toJson(), + }; + +ExtraConfigBt _$ExtraConfigBtFromJson(Map json) => + ExtraConfigBt() + ..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List) + .map((e) => e as String) + .toList() + ..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null + ? null + : DateTime.parse(json['lastTrackerUpdateTime'] as String) + ..subscribeTrackers = (json['subscribeTrackers'] as List) + .map((e) => e as String) + .toList() + ..customTrackers = (json['customTrackers'] as List) + .map((e) => e as String) + .toList(); + +Map _$ExtraConfigBtToJson(ExtraConfigBt instance) { + final val = { + 'trackerSubscribeUrls': instance.trackerSubscribeUrls, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('lastTrackerUpdateTime', + instance.lastTrackerUpdateTime?.toIso8601String()); + val['subscribeTrackers'] = instance.subscribeTrackers; + val['customTrackers'] = instance.customTrackers; + return val; +} diff --git a/ui/flutter/lib/api/model/options.dart b/ui/flutter/lib/api/model/options.dart index 01095645..e8bb5b0a 100644 --- a/ui/flutter/lib/api/model/options.dart +++ b/ui/flutter/lib/api/model/options.dart @@ -6,13 +6,11 @@ part 'options.g.dart'; class Options { String name; String path; - int connections; List selectFiles; Options({ required this.name, required this.path, - required this.connections, required this.selectFiles, }); diff --git a/ui/flutter/lib/api/model/options.g.dart b/ui/flutter/lib/api/model/options.g.dart index a97024f6..4327f40e 100644 --- a/ui/flutter/lib/api/model/options.g.dart +++ b/ui/flutter/lib/api/model/options.g.dart @@ -9,7 +9,6 @@ part of 'options.dart'; Options _$OptionsFromJson(Map json) => Options( name: json['name'] as String, path: json['path'] as String, - connections: json['connections'] as int, selectFiles: (json['selectFiles'] as List).map((e) => e as int).toList(), ); @@ -17,6 +16,5 @@ Options _$OptionsFromJson(Map json) => Options( Map _$OptionsToJson(Options instance) => { 'name': instance.name, 'path': instance.path, - 'connections': instance.connections, 'selectFiles': instance.selectFiles, }; diff --git a/ui/flutter/lib/api/model/server_config.dart b/ui/flutter/lib/api/model/server_config.dart deleted file mode 100644 index 70971d06..00000000 --- a/ui/flutter/lib/api/model/server_config.dart +++ /dev/null @@ -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? extra; - - ServerConfig({ - required this.host, - required this.port, - required this.connections, - required this.downloadDir, - this.extra, - }); - - factory ServerConfig.fromJson(Map json) => - _$ServerConfigFromJson(json); - Map toJson() => _$ServerConfigToJson(this); -} diff --git a/ui/flutter/lib/api/model/server_config.g.dart b/ui/flutter/lib/api/model/server_config.g.dart deleted file mode 100644 index ed8dc3f7..00000000 --- a/ui/flutter/lib/api/model/server_config.g.dart +++ /dev/null @@ -1,33 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'server_config.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -ServerConfig _$ServerConfigFromJson(Map 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?, - ); - -Map _$ServerConfigToJson(ServerConfig instance) { - final val = { - '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; -} diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index cbb8038f..e41b59cf 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -7,7 +7,7 @@ part 'task.g.dart'; enum Status { ready, running, pause, error, done } -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class Task { String id; Resource res; diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index 94e5b6ef..2e9b7e5c 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -18,10 +18,10 @@ Task _$TaskFromJson(Map json) => Task( Map _$TaskToJson(Task instance) => { 'id': instance.id, - 'res': instance.res, - 'opts': instance.opts, + 'res': instance.res.toJson(), + 'opts': instance.opts.toJson(), 'status': _$StatusEnumMap[instance.status]!, - 'progress': instance.progress, + 'progress': instance.progress.toJson(), 'size': instance.size, 'createdAt': instance.createdAt.toIso8601String(), }; diff --git a/ui/flutter/lib/core/common/libgopeed_ffi.dart b/ui/flutter/lib/core/common/libgopeed_ffi.dart index ba271a0a..839025a2 100644 --- a/ui/flutter/lib/core/common/libgopeed_ffi.dart +++ b/ui/flutter/lib/core/common/libgopeed_ffi.dart @@ -20,7 +20,7 @@ class LibgopeedFFi implements LibgopeedInterface { var completer = Completer(); var result = _libgopeed.Start(jsonEncode(cfg).toNativeUtf8().cast()); if (result.r1 != nullptr) { - completer.completeError(result.r1.cast().toDartString()); + completer.completeError(Exception(result.r1.cast().toDartString())); } else { completer.complete(result.r0); } diff --git a/ui/flutter/lib/core/common/start_config.dart b/ui/flutter/lib/core/common/start_config.dart index 6748d20c..26bf481d 100644 --- a/ui/flutter/lib/core/common/start_config.dart +++ b/ui/flutter/lib/core/common/start_config.dart @@ -4,19 +4,14 @@ part 'start_config.g.dart'; @JsonSerializable() class StartConfig { - String network; - String address; - String storage; - String storageDir; - int refreshInterval; + late String network; + late String address; + late String storage; + late String storageDir; + late int refreshInterval; + late String apiToken; - StartConfig({ - required this.network, - required this.address, - required this.storage, - required this.storageDir, - required this.refreshInterval, - }); + StartConfig(); factory StartConfig.fromJson(Map json) => _$StartConfigFromJson(json); diff --git a/ui/flutter/lib/core/common/start_config.g.dart b/ui/flutter/lib/core/common/start_config.g.dart index e765366d..8732accb 100644 --- a/ui/flutter/lib/core/common/start_config.g.dart +++ b/ui/flutter/lib/core/common/start_config.g.dart @@ -6,13 +6,13 @@ part of 'start_config.dart'; // JsonSerializableGenerator // ************************************************************************** -StartConfig _$StartConfigFromJson(Map json) => StartConfig( - network: json['network'] as String, - address: json['address'] as String, - storage: json['storage'] as String, - storageDir: json['storageDir'] as String, - refreshInterval: json['refreshInterval'] as int, - ); +StartConfig _$StartConfigFromJson(Map json) => StartConfig() + ..network = json['network'] as String + ..address = json['address'] as String + ..storage = json['storage'] as String + ..storageDir = json['storageDir'] as String + ..refreshInterval = json['refreshInterval'] as int + ..apiToken = json['apiToken'] as String; Map _$StartConfigToJson(StartConfig instance) => { @@ -21,4 +21,5 @@ Map _$StartConfigToJson(StartConfig instance) => 'storage': instance.storage, 'storageDir': instance.storageDir, 'refreshInterval': instance.refreshInterval, + 'apiToken': instance.apiToken, }; diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart index 5e711b57..dc3b7814 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart @@ -1,22 +1,18 @@ import 'dart:async'; +import 'package:gopeed/core/common/start_config.dart'; + import '../libgopeed_boot.dart'; LibgopeedBoot create() => LibgopeedBootBrowser(); class LibgopeedBootBrowser implements LibgopeedBoot { - late LibgopeedConfig _config; - - LibgopeedBootBrowser() { - _config = LibgopeedConfig(); + // do nothing + @override + Future start(StartConfig cfg) async { + return 0; } - @override - LibgopeedConfig get config => _config; - - @override - Future start() async {} - @override Future stop() async {} } diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart index 22a2c284..3705e82c 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart @@ -16,48 +16,30 @@ import '../libgopeed_boot.dart'; LibgopeedBoot create() => LibgopeedBootNative(); class LibgopeedBootNative implements LibgopeedBoot { - late LibgopeedConfig _config; late LibgopeedInterface _libgopeed; late SendPort _childSendPort; LibgopeedBootNative() { - _config = LibgopeedConfig(); if (!Util.isDesktop()) { _libgopeed = LibgopeedChannel(); } } @override - LibgopeedConfig get config => _config; - - @override - Future start() async { + Future start(StartConfig cfg) async { var storageDir = "./"; - if (!Util.isUnix()) { - // not support unix socket, use tcp - _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 ?? ""; - } + if (Platform.isAndroid) { + storageDir = (await getExternalStorageDirectory())?.path ?? storageDir; } - final cfg = StartConfig( - network: _config.network, - address: _config.address, - storage: 'bolt', - storageDir: storageDir, - refreshInterval: _config.refreshInterval); + if (Platform.isIOS) { + storageDir = (await getLibraryDirectory()).path; + } + cfg.storage = 'bolt'; + cfg.storageDir = storageDir; + cfg.refreshInterval = 0; var port = Util.isDesktop() ? await _ffiStart(cfg) : await _libgopeed.start(cfg); - if (_config.network == "tcp") { - _config.address = "${_config.address.split(":")[0]}:$port"; - } + return port; } @override diff --git a/ui/flutter/lib/core/libgopeed_boot.dart b/ui/flutter/lib/core/libgopeed_boot.dart index 2383636f..53d7d370 100644 --- a/ui/flutter/lib/core/libgopeed_boot.dart +++ b/ui/flutter/lib/core/libgopeed_boot.dart @@ -1,19 +1,8 @@ +import 'common/start_config.dart'; import "libgopeed_boot_stub.dart" if (dart.library.html) 'entry/libgopeed_boot_browser.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 { static const unixSocketPath = 'gopeed.sock'; @@ -26,9 +15,7 @@ abstract class LibgopeedBoot { factory LibgopeedBoot() => create(); - Future start(); + Future start(StartConfig cfg); Future stop(); - - LibgopeedConfig get config; } diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart new file mode 100644 index 00000000..ee2edefd --- /dev/null +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -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', + }, +}; diff --git a/ui/flutter/lib/i18n/langs/ru_RU.dart b/ui/flutter/lib/i18n/langs/ru_RU.dart new file mode 100644 index 00000000..08acb3d3 --- /dev/null +++ b/ui/flutter/lib/i18n/langs/ru_RU.dart @@ -0,0 +1,32 @@ +const ruRU = { + 'ru_RU': { + 'label': 'Русский(Россия)', + 'error': 'Ошибка', + 'confirm': 'Подтвердить', + 'cancel': 'Отмена', + '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.selectDir': 'Выберите папку', + 'setting.title': 'Настройки', + 'setting.basic': 'Основные параметры', + 'setting.theme': 'Тема', + 'setting.themeSystem': 'Системная', + 'setting.themeLight': 'Светлая', + 'setting.themeDark': 'Тёмная', + 'setting.downloadDir': 'Папка загрузки', + 'setting.downloadDirValid': 'Пожалуйста, выберите папку загрузки', + 'setting.connections': 'Количество подключений', + 'setting.locale': 'Язык', + 'task.deleteTask': 'Удалить задачу', + 'task.deleteTaskTip': 'Сохранить загруженные файлы', + 'task.delete': 'Удалить', + }, +}; diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart new file mode 100644 index 00000000..e464f503 --- /dev/null +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -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': '删除', + } +}; diff --git a/ui/flutter/lib/i18n/messages.dart b/ui/flutter/lib/i18n/messages.dart index b549059f..306890ad 100644 --- a/ui/flutter/lib/i18n/messages.dart +++ b/ui/flutter/lib/i18n/messages.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'langs/en_us.dart'; +import 'langs/zh_cn.dart'; + Locale toLocale(String key) { final arr = key.split('_'); return Locale(arr[0], arr[1]); @@ -11,107 +14,11 @@ String getLocaleKey(Locale locale) { } final messages = _Messages(); +const mainLocale = Locale('zh', 'CN'); const fallbackLocale = Locale('en', 'US'); class _Messages extends Translations { + // just include available locales here @override - Map> get keys => { - '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': '英文(美国)', - 'setting.locale.ru_RU': '俄语 (俄罗斯) ', - '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)', - 'setting.locale.ru_RU': 'Russian(Russia)', - 'task.deleteTask': 'Delete Task', - 'task.deleteTaskTip': 'Keep downloaded files', - 'task.delete': 'Delete', - }, - 'ru_RU': { - 'error': 'Ошибка', - 'confirm': 'Подтвердить', - 'cancel': 'Отмена', - '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.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': 'Английский(США)', - 'setting.locale.ru_RU': 'Русский(Россия)', - 'task.deleteTask': 'Удалить задачу', - 'task.deleteTaskTip': 'Сохранить загруженные файлы', - 'task.delete': 'Удалить', - }, - }; + Map> get keys => {...zhCN, ...enUS}; } diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 6e3d8531..538c5256 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -1,33 +1,69 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.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 'i18n/messages.dart'; -import 'routes/router.dart'; -import 'setting/setting.dart'; -import 'theme/theme.dart'; +import 'pages/app/app_controller.dart'; +import 'pages/app/app_view.dart'; import 'util/log_util.dart'; import 'util/mac_secure_util.dart'; +import 'util/package_info.dart'; void main() async { + await init(); + onStart(); + + runApp(const AppView()); +} + +Future init() async { WidgetsFlutterBinding.ensureInitialized(); + final controller = Get.put(AppController()); try { - await LibgopeedBoot.instance.start(); - await Setting.instance.load(); + await controller.loadStartConfig(); + final startCfg = controller.startConfig.value; + controller.runningPort.value = await LibgopeedBoot.instance.start(startCfg); + api.init(startCfg.network, controller.runningAddress(), startCfg.apiToken); } catch (e) { - logger.e("init fail", e); + logger.e("libgopeed init fail", e); } - MacSecureUtil.loadBookmark(); - runApp(GetMaterialApp.router( - debugShowCheckedModeBanner: false, - theme: GopeedTheme.light, - darkTheme: GopeedTheme.dark, - themeMode: Setting.instance.themeMode, - translations: messages, - locale: toLocale(Setting.instance.locale), - fallbackLocale: fallbackLocale, - getPages: Routes.routes, - )); + try { + await controller.loadDownloaderConfig(); + MacSecureUtil.loadBookmark(); + } catch (e) { + logger.e("load config fail", e); + } + + try { + await initPackageInfo(); + } catch (e) { + logger.e("init package info fail", e); + } +} + +Future onStart() async { + final appController = Get.find(); + 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"); + } + }); + } } diff --git a/ui/flutter/lib/pages/app/app_controller.dart b/ui/flutter/lib/pages/app/app_controller.dart new file mode 100644 index 00000000..73841bf2 --- /dev/null +++ b/ui/flutter/lib/pages/app/app_controller.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:external_path/external_path.dart'; +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 _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 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 loadDownloaderConfig() async { + try { + downloaderConfig.value = await getConfig(); + } catch (e) { + logger.w("load downloader config fail", e); + downloaderConfig.value = DownloaderConfig(); + } + await _initDownloaderConfig(); + } + + Future trackerUpdate() async { + final btExtConfig = downloaderConfig.value.extra.bt; + final result = []; + 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 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> _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) { + extra.locale = getLocaleKey(ui.window.locale); + } + if (extra.bt.trackerSubscribeUrls.isEmpty) { + // default select all tracker subscribe urls + extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls); + } + + if (Util.isAndroid()) { + config.downloadDir = + '${await ExternalPath.getExternalStoragePublicDirectory(ExternalPath.DIRECTORY_DOWNLOADS)}/com.gopeed'; + return; + } + + if (config.downloadDir.isEmpty) { + if (Util.isWeb()) { + config.downloadDir = './'; + } + if (Util.isDesktop()) { + config.downloadDir = (await getDownloadsDirectory())?.path ?? "./"; + } + } + } + + Future 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); + } +} diff --git a/ui/flutter/lib/pages/app/app_view.dart b/ui/flutter/lib/pages/app/app_view.dart new file mode 100644 index 00000000..d51edc21 --- /dev/null +++ b/ui/flutter/lib/pages/app/app_view.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.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 { + 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, + getPages: Routes.routes, + ); + } +} diff --git a/ui/flutter/lib/pages/create/create_view.dart b/ui/flutter/lib/pages/create/create_view.dart index 4cb5151a..2f56532b 100644 --- a/ui/flutter/lib/pages/create/create_view.dart +++ b/ui/flutter/lib/pages/create/create_view.dart @@ -1,27 +1,27 @@ import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:rounded_loading_button/rounded_loading_button.dart'; + import '../../api/api.dart'; import '../../api/model/create_task.dart'; import '../../api/model/options.dart'; import '../../api/model/request.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 '../../util/util.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 { - CreateView({Key? key}) : super(key: key); - final _resolveFormKey = GlobalKey(); + final _urlController = TextEditingController(); final _confirmController = RoundedLoadingButtonController(); + CreateView({Key? key}) : super(key: key); @override Widget build(BuildContext context) { @@ -103,9 +103,11 @@ class CreateView extends GetView { } Future _showResolveDialog(Resource res) async { + final appController = Get.find(); + final setting = appController.downloaderConfig.value; + final createFormKey = GlobalKey(); - final pathController = - TextEditingController(text: Setting.instance.downloadDir); + final pathController = TextEditingController(text: setting.downloadDir); final downloadController = RoundedLoadingButtonController(); var fileValues = List.filled(res.files.length, true); @@ -181,7 +183,6 @@ class CreateView extends GetView { opts: Options( name: '', path: pathController.text, - connections: Setting.instance.connections, selectFiles: fileValues .asMap() .entries diff --git a/ui/flutter/lib/pages/root/root_controller.dart b/ui/flutter/lib/pages/root/root_controller.dart index 826348fc..6f4f9e0c 100644 --- a/ui/flutter/lib/pages/root/root_controller.dart +++ b/ui/flutter/lib/pages/root/root_controller.dart @@ -1,20 +1,4 @@ import 'package:get/get.dart'; +import '../../api/api.dart'; -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++; -} +class RootController extends GetxController {} diff --git a/ui/flutter/lib/pages/setting/setting_controller.dart b/ui/flutter/lib/pages/setting/setting_controller.dart index 86fc93c4..924f6dcf 100644 --- a/ui/flutter/lib/pages/setting/setting_controller.dart +++ b/ui/flutter/lib/pages/setting/setting_controller.dart @@ -1,12 +1,16 @@ import 'package:get/get.dart'; -import '../../setting/setting.dart'; - class SettingController extends GetxController { - final setting = Setting.instance.obs; - final tapStatues = {}.obs; + final tapStatues = {}.obs; - void clearTapStatus() { - tapStatues.clear(); + // set all tap status to false + void clearTap() { + tapStatues.updateAll((key, value) => false); + } + + // set one tap status to true + void onTap(String key) { + clearTap(); + tapStatues[key] = true; } } diff --git a/ui/flutter/lib/pages/setting/setting_view.dart b/ui/flutter/lib/pages/setting/setting_view.dart index 38f6d699..e62b2d2f 100644 --- a/ui/flutter/lib/pages/setting/setting_view.dart +++ b/ui/flutter/lib/pages/setting/setting_view.dart @@ -1,207 +1,548 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:gopeed/i18n/messages.dart'; -import 'package:gopeed/util/util.dart'; -import '../../setting/setting.dart'; -import '../../widget/directory_selector.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../i18n/messages.dart'; +import '../../util/package_info.dart'; +import '../../util/util.dart'; +import '../../widget/check_list_view.dart'; +import '../../widget/directory_selector.dart'; +import '../../widget/outlined_button_loading.dart'; +import '../app/app_controller.dart'; import 'setting_controller.dart'; +const _padding = SizedBox(height: 10); +final _divider = const Divider().paddingOnly(left: 10, right: 10); + class SettingView extends GetView { const SettingView({Key? key}) : super(key: key); @override Widget build(BuildContext context) { + final appController = Get.find(); + final downloaderCfg = appController.downloaderConfig; + final startCfg = appController.startConfig; + Timer? timer; - debounceSave() { + debounceSave({bool needRestart = false}) { var completer = Completer(); timer?.cancel(); timer = Timer(const Duration(milliseconds: 500), () { - Setting.instance - .save() + appController + .saveConfig() .then(completer.complete) .onError(completer.completeError); + if (needRestart) { + Get.snackbar('tip'.tr, 'setting.effectAfterRestart'.tr); + } }); return completer.future; } - return Scaffold( - appBar: AppBar( - title: Text('setting.title'.tr), - centerTitle: true, - ), - body: Column( + // download basic config items start + final buildDownloadDir = _buildConfigItem( + 'setting.downloadDir', () => downloaderCfg.value.downloadDir, + (Key key) { + final downloadDirController = + 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, children: [ - Text('setting.basic'.tr, style: Get.textTheme.titleLarge), - const SizedBox(height: 10), - Obx(() => Card( - child: Column( - children: _buildConfigItems([ - _ConfigItem( - 'setting.theme'.tr, - () => _getThemeName(controller.setting.value.themeMode), - () => DropdownButton( - value: controller.setting.value.themeMode, - isDense: true, - onChanged: (value) async { - controller.setting.value.themeMode = value!; - controller.clearTapStatus(); - Get.changeThemeMode(value); + SizedBox( + height: 200, + child: CheckListView( + items: allTrackerSubscribeUrls, + checked: btExtConfig.trackerSubscribeUrls, + onChanged: (value) { + btExtConfig.trackerSubscribeUrls = value; - await debounceSave(); - }, - items: ThemeMode.values - .map((e) => DropdownMenuItem( - value: e, - child: Text(_getThemeName(e)), - )) - .toList(), - )), - _ConfigItem('setting.downloadDir'.tr, - () => controller.setting.value.downloadDir, () { - final downloadDirController = TextEditingController( - text: controller.setting.value.downloadDir); - downloadDirController.addListener(() async { - if (downloadDirController.text != - controller.setting.value.downloadDir) { - controller.setting.value.downloadDir = - downloadDirController.text; - if (Util.isDesktop()) { - controller.clearTapStatus(); - } - - await debounceSave(); - } - }); - return DirectorySelector( - controller: downloadDirController, - showLabel: false, - ); - }), - _ConfigItem('setting.connections'.tr, - () => controller.setting.value.connections.toString(), - () { - final connectionsController = TextEditingController( - 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( - 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( - value: - e.substring(_settingLocaleKey.length), - child: Text(e.tr), - )) - .toList(), - )), - ]), - ))), + debounceSave(); + }, + ), + ), + _padding, + Row( + children: [ + OutlinedButtonLoading( + onPressed: () async { + trackerUpdateController.start(); + try { + await appController.trackerUpdate(); + } catch (e) { + Get.snackbar('error'.tr, 'setting.subscribeFail'.tr); + } finally { + trackerUpdateController.stop(); + } + }, + controller: trackerUpdateController, + child: Text('setting.update'.tr), + ), + Flexible( + child: SizedBox( + width: 200, + child: SwitchListTile( + controlAffinity: ListTileControlAffinity.leading, + value: true, + onChanged: (bool value) {}, + title: Text('setting.updateDaily'.tr)), + ), + ), + ], + ), + Text('setting.lastUpdate'.trParams({ + 'time': btExtConfig.lastTrackerUpdateTime != null + ? DateFormat('yyyy-MM-dd HH:mm:ss') + .format(btExtConfig.lastTrackerUpdateTime!) + : '' + })), ], - ).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( + 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( + value: e.name, + child: Text(_getThemeName(e.name)), + )) + .toList(), + )); + final buildLocale = _buildConfigItem( + 'setting.locale', + () => messages.keys[downloaderCfg.value.extra.locale]!['label']!, + (Key key) => DropdownButton( + 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( + value: e, + child: Text(messages.keys[e]!['label']!), + )) + .toList(), + )); + + // about config items start + buildHomepage() => ListTile( + title: Text('主页'.tr), + subtitle: const Text('https://github.com/monkeyWie/gopeed'), + onTap: () { + launchUrl(Uri.parse('https://github.com/monkeyWie/gopeed')); + }, + ); + buildVersion() => ListTile( + title: Text('版本'.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 = [ + SizedBox( + width: 150, + child: DropdownButtonFormField( + value: startCfg.value.network, + onChanged: (value) async { + startCfg.update((val) { + val!.network = value!; + }); + + await debounceSave(needRestart: true); + }, + items: [ + !Util.isMobile() + ? const DropdownMenuItem( + value: 'tcp', + child: Text('TCP'), + ) + : null, + Util.isUnix() + ? const DropdownMenuItem( + 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 _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 _addPadding(List widgets) { final result = []; - for (var i = 0; i < buildItems.length; i++) { - final buildItem = buildItems[i]; - result.add(ListTile( - title: Text(buildItem.label), - subtitle: controller.tapStatues[i] ?? false - ? buildItem.inputItem() - : Text(buildItem.text()), - onTap: () { - controller.tapStatues[i] = true; - // set other false - for (var j = 0; j < controller.tapStatues.length; j++) { - if (j != i) { - controller.tapStatues[j] = false; - } - } - })); - if (i != buildItems.length - 1) { - result.add(const Divider()); + for (var i = 0; i < widgets.length; i++) { + result.add(widgets[i]); + result.add(_padding); + } + return result; + } + + List _addDivider(List widgets) { + final result = []; + final newArr = widgets.where((e) => e != null).map((e) => e!).toList(); + for (var i = 0; i < newArr.length; i++) { + result.add(newArr[i]); + if (i != newArr.length - 1) { + result.add(_divider); } } return result; } - String _getThemeName(ThemeMode themeMode) { - switch (themeMode) { - case ThemeMode.system: - return 'setting.themeSystem'.tr; + String _getThemeName(String? themeMode) { + switch (ThemeMode.values.byName(themeMode ?? ThemeMode.system.name)) { case ThemeMode.light: return 'setting.themeLight'.tr; case ThemeMode.dark: return 'setting.themeDark'.tr; + default: + return 'setting.themeSystem'.tr; } } - - final _settingLocaleKey = 'setting.locale.'; - - List _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 { late String label; late String Function() text; - late Widget Function() inputItem; + late Widget Function(Key key) inputItem; _ConfigItem(this.label, this.text, this.inputItem); } -class NumericalRangeFormatter extends TextInputFormatter { +class _NumericalRangeFormatter extends TextInputFormatter { final int min; final int max; - NumericalRangeFormatter({required this.min, required this.max}); + _NumericalRangeFormatter({required this.min, required this.max}); @override TextEditingValue formatEditUpdate( diff --git a/ui/flutter/lib/pages/task/task_controller.dart b/ui/flutter/lib/pages/task/task_controller.dart index 53b19707..d837b506 100644 --- a/ui/flutter/lib/pages/task/task_controller.dart +++ b/ui/flutter/lib/pages/task/task_controller.dart @@ -1,10 +1,9 @@ import 'dart:async'; import 'package:get/get.dart'; -import '../../api/model/task.dart'; -import '../../core/libgopeed_boot.dart'; import '../../api/api.dart'; +import '../../api/model/task.dart'; class TaskController extends GetxController { final tabIndex = 0.obs; @@ -16,27 +15,6 @@ class TaskController extends GetxController { 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 { if (tabIndex.value == 0) { 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 void onClose() { super.onClose(); _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; + } } diff --git a/ui/flutter/lib/pages/task/task_view.dart b/ui/flutter/lib/pages/task/task_view.dart index 61d8e089..a8c12d4d 100644 --- a/ui/flutter/lib/pages/task/task_view.dart +++ b/ui/flutter/lib/pages/task/task_view.dart @@ -2,62 +2,14 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../../api/api.dart'; -import '../../api/model/task.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../api/api.dart'; +import '../../api/model/task.dart'; import '../../util/util.dart'; import 'task_controller.dart'; -class TaskView extends GetView { - 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 tasks) { - return ListView.builder( - itemCount: tasks.length, - itemBuilder: (context, index) { - return Item(task: tasks[index]); - }, - ); - } -} - class Item extends StatelessWidget { final Task task; @@ -79,22 +31,20 @@ class Item extends StatelessWidget { Row( children: [ Expanded( - flex: 1, child: Text( - "${isDone() ? "" : "${Util.fmtByte(task.progress.downloaded)} / "}${Util.fmtByte(task.size)}", - style: Get.textTheme.bodyText1 - ?.copyWith(color: Get.theme.disabledColor), - ).padding(left: 18)), + "${isDone() ? "" : "${Util.fmtByte(task.progress.downloaded)} / "}${Util.fmtByte(task.size)}", + style: context.textTheme.bodyText1 + ?.copyWith(color: Get.theme.disabledColor), + ).padding(left: 18)), Expanded( - flex: 1, child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text("${Util.fmtByte(task.progress.speed)} / s", - style: Get.textTheme.subtitle2), - ...buildActions() - ], - )), + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text("${Util.fmtByte(task.progress.speed)} / s", + style: context.textTheme.subtitle2), + ...buildActions() + ], + )), ], ), LinearProgressIndicator( @@ -144,18 +94,32 @@ class Item extends StatelessWidget { 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 _showDeleteDialog(String id) { final keep = true.obs; + final context = Get.context!; + return showDialog( - context: Get.context!, + context: context, barrierDismissible: false, builder: (_) => AlertDialog( title: Text('task.deleteTask'.tr), content: Obx(() => CheckboxListTile( value: keep.value, title: Text('task.deleteTaskTip'.tr, - style: Get.textTheme.bodyText1), + style: context.textTheme.bodyText1), onChanged: (v) { keep.value = v!; })), @@ -182,16 +146,52 @@ class Item extends StatelessWidget { ], )); } +} - double getProgress() { - return task.size <= 0 ? 1 : task.progress.downloaded / task.size; +class TaskView extends GetView { + 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() { - return task.status == Status.running; - } - - bool isDone() { - return task.status == Status.done; + Widget buildTaskList(List tasks) { + return ListView.builder( + itemCount: tasks.length, + itemBuilder: (context, index) { + return Item(task: tasks[index]); + }, + ); } } diff --git a/ui/flutter/lib/routes/router.dart b/ui/flutter/lib/routes/router.dart index 749fd8fa..ba593753 100644 --- a/ui/flutter/lib/routes/router.dart +++ b/ui/flutter/lib/routes/router.dart @@ -1,13 +1,13 @@ 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/home/home_controller.dart'; import '../pages/home/home_view.dart'; import '../pages/root/root_controller.dart'; import '../pages/root/root_view.dart'; import '../pages/setting/setting_controller.dart'; +import '../pages/setting/setting_view.dart'; import '../pages/task/task_controller.dart'; import '../pages/task/task_view.dart'; diff --git a/ui/flutter/lib/setting/setting.dart b/ui/flutter/lib/setting/setting.dart deleted file mode 100644 index e2d1f8e7..00000000 --- a/ui/flutter/lib/setting/setting.dart +++ /dev/null @@ -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 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 save() async { - final config = ServerConfig( - host: host, - port: port, - connections: connections, - downloadDir: downloadDir, - extra: { - 'themeMode': themeMode.name, - 'locale': locale, - }, - ); - await putConfig(config); - } -} diff --git a/ui/flutter/lib/util/log_util.dart b/ui/flutter/lib/util/log_util.dart index 0106c012..6bd45235 100644 --- a/ui/flutter/lib/util/log_util.dart +++ b/ui/flutter/lib/util/log_util.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:logger/src/outputs/file_output.dart'; @@ -7,5 +8,13 @@ import 'util.dart'; final logger = Logger( 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; +} diff --git a/ui/flutter/lib/util/package_info.dart b/ui/flutter/lib/util/package_info.dart new file mode 100644 index 00000000..5a2d9164 --- /dev/null +++ b/ui/flutter/lib/util/package_info.dart @@ -0,0 +1,7 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +late PackageInfo packageInfo; + +Future initPackageInfo() async { + packageInfo = await PackageInfo.fromPlatform(); +} diff --git a/ui/flutter/lib/util/util.dart b/ui/flutter/lib/util/util.dart index 90baf6f5..1604f663 100644 --- a/ui/flutter/lib/util/util.dart +++ b/ui/flutter/lib/util/util.dart @@ -3,6 +3,14 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; 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) { if (byte < 1024) { 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() { return !kIsWeb && Platform.isAndroid; } - static isMacos() { - return !kIsWeb && Platform.isMacOS; + static isIOS() { + return !kIsWeb && Platform.isIOS; + } + + static isMobile() { + return !kIsWeb && (Platform.isAndroid || Platform.isIOS); } static isDesktop() { @@ -42,10 +42,18 @@ class Util { return Platform.isWindows || Platform.isLinux || Platform.isMacOS; } + static isMacos() { + return !kIsWeb && Platform.isMacOS; + } + static isUnix() { if (kIsWeb) { return false; } return !Platform.isWindows; } + + static isWeb() { + return kIsWeb; + } } diff --git a/ui/flutter/lib/widget/check_list_view.dart b/ui/flutter/lib/widget/check_list_view.dart new file mode 100644 index 00000000..ba435790 --- /dev/null +++ b/ui/flutter/lib/widget/check_list_view.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class CheckListView extends StatefulWidget { + final List items; + final List checked; + final void Function(List value) onChanged; + + const CheckListView({ + Key? key, + required this.items, + required this.checked, + required this.onChanged, + }) : super(key: key); + + @override + State createState() => _CheckListView(); +} + +class _CheckListView extends State { + bool get _allChecked => _checked.length == _items.length; + + List get _checked => widget.checked; + + List 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), + ); + }), + ), + ], + )); + } +} diff --git a/ui/flutter/lib/widget/directory_selector.dart b/ui/flutter/lib/widget/directory_selector.dart index 6fef9d37..edf4ce5c 100644 --- a/ui/flutter/lib/widget/directory_selector.dart +++ b/ui/flutter/lib/widget/directory_selector.dart @@ -1,9 +1,9 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:gopeed/util/util.dart'; import '../util/mac_secure_util.dart'; +import '../util/util.dart'; class DirectorySelector extends StatefulWidget { final TextEditingController controller; diff --git a/ui/flutter/lib/widget/file_list_view.dart b/ui/flutter/lib/widget/file_list_view.dart index dcc81b13..7293d876 100644 --- a/ui/flutter/lib/widget/file_list_view.dart +++ b/ui/flutter/lib/widget/file_list_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../util/util.dart'; import '../api/model/resource.dart'; +import '../util/util.dart'; class FileListView extends StatefulWidget { final List files; @@ -30,7 +30,7 @@ class _FileListViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding(padding: EdgeInsets.only(top: 10)), - Text('create.selectDir'.tr, + Text('create.selectFile'.tr, style: TextStyle(color: themeData.hintColor)), Expanded( child: Container( diff --git a/ui/flutter/lib/widget/outlined_button_loading.dart b/ui/flutter/lib/widget/outlined_button_loading.dart new file mode 100644 index 00000000..1779ab2a --- /dev/null +++ b/ui/flutter/lib/widget/outlined_button_loading.dart @@ -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 createState() => _OutlinedButtonLoadingState(); +} + +class _OutlinedButtonLoadingState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + 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 { + OutlinedButtonLoadingController() : super(false); + + void start() { + value = true; + } + + void stop() { + value = false; + } +} diff --git a/ui/flutter/macos/Podfile.lock b/ui/flutter/macos/Podfile.lock index 1569144a..4338c3af 100644 --- a/ui/flutter/macos/Podfile.lock +++ b/ui/flutter/macos/Podfile.lock @@ -6,6 +6,8 @@ PODS: - FlutterMacOS (1.0.0) - macos_secure_bookmarks (0.0.3): - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS - path_provider_macos (0.0.1): - FlutterMacOS - shared_preferences_macos (0.0.1): @@ -18,6 +20,7 @@ DEPENDENCIES: - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - 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`) - shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -31,6 +34,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral macos_secure_bookmarks: :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: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos shared_preferences_macos: @@ -43,6 +48,7 @@ SPEC CHECKSUMS: file_selector_macos: f1b08a781e66103e3ba279fd5d4024a2478b3af6 FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 macos_secure_bookmarks: cff041c4b377fa00941bb901aaec4d27fac5edf2 + package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727 url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 8e8ad0f6..1d2add2c 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -376,7 +376,7 @@ packages: source: hosted version: "3.2.2" intl: - dependency: transitive + dependency: "direct main" description: name: intl url: "https://pub.flutter-io.cn" @@ -494,6 +494,20 @@ packages: url: "https://pub.flutter-io.cn" source: hosted 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: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index f28da324..8a7e9559 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -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. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 +version: 1.2.0+1 environment: sdk: ">=2.17.6 <3.0.0" @@ -50,6 +50,8 @@ dependencies: external_path: ^1.0.3 logger: ^1.1.0 desktop_drop: ^0.3.3 + intl: ^0.17.0 + package_info_plus: ^3.0.2 dev_dependencies: flutter_test: