diff --git a/bind/common.go b/bind/common.go new file mode 100644 index 000000000..a945bfd42 --- /dev/null +++ b/bind/common.go @@ -0,0 +1,41 @@ +package bind + +import ( + "encoding/json" + "errors" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" +) + +// Global singleton instance +var instance *api.Instance + +func Init(config *model.StartConfig) (err error) { + if instance != nil { + return nil + } + + config.ProductionMode = true + instance, err = api.Create(config) + return +} + +func Invoke(request *api.Request) string { + if instance == nil { + return BuildResult(errors.New("instance not initialized")) + } + return jsonEncode(api.Invoke(instance, request)) +} + +func BuildResult(data any) string { + if err, ok := data.(error); ok { + buf, _ := json.Marshal(model.NewErrorResult[any](err.Error())) + return string(buf) + } + return jsonEncode(model.NewOkResult(data)) +} + +func jsonEncode(data any) string { + buf, _ := json.Marshal(data) + return string(buf) +} diff --git a/bind/desktop/main.go b/bind/desktop/main.go index 5b63770f9..f1b12b5e9 100644 --- a/bind/desktop/main.go +++ b/bind/desktop/main.go @@ -3,27 +3,32 @@ package main import "C" import ( "encoding/json" - "github.com/GopeedLab/gopeed/pkg/rest" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/bind" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" ) func main() {} -//export Start -func Start(cfg *C.char) (int, *C.char) { +//export Init +func Init(cfg *C.char) *C.char { var config model.StartConfig if err := json.Unmarshal([]byte(C.GoString(cfg)), &config); err != nil { - return 0, C.CString(err.Error()) + return C.CString(err.Error()) } - config.ProductionMode = true - realPort, err := rest.Start(&config) - if err != nil { - return 0, C.CString(err.Error()) + if err := bind.Init(&config); err != nil { + return C.CString(err.Error()) } - return realPort, nil + + return nil } -//export Stop -func Stop() { - rest.Stop() +//export Invoke +func Invoke(req *C.char) *C.char { + var request api.Request + if err := json.Unmarshal([]byte(C.GoString(req)), &request); err != nil { + return C.CString(bind.BuildResult(err)) + } + + return C.CString(bind.Invoke(&request)) } diff --git a/bind/mobile/main.go b/bind/mobile/main.go index 0a81ba535..000cdc2cf 100644 --- a/bind/mobile/main.go +++ b/bind/mobile/main.go @@ -3,20 +3,15 @@ package libgopeed // #cgo LDFLAGS: -static-libstdc++ import "C" import ( - "encoding/json" - "github.com/GopeedLab/gopeed/pkg/rest" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/bind" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" ) -func Start(cfg string) (int, error) { - var config model.StartConfig - if err := json.Unmarshal([]byte(cfg), &config); err != nil { - return 0, err - } - config.ProductionMode = true - return rest.Start(&config) +func Init(cfg *model.StartConfig) error { + return bind.Init(cfg) } -func Stop() { - rest.Stop() +func Invoke(request *api.Request) string { + return bind.Invoke(request) } diff --git a/cmd/api/main.go b/cmd/api/main.go index c9ad67594..080870cd9 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,15 +2,21 @@ package main import ( "github.com/GopeedLab/gopeed/cmd" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/base" ) // only for local development func main() { cfg := &model.StartConfig{ - Network: "tcp", - Address: "127.0.0.1:9999", - Storage: model.StorageBolt, + Storage: model.StorageBolt, + DownloadConfig: &base.DownloaderStoreConfig{ + Http: &base.DownloaderHttpConfig{ + Enable: true, + Host: "127.0.0.1", + Port: 9999, + }, + }, WebEnable: true, } cmd.Start(cfg) diff --git a/cmd/server.go b/cmd/server.go index 667e01a83..d16b57b0e 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -3,10 +3,9 @@ package cmd import ( _ "embed" "fmt" + "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/api/model" "github.com/GopeedLab/gopeed/pkg/base" - "github.com/GopeedLab/gopeed/pkg/rest" - "github.com/GopeedLab/gopeed/pkg/rest/model" - "net/http" "os" "os/signal" "path/filepath" @@ -18,20 +17,22 @@ var banner string func Start(cfg *model.StartConfig) { fmt.Println(banner) - srv, listener, err := rest.BuildServer(cfg) + instance, err := api.Create(cfg) if err != nil { panic(err) } - downloadCfg, err := rest.Downloader.GetConfig() - if err != nil { - panic(err) + + downloadCfgResult := instance.GetConfig() + if downloadCfgResult.HasError() { + panic(downloadCfgResult.Msg) } + downloadCfg := downloadCfgResult.Data if downloadCfg.FirstLoad { // Set default download config if cfg.DownloadConfig != nil { cfg.DownloadConfig.Merge(downloadCfg) // TODO Use PatchConfig - rest.Downloader.PutConfig(cfg.DownloadConfig) + instance.PutConfig(cfg.DownloadConfig) downloadCfg = cfg.DownloadConfig } @@ -48,25 +49,28 @@ func Start(cfg *model.StartConfig) { } if downloadDir != "" { downloadCfg.DownloadDir = downloadDir - rest.Downloader.PutConfig(downloadCfg) + instance.PutConfig(downloadCfg) } } } - watchExit() - fmt.Printf("Server start success on http://%s\n", listener.Addr().String()) - if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + watchExit(instance) + + port, start, err := api.ListenHttp(cfg.DownloadConfig.Http, instance) + if err != nil { panic(err) } + fmt.Printf("Server start success on http://%s:%d\n", cfg.DownloadConfig.Http.Host, port) + start() } -func watchExit() { +func watchExit(instance *api.Instance) { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { sig := <-sigs fmt.Printf("Server is shutting down due to signal: %s\n", sig) - rest.Downloader.Close() + instance.Close() os.Exit(0) }() } diff --git a/cmd/web/flags.go b/cmd/web/flags.go index 3d0537b56..b5e94cafe 100644 --- a/cmd/web/flags.go +++ b/cmd/web/flags.go @@ -10,7 +10,7 @@ import ( ) type args struct { - Address *string `json:"address"` + Host *string `json:"host"` Port *int `json:"port"` Username *string `json:"username"` Password *string `json:"password"` @@ -24,7 +24,7 @@ type args struct { func parse() *args { var cliArgs args - cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address") + cliArgs.Host = flag.String("H", "0.0.0.0", "Bind Host") cliArgs.Port = flag.Int("P", 9999, "Bind Port") cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username") cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd") @@ -35,8 +35,8 @@ func parse() *args { // args priority: config file > cli args cfgArgs := loadConfig(*cliArgs.configPath) - if cfgArgs.Address == nil { - cfgArgs.Address = cliArgs.Address + if cfgArgs.Host == nil { + cfgArgs.Host = cliArgs.Host } if cfgArgs.Port == nil { cfgArgs.Port = cliArgs.Port diff --git a/cmd/web/main.go b/cmd/web/main.go index d62655709..57087160c 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -5,9 +5,9 @@ package main import ( "embed" - "fmt" "github.com/GopeedLab/gopeed/cmd" - "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/base" "io/fs" "os" "path/filepath" @@ -42,12 +42,19 @@ func main() { dir = filepath.Dir(exe) } + if args.DownloadConfig == nil { + args.DownloadConfig = &base.DownloaderStoreConfig{} + } + args.DownloadConfig.Http = &base.DownloaderHttpConfig{ + Enable: true, + Host: *args.Host, + Port: *args.Port, + ApiToken: *args.ApiToken, + } + cfg := &model.StartConfig{ - Network: "tcp", - Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port), Storage: model.StorageBolt, StorageDir: filepath.Join(dir, "storage"), - ApiToken: *args.ApiToken, DownloadConfig: args.DownloadConfig, ProductionMode: true, WebEnable: true, diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 1a45a6f93..480a82d6e 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -332,7 +332,7 @@ func (f *Fetcher) doUpload(fromUpload bool) { } } -// Get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972 +// get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972 func (f *Fetcher) torrentStats() torrent.TorrentStats { defer func() { if r := recover(); r != nil { diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 1d3def5ae..33c5f521a 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -146,7 +146,7 @@ func (f *Fetcher) Resolve(req *base.Request) error { file.Name = filename } } - // Get file filePath by URL + // get file filePath by URL if file.Name == "" { file.Name = path.Base(httpReq.URL.Path) } @@ -499,7 +499,7 @@ func (fm *FetcherManager) ParseName(u string) string { if err != nil { return "" } - // Get filePath by URL + // get filePath by URL name = path.Base(url.Path) // If file name is empty, use host name if name == "" || name == "/" || name == "." { diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 000000000..af6aeacb3 --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,400 @@ +package api + +import ( + "context" + "fmt" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/GopeedLab/gopeed/pkg/util" + "net" + "net/http" + "reflect" + "runtime" + "sync" +) + +type Instance struct { + startCfg *model.StartConfig + downloader *download.Downloader + + httpLock sync.Mutex + srv *http.Server + listener net.Listener +} + +func Create(startCfg *model.StartConfig) (*Instance, error) { + if startCfg == nil { + startCfg = &model.StartConfig{} + } + startCfg.Init() + + i := &Instance{ + startCfg: startCfg, + } + + downloadCfg := &download.DownloaderConfig{ + ProductionMode: startCfg.ProductionMode, + RefreshInterval: startCfg.RefreshInterval, + } + if startCfg.Storage == model.StorageBolt { + downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir) + } else { + downloadCfg.Storage = download.NewMemStorage() + } + downloadCfg.StorageDir = startCfg.StorageDir + downloadCfg.Init() + downloader := download.NewDownloader(downloadCfg) + if err := downloader.Setup(); err != nil { + return nil, err + } + i.downloader = downloader + i.httpLock = sync.Mutex{} + + return i, nil +} + +func (i *Instance) Listener(listener download.Listener) *model.Result[any] { + i.downloader.Listener(listener) + return model.NewNilResult() +} + +func (i *Instance) StartHttp() *model.Result[int] { + i.httpLock.Lock() + defer i.httpLock.Unlock() + + return i.startHttp() +} + +func (i *Instance) startHttp() *model.Result[int] { + httpCfg := i.getHttpConfig() + if httpCfg == nil || !httpCfg.Enable { + return model.NewErrorResult[int]("HTTP API server not enabled") + } + if i.srv != nil { + return model.NewErrorResult[int]("HTTP API server already started") + } + + port, start, err := ListenHttp(httpCfg, i) + if err != nil { + return model.NewErrorResult[int](err.Error()) + } + httpCfg.RunningPort = port + go start() + return model.NewOkResult(port) +} + +func (i *Instance) getHttpConfig() *base.DownloaderHttpConfig { + var httpCfg *base.DownloaderHttpConfig + if i.startCfg.DownloadConfig != nil && i.startCfg.DownloadConfig.Http != nil { + httpCfg = i.startCfg.DownloadConfig.Http + } else { + cfg, _ := i.downloader.GetConfig() + httpCfg = cfg.Http + } + return httpCfg +} + +func (i *Instance) StopHttp() *model.Result[any] { + i.httpLock.Lock() + defer i.httpLock.Unlock() + + return i.stopHttp() +} + +func (i *Instance) stopHttp() *model.Result[any] { + if i.srv != nil { + if err := i.srv.Shutdown(context.Background()); err != nil { + i.downloader.Logger.Warn().Err(err).Msg("shutdown http server failed") + } + i.srv = nil + i.listener = nil + } + httpCfg := i.getHttpConfig() + if httpCfg != nil { + httpCfg.RunningPort = 0 + } + return model.NewNilResult() +} + +func (i *Instance) RestartHttp() *model.Result[int] { + i.httpLock.Lock() + defer i.httpLock.Unlock() + + i.stopHttp() + return i.startHttp() +} + +func (i *Instance) Info() *model.Result[map[string]any] { + return model.NewOkResult(map[string]any{ + "version": base.Version, + "runtime": runtime.Version(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "inDocker": base.InDocker == "true", + }) +} + +func (i *Instance) Resolve(req *base.Request) *model.Result[*download.ResolveResult] { + rr, err := i.downloader.Resolve(req) + if err != nil { + return model.NewErrorResult[*download.ResolveResult](err.Error()) + } + return model.NewOkResult(rr) +} + +func (i *Instance) CreateTask(req *model.CreateTask) *model.Result[string] { + var ( + taskId string + err error + ) + if req.Rid != "" { + taskId, err = i.downloader.Create(req.Rid, req.Opt) + } else if req.Req != nil { + taskId, err = i.downloader.CreateDirect(req.Req, req.Opt) + } else { + return model.NewErrorResult[string]("param invalid: rid or req", model.CodeInvalidParam) + } + if err != nil { + return model.NewErrorResult[string](err.Error()) + } + return model.NewOkResult(taskId) +} + +func (i *Instance) CreateTaskBatch(req *model.CreateTaskBatch) *model.Result[[]string] { + if len(req.Reqs) == 0 { + return model.NewErrorResult[[]string]("param invalid: reqs", model.CodeInvalidParam) + } + taskIds, err := i.downloader.CreateDirectBatch(req.Reqs, req.Opt) + if err != nil { + return model.NewErrorResult[[]string](err.Error()) + } + return model.NewOkResult(taskIds) +} + +func (i *Instance) PauseTask(taskId string) *model.Result[any] { + err := i.downloader.Pause(&download.TaskFilter{ + ID: []string{taskId}, + }) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) PauseTasks(filter *download.TaskFilter) *model.Result[any] { + err := i.downloader.Pause(filter) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) ContinueTask(taskId string) *model.Result[any] { + err := i.downloader.Continue(&download.TaskFilter{ + ID: []string{taskId}, + }) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) ContinueTasks(filter *download.TaskFilter) *model.Result[any] { + err := i.downloader.Continue(filter) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) DeleteTask(taskId string, force bool) *model.Result[any] { + err := i.downloader.Delete(&download.TaskFilter{ + ID: []string{taskId}, + }, force) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) DeleteTasks(filter *download.TaskFilter, force bool) *model.Result[any] { + err := i.downloader.Delete(filter, force) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) GetTask(taskId string) *model.Result[*download.Task] { + if taskId == "" { + return model.NewErrorResult[*download.Task]("param invalid: id", model.CodeInvalidParam) + } + task := i.downloader.GetTask(taskId) + if task == nil { + return model.NewErrorResult[*download.Task]("task not found", model.CodeTaskNotFound) + } + return model.NewOkResult(task) +} + +func (i *Instance) GetTasks(filter *download.TaskFilter) *model.Result[[]*download.Task] { + return model.NewOkResult(i.downloader.GetTasksByFilter(filter)) +} + +func (i *Instance) GetTaskStats(taskId string) *model.Result[any] { + stats, err := i.downloader.GetTaskStats(taskId) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewOkResult(stats) +} + +func (i *Instance) GetConfig() *model.Result[*base.DownloaderStoreConfig] { + config, err := i.downloader.GetConfig() + if err != nil { + return model.NewErrorResult[*base.DownloaderStoreConfig](err.Error()) + } + return model.NewOkResult(config) +} + +func (i *Instance) PutConfig(cfg *base.DownloaderStoreConfig) *model.Result[any] { + err := i.downloader.PutConfig(cfg) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) InstallExtension(req *model.InstallExtension) *model.Result[string] { + var ( + installedExt *download.Extension + err error + ) + if req.DevMode { + installedExt, err = i.downloader.InstallExtensionByFolder(req.URL, true) + } else { + installedExt, err = i.downloader.InstallExtensionByGit(req.URL) + } + if err != nil { + return model.NewErrorResult[string](err.Error()) + } + + return model.NewOkResult(installedExt.Identity) +} + +func (i *Instance) GetExtension(identity string) *model.Result[*download.Extension] { + extension, err := i.downloader.GetExtension(identity) + if err != nil { + return model.NewErrorResult[*download.Extension](err.Error()) + } + return model.NewOkResult(extension) +} + +func (i *Instance) GetExtensions() *model.Result[[]*download.Extension] { + return model.NewOkResult(i.downloader.GetExtensions()) +} + +func (i *Instance) UpdateExtensionSettings(identity string, req *model.UpdateExtensionSettings) *model.Result[any] { + err := i.downloader.UpdateExtensionSettings(identity, req.Settings) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) SwitchExtension(identity string, req *model.SwitchExtension) *model.Result[any] { + err := i.downloader.SwitchExtension(identity, req.Status) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) DeleteExtension(identity string) *model.Result[any] { + err := i.downloader.DeleteExtension(identity) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) UpgradeCheckExtension(identity string) *model.Result[*model.UpgradeCheckExtensionResp] { + newVersion, err := i.downloader.UpgradeCheckExtension(identity) + if err != nil { + return model.NewErrorResult[*model.UpgradeCheckExtensionResp](err.Error()) + } + + return model.NewOkResult(&model.UpgradeCheckExtensionResp{ + NewVersion: newVersion, + }) +} + +func (i *Instance) UpgradeExtension(identity string) *model.Result[any] { + err := i.downloader.UpgradeExtension(identity) + if err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +func (i *Instance) Close() *model.Result[any] { + i.StopHttp() + if i.downloader != nil { + if err := i.downloader.Close(); err != nil { + i.downloader.Logger.Warn().Err(err).Msg("close downloader failed") + } + i.downloader = nil + } + + return model.NewNilResult() +} + +func (i *Instance) Clear() *model.Result[any] { + if err := i.downloader.Clear(); err != nil { + return model.NewErrorResult[any](err.Error()) + } + return model.NewNilResult() +} + +type Request struct { + Method string `json:"method"` + Params []any `json:"params"` +} + +// Invoke support dynamic call method +func Invoke(instance *Instance, request *Request) (ret any) { + defer func() { + if err := recover(); err != nil { + ret = model.NewErrorResult[any](fmt.Sprintf("%v", err)) + } + }() + + method, args := request.Method, request.Params + dsType := reflect.ValueOf(instance) + fn := dsType.MethodByName(method) + numIn := fn.Type().NumIn() + in := make([]reflect.Value, numIn) + for i := 0; i < numIn; i++ { + paramType := fn.Type().In(i) + arg := args[i] + if arg == nil { + in[i] = reflect.Zero(fn.Type().In(i)) + continue + } + var param reflect.Value + var paramPtr any + if paramType.Kind() == reflect.Ptr { + param = reflect.New(paramType.Elem()) + paramPtr = param.Interface() + } else { + param = reflect.New(paramType).Elem() + paramPtr = param.Addr().Interface() + } + if err := util.MapToStruct(arg, paramPtr); err != nil { + panic(err) + } + in[i] = param + } + retVals := fn.Call(in) + return retVals[0].Interface() +} diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go new file mode 100644 index 000000000..97f691b39 --- /dev/null +++ b/pkg/api/api_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/download" + "testing" +) + +func TestInvoke(t *testing.T) { + doTestInvoke(t, "Info", []any{}, model.CodeOk) + doTestInvoke(t, "Info1", []any{}, model.CodeError) + doTestInvoke(t, "CreateTask", []any{nil}, model.CodeError) + doTestInvoke(t, "GetTasks", nil, model.CodeError) + doTestInvoke(t, "GetTasks", []any{}, model.CodeError) + doTestInvoke(t, "GetTasks", []any{"{abc:123"}, model.CodeError) + doTestInvoke(t, "GetTasks", []any{&download.TaskFilter{}}, model.CodeOk) + doTestInvoke(t, "SwitchExtension", []any{"test", &model.SwitchExtension{Status: true}}, model.CodeError) +} + +func doTestInvoke(t *testing.T, method string, params []any, expectCode model.RespCode) { + instance, _ := Create(nil) + defer instance.Close() + + result := Invoke(instance, &Request{ + Method: method, + Params: params, + }) + buf, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + + var res model.Result[any] + if err := json.Unmarshal(buf, &res); err != nil { + t.Fatal(err) + } + + if res.Code != expectCode { + t.Fatalf("Invoke method [%s] failed, expect code [%d], got [%d], msg: %s", method, expectCode, res.Code, res.Msg) + } +} diff --git a/pkg/api/http.go b/pkg/api/http.go new file mode 100644 index 000000000..87b4706ec --- /dev/null +++ b/pkg/api/http.go @@ -0,0 +1,451 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "github.com/GopeedLab/gopeed/pkg/api/model" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/gorilla/handlers" + "github.com/gorilla/mux" + "github.com/pkg/errors" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" +) + +const instanceKey string = "apiInstance" + +func ListenHttp(httpCfg *base.DownloaderHttpConfig, ai *Instance) (int, func(), error) { + var r = mux.NewRouter() + r.Methods(http.MethodGet).Path("/api/v1/info").HandlerFunc(Info) + r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve) + r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask) + r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch) + r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask) + r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks) + r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask) + r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueTasks) + r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask) + r.Methods(http.MethodDelete).Path("/api/v1/tasks").HandlerFunc(DeleteTasks) + r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask) + r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks) + r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}/stats").HandlerFunc(GetTaskStats) + r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig) + r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig) + r.Methods(http.MethodPost).Path("/api/v1/extensions").HandlerFunc(InstallExtension) + r.Methods(http.MethodGet).Path("/api/v1/extensions").HandlerFunc(GetExtensions) + r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}").HandlerFunc(GetExtension) + r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/settings").HandlerFunc(UpdateExtensionSettings) + r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/switch").HandlerFunc(SwitchExtension) + r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension) + r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/upgrade").HandlerFunc(UpgradeCheckExtension) + r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/upgrade").HandlerFunc(UpgradeExtension) + if ai.startCfg.WebEnable { + r.Path("/api/v1/proxy").HandlerFunc(DoProxy) + r.PathPrefix("/fs/tasks").Handler(http.FileServer(&taskFileSystem{ + downloader: ai.downloader, + })) + r.PathPrefix("/fs/extensions").Handler(http.FileServer(&extensionFileSystem{ + downloader: ai.downloader, + })) + r.PathPrefix("/").Handler(http.FileServer(http.FS(ai.startCfg.WebFS))) + } + + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), instanceKey, ai) + h.ServeHTTP(w, r.WithContext(ctx)) + }) + }) + + if httpCfg.ApiToken != "" || (ai.startCfg.WebEnable && ai.startCfg.WebBasicAuth != nil) { + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if httpCfg.ApiToken != "" && r.Header.Get("X-Api-Token") == httpCfg.ApiToken { + h.ServeHTTP(w, r) + return + } + if ai.startCfg.WebEnable && ai.startCfg.WebBasicAuth != nil { + if r.Header.Get("Authorization") == ai.startCfg.WebBasicAuth.Authorization() { + h.ServeHTTP(w, r) + return + } + w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"") + } + WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult[any]("unauthorized", model.CodeUnauthorized)) + }) + }) + } + + // recover panic + r.Use(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if v := recover(); v != nil { + err := errors.WithStack(fmt.Errorf("%v", v)) + ai.downloader.Logger.Error().Stack().Err(err).Msgf("http server panic: %s %s", r.Method, r.RequestURI) + WriteJson(w, model.NewErrorResult[any](err.Error(), model.CodeError)) + } + }() + h.ServeHTTP(w, r) + }) + }) + + srv := &http.Server{Handler: handlers.CORS( + handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}), + handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), + handlers.AllowedOrigins([]string{"*"}), + )(r)} + + listener, err := net.Listen("tcp", net.JoinHostPort(httpCfg.Host, fmt.Sprintf("%d", httpCfg.Port))) + if err != nil { + return 0, nil, err + } + ai.srv = srv + ai.listener = listener + + return listener.Addr().(*net.TCPAddr).Port, func() { + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + ai.downloader.Logger.Error().Err(err).Msg("http server listen error") + } + }, nil +} + +func getInstance(r *http.Request) *Instance { + return r.Context().Value(instanceKey).(*Instance) +} + +func Info(w http.ResponseWriter, r *http.Request) { + WriteJson(w, getInstance(r).Info()) +} + +func Resolve(w http.ResponseWriter, r *http.Request) { + var req base.Request + if ReadJson(r, w, &req) { + WriteJson(w, getInstance(r).Resolve(&req)) + } +} + +func CreateTask(w http.ResponseWriter, r *http.Request) { + var req model.CreateTask + if ReadJson(r, w, &req) { + WriteJson(w, getInstance(r).CreateTask(&req)) + } +} + +func CreateTaskBatch(w http.ResponseWriter, r *http.Request) { + var req model.CreateTaskBatch + if ReadJson(r, w, &req) { + WriteJson(w, getInstance(r).CreateTaskBatch(&req)) + } +} + +func PauseTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).PauseTask(taskId)) +} + +func PauseTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + WriteJson(w, getInstance(r).PauseTasks(&filter)) +} + +func ContinueTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).ContinueTask(taskId)) +} + +func ContinueTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + WriteJson(w, getInstance(r).ContinueTasks(&filter)) +} + +func DeleteTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + force := r.FormValue("force") + WriteJson(w, getInstance(r).DeleteTask(taskId, force == "true")) +} + +func DeleteTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + force := r.FormValue("force") + WriteJson(w, getInstance(r).DeleteTasks(&filter, force == "true")) +} + +func GetTask(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).GetTask(taskId)) +} + +func GetTasks(w http.ResponseWriter, r *http.Request) { + var filter download.TaskFilter + if !parseFilter(r, w, &filter) { + return + } + WriteJson(w, getInstance(r).GetTasks(&filter)) +} + +func GetTaskStats(w http.ResponseWriter, r *http.Request) { + var taskId string + if !parseTaskId(r, w, &taskId) { + return + } + WriteJson(w, getInstance(r).GetTaskStats(taskId)) +} + +func GetConfig(w http.ResponseWriter, r *http.Request) { + WriteJson(w, getInstance(r).GetConfig()) +} + +func PutConfig(w http.ResponseWriter, r *http.Request) { + var cfg base.DownloaderStoreConfig + if !ReadJson(r, w, &cfg) { + return + } + WriteJson(w, getInstance(r).PutConfig(&cfg)) +} + +func InstallExtension(w http.ResponseWriter, r *http.Request) { + var req model.InstallExtension + if !ReadJson(r, w, &req) { + return + } + WriteJson(w, getInstance(r).InstallExtension(&req)) +} + +func GetExtensions(w http.ResponseWriter, r *http.Request) { + WriteJson(w, getInstance(r).GetExtensions()) +} + +func GetExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).GetExtension(identity)) +} + +func UpdateExtensionSettings(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + var req model.UpdateExtensionSettings + if !ReadJson(r, w, &req) { + return + } + WriteJson(w, getInstance(r).UpdateExtensionSettings(identity, &req)) +} + +func SwitchExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + var switchExtension model.SwitchExtension + if !ReadJson(r, w, &switchExtension) { + return + } + WriteJson(w, getInstance(r).SwitchExtension(identity, &switchExtension)) +} + +func DeleteExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).DeleteExtension(identity)) +} + +func UpgradeCheckExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).UpgradeCheckExtension(identity)) +} + +func UpgradeExtension(w http.ResponseWriter, r *http.Request) { + var identity string + if !parseIdentity(r, w, &identity) { + return + } + WriteJson(w, getInstance(r).UpgradeExtension(identity)) +} + +func DoProxy(w http.ResponseWriter, r *http.Request) { + target := r.Header.Get("X-Target-Uri") + if target == "" { + writeError(w, "param invalid: X-Target-Uri") + return + } + targetUrl, err := url.Parse(target) + if err != nil { + writeError(w, err.Error()) + return + } + r.RequestURI = "" + r.URL = targetUrl + r.Host = targetUrl.Host + resp, err := http.DefaultClient.Do(r) + if err != nil { + writeError(w, err.Error()) + return + } + defer resp.Body.Close() + for k, vv := range resp.Header { + for _, v := range vv { + w.Header().Set(k, v) + } + } + w.WriteHeader(resp.StatusCode) + buf, err := io.ReadAll(resp.Body) + if err != nil { + writeError(w, err.Error()) + return + } + w.Write(buf) +} + +func parsePathParam(r *http.Request, w http.ResponseWriter, name string, value *string) bool { + vars := mux.Vars(r) + *value = vars[name] + if *value == "" { + WriteJson(w, model.NewErrorResult[any]("param invalid: id", model.CodeInvalidParam)) + return false + } + return true +} + +func parseTaskId(r *http.Request, w http.ResponseWriter, taskId *string) bool { + return parsePathParam(r, w, "id", taskId) +} + +func parseIdentity(r *http.Request, w http.ResponseWriter, identity *string) bool { + return parsePathParam(r, w, "identity", identity) +} + +func parseFilter(r *http.Request, w http.ResponseWriter, filter *download.TaskFilter) bool { + if err := r.ParseForm(); err != nil { + WriteJson(w, model.NewErrorResult[any](err.Error())) + return false + } + + filter.ID = r.Form["id"] + filter.Status = convertStatues(r.Form["status"]) + filter.NotStatus = convertStatues(r.Form["notStatus"]) + return true +} + +func convertStatues(statues []string) []base.Status { + result := make([]base.Status, 0) + for _, status := range statues { + result = append(result, base.Status(status)) + } + return result +} + +func writeError(w http.ResponseWriter, msg string) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(msg)) +} + +func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + WriteJson(w, model.NewErrorResult[any](err.Error())) + return false + } + return true +} + +func WriteJson(w http.ResponseWriter, v any) { + WriteStatusJson(w, http.StatusOK, v) +} + +func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(v) +} + +func resolvePath(urlPath string, prefix string) (identity string, path string, err error) { + // remove prefix + clearPath := strings.TrimPrefix(urlPath, prefix) + // match extension identity, eg: /fs/extensions/identity/xxx + reg := regexp.MustCompile(`^/([^/]+)/(.*)$`) + if !reg.MatchString(clearPath) { + err = os.ErrNotExist + return + } + matched := reg.FindStringSubmatch(clearPath) + if len(matched) != 3 { + err = os.ErrNotExist + return + } + return matched[1], matched[2], nil +} + +// handle task file resource +type taskFileSystem struct { + downloader *download.Downloader +} + +func (e *taskFileSystem) Open(name string) (http.File, error) { + // get extension identity + identity, path, err := resolvePath(name, "/fs/tasks") + if err != nil { + return nil, err + } + task := e.downloader.GetTask(identity) + if task == nil { + return nil, os.ErrNotExist + } + return os.Open(filepath.Join(task.Meta.RootDirPath(), path)) +} + +// handle extension file resource +type extensionFileSystem struct { + downloader *download.Downloader +} + +func (e *extensionFileSystem) Open(name string) (http.File, error) { + // get extension identity + identity, path, err := resolvePath(name, "/fs/extensions") + if err != nil { + return nil, err + } + extension, err := e.downloader.GetExtension(identity) + if err != nil { + return nil, os.ErrNotExist + } + extensionPath := e.downloader.ExtensionPath(extension) + return os.Open(filepath.Join(extensionPath, path)) +} diff --git a/pkg/rest/server_test.go b/pkg/api/http_test.go similarity index 90% rename from pkg/rest/server_test.go rename to pkg/api/http_test.go index 930d98179..74f2551cc 100644 --- a/pkg/rest/server_test.go +++ b/pkg/api/http_test.go @@ -1,4 +1,4 @@ -package rest +package api import ( "bytes" @@ -7,9 +7,9 @@ import ( "errors" "fmt" "github.com/GopeedLab/gopeed/internal/test" + "github.com/GopeedLab/gopeed/pkg/api/model" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" - "github.com/GopeedLab/gopeed/pkg/rest/model" "io" "net" "net/http" @@ -21,7 +21,8 @@ import ( ) var ( - restPort int + httpInstance *Instance + restPort int taskReq = &base.Request{ Extra: map[string]any{ @@ -90,7 +91,7 @@ func TestCreateTask(t *testing.T) { var wg sync.WaitGroup wg.Add(1) - Downloader.Listener(func(event *download.Event) { + httpInstance.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } @@ -117,7 +118,7 @@ func TestCreateDirectTask(t *testing.T) { doTest(func() { var wg sync.WaitGroup wg.Add(1) - Downloader.Listener(func(event *download.Event) { + httpInstance.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } @@ -156,7 +157,7 @@ func TestCreateDirectTaskWithResoled(t *testing.T) { doTest(func() { var wg sync.WaitGroup wg.Add(1) - Downloader.Listener(func(event *download.Event) { + httpInstance.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } @@ -180,7 +181,7 @@ func TestPauseAndContinueTask(t *testing.T) { doTest(func() { var wg sync.WaitGroup wg.Add(1) - Downloader.Listener(func(event *download.Event) { + httpInstance.Listener(func(event *download.Event) { switch event.Key { case download.EventKeyFinally: wg.Done() @@ -214,10 +215,7 @@ func TestPauseAndContinueTask(t *testing.T) { func TestPauseAllAndContinueALLTasks(t *testing.T) { doTest(func() { - cfg, err := Downloader.GetConfig() - if err != nil { - t.Fatal(err) - } + cfg := UnwrapResult(httpInstance.GetConfig()) createAndPause := func() { taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) @@ -275,7 +273,7 @@ func TestDeleteAllTasks(t *testing.T) { var wg sync.WaitGroup wg.Add(taskCount) - Downloader.Listener(func(event *download.Event) { + httpInstance.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } @@ -301,7 +299,7 @@ func TestDeleteTasksByStatues(t *testing.T) { var wg sync.WaitGroup wg.Add(taskCount) - Downloader.Listener(func(event *download.Event) { + httpInstance.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } @@ -325,7 +323,7 @@ func TestGetTasks(t *testing.T) { doTest(func() { var wg sync.WaitGroup wg.Add(1) - Downloader.Listener(func(event *download.Event) { + httpInstance.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } @@ -354,7 +352,10 @@ func TestGetAndPutConfig(t *testing.T) { cfg := httpRequestCheckOk[*base.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil) cfg.DownloadDir = "./download" cfg.Extra = map[string]any{ - "serverConfig": &Config{ + "serverConfig": &struct { + Host string `json:"host"` + Port int `json:"port"` + }{ Host: "127.0.0.1", Port: 8080, }, @@ -446,16 +447,16 @@ func TestDeleteExtension(t *testing.T) { }) } -func TestUpdateCheckExtension(t *testing.T) { +func TestUpgradeCheckExtension(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) - resp := httpRequestCheckOk[*model.UpdateCheckExtensionResp](http.MethodGet, "/api/v1/extensions/"+identity+"/update", nil) + resp := httpRequestCheckOk[*model.UpgradeCheckExtensionResp](http.MethodGet, "/api/v1/extensions/"+identity+"/upgrade", nil) // no new version if resp.NewVersion != "" { - t.Errorf("UpdateCheckExtension() got = %v, want %v", resp.NewVersion, "") + t.Errorf("UpgradeCheckExtension() got = %v, want %v", resp.NewVersion, "") } // force update - httpRequestCheckOk[any](http.MethodPost, "/api/v1/extensions/"+identity+"/update", nil) + httpRequestCheckOk[any](http.MethodPost, "/api/v1/extensions/"+identity+"/upgrade", nil) }) } @@ -506,13 +507,19 @@ func TestDoProxy(t *testing.T) { func TestApiToken(t *testing.T) { var cfg = &model.StartConfig{} cfg.Init() - cfg.ApiToken = "123456" - fileListener := doStart(cfg) + cfg.DownloadConfig = &base.DownloaderStoreConfig{ + Http: &base.DownloaderHttpConfig{ + Enable: true, + ApiToken: "123456", + }, + } + httpInstance, _ = Create(cfg) + fileListener := doStart() defer func() { if err := fileListener.Close(); err != nil { panic(err) } - Stop() + httpInstance.Close() }() status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) @@ -521,7 +528,7 @@ func TestApiToken(t *testing.T) { } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "X-Api-Token": cfg.ApiToken, + "X-Api-Token": cfg.DownloadConfig.Http.ApiToken, }, nil) if status != http.StatusOK { t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusOK) @@ -532,18 +539,24 @@ func TestApiToken(t *testing.T) { func TestAuthorization(t *testing.T) { var cfg = &model.StartConfig{} cfg.Init() - cfg.ApiToken = "123456" + cfg.DownloadConfig = &base.DownloaderStoreConfig{ + Http: &base.DownloaderHttpConfig{ + Enable: true, + ApiToken: "123456", + }, + } cfg.WebEnable = true cfg.WebBasicAuth = &model.WebBasicAuth{ Username: "admin", Password: "123456", } - fileListener := doStart(cfg) + httpInstance, _ = Create(cfg) + fileListener := doStart() defer func() { if err := fileListener.Close(); err != nil { panic(err) } - Stop() + httpInstance.Close() }() status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) @@ -559,7 +572,7 @@ func TestAuthorization(t *testing.T) { } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "X-Api-Token": cfg.ApiToken, + "X-Api-Token": cfg.DownloadConfig.Http.ApiToken, }, nil) if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) @@ -573,17 +586,23 @@ func doTest(handler func()) { cfg.Storage = storage cfg.StorageDir = ".test_storage" cfg.WebEnable = true - fileListener := doStart(cfg) + cfg.DownloadConfig = &base.DownloaderStoreConfig{ + Http: &base.DownloaderHttpConfig{ + Enable: true, + }, + } + httpInstance, _ = Create(cfg) + fileListener := doStart() defer func() { if err := fileListener.Close(); err != nil { panic(err) } - Stop() - Downloader.Clear() + httpInstance.StopHttp() + httpInstance.Clear() }() defer func() { - Downloader.Pause(nil) - Downloader.Delete(nil, true) + httpInstance.PauseTasks(nil) + httpInstance.DeleteTasks(nil, true) os.RemoveAll(cfg.StorageDir) }() taskReq.URL = "http://" + fileListener.Addr().String() + "/" + test.BuildName @@ -593,15 +612,19 @@ func doTest(handler func()) { testFunc(model.StorageBolt) } -func doStart(cfg *model.StartConfig) net.Listener { - port, err := Start(cfg) - if err != nil { - panic(err) - } +func doStart() net.Listener { + port := UnwrapResult(httpInstance.StartHttp()) restPort = port return test.StartTestFileServer() } +func UnwrapResult[T any](result *model.Result[T]) T { + if result.HasError() { + panic(result.Msg) + } + return result.Data +} + func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) { var reader io.Reader if body != nil { diff --git a/pkg/rest/model/server.go b/pkg/api/model/api.go similarity index 80% rename from pkg/rest/model/server.go rename to pkg/api/model/api.go index 459349095..faa7fec9f 100644 --- a/pkg/rest/model/server.go +++ b/pkg/api/model/api.go @@ -14,13 +14,10 @@ const ( ) type StartConfig struct { - Network string `json:"network"` - Address string `json:"address"` RefreshInterval int `json:"refreshInterval"` Storage Storage `json:"storage"` StorageDir string `json:"storageDir"` WhiteDownloadDirs []string `json:"whiteDownloadDirs"` - ApiToken string `json:"apiToken"` DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"` ProductionMode bool @@ -31,12 +28,6 @@ type StartConfig struct { } func (cfg *StartConfig) Init() *StartConfig { - if cfg.Network == "" { - cfg.Network = "tcp" - } - if cfg.Address == "" { - cfg.Address = "127.0.0.1:0" - } if cfg.RefreshInterval == 0 { cfg.RefreshInterval = 350 } @@ -59,3 +50,9 @@ func (cfg *WebBasicAuth) Authorization() string { userId := cfg.Username + ":" + cfg.Password return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId)) } + +type HttpListenResult struct { + Host string `json:"host"` + // Port is the http server real listen port. + Port int `json:"port"` +} diff --git a/pkg/rest/model/extension.go b/pkg/api/model/extension.go similarity index 88% rename from pkg/rest/model/extension.go rename to pkg/api/model/extension.go index 2c67e5678..46d686358 100644 --- a/pkg/rest/model/extension.go +++ b/pkg/api/model/extension.go @@ -13,6 +13,6 @@ type SwitchExtension struct { Status bool `json:"status"` } -type UpdateCheckExtensionResp struct { +type UpgradeCheckExtensionResp struct { NewVersion string `json:"newVersion"` } diff --git a/pkg/rest/model/result.go b/pkg/api/model/result.go similarity index 76% rename from pkg/rest/model/result.go rename to pkg/api/model/result.go index ae3ca22df..af4525be6 100644 --- a/pkg/rest/model/result.go +++ b/pkg/api/model/result.go @@ -1,5 +1,7 @@ package model +import "encoding/json" + type RespCode int const ( @@ -20,6 +22,15 @@ type Result[T any] struct { Data T `json:"data"` } +func (r *Result[T]) Json() string { + buf, _ := json.Marshal(r) + return string(buf) +} + +func (r *Result[T]) HasError() bool { + return r.Code != CodeOk +} + func NewOkResult[T any](data T) *Result[T] { return &Result[T]{ Code: CodeOk, @@ -33,14 +44,14 @@ func NewNilResult() *Result[any] { } } -func NewErrorResult(msg string, code ...RespCode) *Result[any] { +func NewErrorResult[T any](msg string, code ...RespCode) *Result[T] { // if code is not provided, the default code is CodeError c := CodeError if len(code) > 0 { c = code[0] } - return &Result[any]{ + return &Result[T]{ Code: c, Msg: msg, } diff --git a/pkg/rest/model/task.go b/pkg/api/model/task.go similarity index 100% rename from pkg/rest/model/task.go rename to pkg/api/model/task.go diff --git a/pkg/base/model.go b/pkg/base/model.go index da5cefd86..fd46cfaed 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -171,6 +171,7 @@ type DownloaderStoreConfig struct { MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol Extra map[string]any `json:"extra"` + Http *DownloaderHttpConfig `json:"http"` Proxy *DownloaderProxyConfig `json:"proxy"` } @@ -181,6 +182,12 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig { if cfg.ProtocolConfig == nil { cfg.ProtocolConfig = make(map[string]any) } + if cfg.Extra == nil { + cfg.Extra = make(map[string]any) + } + if cfg.Http == nil { + cfg.Http = &DownloaderHttpConfig{} + } if cfg.Proxy == nil { cfg.Proxy = &DownloaderProxyConfig{} } @@ -203,12 +210,28 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl if cfg.Extra == nil { cfg.Extra = beforeCfg.Extra } + if cfg.Http == nil { + cfg.Http = beforeCfg.Http + } if cfg.Proxy == nil { cfg.Proxy = beforeCfg.Proxy } return cfg } +type DownloaderHttpConfig struct { + // Enable is the flag that enable http server + Enable bool `json:"Enable"` + // Host is the http server listen address. + Host string `json:"host"` + // Port is the http server listen port. + Port int `json:"port"` + // ApiToken is the auth token for http API + ApiToken string `json:"apiToken"` + // RunningPort is the http server real listen port. + RunningPort int `json:"runningPort"` +} + type DownloaderProxyConfig struct { Enable bool `json:"enable"` // System is the flag that use system proxy diff --git a/pkg/base/model_test.go b/pkg/base/model_test.go index f218162ea..7fe30b348 100644 --- a/pkg/base/model_test.go +++ b/pkg/base/model_test.go @@ -17,6 +17,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { &DownloaderStoreConfig{ MaxRunning: 5, ProtocolConfig: map[string]any{}, + Extra: make(map[string]any), + Http: &DownloaderHttpConfig{}, Proxy: &DownloaderProxyConfig{}, }, }, @@ -28,6 +30,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { &DownloaderStoreConfig{ MaxRunning: 10, ProtocolConfig: map[string]any{}, + Extra: make(map[string]any), + Http: &DownloaderHttpConfig{}, Proxy: &DownloaderProxyConfig{}, }, }, @@ -43,6 +47,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { ProtocolConfig: map[string]any{ "key": "value", }, + Extra: make(map[string]any), + Http: &DownloaderHttpConfig{}, Proxy: &DownloaderProxyConfig{}, }, }, @@ -56,6 +62,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { &DownloaderStoreConfig{ MaxRunning: 5, ProtocolConfig: map[string]any{}, + Extra: make(map[string]any), + Http: &DownloaderHttpConfig{}, Proxy: &DownloaderProxyConfig{ Enable: true, }, diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 7165027e5..e4c3c45f6 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -252,7 +252,7 @@ func (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fet ctl.GetConfig = func(v any) { d.getProtocolConfig(fm.Name(), v) } - // Get proxy config, task request proxy config has higher priority, then use global proxy config + // get proxy config, task request proxy config has higher priority, then use global proxy config ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) { if requestProxy == nil { return d.cfg.Proxy.ToHandler() @@ -390,7 +390,7 @@ func (d *Downloader) Pause(filter *TaskFilter) (err error) { return d.pauseAll() } - filter.NotStatuses = []base.Status{base.DownloadStatusPause, base.DownloadStatusError, base.DownloadStatusDone} + filter.NotStatus = []base.Status{base.DownloadStatusPause, base.DownloadStatusError, base.DownloadStatusDone} pauseTasks := d.GetTasksByFilter(filter) if len(pauseTasks) == 0 { return ErrTaskNotFound @@ -430,7 +430,7 @@ func (d *Downloader) Continue(filter *TaskFilter) (err error) { return d.continueAll() } - filter.NotStatuses = []base.Status{base.DownloadStatusRunning, base.DownloadStatusDone} + filter.NotStatus = []base.Status{base.DownloadStatusRunning, base.DownloadStatusDone} continueTasks := d.GetTasksByFilter(filter) if len(continueTasks) == 0 { return ErrTaskNotFound @@ -527,7 +527,7 @@ func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) { func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) { if filter == nil || filter.IsEmpty() { - return d.deleteAll() + return d.deleteAll(force) } deleteTasks := d.GetTasksByFilter(filter) @@ -572,24 +572,27 @@ func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) { return } -func (d *Downloader) deleteAll() (err error) { +func (d *Downloader) deleteAll(force bool) (err error) { + deleteTasksPtr := make([]*Task, 0) + func() { d.lock.Lock() defer d.lock.Unlock() + deleteTasksPtr = append(deleteTasksPtr, d.tasks...) d.tasks = make([]*Task, 0) d.waitTasks = make([]*Task, 0) }() - for _, task := range d.tasks { - if err = d.doDelete(task, true); err != nil { + for _, task := range deleteTasksPtr { + if err = d.doDelete(task, force); err != nil { return } } return } -func (d *Downloader) Stats(id string) (sr any, err error) { +func (d *Downloader) GetTaskStats(id string) (sr any, err error) { task := d.GetTask(id) if task == nil { return sr, ErrTaskNotFound @@ -719,10 +722,10 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task { } idMatch := func(task *Task) bool { - if len(filter.IDs) == 0 { + if len(filter.ID) == 0 { return true } - for _, id := range filter.IDs { + for _, id := range filter.ID { if task.ID == id { return true } @@ -730,10 +733,10 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task { return false } statusMatch := func(task *Task) bool { - if len(filter.Statuses) == 0 { + if len(filter.Status) == 0 { return true } - for _, status := range filter.Statuses { + for _, status := range filter.Status { if task.Status == status { return true } @@ -741,10 +744,10 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task { return false } notStatusMatch := func(task *Task) bool { - if len(filter.NotStatuses) == 0 { + if len(filter.NotStatus) == 0 { return true } - for _, status := range filter.NotStatuses { + for _, status := range filter.NotStatus { if task.Status == status { return false } diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index a7cfd10c9..710da432d 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -400,7 +400,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) { t.Fatal(err) } time.Sleep(time.Millisecond * 1001) - err = downloader.Pause(&TaskFilter{IDs: []string{id}}) + err = downloader.Pause(&TaskFilter{ID: []string{id}}) if err != nil { t.Fatal(err) } @@ -424,7 +424,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) { wg.Done() } }) - err = downloader.Continue(&TaskFilter{IDs: []string{id}}) + err = downloader.Continue(&TaskFilter{ID: []string{id}}) wg.Wait() if err != nil { t.Fatal(err) @@ -551,7 +551,7 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter ids", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - IDs: taskIds, + ID: taskIds, }) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), len(reqs)) @@ -560,7 +560,7 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter match ids", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - IDs: []string{taskIds[0]}, + ID: []string{taskIds[0]}, }) if len(tasks) != 1 { t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), 1) @@ -569,7 +569,7 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter not match ids", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - IDs: []string{"xxx"}, + ID: []string{"xxx"}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), 0) @@ -578,7 +578,7 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - Statuses: []base.Status{base.DownloadStatusDone}, + Status: []base.Status{base.DownloadStatusDone}, }) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter status task got = %v, want %v", len(tasks), len(reqs)) @@ -587,7 +587,7 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter not match status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - Statuses: []base.Status{base.DownloadStatusError}, + Status: []base.Status{base.DownloadStatusError}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter status task got = %v, want %v", len(tasks), 0) @@ -596,7 +596,7 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter match notStatus", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - NotStatuses: []base.Status{base.DownloadStatusRunning, base.DownloadStatusPause}, + NotStatus: []base.Status{base.DownloadStatusRunning, base.DownloadStatusPause}, }) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter match notStatus task got = %v, want %v", len(tasks), len(reqs)) @@ -605,7 +605,7 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter not match notStatus", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - NotStatuses: []base.Status{base.DownloadStatusDone}, + NotStatus: []base.Status{base.DownloadStatusDone}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter not match notStatus task got = %v, want %v", len(tasks), 0) @@ -614,8 +614,8 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter match ids and status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - IDs: []string{taskIds[0]}, - Statuses: []base.Status{base.DownloadStatusDone}, + ID: []string{taskIds[0]}, + Status: []base.Status{base.DownloadStatusDone}, }) if len(tasks) != 1 { t.Errorf("GetTasksByFilter match ids and status task got = %v, want %v", len(tasks), 1) @@ -624,8 +624,8 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { t.Run("GetTasksByFilter not match ids and status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ - IDs: []string{taskIds[0]}, - Statuses: []base.Status{base.DownloadStatusError}, + ID: []string{taskIds[0]}, + Status: []base.Status{base.DownloadStatusError}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter not match ids and status task got = %v, want %v", len(tasks), 0) diff --git a/pkg/download/extension.go b/pkg/download/extension.go index ced73f2ad..fc3ad215c 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -727,13 +727,13 @@ func NewExtensionTask(download *Downloader, task *Task) *ExtensionTask { func (t *ExtensionTask) Continue() error { return t.download.Continue(&TaskFilter{ - IDs: []string{t.task.ID}, + ID: []string{t.task.ID}, }) } func (t *ExtensionTask) Pause() error { return t.download.Pause(&TaskFilter{ - IDs: []string{t.task.ID}, + ID: []string{t.task.ID}, }) } diff --git a/pkg/download/model.go b/pkg/download/model.go index 085a20f0f..17d374f87 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -75,7 +75,7 @@ func (t *Task) Name() string { return t.Meta.Res.Name } - // Get the name of the first file + // get the name of the first file return t.Meta.Res.Files[0].Name } @@ -113,13 +113,13 @@ func (t *Task) calcSpeed(speedArr []int64, downloaded int64, usedTime float64) i } type TaskFilter struct { - IDs []string - Statuses []base.Status - NotStatuses []base.Status + ID []string `json:"id"` + Status []base.Status `json:"status"` + NotStatus []base.Status `json:"notStatus"` } func (f *TaskFilter) IsEmpty() bool { - return len(f.IDs) == 0 && len(f.Statuses) == 0 && len(f.NotStatuses) == 0 + return len(f.ID) == 0 && len(f.Status) == 0 && len(f.NotStatus) == 0 } type DownloaderConfig struct { diff --git a/pkg/rest/api.go b/pkg/rest/api.go deleted file mode 100644 index 9e030b349..000000000 --- a/pkg/rest/api.go +++ /dev/null @@ -1,389 +0,0 @@ -package rest - -import ( - "github.com/GopeedLab/gopeed/pkg/base" - "github.com/GopeedLab/gopeed/pkg/download" - "github.com/GopeedLab/gopeed/pkg/rest/model" - "github.com/gorilla/mux" - "io" - "net/http" - "net/url" - "runtime" -) - -func Info(w http.ResponseWriter, r *http.Request) { - info := map[string]any{ - "version": base.Version, - "runtime": runtime.Version(), - "os": runtime.GOOS, - "arch": runtime.GOARCH, - "inDocker": base.InDocker == "true", - } - WriteJson(w, model.NewOkResult(info)) -} - -func Resolve(w http.ResponseWriter, r *http.Request) { - var req base.Request - if ReadJson(r, w, &req) { - rr, err := Downloader.Resolve(&req) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(rr)) - } -} - -func CreateTask(w http.ResponseWriter, r *http.Request) { - var req model.CreateTask - if ReadJson(r, w, &req) { - var ( - taskId string - err error - ) - if req.Rid != "" { - taskId, err = Downloader.Create(req.Rid, req.Opt) - } else if req.Req != nil { - taskId, err = Downloader.CreateDirect(req.Req, req.Opt) - } else { - WriteJson(w, model.NewErrorResult("param invalid: rid or req", model.CodeInvalidParam)) - return - } - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(taskId)) - } -} - -func CreateTaskBatch(w http.ResponseWriter, r *http.Request) { - var req model.CreateTaskBatch - if ReadJson(r, w, &req) { - if len(req.Reqs) == 0 { - WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam)) - return - } - taskIds, err := Downloader.CreateDirectBatch(req.Reqs, req.Opt) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(taskIds)) - } -} - -func PauseTask(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseIdFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Pause(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func PauseTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Pause(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func ContinueTask(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseIdFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Continue(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func ContinueTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - if err := Downloader.Continue(filter); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func DeleteTask(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseIdFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - force := r.FormValue("force") - - if err := Downloader.Delete(filter, force == "true"); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func DeleteTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - force := r.FormValue("force") - - if err := Downloader.Delete(filter, force == "true"); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func GetTask(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - taskId := vars["id"] - if taskId == "" { - WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) - return - } - task := Downloader.GetTask(taskId) - if task == nil { - WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound)) - return - } - WriteJson(w, model.NewOkResult(task)) -} - -func GetTasks(w http.ResponseWriter, r *http.Request) { - filter, errResult := parseFilter(r) - if errResult != nil { - WriteJson(w, errResult) - return - } - - tasks := Downloader.GetTasksByFilter(filter) - WriteJson(w, model.NewOkResult(tasks)) -} - -func GetConfig(w http.ResponseWriter, r *http.Request) { - WriteJson(w, model.NewOkResult(getServerConfig())) -} - -func PutConfig(w http.ResponseWriter, r *http.Request) { - var cfg base.DownloaderStoreConfig - if ReadJson(r, w, &cfg) { - if err := Downloader.PutConfig(&cfg); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - } - WriteJson(w, model.NewNilResult()) -} - -func InstallExtension(w http.ResponseWriter, r *http.Request) { - var req model.InstallExtension - if ReadJson(r, w, &req) { - var ( - installedExt *download.Extension - err error - ) - if req.DevMode { - installedExt, err = Downloader.InstallExtensionByFolder(req.URL, true) - } else { - installedExt, err = Downloader.InstallExtensionByGit(req.URL) - } - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(installedExt.Identity)) - } -} - -func GetExtensions(w http.ResponseWriter, r *http.Request) { - list := Downloader.GetExtensions() - WriteJson(w, model.NewOkResult(list)) -} - -func GetExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - ext, err := Downloader.GetExtension(identity) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(ext)) -} - -func UpdateExtensionSettings(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - var req model.UpdateExtensionSettings - if ReadJson(r, w, &req) { - if err := Downloader.UpdateExtensionSettings(identity, req.Settings); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - } - WriteJson(w, model.NewNilResult()) -} - -func SwitchExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - var switchExtension model.SwitchExtension - if ReadJson(r, w, &switchExtension) { - if err := Downloader.SwitchExtension(identity, switchExtension.Status); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - } - WriteJson(w, model.NewNilResult()) -} - -func DeleteExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - if err := Downloader.DeleteExtension(identity); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func UpdateCheckExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - newVersion, err := Downloader.UpgradeCheckExtension(identity) - if err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewOkResult(&model.UpdateCheckExtensionResp{ - NewVersion: newVersion, - })) -} - -func UpdateExtension(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - identity := vars["identity"] - if err := Downloader.UpgradeExtension(identity); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return - } - WriteJson(w, model.NewNilResult()) -} - -func DoProxy(w http.ResponseWriter, r *http.Request) { - target := r.Header.Get("X-Target-Uri") - if target == "" { - writeError(w, "param invalid: X-Target-Uri") - return - } - targetUrl, err := url.Parse(target) - if err != nil { - writeError(w, err.Error()) - return - } - r.RequestURI = "" - r.URL = targetUrl - r.Host = targetUrl.Host - resp, err := http.DefaultClient.Do(r) - if err != nil { - writeError(w, err.Error()) - return - } - defer resp.Body.Close() - for k, vv := range resp.Header { - for _, v := range vv { - w.Header().Set(k, v) - } - } - w.WriteHeader(resp.StatusCode) - buf, err := io.ReadAll(resp.Body) - if err != nil { - writeError(w, err.Error()) - return - } - w.Write(buf) -} - -func GetStats(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - taskId := vars["id"] - if taskId == "" { - WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) - return - } - statsResult, err := Downloader.Stats(taskId) - if err != nil { - writeError(w, err.Error()) - return - } - WriteJson(w, model.NewOkResult(statsResult)) -} - -func parseIdFilter(r *http.Request) (*download.TaskFilter, any) { - vars := mux.Vars(r) - taskId := vars["id"] - if taskId == "" { - return nil, model.NewErrorResult("param invalid: id", model.CodeInvalidParam) - } - - filter := &download.TaskFilter{ - IDs: []string{taskId}, - } - return filter, nil -} - -func parseFilter(r *http.Request) (*download.TaskFilter, any) { - if err := r.ParseForm(); err != nil { - return nil, model.NewErrorResult(err.Error()) - } - - filter := &download.TaskFilter{ - IDs: r.Form["id"], - Statuses: convertStatues(r.Form["status"]), - NotStatuses: convertStatues(r.Form["notStatus"]), - } - return filter, nil -} - -func convertStatues(statues []string) []base.Status { - result := make([]base.Status, 0) - for _, status := range statues { - result = append(result, base.Status(status)) - } - return result -} - -func writeError(w http.ResponseWriter, msg string) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(msg)) -} - -func getServerConfig() *base.DownloaderStoreConfig { - cfg, _ := Downloader.GetConfig() - return cfg -} diff --git a/pkg/rest/config.go b/pkg/rest/config.go deleted file mode 100644 index fddff6b7c..000000000 --- a/pkg/rest/config.go +++ /dev/null @@ -1,6 +0,0 @@ -package rest - -type Config struct { - Host string `json:"host"` - Port int `json:"port"` -} diff --git a/pkg/rest/server.go b/pkg/rest/server.go deleted file mode 100644 index 9c0836d46..000000000 --- a/pkg/rest/server.go +++ /dev/null @@ -1,241 +0,0 @@ -package rest - -import ( - "context" - "encoding/json" - "fmt" - "github.com/GopeedLab/gopeed/pkg/download" - "github.com/GopeedLab/gopeed/pkg/rest/model" - "github.com/GopeedLab/gopeed/pkg/util" - "github.com/gorilla/handlers" - "github.com/gorilla/mux" - "github.com/pkg/errors" - "net" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" -) - -var ( - srv *http.Server - runningPort int - - Downloader *download.Downloader -) - -func Start(startCfg *model.StartConfig) (port int, err error) { - // avoid repeat start - if srv != nil { - return runningPort, nil - } - - var listener net.Listener - srv, listener, err = BuildServer(startCfg) - if err != nil { - return - } - - go func() { - if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - panic(err) - } - }() - - if addr, ok := listener.Addr().(*net.TCPAddr); ok { - port = addr.Port - runningPort = port - } - return -} - -func Stop() { - defer func() { - srv = nil - }() - - if srv != nil { - if err := srv.Shutdown(context.TODO()); err != nil { - Downloader.Logger.Warn().Err(err).Msg("shutdown server failed") - } - } - if Downloader != nil { - if err := Downloader.Close(); err != nil { - Downloader.Logger.Warn().Err(err).Msg("close downloader failed") - } - } -} - -func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error) { - if startCfg == nil { - startCfg = &model.StartConfig{} - } - startCfg.Init() - - downloadCfg := &download.DownloaderConfig{ - ProductionMode: startCfg.ProductionMode, - RefreshInterval: startCfg.RefreshInterval, - } - if startCfg.Storage == model.StorageBolt { - downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir) - } else { - downloadCfg.Storage = download.NewMemStorage() - } - downloadCfg.StorageDir = startCfg.StorageDir - downloadCfg.Init() - Downloader = download.NewDownloader(downloadCfg) - if err := Downloader.Setup(); err != nil { - return nil, nil, err - } - - if startCfg.Network == "unix" { - util.SafeRemove(startCfg.Address) - } - - listener, err := net.Listen(startCfg.Network, startCfg.Address) - if err != nil { - return nil, nil, err - } - - var r = mux.NewRouter() - r.Methods(http.MethodGet).Path("/api/v1/info").HandlerFunc(Info) - r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve) - r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask) - r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch) - r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask) - r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks) - r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask) - r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueTasks) - r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask) - r.Methods(http.MethodDelete).Path("/api/v1/tasks").HandlerFunc(DeleteTasks) - r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask) - r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks) - r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}/stats").HandlerFunc(GetStats) - r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig) - r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig) - r.Methods(http.MethodPost).Path("/api/v1/extensions").HandlerFunc(InstallExtension) - r.Methods(http.MethodGet).Path("/api/v1/extensions").HandlerFunc(GetExtensions) - r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}").HandlerFunc(GetExtension) - r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/settings").HandlerFunc(UpdateExtensionSettings) - r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/switch").HandlerFunc(SwitchExtension) - r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension) - r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension) - r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension) - r.Path("/api/v1/proxy").HandlerFunc(DoProxy) - if startCfg.WebEnable { - r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem))) - r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem))) - r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS))) - } - - if startCfg.ApiToken != "" || (startCfg.WebEnable && startCfg.WebBasicAuth != nil) { - r.Use(func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if startCfg.ApiToken != "" && r.Header.Get("X-Api-Token") == startCfg.ApiToken { - h.ServeHTTP(w, r) - return - } - if startCfg.WebEnable && startCfg.WebBasicAuth != nil { - if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() { - h.ServeHTTP(w, r) - return - } - w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"") - } - WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) - }) - }) - } - - // recover panic - r.Use(func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if v := recover(); v != nil { - err := errors.WithStack(fmt.Errorf("%v", v)) - Downloader.Logger.Error().Stack().Err(err).Msgf("http server panic: %s %s", r.Method, r.RequestURI) - WriteJson(w, model.NewErrorResult(err.Error(), model.CodeError)) - } - }() - h.ServeHTTP(w, r) - }) - }) - - srv = &http.Server{Handler: handlers.CORS( - 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 resolvePath(urlPath string, prefix string) (identity string, path string, err error) { - // remove prefix - clearPath := strings.TrimPrefix(urlPath, prefix) - // match extension identity, eg: /fs/extensions/identity/xxx - reg := regexp.MustCompile(`^/([^/]+)/(.*)$`) - if !reg.MatchString(clearPath) { - err = os.ErrNotExist - return - } - matched := reg.FindStringSubmatch(clearPath) - if len(matched) != 3 { - err = os.ErrNotExist - return - } - return matched[1], matched[2], nil -} - -// handle task file resource -type taskFileSystem struct { -} - -func (e *taskFileSystem) Open(name string) (http.File, error) { - // get extension identity - identity, path, err := resolvePath(name, "/fs/tasks") - if err != nil { - return nil, err - } - task := Downloader.GetTask(identity) - if task == nil { - return nil, os.ErrNotExist - } - return os.Open(filepath.Join(task.Meta.RootDirPath(), path)) -} - -// handle extension file resource -type extensionFileSystem struct { -} - -func (e *extensionFileSystem) Open(name string) (http.File, error) { - // get extension identity - identity, path, err := resolvePath(name, "/fs/extensions") - if err != nil { - return nil, err - } - extension, err := Downloader.GetExtension(identity) - if err != nil { - return nil, os.ErrNotExist - } - extensionPath := Downloader.ExtensionPath(extension) - return os.Open(filepath.Join(extensionPath, path)) -} - -func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool { - if err := json.NewDecoder(r.Body).Decode(v); err != nil { - WriteJson(w, model.NewErrorResult(err.Error())) - return false - } - return true -} - -func WriteJson(w http.ResponseWriter, v any) { - WriteStatusJson(w, http.StatusOK, v) -} - -func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(statusCode) - json.NewEncoder(w).Encode(v) -} diff --git a/ui/flutter/include/libgopeed.h b/ui/flutter/include/libgopeed.h index 766cf7e67..85fdf8d04 100644 --- a/ui/flutter/include/libgopeed.h +++ b/ui/flutter/include/libgopeed.h @@ -74,14 +74,8 @@ typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; extern "C" { #endif - -/* Return type for Start */ -struct Start_return { - GoInt r0; - char* r1; -}; -extern struct Start_return Start(char* cfg); -extern void Stop(); +extern __declspec(dllexport) char* Init(char* cfg); +extern __declspec(dllexport) char* Invoke(char* req); #ifdef __cplusplus } diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart deleted file mode 100644 index a30408204..000000000 --- a/ui/flutter/lib/api/api.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; -import 'package:flutter/foundation.dart'; -import 'package:gopeed/api/model/extension.dart'; -import 'package:gopeed/api/model/install_extension.dart'; -import 'package:gopeed/api/model/switch_extension.dart'; - -import '../util/util.dart'; -import 'model/create_task.dart'; -import 'model/downloader_config.dart'; -import 'model/request.dart'; -import 'model/resolve_result.dart'; -import 'model/result.dart'; -import 'model/task.dart'; -import 'model/update_extension_settings.dart'; -import 'model/update_check_extension_resp.dart'; - -class _Client { - static _Client? _instance; - - late Dio dio; - - _Client._internal(); - - factory _Client(String network, String address, String apiToken) { - if (_instance == null) { - _instance = _Client._internal(); - var dio = Dio(); - 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.options.contentType = Headers.jsonContentType; - dio.options.sendTimeout = const Duration(seconds: 5); - dio.options.connectTimeout = const Duration(seconds: 5); - dio.options.receiveTimeout = const Duration(seconds: 60); - 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 IOHttpClientAdapter) - .createHttpClient = () { - final client = HttpClient(); - client.connectionFactory = - (Uri uri, String? proxyHost, int? proxyPort) { - return Socket.startConnect( - InternetAddress(address, type: InternetAddressType.unix), 0); - }; - return client; - }; - } - } - return _instance!; - } -} - -class TimeoutException implements Exception { - final String message; - - TimeoutException(this.message); -} - -late _Client _client; - -void init(String network, String address, String apiToken) { - _client = _Client(network, address, apiToken); -} - -Future _parse( - Future Function() fetch, - T Function(dynamic json)? fromJsonT, -) async { - try { - var resp = await fetch(); - fromJsonT ??= (json) => null as T; - final result = Result.fromJson(resp.data, fromJsonT); - if (result.code == 0) { - return result.data as T; - } else { - throw Exception(result); - } - } on DioException catch (e) { - if (e.type == DioExceptionType.sendTimeout || - e.type == DioExceptionType.receiveTimeout || - e.type == DioExceptionType.connectionTimeout) { - throw TimeoutException("request timeout"); - } - throw Exception(Result(code: 1000, msg: e.message)); - } -} - -Future resolve(Request request) async { - return _parse( - () => _client.dio.post("/api/v1/resolve", data: request), - (data) => ResolveResult.fromJson(data)); -} - -Future createTask(CreateTask createTask) async { - return _parse( - () => _client.dio.post("/api/v1/tasks", data: createTask), - (data) => data as String); -} - -Future> getTasks(List statuses) async { - return _parse>( - () => _client.dio.get( - "/api/v1/tasks?${statuses.map((e) => "status=${e.name}").join("&")}"), - (data) => (data as List).map((e) => Task.fromJson(e)).toList()); -} - -Future pauseTask(String id) async { - return _parse(() => _client.dio.put("/api/v1/tasks/$id/pause"), null); -} - -Future continueTask(String id) async { - return _parse(() => _client.dio.put("/api/v1/tasks/$id/continue"), null); -} - -Future pauseAllTasks() async { - return _parse(() => _client.dio.put("/api/v1/tasks/pause"), null); -} - -Future continueAllTasks() async { - return _parse(() => _client.dio.put("/api/v1/tasks/continue"), null); -} - -Future deleteTask(String id, bool force) async { - return _parse( - () => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null); -} - -Future getConfig() async { - return _parse(() => _client.dio.get("/api/v1/config"), - (data) => DownloaderConfig.fromJson(data)); -} - -Future putConfig(DownloaderConfig config) async { - return _parse(() => _client.dio.put("/api/v1/config", data: config), null); -} - -Future installExtension(InstallExtension installExtension) async { - return _parse( - () => _client.dio.post("/api/v1/extensions", data: installExtension), - null); -} - -Future> getExtensions() async { - return _parse>(() => _client.dio.get("/api/v1/extensions"), - (data) => (data as List).map((e) => Extension.fromJson(e)).toList()); -} - -Future updateExtensionSettings( - String identity, UpdateExtensionSettings updateExtensionSettings) async { - return _parse( - () => _client.dio.put("/api/v1/extensions/$identity/settings", - data: updateExtensionSettings), - null); -} - -Future switchExtension( - String identity, SwitchExtension switchExtension) async { - return _parse( - () => _client.dio - .put("/api/v1/extensions/$identity/switch", data: switchExtension), - null); -} - -Future deleteExtension(String identity) async { - return _parse(() => _client.dio.delete("/api/v1/extensions/$identity"), null); -} - -Future upgradeCheckExtension(String identity) async { - return _parse(() => _client.dio.get("/api/v1/extensions/$identity/update"), - (data) => UpdateCheckExtensionResp.fromJson(data)); -} - -Future updateExtension(String identity) async { - return _parse( - () => _client.dio.post("/api/v1/extensions/$identity/update"), null); -} - -Future> proxyRequest(String uri, - {data, Options? options}) async { - options ??= Options(); - options.headers ??= {}; - options.headers!["X-Target-Uri"] = uri; - - // add timestamp to avoid cache - return _client.dio.request( - "/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}", - data: data, - options: options); -} - -String join(String path) { - return "${_client.dio.options.baseUrl}/${Util.cleanPath(path)}"; -} diff --git a/ui/flutter/lib/api/api_exception.dart b/ui/flutter/lib/api/api_exception.dart new file mode 100644 index 000000000..522d31e42 --- /dev/null +++ b/ui/flutter/lib/api/api_exception.dart @@ -0,0 +1,6 @@ +class ApiException implements Exception { + final int code; + final String message; + + ApiException(this.code, this.message); +} diff --git a/ui/flutter/lib/api/entry/libgopeed_boot_base.dart b/ui/flutter/lib/api/entry/libgopeed_boot_base.dart new file mode 100644 index 000000000..3f3570313 --- /dev/null +++ b/ui/flutter/lib/api/entry/libgopeed_boot_base.dart @@ -0,0 +1,25 @@ +import 'package:dio/dio.dart'; + +import '../api_exception.dart'; +import '../model/result.dart'; + +mixin LibgopeedBootBase { + Dio createDio() { + Dio dio = Dio(); + dio.options.contentType = Headers.jsonContentType; + dio.options.sendTimeout = const Duration(seconds: 5); + dio.options.connectTimeout = const Duration(seconds: 5); + dio.options.receiveTimeout = const Duration(seconds: 60); + return dio; + } + + T handleResult( + Result result, + ) { + if (result.code == 0) { + return result.data as T; + } else { + throw ApiException(result.code, result.msg ?? ''); + } + } +} diff --git a/ui/flutter/lib/api/entry/libgopeed_boot_native.dart b/ui/flutter/lib/api/entry/libgopeed_boot_native.dart new file mode 100644 index 000000000..fa693d800 --- /dev/null +++ b/ui/flutter/lib/api/entry/libgopeed_boot_native.dart @@ -0,0 +1,239 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; + +import 'package:dio/dio.dart'; + +import '../../api/model/create_task.dart'; +import '../../api/model/create_task_batch.dart'; +import '../../api/model/downloader_config.dart'; +import '../../api/model/extension.dart'; +import '../../api/model/http_listen_result.dart'; +import '../../api/model/info.dart'; +import '../../api/model/install_extension.dart'; +import '../../api/model/request.dart'; +import '../../api/model/resolve_result.dart'; +import '../../api/model/result.dart'; +import '../../api/model/switch_extension.dart'; +import '../../api/model/task.dart'; +import '../../api/model/task_filter.dart'; +import '../../api/model/update_check_extension_resp.dart'; +import '../../api/model/update_extension_settings.dart'; +import '../../util/extensions.dart'; +import '../../util/util.dart'; +import '../libgopeed_boot.dart'; +import '../native/channel/libgopeed_channel.dart'; +import '../native/ffi/libgopeed_bind.dart'; +import '../native/ffi/libgopeed_ffi.dart'; +import '../native/libgopeed_interface.dart'; +import '../native/model/invoke_request.dart'; +import '../native/model/start_config.dart'; +import 'libgopeed_boot_base.dart'; + +LibgopeedBoot create() => LibgopeedBootNative(); + +class LibgopeedBootNative + with LibgopeedBootBase + implements LibgopeedBoot, LibgopeedApi { + late LibgopeedAbi _libgopeedAbi; + late Dio _dio; + + LibgopeedBootNative() { + if (Util.isDesktop()) { + var libName = "libgopeed."; + if (Platform.isWindows) { + libName += "dll"; + } + if (Platform.isMacOS) { + libName += "dylib"; + } + if (Platform.isLinux) { + libName += "so"; + } + _libgopeedAbi = LibgopeedFFi(LibgopeedBind(DynamicLibrary.open(libName))); + } else { + _libgopeedAbi = LibgopeedChannel(); + } + } + + Future _invoke( + String method, + List params, + T Function(dynamic json)? fromJsonT, + ) async { + final invokeRequest = InvokeRequest(method: method, params: params); + final resp = await _libgopeedAbi.invoke(jsonEncode(invokeRequest)); + final result = + Result.fromJson(jsonDecode(resp), fromJsonT ??= (json) => null as T); + return handleResult(result); + } + + List _parseTaskFilter(TaskFilter? filter) { + if (filter == null) { + return [null]; + } + return [filter]; + } + + @override + Future init(StartConfig cfg) async { + await _libgopeedAbi.init(jsonEncode(cfg.toJson())); + _dio = createDio(); + return this; + } + + @override + Future startHttp() async { + return _invoke( + "StartHttp", [], (json) => HttpListenResult.fromJson(json)); + } + + @override + Future stopHttp() async { + await _invoke("StopHttp", [], null); + } + + @override + Future restartHttp() async { + await _invoke("RestartHttp", [], null); + } + + @override + Future info() async { + return _invoke("Info", [], (json) => Info.fromJson(json)); + } + + @override + Future resolve(Request request) async { + return _invoke( + "Resolve", [request], (json) => ResolveResult.fromJson(json)); + } + + @override + Future createTask(CreateTask createTask) async { + return _invoke( + "CreateTask", [createTask], (json) => json as String); + } + + @override + Future createTaskBatch(CreateTaskBatch createTask) async { + return _invoke( + "CreateTaskBatch", [createTask], (json) => json as String); + } + + @override + Future pauseTask(String id) async { + await _invoke("PauseTask", [id], null); + } + + @override + Future pauseTasks(TaskFilter? filter) async { + await _invoke("PauseTasks", _parseTaskFilter(filter), null); + } + + @override + Future continueTask(String id) async { + await _invoke("ContinueTask", [id], null); + } + + @override + Future continueTasks(TaskFilter? filter) async { + await _invoke("ContinueTasks", _parseTaskFilter(filter), null); + } + + @override + Future deleteTask(String id, bool force) async { + await _invoke("DeleteTask", [id, force], null); + } + + @override + Future deleteTasks(TaskFilter? filter, bool force) async { + await _invoke( + "DeleteTasks", + _parseTaskFilter(filter).apply((it) { + it.add(force); + }), + null); + } + + @override + Future getTask(String id) async { + return _invoke("GetTask", [id], (json) => Task.fromJson(json)); + } + + @override + Future> getTasks(TaskFilter? filter) async { + return _invoke>("GetTasks", _parseTaskFilter(filter), + (json) => (json as List).map((e) => Task.fromJson(e)).toList()); + } + + @override + Future> getTaskStats(String id) async { + return _invoke>( + "GetTaskStats", [id], (json) => json as Map); + } + + @override + Future getConfig() async { + return _invoke( + "GetConfig", [], (json) => DownloaderConfig.fromJson(json)); + } + + @override + Future putConfig(DownloaderConfig config) async { + await _invoke("PutConfig", [config], null); + } + + @override + Future installExtension(InstallExtension installExtension) async { + await _invoke("InstallExtension", [installExtension], null); + } + + @override + Future> getExtensions() async { + return _invoke>("GetExtensions", [], + (json) => (json as List).map((e) => Extension.fromJson(e)).toList()); + } + + @override + Future updateExtensionSettings( + String identity, UpdateExtensionSettings updateExtensionSettings) async { + await _invoke( + "UpdateExtensionSettings", [identity, updateExtensionSettings], null); + } + + @override + Future switchExtension( + String identity, SwitchExtension switchExtension) async { + await _invoke("SwitchExtension", [identity, switchExtension], null); + } + + @override + Future deleteExtension(String identity) async { + await _invoke("DeleteExtension", [identity], null); + } + + @override + Future upgradeCheckExtension( + String identity) async { + return _invoke("UpgradeCheckExtension", + [identity], (json) => UpdateCheckExtensionResp.fromJson(json)); + } + + @override + Future upgradeExtension(String identity) async { + await _invoke("UpgradeExtension", [identity], null); + } + + @override + Future close() async { + await _invoke("Close", [], null); + } + + @override + Future> proxyRequest(String uri, + {data, Options? options}) { + return _dio.request(uri, data: data, options: options); + } +} diff --git a/ui/flutter/lib/api/entry/libgopeed_boot_web.dart b/ui/flutter/lib/api/entry/libgopeed_boot_web.dart new file mode 100644 index 000000000..9e8814221 --- /dev/null +++ b/ui/flutter/lib/api/entry/libgopeed_boot_web.dart @@ -0,0 +1,277 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import '../../api/model/create_task.dart'; +import '../../api/model/create_task_batch.dart'; +import '../../api/model/downloader_config.dart'; +import '../../api/model/extension.dart'; +import '../../api/model/http_listen_result.dart'; +import '../../api/model/info.dart'; +import '../../api/model/install_extension.dart'; +import '../../api/model/request.dart'; +import '../../api/model/resolve_result.dart'; +import '../../api/model/result.dart'; +import '../../api/model/switch_extension.dart'; +import '../../api/model/task.dart'; +import '../../api/model/task_filter.dart'; +import '../../api/model/update_check_extension_resp.dart'; +import '../../api/model/update_extension_settings.dart'; +import '../../util/extensions.dart'; +import '../../util/util.dart'; +import '../api_exception.dart'; +import '../libgopeed_boot.dart'; +import '../native/libgopeed_interface.dart'; +import '../native/model/start_config.dart'; +import 'libgopeed_boot_base.dart'; + +LibgopeedBoot create() => LibgopeedBootWeb(); + +class LibgopeedBootWeb + with LibgopeedBootBase + implements LibgopeedBoot, LibgopeedApi { + late Dio _dio; + static const String _apiPrefix = '/api/v1'; + + LibgopeedBootWeb(); + + Future _fetch( + Future Function() fetch, + T Function(dynamic json)? fromJsonT, + ) async { + try { + var resp = await fetch(); + fromJsonT ??= (json) => null as T; + final result = Result.fromJson(resp.data, fromJsonT); + return handleResult(result); + } on DioException catch (e) { + if (e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.connectionTimeout) { + throw ApiException(1000, 'request timeout'); + } + throw ApiException(1000, e.message ?? ""); + } + } + + Map? _parseTaskFilter(TaskFilter? filter) { + if (filter == null) { + return null; + } + + Map params = {}; + if (filter.id != null) { + params['id'] = filter.id; + } + if (filter.status != null) { + params['status'] = filter.status; + } + if (filter.notStatus != null) { + params['notStatus'] = filter.notStatus; + } + if (params.isEmpty) { + return null; + } + return params; + } + + @override + Future init(StartConfig cfg) async { + _dio = createDio(); + _dio.options.baseUrl = kDebugMode ? 'http://127.0.0.1:9999' : ''; + return this; + } + + @override + Future startHttp() async { + throw UnimplementedError(); + } + + @override + Future stopHttp() async { + throw UnimplementedError(); + } + + @override + Future restartHttp() async { + throw UnimplementedError(); + } + + @override + Future info() { + return _fetch( + () => _dio.get("$_apiPrefix/info"), (data) => Info.fromJson(data)); + } + + @override + Future resolve(Request request) async { + return _fetch( + () => _dio.post("$_apiPrefix/resolve", data: request), + (data) => ResolveResult.fromJson(data)); + } + + @override + Future createTask(CreateTask createTask) async { + return _fetch( + () => _dio.post("$_apiPrefix/tasks", data: createTask), + (data) => data as String); + } + + @override + Future createTaskBatch(CreateTaskBatch createTask) async { + return _fetch( + () => _dio.post("$_apiPrefix/tasks", data: createTask), + (data) => data as String); + } + + @override + Future pauseTask(String id) async { + await _fetch(() => _dio.put("$_apiPrefix/tasks/$id/pause"), null); + } + + @override + Future pauseTasks(TaskFilter? filter) async { + await _fetch( + () => _dio.put("$_apiPrefix/tasks/pause", + queryParameters: _parseTaskFilter(filter)), + null); + } + + @override + Future continueTask(String id) async { + await _fetch(() => _dio.put("$_apiPrefix/tasks/$id/continue"), null); + } + + @override + Future continueTasks(TaskFilter? filter) async { + await _fetch( + () => _dio.put("$_apiPrefix/tasks/continue", + queryParameters: _parseTaskFilter(filter)), + null); + } + + @override + Future deleteTask(String id, bool force) async { + await _fetch( + () => _dio + .delete("$_apiPrefix/tasks/$id", queryParameters: {'force': force}), + null); + } + + @override + Future deleteTasks(TaskFilter? filter, bool force) async { + await _fetch( + () => _dio.delete("$_apiPrefix/tasks", + queryParameters: _parseTaskFilter(filter)?.apply((it) { + it['force'] = force; + })), + null); + } + + @override + Future getTask(String id) async { + return _fetch( + () => _dio.get("$_apiPrefix/tasks/$id"), (data) => Task.fromJson(data)); + } + + @override + Future> getTasks(TaskFilter? filter) async { + return _fetch>( + () => _dio.get("$_apiPrefix/tasks", + queryParameters: _parseTaskFilter(filter)), + (data) => (data as List).map((e) => Task.fromJson(e)).toList()); + } + + @override + Future> getTaskStats(String id) async { + return _fetch>( + () => _dio.get("$_apiPrefix/tasks/$id/stats"), + (data) => data as Map); + } + + @override + Future getConfig() async { + return _fetch(() => _dio.get("$_apiPrefix/config"), + (data) => DownloaderConfig.fromJson(data)); + } + + @override + Future putConfig(DownloaderConfig config) async { + await _fetch( + () => _dio.put("$_apiPrefix/config", data: config), null); + } + + @override + Future installExtension(InstallExtension installExtension) async { + await _fetch( + () => _dio.post("$_apiPrefix/extensions", data: installExtension), + null); + } + + @override + Future> getExtensions() async { + return _fetch>(() => _dio.get("$_apiPrefix/extensions"), + (data) => (data as List).map((e) => Extension.fromJson(e)).toList()); + } + + @override + Future updateExtensionSettings( + String identity, UpdateExtensionSettings updateExtensionSettings) async { + await _fetch( + () => _dio.put("$_apiPrefix/extensions/$identity/settings", + data: updateExtensionSettings), + null); + } + + @override + Future switchExtension( + String identity, SwitchExtension switchExtension) async { + await _fetch( + () => _dio.put("$_apiPrefix/extensions/$identity/switch", + data: switchExtension), + null); + } + + @override + Future deleteExtension(String identity) async { + await _fetch( + () => _dio.delete("$_apiPrefix/extensions/$identity"), null); + } + + @override + Future upgradeCheckExtension( + String identity) async { + return _fetch( + () => _dio.get("$_apiPrefix/extensions/$identity/upgrade"), + (data) => UpdateCheckExtensionResp.fromJson(data)); + } + + @override + Future upgradeExtension(String identity) async { + await _fetch( + () => _dio.post("$_apiPrefix/extensions/$identity/upgrade"), null); + } + + @override + Future close() async { + throw UnimplementedError(); + } + + // To avoid CORS, use a backend proxy to forward the request + @override + Future> proxyRequest(String uri, + {data, Options? options}) async { + options ??= Options(); + options.headers ??= {}; + options.headers!["X-Target-Uri"] = uri; + + // add timestamp to avoid cache + return _dio.request( + "/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}", + data: data, + options: options); + } + + String join(String path) { + return "${_dio.options.baseUrl}/${Util.cleanPath(path)}"; + } +} diff --git a/ui/flutter/lib/api/libgopeed_boot.dart b/ui/flutter/lib/api/libgopeed_boot.dart new file mode 100644 index 000000000..d000c7ba7 --- /dev/null +++ b/ui/flutter/lib/api/libgopeed_boot.dart @@ -0,0 +1,18 @@ +import "libgopeed_boot_stub.dart" + if (dart.library.html) 'entry/libgopeed_boot_browser.dart' + if (dart.library.io) 'entry/libgopeed_boot_native.dart'; +import 'native/libgopeed_interface.dart'; + +abstract class LibgopeedBoot implements LibgopeedApiSingleton { + static LibgopeedApi? _instance; + + static void singleton(LibgopeedApi instance) { + _instance ??= instance; + } + + static LibgopeedApi get instance { + return _instance!; + } + + factory LibgopeedBoot() => create(); +} diff --git a/ui/flutter/lib/core/libgopeed_boot_stub.dart b/ui/flutter/lib/api/libgopeed_boot_stub.dart similarity index 100% rename from ui/flutter/lib/core/libgopeed_boot_stub.dart rename to ui/flutter/lib/api/libgopeed_boot_stub.dart diff --git a/ui/flutter/lib/api/model/create_task_batch.dart b/ui/flutter/lib/api/model/create_task_batch.dart new file mode 100644 index 000000000..e6e5c6b59 --- /dev/null +++ b/ui/flutter/lib/api/model/create_task_batch.dart @@ -0,0 +1,20 @@ +import 'package:gopeed/api/model/extension.dart'; +import 'package:gopeed/api/model/request.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'create_task_batch.g.dart'; + +@JsonSerializable() +class CreateTaskBatch { + List reqs; + Option? opt; + + CreateTaskBatch({ + this.reqs = const [], + this.opt, + }); + + factory CreateTaskBatch.fromJson(Map json) => + _$CreateTaskBatchFromJson(json); + Map toJson() => _$CreateTaskBatchToJson(this); +} diff --git a/ui/flutter/lib/api/model/create_task_batch.g.dart b/ui/flutter/lib/api/model/create_task_batch.g.dart new file mode 100644 index 000000000..20867fe0b --- /dev/null +++ b/ui/flutter/lib/api/model/create_task_batch.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_task_batch.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreateTaskBatch _$CreateTaskBatchFromJson(Map json) => + CreateTaskBatch( + reqs: (json['reqs'] as List?) + ?.map((e) => Request.fromJson(e as Map)) + .toList() ?? + const [], + opt: json['opt'] == null + ? null + : Option.fromJson(json['opt'] as Map), + ); + +Map _$CreateTaskBatchToJson(CreateTaskBatch instance) { + final val = { + 'reqs': instance.reqs, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('opt', instance.opt); + return val; +} diff --git a/ui/flutter/lib/api/model/http_listen_result.dart b/ui/flutter/lib/api/model/http_listen_result.dart new file mode 100644 index 000000000..a875c7fc4 --- /dev/null +++ b/ui/flutter/lib/api/model/http_listen_result.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'http_listen_result.g.dart'; + +@JsonSerializable() +class HttpListenResult { + String host; + int port; + + HttpListenResult({ + this.host = '', + this.port = 0, + }); + + factory HttpListenResult.fromJson(Map json) => + _$HttpListenResultFromJson(json); + Map toJson() => _$HttpListenResultToJson(this); +} diff --git a/ui/flutter/lib/api/model/http_listen_result.g.dart b/ui/flutter/lib/api/model/http_listen_result.g.dart new file mode 100644 index 000000000..db3096a0c --- /dev/null +++ b/ui/flutter/lib/api/model/http_listen_result.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'http_listen_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +HttpListenResult _$HttpListenResultFromJson(Map json) => + HttpListenResult( + host: json['host'] as String? ?? '', + port: (json['port'] as num?)?.toInt() ?? 0, + ); + +Map _$HttpListenResultToJson(HttpListenResult instance) => + { + 'host': instance.host, + 'port': instance.port, + }; diff --git a/ui/flutter/lib/api/model/info.dart b/ui/flutter/lib/api/model/info.dart new file mode 100644 index 000000000..d7c9f0ce6 --- /dev/null +++ b/ui/flutter/lib/api/model/info.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'info.g.dart'; + +@JsonSerializable() +class Info { + String version; + String runtime; + String os; + String arch; + bool inDocker; + + Info({ + this.version = '', + this.runtime = '', + this.os = '', + this.arch = '', + this.inDocker = false, + }); + + factory Info.fromJson(Map json) => _$InfoFromJson(json); + Map toJson() => _$InfoToJson(this); +} diff --git a/ui/flutter/lib/api/model/info.g.dart b/ui/flutter/lib/api/model/info.g.dart new file mode 100644 index 000000000..f14e67e02 --- /dev/null +++ b/ui/flutter/lib/api/model/info.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Info _$InfoFromJson(Map json) => Info( + version: json['version'] as String? ?? '', + runtime: json['runtime'] as String? ?? '', + os: json['os'] as String? ?? '', + arch: json['arch'] as String? ?? '', + inDocker: json['inDocker'] as bool? ?? false, + ); + +Map _$InfoToJson(Info instance) => { + 'version': instance.version, + 'runtime': instance.runtime, + 'os': instance.os, + 'arch': instance.arch, + 'inDocker': instance.inDocker, + }; diff --git a/ui/flutter/lib/api/model/options.g.dart b/ui/flutter/lib/api/model/options.g.dart index b496e9fc6..40a1a8a4e 100644 --- a/ui/flutter/lib/api/model/options.g.dart +++ b/ui/flutter/lib/api/model/options.g.dart @@ -10,7 +10,7 @@ Options _$OptionsFromJson(Map json) => Options( name: json['name'] as String, path: json['path'] as String, selectFiles: (json['selectFiles'] as List?) - ?.map((e) => e as int) + ?.map((e) => (e as num).toInt()) .toList() ?? const [], extra: json['extra'], @@ -35,7 +35,7 @@ Map _$OptionsToJson(Options instance) { OptsExtraHttp _$OptsExtraHttpFromJson(Map json) => OptsExtraHttp() - ..connections = json['connections'] as int + ..connections = (json['connections'] as num).toInt() ..autoTorrent = json['autoTorrent'] as bool; Map _$OptsExtraHttpToJson(OptsExtraHttp instance) => diff --git a/ui/flutter/lib/api/model/result.g.dart b/ui/flutter/lib/api/model/result.g.dart index 452a7c7ca..03d4f734e 100644 --- a/ui/flutter/lib/api/model/result.g.dart +++ b/ui/flutter/lib/api/model/result.g.dart @@ -11,7 +11,7 @@ Result _$ResultFromJson( T Function(Object? json) fromJsonT, ) => Result( - code: json['code'] as int, + code: (json['code'] as num).toInt(), msg: json['msg'] as String?, data: _$nullableGenericFromJson(json['data'], fromJsonT), ); diff --git a/ui/flutter/lib/api/model/task_filter.dart b/ui/flutter/lib/api/model/task_filter.dart new file mode 100644 index 000000000..387af91b9 --- /dev/null +++ b/ui/flutter/lib/api/model/task_filter.dart @@ -0,0 +1,17 @@ +import 'package:gopeed/api/model/task.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'task_filter.g.dart'; + +@JsonSerializable() +class TaskFilter { + List? id; + List? status; + List? notStatus; + + TaskFilter({this.id, this.status, this.notStatus}); + + factory TaskFilter.fromJson(Map json) => + _$TaskFilterFromJson(json); + Map toJson() => _$TaskFilterToJson(this); +} diff --git a/ui/flutter/lib/api/model/task_filter.g.dart b/ui/flutter/lib/api/model/task_filter.g.dart new file mode 100644 index 000000000..b391c82d2 --- /dev/null +++ b/ui/flutter/lib/api/model/task_filter.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'task_filter.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TaskFilter _$TaskFilterFromJson(Map json) => TaskFilter( + id: (json['ids'] as List?)?.map((e) => e as String).toList(), + status: (json['statuses'] as List?) + ?.map((e) => $enumDecode(_$StatusEnumMap, e)) + .toList(), + notStatus: (json['notStatuses'] as List?) + ?.map((e) => $enumDecode(_$StatusEnumMap, e)) + .toList(), + ); + +Map _$TaskFilterToJson(TaskFilter instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('ids', instance.id); + writeNotNull( + 'statuses', instance.status?.map((e) => _$StatusEnumMap[e]!).toList()); + writeNotNull('notStatuses', + instance.notStatus?.map((e) => _$StatusEnumMap[e]!).toList()); + return val; +} + +const _$StatusEnumMap = { + Status.ready: 'ready', + Status.running: 'running', + Status.pause: 'pause', + Status.wait: 'wait', + Status.error: 'error', + Status.done: 'done', +}; diff --git a/ui/flutter/lib/api/native/channel/libgopeed_channel.dart b/ui/flutter/lib/api/native/channel/libgopeed_channel.dart new file mode 100644 index 000000000..934fd3e2a --- /dev/null +++ b/ui/flutter/lib/api/native/channel/libgopeed_channel.dart @@ -0,0 +1,22 @@ +import 'package:flutter/services.dart'; + +import '../libgopeed_interface.dart'; + +class LibgopeedChannel implements LibgopeedAbi { + static const _channel = MethodChannel('gopeed.com/libgopeed'); + + @override + Future init(String cfg) async { + await _channel.invokeMethod('init', { + 'cfg': cfg, + }); + } + + @override + Future invoke(String params) async { + final result = await _channel.invokeMethod('invoke', { + 'params': params, + }); + return result!; + } +} diff --git a/ui/flutter/lib/core/ffi/libgopeed_bind.dart b/ui/flutter/lib/api/native/ffi/libgopeed_bind.dart similarity index 96% rename from ui/flutter/lib/core/ffi/libgopeed_bind.dart rename to ui/flutter/lib/api/native/ffi/libgopeed_bind.dart index 53ddedb7c..4ed93a947 100644 --- a/ui/flutter/lib/core/ffi/libgopeed_bind.dart +++ b/ui/flutter/lib/api/native/ffi/libgopeed_bind.dart @@ -857,27 +857,33 @@ class LibgopeedBind { late final __FCmulcr = __FCmulcrPtr.asFunction<_Fcomplex Function(_Fcomplex, double)>(); - Start_return Start( + ffi.Pointer Init( ffi.Pointer cfg, ) { - return _Start( + return _Init( cfg, ); } - late final _StartPtr = - _lookup)>>( - 'Start'); - late final _Start = - _StartPtr.asFunction)>(); + late final _InitPtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function(ffi.Pointer)>>('Init'); + late final _Init = _InitPtr.asFunction< + ffi.Pointer Function(ffi.Pointer)>(); - void Stop() { - return _Stop(); + ffi.Pointer Invoke( + ffi.Pointer req, + ) { + return _Invoke( + req, + ); } - late final _StopPtr = - _lookup>('Stop'); - late final _Stop = _StopPtr.asFunction(); + late final _InvokePtr = _lookup< + ffi.NativeFunction< + ffi.Pointer Function(ffi.Pointer)>>('Invoke'); + late final _Invoke = _InvokePtr.asFunction< + ffi.Pointer Function(ffi.Pointer)>(); } typedef va_list = ffi.Pointer; @@ -914,6 +920,7 @@ final class _Mbstatet extends ffi.Struct { } typedef errno_t = ffi.Int; +typedef Darterrno_t = int; final class _GoString_ extends ffi.Struct { external ffi.Pointer p; @@ -923,6 +930,7 @@ final class _GoString_ extends ffi.Struct { } typedef ptrdiff_t = ffi.LongLong; +typedef Dartptrdiff_t = int; final class _C_double_complex extends ffi.Struct { @ffi.Array.multi([2]) @@ -957,13 +965,7 @@ final class GoSlice extends ffi.Struct { typedef GoInt = GoInt64; typedef GoInt64 = ffi.LongLong; - -final class Start_return extends ffi.Struct { - @GoInt() - external int r0; - - external ffi.Pointer r1; -} +typedef DartGoInt64 = int; const int _VCRT_COMPILER_PREPROCESSOR = 1; @@ -993,6 +995,10 @@ const int _HAS_CXX23 = 0; const int _HAS_NODISCARD = 1; +const int _ARM_WINAPI_PARTITION_DESKTOP_SDK_AVAILABLE = 1; + +const int _CRT_BUILD_DESKTOP_APP = 1; + const int _UCRT_DISABLED_WARNINGS = 4324; const int _ARGMAX = 100; @@ -1009,9 +1015,7 @@ const int _CRT_FUNCTIONS_REQUIRED = 1; const int _CRT_HAS_CXX17 = 0; -const int _ARM_WINAPI_PARTITION_DESKTOP_SDK_AVAILABLE = 1; - -const int _CRT_BUILD_DESKTOP_APP = 1; +const int _CRT_HAS_C11 = 0; const int _CRT_INTERNAL_NONSTDC_NAMES = 1; diff --git a/ui/flutter/lib/api/native/ffi/libgopeed_ffi.dart b/ui/flutter/lib/api/native/ffi/libgopeed_ffi.dart new file mode 100644 index 000000000..5f5cd0223 --- /dev/null +++ b/ui/flutter/lib/api/native/ffi/libgopeed_ffi.dart @@ -0,0 +1,41 @@ +import 'dart:async'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +import '../libgopeed_interface.dart'; +import 'libgopeed_bind.dart'; + +class LibgopeedFFi implements LibgopeedAbi { + late LibgopeedBind _libgopeed; + + LibgopeedFFi(LibgopeedBind libgopeed) { + _libgopeed = libgopeed; + } + + @override + Future init(String cfg) { + final cfgPtr = cfg.toNativeUtf8(); + try { + final result = _libgopeed.Init(cfgPtr.cast()); + if (result != nullptr) { + throw Exception( + 'Libgopeed init failed: ${result.cast().toDartString()}'); + } + return Future.value(); + } finally { + calloc.free(cfgPtr); + } + } + + @override + Future invoke(String params) { + final paramsPtr = params.toNativeUtf8(); + try { + final result = _libgopeed.Invoke(paramsPtr.cast()); + return Future.value(result.cast().toDartString()); + } finally { + calloc.free(paramsPtr); + } + } +} diff --git a/ui/flutter/lib/api/native/libgopeed_interface.dart b/ui/flutter/lib/api/native/libgopeed_interface.dart new file mode 100644 index 000000000..d58f8200e --- /dev/null +++ b/ui/flutter/lib/api/native/libgopeed_interface.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; + +import '../model/create_task.dart'; +import '../model/create_task_batch.dart'; +import '../model/downloader_config.dart'; +import '../model/extension.dart'; +import '../model/http_listen_result.dart'; +import '../model/info.dart'; +import '../model/install_extension.dart'; +import '../model/request.dart'; +import '../model/resolve_result.dart'; +import '../model/switch_extension.dart'; +import '../model/task.dart'; +import '../model/task_filter.dart'; +import '../model/update_check_extension_resp.dart'; +import '../model/update_extension_settings.dart'; +import 'model/start_config.dart'; + +abstract class LibgopeedAbi { + Future init(String cfg); + Future invoke(String params); +} + +abstract class LibgopeedApiSingleton { + Future init(StartConfig config); +} + +abstract class LibgopeedApi { + Future startHttp(); + + Future stopHttp(); + + Future restartHttp(); + + Future info(); + + Future resolve(Request request); + + Future createTask(CreateTask createTask); + + Future createTaskBatch(CreateTaskBatch createTask); + + Future pauseTask(String id); + + Future pauseTasks(TaskFilter? filter); + + Future continueTask(String id); + + Future continueTasks(TaskFilter? filter); + + Future deleteTask(String id, bool force); + + Future deleteTasks(TaskFilter? filter, bool force); + + Future getTask(String id); + + Future> getTasks(TaskFilter? filter); + + Future> getTaskStats(String id); + + Future getConfig(); + + Future putConfig(DownloaderConfig config); + + Future installExtension(InstallExtension installExtension); + + Future> getExtensions(); + + Future updateExtensionSettings( + String identity, UpdateExtensionSettings updateExtensionSettings); + + Future switchExtension( + String identity, SwitchExtension switchExtension); + + Future deleteExtension(String identity); + + Future upgradeCheckExtension(String identity); + + Future upgradeExtension(String identity); + + Future close(); + + Future> proxyRequest(String uri, + {data, Options? options}); +} diff --git a/ui/flutter/lib/api/native/model/invoke_request.dart b/ui/flutter/lib/api/native/model/invoke_request.dart new file mode 100644 index 000000000..56bee9dfa --- /dev/null +++ b/ui/flutter/lib/api/native/model/invoke_request.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'invoke_request.g.dart'; + +@JsonSerializable() +class InvokeRequest { + String method; + List params; + + InvokeRequest({ + this.method = '', + this.params = const [], + }); + + factory InvokeRequest.fromJson(Map json) => + _$InvokeRequestFromJson(json); + + Map toJson() => _$InvokeRequestToJson(this); +} diff --git a/ui/flutter/lib/api/native/model/invoke_request.g.dart b/ui/flutter/lib/api/native/model/invoke_request.g.dart new file mode 100644 index 000000000..79e748946 --- /dev/null +++ b/ui/flutter/lib/api/native/model/invoke_request.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'invoke_request.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +InvokeRequest _$InvokeRequestFromJson(Map json) => + InvokeRequest( + method: json['method'] as String? ?? '', + params: (json['params'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + ); + +Map _$InvokeRequestToJson(InvokeRequest instance) => + { + 'method': instance.method, + 'params': instance.params, + }; diff --git a/ui/flutter/lib/core/common/start_config.dart b/ui/flutter/lib/api/native/model/start_config.dart similarity index 63% rename from ui/flutter/lib/core/common/start_config.dart rename to ui/flutter/lib/api/native/model/start_config.dart index 26bf481dc..207428e59 100644 --- a/ui/flutter/lib/core/common/start_config.dart +++ b/ui/flutter/lib/api/native/model/start_config.dart @@ -4,14 +4,13 @@ part 'start_config.g.dart'; @JsonSerializable() class StartConfig { - late String network; - late String address; - late String storage; - late String storageDir; - late int refreshInterval; - late String apiToken; + String storage; + String storageDir; - StartConfig(); + StartConfig({ + this.storage = '', + this.storageDir = '', + }); factory StartConfig.fromJson(Map json) => _$StartConfigFromJson(json); diff --git a/ui/flutter/lib/core/common/start_config.g.dart b/ui/flutter/lib/api/native/model/start_config.g.dart similarity index 53% rename from ui/flutter/lib/core/common/start_config.g.dart rename to ui/flutter/lib/api/native/model/start_config.g.dart index 8732accb0..8166b2ab2 100644 --- a/ui/flutter/lib/core/common/start_config.g.dart +++ b/ui/flutter/lib/api/native/model/start_config.g.dart @@ -6,20 +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 - ..apiToken = json['apiToken'] as String; +StartConfig _$StartConfigFromJson(Map json) => StartConfig( + storage: json['storage'] as String? ?? '', + storageDir: json['storageDir'] as String? ?? '', + ); Map _$StartConfigToJson(StartConfig instance) => { - 'network': instance.network, - 'address': instance.address, 'storage': instance.storage, 'storageDir': instance.storageDir, - 'refreshInterval': instance.refreshInterval, - 'apiToken': instance.apiToken, }; diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart index f6afe3613..6be232586 100644 --- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart +++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart @@ -14,10 +14,8 @@ import 'package:uri_to_file/uri_to_file.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; -import '../../../../api/api.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../api/model/downloader_config.dart'; -import '../../../../core/common/start_config.dart'; -import '../../../../core/libgopeed_boot.dart'; import '../../../../database/database.dart'; import '../../../../database/entity.dart'; import '../../../../i18n/message.dart'; @@ -74,12 +72,9 @@ final allTrackerSubscribeUrlCdns = Map.fromIterable(allTrackerSubscribeUrls, }); class AppController extends GetxController with WindowListener, TrayListener { - static StartConfig? _defaultStartConfig; - final autoStartup = false.obs; - final startConfig = StartConfig().obs; - final runningPort = 0.obs; final downloaderConfig = DownloaderConfig().obs; + final httpRunningPort = 0.obs; late AppLinks _appLinks; StreamSubscription? _linkSubscription; @@ -111,7 +106,7 @@ class AppController extends GetxController with WindowListener, TrayListener { void onClose() { _linkSubscription?.cancel(); trayManager.removeListener(this); - LibgopeedBoot.instance.stop(); + LibgopeedBoot.instance.close(); } @override @@ -218,11 +213,12 @@ class AppController extends GetxController with WindowListener, TrayListener { ), MenuItem( label: "startAll".tr, - onClick: (menuItem) async => {continueAllTasks()}, + onClick: (menuItem) async => + {LibgopeedBoot.instance.continueTasks(null)}, ), MenuItem( label: "pauseAll".tr, - onClick: (menuItem) async => {pauseAllTasks()}, + onClick: (menuItem) async => {LibgopeedBoot.instance.pauseTasks(null)}, ), MenuItem( label: 'setting'.tr, @@ -247,7 +243,7 @@ class AppController extends GetxController with WindowListener, TrayListener { label: 'exit'.tr, onClick: (menuItem) async { try { - await LibgopeedBoot.instance.stop(); + await LibgopeedBoot.instance.close(); } catch (e) { logger.w("libgopeed stop fail", e); } @@ -313,43 +309,9 @@ class AppController extends GetxController with WindowListener, TrayListener { await Get.rootDelegate.offAndToNamed(Routes.CREATE, arguments: path); } - 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.supportUnixSocket()) { - // not support unix socket, use tcp - _defaultStartConfig!.network = "tcp"; - _defaultStartConfig!.address = "127.0.0.1:0"; - } else { - _defaultStartConfig!.network = "unix"; - _defaultStartConfig!.address = - "${(await getTemporaryDirectory()).path}/$unixSocketPath"; - } - _defaultStartConfig!.apiToken = ''; - return _defaultStartConfig!; - } - - Future loadStartConfig() async { - final defaultCfg = await _initDefaultStartConfig(); - final saveCfg = Database.instance.getStartConfig(); - startConfig.value.network = saveCfg?.network ?? defaultCfg.network; - startConfig.value.address = saveCfg?.address ?? defaultCfg.address; - startConfig.value.apiToken = saveCfg?.apiToken ?? defaultCfg.apiToken; - return startConfig.value; - } - Future loadDownloaderConfig() async { try { - downloaderConfig.value = await getConfig(); + downloaderConfig.value = await LibgopeedBoot.instance.getConfig(); } catch (e) { logger.w("load downloader config fail", e, StackTrace.current); downloaderConfig.value = DownloaderConfig(); @@ -382,7 +344,7 @@ class AppController extends GetxController with WindowListener, TrayListener { }); refreshTrackers(); - await saveConfig(); + await LibgopeedBoot.instance.putConfig(downloaderConfig.value); } refreshTrackers() { @@ -411,7 +373,7 @@ class AppController extends GetxController with WindowListener, TrayListener { } Future> _fetchTrackers(String subscribeUrl) async { - final resp = await proxyRequest(subscribeUrl); + final resp = await LibgopeedBoot.instance.proxyRequest(subscribeUrl); if (resp.statusCode != 200) { throw Exception( 'Failed to get trackers, status code: ${resp.statusCode}'); @@ -468,12 +430,4 @@ class AppController extends GetxController with WindowListener, TrayListener { args: ['--${Args.flagHidden}']); autoStartup.value = await launchAtStartup.isEnabled(); } - - Future saveConfig() async { - Database.instance.saveStartConfig(StartConfigEntity( - network: startConfig.value.network, - address: startConfig.value.address, - apiToken: startConfig.value.apiToken)); - await putConfig(downloaderConfig.value); - } } diff --git a/ui/flutter/lib/app/modules/create/views/create_view.dart b/ui/flutter/lib/app/modules/create/views/create_view.dart index cf4e16a24..b3e3e1505 100644 --- a/ui/flutter/lib/app/modules/create/views/create_view.dart +++ b/ui/flutter/lib/app/modules/create/views/create_view.dart @@ -7,7 +7,7 @@ import 'package:get/get.dart'; import 'package:path/path.dart' as path; import 'package:rounded_loading_button_plus/rounded_loading_button.dart'; -import '../../../../api/api.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../api/model/create_task.dart'; import '../../../../api/model/options.dart'; import '../../../../api/model/request.dart'; @@ -594,7 +594,7 @@ class CreateView extends GetView { final isDirect = controller.directDownload.value || isMultiLine; if (isDirect) { await Future.wait(urls.map((url) { - return createTask(CreateTask( + return LibgopeedBoot.instance.createTask(CreateTask( req: Request( url: url, extra: parseReqExtra(url), @@ -610,7 +610,7 @@ class CreateView extends GetView { })); Get.rootDelegate.offNamed(Routes.TASK); } else { - final rr = await resolve(Request( + final rr = await LibgopeedBoot.instance.resolve(Request( url: submitUrl, extra: parseReqExtra(_urlController.text), proxy: parseProxy(), @@ -741,23 +741,28 @@ class CreateView extends GetView { await Future.wait( controller.selectedIndexes.map((index) { final file = rr.res.files[index]; - return createTask(CreateTask( - req: file.req!..proxy = parseProxy(), - opt: Options( - name: file.name, - path: path.join(_pathController.text, - rr.res.name, file.path), - selectFiles: [], - extra: optExtra))); + return LibgopeedBoot.instance.createTask( + CreateTask( + req: file.req!..proxy = parseProxy(), + opt: Options( + name: file.name, + path: path.join( + _pathController.text, + rr.res.name, + file.path), + selectFiles: [], + extra: optExtra))); })); } else { - await createTask(CreateTask( - rid: rr.id, - opt: Options( - name: _renameController.text, - path: _pathController.text, - selectFiles: controller.selectedIndexes, - extra: optExtra))); + await LibgopeedBoot.instance.createTask( + CreateTask( + rid: rr.id, + opt: Options( + name: _renameController.text, + path: _pathController.text, + selectFiles: + controller.selectedIndexes, + extra: optExtra))); } Get.back(); Get.rootDelegate.offNamed(Routes.TASK); diff --git a/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart b/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart index 54b4c3f64..dc197b3a2 100644 --- a/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart +++ b/ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart @@ -1,6 +1,6 @@ import 'package:get/get.dart'; -import 'package:gopeed/api/api.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../api/model/extension.dart'; class ExtensionController extends GetxController { @@ -17,12 +17,13 @@ class ExtensionController extends GetxController { } Future load() async { - extensions.value = await getExtensions(); + extensions.value = await LibgopeedBoot.instance.getExtensions(); } Future checkUpdate() async { for (final ext in extensions) { - final resp = await upgradeCheckExtension(ext.identity); + final resp = + await LibgopeedBoot.instance.upgradeCheckExtension(ext.identity); if (resp.newVersion.isNotEmpty) { updateFlags[ext.identity] = resp.newVersion; } diff --git a/ui/flutter/lib/app/modules/extension/views/extension_view.dart b/ui/flutter/lib/app/modules/extension/views/extension_view.dart index 0a9dbe4a2..a03c67de3 100644 --- a/ui/flutter/lib/app/modules/extension/views/extension_view.dart +++ b/ui/flutter/lib/app/modules/extension/views/extension_view.dart @@ -11,7 +11,8 @@ import 'package:path/path.dart' as path; import 'package:rounded_loading_button_plus/rounded_loading_button.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../../../api/api.dart'; +import '../../../../api/entry/libgopeed_boot_web.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../api/model/extension.dart'; import '../../../../api/model/install_extension.dart'; import '../../../../api/model/switch_extension.dart'; @@ -78,8 +79,9 @@ class ExtensionView extends GetView { } _installBtnController.start(); try { - await installExtension(InstallExtension( - url: _installUrlController.text)); + await LibgopeedBoot.instance.installExtension( + InstallExtension( + url: _installUrlController.text)); Get.snackbar('tip'.tr, 'extensionInstallSuccess'.tr); await controller.load(); } catch (e) { @@ -98,7 +100,7 @@ class ExtensionView extends GetView { if (dir != null) { MacSecureUtil.saveBookmark(dir); try { - await installExtension( + await LibgopeedBoot.instance.installExtension( InstallExtension(devMode: true, url: dir)); Get.snackbar( 'tip'.tr, 'extensionInstallSuccess'.tr); @@ -133,8 +135,10 @@ class ExtensionView extends GetView { ) : Util.isWeb() ? Image.network( - join( - '/fs/extensions/${extension.identity}/${extension.icon}'), + (LibgopeedBoot.instance + as LibgopeedBootWeb) + .join( + '/fs/extensions/${extension.identity}/${extension.icon}'), width: 48, height: 48, ) @@ -155,9 +159,9 @@ class ExtensionView extends GetView { value: !extension.disabled, onChanged: (value) async { try { - await switchExtension( - extension.identity, - SwitchExtension(status: value)); + await LibgopeedBoot.instance + .switchExtension(extension.identity, + SwitchExtension(status: value)); await controller.load(); } catch (e) { showErrorMessage(e); @@ -354,10 +358,11 @@ class ExtensionView extends GetView { try { confrimController.start(); if (formKey.currentState?.saveAndValidate() == true) { - await updateExtensionSettings( - extension.identity, - UpdateExtensionSettings( - settings: formKey.currentState!.value)); + await LibgopeedBoot.instance + .updateExtensionSettings( + extension.identity, + UpdateExtensionSettings( + settings: formKey.currentState!.value)); await controller.load(); Get.back(); } @@ -454,7 +459,8 @@ class ExtensionView extends GetView { ), onPressed: () async { try { - await deleteExtension(extension.identity); + await LibgopeedBoot.instance + .deleteExtension(extension.identity); await controller.load(); Get.back(); } catch (e) { @@ -487,18 +493,19 @@ class ExtensionView extends GetView { onPressed: () async { confrimController.start(); try { - await updateExtension(extension.identity); + await LibgopeedBoot.instance + .upgradeExtension(extension.identity); await controller.load(); controller.updateFlags.remove(extension.identity); Get.back(); - showMessage('tip'.tr, 'extensionUpdateSuccess'.tr); + showMessage('tip'.tr, 'extensionUpgradeSuccess'.tr); } catch (e) { showErrorMessage(e); } finally { confrimController.stop(); } }, - child: Text('newVersionUpdate'.tr), + child: Text('newVersionUpgrade'.tr), ), ], )); diff --git a/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart b/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart index 3f1995c0a..844e3a3f4 100644 --- a/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart +++ b/ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart @@ -1,7 +1,8 @@ import 'dart:convert'; import 'package:get/get.dart'; -import 'package:gopeed/api/api.dart'; + +import '../../../../api/libgopeed_boot.dart'; class SettingController extends GetxController { final tapStatues = {}.obs; @@ -28,12 +29,13 @@ class SettingController extends GetxController { void fetchLatestVersion() async { String? releaseDataStr; try { - releaseDataStr = (await proxyRequest( + releaseDataStr = (await LibgopeedBoot.instance.proxyRequest( "https://api.github.com/repos/GopeedLab/gopeed/releases/latest")) .data; } catch (e) { - releaseDataStr = - (await proxyRequest("https://gopeed.com/api/release")).data; + releaseDataStr = (await LibgopeedBoot.instance + .proxyRequest("https://gopeed.com/api/release")) + .data; } if (releaseDataStr == null) { return; diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index afa7e231d..9dc3313e7 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -10,6 +10,7 @@ import 'package:intl/intl.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../api/model/downloader_config.dart'; import '../../../../i18n/message.dart'; import '../../../../util/input_formatter.dart'; @@ -35,7 +36,6 @@ class SettingView extends GetView { Widget build(BuildContext context) { final appController = Get.find(); final downloaderCfg = appController.downloaderConfig; - final startCfg = appController.startConfig; Timer? timer; Future debounceSave( @@ -51,8 +51,8 @@ class SettingView extends GetView { return; } } - appController - .saveConfig() + LibgopeedBoot.instance + .putConfig(downloaderCfg.value) .then((_) => completer.complete(true)) .onError(completer.completeError); if (needRestart) { @@ -530,7 +530,7 @@ class SettingView extends GetView { launchUrl(Uri.parse('https://gopeed.com'), mode: LaunchMode.externalApplication); }, - child: Text('newVersionUpdate'.tr), + child: Text('newVersionUpgrade'.tr), ), ], )); @@ -637,7 +637,7 @@ class SettingView extends GetView { final portController = TextEditingController(text: port); updateAddress() async { final newAddress = '${ipController.text}:${portController.text}'; - if (newAddress != startCfg.value.address) { + if (newAddress != proxy.host) { proxy.host = newAddress; await debounceSave(); @@ -718,152 +718,152 @@ class SettingView extends GetView { ); // advanced config API items start - final buildApiProtocol = _buildConfigItem( - 'protocol', - () => startCfg.value.network == 'tcp' - ? 'TCP ${startCfg.value.address}' - : 'Unix', - (Key key) { - final items = [ - SizedBox( - width: 80, - child: DropdownButtonFormField( - value: startCfg.value.network, - onChanged: Util.isDesktop() - ? (value) async { - startCfg.update((val) { - val!.network = value!; - }); - - await debounceSave(needRestart: true); - } - : null, - items: [ - const DropdownMenuItem( - value: 'tcp', - child: Text('TCP'), - ), - Util.supportUnixSocket() - ? 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 { - if (ipController.text.isEmpty || portController.text.isEmpty) { - return; - } - final newAddress = '${ipController.text}:${portController.text}'; - if (newAddress != startCfg.value.address) { - startCfg.value.address = newAddress; - - final saved = await debounceSave( - check: () async { - // Check if address already in use - final configIp = ipController.text; - final configPort = int.parse(portController.text); - if (configPort == 0) { - return ''; - } - try { - final socket = await Socket.connect(configIp, configPort, - timeout: const Duration(seconds: 3)); - socket.close(); - return 'portInUse' - .trParams({'port': configPort.toString()}); - } catch (e) { - return ''; - } - }, - needRestart: true); - - // If save failed, restore the old address - if (!saved) { - final oldAddress = - (await appController.loadStartConfig()).address; - startCfg.update((val) async { - val!.address = oldAddress; - }); - } - } - } - - ipController.addListener(updateAddress); - portController.addListener(updateAddress); - items.addAll([ - const Padding(padding: EdgeInsets.only(left: 20)), - Flexible( - 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)), - Flexible( - child: TextFormField( - controller: portController, - decoration: InputDecoration( - labelText: '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('apiToken', - () => startCfg.value.apiToken.isEmpty ? 'notSet'.tr : 'set'.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); - } - }); - return TextField( - key: key, - obscureText: true, - controller: apiTokenController, - focusNode: FocusNode(), - ); - }); + // final buildApiProtocol = _buildConfigItem( + // 'protocol', + // () => startCfg.value.host == 'tcp' + // ? 'TCP ${startCfg.value.address}' + // : 'Unix', + // (Key key) { + // final items = [ + // SizedBox( + // width: 80, + // child: DropdownButtonFormField( + // value: startCfg.value.host, + // onChanged: Util.isDesktop() + // ? (value) async { + // startCfg.update((val) { + // val!.host = value!; + // }); + + // await debounceSave(needRestart: true); + // } + // : null, + // items: [ + // const DropdownMenuItem( + // value: 'tcp', + // child: Text('TCP'), + // ), + // Util.supportUnixSocket() + // ? const DropdownMenuItem( + // value: 'unix', + // child: Text('Unix'), + // ) + // : null, + // ].where((e) => e != null).map((e) => e!).toList(), + // ), + // ) + // ]; + // if (Util.isDesktop() && startCfg.value.host == '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 { + // if (ipController.text.isEmpty || portController.text.isEmpty) { + // return; + // } + // final newAddress = '${ipController.text}:${portController.text}'; + // if (newAddress != startCfg.value.address) { + // startCfg.value.address = newAddress; + + // final saved = await debounceSave( + // check: () async { + // // Check if address already in use + // final configIp = ipController.text; + // final configPort = int.parse(portController.text); + // if (configPort == 0) { + // return ''; + // } + // try { + // final socket = await Socket.connect(configIp, configPort, + // timeout: const Duration(seconds: 3)); + // socket.close(); + // return 'portInUse' + // .trParams({'port': configPort.toString()}); + // } catch (e) { + // return ''; + // } + // }, + // needRestart: true); + + // // If save failed, restore the old address + // if (!saved) { + // // final oldAddress = + // // (await appController.loadStartConfig()).address; + // // startCfg.update((val) async { + // // val!.address = oldAddress; + // // }); + // } + // } + // } + + // ipController.addListener(updateAddress); + // portController.addListener(updateAddress); + // items.addAll([ + // const Padding(padding: EdgeInsets.only(left: 20)), + // Flexible( + // 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)), + // Flexible( + // child: TextFormField( + // controller: portController, + // decoration: InputDecoration( + // labelText: '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('apiToken', + // () => startCfg.value.apiToken.isEmpty ? 'notSet'.tr : 'set'.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); + // } + // }); + // return TextField( + // key: key, + // obscureText: true, + // controller: apiTokenController, + // focusNode: FocusNode(), + // ); + // }); // advanced config log items start buildLogsDir() { @@ -993,16 +993,16 @@ class SettingView extends GetView { child: Column( children: _addDivider([buildProxy()]), )), - const Text('API'), - Card( - child: Column( - children: _addDivider([ - buildApiProtocol(), - Util.isDesktop() && startCfg.value.network == 'tcp' - ? buildApiToken() - : null, - ]), - )), + // const Text('API'), + // Card( + // child: Column( + // children: _addDivider([ + // buildApiProtocol(), + // Util.isDesktop() && startCfg.value.host == 'tcp' + // ? buildApiToken() + // : null, + // ]), + // )), Text('developer'.tr), Card( child: Column( diff --git a/ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart b/ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart index 3eadf2329..0af677570 100644 --- a/ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart +++ b/ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart @@ -1,5 +1,5 @@ import 'package:get/get.dart'; -import '../../../../api/api.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../api/model/resource.dart'; import '../../../../api/model/task.dart'; @@ -31,7 +31,7 @@ class TaskFilesController extends GetxController { super.onInit(); final taskId = Get.rootDelegate.parameters['id']; - final tasks = await getTasks([]); + final tasks = await LibgopeedBoot.instance.getTasks(null); task.value = tasks.firstWhere((element) => element.id == taskId); parseDirMap(task.value!.meta.res!.files); toDir("/"); diff --git a/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart b/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart index bca359678..8d0da35ab 100644 --- a/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart +++ b/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart @@ -2,8 +2,9 @@ import 'dart:async'; import 'package:get/get.dart'; -import '../../../../api/api.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../api/model/task.dart'; +import '../../../../api/model/task_filter.dart'; abstract class TaskListController extends GetxController { List statuses; @@ -44,7 +45,8 @@ abstract class TaskListController extends GetxController { } getTasksState() async { - final tasks = await getTasks(statuses); + final tasks = + await LibgopeedBoot.instance.getTasks(TaskFilter(status: statuses)); // sort tasks by create time tasks.sort(compare); this.tasks.value = tasks; diff --git a/ui/flutter/lib/app/modules/task/views/task_files_view.dart b/ui/flutter/lib/app/modules/task/views/task_files_view.dart index 0cd38d4da..18d02edbe 100644 --- a/ui/flutter/lib/app/modules/task/views/task_files_view.dart +++ b/ui/flutter/lib/app/modules/task/views/task_files_view.dart @@ -5,7 +5,8 @@ import 'package:path/path.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../../../api/api.dart' as api; +import '../../../../api/entry/libgopeed_boot_web.dart'; +import '../../../../api/libgopeed_boot.dart'; import '../../../../util/browser_download/browser_download.dart'; import '../../../../util/file_icon.dart'; import '../../../../util/icons.dart'; @@ -84,8 +85,10 @@ class TaskFilesView extends GetView { mainAxisAlignment: MainAxisAlignment.end, children: Util.isWeb() ? () { - final accessUrl = api.join( - "/fs/tasks/${controller.task.value!.id}$fileRelativePath"); + final accessUrl = (LibgopeedBoot + .instance as LibgopeedBootWeb) + .join( + "/fs/tasks/${controller.task.value!.id}$fileRelativePath"); return [ IconButton( icon: diff --git a/ui/flutter/lib/app/views/buid_task_list_view.dart b/ui/flutter/lib/app/views/buid_task_list_view.dart index cb630efaf..4f73576fb 100644 --- a/ui/flutter/lib/app/views/buid_task_list_view.dart +++ b/ui/flutter/lib/app/views/buid_task_list_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:styled_widget/styled_widget.dart'; -import '../../api/api.dart'; +import '../../api/libgopeed_boot.dart'; import '../../api/model/task.dart'; import '../../util/file_icon.dart'; import '../../util/icons.dart'; @@ -95,8 +95,9 @@ class BuildTaskListView extends GetView { try { final force = !appController .downloaderConfig.value.extra.lastDeleteTaskKeep; - await appController.saveConfig(); - await deleteTask(id, force); + LibgopeedBoot.instance + .putConfig(appController.downloaderConfig.value); + await LibgopeedBoot.instance.deleteTask(id, force); Get.back(); } catch (e) { showErrorMessage(e); @@ -122,7 +123,7 @@ class BuildTaskListView extends GetView { icon: const Icon(Icons.pause), onPressed: () async { try { - await pauseTask(task.id); + await LibgopeedBoot.instance.pauseTask(task.id); } catch (e) { showErrorMessage(e); } @@ -133,7 +134,7 @@ class BuildTaskListView extends GetView { icon: const Icon(Icons.play_arrow), onPressed: () async { try { - await continueTask(task.id); + await LibgopeedBoot.instance.continueTask(task.id); } catch (e) { showErrorMessage(e); } diff --git a/ui/flutter/lib/core/common/libgopeed_channel.dart b/ui/flutter/lib/core/common/libgopeed_channel.dart deleted file mode 100644 index a94d37444..000000000 --- a/ui/flutter/lib/core/common/libgopeed_channel.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/services.dart'; - -import 'libgopeed_interface.dart'; -import 'start_config.dart'; - -class LibgopeedChannel implements LibgopeedInterface { - static const _channel = MethodChannel('gopeed.com/libgopeed'); - - @override - Future start(StartConfig cfg) async { - final port = await _channel.invokeMethod('start', { - 'cfg': jsonEncode(cfg), - }); - return port as int; - } - - @override - Future stop() async { - return await _channel.invokeMethod('stop'); - } -} diff --git a/ui/flutter/lib/core/common/libgopeed_ffi.dart b/ui/flutter/lib/core/common/libgopeed_ffi.dart deleted file mode 100644 index ae2db5f97..000000000 --- a/ui/flutter/lib/core/common/libgopeed_ffi.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:ffi'; - -import 'package:ffi/ffi.dart'; - -import '../ffi/libgopeed_bind.dart'; -import 'libgopeed_interface.dart'; -import 'start_config.dart'; - -class LibgopeedFFi implements LibgopeedInterface { - late LibgopeedBind _libgopeed; - - LibgopeedFFi(LibgopeedBind libgopeed) { - _libgopeed = libgopeed; - } - - @override - Future start(StartConfig cfg) { - var completer = Completer(); - var result = _libgopeed.Start(jsonEncode(cfg).toNativeUtf8().cast()); - if (result.r1 != nullptr) { - completer.completeError(Exception(result.r1.cast().toDartString())); - } else { - completer.complete(result.r0); - } - return completer.future; - } - - @override - Future stop() { - var completer = Completer(); - _libgopeed.Stop(); - completer.complete(); - return completer.future; - } -} diff --git a/ui/flutter/lib/core/common/libgopeed_interface.dart b/ui/flutter/lib/core/common/libgopeed_interface.dart deleted file mode 100644 index 559d799f7..000000000 --- a/ui/flutter/lib/core/common/libgopeed_interface.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'start_config.dart'; - -abstract class LibgopeedInterface { - Future start(StartConfig cfg); - - Future stop(); -} diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart deleted file mode 100644 index 05efe9639..000000000 --- a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import '../common/start_config.dart'; - -import '../libgopeed_boot.dart'; - -LibgopeedBoot create() => LibgopeedBootBrowser(); - -class LibgopeedBootBrowser implements LibgopeedBoot { - // do nothing - @override - Future start(StartConfig cfg) async { - return 0; - } - - @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 deleted file mode 100644 index c03f004c3..000000000 --- a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:async'; -import 'dart:ffi'; -import 'dart:io'; - -import '../../util/util.dart'; -import '../common/libgopeed_channel.dart'; -import '../common/libgopeed_ffi.dart'; -import '../common/libgopeed_interface.dart'; -import '../common/start_config.dart'; -import '../ffi/libgopeed_bind.dart'; -import '../libgopeed_boot.dart'; - -LibgopeedBoot create() => LibgopeedBootNative(); - -class LibgopeedBootNative implements LibgopeedBoot { - late LibgopeedInterface _libgopeed; - - LibgopeedBootNative() { - if (Util.isDesktop()) { - var libName = "libgopeed."; - if (Platform.isWindows) { - libName += "dll"; - } - if (Platform.isMacOS) { - libName += "dylib"; - } - if (Platform.isLinux) { - libName += "so"; - } - _libgopeed = LibgopeedFFi(LibgopeedBind(DynamicLibrary.open(libName))); - } else { - _libgopeed = LibgopeedChannel(); - } - } - - @override - Future start(StartConfig cfg) async { - cfg.storage = 'bolt'; - cfg.storageDir = Util.getStorageDir(); - cfg.refreshInterval = 0; - var port = await _libgopeed.start(cfg); - return port; - } - - @override - Future stop() async { - await _libgopeed.stop(); - } -} diff --git a/ui/flutter/lib/core/libgopeed_boot.dart b/ui/flutter/lib/core/libgopeed_boot.dart deleted file mode 100644 index 98bf3dc82..000000000 --- a/ui/flutter/lib/core/libgopeed_boot.dart +++ /dev/null @@ -1,19 +0,0 @@ -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'; - -abstract class LibgopeedBoot { - static LibgopeedBoot? _instance; - - static LibgopeedBoot get instance { - _instance ??= LibgopeedBoot(); - return _instance!; - } - - factory LibgopeedBoot() => create(); - - Future start(StartConfig cfg); - - Future stop(); -} diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 6af60fa4a..6dc9cfcbf 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -71,12 +71,12 @@ const enUS = { 'deleteTaskTip': 'Keep downloaded files', 'delete': 'Delete', 'newVersionTitle': 'Discover new version @version', - 'newVersionUpdate': 'Update Now', + 'newVersionUpgrade': 'Update Now', 'newVersionLater': 'Later', 'extensions': 'Extensions', 'extensionInstallUrl': 'Install URL', 'extensionInstallSuccess': 'Installed successfully', - 'extensionUpdateSuccess': 'Updated successfully', + 'extensionUpgradeSuccess': 'Updated successfully', 'extensionDelete': 'Delete Extension', 'extensionAlreadyLatest': 'It\'s already the latest version', 'extensionFind': 'Find Extensions', diff --git a/ui/flutter/lib/i18n/langs/es_es.dart b/ui/flutter/lib/i18n/langs/es_es.dart index 22219bc77..6aefd6d54 100644 --- a/ui/flutter/lib/i18n/langs/es_es.dart +++ b/ui/flutter/lib/i18n/langs/es_es.dart @@ -21,9 +21,11 @@ const esES = { 'downloadLinkValid': 'Por favor, introduce el enlace de descarga', 'downloadLinkHit': 'Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append', - 'downloadLinkHitDesktop': ', o arrastra el archivo torrent aquí directamente', + 'downloadLinkHitDesktop': + ', o arrastra el archivo torrent aquí directamente', 'download': 'Descargar', - 'noFileSelected': 'Por favor, selecciona al menos un archivo para continuar.', + 'noFileSelected': + 'Por favor, selecciona al menos un archivo para continuar.', 'noStoragePermission': 'Se requiere permiso de almacenamiento', 'selectFile': 'Seleccionar Archivo', 'rename': 'Renombrar', @@ -43,7 +45,8 @@ const esES = { 'updateDaily': 'Actualizar diariamente', 'lastUpdate': 'Última actualización: @time', 'addTracker': 'Añadir Tracker', - 'addTrackerHit': 'Por favor, introduce la URL del servidor tracker, una por línea', + 'addTrackerHit': + 'Por favor, introduce la URL del servidor tracker, una por línea', 'ui': 'Interfaz', 'theme': 'Tema', 'themeSystem': 'Sistema', @@ -67,12 +70,12 @@ const esES = { 'deleteTaskTip': 'Mantener archivos descargados', 'delete': 'Eliminar', 'newVersionTitle': 'Nueva versión @version disponible', - 'newVersionUpdate': 'Actualizar Ahora', + 'newVersionUpgrade': 'Actualizar Ahora', 'newVersionLater': 'Más tarde', 'extensions': 'Extensiones', 'extensionInstallUrl': 'URL de instalación', 'extensionInstallSuccess': 'Instalado con éxito', - 'extensionUpdateSuccess': 'Actualizado con éxito', + 'extensionUpgradeSuccess': 'Actualizado con éxito', 'extensionDelete': 'Eliminar Extensión', 'extensionAlreadyLatest': 'Ya es la versión más reciente', 'extensionFind': 'Buscar Extensiones', @@ -105,4 +108,4 @@ const esES = { 'downloadPath': 'Ruta de Descarga', 'skipVerifyCert': 'Omitir Verificación de Certificado', }, -}; \ No newline at end of file +}; diff --git a/ui/flutter/lib/i18n/langs/fa_ir.dart b/ui/flutter/lib/i18n/langs/fa_ir.dart index b9825baaa..72966e9ca 100644 --- a/ui/flutter/lib/i18n/langs/fa_ir.dart +++ b/ui/flutter/lib/i18n/langs/fa_ir.dart @@ -55,7 +55,7 @@ const faIR = { 'deleteTaskTip': 'فایل های دانلود شده را نگه دارد', 'delete': 'پاک کردن', 'newVersionTitle': '@version عنوان: کشف نسخه جدید', - 'newVersionUpdate': 'بروزرسانی', + 'newVersionUpgrade': 'بروزرسانی', 'newVersionLater': 'بعداً', 'thanks': 'تشکر', 'thanksDesc': diff --git a/ui/flutter/lib/i18n/langs/fr_fr.dart b/ui/flutter/lib/i18n/langs/fr_fr.dart index e5e94c3da..de420bdd8 100644 --- a/ui/flutter/lib/i18n/langs/fr_fr.dart +++ b/ui/flutter/lib/i18n/langs/fr_fr.dart @@ -19,10 +19,12 @@ const frFR = { 'advancedOptions': 'Options avancées', 'downloadLink': 'Lien de téléchargement', 'downloadLinkValid': 'Veuillez entrer le lien de téléchargement', - 'downloadLinkHit': 'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge', + 'downloadLinkHit': + 'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge', 'downloadLinkHitDesktop': ', ou glissez directement le fichier torrent ici', 'download': 'Télécharger', - 'noFileSelected': 'Veuillez sélectionner au moins un fichier pour continuer.', + 'noFileSelected': + 'Veuillez sélectionner au moins un fichier pour continuer.', 'noStoragePermission': 'Permission de stockage requise', 'selectFile': 'Sélectionner un fichier', 'rename': 'Renommer', @@ -32,16 +34,19 @@ const frFR = { 'downloadDir': 'Répertoire de téléchargement', 'downloadDirValid': 'Veuillez sélectionner le répertoire de téléchargement', 'connections': 'Connexions', - 'useServerCtime': 'Utiliser l\'heure du serveur pour la création de fichier', + 'useServerCtime': + 'Utiliser l\'heure du serveur pour la création de fichier', 'maxRunning': 'Tâches en cours maximum', 'items': '@count éléments', 'subscribeTracker': 'S\'abonner au tracker', - 'subscribeFail': 'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard', + 'subscribeFail': + 'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard', 'update': 'Mettre à jour', 'updateDaily': 'Mettre à jour quotidiennement', 'lastUpdate': 'Dernière mise à jour : @time', 'addTracker': 'Ajouter un tracker', - 'addTrackerHit': 'Veuillez entrer l\'URL du serveur de tracker, une par ligne', + 'addTrackerHit': + 'Veuillez entrer l\'URL du serveur de tracker, une par ligne', 'ui': 'Interface utilisateur', 'theme': 'Thème', 'themeSystem': 'Système', @@ -65,12 +70,12 @@ const frFR = { 'deleteTaskTip': 'Conserver les fichiers téléchargés', 'delete': 'Supprimer', 'newVersionTitle': 'Nouvelle version @version disponible', - 'newVersionUpdate': 'Mettre à jour maintenant', + 'newVersionUpgrade': 'Mettre à jour maintenant', 'newVersionLater': 'Plus tard', 'extensions': 'Extensions', 'extensionInstallUrl': 'URL d\'installation', 'extensionInstallSuccess': 'Installé avec succès', - 'extensionUpdateSuccess': 'Mis à jour avec succès', + 'extensionUpgradeSuccess': 'Mis à jour avec succès', 'extensionDelete': 'Supprimer l\'extension', 'extensionAlreadyLatest': 'C\'est déjà la dernière version', 'extensionFind': 'Trouver des extensions', @@ -89,7 +94,8 @@ const frFR = { 'username': 'Nom d\'utilisateur', 'password': 'Mot de passe', 'thanks': 'Merci', - 'thanksDesc': 'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !', + 'thanksDesc': + 'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !', 'browserExtension': 'Extension du navigateur', 'launchAtStartup': 'Lancer au démarrage', 'skipVerifyCert': 'Bypass Verifikasi Sertifikat', diff --git a/ui/flutter/lib/i18n/langs/id_id.dart b/ui/flutter/lib/i18n/langs/id_id.dart index c58a4ed19..de8fa0eee 100644 --- a/ui/flutter/lib/i18n/langs/id_id.dart +++ b/ui/flutter/lib/i18n/langs/id_id.dart @@ -22,7 +22,8 @@ const idID = { 'downloadLinkValid': 'Silakan masukkan tautan unduhan', 'downloadLinkHit': 'Tolong masukkan tautan unduhan, HTTP/HTTPS/MAGNET didukung@append', - 'downloadLinkHitDesktop': ', atau seret file torrent ke sini secara langsung', + 'downloadLinkHitDesktop': + ', atau seret file torrent ke sini secara langsung', 'download': 'Download', 'noFileSelected': 'Silakan pilih setidaknya satu file untuk melanjutkan.', 'noStoragePermission': 'Izin penyimpanan diperlukan', @@ -71,12 +72,12 @@ const idID = { 'deleteTaskTip': 'Simpan file yang terunduh', 'delete': 'Hapus', 'newVersionTitle': 'Temukan versi batu @version', - 'newVersionUpdate': 'Perbarui Sekarang', + 'newVersionUpgrade': 'Perbarui Sekarang', 'newVersionLater': 'Nanti', 'extensions': 'Ekstensi', 'extensionInstallUrl': 'URL Instal', 'extensionInstallSuccess': 'Diinstal dengan sukses', - 'extensionUpdateSuccess': 'Diperbaruhi dengan sukses', + 'extensionUpgradeSuccess': 'Diperbaruhi dengan sukses', 'extensionDelete': 'Hapus Ekstensi', 'extensionAlreadyLatest': 'Ini sudah versi terbaru', 'extensionFind': 'Temukan Ekstensi', diff --git a/ui/flutter/lib/i18n/langs/it_it.dart b/ui/flutter/lib/i18n/langs/it_it.dart index 3c53c31d6..2372a6efa 100644 --- a/ui/flutter/lib/i18n/langs/it_it.dart +++ b/ui/flutter/lib/i18n/langs/it_it.dart @@ -68,12 +68,12 @@ const itIT = { 'deleteTaskTip': 'Conserva i file scaricati', 'delete': 'Elimina', 'newVersionTitle': 'Scopri la nuova versione @version', - 'newVersionUpdate': 'Aggiorna ora', + 'newVersionUpgrade': 'Aggiorna ora', 'newVersionLater': 'Dopo', 'extensions': 'Estensioni', 'extensionInstallUrl': 'Installa URL', 'extensionInstallSuccess': 'Installato con successo', - 'extensionUpdateSuccess': 'Aggiornato con successo', + 'extensionUpgradeSuccess': 'Aggiornato con successo', 'extensionDelete': 'Elimina estensione', 'extensionAlreadyLatest': "È già l'ultima versione", 'extensionFind': 'Trova estensioni', diff --git a/ui/flutter/lib/i18n/langs/ja_jp.dart b/ui/flutter/lib/i18n/langs/ja_jp.dart index 26bc3111a..5d0233bac 100644 --- a/ui/flutter/lib/i18n/langs/ja_jp.dart +++ b/ui/flutter/lib/i18n/langs/ja_jp.dart @@ -57,7 +57,7 @@ const jaJP = { 'deleteTaskTip': 'ダウンロードしたファイルを保持', 'delete': '削除', 'newVersionTitle': '新しいバージョン @version を発見する', - 'newVersionUpdate': 'アップデート', + 'newVersionUpgrade': 'アップデート', 'newVersionLater': '後で', 'thanks': '感謝', 'thanksDesc': 'Gopeedコミュニティの建設に協力してくださったすべての貢献者の方々に感謝します!', diff --git a/ui/flutter/lib/i18n/langs/pl_pl.dart b/ui/flutter/lib/i18n/langs/pl_pl.dart index 6327da1c4..70120a2ca 100644 --- a/ui/flutter/lib/i18n/langs/pl_pl.dart +++ b/ui/flutter/lib/i18n/langs/pl_pl.dart @@ -66,12 +66,12 @@ const plPL = { 'deleteTaskTip': 'Zachowaj pobrane pliki', 'delete': 'Usuń', 'newVersionTitle': 'Sprawdź aktualizację @version', - 'newVersionUpdate': 'Aktualizuj teraz', + 'newVersionUpgrade': 'Aktualizuj teraz', 'newVersionLater': 'Później', 'extensions': 'Wtyczki', 'extensionInstallUrl': 'Instaluj z url', 'extensionInstallSuccess': 'Instalacja zakończona', - 'extensionUpdateSuccess': 'Aktualizacja zakończona', + 'extensionUpgradeSuccess': 'Aktualizacja zakończona', 'extensionDelete': 'Usuń wtyczkę', 'extensionAlreadyLatest': 'Wtyczka w aktualnej wersji', 'extensionFind': 'Wyszukaj wtyczkę', diff --git a/ui/flutter/lib/i18n/langs/ru_ru.dart b/ui/flutter/lib/i18n/langs/ru_ru.dart index e5a6f3650..b41d4b5e7 100644 --- a/ui/flutter/lib/i18n/langs/ru_ru.dart +++ b/ui/flutter/lib/i18n/langs/ru_ru.dart @@ -61,11 +61,11 @@ const ruRU = { 'deleteTaskTip': 'Сохранить загруженные файлы', 'delete': 'Удалить', 'newVersionTitle': 'Обнаружена новая версия @version', - 'newVersionUpdate': 'Обновить', + 'newVersionUpgrade': 'Обновить', 'newVersionLater': 'позже', 'extensions': 'Расширения', 'extensionInstallSuccess': 'Установка завершена', - 'extensionUpdateSuccess': 'Обновление завершено', + 'extensionUpgradeSuccess': 'Обновление завершено', 'extensionDelete': 'Удалить расширение', 'extensionAlreadyLatest': 'Это последняя версия', 'extensionFind': 'Найти расширения', diff --git a/ui/flutter/lib/i18n/langs/ta_ta.dart b/ui/flutter/lib/i18n/langs/ta_ta.dart index e9e78338a..4ff07c71a 100644 --- a/ui/flutter/lib/i18n/langs/ta_ta.dart +++ b/ui/flutter/lib/i18n/langs/ta_ta.dart @@ -21,7 +21,8 @@ const taTA = { 'downloadLinkValid': 'பதிவிறக்க இணைப்பை உள்ளிடவும்', 'downloadLinkHit': 'பதிவிறக்க இணைப்பை உள்ளிடவும், HTTP/HTTPS/MAGNET ஆகியவற்றை ஏற்கும்@append', - 'downloadLinkHitDesktop': ', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்', + 'downloadLinkHitDesktop': + ', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்', 'download': 'பதிவிறக்கு', 'noFileSelected': 'குறைந்தபட்சம் ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்.', 'noStoragePermission': 'சேமிப்பக அனுமதி தேவை', @@ -67,12 +68,12 @@ const taTA = { 'deleteTaskTip': 'பதிவிறக்கம் செய்யப்பட்ட கோப்புகளை வைத்திரு', 'delete': 'நீக்கு', 'newVersionTitle': 'புதிய பதிப்பைக் கண்டறியவும் @version', - 'newVersionUpdate': 'இப்பொழுது புதுப்பி', + 'newVersionUpgrade': 'இப்பொழுது புதுப்பி', 'newVersionLater': 'பின்னர்', 'extensions': 'நீட்டிப்புகள்', 'extensionInstallUrl': 'நிறுவப்படும் URL', 'extensionInstallSuccess': 'வெற்றிகரமாக நிறுவப்பட்டது', - 'extensionUpdateSuccess': 'வெற்றிகரமாக புதுப்பிக்கப்பட்டது', + 'extensionUpgradeSuccess': 'வெற்றிகரமாக புதுப்பிக்கப்பட்டது', 'extensionDelete': 'நீட்டிப்பை நீக்கு', 'extensionAlreadyLatest': 'இது ஏற்கனவே சமீபத்திய பதிப்பாகும்', 'extensionFind': 'நீட்டிப்புகளைக் கண்டறியவும்', diff --git a/ui/flutter/lib/i18n/langs/tr_tr.dart b/ui/flutter/lib/i18n/langs/tr_tr.dart index 5c7912164..13bf472c1 100644 --- a/ui/flutter/lib/i18n/langs/tr_tr.dart +++ b/ui/flutter/lib/i18n/langs/tr_tr.dart @@ -20,8 +20,10 @@ const trTR = { 'followSettings': 'Ayarları takip et', 'downloadLink': 'İndirme bağlantısı', 'downloadLinkValid': 'Lütfen indirme bağlantısını girin', - 'downloadLinkHit': 'Lütfen indirme bağlantısını girin, HTTP/HTTPS/MAGNET desteklenir@append', - 'downloadLinkHitDesktop': ', veya torrent dosyasını doğrudan buraya sürükleyin', + 'downloadLinkHit': + 'Lütfen indirme bağlantısını girin, HTTP/HTTPS/MAGNET desteklenir@append', + 'downloadLinkHitDesktop': + ', veya torrent dosyasını doğrudan buraya sürükleyin', 'download': 'İndir', 'noFileSelected': 'Devam etmek için lütfen en az bir dosya seçin.', 'noStoragePermission': 'Depolama izni gerekli', @@ -38,12 +40,14 @@ const trTR = { 'defaultDirectDownload': 'Doğrudan indir seçili olsun', 'items': '@count tane', 'subscribeTracker': 'İzleyiciye abone ol', - 'subscribeFail': 'Abone olunamadı, lütfen ağı kontrol edin veya daha sonra tekrar deneyin', + 'subscribeFail': + 'Abone olunamadı, lütfen ağı kontrol edin veya daha sonra tekrar deneyin', 'update': 'Güncelle', 'updateDaily': 'Günlük olarak güncelle', 'lastUpdate': 'Son güncellenme tarihi: @time', 'addTracker': 'İzleyici ekle', - 'addTrackerHit': 'Lütfen her satıra bir tane olmak üzere izleyici sunucusu bağlantısını girin', + 'addTrackerHit': + 'Lütfen her satıra bir tane olmak üzere izleyici sunucusu bağlantısını girin', 'ui': 'Arayüz', 'theme': 'Tema', 'themeSystem': 'Sistem', @@ -58,7 +62,8 @@ const trTR = { 'apiToken': 'API anahtarı', 'notSet': 'AYARLI DEĞİL', 'set': 'AYARLI', - 'portInUse': 'Bağlantı noktası [@port] kullanımda, lütfen bağlantı noktasını değiştirin', + 'portInUse': + 'Bağlantı noktası [@port] kullanımda, lütfen bağlantı noktasını değiştirin', 'effectAfterRestart': 'Yeniden başlatma sonrası etki edecektir', 'developer': 'Geliştirici', 'logDirectory': 'Kayıt dizini', @@ -69,12 +74,12 @@ const trTR = { 'deleteTaskTip': 'İndirilen dosyaları sakla', 'delete': 'Sil', 'newVersionTitle': 'Yeni sürümü keşfet @version', - 'newVersionUpdate': 'Şimdi güncelle', + 'newVersionUpgrade': 'Şimdi güncelle', 'newVersionLater': 'Sonra', 'extensions': 'Uzantılar', 'extensionInstallUrl': 'İndirme bağlantısı', 'extensionInstallSuccess': 'Başarıyla indirildi', - 'extensionUpdateSuccess': 'Başarıyla güncellendi', + 'extensionUpgradeSuccess': 'Başarıyla güncellendi', 'extensionDelete': 'Uzantıyı sil', 'extensionAlreadyLatest': 'Bu zaten en son sürüm', 'extensionFind': 'Uzantı ara', @@ -93,7 +98,8 @@ const trTR = { 'username': 'Kullanıcı adı', 'password': 'Şifre', 'thanks': 'Teşekkürler', - 'thanksDesc': 'Gopeed topluluğunun oluşmasına ve gelişmesine yardımcı olan tüm katılımcılara teşekkürler!', + 'thanksDesc': + 'Gopeed topluluğunun oluşmasına ve gelişmesine yardımcı olan tüm katılımcılara teşekkürler!', 'browserExtension': 'Tarayıcı uzantısı', 'launchAtStartup': 'Başlangıçta başlat', 'seedConfig': 'Seed ayarları', diff --git a/ui/flutter/lib/i18n/langs/vi_vn.dart b/ui/flutter/lib/i18n/langs/vi_vn.dart index de182fa85..62a4c484a 100644 --- a/ui/flutter/lib/i18n/langs/vi_vn.dart +++ b/ui/flutter/lib/i18n/langs/vi_vn.dart @@ -65,12 +65,12 @@ const viVN = { 'deleteTaskTip': 'Giữ các tệp đã tải về', 'delete': 'Xóa', 'newVersionTitle': 'Khám phá phiên bản mới @version', - 'newVersionUpdate': 'Cập nhật ngay', + 'newVersionUpgrade': 'Cập nhật ngay', 'newVersionLater': 'Sau', 'extensions': 'Tiện ích mở rộng', 'extensionInstallUrl': 'Liên kết cài đặt', 'extensionInstallSuccess': 'Cài đặt thành công', - 'extensionUpdateSuccess': 'Cập nhật thành công', + 'extensionUpgradeSuccess': 'Cập nhật thành công', 'extensionDelete': 'Xóa tiện ích mở rộng', 'extensionAlreadyLatest': 'Đây đã là phiên bản mới nhất', 'extensionFind': 'Tìm tiện ích mở rộng', diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index a84dee909..0df25dc2d 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -69,12 +69,12 @@ const zhCN = { 'deleteTaskTip': '保留已下载的文件', 'delete': '删除', 'newVersionTitle': '发现新版本 @version', - 'newVersionUpdate': '立即更新', + 'newVersionUpgrade': '立即更新', 'newVersionLater': '稍后再说', 'extensions': '扩展', 'extensionInstallUrl': '安装链接', 'extensionInstallSuccess': '安装成功', - 'extensionUpdateSuccess': '更新成功', + 'extensionUpgradeSuccess': '更新成功', 'extensionDelete': '删除扩展', 'extensionAlreadyLatest': '已经是最新版本', 'extensionFind': '获取扩展', diff --git a/ui/flutter/lib/i18n/langs/zh_tw.dart b/ui/flutter/lib/i18n/langs/zh_tw.dart index 0a4ce12a0..5a3696072 100644 --- a/ui/flutter/lib/i18n/langs/zh_tw.dart +++ b/ui/flutter/lib/i18n/langs/zh_tw.dart @@ -69,12 +69,12 @@ const zhTW = { 'deleteTaskTip': '保留已下載的檔案', 'delete': '刪除', 'newVersionTitle': '發現新版本 @version', - 'newVersionUpdate': '立即更新', + 'newVersionUpgrade': '立即更新', 'newVersionLater': '稍後', 'extensions': '擴充功能', 'extensionInstallUrl': '安裝 URL', 'extensionInstallSuccess': '安裝成功', - 'extensionUpdateSuccess': '更新成功', + 'extensionUpgradeSuccess': '更新成功', 'extensionDelete': '刪除擴充功能', 'extensionAlreadyLatest': '已是最新版本', 'extensionFind': '尋找擴充功能', diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 06b14f4f8..8bd6fb64a 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -2,12 +2,12 @@ import 'package:args/args.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:gopeed/api/native/model/start_config.dart'; import 'package:window_manager/window_manager.dart'; -import 'api/api.dart' as api; import 'app/modules/app/controllers/app_controller.dart'; import 'app/modules/app/views/app_view.dart'; -import 'core/libgopeed_boot.dart'; +import 'api/libgopeed_boot.dart'; import 'database/database.dart'; import 'i18n/message.dart'; import 'util/locale_manager.dart'; @@ -73,10 +73,11 @@ Future init(Args args) async { final controller = Get.put(AppController()); try { - await controller.loadStartConfig(); - final startCfg = controller.startConfig.value; - controller.runningPort.value = await LibgopeedBoot.instance.start(startCfg); - api.init(startCfg.network, controller.runningAddress(), startCfg.apiToken); + final instance = await LibgopeedBoot().init(StartConfig( + storage: "bolt", + storageDir: Util.getStorageDir(), + )); + LibgopeedBoot.singleton(instance); } catch (e) { logger.e("libgopeed init fail", e); } diff --git a/ui/flutter/lib/util/extensions.dart b/ui/flutter/lib/util/extensions.dart new file mode 100644 index 000000000..b3595311b --- /dev/null +++ b/ui/flutter/lib/util/extensions.dart @@ -0,0 +1,10 @@ +extension ApplyExtension on T { + T apply(void Function(T) fn) { + fn(this); + return this; + } +} + +extension LetExtension on T { + R let(R Function(T) fn) => fn(this); +} diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index acba56491..0bda08967 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -293,18 +293,18 @@ packages: dependency: "direct dev" description: name: ffigen - sha256: d3e76c2ad48a4e7f93a29a162006f00eba46ce7c08194a77bb5c5e97d1b5ff0a + sha256: dead012f29db2be71ea152458f5eab600de98fbc244e01088ae6bf2616bceca7 url: "https://pub.dev" source: hosted - version: "8.0.2" + version: "11.0.0" file: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.1" file_picker: dependency: "direct main" description: @@ -525,30 +525,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" - url: "https://pub.dev" - source: hosted - version: "10.0.0" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 - url: "https://pub.dev" - source: hosted - version: "2.0.1" lecle_downloads_path_provider: dependency: "direct main" description: @@ -617,18 +593,18 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.5.0" math_expressions: dependency: transitive description: @@ -649,10 +625,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.10.0" mime: dependency: transitive description: @@ -697,10 +673,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.8.3" path_provider: dependency: "direct main" description: @@ -1203,14 +1179,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 - url: "https://pub.dev" - source: hosted - version: "13.0.0" watcher: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 792237bac..136348e93 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -18,7 +18,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.6.4+1 environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ">=3.0.0 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -85,7 +85,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^2.0.0 - ffigen: ^8.0.2 + ffigen: ^11.0.0 # build json model: dart run build_runner build --delete-conflicting-outputs build_runner: ^2.2.1 json_serializable: ^6.3.2 @@ -150,7 +150,7 @@ flutter_icons: ffigen: name: LibgopeedBind description: Bindings to gopeed library. - output: 'lib/core/ffi/libgopeed_bind.dart' + output: "lib/api/native/ffi/libgopeed_bind.dart" headers: entry-points: - - 'include/libgopeed.h' + - "include/libgopeed.h"