From 39cd9034cdeb3a92391db585e6d6fbe6e057b353 Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 5 Sep 2026 20:18:42 +0800 Subject: [PATCH] feat: move native communication to FFI (#1457) --- bind/desktop/main.go | 76 ++ bind/mobile/main.go | 43 ++ pkg/api/events.go | 82 +++ pkg/api/events_test.go | 37 + pkg/api/routes.go | 94 +++ pkg/api/service.go | 457 ++++++++++++ pkg/base/model.go | 21 + pkg/rest/api.go | 3 +- pkg/rest/api_server_manager.go | 214 ++++++ pkg/rest/invoke.go | 44 ++ pkg/rest/invoke_test.go | 229 ++++++ pkg/rest/model/server.go | 41 ++ pkg/rest/model/server_test.go | 45 ++ pkg/rest/server.go | 396 ++++++----- pkg/rest/server_test.go | 191 +++-- ui/flutter/.gitignore | 2 - .../kotlin/com/gopeed/gopeed/MainActivity.kt | 42 +- .../docs/backend-transport-architecture.md | 658 ++++++++++++++++++ ui/flutter/include/libgopeed.h | 7 + .../ios/Runner.xcodeproj/project.pbxproj | 6 + ui/flutter/ios/Runner/AppDelegate.swift | 21 + .../ios/Runner/GopeedTaskEventForwarder.h | 16 + .../ios/Runner/GopeedTaskEventForwarder.m | 35 + .../ios/Runner/Runner-Bridging-Header.h | 1 + ui/flutter/lib/api/api.dart | 150 ++-- .../lib/api/model/downloader_config.dart | 44 +- .../lib/api/model/downloader_config.g.dart | 44 +- .../app_notification_controller.dart | 47 +- .../application/app_platform_controller.dart | 43 +- .../application/app_runtime_controller.dart | 225 +++++- .../core/capabilities/app_capabilities.dart | 23 +- .../lib/core/common/api_server_state.dart | 64 ++ .../lib/core/common/libgopeed_channel.dart | 48 ++ ui/flutter/lib/core/common/libgopeed_ffi.dart | 82 ++- .../lib/core/common/libgopeed_interface.dart | 16 + ui/flutter/lib/core/common/start_config.dart | 1 + .../lib/core/common/start_config.g.dart | 2 + ui/flutter/lib/core/common/task_event.dart | 38 + .../lib/core/entry/app_initializer.dart | 11 - .../core/entry/libgopeed_boot_browser.dart | 38 + .../lib/core/entry/libgopeed_boot_native.dart | 28 + ui/flutter/lib/core/ffi/libgopeed_bind.dart | 66 ++ ui/flutter/lib/core/ffi/libgopeed_worker.dart | 144 ++++ ui/flutter/lib/core/libgopeed_boot.dart | 16 + .../gopeed/entry/gopeed_transport_native.dart | 93 +++ .../gopeed/entry/gopeed_transport_stub.dart | 5 + .../gopeed/entry/gopeed_transport_web.dart | 73 ++ .../core/network/gopeed/gopeed_transport.dart | 29 + .../lib/core/window/app_window_bootstrap.dart | 4 +- ui/flutter/lib/database/database.dart | 144 ---- ui/flutter/lib/database/entity.dart | 27 - ui/flutter/lib/database/entity.g.dart | 35 - .../application/settings_controller.dart | 7 + .../presentation/pages/settings_page.dart | 430 ++++++++++-- ui/flutter/lib/l10n/app_ar.arb | 14 +- ui/flutter/lib/l10n/app_ca.arb | 14 +- ui/flutter/lib/l10n/app_de.arb | 14 +- ui/flutter/lib/l10n/app_en.arb | 14 +- ui/flutter/lib/l10n/app_es.arb | 14 +- ui/flutter/lib/l10n/app_fa.arb | 14 +- ui/flutter/lib/l10n/app_fr.arb | 14 +- ui/flutter/lib/l10n/app_hu.arb | 14 +- ui/flutter/lib/l10n/app_id.arb | 14 +- ui/flutter/lib/l10n/app_it.arb | 14 +- ui/flutter/lib/l10n/app_ja.arb | 14 +- ui/flutter/lib/l10n/app_ko.arb | 14 +- ui/flutter/lib/l10n/app_pl.arb | 14 +- ui/flutter/lib/l10n/app_pt.arb | 14 +- ui/flutter/lib/l10n/app_ru.arb | 14 +- ui/flutter/lib/l10n/app_ta.arb | 14 +- ui/flutter/lib/l10n/app_tr.arb | 14 +- ui/flutter/lib/l10n/app_uk.arb | 14 +- ui/flutter/lib/l10n/app_vi.arb | 14 +- ui/flutter/lib/l10n/app_zh.arb | 14 +- ui/flutter/lib/l10n/app_zh_TW.arb | 14 +- ui/flutter/lib/main.dart | 16 +- ui/flutter/lib/util/analytics.dart | 18 +- ui/flutter/pubspec.lock | 8 - ui/flutter/pubspec.yaml | 8 +- .../test/api/downloader_config_test.dart | 32 + ui/flutter/test/api/resolve_test.dart | 59 +- .../test/core/common/task_event_test.dart | 24 + .../application/web_auth_controller_test.dart | 41 ++ .../application/settings_controller_test.dart | 41 +- .../create_task_window_page_test.dart | 23 +- ui/flutter/test/widget_test.dart | 142 +++- 86 files changed, 4558 insertions(+), 886 deletions(-) create mode 100644 pkg/api/events.go create mode 100644 pkg/api/events_test.go create mode 100644 pkg/api/routes.go create mode 100644 pkg/api/service.go create mode 100644 pkg/rest/api_server_manager.go create mode 100644 pkg/rest/invoke.go create mode 100644 pkg/rest/invoke_test.go create mode 100644 pkg/rest/model/server_test.go create mode 100644 ui/flutter/docs/backend-transport-architecture.md create mode 100644 ui/flutter/ios/Runner/GopeedTaskEventForwarder.h create mode 100644 ui/flutter/ios/Runner/GopeedTaskEventForwarder.m create mode 100644 ui/flutter/lib/core/common/api_server_state.dart create mode 100644 ui/flutter/lib/core/common/task_event.dart create mode 100644 ui/flutter/lib/core/ffi/libgopeed_worker.dart create mode 100644 ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart create mode 100644 ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_stub.dart create mode 100644 ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart create mode 100644 ui/flutter/lib/core/network/gopeed/gopeed_transport.dart delete mode 100644 ui/flutter/lib/database/database.dart delete mode 100644 ui/flutter/lib/database/entity.dart delete mode 100644 ui/flutter/lib/database/entity.g.dart create mode 100644 ui/flutter/test/core/common/task_event_test.dart create mode 100644 ui/flutter/test/features/auth/application/web_auth_controller_test.dart diff --git a/bind/desktop/main.go b/bind/desktop/main.go index b809271d..cedbe38f 100644 --- a/bind/desktop/main.go +++ b/bind/desktop/main.go @@ -1,8 +1,21 @@ package main +/* +#include +#include + +typedef void (*TaskEventCallback)(char* payload); + +static void callTaskEventCallback(uintptr_t callback, char* payload) { + ((TaskEventCallback)callback)(payload); +} +*/ import "C" import ( "encoding/json" + "fmt" + "unsafe" + "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" ) @@ -16,6 +29,7 @@ func Start(cfg *C.char) (int, *C.char) { return 0, C.CString(err.Error()) } config.ProductionMode = true + config.NativeMode = true applyWebViewProvider(&config) realPort, err := rest.Start(&config) if err != nil { @@ -28,3 +42,65 @@ func Start(cfg *C.char) (int, *C.char) { func Stop() { rest.Stop() } + +//export GetAPIServerState +func GetAPIServerState() *C.char { + return C.CString(apiServerResult(rest.GetAPIServerState())) +} + +//export StartAPIServer +func StartAPIServer() *C.char { + return C.CString(apiServerResult(rest.StartAPIServer())) +} + +//export StopAPIServer +func StopAPIServer() *C.char { + return C.CString(apiServerResult(rest.StopAPIServer())) +} + +//export RestartAPIServer +func RestartAPIServer() *C.char { + return C.CString(apiServerResult(rest.RestartAPIServer())) +} + +func apiServerResult(state *model.APIServerState, err error) string { + result := &model.APIServerOperationResult{State: state} + if err != nil { + result.Error = err.Error() + } + data, marshalErr := json.Marshal(result) + if marshalErr != nil { + return fmt.Sprintf(`{"state":null,"error":%q}`, marshalErr.Error()) + } + return string(data) +} + +//export Invoke +func Invoke(method *C.char, path *C.char, query *C.char, body *C.char) *C.char { + return C.CString(rest.Invoke( + C.GoString(method), + C.GoString(path), + C.GoString(query), + C.GoString(body), + )) +} + +//export SubscribeTaskEvents +func SubscribeTaskEvents(mask C.ulonglong, callback C.uintptr_t) { + if mask == 0 || callback == 0 { + rest.SubscribeTaskEvents(0, nil) + return + } + rest.SubscribeTaskEvents(uint64(mask), func(payload string) { + // Ownership crosses the asynchronous Dart callback boundary. Dart frees + // this string with FreeCString after decoding it. + C.callTaskEventCallback(callback, C.CString(payload)) + }) +} + +//export FreeCString +func FreeCString(value *C.char) { + if value != nil { + C.free(unsafe.Pointer(value)) + } +} diff --git a/bind/mobile/main.go b/bind/mobile/main.go index 5bdcf649..ab45ae32 100644 --- a/bind/mobile/main.go +++ b/bind/mobile/main.go @@ -4,6 +4,7 @@ package libgopeed import "C" import ( "encoding/json" + "github.com/GopeedLab/gopeed/internal/webview/rpcprovider" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" @@ -15,6 +16,7 @@ func Start(cfg string) (int, error) { return 0, err } config.ProductionMode = true + config.NativeMode = true applyWebViewProvider(&config) return rest.Start(&config) } @@ -23,6 +25,47 @@ func Stop() { rest.Stop() } +func GetAPIServerState() string { + return apiServerResult(rest.GetAPIServerState()) +} + +func StartAPIServer() string { + return apiServerResult(rest.StartAPIServer()) +} + +func StopAPIServer() string { + return apiServerResult(rest.StopAPIServer()) +} + +func RestartAPIServer() string { + return apiServerResult(rest.RestartAPIServer()) +} + +func apiServerResult(state *model.APIServerState, err error) string { + result := &model.APIServerOperationResult{State: state} + if err != nil { + result.Error = err.Error() + } + data, _ := json.Marshal(result) + return string(data) +} + +func Invoke(method, path, query, body string) string { + return rest.Invoke(method, path, query, body) +} + +type TaskEventListener interface { + OnTaskEvent(payload string) +} + +func SubscribeTaskEvents(mask int64, listener TaskEventListener) { + if mask == 0 || listener == nil { + rest.SubscribeTaskEvents(0, nil) + return + } + rest.SubscribeTaskEvents(uint64(mask), listener.OnTaskEvent) +} + func applyWebViewProvider(config *model.StartConfig) { if config == nil || config.WebViewRPCConfig == nil || !config.WebViewRPCConfig.Enabled() { return diff --git a/pkg/api/events.go b/pkg/api/events.go new file mode 100644 index 00000000..fa2e416f --- /dev/null +++ b/pkg/api/events.go @@ -0,0 +1,82 @@ +package api + +import ( + "encoding/json" + "sync" + + "github.com/GopeedLab/gopeed/pkg/download" +) + +type TaskEventMask uint64 + +const ( + TaskEventDone TaskEventMask = 1 << iota + TaskEventError +) + +type TaskEvent struct { + Type string `json:"type"` + TaskID string `json:"taskId"` + Name string `json:"name,omitempty"` + Error string `json:"error,omitempty"` +} + +type TaskEventListener func(payload string) + +type taskEventSubscription struct { + mu sync.RWMutex + mask TaskEventMask + listener TaskEventListener +} + +func (s *Service) SubscribeTaskEvents(mask TaskEventMask, listener TaskEventListener) { + s.taskEvents.mu.Lock() + defer s.taskEvents.mu.Unlock() + s.taskEvents.mask = mask + s.taskEvents.listener = listener +} + +func (s *Service) emitTaskEvent(event *download.Event) { + if event == nil || event.Task == nil { + return + } + + var mask TaskEventMask + switch event.Key { + case download.EventKeyDone: + mask = TaskEventDone + case download.EventKeyError: + mask = TaskEventError + default: + return + } + + s.taskEvents.mu.RLock() + if s.taskEvents.mask&mask == 0 || s.taskEvents.listener == nil { + s.taskEvents.mu.RUnlock() + return + } + listener := s.taskEvents.listener + s.taskEvents.mu.RUnlock() + + payload := TaskEvent{ + Type: "task." + string(event.Key), + TaskID: event.Task.ID, + } + if event.Task.Meta != nil { + payload.Name = event.Task.Name() + } + if event.Err != nil { + payload.Error = event.Err.Error() + } + data, err := json.Marshal(payload) + if err != nil { + return + } + + // A platform callback must never be able to crash a download worker. + func() { + defer func() { _ = recover() }() + listener(string(data)) + }() +} diff --git a/pkg/api/events_test.go b/pkg/api/events_test.go new file mode 100644 index 00000000..f5bad72e --- /dev/null +++ b/pkg/api/events_test.go @@ -0,0 +1,37 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/GopeedLab/gopeed/pkg/download" +) + +func TestTaskEventSubscriptionFiltersMask(t *testing.T) { + service := &Service{} + var payloads []string + service.SubscribeTaskEvents(TaskEventDone, func(payload string) { + payloads = append(payloads, payload) + }) + + task := &download.Task{ID: "task-1"} + service.emitTaskEvent(&download.Event{Key: download.EventKeyError, Task: task}) + service.emitTaskEvent(&download.Event{Key: download.EventKeyDone, Task: task}) + + if len(payloads) != 1 { + t.Fatalf("got %d callbacks, want 1", len(payloads)) + } + var event TaskEvent + if err := json.Unmarshal([]byte(payloads[0]), &event); err != nil { + t.Fatal(err) + } + if event.Type != "task.done" || event.TaskID != task.ID { + t.Fatalf("unexpected event: %#v", event) + } +} + +func TestTaskEventListenerPanicIsContained(t *testing.T) { + service := &Service{} + service.SubscribeTaskEvents(TaskEventDone, func(string) { panic("callback failed") }) + service.emitTaskEvent(&download.Event{Key: download.EventKeyDone, Task: &download.Task{ID: "task-1"}}) +} diff --git a/pkg/api/routes.go b/pkg/api/routes.go new file mode 100644 index 00000000..2dd8061a --- /dev/null +++ b/pkg/api/routes.go @@ -0,0 +1,94 @@ +package api + +import ( + "fmt" + pathpkg "path" + "strings" +) + +type HandlerFunc func(*Context) *Response + +type RouteSpec struct { + Method string + Pattern string + Handler HandlerFunc + parts []routePart +} + +type routePart struct { + literal string + param string +} + +func newRoute(method, pattern string, handler HandlerFunc) RouteSpec { + pattern = cleanPath(pattern) + return RouteSpec{ + Method: strings.ToUpper(method), + Pattern: pattern, + Handler: handler, + parts: parseRouteParts(pattern), + } +} + +func validateRoutes(routes []RouteSpec) error { + patterns := make(map[string]struct{}, len(routes)) + for _, route := range routes { + if route.Method == "" || route.Pattern == "" || route.Handler == nil { + return fmt.Errorf("invalid API route: %+v", route) + } + key := route.Method + " " + route.Pattern + if _, exists := patterns[key]; exists { + return fmt.Errorf("duplicate API route: %s", key) + } + patterns[key] = struct{}{} + } + return nil +} + +func parseRouteParts(pattern string) []routePart { + pattern = strings.Trim(pattern, "/") + if pattern == "" { + return nil + } + segments := strings.Split(pattern, "/") + parts := make([]routePart, 0, len(segments)) + for _, segment := range segments { + if strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") { + name := strings.TrimSuffix(strings.TrimPrefix(segment, "{"), "}") + parts = append(parts, routePart{param: name}) + continue + } + parts = append(parts, routePart{literal: segment}) + } + return parts +} + +func (r RouteSpec) Match(rawPath string) (map[string]string, bool) { + path := strings.Trim(cleanPath(rawPath), "/") + if path == "" && len(r.parts) == 0 { + return map[string]string{}, true + } + segments := strings.Split(path, "/") + if len(segments) != len(r.parts) { + return nil, false + } + params := make(map[string]string) + for index, part := range r.parts { + if part.param != "" { + params[part.param] = segments[index] + continue + } + if part.literal != segments[index] { + return nil, false + } + } + return params, true +} + +func cleanPath(rawPath string) string { + clean := pathpkg.Clean("/" + strings.TrimSpace(rawPath)) + if clean == "." { + return "/" + } + return clean +} diff --git a/pkg/api/service.go b/pkg/api/service.go new file mode 100644 index 00000000..b69a9fe9 --- /dev/null +++ b/pkg/api/service.go @@ -0,0 +1,457 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "runtime" + "strings" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download" + restmodel "github.com/GopeedLab/gopeed/pkg/rest/model" +) + +type Context struct { + Method string + Path string + Query url.Values + Body []byte + Headers http.Header + PathParams map[string]string +} + +func (c *Context) PathParam(name string) string { + if c == nil || c.PathParams == nil { + return "" + } + return c.PathParams[name] +} + +type Response struct { + StatusCode int + Body any +} + +type Service struct { + Downloader *download.Downloader + routes []RouteSpec + taskEvents taskEventSubscription +} + +func NewService(downloader *download.Downloader) (*Service, error) { + if downloader == nil { + return nil, fmt.Errorf("downloader is nil") + } + service := &Service{Downloader: downloader} + service.routes = []RouteSpec{ + newRoute(http.MethodGet, "/api/v1/info", service.info), + newRoute(http.MethodPost, "/api/v1/resolve", service.resolve), + newRoute(http.MethodPost, "/api/v1/tasks", service.createTask), + newRoute(http.MethodPost, "/api/v1/tasks/batch", service.createTaskBatch), + newRoute(http.MethodPatch, "/api/v1/tasks/{id}", service.patchTask), + newRoute(http.MethodPut, "/api/v1/tasks/{id}/pause", service.pauseTask), + newRoute(http.MethodPut, "/api/v1/tasks/pause", service.pauseTasks), + newRoute(http.MethodPut, "/api/v1/tasks/{id}/continue", service.continueTask), + newRoute(http.MethodPut, "/api/v1/tasks/continue", service.continueTasks), + newRoute(http.MethodDelete, "/api/v1/tasks/{id}", service.deleteTask), + newRoute(http.MethodDelete, "/api/v1/tasks", service.deleteTasks), + newRoute(http.MethodGet, "/api/v1/tasks/{id}", service.getTask), + newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", service.getTaskStatus), + newRoute(http.MethodGet, "/api/v1/tasks", service.getTasks), + newRoute(http.MethodGet, "/api/v1/tasks/{id}/stats", service.getStats), + newRoute(http.MethodGet, "/api/v1/config", service.getConfig), + newRoute(http.MethodPut, "/api/v1/config", service.putConfig), + newRoute(http.MethodPost, "/api/v1/extensions", service.installExtension), + newRoute(http.MethodGet, "/api/v1/extensions", service.getExtensions), + newRoute(http.MethodGet, "/api/v1/extensions/{identity}", service.getExtension), + newRoute(http.MethodPut, "/api/v1/extensions/{identity}/settings", service.updateExtensionSettings), + newRoute(http.MethodPut, "/api/v1/extensions/{identity}/switch", service.switchExtension), + newRoute(http.MethodDelete, "/api/v1/extensions/{identity}", service.deleteExtension), + newRoute(http.MethodGet, "/api/v1/extensions/{identity}/update", service.updateCheckExtension), + newRoute(http.MethodPost, "/api/v1/extensions/{identity}/update", service.updateExtension), + newRoute(http.MethodPost, "/api/v1/webhook/test", service.testWebhook), + } + if err := validateRoutes(service.routes); err != nil { + return nil, err + } + downloader.Listener(service.emitTaskEvent) + return service, nil +} + +func (s *Service) RouteSpecs() []RouteSpec { + return append([]RouteSpec(nil), s.routes...) +} + +func (s *Service) Dispatch(method, rawPath string, query url.Values, body []byte, headers http.Header) (response *Response) { + defer func() { + if recovered := recover(); recovered != nil { + response = newErrorResponse(fmt.Sprintf("%v", recovered)) + } + }() + + method = strings.ToUpper(strings.TrimSpace(method)) + path := cleanPath(rawPath) + for _, route := range s.routes { + if route.Method != method { + continue + } + pathParams, matched := route.Match(path) + if !matched { + continue + } + return route.Handler(&Context{ + Method: method, + Path: path, + Query: query, + Body: body, + Headers: headers, + PathParams: pathParams, + }) + } + return newStatusResponse(http.StatusNotFound, restmodel.NewErrorResult("not found")) +} + +func (s *Service) JSON(method, rawPath string, query url.Values, body []byte, headers http.Header) (int, []byte) { + response := s.Dispatch(method, rawPath, query, body, headers) + data, err := json.Marshal(response.Body) + if err != nil { + response = newErrorResponse(err.Error()) + data, _ = json.Marshal(response.Body) + } + return response.StatusCode, data +} + +func (s *Service) info(_ *Context) *Response { + return newOKResponse(map[string]any{ + "version": base.Version, + "runtime": runtime.Version(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "inDocker": base.InDocker == "true", + }) +} + +func (s *Service) resolve(ctx *Context) *Response { + var request restmodel.ResolveTask + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + result, err := s.Downloader.Resolve(request.Req, request.Opts) + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(result) +} + +func (s *Service) createTask(ctx *Context) *Response { + var request restmodel.CreateTask + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + var ( + taskID string + err error + ) + if request.Rid != "" { + taskID, err = s.Downloader.Create(request.Rid) + } else if request.Req != nil { + taskID, err = s.Downloader.CreateDirect(request.Req, request.Opts) + } else { + return newErrorResponse("param invalid: rid or req", restmodel.CodeInvalidParam) + } + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(taskID) +} + +func (s *Service) createTaskBatch(ctx *Context) *Response { + var request base.CreateTaskBatch + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + if len(request.Reqs) == 0 { + return newErrorResponse("param invalid: reqs", restmodel.CodeInvalidParam) + } + taskIDs, err := s.Downloader.CreateDirectBatch(&request) + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(taskIDs) +} + +func (s *Service) patchTask(ctx *Context) *Response { + taskID := ctx.PathParam("id") + if taskID == "" { + return invalidIDResponse() + } + var request restmodel.ResolveTask + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + if err := s.Downloader.Patch(taskID, request.Req, request.Opts); err != nil { + if err == download.ErrTaskNotFound { + return taskNotFoundResponse() + } + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) pauseTask(ctx *Context) *Response { + return s.applyTaskAction(ctx, s.Downloader.Pause) +} + +func (s *Service) pauseTasks(ctx *Context) *Response { + return applyFilterAction(taskFilterFromQuery(ctx.Query), s.Downloader.Pause) +} + +func (s *Service) continueTask(ctx *Context) *Response { + return s.applyTaskAction(ctx, s.Downloader.Continue) +} + +func (s *Service) continueTasks(ctx *Context) *Response { + return applyFilterAction(taskFilterFromQuery(ctx.Query), s.Downloader.Continue) +} + +func (s *Service) applyTaskAction(ctx *Context, action func(*download.TaskFilter) error) *Response { + taskID := ctx.PathParam("id") + if taskID == "" { + return invalidIDResponse() + } + return applyFilterAction(&download.TaskFilter{IDs: []string{taskID}}, action) +} + +func applyFilterAction(filter *download.TaskFilter, action func(*download.TaskFilter) error) *Response { + if err := action(filter); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) deleteTask(ctx *Context) *Response { + taskID := ctx.PathParam("id") + if taskID == "" { + return invalidIDResponse() + } + return deleteTasks(s.Downloader, &download.TaskFilter{IDs: []string{taskID}}, ctx.Query.Get("force") == "true") +} + +func (s *Service) deleteTasks(ctx *Context) *Response { + return deleteTasks(s.Downloader, taskFilterFromQuery(ctx.Query), ctx.Query.Get("force") == "true") +} + +func deleteTasks(downloader *download.Downloader, filter *download.TaskFilter, force bool) *Response { + if err := downloader.Delete(filter, force); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) getTask(ctx *Context) *Response { + taskID := ctx.PathParam("id") + if taskID == "" { + return invalidIDResponse() + } + task := s.Downloader.GetTask(taskID) + if task == nil { + return taskNotFoundResponse() + } + return newOKResponse(task) +} + +func (s *Service) getTaskStatus(ctx *Context) *Response { + taskID := ctx.PathParam("id") + if taskID == "" { + return invalidIDResponse() + } + status, err := s.Downloader.RuntimeStatus(taskID) + if err != nil { + if err == download.ErrTaskNotFound { + return taskNotFoundResponse() + } + return newErrorResponse(err.Error()) + } + return newOKResponse(status) +} + +func (s *Service) getTasks(ctx *Context) *Response { + return newOKResponse(s.Downloader.GetTasksByFilter(taskFilterFromQuery(ctx.Query))) +} + +func (s *Service) getStats(ctx *Context) *Response { + taskID := ctx.PathParam("id") + if taskID == "" { + return invalidIDResponse() + } + stats, err := s.Downloader.Stats(taskID) + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(stats) +} + +func (s *Service) getConfig(_ *Context) *Response { + config, err := s.Downloader.GetConfig() + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(config) +} + +func (s *Service) putConfig(ctx *Context) *Response { + var config base.DownloaderStoreConfig + if response := decodeRequest(ctx.Body, &config); response != nil { + return response + } + if err := s.Downloader.PutConfig(&config); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) installExtension(ctx *Context) *Response { + var request restmodel.InstallExtension + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + var ( + extension *download.Extension + err error + ) + if request.DevMode { + extension, err = s.Downloader.InstallExtensionByFolder(request.URL, true) + } else { + extension, err = s.Downloader.InstallExtensionByGit(request.URL) + } + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(extension.Identity) +} + +func (s *Service) getExtensions(_ *Context) *Response { + return newOKResponse(s.Downloader.GetExtensions()) +} + +func (s *Service) getExtension(ctx *Context) *Response { + identity := ctx.PathParam("identity") + if identity == "" { + return newErrorResponse("param invalid: identity", restmodel.CodeInvalidParam) + } + extension, err := s.Downloader.GetExtension(identity) + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(extension) +} + +func (s *Service) updateExtensionSettings(ctx *Context) *Response { + identity := ctx.PathParam("identity") + var request restmodel.UpdateExtensionSettings + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + if err := s.Downloader.UpdateExtensionSettings(identity, request.Settings); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) switchExtension(ctx *Context) *Response { + identity := ctx.PathParam("identity") + var request restmodel.SwitchExtension + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + if err := s.Downloader.SwitchExtension(identity, request.Status); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) deleteExtension(ctx *Context) *Response { + if err := s.Downloader.DeleteExtension(ctx.PathParam("identity")); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) updateCheckExtension(ctx *Context) *Response { + version, err := s.Downloader.UpgradeCheckExtension(ctx.PathParam("identity")) + if err != nil { + return newErrorResponse(err.Error()) + } + return newOKResponse(&restmodel.UpdateCheckExtensionResp{NewVersion: version}) +} + +func (s *Service) updateExtension(ctx *Context) *Response { + if err := s.Downloader.UpgradeExtension(ctx.PathParam("identity")); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func (s *Service) testWebhook(ctx *Context) *Response { + var request restmodel.TestWebhookReq + if response := decodeRequest(ctx.Body, &request); response != nil { + return response + } + if err := s.Downloader.TestWebhookUrl(request.URL); err != nil { + return newErrorResponse(err.Error()) + } + return newNilResponse() +} + +func decodeRequest(body []byte, target any) *Response { + if err := json.NewDecoder(bytes.NewReader(body)).Decode(target); err != nil { + return newErrorResponse(err.Error()) + } + return nil +} + +func taskFilterFromQuery(query url.Values) *download.TaskFilter { + if query == nil { + query = url.Values{} + } + return &download.TaskFilter{ + IDs: query["id"], + Statuses: convertStatuses(query["status"]), + NotStatuses: convertStatuses(query["notStatus"]), + } +} + +func convertStatuses(values []string) []base.Status { + statuses := make([]base.Status, 0, len(values)) + for _, value := range values { + statuses = append(statuses, base.Status(value)) + } + return statuses +} + +func invalidIDResponse() *Response { + return newErrorResponse("param invalid: id", restmodel.CodeInvalidParam) +} + +func taskNotFoundResponse() *Response { + return newErrorResponse("task not found", restmodel.CodeTaskNotFound) +} + +func newOKResponse(data any) *Response { + return &Response{StatusCode: http.StatusOK, Body: restmodel.NewOkResult(data)} +} + +func newNilResponse() *Response { + return &Response{StatusCode: http.StatusOK, Body: restmodel.NewNilResult()} +} + +func newErrorResponse(message string, code ...restmodel.RespCode) *Response { + return &Response{StatusCode: http.StatusOK, Body: restmodel.NewErrorResult(message, code...)} +} + +func newStatusResponse(statusCode int, body any) *Response { + return &Response{StatusCode: statusCode, Body: body} +} diff --git a/pkg/base/model.go b/pkg/base/model.go index b77e9928..26417845 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -208,6 +208,7 @@ type DownloaderStoreConfig struct { Script *ScriptConfig `json:"script"` // Script is the script execution configuration AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration + API *APIServerConfig `json:"api"` // API is the optional REST server configuration AutoStartTasks bool `json:"autoStartTasks"` // AutoStartTasks continues all unfinished tasks when the backend starts AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files } @@ -274,6 +275,26 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl if cfg.Archive == nil { cfg.Archive = beforeCfg.Archive } + if cfg.API == nil { + cfg.API = beforeCfg.API + } + return cfg +} + +type APIServerConfig struct { + Enable bool `json:"enable"` + Network string `json:"network"` + Address string `json:"address"` + Token string `json:"token"` +} + +func (cfg *APIServerConfig) Init() *APIServerConfig { + if cfg.Network == "" { + cfg.Network = "tcp" + } + if cfg.Address == "" { + cfg.Address = "127.0.0.1:9999" + } return cfg } diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 888c3cc6..cd65b194 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -351,7 +351,8 @@ func DoProxy(w http.ResponseWriter, r *http.Request) { r.RequestURI = "" r.URL = targetUrl r.Host = targetUrl.Host - r.Header.Del("Authorization") + r.Header.Del("Cookie") + r.Header.Del("X-Api-Token") r.Header.Del("X-Target-Uri") resp, err := http.DefaultClient.Do(r) if err != nil { diff --git a/pkg/rest/api_server_manager.go b/pkg/rest/api_server_manager.go new file mode 100644 index 00000000..01e1e2a5 --- /dev/null +++ b/pkg/rest/api_server_manager.go @@ -0,0 +1,214 @@ +package rest + +import ( + "context" + "net" + "net/http" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/rest/model" + "github.com/GopeedLab/gopeed/pkg/util" + "github.com/pkg/errors" +) + +type apiServerManager struct { + server *http.Server + runningPort int + activeConfig *base.APIServerConfig + lastError string +} + +var apiServer = &apiServerManager{} + +func GetAPIServerState() (*model.APIServerState, error) { + runtimeMu.Lock() + defer runtimeMu.Unlock() + desired, err := desiredAPIServerConfigLocked() + return apiServer.stateLocked(desired), err +} + +func StartAPIServer() (*model.APIServerState, error) { + runtimeMu.Lock() + defer runtimeMu.Unlock() + desired, err := desiredAPIServerConfigLocked() + if err != nil { + return apiServer.stateLocked(nil), err + } + if !desired.Enable { + err = errors.New("API server is disabled in persisted config") + apiServer.lastError = err.Error() + return apiServer.stateLocked(desired), err + } + _, err = apiServer.startLocked(desired) + return apiServer.stateLocked(desired), err +} + +func StopAPIServer() (*model.APIServerState, error) { + runtimeMu.Lock() + defer runtimeMu.Unlock() + desired, configErr := desiredAPIServerConfigLocked() + err := apiServer.stopLocked() + if err == nil { + err = configErr + } + return apiServer.stateLocked(desired), err +} + +func RestartAPIServer() (*model.APIServerState, error) { + runtimeMu.Lock() + defer runtimeMu.Unlock() + desired, err := desiredAPIServerConfigLocked() + if err != nil { + return apiServer.stateLocked(nil), err + } + if !desired.Enable { + err = errors.New("API server is disabled in persisted config") + apiServer.lastError = err.Error() + return apiServer.stateLocked(desired), err + } + err = apiServer.restartLocked(desired) + return apiServer.stateLocked(desired), err +} + +func desiredAPIServerConfigLocked() (*base.APIServerConfig, error) { + if Downloader == nil { + return nil, errors.New("service not started") + } + config, err := Downloader.GetConfig() + if err != nil { + return nil, err + } + if config.API == nil { + return (&base.APIServerConfig{}).Init(), nil + } + return cloneAPIServerConfig(config.API).Init(), nil +} + +func (m *apiServerManager) startLocked(config *base.APIServerConfig) (int, error) { + if config == nil { + return 0, errors.New("API server config is nil") + } + if m.server != nil { + if sameAPIServerConfig(m.activeConfig, config) { + m.lastError = "" + return m.runningPort, nil + } + err := errors.New("API server is already running with different config") + m.lastError = err.Error() + return m.runningPort, err + } + + return m.startWithConfigLocked(startConfigForAPIServer(config), config) +} + +func (m *apiServerManager) startWithConfigLocked( + startConfig *model.StartConfig, + activeConfig *base.APIServerConfig, +) (int, error) { + server, listener, err := buildServer(startConfig, false) + if err != nil { + m.lastError = err.Error() + return 0, err + } + m.server = server + m.runningPort = 0 + m.activeConfig = cloneAPIServerConfig(activeConfig) + m.activeConfig.Enable = true + m.lastError = "" + if address, ok := listener.Addr().(*net.TCPAddr); ok { + m.runningPort = address.Port + } + go m.serve(server, listener) + return m.runningPort, nil +} + +func (m *apiServerManager) serve(server *http.Server, listener net.Listener) { + err := server.Serve(listener) + if err == nil || errors.Is(err, http.ErrServerClosed) { + return + } + runtimeMu.Lock() + if m.server == server { + m.server = nil + m.runningPort = 0 + m.activeConfig = nil + m.lastError = err.Error() + } + downloader := Downloader + runtimeMu.Unlock() + if downloader != nil { + downloader.Logger.Error().Err(err).Msg("API server stopped unexpectedly") + } +} + +func (m *apiServerManager) stopLocked() error { + server := m.server + config := cloneAPIServerConfig(m.activeConfig) + if server == nil { + m.runningPort = 0 + m.activeConfig = nil + m.lastError = "" + return nil + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + err := server.Shutdown(shutdownCtx) + cancel() + if err != nil { + if closeErr := server.Close(); closeErr != nil { + err = errors.Wrapf(err, "force close API server failed: %v", closeErr) + } + } + m.server = nil + m.runningPort = 0 + m.activeConfig = nil + if config != nil && config.Network == "unix" { + util.SafeRemove(config.Address) + } + if err != nil { + m.lastError = err.Error() + return errors.Wrap(err, "shutdown API server failed") + } + m.lastError = "" + return nil +} + +func (m *apiServerManager) restartLocked(desired *base.APIServerConfig) error { + if m.server == nil { + _, err := m.startLocked(desired) + return err + } + if sameAPIServerConfig(m.activeConfig, desired) { + m.lastError = "" + return nil + } + if err := m.stopLocked(); err != nil { + return err + } + _, err := m.startLocked(desired) + return err +} + +func (m *apiServerManager) stateLocked(desired *base.APIServerConfig) *model.APIServerState { + state := &model.APIServerState{Running: m.server != nil, RunningPort: m.runningPort, LastError: m.lastError} + if desired != nil { + state.Enabled = desired.Enable + } + if m.activeConfig != nil { + state.Network = m.activeConfig.Network + state.Address = m.activeConfig.Address + } + state.PendingApply = !apiServerConfigApplied(desired, m.activeConfig) + return state +} + +func apiServerConfigApplied(desired, active *base.APIServerConfig) bool { + if desired == nil { + return false + } + if !desired.Enable { + return active == nil + } + return active != nil && sameAPIServerConfig(desired, active) +} diff --git a/pkg/rest/invoke.go b/pkg/rest/invoke.go new file mode 100644 index 00000000..122d20c3 --- /dev/null +++ b/pkg/rest/invoke.go @@ -0,0 +1,44 @@ +package rest + +import ( + "encoding/json" + "net/url" + + goapi "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/rest/model" +) + +const ( + TaskEventDone = uint64(goapi.TaskEventDone) + TaskEventError = uint64(goapi.TaskEventError) +) + +// Invoke dispatches a Core API request without using an HTTP listener. It is +// the shared entry point for Desktop FFI and gomobile bindings. +func Invoke(method, path, rawQuery, body string) string { + service := APIService + if service == nil { + return marshalInvokeResult(model.NewErrorResult("service not started")) + } + query, err := url.ParseQuery(rawQuery) + if err != nil { + return marshalInvokeResult(model.NewErrorResult(err.Error())) + } + _, data := service.JSON(method, path, query, []byte(body), nil) + return string(data) +} + +func SubscribeTaskEvents(mask uint64, listener func(payload string)) { + if APIService == nil { + return + } + APIService.SubscribeTaskEvents(goapi.TaskEventMask(mask), listener) +} + +func marshalInvokeResult(value any) string { + data, err := json.Marshal(value) + if err != nil { + return `{"code":1000,"msg":"marshal response failed"}` + } + return string(data) +} diff --git a/pkg/rest/invoke_test.go b/pkg/rest/invoke_test.go new file mode 100644 index 00000000..6bafc4da --- /dev/null +++ b/pkg/rest/invoke_test.go @@ -0,0 +1,229 @@ +package rest + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "testing" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/rest/model" +) + +func TestNativeInvokeWithoutRESTListener(t *testing.T) { + cfg := &model.StartConfig{ + NativeMode: true, + Storage: model.StorageMem, + } + port, err := Start(cfg) + if err != nil { + t.Fatal(err) + } + defer Stop() + if port != 0 { + t.Fatalf("native runtime opened REST port %d by default", port) + } + + var result model.Result[json.RawMessage] + if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + t.Fatal(err) + } + if result.Code != model.CodeOk { + t.Fatalf("invoke failed: %#v", result) + } +} + +func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) { + port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}) + if err != nil { + t.Fatal(err) + } + defer Stop() + if port != 0 { + t.Fatalf("native runtime opened REST port %d by default", port) + } + + putAPIServerConfig(t, &base.APIServerConfig{ + Enable: true, + Network: "tcp", + Address: "127.0.0.1:0", + Token: "secret", + }) + state, err := StartAPIServer() + if err != nil { + t.Fatal(err) + } + port = state.RunningPort + if port == 0 { + t.Fatal("enabling the REST API did not open a TCP listener") + } + + request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port), nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Api-Token", "secret") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("REST API returned status %d", response.StatusCode) + } + + putAPIServerConfig(t, &base.APIServerConfig{Enable: false}) + if state, err := StopAPIServer(); err != nil { + t.Fatal(err) + } else if state.Running || state.RunningPort != 0 || state.PendingApply { + t.Fatalf("unexpected stopped API server state: %#v", state) + } + + var result model.Result[json.RawMessage] + if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + t.Fatal(err) + } + if result.Code != model.CodeOk { + t.Fatalf("in-process API stopped with REST listener: %#v", result) + } + + if _, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port)); err == nil { + t.Fatal("REST listener still accepted connections after it was disabled") + } +} + +func TestRestartAPIServerFailsFastAndLeavesListenerStopped(t *testing.T) { + if _, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}); err != nil { + t.Fatal(err) + } + defer Stop() + + putAPIServerConfig(t, &base.APIServerConfig{ + Enable: true, + Network: "tcp", + Address: "127.0.0.1:0", + Token: "secret", + }) + startedState, err := StartAPIServer() + if err != nil { + t.Fatal(err) + } + previousPort := startedState.RunningPort + putAPIServerConfig(t, &base.APIServerConfig{ + Enable: true, + Network: "unsupported-network", + Address: "invalid", + }) + state, err := RestartAPIServer() + if err == nil { + t.Fatal("invalid listener configuration unexpectedly succeeded") + } + if state.Running || state.RunningPort != 0 || !state.PendingApply || state.LastError == "" { + t.Fatalf("failed restart did not leave REST listener stopped: %#v", state) + } + + if _, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", previousPort)); err == nil { + t.Fatal("previous REST listener still accepted connections after failed restart") + } + + var result model.Result[json.RawMessage] + if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + t.Fatal(err) + } + if result.Code != model.CodeOk { + t.Fatalf("in-process API unavailable after failed REST restart: %#v", result) + } +} + +func TestNativeRESTSettingPersistsInGoStorage(t *testing.T) { + storageDir := filepath.Join(t.TempDir(), "storage") + start := func() int { + port, err := Start(&model.StartConfig{ + NativeMode: true, + Storage: model.StorageBolt, + StorageDir: storageDir, + }) + if err != nil { + t.Fatal(err) + } + return port + } + + if port := start(); port != 0 { + t.Fatalf("first native start opened REST port %d", port) + } + config, err := Downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + config.API = newAPIServerConfigForTest() + if err := Downloader.PutConfig(config); err != nil { + t.Fatal(err) + } + Stop() + + if port := start(); port == 0 { + Stop() + t.Fatal("persisted REST setting did not open a listener") + } + Stop() +} + +func TestNativeAPIServerAutoStartFailureKeepsCoreAvailable(t *testing.T) { + storageDir := filepath.Join(t.TempDir(), "storage") + startConfig := &model.StartConfig{ + NativeMode: true, + Storage: model.StorageBolt, + StorageDir: storageDir, + } + if _, err := Start(startConfig); err != nil { + t.Fatal(err) + } + putAPIServerConfig(t, &base.APIServerConfig{ + Enable: true, + Network: "unsupported-network", + Address: "invalid", + }) + Stop() + + port, err := Start(startConfig) + if err != nil { + t.Fatalf("optional REST listener failure stopped native core startup: %v", err) + } + defer Stop() + if port != 0 { + t.Fatalf("failed REST listener reported running port %d", port) + } + state, err := GetAPIServerState() + if err != nil { + t.Fatal(err) + } + if !state.Enabled || state.Running || !state.PendingApply || state.LastError == "" { + t.Fatalf("unexpected state after optional REST listener failure: %#v", state) + } + + var result model.Result[json.RawMessage] + if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil { + t.Fatal(err) + } + if result.Code != model.CodeOk { + t.Fatalf("in-process API unavailable after REST listener failure: %#v", result) + } +} + +func newAPIServerConfigForTest() *base.APIServerConfig { + return &base.APIServerConfig{Enable: true, Network: "tcp", Address: "127.0.0.1:0"} +} + +func putAPIServerConfig(t *testing.T, apiConfig *base.APIServerConfig) { + t.Helper() + config, err := Downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + config.API = apiConfig + if err := Downloader.PutConfig(config); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/rest/model/server.go b/pkg/rest/model/server.go index 4e586c1c..6f05434b 100644 --- a/pkg/rest/model/server.go +++ b/pkg/rest/model/server.go @@ -1,6 +1,8 @@ package model import ( + "errors" + "github.com/GopeedLab/gopeed/pkg/base" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" "io/fs" @@ -16,6 +18,7 @@ const ( type StartConfig struct { Network string `json:"network"` Address string `json:"address"` + ApiEnable *bool `json:"apiEnable,omitempty"` RefreshInterval int `json:"refreshInterval"` Storage Storage `json:"storage"` StorageDir string `json:"storageDir"` @@ -25,6 +28,7 @@ type StartConfig struct { WebViewRPCConfig *enginewebview.RPCConfig `json:"webViewRpcConfig,omitempty"` ProductionMode bool + NativeMode bool WebViewProvider enginewebview.Provider `json:"-"` WebEnable bool @@ -32,6 +36,26 @@ type StartConfig struct { WebAuth *WebAuth } +func (cfg *StartConfig) APIEnabled() bool { + if cfg == nil || cfg.WebEnable { + return true + } + // Preserve the behavior of existing CLI and library callers. Native + // Flutter explicitly sends false when it wants an in-process-only runtime. + return cfg.ApiEnable == nil || *cfg.ApiEnable +} + +func (cfg *StartConfig) Validate() error { + if cfg == nil { + return nil + } + if cfg.WebEnable && cfg.ApiToken != "" && + (cfg.WebAuth == nil || cfg.WebAuth.Username == "" || cfg.WebAuth.Password == "") { + return errors.New("web authentication username and password are required when API token is enabled for the web server") + } + return nil +} + func (cfg *StartConfig) Init() *StartConfig { if cfg.Network == "" { cfg.Network = "tcp" @@ -55,3 +79,20 @@ type WebAuth struct { Username string Password string } + +// APIServerState describes the listener that is actually running. The +// persisted APIServerConfig remains the desired state. +type APIServerState struct { + Enabled bool `json:"enabled"` + Running bool `json:"running"` + Network string `json:"network"` + Address string `json:"address"` + RunningPort int `json:"runningPort"` + PendingApply bool `json:"pendingApply"` + LastError string `json:"lastError"` +} + +type APIServerOperationResult struct { + State *APIServerState `json:"state"` + Error string `json:"error"` +} diff --git a/pkg/rest/model/server_test.go b/pkg/rest/model/server_test.go new file mode 100644 index 00000000..5593a331 --- /dev/null +++ b/pkg/rest/model/server_test.go @@ -0,0 +1,45 @@ +package model + +import "testing" + +func TestStartConfigValidateWebAPIToken(t *testing.T) { + tests := []struct { + name string + config *StartConfig + wantErr bool + }{ + { + name: "web API token without web auth", + config: &StartConfig{WebEnable: true, ApiToken: "token"}, + wantErr: true, + }, + { + name: "web API token with web auth", + config: &StartConfig{ + WebEnable: true, + ApiToken: "token", + WebAuth: &WebAuth{ + Username: "admin", + Password: "secret", + }, + }, + }, + { + name: "web without authentication", + config: &StartConfig{WebEnable: true}, + }, + { + name: "native REST API token", + config: &StartConfig{NativeMode: true, ApiToken: "token"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/rest/server.go b/pkg/rest/server.go index 99fd9f35..6cff2c0d 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -1,9 +1,6 @@ package rest import ( - "context" - "crypto/aes" - "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" @@ -15,10 +12,12 @@ import ( "os" "path/filepath" "regexp" - "strconv" "strings" + "sync" "time" + goapi "github.com/GopeedLab/gopeed/pkg/api" + "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/rest/model" "github.com/GopeedLab/gopeed/pkg/util" @@ -28,83 +27,154 @@ import ( ) var ( - srv *http.Server - runningPort int - aesKey []byte + runtimeMu sync.Mutex Downloader *download.Downloader + APIService *goapi.Service ) const ( - webAuthCookieName = "gopeed_web_auth" - webAuthTokenTTL = 7 * 24 * time.Hour + webAuthCookieName = "gopeed-session" + webAuthSessionTTL = 7 * 24 * time.Hour ) -func Start(startCfg *model.StartConfig) (port int, err error) { - // avoid repeat start - if srv != nil { - return runningPort, nil +type webSessionStore struct { + mu sync.Mutex + sessions map[string]time.Time + now func() time.Time +} + +func newWebSessionStore() *webSessionStore { + return &webSessionStore{ + sessions: make(map[string]time.Time), + now: time.Now, + } +} + +func (s *webSessionStore) create() (string, time.Time, error) { + rawToken := make([]byte, 32) + if _, err := rand.Read(rawToken); err != nil { + return "", time.Time{}, errors.Wrap(err, "generate web session failed") } - var listener net.Listener - srv, listener, err = BuildServer(startCfg) - if err != nil { - return - } + sessionID := base64.RawURLEncoding.EncodeToString(rawToken) + expiresAt := s.now().Add(webAuthSessionTTL) - go func() { - if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - panic(err) + s.mu.Lock() + defer s.mu.Unlock() + for existingToken, expiry := range s.sessions { + if !expiry.After(s.now()) { + delete(s.sessions, existingToken) } - }() - - if addr, ok := listener.Addr().(*net.TCPAddr); ok { - port = addr.Port - runningPort = port } - return + s.sessions[sessionID] = expiresAt + return sessionID, expiresAt, nil +} + +func (s *webSessionStore) valid(sessionID string) bool { + if sessionID == "" { + return false + } + + s.mu.Lock() + defer s.mu.Unlock() + expiresAt, ok := s.sessions[sessionID] + if !ok { + return false + } + if !expiresAt.After(s.now()) { + delete(s.sessions, sessionID) + return false + } + return true +} + +func Start(startCfg *model.StartConfig) (port int, err error) { + runtimeMu.Lock() + defer runtimeMu.Unlock() + + // avoid repeat start + if APIService != nil { + return apiServer.runningPort, nil + } + if startCfg == nil { + startCfg = &model.StartConfig{} + } + startCfg.Init() + if err = startCfg.Validate(); err != nil { + return 0, err + } + if err = initializeCore(startCfg); err != nil { + return 0, err + } + if startCfg.NativeMode { + storedConfig, configErr := Downloader.GetConfig() + if configErr != nil { + return 0, configErr + } + apiConfig := storedConfig.API + if apiConfig == nil { + apiConfig = (&base.APIServerConfig{}).Init() + } else { + apiConfig.Init() + } + startCfg.ApiEnable = &apiConfig.Enable + startCfg.Network = apiConfig.Network + startCfg.Address = apiConfig.Address + startCfg.ApiToken = apiConfig.Token + } + if err := Downloader.ContinueOnStartup(); err != nil { + Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed") + } + if !startCfg.APIEnabled() { + return 0, nil + } + config := &base.APIServerConfig{ + Enable: true, + Network: startCfg.Network, + Address: startCfg.Address, + Token: startCfg.ApiToken, + } + port, startErr := apiServer.startWithConfigLocked(startCfg, config) + if startErr != nil && startCfg.NativeMode { + Downloader.Logger.Error().Err(startErr).Msg("optional API server failed to start") + return 0, nil + } + return port, startErr } func Stop() { - defer func() { - srv = nil - }() - - if srv != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - Downloader.Logger.Warn().Err(err).Msg("shutdown server failed") - } + runtimeMu.Lock() + defer runtimeMu.Unlock() + if err := apiServer.stopLocked(); err != nil && Downloader != nil { + Downloader.Logger.Warn().Err(err).Msg("shutdown server failed") + } + if APIService != nil { + APIService.SubscribeTaskEvents(0, nil) } if Downloader != nil { if err := Downloader.Close(); err != nil { Downloader.Logger.Warn().Err(err).Msg("close downloader failed") } } + APIService = nil + apiServer = &apiServerManager{} } func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error) { + return buildServer(startCfg, true) +} + +func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Server, net.Listener, error) { if startCfg == nil { startCfg = &model.StartConfig{} } startCfg.Init() + if err := startCfg.Validate(); err != nil { + return nil, nil, err + } - downloadCfg := &download.DownloaderConfig{ - ProductionMode: startCfg.ProductionMode, - RefreshInterval: startCfg.RefreshInterval, - WhiteDownloadDirs: startCfg.WhiteDownloadDirs, - WebViewProvider: startCfg.WebViewProvider, - } - 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 { + if err := initializeCore(startCfg); err != nil { return nil, nil, err } @@ -112,77 +182,66 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error util.SafeRemove(startCfg.Address) } - if startCfg.WebEnable { - aesKey = make([]byte, 32) - if _, err := rand.Read(aesKey); err != nil { - return nil, nil, errors.Wrap(err, "generate aes key failed") - } - } - listener, err := net.Listen(startCfg.Network, startCfg.Address) if err != nil { return nil, nil, err } - if err := Downloader.ContinueOnStartup(); err != nil { - Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed") + if continueOnStartup { + if err := Downloader.ContinueOnStartup(); err != nil { + Downloader.Logger.Warn().Err(err).Msg("auto-start tasks failed") + } } 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.MethodPatch).Path("/api/v1/tasks/{id}").HandlerFunc(PatchTask) - 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/{id}/status").HandlerFunc(GetTaskStatus) - 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.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook) - r.Path("/api/v1/proxy").HandlerFunc(DoProxy) + for _, route := range APIService.RouteSpecs() { + route := route + r.Methods(route.Method).Path(route.Pattern).HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + WriteJson(w, model.NewErrorResult(err.Error())) + return + } + response := route.Handler(&goapi.Context{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Body: body, + Headers: r.Header, + PathParams: mux.Vars(r), + }) + WriteStatusJson(w, response.StatusCode, response.Body) + }) + } + r.Path("/api/web/proxy").HandlerFunc(DoProxy) enableApiToken := startCfg.ApiToken != "" enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil + var webSessions *webSessionStore + if enableWebAuth { + webSessions = newWebSessionStore() + } if startCfg.WebEnable { if enableWebAuth { r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var loginReq model.WebAuth if ReadJson(r, w, &loginReq) { if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password { - // Generate a login token, Username:Password:Timestamp - timestamp := time.Now().Unix() - tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp) - token, err := aesEncrypt(aesKey, []byte(tokenData)) + sessionID, expiresAt, err := webSessions.create() if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } - http.SetCookie(w, &http.Cookie{ Name: webAuthCookieName, - Value: token, + Value: sessionID, Path: "/", - Expires: time.Now().Add(webAuthTokenTTL), - MaxAge: int(webAuthTokenTTL.Seconds()), + MaxAge: int(webAuthSessionTTL.Seconds()), + Expires: expiresAt, HttpOnly: true, Secure: requestUsesHTTPS(r), SameSite: http.SameSiteLaxMode, }) + WriteJson(w, model.NewNilResult()) return } @@ -201,19 +260,15 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if enableWebAuth && isProtectedWebFilePath(r.URL.Path) { - cookie, err := r.Cookie(webAuthCookieName) - if err != nil || !validWebAuthToken(cookie.Value, startCfg.WebAuth) { - writeUnauthorized(w, r) - return - } + protectedPath := strings.HasPrefix(r.URL.Path, "/api/") || isProtectedWebFilePath(r.URL.Path) + if r.URL.Path == "/api/web/login" || !protectedPath { h.ServeHTTP(w, r) return } if enableApiToken { apiTokenHeader := r.Header["X-Api-Token"] - // If api token header is set, only check api token ignore basic auth + // If an API token header is set, validate it before other authentication methods. if len(apiTokenHeader) > 0 { if apiTokenHeader[0] == startCfg.ApiToken { h.ServeHTTP(w, r) @@ -226,13 +281,12 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error } if enableWebAuth { - if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" { - h.ServeHTTP(w, r) + cookie, err := r.Cookie(webAuthCookieName) + if err != nil { + writeUnauthorized(w, r) return } - - cookie, err := r.Cookie(webAuthCookieName) - if err != nil || !validWebAuthToken(cookie.Value, startCfg.WebAuth) { + if !webSessions.valid(cookie.Value) { writeUnauthorized(w, r) return } @@ -259,38 +313,78 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error }) }) - srv = &http.Server{Handler: handlers.CORS( + server := &http.Server{Handler: handlers.CORS( handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), handlers.AllowedOrigins([]string{"*"}), handlers.AllowCredentials(), )(r)} - return srv, listener, nil + return server, listener, nil +} + +func startConfigForAPIServer(config *base.APIServerConfig) *model.StartConfig { + enabled := config.Enable + return &model.StartConfig{ + Network: config.Network, + Address: config.Address, + ApiEnable: &enabled, + ApiToken: config.Token, + NativeMode: true, + Storage: model.StorageMem, + WebEnable: false, + ProductionMode: true, + } +} + +func cloneAPIServerConfig(config *base.APIServerConfig) *base.APIServerConfig { + if config == nil { + return nil + } + cloned := *config + return &cloned +} + +func sameAPIServerConfig(left, right *base.APIServerConfig) bool { + return left != nil && right != nil && + left.Enable == right.Enable && left.Network == right.Network && + left.Address == right.Address && left.Token == right.Token +} + +func initializeCore(startCfg *model.StartConfig) error { + if APIService != nil { + return nil + } + downloadCfg := &download.DownloaderConfig{ + ProductionMode: startCfg.ProductionMode, + RefreshInterval: startCfg.RefreshInterval, + WhiteDownloadDirs: startCfg.WhiteDownloadDirs, + WebViewProvider: startCfg.WebViewProvider, + } + 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 err + } + service, err := goapi.NewService(Downloader) + if err != nil { + _ = Downloader.Close() + Downloader = nil + return err + } + APIService = service + return nil } func isProtectedWebFilePath(urlPath string) bool { return urlPath == "/fs" || strings.HasPrefix(urlPath, "/fs/") } -func validWebAuthToken(token string, auth *model.WebAuth) bool { - if token == "" || auth == nil { - return false - } - tokenData, err := aesDecrypt(aesKey, token) - if err != nil { - return false - } - parts := strings.SplitN(string(tokenData), ":", 3) - if len(parts) != 3 || parts[0] != auth.Username || parts[1] != auth.Password { - return false - } - timestamp, err := strconv.ParseInt(parts[2], 10, 64) - if err != nil { - return false - } - return time.Now().Unix()-timestamp <= int64(webAuthTokenTTL.Seconds()) -} - func requestUsesHTTPS(r *http.Request) bool { if r.TLS != nil { return true @@ -430,47 +524,3 @@ func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { w.WriteHeader(statusCode) json.NewEncoder(w).Encode(v) } - -func aesEncrypt(key, data []byte) (string, error) { - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - - cipherText := gcm.Seal(nonce, nonce, data, nil) - return base64.StdEncoding.EncodeToString(cipherText), nil -} - -func aesDecrypt(key []byte, encryptedData string) ([]byte, error) { - cipherText, err := base64.StdEncoding.DecodeString(encryptedData) - if err != nil { - return nil, err - } - - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - if len(cipherText) < gcm.NonceSize() { - return nil, errors.New("ciphertext too short") - } - - nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():] - return gcm.Open(nil, nonce, cipherText, nil) -} diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index ff9b4dc3..35d6abed 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -643,7 +643,7 @@ func TestWebFsEnhance(t *testing.T) { func TestDoProxy(t *testing.T) { doTest(func() { - code, respBody := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{ + code, respBody := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{ "X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md", }, nil) if code != http.StatusOK { @@ -657,7 +657,7 @@ func TestDoProxy(t *testing.T) { }) doTest(func() { - code, _ := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{ + code, _ := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{ "X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND", }, nil) if code != http.StatusNotFound { @@ -802,6 +802,28 @@ func TestApiToken(t *testing.T) { } +func TestBuildServerRejectsWebAPITokenWithoutWebAuth(t *testing.T) { + cfg := (&model.StartConfig{ + Storage: model.StorageMem, + WebEnable: true, + ApiToken: "123456", + }).Init() + + server, listener, err := BuildServer(cfg) + if err == nil { + if listener != nil { + listener.Close() + } + if server != nil { + server.Close() + } + t.Fatal("BuildServer() should reject an API token without web authentication") + } + if server != nil || listener != nil { + t.Fatal("invalid web authentication config must fail before opening a listener") + } +} + func TestAuthorization(t *testing.T) { var cfg = &model.StartConfig{} cfg.Init() @@ -827,118 +849,74 @@ func TestAuthorization(t *testing.T) { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } - status, loginHeaders, loginBody := doHttpRequest1(http.MethodPost, "/api/web/login", nil, cfg.WebAuth) - if status != http.StatusOK { - t.Fatalf("login status = %d, want %d", status, http.StatusOK) - } - var loginResult model.Result[any] - if err := json.Unmarshal(loginBody, &loginResult); err != nil { - t.Fatal(err) - } - if loginResult.Data != nil { - t.Fatalf("login response exposed session data: %#v", loginResult.Data) - } - loginResponse := &http.Response{Header: http.Header{"Set-Cookie": []string{loginHeaders["Set-Cookie"]}}} - cookies := loginResponse.Cookies() - if len(cookies) != 1 { - t.Fatalf("login cookies = %d, want 1", len(cookies)) - } - authCookie := cookies[0] - if authCookie.Name != webAuthCookieName || authCookie.Value == "" || authCookie.Path != "/" || - !authCookie.HttpOnly || authCookie.SameSite != http.SameSiteLaxMode || authCookie.MaxAge <= 0 { - t.Fatalf("unexpected login cookie: %#v", authCookie) - } - bearerHeaders := map[string]string{ - "Authorization": "Bearer " + authCookie.Value, - } - authCookieHeader := map[string]string{ - "Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value), - } - - for _, filePath := range []string{ - "/fs/extensions/not_exist/icon.png", - "/fs/tasks/not_exist/file.bin", - } { - status, _ = doHttpRequest0(http.MethodGet, filePath, nil, nil) - if status != http.StatusUnauthorized { - t.Errorf("unauthenticated %s status = %d, want %d", filePath, status, http.StatusUnauthorized) - } - - status, _ = doHttpRequest0(http.MethodGet, filePath, bearerHeaders, nil) - if status != http.StatusUnauthorized { - t.Errorf("Bearer-only %s status = %d, want %d", filePath, status, http.StatusUnauthorized) - } - - status, _ = doHttpRequest0(http.MethodGet, filePath, authCookieHeader, nil) - if status != http.StatusNotFound { - t.Errorf("cookie-authenticated %s status = %d, want %d", filePath, status, http.StatusNotFound) - } - } - status, _ = doHttpRequest0(http.MethodGet, "/fs/future-resource", nil, nil) - if status != http.StatusUnauthorized { - t.Errorf("unauthenticated future /fs route status = %d, want %d", status, http.StatusUnauthorized) - } - - status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", map[string]string{ - "Cookie": webAuthCookieName + "=invalid", - }, nil) - if status != http.StatusUnauthorized { - t.Errorf("invalid-cookie file status = %d, want %d", status, http.StatusUnauthorized) - } - status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } - status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", bearerHeaders, nil) + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": "Bearer invalid-session", + }, nil) if status != http.StatusUnauthorized { t.Errorf("Bearer-authenticated API status = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": "xxx", + "Cookie": webAuthCookieName + "=invalid-session", }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } + status, loginHeaders, loginBody := doHttpRequest1(http.MethodPost, "/api/web/login", nil, cfg.WebAuth) + if status != http.StatusOK { + t.Fatalf("cookie login got = %v, want %v", status, http.StatusOK) + } + setCookie := loginHeaders["Set-Cookie"] + if !strings.Contains(setCookie, webAuthCookieName+"=") || + !strings.Contains(setCookie, "HttpOnly") || + !strings.Contains(setCookie, "SameSite=Lax") { + t.Fatalf("unexpected auth cookie: %q", setCookie) + } + cookieHeader := strings.SplitN(setCookie, ";", 2)[0] + sessionID := strings.TrimPrefix(cookieHeader, webAuthCookieName+"=") + if sessionID == "" || strings.Contains(string(loginBody), sessionID) { + t.Fatalf("session id must only be returned in the HttpOnly cookie") + } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": "xxx", + "Cookie": cookieHeader, }, nil) - if status != http.StatusUnauthorized { - t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + if status != http.StatusOK { + t.Errorf("cookie authorization got = %v, want %v", status, http.StatusOK) } - - buildToken := func(username, password string, ts int64) string { - token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts))) - return token - } - - fakeToken := buildToken("fake", "fake", time.Now().Unix()) - status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, fakeToken), - }, nil) + status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", nil, nil) if status != http.StatusUnauthorized { - t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) - } - - expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix()) - status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, expireToken), - }, nil) - if status != http.StatusUnauthorized { - t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + t.Errorf("unauthenticated fs request got = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", map[string]string{ - "Cookie": fmt.Sprintf("%s=%s", webAuthCookieName, expireToken), + "Cookie": cookieHeader, + }, nil) + if status != http.StatusNotFound { + t.Errorf("authenticated fs request got = %v, want %v", status, http.StatusNotFound) + } + status, _ = doHttpRequest0(http.MethodGet, "/fs/tasks/not_exist/file.png", nil, nil) + if status != http.StatusUnauthorized { + t.Errorf("unauthenticated task fs request got = %v, want %v", status, http.StatusUnauthorized) + } + status, _ = doHttpRequest0(http.MethodGet, "/fs/tasks/not_exist/file.png", map[string]string{ + "Cookie": cookieHeader, + }, nil) + if status != http.StatusNotFound { + t.Errorf("authenticated task fs request got = %v, want %v", status, http.StatusNotFound) + } + status, _ = doHttpRequest0(http.MethodGet, "/fs/future-resource", nil, nil) + if status != http.StatusUnauthorized { + t.Errorf("unauthenticated future /fs route got = %v, want %v", status, http.StatusUnauthorized) + } + status, _ = doHttpRequest0(http.MethodGet, "/fs/future-resource", map[string]string{ + "Authorization": "Bearer " + sessionID, }, nil) if status != http.StatusUnauthorized { - t.Errorf("expired-cookie file status = %d, want %d", status, http.StatusUnauthorized) - } - - status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authCookieHeader, nil) - if status != http.StatusOK { - t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) + t.Errorf("Bearer-only future /fs route got = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ @@ -947,17 +925,16 @@ func TestAuthorization(t *testing.T) { if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) } - status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value), + "Cookie": cookieHeader, "X-Api-Token": cfg.ApiToken, }, nil) if status != http.StatusOK { - t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) + t.Errorf("cookie and API token authorization got = %v, want %v", status, http.StatusOK) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value), + "Cookie": cookieHeader, "X-Api-Token": "", }, nil) if status != http.StatusUnauthorized { @@ -965,6 +942,28 @@ func TestAuthorization(t *testing.T) { } } +func TestWebSessionStoreExpiry(t *testing.T) { + now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + store := newWebSessionStore() + store.now = func() time.Time { return now } + + sessionID, expiresAt, err := store.create() + if err != nil { + t.Fatal(err) + } + if sessionID == "" || expiresAt != now.Add(webAuthSessionTTL) { + t.Fatalf("unexpected session: id=%q expiresAt=%v", sessionID, expiresAt) + } + if !store.valid(sessionID) { + t.Fatal("new session should be valid") + } + + now = expiresAt + if store.valid(sessionID) { + t.Fatal("expired session should be invalid") + } +} + func TestBuildServerPropagatesWebViewProvider(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/ui/flutter/.gitignore b/ui/flutter/.gitignore index 7f8cd131..d3249dc4 100644 --- a/ui/flutter/.gitignore +++ b/ui/flutter/.gitignore @@ -80,5 +80,3 @@ windows/flutter/generated_plugins.cmake # Runtime data /extensions -database.hive -database.lock diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt index 162ab146..165070da 100644 --- a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt +++ b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt @@ -1,6 +1,7 @@ package com.gopeed.gopeed import com.gopeed.libgopeed.Libgopeed +import com.gopeed.libgopeed.TaskEventListener import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -12,12 +13,13 @@ class MainActivity : FlutterActivity() { val taskQueue = flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue() - MethodChannel( + val libgopeedChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, LIBGOPEED_CHANNEL, StandardMethodCodec.INSTANCE, taskQueue, - ).setMethodCallHandler { call, result -> + ) + libgopeedChannel.setMethodCallHandler { call, result -> when (call.method) { "start" -> { try { @@ -30,6 +32,42 @@ class MainActivity : FlutterActivity() { Libgopeed.stop() result.success(null) } + "getApiServerState" -> result.success(Libgopeed.getAPIServerState()) + "startApiServer" -> result.success(Libgopeed.startAPIServer()) + "stopApiServer" -> result.success(Libgopeed.stopAPIServer()) + "restartApiServer" -> result.success(Libgopeed.restartAPIServer()) + "invoke" -> { + try { + result.success( + Libgopeed.invoke( + call.argument("method") ?: "", + call.argument("path") ?: "", + call.argument("query") ?: "", + call.argument("body") ?: "", + ), + ) + } catch (error: Exception) { + result.error("ERROR", error.message, null) + } + } + "subscribeTaskEvents" -> { + val mask = call.argument("mask")?.toLong() ?: 0L + if (mask == 0L) { + Libgopeed.subscribeTaskEvents(0L, null) + } else { + Libgopeed.subscribeTaskEvents( + mask, + object : TaskEventListener { + override fun onTaskEvent(payload: String?) { + runOnUiThread { + libgopeedChannel.invokeMethod("taskEvent", payload ?: "") + } + } + }, + ) + } + result.success(null) + } else -> result.notImplemented() } } diff --git a/ui/flutter/docs/backend-transport-architecture.md b/ui/flutter/docs/backend-transport-architecture.md new file mode 100644 index 00000000..2de7ab81 --- /dev/null +++ b/ui/flutter/docs/backend-transport-architecture.md @@ -0,0 +1,658 @@ +# Gopeed Go–Flutter In-Process API Architecture + +## 1. Background + +The Flutter native client currently starts the Go backend together with a REST server, then calls that REST API over TCP or a Unix socket. REST remains appropriate for the web client, but using it inside a native process introduces several problems: + +- Flutter and Go run in the same process but still need to manage a listening port, Unix socket, API token, and connection fallback. +- The REST server lifecycle is coupled to the Downloader lifecycle, so the API server cannot be stopped without stopping the download core. +- Go already emits task events, but Flutter can only poll the task list instead of receiving in-process notifications. +- Flutter stores connection settings and preferences in Hive while Go uses Bolt Storage, creating two persistence systems. +- Adding an API can require duplicate work in the Go REST layer, native bridge, and Flutter client. + +The goal is to let native clients call Go directly, retain REST for the web, and provide one consistent API experience to Flutter components. + +## 2. Decision + +Use the following platform strategy: + +| Platform | Flutter-to-Go transport | Local REST server | +| --- | --- | --- | +| Windows / macOS / Linux | Dart FFI | Disabled by default; may be enabled separately | +| Android / iOS | MethodChannel + gomobile | Disabled by default; may be enabled separately | +| Web | REST | Required | + +The objective is to remove socket transport from native business calls, not to force every native platform to use `dart:ffi`. Mobile platforms retain the existing gomobile AAR/XCFramework build pipeline because it has lower implementation and release risk. + +Overall architecture: + +```text +Flutter Widget / Riverpod Controller + | + GopeedClient + | + GopeedTransport + +----------+-----------+ + | | | + Desktop FFI Mobile Web REST + Transport gomobile Transport + | | | + +----------+-----------+ + | + API Dispatcher + | + Typed Application Service + | + Downloader / Storage / Events + | + Optional REST Server Adapter +``` + +## 3. Design Goals + +### 3.1 Required outcomes + +- Native business calls do not use TCP or Unix sockets. +- Web continues to use REST. All published core REST paths, HTTP methods, parameters, and response contracts remain fully compatible. +- The REST server can be started and stopped independently without closing the Downloader. +- Flutter components depend on one `GopeedClient` and do not branch on FFI, gomobile, or REST. +- Go can push task events to Flutter. +- Native clients use events for terminal task notifications; web may continue polling. +- Go Bolt Storage is the primary persistence layer for business configuration and application preferences. +- Flutter's Hive dependency is removed, with no migration of existing Hive data. +- Adding a normal API does not require separate changes to Go REST, Desktop FFI, and the mobile bridge. + +### 3.2 Capabilities excluded from the unified business API + +The following endpoints are web HTTP infrastructure and are not part of the FFI/gomobile API: + +- `/fs/tasks/*` +- `/fs/extensions/*` +- `/api/web/proxy` +- Web static file hosting at `/` + +`/fs/tasks` returns files from the Go host machine to a browser over HTTP. Native clients can access local file paths directly and do not need an equivalent API. + +`/api/web/proxy` is infrastructure used by the web transport to work around browser CORS restrictions. It is not part of the published core `/api/v1/**` API. Native clients can request the target URL directly with Dio and do not need the Go proxy. + +## 4. Backend Maintenance Model + +### 4.1 Layers + +The Go backend is divided into the following responsibilities: + +```text +Runtime +├── ApplicationService +├── Dispatcher +├── TaskEventCallback +├── Storage +└── RestServer (optional) +``` + +#### Runtime + +The Runtime owns the in-process singleton lifecycle: + +- Initialize and close the Downloader. +- Initialize Bolt Storage. +- Create the ApplicationService, Dispatcher, and task-event callback adapter. +- Start, stop, or restart the REST server independently. +- Prevent hot reload or repeated initialization from creating multiple Downloader instances. + +`Runtime.StopRestServer()` closes only the HTTP listener. `Runtime.Close()` closes the Downloader and Storage. + +#### ApplicationService + +This is the single business implementation layer. It exposes typed Go methods such as: + +```text +Resolve +CreateTask +CreateTaskBatch +PatchTask +GetTasks +GetTaskStatus +PauseTasks +ContinueTasks +DeleteTasks +GetConfig +PutConfig +GetExtensions +... +``` + +Service methods must not depend on HTTP-specific concepts: + +- `http.Request` +- `http.ResponseWriter` +- mux path variables +- HTTP headers + +They accept business DTOs and return a consistent business result. + +#### Dispatcher + +The Dispatcher is the shared entry point for REST, FFI, and gomobile. Routes are registered explicitly instead of using Go reflection: + +```text +RouteSpec +├── Method: GET +├── Path: /api/v1/tasks +└── Handler: service.GetTasks +``` + +The Dispatcher accepts a normalized request: + +```json +{ + "method": "GET", + "path": "/api/v1/tasks", + "query": "status=running&status=done", + "body": "" +} +``` + +It returns a normalized response: + +```json +{ + "statusCode": 200, + "body": { + "code": 0, + "data": [] + } +} +``` + +This format does not start HTTP inside a native client and does not pass through `net/http`. The `method/path/query/body` fields reuse the existing REST contract as an in-process request format. + +This allows the same typed Flutter client method to work with REST, FFI, and gomobile without adding a separate native mapping for every API. + +`reflect.MethodByName` is not allowed. Every route must be registered in an explicit registry that validates the following at startup: + +- No duplicate method-and-path pairs. +- Valid path parameters. +- Non-null handlers. + +#### REST adapter + +The REST adapter is limited to transport responsibilities: + +- Convert `http.Request` into a Dispatcher request. +- Write a Dispatcher response back to HTTP. +- Handle API tokens, web login, CORS, compression, and panic recovery. +- Register web-only file, proxy, and static resource routes. + +The REST adapter no longer calls the global `rest.Downloader` directly. + +### 4.2 Route groups + +```text +Core API routes +├── /api/v1/info +├── /api/v1/tasks/* +├── /api/v1/config +├── /api/v1/extensions/* +└── /api/v1/runtime/* + +Web-only routes +├── /api/web/login +├── /api/web/proxy +├── /fs/tasks/* +├── /fs/extensions/* +└── /* static Web UI +``` + +Core routes enter the Dispatcher and can be called through every transport. + +Web-only routes are registered only by the REST server. They do not enter the FFI/gomobile ABI and do not require empty native implementations. + +### 4.3 REST server configuration and runtime state + +REST configuration is persisted in the `api` field of `DownloaderStoreConfig`: + +```json +{ + "enable": false, + "network": "tcp", + "address": "127.0.0.1:9999", + "token": "" +} +``` + +Rules: + +- `DownloaderStoreConfig.api` represents desired configuration only. It does not prove that a listener is running. +- A fresh native installation defaults to `enable=false`. On startup, Go reads the persisted configuration and attempts to start the optional REST server when `enable=true`. +- A web server always enables REST; Flutter Web is only a REST client. +- A REST server enabled separately by a native client exposes the core API by default. +- The Flutter settings page always shows protocol, listen address, and token fields so they can be configured before startup. +- Saving configuration and starting, stopping, or restarting the service are separate backend operations. Flutter does not pass listener configuration directly to the listener manager and does not own persistence behavior. +- Save operations write through the config API. Lifecycle operations read the persisted desired configuration from Go Storage. +- Go keeps actual runtime state in memory: `running`, the active network/address/port, `pendingApply`, and `lastError`. These fields are not persisted. +- `pendingApply=true` means desired configuration and actual runtime state differ. This can happen after configuration is saved without a restart or after startup fails. +- Listener startup failure updates runtime state and error information without closing the Downloader, Storage, or in-process API service. If persisted `enable=true`, the next application startup attempts the listener again. +- Reconfiguration uses fast-fail semantics: stop the old listener, then start the new one. If startup fails, the listener remains stopped and the old listener is not restored. The new desired configuration remains persisted, while `pendingApply` and `lastError` report that it is not active. +- The web UI cannot stop the REST server that serves it. + +Flutter native settings behavior: + +- Show actual service status, the active listen address when running, `lastError` on failure, and a pending-apply message when configuration is not active. +- Keep configuration fields visible at all times. Do not use one switch to represent both persisted intent and runtime state. +- Provide separate Start/Stop and Save buttons. Allow buttons to wrap on narrow layouts. +- Saving while stopped only persists configuration. +- Saving while running validates the draft and asks whether to save and restart immediately. On confirmation, save first and then ask Go to restart. +- Stopping requires confirmation. On confirmation, persist `enable=false` and stop the listener. +- Starting persists `enable=true`, then asks Go to start the listener from persisted configuration. + +## 5. Native ABI and Mobile Bridge + +### 5.1 Desktop FFI + +Keep the Desktop C ABI small and stable: + +```text +Start +Stop +GetAPIServerState +StartAPIServer +StopAPIServer +RestartAPIServer +Invoke +FreeCString +SubscribeTaskEvents +``` + +Requirements: + +- Use the cgo C-string ABI in the first phase. Reconsider pointer-and-length parameters only during a future ABI-breaking revision. +- Memory allocated by Go must be released with `FreeCString`. +- Dart-owned input memory must be released after the synchronous native call returns. +- `Invoke` returns the same Result JSON as REST; business errors do not use a separate ABI error model. +- Run blocking Desktop FFI work outside the Flutter UI isolate. Mobile MethodChannel handlers likewise run blocking Go work on a background task queue. +- Dart bindings are generated from `include/libgopeed.h` with `ffigen`. Every exported Desktop symbol must be added to the `ffigen.functions.include` allowlist. +- Never hand-edit `lib/core/ffi/libgopeed_bind.dart`; regenerate it with `flutter pub run ffigen` after changing the C header. + +### 5.2 Android and iOS gomobile + +Mobile continues to export: + +```text +Start(configJson) +Stop() +GetAPIServerState() +StartAPIServer() +StopAPIServer() +RestartAPIServer() +Invoke(method, path, query, body) +SubscribeTaskEvents(mask, listener) +``` + +Flutter bridge rules: + +- Normal calls use MethodChannel. +- Android performs normal calls on a background task queue. +- Events reuse the same MethodChannel through the reverse `taskEvent` message. +- Kotlin and Swift contain no business logic. They only forward arguments, dispatch callbacks to the UI thread, and translate errors. + +## 6. Event Notification Design + +### 6.1 Minimal task callback + +The current requirement is limited to notifying native clients when a task completes or fails. Do not introduce an EventHub, durable queue, drain protocol, sequence numbers, or progress coalescing for these two low-frequency events. + +The Downloader retains its existing single-listener mechanism. The shared API Service registers one adapter, and Flutter passes a bit mask describing the terminal events it wants Go to forward: + +```text +TaskEventDone = 1 +TaskEventError = 2 +``` + +Event payload: + +```json +{ + "type": "task.done", + "taskId": "task-id", + "name": "example.zip", + "error": null +} +``` + +The callback runs after the Downloader releases task locks. Desktop's `NativeCallable.listener` and the mobile UI-thread forwarding step return quickly and perform no UI or expensive business work inside the Go callback. Platform callback panics are isolated from download workers. + +If high-frequency progress events or reliable multi-consumer delivery become necessary, introduce a queued EventHub as a separate design change instead of paying that complexity now. + +### 6.2 Flutter event API + +Flutter exposes the following to application components: + +```text +Stream taskEvents +subscribeTaskEvents(Set events) +``` + +Implementation: + +- Desktop: `NativeCallable.listener` receives JSON and calls `FreeCString` for the Go-owned payload. +- Mobile: a gomobile listener receives JSON and forwards it through MethodChannel. +- Web: the event stream is empty and task pages continue using their existing refresh strategy. + +Components do not distinguish FFI from gomobile. The current desktop notification controller consumes `taskEvents` directly and replaces the previous two-second notification polling loop. + +## 7. Flutter API Maintenance Model + +### 7.1 Directory responsibilities + +Use the following organization within the existing project structure: + +```text +lib/core/network/gopeed/ +├── gopeed_client.dart +├── gopeed_transport.dart +├── api_request.dart +├── api_response.dart +├── api_exception.dart +├── transports/ +│ ├── rest_transport.dart +│ ├── desktop_ffi_transport.dart +│ ├── mobile_gomobile_transport.dart +│ └── transport_factory.dart +└── events/ + ├── gopeed_event.dart + ├── event_source.dart + └── task_event_reducer.dart +``` + +The existing `lib/api/model/` directory may remain initially to avoid a large model migration during the transport refactor. + +### 7.2 Transports only transport data + +Unified interface: + +```dart +abstract interface class GopeedTransport { + Future request(ApiRequest request); + Stream get events; + Future close(); +} +``` + +A transport must not expose business methods such as `createTask()` or `getTasks()`. Otherwise, every new API would still require changes to all three transports. + +### 7.3 Maintain one typed API in GopeedClient + +All business methods are implemented once in `GopeedClient`: + +```dart +Future> getTasks(List statuses) { + return _request( + method: 'GET', + path: '/api/v1/tasks', + query: {'status': statuses.map((e) => e.name).toList()}, + decode: decodeTasks, + ); +} +``` + +The same method is dispatched differently by platform: + +- Web: RestTransport converts it into a Dio request. +- Desktop: FFI Transport converts it into `GopeedInvoke`. +- Mobile: gomobile Transport converts it into a MethodChannel invocation. + +Components and Riverpod controllers inject only `GopeedClient` or a higher-level `GopeedService`. + +The following patterns are prohibited: + +- Feature widgets importing `api/api.dart` directly. +- Feature widgets accessing Dio, FFI, or MethodChannel directly. +- Components using `kIsWeb` to select a business API. +- Child windows initializing the Go Runtime. + +### 7.4 Non-business HTTP capabilities + +`proxyRequest` is not a Gopeed Core API and belongs in a separate `ExternalHttpClient`: + +```text +Native → request the target directly with Dio +Web → /api/web/proxy +``` + +Task file access does not belong in `GopeedClient`: + +```text +Native → task.storagePath + relativePath +Web → /fs/tasks/{taskId}/{relativePath} +``` + +A web resource helper generates resource URLs used by the web page. + +### 7.5 Multiple windows + +The main window is the sole owner of the Runtime, GopeedClient, and EventSource. + +Child windows continue to use the existing AppCapabilities RPC: + +- The main window forwards child requests to GopeedClient. +- The main window pushes complete state snapshots to child windows. +- Child windows do not load the dynamic library, call gomobile, or open Storage. + +## 8. Cost of Adding an API + +### 8.1 Normal query or command + +For example, to add `GET /api/v1/tasks/{id}/peers`: + +Backend work: + +1. Add a typed method to ApplicationService. +2. Register its method, path, and handler in the Core Route Registry. +3. Add Service/Dispatcher contract tests. + +Flutter work: + +4. Add a Dart model and JSON codec if the API introduces a new data structure. +5. Add one typed method to `GopeedClient`. +6. Call that method from the feature Provider or Controller. + +No changes are required in: + +- REST Transport. +- Desktop FFI ABI. +- Android MainActivity. +- iOS AppDelegate. +- gomobile exports. +- Task-event bridging. + +Typical effort: + +| Change type | Backend effort | Flutter client effort | Transport effort | +| --- | --- | --- | --- | +| Simple query/command using existing DTOs | 15–40 lines + tests | 5–15 lines + tests | 0 | +| New request or response DTO | 30–80 lines + tests | Model/codec + 5–15 client lines | 0 | +| New batch operation | 30–100 lines + tests | 10–30 lines | 0 | +| New task-event type | Mask, filtering, and payload tests | Enum/decoder + tests | Usually 0 | +| New web-only file or HTTP capability | REST handler + security tests | Web helper | 0 on native | + +A simple API should normally take less than half a day. An API involving a new model, permissions, and UI generally takes about one day. More complex business behavior should add effort in the business layer, not through duplicated transport implementations. + +### 8.2 Adding an event + +Adding an event requires only: + +1. Define the event name and payload in Go. +2. Emit it at the relevant business state transition. +3. Add mask-filtering and payload tests. +4. Add a Flutter payload decoder. + +The generic FFI callback, gomobile listener, and MethodChannel forwarding must not change for every new event type. + +### 8.3 Adding a platform-native capability + +File pickers, system notifications, and background location are platform capabilities rather than Gopeed business APIs. Keep them in Flutter plugins or dedicated Platform Channels instead of adding them to the Dispatcher. + +## 9. Storage and Hive Removal + +### 9.1 Go Storage ownership + +The REST server switch, network, address, and token belong in `DownloaderStoreConfig.api`. Window state, creation history, bookmarks, menubar mode, analytics settings, and fallback client ID belong in `DownloaderStoreConfig.extra`. All are written to Go Storage through the unified config API. + +Flutter no longer initializes a local database or stores Go connection parameters. Web uses `Uri.base.origin` to access the same-origin REST server that serves the page and does not maintain separate socket, host, or port settings in browser storage. Existing Hive files are neither read nor imported; the Hive package and database wrappers are removed. + +### 9.2 Startup sequence + +The main window RuntimeController remains the sole owner of the Go Runtime. The app creates the window with safe default dimensions, then restores persisted state after Go is ready: + +```text +Initialize the fixed storageDir +→ Create the main window with safe default dimensions +→ Start the Go Runtime +→ Go reads persisted API configuration and starts the optional REST listener when enabled +→ Read Downloader configuration and client preferences +→ Flutter reads desired API configuration and actual REST runtime state separately +→ Restore window dimensions and maximized state +→ Subscribe to terminal task events +``` + +This preserves the lifecycle rule that the main window owns the Go Runtime and child windows use only Capability RPC. Web authentication uses an HttpOnly Cookie managed by the REST server rather than restoring token storage in Hive. + +### 9.3 Legacy data policy + +This branch intentionally does not migrate `database.hive`. After upgrade, window state, creation history, and the analytics fallback ID start from defaults. Download tasks, Downloader configuration, and extension data continue to come from existing Go Storage and are unaffected. + +### 9.4 Web login state + +Web login state must not be stored in `DownloaderStoreConfig.extra`: + +- Config is global server state and does not belong to one browser or user. +- The first config request itself requires authentication, so storing the browser token in config creates a bootstrap cycle. +- Config is returned to other authenticated clients, so persisting a browser token there would leak the session. +- Native FFI/gomobile calls do not pass through REST authentication and do not need a web token. + +After synchronizing `feat/v2`, the login page and 401 redirect behavior remain in place. On successful login, Go uses `crypto/rand` to create a 256-bit random Session ID. It stores the session and its seven-day expiry in process memory and returns the ID only in a `HttpOnly + SameSite=Lax + Path=/` Cookie. HTTPS and trusted reverse-proxy requests also set `Secure`. The Cookie contains no username, password, or business data, and Flutter Web never reads, persists, or injects the Session ID. + +Sessions are not stored in Bolt and do not require AES. Their lifecycle is: + +- Browser refresh: the Cookie remains valid and the user stays logged in. +- Browser restart: the session remains valid while the Cookie is unexpired and the Go service has not restarted. +- Go service restart: in-memory sessions are cleared; the old Cookie fails validation, and the first API request returns 401 and opens the login page. +- Seven-day expiry: both browser Cookie and server session expire and require a new login. + +The web session protects `/api/**`, `/fs/tasks/**`, and `/fs/extensions/**`. Same-origin API calls, Flutter Web image requests, new-window file access, and downloads automatically include the Cookie. They do not need a separate image authentication header. `X-Api-Token` remains independent and serves CLI and external API clients. If login must survive a Go service restart in the future, add persistent session storage separately instead of mixing it into Downloader business configuration. + +A web server cannot be configured with `ApiToken` alone while omitting complete `WebAuth` credentials. Browsers cannot automatically attach `X-Api-Token`, so that configuration would start a server whose web page is unusable. `StartConfig.Validate` rejects it before opening a listener or initializing the Downloader. To serve both the Web UI and external API clients, enable `WebAuth`: the static login page remains public, the browser uses a Session Cookie after login, and external clients use `X-Api-Token`. A native client that does not enable the Web UI may still use `ApiToken` to protect its optional REST server. + +## 10. Error Model + +Unified error structure: + +```json +{ + "code": 1000, + "message": "task not found", + "details": {}, + "requestId": "42" +} +``` + +Error categories: + +- Business errors: missing task, invalid arguments, extension installation failure. +- Transport errors: uninitialized FFI, disconnected MethodChannel, REST timeout. +- ABI errors: incompatible versions or invalid return buffers. +- Capability errors: invoking a web-only or native-only capability on the wrong platform. + +Flutter converts all of them into `GopeedException`. Components do not handle DioException, PlatformException, or raw FFI errors directly. + +## 11. Testing Strategy + +### 11.1 Go + +- ApplicationService unit tests. +- Dispatcher route and parameter parsing tests. +- Shared request fixtures that verify identical Dispatcher and REST results. +- Runtime repeated-start, shutdown, and independent REST restart tests. +- Tests for desired/runtime state mismatches, native core survival after listener startup failure, and a stopped listener after failed restart. +- Done/error mask filtering, payload, and callback panic-isolation tests. +- Go race tests. +- Repeated FFI buffer allocation/release tests. + +### 11.2 Flutter + +- Use FakeTransport to test GopeedClient paths, query parameters, request bodies, and decoding. +- RestTransport, DesktopFfiTransport, and MobileGomobileTransport contract tests. +- TaskEvent JSON decoder and subscription-mask tests. +- Desktop notification tests for done/error events. +- Main-window and child-window capability forwarding tests. + +### 11.3 Platform smoke tests + +- Windows, macOS, Linux: startup, Invoke, events, shutdown, and repeated startup. +- Android, iOS: gomobile Invoke, reverse MethodChannel events, and background/foreground transitions. +- Web: REST, login, proxy, and task-file downloads. +- Native: enabling and disabling REST does not interrupt download tasks. + +## 12. Implementation Phases + +### Phase 1: Extract the shared backend — implemented on this branch + +- Add the shared API Service and explicit Dispatcher. +- Convert existing REST handlers into a Dispatcher adapter. +- Preserve the published core REST contract exactly. New or breaking changes require a new API version and must not modify the existing `/api/v1/**` contract. + +### Phase 2: Native in-process calls — implemented on this branch + +- Integrate the FFI Transport on Desktop. +- Extend gomobile Invoke and MethodChannel on Mobile. +- Move Flutter API calls to the unified Transport so each typed method is maintained once. +- Do not create a socket listener by default on native clients. + +### Phase 3: Terminal task events — implemented on this branch + +- Add done/error mask filtering and one callback. +- Add the FFI callback on Desktop and the gomobile listener plus MethodChannel forwarding on Mobile. +- Replace desktop notification polling with events; Web does not implement callbacks. + +### Phase 4: Independent REST server lifecycle — implemented on this branch + +- Persist REST server configuration. +- Keep REST disabled by default on native clients. +- Continue using the external REST server on Web. +- Expose separate backend operations for state, start, stop, and restart. Lifecycle operations read only persisted configuration. +- Maintain configuration drafts and actual runtime state separately in the settings page. When saving while running, ask whether to save and restart immediately. +- Require confirmation before stopping REST, without closing the Downloader or native in-process API. + +### Phase 5: Unified Go Storage — implemented on this branch + +- Store Flutter UI preferences in `DownloaderStoreConfig.extra`. +- Restore window state after the Runtime is ready. +- Use the same-origin REST address on Web. +- Explicitly discard legacy Hive data and remove the Hive dependency and database wrappers. + +### Phase 6: Cleanup and release + +- Remove native socket-based API calls. +- Remove mobile bridge code used only to start REST for native API calls. +- Complete cross-platform contract, memory, lifecycle, event, and stress tests. + +## 13. Maintenance Principles + +1. Implement business logic once in Go ApplicationService. +2. Register each API route once in the Core Route Registry. +3. Implement each Flutter typed method once in GopeedClient. +4. Transports serialize and forward data; they do not duplicate business methods. +5. Event transports carry a generic envelope and do not expand the native bridge for every event type. +6. Web-only HTTP capabilities do not enter the native API. +7. Flutter widgets do not know whether the transport is REST, FFI, or gomobile. +8. Child windows never own the Go Runtime. + +Under these rules, the stable change surface for a normal business API is: + +```text +Go Service + Route + Test +Flutter Model (if required) + GopeedClient + Test +``` + +Maintenance effort does not grow linearly with the number of supported platforms. diff --git a/ui/flutter/include/libgopeed.h b/ui/flutter/include/libgopeed.h index 766cf7e6..a7656dd9 100644 --- a/ui/flutter/include/libgopeed.h +++ b/ui/flutter/include/libgopeed.h @@ -82,6 +82,13 @@ struct Start_return { }; extern struct Start_return Start(char* cfg); extern void Stop(); +extern char* GetAPIServerState(); +extern char* StartAPIServer(); +extern char* StopAPIServer(); +extern char* RestartAPIServer(); +extern char* Invoke(char* method, char* path, char* query, char* body); +extern void SubscribeTaskEvents(GoUint64 mask, GoUintptr callback); +extern void FreeCString(char* value); #ifdef __cplusplus } diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index 1b878228..0a84a65f 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* LocationKeepAliveManager.swift */; }; + A20000000000000000000001 /* GopeedTaskEventForwarder.m in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* GopeedTaskEventForwarder.m */; }; B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */; }; C164A2AA80F148015630B8E9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */; }; D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D081C25B294826C0006EB10B /* libc++.tbd */; }; @@ -104,6 +105,8 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A10000000000000000000002 /* LocationKeepAliveManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationKeepAliveManager.swift; sourceTree = ""; }; + A20000000000000000000002 /* GopeedTaskEventForwarder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GopeedTaskEventForwarder.m; sourceTree = ""; }; + A20000000000000000000003 /* GopeedTaskEventForwarder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GopeedTaskEventForwarder.h; sourceTree = ""; }; BE83526BCA48EE87CB910236 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; BFA76EAE38733F2444C8A609 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = ""; }; D554135997A5C7A2C62E1B64 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -238,6 +241,8 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, A10000000000000000000002 /* LocationKeepAliveManager.swift */, + A20000000000000000000003 /* GopeedTaskEventForwarder.h */, + A20000000000000000000002 /* GopeedTaskEventForwarder.m */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -521,6 +526,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, + A20000000000000000000001 /* GopeedTaskEventForwarder.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ui/flutter/ios/Runner/AppDelegate.swift b/ui/flutter/ios/Runner/AppDelegate.swift index b13f412c..aac051ad 100644 --- a/ui/flutter/ios/Runner/AppDelegate.swift +++ b/ui/flutter/ios/Runner/AppDelegate.swift @@ -19,6 +19,7 @@ import UIKit name: "gopeed.com/libgopeed", binaryMessenger: messenger ) + let taskEventForwarder = GopeedTaskEventForwarder(channel: libgopeedChannel) libgopeedChannel.setMethodCallHandler { call, result in switch call.method { case "start": @@ -41,6 +42,26 @@ import UIKit case "stop": LibgopeedStop() result(nil) + case "getApiServerState": + result(LibgopeedGetAPIServerState()) + case "startApiServer": + result(LibgopeedStartAPIServer()) + case "stopApiServer": + result(LibgopeedStopAPIServer()) + case "restartApiServer": + result(LibgopeedRestartAPIServer()) + case "invoke": + let arguments = call.arguments as? [String: Any] + let method = arguments?["method"] as? String ?? "" + let path = arguments?["path"] as? String ?? "" + let query = arguments?["query"] as? String ?? "" + let body = arguments?["body"] as? String ?? "" + result(LibgopeedInvoke(method, path, query, body)) + case "subscribeTaskEvents": + let arguments = call.arguments as? [String: Any] + let mask = (arguments?["mask"] as? NSNumber)?.int64Value ?? 0 + GopeedSubscribeTaskEventsWithForwarder(mask, mask == 0 ? nil : taskEventForwarder) + result(nil) default: result(FlutterMethodNotImplemented) } diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h new file mode 100644 index 00000000..e12dd1fd --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h @@ -0,0 +1,16 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GopeedTaskEventForwarder : NSObject + +- (instancetype)initWithChannel:(FlutterMethodChannel *)channel NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +FOUNDATION_EXPORT void GopeedSubscribeTaskEventsWithForwarder( + int64_t mask, + GopeedTaskEventForwarder * _Nullable listener); + +NS_ASSUME_NONNULL_END diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m new file mode 100644 index 00000000..c6bcee5e --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m @@ -0,0 +1,35 @@ +#import "GopeedTaskEventForwarder.h" + +#import + +@interface GopeedTaskEventForwarder () + +@property(nonatomic, strong) FlutterMethodChannel *channel; + +@end + +@implementation GopeedTaskEventForwarder + +- (instancetype)initWithChannel:(FlutterMethodChannel *)channel { + self = [super init]; + if (self) { + _channel = channel; + } + return self; +} + +- (void)onTaskEvent:(NSString * _Nullable)payload { + NSString *arguments = payload ?: @""; + FlutterMethodChannel *channel = self.channel; + dispatch_async(dispatch_get_main_queue(), ^{ + [channel invokeMethod:@"taskEvent" arguments:arguments]; + }); +} + +@end + +void GopeedSubscribeTaskEventsWithForwarder( + int64_t mask, + GopeedTaskEventForwarder * _Nullable listener) { + LibgopeedSubscribeTaskEvents(mask, listener); +} diff --git a/ui/flutter/ios/Runner/Runner-Bridging-Header.h b/ui/flutter/ios/Runner/Runner-Bridging-Header.h index 308a2a56..a247fd1d 100644 --- a/ui/flutter/ios/Runner/Runner-Bridging-Header.h +++ b/ui/flutter/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,2 @@ #import "GeneratedPluginRegistrant.h" +#import "GopeedTaskEventForwarder.h" diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index 264516e4..3ee841ce 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -1,8 +1,6 @@ -import 'dart:io'; - import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; -import 'package:flutter/foundation.dart'; + +import '../core/network/gopeed/gopeed_transport.dart'; import 'model/create_task.dart'; import 'model/create_task_batch.dart'; @@ -27,77 +25,35 @@ class ApiTimeoutException implements Exception { String toString() => message; } -class _Client { - _Client._(); +GopeedTransport? _transport; - static final _Client instance = _Client._(); - - late Dio dio; - bool initialized = false; - - void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) { - final isUnixSocket = network == 'unix'; - final baseUrl = isUnixSocket - ? 'http://127.0.0.1/' - : kIsWeb - ? kDebugMode - ? 'http://127.0.0.1:9999/' - : '' - : 'http://$address/'; - dio = Dio( - BaseOptions( - baseUrl: baseUrl, - contentType: Headers.jsonContentType, - sendTimeout: const Duration(seconds: 5), - connectTimeout: const Duration(seconds: 5), - receiveTimeout: const Duration(seconds: 120), - ), - ); - dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - if (apiToken.isNotEmpty) { - options.headers['X-Api-Token'] = apiToken; - } - handler.next(options); - }, - onError: (error, handler) { - if (kIsWeb && error.response?.statusCode == 401) { - onUnauthorized?.call(); - } - handler.next(error); - }, - ), - ); - if (isUnixSocket) { - (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; - }; - } - initialized = true; - } +void setTransportForTesting(GopeedTransport transport) { + _transport = transport; } -void init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) { - _Client.instance.init(network, address, apiToken, onUnauthorized: onUnauthorized); +void init(String network, String address, String apiToken, {void Function()? onUnauthorized}) { + _transport = createGopeedTransport( + GopeedTransportConfig(network: network, address: address, apiToken: apiToken, onUnauthorized: onUnauthorized), + ); } void initDefault({String address = '127.0.0.1:9999', String apiToken = ''}) { - if (!_Client.instance.initialized) { + if (_transport == null) { init('tcp', address, apiToken); } } -Future _parse(Future> Function() fetch, T Function(dynamic json)? fromJsonT) async { +Future _request(String path, {String method = 'GET', dynamic data, Map? queryParameters}) { + initDefault(); + return _transport!.request(path, method: method, data: data, queryParameters: queryParameters); +} + +Future _parse(Future Function() fetch, T Function(dynamic json)? fromJsonT) async { initDefault(); try { final resp = await fetch(); fromJsonT ??= (json) => null as T; - final result = Result.fromJson(resp.data, fromJsonT); + final result = Result.fromJson(resp as Map, fromJsonT); if (result.code == 0) { return result.data as T; } @@ -119,169 +75,153 @@ Future resolve(ResolveTask resolveTask) { throw ArgumentError('resolve request is required'); } return _parse( - () => _Client.instance.dio.post('api/v1/resolve', data: resolveTask), + () => _request('api/v1/resolve', method: 'POST', data: resolveTask), (data) => ResolveResult.fromJson(data as Map), ); } Future createTask(CreateTask createTask) { - return _parse(() => _Client.instance.dio.post('api/v1/tasks', data: createTask), (data) => data as String); + return _parse(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String); } Future> createTaskBatch(CreateTaskBatch createTaskBatch) { return _parse>( - () => _Client.instance.dio.post('api/v1/tasks/batch', data: createTaskBatch), + () => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch), (data) => (data as List).map((e) => e as String).toList(), ); } Future patchTask(String id, ResolveTask patchTask) { - return _parse(() => _Client.instance.dio.patch('api/v1/tasks/$id', data: patchTask), null); + return _parse(() => _request('api/v1/tasks/$id', method: 'PATCH', data: patchTask), null); } Future> getTasks(List statuses) { final query = statuses.map((e) => 'status=${e.name}').join('&'); return _parse>( - () => _Client.instance.dio.get('/api/v1/tasks?$query'), + () => _request('/api/v1/tasks?$query'), (data) => (data as List).map((e) => Task.fromJson(e as Map)).toList(), ); } Future getTaskStatus(String id) { return _parse( - () => _Client.instance.dio.get('/api/v1/tasks/$id/status'), + () => _request('/api/v1/tasks/$id/status'), (data) => TaskRuntimeStatus.fromJson(data as Map), ); } Future> getTaskStats(String id) { return _parse>( - () => _Client.instance.dio.get('/api/v1/tasks/$id/stats'), + () => _request('/api/v1/tasks/$id/stats'), (data) => data is Map ? {for (final entry in data.entries) entry.key.toString(): entry.value} : const {}, ); } Future pauseTask(String id) { - return _parse(() => _Client.instance.dio.put('api/v1/tasks/$id/pause'), null); + return _parse(() => _request('api/v1/tasks/$id/pause', method: 'PUT'), null); } Future continueTask(String id) { - return _parse(() => _Client.instance.dio.put('api/v1/tasks/$id/continue'), null); + return _parse(() => _request('api/v1/tasks/$id/continue', method: 'PUT'), null); } Future pauseAllTasks(List? ids) { - return _parse(() => _Client.instance.dio.put('api/v1/tasks/pause', queryParameters: {'id': ids}), null); + return _parse(() => _request('api/v1/tasks/pause', method: 'PUT', queryParameters: {'id': ids}), null); } Future continueAllTasks(List? ids) { - return _parse(() => _Client.instance.dio.put('api/v1/tasks/continue', queryParameters: {'id': ids}), null); + return _parse(() => _request('api/v1/tasks/continue', method: 'PUT', queryParameters: {'id': ids}), null); } Future deleteTask(String id, bool force) { - return _parse(() => _Client.instance.dio.delete('api/v1/tasks/$id?force=$force'), null); + return _parse(() => _request('api/v1/tasks/$id', method: 'DELETE', queryParameters: {'force': force}), null); } Future deleteTasks(List? ids, bool force) { return _parse( - () => _Client.instance.dio.delete('api/v1/tasks', queryParameters: {'id': ids, 'force': force}), + () => _request('api/v1/tasks', method: 'DELETE', queryParameters: {'id': ids, 'force': force}), null, ); } Future getConfig() { return _parse( - () => _Client.instance.dio.get('api/v1/config'), + () => _request('api/v1/config'), (data) => DownloaderConfig.fromJson(data as Map), ); } Future putConfig(DownloaderConfig config) { - return _parse(() => _Client.instance.dio.put('api/v1/config', data: config.toJson()), null); + return _parse(() => _request('api/v1/config', method: 'PUT', data: config.toJson()), null); } Future installExtension(InstallExtension installExtension) { return _parse( - () => _Client.instance.dio.post('api/v1/extensions', data: installExtension.toJson()), + () => _request('api/v1/extensions', method: 'POST', data: installExtension.toJson()), (data) => data as String, ); } Future> getExtensions() { return _parse>( - () => _Client.instance.dio.get('api/v1/extensions'), + () => _request('api/v1/extensions'), (data) => (data as List).map((e) => Extension.fromJson(e as Map)).toList(), ); } Future updateExtensionSettings(String identity, UpdateExtensionSettings updateExtensionSettings) { return _parse( - () => _Client.instance.dio.put('api/v1/extensions/$identity/settings', data: updateExtensionSettings.toJson()), + () => _request('api/v1/extensions/$identity/settings', method: 'PUT', data: updateExtensionSettings.toJson()), null, ); } Future switchExtension(String identity, SwitchExtension switchExtension) { return _parse( - () => _Client.instance.dio.put('api/v1/extensions/$identity/switch', data: switchExtension.toJson()), + () => _request('api/v1/extensions/$identity/switch', method: 'PUT', data: switchExtension.toJson()), null, ); } Future deleteExtension(String identity) { - return _parse(() => _Client.instance.dio.delete('api/v1/extensions/$identity'), null); + return _parse(() => _request('api/v1/extensions/$identity', method: 'DELETE'), null); } Future upgradeCheckExtension(String identity) { return _parse( - () => _Client.instance.dio.get('api/v1/extensions/$identity/update'), + () => _request('api/v1/extensions/$identity/update'), (data) => UpdateCheckExtensionResp.fromJson(data as Map), ); } Future updateExtension(String identity) { - return _parse(() => _Client.instance.dio.post('api/v1/extensions/$identity/update'), null); + return _parse(() => _request('api/v1/extensions/$identity/update', method: 'POST'), null); } Future testWebhook(String url) { - return _parse(() => _Client.instance.dio.post('api/v1/webhook/test', data: {'url': url}), null); + return _parse(() => _request('api/v1/webhook/test', method: 'POST', data: {'url': url}), null); } Future login(LoginReq loginReq) { - return _parse(() => _Client.instance.dio.post('api/web/login', data: loginReq.toJson()), null); + return _parse(() => _request('api/web/login', method: 'POST', data: loginReq.toJson()), null); } Future> proxyRequest(String uri, {dynamic data, Options? options}) async { initDefault(); - options ??= Options(); - options.headers ??= {}; - options.headers!['X-Target-Uri'] = uri; - return _Client.instance.dio.request( - '/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}', - data: data, - options: options, - ); + return _transport!.proxyRequest(uri, data: data, options: options); } String join(String path) { initDefault(); - final baseUrl = _Client.instance.dio.options.baseUrl; - final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; - final cleanPath = path.startsWith('/') ? path.substring(1) : path; - return '$cleanBaseUrl/$cleanPath'; + return _transport!.join(path); } -Future> forward( +Future forward( String path, { String method = 'GET', dynamic data, Map? queryParameters, }) async { - initDefault(); - return _Client.instance.dio.request( - path, - data: data, - queryParameters: queryParameters, - options: Options(method: method), - ); + return _request(path, method: method, data: data, queryParameters: queryParameters); } diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index b0b9522b..b871bb2c 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -13,6 +13,7 @@ class DownloaderConfig { ScriptConfig script = ScriptConfig(); AutoTorrentConfig autoTorrent = AutoTorrentConfig(); ArchiveConfig archive = ArchiveConfig(); + ApiServerConfig api = ApiServerConfig(); bool autoStartTasks; bool autoDeleteMissingFileTasks; @@ -28,6 +29,21 @@ class DownloaderConfig { Map toJson() => _$DownloaderConfigToJson(this); } +@JsonSerializable() +class ApiServerConfig { + bool enable; + String network; + String address; + String token; + + ApiServerConfig({this.enable = false, this.network = 'tcp', this.address = '127.0.0.1:9999', this.token = ''}); + + factory ApiServerConfig.fromJson(Map? json) => + json == null ? ApiServerConfig() : _$ApiServerConfigFromJson(json); + + Map toJson() => _$ApiServerConfigToJson(this); +} + @JsonSerializable(explicitToJson: true) class ProtocolConfig { HttpConfig http = HttpConfig(); @@ -102,6 +118,12 @@ class ExtraConfig { bool notifyWhenNewVersion; bool desktopNotification; bool backgroundLocationKeepAlive; + WindowStateConfig windowState; + Map bookmarks; + List createHistory; + bool runAsMenubarApp; + bool analyticsEnabled; + String analyticsClientId; List downloadCategories; ExtraConfigBt bt = ExtraConfigBt(); @@ -117,8 +139,14 @@ class ExtraConfig { this.notifyWhenNewVersion = true, this.desktopNotification = true, this.backgroundLocationKeepAlive = false, + WindowStateConfig? windowState, + this.bookmarks = const {}, + this.createHistory = const [], + this.runAsMenubarApp = false, + this.analyticsEnabled = true, + this.analyticsClientId = '', this.downloadCategories = const [], - }); + }) : windowState = windowState ?? WindowStateConfig(); factory ExtraConfig.fromJson(Map? json) => json == null ? ExtraConfig() : _$ExtraConfigFromJson(json); @@ -126,6 +154,20 @@ class ExtraConfig { Map toJson() => _$ExtraConfigToJson(this); } +@JsonSerializable() +class WindowStateConfig { + bool isMaximized; + double? width; + double? height; + + WindowStateConfig({this.isMaximized = false, this.width, this.height}); + + factory WindowStateConfig.fromJson(Map? json) => + json == null ? WindowStateConfig() : _$WindowStateConfigFromJson(json); + + Map toJson() => _$WindowStateConfigToJson(this); +} + @JsonSerializable() class DownloadCategory { String name; diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 20e06220..a4ecba0b 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -19,7 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map json) => ..webhook = WebhookConfig.fromJson(json['webhook'] as Map?) ..script = ScriptConfig.fromJson(json['script'] as Map?) ..autoTorrent = AutoTorrentConfig.fromJson(json['autoTorrent'] as Map?) - ..archive = ArchiveConfig.fromJson(json['archive'] as Map?); + ..archive = ArchiveConfig.fromJson(json['archive'] as Map?) + ..api = ApiServerConfig.fromJson(json['api'] as Map?); Map _$DownloaderConfigToJson(DownloaderConfig instance) => { 'downloadDir': instance.downloadDir, @@ -31,10 +32,25 @@ Map _$DownloaderConfigToJson(DownloaderConfig instance) => json) => ApiServerConfig( + enable: json['enable'] as bool? ?? false, + network: json['network'] as String? ?? 'tcp', + address: json['address'] as String? ?? '127.0.0.1:9999', + token: json['token'] as String? ?? '', +); + +Map _$ApiServerConfigToJson(ApiServerConfig instance) => { + 'enable': instance.enable, + 'network': instance.network, + 'address': instance.address, + 'token': instance.token, +}; + ProtocolConfig _$ProtocolConfigFromJson(Map json) => ProtocolConfig() ..http = HttpConfig.fromJson(json['http'] as Map) ..bt = BtConfig.fromJson(json['bt'] as Map) @@ -101,6 +117,14 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, desktopNotification: json['desktopNotification'] as bool? ?? true, backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false, + windowState: json['windowState'] == null + ? null + : WindowStateConfig.fromJson(json['windowState'] as Map?), + bookmarks: (json['bookmarks'] as Map?)?.map((k, e) => MapEntry(k, e as String)) ?? const {}, + createHistory: (json['createHistory'] as List?)?.map((e) => e as String).toList() ?? const [], + runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false, + analyticsEnabled: json['analyticsEnabled'] as bool? ?? true, + analyticsClientId: json['analyticsClientId'] as String? ?? '', downloadCategories: (json['downloadCategories'] as List?) ?.map((e) => DownloadCategory.fromJson(e as Map)) @@ -120,11 +144,29 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => e.toJson()).toList(), 'bt': instance.bt.toJson(), 'githubMirror': instance.githubMirror.toJson(), }; +WindowStateConfig _$WindowStateConfigFromJson(Map json) => WindowStateConfig( + isMaximized: json['isMaximized'] as bool? ?? false, + width: (json['width'] as num?)?.toDouble(), + height: (json['height'] as num?)?.toDouble(), +); + +Map _$WindowStateConfigToJson(WindowStateConfig instance) => { + 'isMaximized': instance.isMaximized, + 'width': ?instance.width, + 'height': ?instance.height, +}; + DownloadCategory _$DownloadCategoryFromJson(Map json) => DownloadCategory( name: json['name'] as String, path: json['path'] as String, diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index 18aea31c..5ca4efb8 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -7,8 +7,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; -import '../../api/model/task.dart'; -import '../../core/capabilities/app_capabilities.dart'; +import '../../core/common/task_event.dart'; +import '../../core/libgopeed_boot.dart'; import '../../l10n/l10n.dart'; import '../../util/util.dart'; import 'app_runtime_controller.dart'; @@ -25,8 +25,7 @@ class AppNotificationState { class AppNotificationController extends AsyncNotifier { final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); - final Map _previousStatus = {}; - Timer? _timer; + StreamSubscription? _taskEventSubscription; var _notificationId = 0; @override @@ -36,9 +35,9 @@ class AppNotificationController extends AsyncNotifier { return const AppNotificationState(); } await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale)); - _startPolling(); + _listenTaskEvents(); ref.onDispose(() { - _timer?.cancel(); + unawaited(_taskEventSubscription?.cancel()); }); return const AppNotificationState(started: true); } @@ -77,30 +76,18 @@ class AppNotificationController extends AsyncNotifier { ); } - void _startPolling() { - _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 2), (_) async { - try { - final runtime = ref.read(appRuntimeControllerProvider).value; - if (runtime?.downloaderConfig.extra.desktopNotification == false) { - return; - } - final tasks = await ref.read(gopeedServiceProvider).getTasks(Status.values); - final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); - for (final task in tasks) { - final previous = _previousStatus[task.id]; - if (previous != null && previous != task.status) { - if (task.status == Status.done) { - await _showNotification(title: locale.notificationTaskDone, body: task.name); - } else if (task.status == Status.error) { - await _showNotification(title: locale.notificationTaskError, body: task.name); - } - } - _previousStatus[task.id] = task.status; - } - final currentIds = tasks.map((task) => task.id).toSet(); - _previousStatus.removeWhere((id, _) => !currentIds.contains(id)); - } catch (_) {} + void _listenTaskEvents() { + _taskEventSubscription?.cancel(); + _taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async { + final runtime = ref.read(appRuntimeControllerProvider).value; + if (runtime?.downloaderConfig.extra.desktopNotification == false) return; + final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); + switch (event.type) { + case TaskEventType.done: + await _showNotification(title: locale.notificationTaskDone, body: event.name); + case TaskEventType.error: + await _showNotification(title: locale.notificationTaskError, body: event.name); + } }); } diff --git a/ui/flutter/lib/app/application/app_platform_controller.dart b/ui/flutter/lib/app/application/app_platform_controller.dart index fd144762..53e542d1 100644 --- a/ui/flutter/lib/app/application/app_platform_controller.dart +++ b/ui/flutter/lib/app/application/app_platform_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show Size; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; @@ -15,8 +16,6 @@ import '../../core/libgopeed_boot.dart'; import '../../core/capabilities/app_capabilities.dart'; import '../../core/window/app_window_launcher.dart'; import '../../core/window/window_capability_transport.dart'; -import '../../database/database.dart'; -import '../../database/entity.dart'; import '../../features/tasks/application/pending_create_task.dart'; import '../../features/tasks/application/pending_update_task.dart'; import '../../l10n/l10n.dart'; @@ -64,9 +63,13 @@ class AppPlatformController extends AsyncNotifier with WindowL VersionInfo? _latestVersion; String? _updateError; Future? _checkUpdateFuture; - final _windowsResizeSave = Util.debounce(() async { + late final void Function() _windowsResizeSave = Util.debounce(() async { final size = await windowManager.getSize(); - Database.instance.saveWindowState(WindowStateEntity(width: size.width, height: size.height)); + await ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) { + extra.windowState + ..width = size.width + ..height = size.height; + }); }, 500); @override @@ -103,11 +106,13 @@ class AppPlatformController extends AsyncNotifier with WindowL Future _start() async { _started = true; + final extra = ref.read(appRuntimeControllerProvider).value!.downloaderConfig.extra; await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry); + await _restoreWindowState(extra.windowState); windowManager.addListener(this); await windowManager.setPreventClose(true); if (Util.isMacos()) { - _runAsMenubarApp = Database.instance.getRunAsMenubarApp(); + _runAsMenubarApp = extra.runAsMenubarApp; await windowManager.setSkipTaskbar(_runAsMenubarApp); } await _initTray(); @@ -245,7 +250,9 @@ class AppPlatformController extends AsyncNotifier with WindowL if (!Util.isMacos()) { return; } - Database.instance.saveRunAsMenubarApp(enabled); + await ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) { + extra.runAsMenubarApp = enabled; + }); _runAsMenubarApp = enabled; await windowManager.setSkipTaskbar(enabled); await Future.delayed(const Duration(milliseconds: 100)); @@ -369,16 +376,36 @@ class AppPlatformController extends AsyncNotifier with WindowL @override void onWindowMaximize() { - Database.instance.saveWindowState(WindowStateEntity(isMaximized: true)); + unawaited( + ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) { + extra.windowState.isMaximized = true; + }), + ); } @override void onWindowUnmaximize() { - Database.instance.saveWindowState(WindowStateEntity(isMaximized: false)); + unawaited( + ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) { + extra.windowState.isMaximized = false; + }), + ); } @override void onWindowResize() { _windowsResizeSave(); } + + Future _restoreWindowState(WindowStateConfig windowState) async { + final width = windowState.width; + final height = windowState.height; + if (width != null && height != null && width > 0 && height > 0) { + await windowManager.setSize(Size(width, height)); + await windowManager.center(); + } + if (windowState.isMaximized) { + await windowManager.maximize(); + } + } } diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart index 20bc04a3..5dfbfbf4 100644 --- a/ui/flutter/lib/app/application/app_runtime_controller.dart +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -8,14 +8,15 @@ import 'package:path_provider/path_provider.dart'; import '../../api/api.dart' as api; import '../../api/model/downloader_config.dart'; import '../../core/capabilities/app_capabilities.dart'; +import '../../core/common/api_server_state.dart'; import '../../core/common/start_config.dart'; +import '../../core/common/task_event.dart'; import '../../core/entry/app_initializer.dart'; import '../../core/libgopeed_boot.dart'; -import '../../database/database.dart'; -import '../../database/entity.dart'; import '../../features/auth/application/web_auth_controller.dart'; import '../../l10n/l10n.dart'; import '../../util/log_util.dart'; +import '../../util/analytics.dart'; import '../../util/package_info.dart'; import '../../util/util.dart'; import '../rpc/webview_rpc_service.dart'; @@ -31,28 +32,26 @@ final appRuntimeControllerProvider = AsyncNotifierProvider apiServerState.runningPort; + + String runningAddress() => apiServerState.runningAddress(); } class AppRuntimeController extends AsyncNotifier { static StartConfig? _defaultStartConfig; + Future _preferenceWrites = Future.value(); @override Future build() async { @@ -63,11 +62,18 @@ class AppRuntimeController extends AsyncNotifier { Future reloadConfig() async { final current = state.value; if (current == null) return; + final config = await ref.read(gopeedServiceProvider).getConfig(); + final apiState = kIsWeb ? current.apiServerState : (await LibgopeedBoot.instance.getApiServerState()).state; + final startConfig = _copyStartConfig(current.startConfig) + ..apiEnable = config.api.enable + ..network = config.api.network + ..address = config.api.address + ..apiToken = config.api.token; state = AsyncValue.data( AppRuntimeState( - startConfig: current.startConfig, - runningPort: current.runningPort, - downloaderConfig: await ref.read(gopeedServiceProvider).getConfig(), + startConfig: startConfig, + apiServerState: apiState, + downloaderConfig: config, localBackendStarted: current.localBackendStarted, startupError: current.startupError, ), @@ -80,7 +86,7 @@ class AppRuntimeController extends AsyncNotifier { state = AsyncValue.data( AppRuntimeState( startConfig: current.startConfig, - runningPort: current.runningPort, + apiServerState: current.apiServerState, downloaderConfig: DownloaderConfig.fromJson(config.toJson()), localBackendStarted: current.localBackendStarted, startupError: current.startupError, @@ -90,7 +96,7 @@ class AppRuntimeController extends AsyncNotifier { } Future _init() async { - await AppInitializer.ensureDatabaseInitialized(); + await AppInitializer.ensureStorageInitialized(); try { await initPackageInfo(); } catch (_) {} @@ -104,37 +110,57 @@ class AppRuntimeController extends AsyncNotifier { } Object? startupError; var runningPort = 0; + var apiServerState = const ApiServerState( + enabled: false, + running: false, + network: '', + address: '', + runningPort: 0, + pendingApply: false, + lastError: '', + ); var localBackendStarted = false; try { // Go's rest.Start is already idempotent: when its server is running it // returns the existing port before rebuilding or touching the Unix // socket. This is also what keeps Riverpod hot reload safe. runningPort = await LibgopeedBoot.instance.start(startConfig); + await LibgopeedBoot.instance.subscribeTaskEvents({TaskEventType.done, TaskEventType.error}); + if (!kIsWeb) { + final statusResult = await LibgopeedBoot.instance.getApiServerState(); + apiServerState = statusResult.state; + if (statusResult.error.isNotEmpty) { + logger.w('read API server state failed: ${statusResult.error}'); + } + } localBackendStarted = true; } catch (error, stackTrace) { - startupError = error; - logger.w('libgopeed init failed, falling back to external API', error, stackTrace); - if (startConfig.network == 'unix') { - startConfig.network = 'tcp'; - startConfig.address = '127.0.0.1:9999'; - } - runningPort = int.tryParse(startConfig.address.split(':').last) ?? 9999; + logger.e('libgopeed init failed', error, stackTrace); + rethrow; } - api.init( - startConfig.network, - _runningAddress(startConfig, runningPort), - startConfig.apiToken, - onUnauthorized: ref.read(webAuthControllerProvider).requireLogin, - ); + if (kIsWeb) { + final webAuth = ref.read(webAuthControllerProvider); + api.init('tcp', Uri.base.authority, '', onUnauthorized: webAuth.requireLogin); + } else { + api.init(startConfig.network, _runningAddress(startConfig, runningPort), startConfig.apiToken); + } final config = await _loadDownloaderConfig(); + if (!kIsWeb) { + startConfig + ..apiEnable = config.api.enable + ..network = config.api.network + ..address = config.api.address + ..apiToken = config.api.token; + } unawaited(_initTrackerUpdate(config)); unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale))); + await _initAnalytics(config); final result = AppRuntimeState( startConfig: startConfig, - runningPort: runningPort, + apiServerState: apiServerState, downloaderConfig: config, localBackendStarted: localBackendStarted, startupError: startupError, @@ -158,11 +184,10 @@ class AppRuntimeController extends AsyncNotifier { Future _loadStartConfig() async { final defaultCfg = await _initDefaultStartConfig(); - final saved = Database.instance.getStartConfig(); final cfg = StartConfig() - ..network = saved?.network ?? defaultCfg.network - ..address = saved?.address ?? defaultCfg.address - ..apiToken = saved?.apiToken ?? defaultCfg.apiToken + ..network = defaultCfg.network + ..address = defaultCfg.address + ..apiToken = defaultCfg.apiToken ..storage = defaultCfg.storage ..storageDir = defaultCfg.storageDir ..refreshInterval = defaultCfg.refreshInterval; @@ -178,6 +203,13 @@ class AppRuntimeController extends AsyncNotifier { ..storageDir = Util.getStorageDir() ..refreshInterval = 0 ..apiToken = ''; + if (kIsWeb) { + cfg + ..network = 'tcp' + ..address = Uri.base.authority; + _defaultStartConfig = cfg; + return cfg; + } if (!Util.supportUnixSocket()) { cfg ..network = 'tcp' @@ -270,6 +302,20 @@ class AppRuntimeController extends AsyncNotifier { } } + Future _initAnalytics(DownloaderConfig config) async { + if (!AnalyticsConfig.isConfigured || !config.extra.analyticsEnabled) return; + try { + await Analytics.instance.init(fallbackClientId: config.extra.analyticsClientId); + if (config.extra.analyticsClientId.isEmpty && Analytics.instance.clientId.isNotEmpty) { + config.extra.analyticsClientId = Analytics.instance.clientId; + await ref.read(gopeedServiceProvider).putConfig(config); + } + unawaited(Analytics.instance.logAppOpen()); + } catch (error, stackTrace) { + logger.w('GA4 init failed', error, stackTrace); + } + } + Future updateTrackers(DownloaderConfig config) async { final btExtra = config.extra.bt; final trackers = []; @@ -290,7 +336,7 @@ class AppRuntimeController extends AsyncNotifier { state = AsyncValue.data( AppRuntimeState( startConfig: current.startConfig, - runningPort: current.runningPort, + apiServerState: current.apiServerState, downloaderConfig: config, localBackendStarted: current.localBackendStarted, startupError: current.startupError, @@ -322,11 +368,116 @@ class AppRuntimeController extends AsyncNotifier { return '${config.address.split(':').first}:$runningPort'; } - Future saveStartConfig(StartConfig config) async { - Database.instance.saveStartConfig( - StartConfigEntity(network: config.network, address: config.address, apiToken: config.apiToken), + Future saveApiServerConfig(StartConfig config) async { + if (kIsWeb) return; + await _persistApiServerConfig(config); + } + + Future startApiServer() async { + if (kIsWeb) return; + await _persistApiServerEnabled(true); + await _applyApiServerOperation(LibgopeedBoot.instance.startApiServer()); + } + + Future stopApiServer() async { + if (kIsWeb) return; + await _persistApiServerEnabled(false); + await _applyApiServerOperation(LibgopeedBoot.instance.stopApiServer()); + } + + Future restartApiServer(StartConfig config) async { + if (kIsWeb) return; + final desired = _copyStartConfig(config)..apiEnable = true; + await _persistApiServerConfig(desired); + await _applyApiServerOperation(LibgopeedBoot.instance.restartApiServer()); + } + + Future refreshApiServerState() async { + if (kIsWeb) return; + await _applyApiServerOperation(LibgopeedBoot.instance.getApiServerState()); + } + + Future _persistApiServerConfig(StartConfig config) async { + final current = state.value; + if (current == null) return; + final nextConfig = _copyStartConfig(config); + final downloaderConfig = await ref.read(gopeedServiceProvider).getConfig(); + downloaderConfig.api + ..enable = nextConfig.apiEnable + ..network = nextConfig.network + ..address = nextConfig.address + ..token = nextConfig.apiToken; + await ref.read(gopeedServiceProvider).putConfig(downloaderConfig); + final statusResult = await LibgopeedBoot.instance.getApiServerState(); + _replaceApiRuntime(nextConfig, downloaderConfig, statusResult.state); + if (statusResult.error.isNotEmpty) { + throw StateError(statusResult.error); + } + } + + Future _persistApiServerEnabled(bool enabled) async { + final current = state.value; + if (current == null) return; + final downloaderConfig = await ref.read(gopeedServiceProvider).getConfig(); + downloaderConfig.api.enable = enabled; + await ref.read(gopeedServiceProvider).putConfig(downloaderConfig); + final nextConfig = _copyStartConfig(current.startConfig)..apiEnable = enabled; + final statusResult = await LibgopeedBoot.instance.getApiServerState(); + _replaceApiRuntime(nextConfig, downloaderConfig, statusResult.state); + if (statusResult.error.isNotEmpty) { + throw StateError(statusResult.error); + } + } + + Future _applyApiServerOperation(Future operation) async { + final result = await operation; + final current = state.value; + if (current != null) { + _replaceApiRuntime(current.startConfig, current.downloaderConfig, result.state); + } + if (result.error.isNotEmpty) { + throw StateError(result.error); + } + } + + void _replaceApiRuntime(StartConfig config, DownloaderConfig downloaderConfig, ApiServerState apiState) { + final current = state.value; + if (current == null) return; + state = AsyncValue.data( + AppRuntimeState( + startConfig: _copyStartConfig(config), + apiServerState: apiState, + downloaderConfig: DownloaderConfig.fromJson(downloaderConfig.toJson()), + localBackendStarted: current.localBackendStarted, + startupError: current.startupError, + ), ); } + + StartConfig _copyStartConfig(StartConfig config) { + return StartConfig() + ..network = config.network + ..address = config.address + ..apiEnable = config.apiEnable + ..storage = config.storage + ..storageDir = config.storageDir + ..refreshInterval = config.refreshInterval + ..apiToken = config.apiToken + ..webViewRpcConfig = config.webViewRpcConfig; + } + + Future updateExtraConfig(void Function(ExtraConfig extra) mutation) { + final operation = _preferenceWrites.then((_) async { + final current = state.value; + if (current == null) return; + final config = await ref.read(gopeedServiceProvider).getConfig(); + mutation(config.extra); + await ref.read(gopeedServiceProvider).putConfig(config); + replaceDownloaderConfig(config); + }); + _preferenceWrites = operation.then((_) {}, onError: (_, _) {}); + return operation; + } } const allTrackerSubscribeUrls = [ diff --git a/ui/flutter/lib/core/capabilities/app_capabilities.dart b/ui/flutter/lib/core/capabilities/app_capabilities.dart index ba736b39..6333ea5f 100644 --- a/ui/flutter/lib/core/capabilities/app_capabilities.dart +++ b/ui/flutter/lib/core/capabilities/app_capabilities.dart @@ -12,7 +12,6 @@ import '../../api/model/switch_extension.dart'; import '../../api/model/task.dart'; import '../../api/model/update_check_extension_resp.dart'; import '../../api/model/update_extension_settings.dart'; -import '../../database/database.dart'; import '../window/app_window_appearance.dart'; import 'capability_rpc.dart'; import 'gopeed_capability.dart'; @@ -147,19 +146,33 @@ void _bindGopeed(CapabilityRegistry registry) { void _bindStorage(CapabilityRegistry registry) { registry - ..bind(StorageMethods.getCreateHistory, (_) async => Database.instance.getCreateHistory() ?? const []) + ..bind(StorageMethods.getCreateHistory, (_) async { + final config = await api.getConfig(); + return config.extra.createHistory; + }) ..bind(StorageMethods.saveCreateHistory, (urls) async { + final config = await api.getConfig(); + final history = List.of(config.extra.createHistory); for (final url in urls) { - Database.instance.saveCreateHistory(url); + history.remove(url); + history.insert(0, url); + if (history.length > 64) history.removeLast(); } + config.extra.createHistory = history; + await api.putConfig(config); return const RpcUnit(); }) ..bind(StorageMethods.removeCreateHistory, (url) async { - Database.instance.removeCreateHistory(url); + final config = await api.getConfig(); + final history = List.of(config.extra.createHistory)..remove(url); + config.extra.createHistory = history; + await api.putConfig(config); return const RpcUnit(); }) ..bind(StorageMethods.clearCreateHistory, (_) async { - Database.instance.clearCreateHistory(); + final config = await api.getConfig(); + config.extra.createHistory = const []; + await api.putConfig(config); return const RpcUnit(); }); } diff --git a/ui/flutter/lib/core/common/api_server_state.dart b/ui/flutter/lib/core/common/api_server_state.dart new file mode 100644 index 00000000..7fa4a0c1 --- /dev/null +++ b/ui/flutter/lib/core/common/api_server_state.dart @@ -0,0 +1,64 @@ +class ApiServerState { + const ApiServerState({ + required this.enabled, + required this.running, + required this.network, + required this.address, + required this.runningPort, + required this.pendingApply, + required this.lastError, + }); + + final bool enabled; + final bool running; + final String network; + final String address; + final int runningPort; + final bool pendingApply; + final String lastError; + + factory ApiServerState.fromJson(Map json) { + return ApiServerState( + enabled: json['enabled'] as bool? ?? false, + running: json['running'] as bool? ?? false, + network: json['network'] as String? ?? '', + address: json['address'] as String? ?? '', + runningPort: json['runningPort'] as int? ?? 0, + pendingApply: json['pendingApply'] as bool? ?? false, + lastError: json['lastError'] as String? ?? '', + ); + } + + String runningAddress() { + if (!running) return ''; + if (network == 'unix') return address; + final separator = address.lastIndexOf(':'); + final host = separator < 0 ? address : address.substring(0, separator); + return '$host:$runningPort'; + } +} + +class ApiServerOperationResult { + const ApiServerOperationResult({required this.state, required this.error}); + + final ApiServerState state; + final String error; + + factory ApiServerOperationResult.fromJson(Map json) { + final rawState = json['state'] as Map?; + return ApiServerOperationResult( + state: rawState == null + ? const ApiServerState( + enabled: false, + running: false, + network: '', + address: '', + runningPort: 0, + pendingApply: true, + lastError: '', + ) + : ApiServerState.fromJson(rawState), + error: json['error'] as String? ?? '', + ); + } +} diff --git a/ui/flutter/lib/core/common/libgopeed_channel.dart b/ui/flutter/lib/core/common/libgopeed_channel.dart index 31c1ee1a..7bc32778 100644 --- a/ui/flutter/lib/core/common/libgopeed_channel.dart +++ b/ui/flutter/lib/core/common/libgopeed_channel.dart @@ -1,12 +1,24 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/services.dart'; +import 'api_server_state.dart'; import 'libgopeed_interface.dart'; import 'start_config.dart'; +import 'task_event.dart'; class LibgopeedChannel implements LibgopeedInterface { static const _channel = MethodChannel('gopeed.com/libgopeed'); + final _taskEvents = StreamController.broadcast(); + + LibgopeedChannel() { + _channel.setMethodCallHandler((call) async { + if (call.method != 'taskEvent') return; + final payload = jsonDecode(call.arguments as String) as Map; + _taskEvents.add(TaskEvent.fromJson(payload)); + }); + } @override Future start(StartConfig cfg) async { @@ -18,4 +30,40 @@ class LibgopeedChannel implements LibgopeedInterface { Future stop() async { return await _channel.invokeMethod('stop'); } + + Future _apiServerOperation(String method) async { + final payload = await _channel.invokeMethod(method) ?? ''; + return ApiServerOperationResult.fromJson(jsonDecode(payload) as Map); + } + + @override + Future getApiServerState() => _apiServerOperation('getApiServerState'); + + @override + Future startApiServer() => _apiServerOperation('startApiServer'); + + @override + Future stopApiServer() => _apiServerOperation('stopApiServer'); + + @override + Future restartApiServer() => _apiServerOperation('restartApiServer'); + + @override + Future invoke(String method, String path, {String query = '', String body = ''}) async { + final result = await _channel.invokeMethod('invoke', { + 'method': method, + 'path': path, + 'query': query, + 'body': body, + }); + return result ?? ''; + } + + @override + Stream get taskEvents => _taskEvents.stream; + + @override + Future subscribeTaskEvents(Set events) { + return _channel.invokeMethod('subscribeTaskEvents', {'mask': events.mask}); + } } diff --git a/ui/flutter/lib/core/common/libgopeed_ffi.dart b/ui/flutter/lib/core/common/libgopeed_ffi.dart index ae2db5f9..aae4b938 100644 --- a/ui/flutter/lib/core/common/libgopeed_ffi.dart +++ b/ui/flutter/lib/core/common/libgopeed_ffi.dart @@ -5,24 +5,47 @@ import 'dart:ffi'; import 'package:ffi/ffi.dart'; import '../ffi/libgopeed_bind.dart'; +import '../ffi/libgopeed_worker.dart'; +import 'api_server_state.dart'; import 'libgopeed_interface.dart'; import 'start_config.dart'; +import 'task_event.dart'; class LibgopeedFFi implements LibgopeedInterface { late LibgopeedBind _libgopeed; + final _taskEvents = StreamController.broadcast(); + final _worker = LibgopeedWorker(); + late final NativeCallable)> _taskEventCallback; LibgopeedFFi(LibgopeedBind libgopeed) { _libgopeed = libgopeed; + _taskEventCallback = NativeCallable)>.listener(_onTaskEvent); + } + + void _onTaskEvent(Pointer payload) { + try { + final decoded = jsonDecode(payload.cast().toDartString()) as Map; + _taskEvents.add(TaskEvent.fromJson(decoded)); + } finally { + _libgopeed.FreeCString(payload); + } } @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); + final cfgPtr = jsonEncode(cfg).toNativeUtf8(); + try { + final result = _libgopeed.Start(cfgPtr.cast()); + if (result.r1 != nullptr) { + final message = result.r1.cast().toDartString(); + _libgopeed.FreeCString(result.r1); + completer.completeError(Exception(message)); + } else { + completer.complete(result.r0); + } + } finally { + malloc.free(cfgPtr); } return completer.future; } @@ -34,4 +57,53 @@ class LibgopeedFFi implements LibgopeedInterface { completer.complete(); return completer.future; } + + Future _apiServerOperation(String operation) async { + final payload = await _worker.apiServer(operation); + return ApiServerOperationResult.fromJson(jsonDecode(payload) as Map); + } + + @override + Future getApiServerState() => _apiServerOperation('get'); + + @override + Future startApiServer() => _apiServerOperation('start'); + + @override + Future stopApiServer() => _apiServerOperation('stop'); + + @override + Future restartApiServer() => _apiServerOperation('restart'); + + @override + Future invoke(String method, String path, {String query = '', String body = ''}) async { + final methodPtr = method.toNativeUtf8(); + final pathPtr = path.toNativeUtf8(); + final queryPtr = query.toNativeUtf8(); + final bodyPtr = body.toNativeUtf8(); + Pointer? resultPtr; + try { + resultPtr = _libgopeed.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast()); + if (resultPtr == nullptr) { + throw StateError('Gopeed Invoke returned a null response'); + } + return resultPtr.cast().toDartString(); + } finally { + malloc.free(methodPtr); + malloc.free(pathPtr); + malloc.free(queryPtr); + malloc.free(bodyPtr); + if (resultPtr != null && resultPtr != nullptr) { + _libgopeed.FreeCString(resultPtr); + } + } + } + + @override + Stream get taskEvents => _taskEvents.stream; + + @override + Future subscribeTaskEvents(Set events) async { + _libgopeed.SubscribeTaskEvents(events.mask, events.isEmpty ? 0 : _taskEventCallback.nativeFunction.address); + } } diff --git a/ui/flutter/lib/core/common/libgopeed_interface.dart b/ui/flutter/lib/core/common/libgopeed_interface.dart index 559d799f..d46901ef 100644 --- a/ui/flutter/lib/core/common/libgopeed_interface.dart +++ b/ui/flutter/lib/core/common/libgopeed_interface.dart @@ -1,7 +1,23 @@ +import 'api_server_state.dart'; import 'start_config.dart'; +import 'task_event.dart'; abstract class LibgopeedInterface { Future start(StartConfig cfg); Future stop(); + + Future getApiServerState(); + + Future startApiServer(); + + Future stopApiServer(); + + Future restartApiServer(); + + Future invoke(String method, String path, {String query = '', String body = ''}); + + Stream get taskEvents; + + Future subscribeTaskEvents(Set events); } diff --git a/ui/flutter/lib/core/common/start_config.dart b/ui/flutter/lib/core/common/start_config.dart index f832e811..3a7c6aec 100644 --- a/ui/flutter/lib/core/common/start_config.dart +++ b/ui/flutter/lib/core/common/start_config.dart @@ -19,6 +19,7 @@ class WebViewRpcConfig { class StartConfig { late String network; late String address; + bool apiEnable = false; late String storage; late String storageDir; late int refreshInterval; diff --git a/ui/flutter/lib/core/common/start_config.g.dart b/ui/flutter/lib/core/common/start_config.g.dart index 305faf21..866eb000 100644 --- a/ui/flutter/lib/core/common/start_config.g.dart +++ b/ui/flutter/lib/core/common/start_config.g.dart @@ -22,6 +22,7 @@ Map _$WebViewRpcConfigToJson(WebViewRpcConfig instance) => StartConfig _$StartConfigFromJson(Map json) => StartConfig() ..network = json['network'] as String ..address = json['address'] as String + ..apiEnable = json['apiEnable'] as bool ..storage = json['storage'] as String ..storageDir = json['storageDir'] as String ..refreshInterval = (json['refreshInterval'] as num).toInt() @@ -36,6 +37,7 @@ Map _$StartConfigToJson(StartConfig instance) => { 'network': instance.network, 'address': instance.address, + 'apiEnable': instance.apiEnable, 'storage': instance.storage, 'storageDir': instance.storageDir, 'refreshInterval': instance.refreshInterval, diff --git a/ui/flutter/lib/core/common/task_event.dart b/ui/flutter/lib/core/common/task_event.dart new file mode 100644 index 00000000..91a6957f --- /dev/null +++ b/ui/flutter/lib/core/common/task_event.dart @@ -0,0 +1,38 @@ +enum TaskEventType { + done(1, 'task.done'), + error(2, 'task.error'); + + const TaskEventType(this.mask, this.wireName); + + final int mask; + final String wireName; + + static TaskEventType fromWireName(String value) { + return values.firstWhere( + (type) => type.wireName == value, + orElse: () => throw FormatException('Unknown task event type: $value'), + ); + } +} + +extension TaskEventTypeSet on Set { + int get mask => fold(0, (value, type) => value | type.mask); +} + +class TaskEvent { + const TaskEvent({required this.type, required this.taskId, required this.name, this.error}); + + final TaskEventType type; + final String taskId; + final String name; + final String? error; + + factory TaskEvent.fromJson(Map json) { + return TaskEvent( + type: TaskEventType.fromWireName(json['type'] as String), + taskId: json['taskId'] as String, + name: json['name'] as String? ?? '', + error: json['error'] as String?, + ); + } +} diff --git a/ui/flutter/lib/core/entry/app_initializer.dart b/ui/flutter/lib/core/entry/app_initializer.dart index a7ab8d2c..38b45483 100644 --- a/ui/flutter/lib/core/entry/app_initializer.dart +++ b/ui/flutter/lib/core/entry/app_initializer.dart @@ -1,20 +1,9 @@ -import '../../database/database.dart'; import '../../util/util.dart'; class AppInitializer { static Future? _storageInitialization; - static Future? _databaseInitialization; static Future ensureStorageInitialized() { return _storageInitialization ??= Util.initStorageDir(); } - - static Future ensureDatabaseInitialized() { - return _databaseInitialization ??= _initializeDatabase(); - } - - static Future _initializeDatabase() async { - await ensureStorageInitialized(); - await Database.instance.init(); - } } diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart index 6ea61cd0..04f18448 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import '../common/api_server_state.dart'; import '../common/start_config.dart'; +import '../common/task_event.dart'; import '../libgopeed_boot.dart'; @@ -13,4 +15,40 @@ class LibgopeedBootBrowser implements LibgopeedBoot { @override Future stop() async {} + + @override + Future getApiServerState() async => _unsupportedApiServerResult; + + @override + Future startApiServer() async => _unsupportedApiServerResult; + + @override + Future stopApiServer() async => _unsupportedApiServerResult; + + @override + Future restartApiServer() async => _unsupportedApiServerResult; + + @override + Future invoke(String method, String path, {String query = '', String body = ''}) { + throw UnsupportedError('Native Gopeed invoke is unavailable on web'); + } + + @override + Stream get taskEvents => const Stream.empty(); + + @override + Future subscribeTaskEvents(Set events) async {} } + +const _unsupportedApiServerResult = ApiServerOperationResult( + state: ApiServerState( + enabled: false, + running: false, + network: '', + address: '', + runningPort: 0, + pendingApply: false, + lastError: '', + ), + error: '', +); diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart index 6c81679b..1077c045 100644 --- a/ui/flutter/lib/core/entry/libgopeed_boot_native.dart +++ b/ui/flutter/lib/core/entry/libgopeed_boot_native.dart @@ -6,7 +6,9 @@ import '../../util/util.dart'; import '../common/libgopeed_channel.dart'; import '../common/libgopeed_ffi.dart'; import '../common/libgopeed_interface.dart'; +import '../common/api_server_state.dart'; import '../common/start_config.dart'; +import '../common/task_event.dart'; import '../ffi/libgopeed_bind.dart'; import '../libgopeed_boot.dart'; @@ -43,6 +45,32 @@ class LibgopeedBootNative implements LibgopeedBoot { @override Future stop() async { + await _libgopeed.subscribeTaskEvents({}); await _libgopeed.stop(); } + + @override + Future getApiServerState() => _libgopeed.getApiServerState(); + + @override + Future startApiServer() => _libgopeed.startApiServer(); + + @override + Future stopApiServer() => _libgopeed.stopApiServer(); + + @override + Future restartApiServer() => _libgopeed.restartApiServer(); + + @override + Future invoke(String method, String path, {String query = '', String body = ''}) { + return _libgopeed.invoke(method, path, query: query, body: body); + } + + @override + Stream get taskEvents => _libgopeed.taskEvents; + + @override + Future subscribeTaskEvents(Set events) { + return _libgopeed.subscribeTaskEvents(events); + } } diff --git a/ui/flutter/lib/core/ffi/libgopeed_bind.dart b/ui/flutter/lib/core/ffi/libgopeed_bind.dart index 8fd4ffc8..347d416b 100644 --- a/ui/flutter/lib/core/ffi/libgopeed_bind.dart +++ b/ui/flutter/lib/core/ffi/libgopeed_bind.dart @@ -29,6 +29,72 @@ class LibgopeedBind { late final _StopPtr = _lookup>('Stop'); late final _Stop = _StopPtr.asFunction(); + + ffi.Pointer GetAPIServerState() => _GetAPIServerState(); + + late final _GetAPIServerStatePtr = _lookup Function()>>('GetAPIServerState'); + late final _GetAPIServerState = _GetAPIServerStatePtr.asFunction Function()>(); + + ffi.Pointer StartAPIServer() => _StartAPIServer(); + + late final _StartAPIServerPtr = _lookup Function()>>('StartAPIServer'); + late final _StartAPIServer = _StartAPIServerPtr.asFunction Function()>(); + + ffi.Pointer StopAPIServer() => _StopAPIServer(); + + late final _StopAPIServerPtr = _lookup Function()>>('StopAPIServer'); + late final _StopAPIServer = _StopAPIServerPtr.asFunction Function()>(); + + ffi.Pointer RestartAPIServer() => _RestartAPIServer(); + + late final _RestartAPIServerPtr = _lookup Function()>>('RestartAPIServer'); + late final _RestartAPIServer = _RestartAPIServerPtr.asFunction Function()>(); + + ffi.Pointer Invoke( + ffi.Pointer method, + ffi.Pointer path, + ffi.Pointer query, + ffi.Pointer body, + ) { + return _Invoke(method, path, query, body); + } + + late final _InvokePtr = + _lookup< + ffi.NativeFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ) + > + >('Invoke'); + late final _Invoke = + _InvokePtr.asFunction< + ffi.Pointer Function( + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ffi.Pointer, + ) + >(); + + void SubscribeTaskEvents(int mask, int callback) { + return _SubscribeTaskEvents(mask, callback); + } + + late final _SubscribeTaskEventsPtr = _lookup>( + 'SubscribeTaskEvents', + ); + late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction(); + + void FreeCString(ffi.Pointer value) { + return _FreeCString(value); + } + + late final _FreeCStringPtr = _lookup)>>('FreeCString'); + late final _FreeCString = _FreeCStringPtr.asFunction)>(); } final class Start_return extends ffi.Struct { diff --git a/ui/flutter/lib/core/ffi/libgopeed_worker.dart b/ui/flutter/lib/core/ffi/libgopeed_worker.dart new file mode 100644 index 00000000..72f60912 --- /dev/null +++ b/ui/flutter/lib/core/ffi/libgopeed_worker.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:ffi/ffi.dart'; + +import 'libgopeed_bind.dart'; + +class LibgopeedWorker { + LibgopeedWorker() { + _receivePort.listen(_handleWorkerMessage); + _errorPort.listen(_handleWorkerError); + _isolateReady = Isolate.spawn(_workerMain, _receivePort.sendPort, onError: _errorPort.sendPort); + } + + final ReceivePort _receivePort = ReceivePort(); + final ReceivePort _errorPort = ReceivePort(); + final Map> _pending = {}; + late final Future _isolateReady; + final Completer _sendPort = Completer(); + int _requestID = 0; + + Future invoke(String method, String path, String query, String body) async { + await _isolateReady; + final sendPort = await _sendPort.future; + final requestID = _requestID++; + final completer = Completer(); + _pending[requestID] = completer; + sendPort.send(['invoke', requestID, method, path, query, body]); + return completer.future; + } + + Future apiServer(String operation) async { + await _isolateReady; + final sendPort = await _sendPort.future; + final requestID = _requestID++; + final completer = Completer(); + _pending[requestID] = completer; + sendPort.send(['apiServer', requestID, operation]); + return completer.future; + } + + void _handleWorkerMessage(dynamic message) { + if (message is SendPort) { + if (!_sendPort.isCompleted) _sendPort.complete(message); + return; + } + final response = message as List; + final requestID = response[0] as int; + final completer = _pending.remove(requestID); + if (completer == null) return; + if (response[1] as bool) { + completer.complete(response[2] as String); + } else { + completer.completeError(StateError(response[2] as String)); + } + } + + void _handleWorkerError(dynamic message) { + final parts = message is List ? message : [message]; + final error = StateError(parts.map((part) => part.toString()).join('\n')); + if (!_sendPort.isCompleted) _sendPort.completeError(error); + for (final completer in _pending.values) { + completer.completeError(error); + } + _pending.clear(); + } + + static void _workerMain(SendPort mainSendPort) { + final requests = ReceivePort(); + final bindings = LibgopeedBind(DynamicLibrary.open(_libraryName())); + mainSendPort.send(requests.sendPort); + requests.listen((dynamic message) { + final request = message as List; + final type = request[0] as String; + final requestID = request[1] as int; + try { + final response = switch (type) { + 'invoke' => _invoke( + bindings, + request[2] as String, + request[3] as String, + request[4] as String, + request[5] as String, + ), + 'apiServer' => _apiServer(bindings, request[2] as String), + _ => throw StateError('Unknown libgopeed worker request: $type'), + }; + mainSendPort.send([requestID, true, response]); + } catch (error) { + mainSendPort.send([requestID, false, error.toString()]); + } + }); + } + + static String _apiServer(LibgopeedBind bindings, String operation) { + final resultPtr = switch (operation) { + 'get' => bindings.GetAPIServerState(), + 'start' => bindings.StartAPIServer(), + 'stop' => bindings.StopAPIServer(), + 'restart' => bindings.RestartAPIServer(), + _ => throw StateError('Unknown API server operation: $operation'), + }; + if (resultPtr == nullptr) { + throw StateError('Gopeed API server operation returned a null response'); + } + try { + return resultPtr.cast().toDartString(); + } finally { + bindings.FreeCString(resultPtr); + } + } + + static String _invoke(LibgopeedBind bindings, String method, String path, String query, String body) { + final methodPtr = method.toNativeUtf8(); + final pathPtr = path.toNativeUtf8(); + final queryPtr = query.toNativeUtf8(); + final bodyPtr = body.toNativeUtf8(); + Pointer? resultPtr; + try { + resultPtr = bindings.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast()); + if (resultPtr == nullptr) { + throw StateError('Gopeed Invoke returned a null response'); + } + return resultPtr.cast().toDartString(); + } finally { + malloc.free(methodPtr); + malloc.free(pathPtr); + malloc.free(queryPtr); + malloc.free(bodyPtr); + if (resultPtr != null && resultPtr != nullptr) { + bindings.FreeCString(resultPtr); + } + } + } + + static String _libraryName() { + if (Platform.isWindows) return 'libgopeed.dll'; + if (Platform.isMacOS) return 'libgopeed.dylib'; + if (Platform.isLinux) return 'libgopeed.so'; + throw UnsupportedError('Desktop FFI is unavailable on ${Platform.operatingSystem}'); + } +} diff --git a/ui/flutter/lib/core/libgopeed_boot.dart b/ui/flutter/lib/core/libgopeed_boot.dart index 98bf3dc8..7f8adf4d 100644 --- a/ui/flutter/lib/core/libgopeed_boot.dart +++ b/ui/flutter/lib/core/libgopeed_boot.dart @@ -1,4 +1,6 @@ +import 'common/api_server_state.dart'; import 'common/start_config.dart'; +import 'common/task_event.dart'; import "libgopeed_boot_stub.dart" if (dart.library.html) 'entry/libgopeed_boot_browser.dart' if (dart.library.io) 'entry/libgopeed_boot_native.dart'; @@ -16,4 +18,18 @@ abstract class LibgopeedBoot { Future start(StartConfig cfg); Future stop(); + + Future getApiServerState(); + + Future startApiServer(); + + Future stopApiServer(); + + Future restartApiServer(); + + Future invoke(String method, String path, {String query = '', String body = ''}); + + Stream get taskEvents; + + Future subscribeTaskEvents(Set events); } diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart new file mode 100644 index 00000000..36f3f3e5 --- /dev/null +++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_native.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../../libgopeed_boot.dart'; +import '../../../ffi/libgopeed_worker.dart'; +import '../../../../util/util.dart'; +import '../gopeed_transport.dart'; + +GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport(); + +class NativeGopeedTransport implements GopeedTransport { + NativeGopeedTransport() + : _externalDio = Dio( + BaseOptions( + contentType: Headers.jsonContentType, + sendTimeout: const Duration(seconds: 5), + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 120), + ), + ); + + final Dio _externalDio; + final LibgopeedWorker? _ffiWorker = Util.isDesktop() ? LibgopeedWorker() : null; + + @override + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? queryParameters, + }) async { + final request = _RequestParts.from(path, queryParameters); + final requestBody = data == null ? '' : jsonEncode(data); + final payload = _ffiWorker != null + ? await _ffiWorker.invoke(method.toUpperCase(), request.path, request.query, requestBody) + : await LibgopeedBoot.instance.invoke( + method.toUpperCase(), + request.path, + query: request.query, + body: requestBody, + ); + return jsonDecode(payload); + } + + @override + Future> proxyRequest(String uri, {dynamic data, Options? options}) { + return _externalDio.request( + uri, + data: data, + options: (options ?? Options()).copyWith(responseType: ResponseType.plain), + ); + } + + @override + String join(String path) => path; +} + +class _RequestParts { + const _RequestParts(this.path, this.query); + + final String path; + final String query; + + factory _RequestParts.from(String rawPath, Map? queryParameters) { + var path = rawPath; + var query = ''; + final queryIndex = rawPath.indexOf('?'); + if (queryIndex >= 0) { + path = rawPath.substring(0, queryIndex); + query = rawPath.substring(queryIndex + 1); + } + final extraQuery = _encodeQueryParameters(queryParameters); + if (extraQuery.isNotEmpty) { + query = query.isEmpty ? extraQuery : '$query&$extraQuery'; + } + return _RequestParts(path, query); + } + + static String _encodeQueryParameters(Map? queryParameters) { + if (queryParameters == null || queryParameters.isEmpty) return ''; + final segments = []; + queryParameters.forEach((key, value) { + if (value == null) return; + final values = value is Iterable ? value : [value]; + for (final item in values) { + if (item == null) continue; + segments.add('${Uri.encodeQueryComponent(key)}=${Uri.encodeQueryComponent(item.toString())}'); + } + }); + return segments.join('&'); + } +} diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_stub.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_stub.dart new file mode 100644 index 00000000..251eff4e --- /dev/null +++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_stub.dart @@ -0,0 +1,5 @@ +import '../gopeed_transport.dart'; + +GopeedTransport createTransport(GopeedTransportConfig config) { + throw UnsupportedError('Gopeed transport is unavailable on this platform'); +} diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart new file mode 100644 index 00000000..0fca3167 --- /dev/null +++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart @@ -0,0 +1,73 @@ +import 'package:dio/dio.dart'; + +import '../gopeed_transport.dart'; + +GopeedTransport createTransport(GopeedTransportConfig config) => RestGopeedTransport(config); + +class RestGopeedTransport implements GopeedTransport { + RestGopeedTransport(GopeedTransportConfig config) + : _dio = Dio( + BaseOptions( + baseUrl: '${Uri.base.origin}/', + contentType: Headers.jsonContentType, + sendTimeout: const Duration(seconds: 5), + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 120), + ), + ) { + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + if (config.apiToken.isNotEmpty) { + options.headers['X-Api-Token'] = config.apiToken; + } + handler.next(options); + }, + onError: (error, handler) { + if (error.response?.statusCode == 401) { + config.onUnauthorized?.call(); + } + handler.next(error); + }, + ), + ); + } + + final Dio _dio; + + @override + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? queryParameters, + }) async { + final response = await _dio.request( + path, + data: data, + queryParameters: queryParameters, + options: Options(method: method), + ); + return response.data; + } + + @override + Future> proxyRequest(String uri, {dynamic data, Options? options}) { + options ??= Options(); + options.headers ??= {}; + options.headers!['X-Target-Uri'] = uri; + return _dio.request( + '/api/web/proxy?t=${DateTime.now().millisecondsSinceEpoch}', + data: data, + options: options.copyWith(responseType: ResponseType.plain), + ); + } + + @override + String join(String path) { + final baseUrl = _dio.options.baseUrl; + final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + return '$cleanBaseUrl/$cleanPath'; + } +} diff --git a/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart new file mode 100644 index 00000000..9aed7d29 --- /dev/null +++ b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; + +import 'entry/gopeed_transport_stub.dart' + if (dart.library.html) 'entry/gopeed_transport_web.dart' + if (dart.library.io) 'entry/gopeed_transport_native.dart'; + +class GopeedTransportConfig { + const GopeedTransportConfig({ + required this.network, + required this.address, + required this.apiToken, + this.onUnauthorized, + }); + + final String network; + final String address; + final String apiToken; + final void Function()? onUnauthorized; +} + +abstract interface class GopeedTransport { + Future request(String path, {String method = 'GET', dynamic data, Map? queryParameters}); + + Future> proxyRequest(String uri, {dynamic data, Options? options}); + + String join(String path); +} + +GopeedTransport createGopeedTransport(GopeedTransportConfig config) => createTransport(config); diff --git a/ui/flutter/lib/core/window/app_window_bootstrap.dart b/ui/flutter/lib/core/window/app_window_bootstrap.dart index e23be5e4..275e58c8 100644 --- a/ui/flutter/lib/core/window/app_window_bootstrap.dart +++ b/ui/flutter/lib/core/window/app_window_bootstrap.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart' show Colors; import 'package:flutter/widgets.dart'; import 'package:window_manager/window_manager.dart'; -import '../../database/database.dart'; import '../../l10n/l10n.dart'; import 'app_window_chrome.dart'; import 'app_window_payload.dart'; @@ -16,9 +15,8 @@ class AppWindowBootstrap { await windowManager.ensureInitialized(); - final windowState = Database.instance.getWindowState(); final windowOptions = WindowOptions( - size: Size(windowState?.width ?? 1024, windowState?.height ?? 768), + size: const Size(1024, 768), center: true, skipTaskbar: false, backgroundColor: Colors.transparent, diff --git a/ui/flutter/lib/database/database.dart b/ui/flutter/lib/database/database.dart deleted file mode 100644 index a62cbf85..00000000 --- a/ui/flutter/lib/database/database.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'dart:convert'; - -import 'package:gopeed/util/util.dart'; -import 'package:hive/hive.dart'; - -import 'entity.dart'; - -const String _startConfig = 'startConfig'; -const String _windowState = 'windowState'; -const String _bookmark = 'bookmark'; -const String _createHistory = 'createHistory'; -const String _runAsMenubarApp = 'runAsMenubarApp'; -const String _analyticsEnabled = 'analyticsEnabled'; -const String _analyticsClientId = 'analyticsClientId'; - -class Database { - static final Database _instance = Database._internal(); - - static Database get instance => _instance; - - factory Database() { - return _instance; - } - - late Box box; - bool _initialized = false; - - Database._internal(); - - bool get initialized => _initialized; - - Future init() async { - if (_initialized) { - return; - } - Hive.init(Util.getStorageDir()); - box = await Hive.openBox('database'); - _initialized = true; - } - - void save(String key, T entity) { - box.put(key, jsonEncode(entity)); - } - - T? get(String key, T Function(dynamic json) fromJsonT) { - final json = box.get(key); - if (json == null) { - return null; - } - return fromJsonT(jsonDecode(json)); - } - - void clear(String key) { - box.delete(key); - } - - void saveStartConfig(StartConfigEntity entity) { - save(_startConfig, entity); - } - - StartConfigEntity? getStartConfig() { - return get(_startConfig, (json) => StartConfigEntity.fromJson(json)); - } - - /// Patch non-null fields with the original value - void saveWindowState(WindowStateEntity entity) { - final state = getWindowState(); - entity.isMaximized ??= state?.isMaximized; - entity.width ??= state?.width; - entity.height ??= state?.height; - save(_windowState, entity); - } - - WindowStateEntity? getWindowState() { - return get(_windowState, (json) => WindowStateEntity.fromJson(json)); - } - - /// Use map to ensure that the same directory only saves the latest bookmark - void saveBookmark(MapEntry entry) { - final map = getBookmark() ?? {}; - map[entry.key] = entry.value; - save>(_bookmark, map); - } - - Map? getBookmark() { - return get>(_bookmark, (json) { - return (json as Map).map((key, value) => MapEntry(key, value.toString())); - }); - } - - void saveCreateHistory(String url) { - var list = getCreateHistory() ?? []; - list.remove(url); - list.insert(0, url); - if (list.length > 64) { - list.removeLast(); - } - save>(_createHistory, list); - } - - List? getCreateHistory() { - return get>(_createHistory, (json) { - return (json as List).map((e) => e.toString()).toList(); - }); - } - - void removeCreateHistory(String url) { - final list = getCreateHistory() ?? []; - list.remove(url); - if (list.isEmpty) { - clearCreateHistory(); - } else { - save>(_createHistory, list); - } - } - - void clearCreateHistory() { - clear(_createHistory); - } - - void saveRunAsMenubarApp(bool value) { - box.put(_runAsMenubarApp, value); - } - - bool getRunAsMenubarApp() { - return box.get(_runAsMenubarApp, defaultValue: false) as bool; - } - - void saveAnalyticsEnabled(bool value) { - box.put(_analyticsEnabled, value); - } - - bool getAnalyticsEnabled() { - return box.get(_analyticsEnabled, defaultValue: true) as bool; - } - - void saveAnalyticsClientId(String clientId) { - box.put(_analyticsClientId, clientId); - } - - String? getAnalyticsClientId() { - return box.get(_analyticsClientId) as String?; - } -} diff --git a/ui/flutter/lib/database/entity.dart b/ui/flutter/lib/database/entity.dart deleted file mode 100644 index a30a5ec3..00000000 --- a/ui/flutter/lib/database/entity.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'entity.g.dart'; - -@JsonSerializable() -class StartConfigEntity { - String network; - String address; - String apiToken; - - StartConfigEntity({required this.network, required this.address, required this.apiToken}); - - factory StartConfigEntity.fromJson(Map json) => _$StartConfigEntityFromJson(json); - Map toJson() => _$StartConfigEntityToJson(this); -} - -@JsonSerializable() -class WindowStateEntity { - bool? isMaximized; - double? width; - double? height; - - WindowStateEntity({this.isMaximized, this.width, this.height}); - - factory WindowStateEntity.fromJson(Map json) => _$WindowStateEntityFromJson(json); - Map toJson() => _$WindowStateEntityToJson(this); -} diff --git a/ui/flutter/lib/database/entity.g.dart b/ui/flutter/lib/database/entity.g.dart deleted file mode 100644 index 90876475..00000000 --- a/ui/flutter/lib/database/entity.g.dart +++ /dev/null @@ -1,35 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'entity.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -StartConfigEntity _$StartConfigEntityFromJson(Map json) => - StartConfigEntity( - network: json['network'] as String, - address: json['address'] as String, - apiToken: json['apiToken'] as String, - ); - -Map _$StartConfigEntityToJson(StartConfigEntity instance) => - { - 'network': instance.network, - 'address': instance.address, - 'apiToken': instance.apiToken, - }; - -WindowStateEntity _$WindowStateEntityFromJson(Map json) => - WindowStateEntity( - isMaximized: json['isMaximized'] as bool?, - width: (json['width'] as num?)?.toDouble(), - height: (json['height'] as num?)?.toDouble(), - ); - -Map _$WindowStateEntityToJson(WindowStateEntity instance) => - { - 'isMaximized': ?instance.isMaximized, - 'width': ?instance.width, - 'height': ?instance.height, - }; diff --git a/ui/flutter/lib/features/settings/application/settings_controller.dart b/ui/flutter/lib/features/settings/application/settings_controller.dart index d6fe41ae..0af7e1b0 100644 --- a/ui/flutter/lib/features/settings/application/settings_controller.dart +++ b/ui/flutter/lib/features/settings/application/settings_controller.dart @@ -33,6 +33,13 @@ class SettingsController extends AsyncNotifier { Future save(DownloaderConfig config) async { state = AsyncValue.data(SettingsState(config: config, saving: true)); + final latest = await ref.read(gopeedServiceProvider).getConfig(); + config.extra + ..windowState = latest.extra.windowState + ..bookmarks = latest.extra.bookmarks + ..createHistory = latest.extra.createHistory + ..runAsMenubarApp = latest.extra.runAsMenubarApp + ..analyticsClientId = latest.extra.analyticsClientId; await ref.read(gopeedServiceProvider).putConfig(config); ref.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(config); state = AsyncValue.data(SettingsState(config: config)); diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index de07a5b3..c6aecf80 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -17,9 +17,7 @@ import '../../../../app/application/app_runtime_controller.dart'; import '../../../../app/application/location_keep_alive.dart'; import '../../../../core/common/start_config.dart'; import '../../../../core/utils/breakpoints.dart'; -import '../../../../core/utils/tcp_port_checker.dart'; import '../../../../core/window/app_window_chrome.dart'; -import '../../../../database/database.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_choice_segmented_control.dart'; @@ -85,7 +83,6 @@ class _SettingsPageState extends ConsumerState { bool _syncingStartControllers = false; bool _savingStartConfig = false; String? _apiNetworkDraft; - late bool _analyticsEnabled; List get _textControllers => [ _downloadDirController, @@ -117,7 +114,6 @@ class _SettingsPageState extends ConsumerState { @override void initState() { super.initState(); - _analyticsEnabled = Database.instance.initialized ? Database.instance.getAnalyticsEnabled() : true; for (final controller in _textControllers) { controller.addListener(_scheduleTextSave); } @@ -281,7 +277,8 @@ class _SettingsPageState extends ConsumerState { AppPlatformState? platform, AppRuntimeState? runtimeState, ) { - final apiConfigDirty = _startConfig != null && _startDraftSignature() != _startSignature(_startConfig!); + final apiConfigDirty = _startConfig != null && _startDraftSignature() != _loadedStartSignature; + final apiServerVisualState = _apiServerVisualState(runtimeState); switch (section) { case _SettingsSection.basic: return _SettingsSectionStack( @@ -410,11 +407,8 @@ class _SettingsPageState extends ConsumerState { title: context.l10n.analyticsEnabled, subtitle: context.l10n.analyticsEnabledDesc, child: shad.Switch( - value: _analyticsEnabled, - onChanged: (value) { - setState(() => _analyticsEnabled = value); - if (Database.instance.initialized) Database.instance.saveAnalyticsEnabled(value); - }, + value: config.extra.analyticsEnabled, + onChanged: (value) => _mutateConfig((next) => next.extra.analyticsEnabled = value), ), ), SettingsItem( @@ -768,62 +762,94 @@ class _SettingsPageState extends ConsumerState { ], ), ), - _SettingsBlock( - title: 'API', - child: _SettingsGroup( - children: [ - SettingsItem( - title: context.l10n.protocol, - subtitle: context.l10n.restartRequired, - child: AppChoiceSegmentedControl( - value: _apiNetworkValue, - buttonKeyPrefix: 'settings-choice', - options: [ - const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined), - if (Util.supportUnixSocket()) - const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined), - ], - onChanged: _changeApiNetwork, - ), - ), - if (_apiNetworkValue == 'unix') + if (!kIsWeb) + _SettingsBlock( + title: 'API', + child: _SettingsGroup( + children: [ SettingsItem( - title: context.l10n.socketPath, - subtitle: context.l10n.restartRequired, - child: _TextSettingControl(controller: _apiAddressController), - ) - else - SettingsItem( - title: context.l10n.listenAddress, - subtitle: context.l10n.restartRequired, - child: _HostPortControl( - hostController: _apiHostController, - portController: _apiPortController, - hostKey: const ValueKey('api-host-input'), - portKey: const ValueKey('api-port-input'), + title: context.l10n.apiServerRuntimeStatus, + subtitle: _apiServerRuntimeDetails(runtimeState), + child: _ApiServerStatusLight( + key: const ValueKey('api-server-runtime-status'), + state: apiServerVisualState, + label: switch (apiServerVisualState) { + _ApiServerVisualState.loading => context.l10n.readingRuntimeStatus, + _ApiServerVisualState.running => context.l10n.apiServerRunning, + _ApiServerVisualState.stopped => context.l10n.apiServerStopped, + _ApiServerVisualState.failed => context.l10n.apiServerFailed, + }, ), ), - if (Util.isDesktop() && _apiNetworkValue == 'tcp') SettingsItem( - title: context.l10n.apiToken, - child: _TextSettingControl(controller: _apiTokenController, obscureText: true), + title: context.l10n.protocol, + child: AppChoiceSegmentedControl( + value: _apiNetworkValue, + buttonKeyPrefix: 'settings-choice', + options: [ + const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined), + if (Util.supportUnixSocket()) + const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined), + ], + onChanged: _changeApiNetwork, + ), ), - SettingsItem( - title: context.l10n.currentConnection, - subtitle: runtimeState == null - ? context.l10n.readingRuntimeStatus - : '${runtimeState.startConfig.network}://${runtimeState.runningAddress()}', - child: AppLoadingButton( - key: const ValueKey('save-api-config-button'), - onPressed: runtimeState == null || !apiConfigDirty ? null : _saveStartConfig, - loading: _savingStartConfig, - icon: const Icon(Icons.save_outlined), - child: Text(context.l10n.save), + if (_apiNetworkValue == 'unix') + SettingsItem( + title: context.l10n.socketPath, + child: _TextSettingControl(controller: _apiAddressController), + ) + else + SettingsItem( + title: context.l10n.listenAddress, + child: _HostPortControl( + hostController: _apiHostController, + portController: _apiPortController, + hostKey: const ValueKey('api-host-input'), + portKey: const ValueKey('api-port-input'), + ), + ), + if (_apiNetworkValue == 'tcp') + SettingsItem( + title: context.l10n.apiToken, + child: _TextSettingControl(controller: _apiTokenController, obscureText: true), + ), + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.end, + children: [ + AppLoadingButton( + key: const ValueKey('toggle-api-server-button'), + onPressed: runtimeState == null ? null : _toggleApiServer, + loading: _savingStartConfig, + icon: Icon( + runtimeState?.apiServerState.running ?? false + ? Icons.stop_circle_outlined + : Icons.play_circle_outline, + ), + child: Text( + runtimeState?.apiServerState.running ?? false + ? context.l10n.stopApiServer + : context.l10n.startApiServer, + ), + ), + AppLoadingButton( + key: const ValueKey('save-api-config-button'), + onPressed: runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig, + loading: _savingStartConfig, + icon: const Icon(Icons.save_outlined), + variant: AppLoadingButtonVariant.primary, + child: Text(context.l10n.save), + ), + ], + ), ), - ), - ], + ], + ), ), - ), _SettingsBlock( title: context.l10n.developer, child: _SettingsGroup( @@ -925,6 +951,15 @@ class _SettingsPageState extends ConsumerState { if (!force && _loadedStartSignature == signature && _startConfig != null) { return; } + if (!force && + _startConfig != null && + _startConfig!.network == config.network && + _startConfig!.address == config.address && + _startConfig!.apiToken == config.apiToken) { + _startConfig!.apiEnable = config.apiEnable; + _loadedStartSignature = signature; + return; + } _loadedStartSignature = signature; _startConfig = _copyStartConfig(config); _apiNetworkDraft = config.network == 'unix' && Util.supportUnixSocket() ? 'unix' : 'tcp'; @@ -1042,24 +1077,31 @@ class _SettingsPageState extends ConsumerState { } } - Future _saveStartConfig() async { + Future _saveApiServerConfig() async { final config = _startConfig; - final runtimeConfig = ref.read(appRuntimeControllerProvider).value?.startConfig; - if (config == null || runtimeConfig == null || _savingStartConfig) { + final runtimeState = ref.read(appRuntimeControllerProvider).value; + if (config == null || runtimeState == null || _savingStartConfig) { return; } final snapshot = _copyStartConfig(config); _applyStartControllers(snapshot); + final validationMessage = await _validateStartConfig(snapshot); + if (validationMessage != null) { + if (mounted) _toast(validationMessage); + return; + } + if (runtimeState.apiServerState.running && !await _confirmRestartApiServer()) { + return; + } setState(() => _savingStartConfig = true); try { - final validationMessage = await _validateStartConfig(snapshot, runtimeConfig); - if (validationMessage != null) { - if (mounted) _toast(validationMessage); - return; + final controller = ref.read(appRuntimeControllerProvider.notifier); + if (runtimeState.apiServerState.running) { + await controller.restartApiServer(snapshot); + } else { + await controller.saveApiServerConfig(snapshot); } - await ref.read(appRuntimeControllerProvider.notifier).saveStartConfig(snapshot); - _startConfig = snapshot; - _apiNetworkDraft = snapshot.network; + _syncStartConfig(ref.read(appRuntimeControllerProvider).requireValue.startConfig, force: true); if (mounted) _toast(context.l10n.apiConfigSaved, type: AppToastType.success); } catch (error) { if (mounted) _toast(error.toString()); @@ -1068,7 +1110,79 @@ class _SettingsPageState extends ConsumerState { } } - Future _validateStartConfig(StartConfig candidate, StartConfig current) async { + Future _toggleApiServer() async { + final runtimeState = ref.read(appRuntimeControllerProvider).value; + if (runtimeState == null || _savingStartConfig) return; + if (runtimeState.apiServerState.running && !await _confirmDisableApiServer()) return; + + setState(() => _savingStartConfig = true); + try { + final controller = ref.read(appRuntimeControllerProvider.notifier); + if (runtimeState.apiServerState.running) { + await controller.stopApiServer(); + } else { + await controller.startApiServer(); + } + } catch (error) { + if (mounted) _toast(error.toString()); + } finally { + if (mounted) setState(() => _savingStartConfig = false); + } + } + + Future _confirmDisableApiServer() async { + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => shad.AlertDialog( + key: const ValueKey('disable-api-server-dialog'), + title: Text(dialogContext.l10n.stopListening), + content: Text(dialogContext.l10n.disableApiServerConfirm), + actions: [ + shad.SecondaryButton( + key: const ValueKey('cancel-disable-api-server-button'), + onPressed: () => shad.closeOverlay(dialogContext, false), + child: Text(dialogContext.l10n.cancel), + ), + shad.DestructiveButton( + key: const ValueKey('confirm-disable-api-server-button'), + onPressed: () => shad.closeOverlay(dialogContext, true), + child: Text(dialogContext.l10n.confirm), + ), + ], + ), + ); + return await overlay.future ?? false; + } + + Future _confirmRestartApiServer() async { + final overlay = const shad.DialogOverlayHandler().show( + context: context, + alignment: Alignment.center, + barrierDismissable: false, + builder: (dialogContext) => shad.AlertDialog( + key: const ValueKey('restart-api-server-dialog'), + title: Text(dialogContext.l10n.saveAndRestartApiServer), + content: Text(dialogContext.l10n.restartApiServerConfirm), + actions: [ + shad.SecondaryButton( + key: const ValueKey('cancel-restart-api-server-button'), + onPressed: () => shad.closeOverlay(dialogContext, false), + child: Text(dialogContext.l10n.cancel), + ), + shad.PrimaryButton( + key: const ValueKey('confirm-restart-api-server-button'), + onPressed: () => shad.closeOverlay(dialogContext, true), + child: Text(dialogContext.l10n.saveAndRestartApiServer), + ), + ], + ), + ); + return await overlay.future ?? false; + } + + Future _validateStartConfig(StartConfig candidate) async { final l10n = context.l10n; if (candidate.network == 'unix') { return candidate.address.trim().isEmpty ? l10n.socketPathRequired : null; @@ -1079,10 +1193,6 @@ class _SettingsPageState extends ConsumerState { final port = int.tryParse(address.port); if (port == null || port < 0 || port > 65535) return l10n.portRangeError; - final addressChanged = current.network != candidate.network || current.address != candidate.address; - if (addressChanged && port != 0 && await isTcpPortInUse(address.host, port)) { - return l10n.portInUseValue(port); - } return null; } @@ -1312,7 +1422,7 @@ class _SettingsPageState extends ConsumerState { } String _startSignature(StartConfig config) { - return '${config.network}|${config.address}|${config.apiToken}'; + return '${config.apiEnable}|${config.network}|${config.address}|${config.apiToken}'; } String _startDraftSignature() { @@ -1320,13 +1430,14 @@ class _SettingsPageState extends ConsumerState { final address = network == 'unix' ? _apiAddressController.text.trim() : _joinHostPort(_apiHostController.text, _apiPortController.text); - return '$network|$address|${_apiTokenController.text.trim()}'; + return '${_startConfig?.apiEnable ?? false}|$network|$address|${_apiTokenController.text.trim()}'; } StartConfig _copyStartConfig(StartConfig config) { return StartConfig() ..network = config.network ..address = config.address + ..apiEnable = config.apiEnable ..storage = config.storage ..storageDir = config.storageDir ..refreshInterval = config.refreshInterval @@ -1443,6 +1554,29 @@ class _SettingsPageState extends ConsumerState { }; } + String? _apiServerRuntimeDetails(AppRuntimeState? runtimeState) { + if (runtimeState == null) return null; + final apiState = runtimeState.apiServerState; + final details = []; + if (apiState.running) { + details.add('${apiState.network}://${apiState.runningAddress()}'); + } + if (apiState.lastError.isNotEmpty) { + details.add(apiState.lastError); + } else if (apiState.pendingApply) { + details.add(context.l10n.apiConfigPendingApply); + } + return details.isEmpty ? null : details.join('\n'); + } + + _ApiServerVisualState _apiServerVisualState(AppRuntimeState? runtimeState) { + if (runtimeState == null) return _ApiServerVisualState.loading; + final apiState = runtimeState.apiServerState; + if (apiState.running) return _ApiServerVisualState.running; + if (apiState.lastError.isNotEmpty) return _ApiServerVisualState.failed; + return _ApiServerVisualState.stopped; + } + String _languageValue(DownloaderConfig config) { final locale = supportedLocaleFromConfig(config.extra.locale); return locale == null ? 'system' : localeConfigValue(locale); @@ -1453,6 +1587,150 @@ class _SettingsPageState extends ConsumerState { } } +enum _ApiServerVisualState { loading, running, stopped, failed } + +class _ApiServerStatusLight extends StatefulWidget { + const _ApiServerStatusLight({super.key, required this.state, required this.label}); + + final _ApiServerVisualState state; + final String label; + + @override + State<_ApiServerStatusLight> createState() => _ApiServerStatusLightState(); +} + +class _ApiServerStatusLightState extends State<_ApiServerStatusLight> with SingleTickerProviderStateMixin { + late final AnimationController _pulseController; + bool _disableAnimations = false; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1400), value: 0.35); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _disableAnimations = MediaQuery.maybeOf(context)?.disableAnimations ?? false; + _syncPulseAnimation(); + } + + @override + void didUpdateWidget(covariant _ApiServerStatusLight oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.state != widget.state) _syncPulseAnimation(); + } + + void _syncPulseAnimation() { + final shouldPulse = + !_disableAnimations && + (widget.state == _ApiServerVisualState.running || widget.state == _ApiServerVisualState.loading); + if (shouldPulse) { + if (!_pulseController.isAnimating) _pulseController.repeat(reverse: true); + return; + } + _pulseController + ..stop() + ..value = switch (widget.state) { + _ApiServerVisualState.running => 0.55, + _ApiServerVisualState.loading => 0.3, + _ApiServerVisualState.failed => 0.5, + _ApiServerVisualState.stopped => 0, + }; + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final color = switch (widget.state) { + _ApiServerVisualState.running => palette.success, + _ApiServerVisualState.failed => palette.error, + _ApiServerVisualState.loading || _ApiServerVisualState.stopped => palette.textMuted, + }; + final labelColor = widget.state == _ApiServerVisualState.stopped ? palette.textSecondary : color; + return Semantics( + label: widget.label, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _pulseController, + builder: (context, child) { + final pulse = Curves.easeInOut.transform(_pulseController.value); + final glowSize = switch (widget.state) { + _ApiServerVisualState.running => 12.0 + pulse * 8, + _ApiServerVisualState.loading => 10.0 + pulse * 5, + _ApiServerVisualState.failed => 16.0, + _ApiServerVisualState.stopped => 10.0, + }; + final glowAlpha = switch (widget.state) { + _ApiServerVisualState.running => 0.08 + pulse * 0.12, + _ApiServerVisualState.loading => 0.05 + pulse * 0.06, + _ApiServerVisualState.failed => 0.13, + _ApiServerVisualState.stopped => 0.08, + }; + final glowShadow = switch (widget.state) { + _ApiServerVisualState.running => [ + BoxShadow( + color: color.withValues(alpha: 0.18 + pulse * 0.2), + blurRadius: 4 + pulse * 8, + spreadRadius: pulse * 1.5, + ), + ], + _ApiServerVisualState.failed => [ + BoxShadow(color: color.withValues(alpha: 0.28), blurRadius: 7, spreadRadius: 0.5), + ], + _ApiServerVisualState.loading || _ApiServerVisualState.stopped => const [], + }; + return SizedBox.square( + dimension: 24, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + key: const ValueKey('api-server-status-glow'), + width: glowSize, + height: glowSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withValues(alpha: glowAlpha), + boxShadow: glowShadow, + ), + ), + child!, + ], + ), + ); + }, + child: AnimatedContainer( + key: const ValueKey('api-server-status-dot'), + duration: const Duration(milliseconds: 300), + width: widget.state == _ApiServerVisualState.loading ? 6 : 8, + height: widget.state == _ApiServerVisualState.loading ? 6 : 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.state == _ApiServerVisualState.stopped ? color.withValues(alpha: 0.72) : color, + ), + ), + ), + const SizedBox(width: 6), + Text( + widget.label, + style: TextStyle(color: labelColor, fontSize: 12, fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} + class _SettingsGroup extends StatelessWidget { const _SettingsGroup({required this.children}); diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index d51c6b13..7d34e2dd 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -34,7 +34,7 @@ "advanced": "متقدم", "general": "عام", "downloadDir": "مجلد التنزيل", - "rememberLastDownloadDir": "حفظ دليل آخر مهمة تلقائيًا", + "rememberLastDownloadDir": "حفظ كمجلد التنزيل الافتراضي", "downloadDirValid": "الرجاء تحديد مجلد التنزيل", "connections": "الاتصالات", "useServerCtime": "استخدام وقت الخادم لإنشاء الملف", @@ -393,7 +393,17 @@ "deleteTasksTitle": "هل تريد حذف مهام {count}؟", "keepFilesDescription": "قم فقط بإزالة سجل المهمة من Gopeed", "readingRuntimeStatus": "قراءة حالة وقت العمل...", - "apiConfigSaved": "تم إنقاذها، أعيدي تطبيقها", + "apiConfigSaved": "تم حفظ إعدادات API", + "apiConfigPendingApply": "لم يتم تطبيق الإعدادات المحفوظة بعد على الخدمة قيد التشغيل", + "apiServerRunning": "قيد التشغيل", + "apiServerStopped": "متوقفة", + "apiServerFailed": "خطأ", + "apiServerRuntimeStatus": "حالة التشغيل", + "startApiServer": "بدء", + "stopApiServer": "إيقاف", + "saveAndRestartApiServer": "حفظ وإعادة تشغيل", + "restartApiServerConfirm": "خدمة REST API قيد التشغيل. سيؤدي تطبيق الإعدادات إلى إعادة تشغيلها وقطع اتصالات API الحالية.", + "disableApiServerConfirm": "هل تريد تعطيل خادم REST API؟ سيتم قطع اتصالات API الحالية.", "socketPathRequired": "الطريق السريع مطلوب", "serverRequired": "مطلوب من الموظف", "portRangeError": "أدخل ميناء ما بين 0 و 65535", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index c7f9d231..97f75fb5 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -34,7 +34,7 @@ "advanced": "Avançat", "general": "General", "downloadDir": "Carpeta de baixades", - "rememberLastDownloadDir": "Desa automàticament l'últim directori de tasca", + "rememberLastDownloadDir": "Desa com a carpeta de baixades predeterminada", "downloadDirValid": "Seleccioneu la carpeta de baixades", "connections": "Connexions", "useServerCtime": "Utilitza l'hora del servidor per a la creació de fitxers", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Vols suprimir les tasques {count}?", "keepFilesDescription": "Elimina només el registre de la tasca de Gopeed", "readingRuntimeStatus": "S' està llegint l' estat de l' hora d' execució...", - "apiConfigSaved": "S' han desat els paràmetres de l' API. Reinicieu l' aplicació per aplicar- los", + "apiConfigSaved": "La configuració de l'API s'ha desat", + "apiConfigPendingApply": "La configuració desada encara no s'ha aplicat al servei en execució", + "apiServerRunning": "En execució", + "apiServerStopped": "Aturat", + "apiServerFailed": "Error", + "apiServerRuntimeStatus": "Estat d'execució", + "startApiServer": "Inicia", + "stopApiServer": "Atura", + "saveAndRestartApiServer": "Desa i reinicia", + "restartApiServerConfirm": "El servei REST API està en execució. Aplicar aquesta configuració el reiniciarà i interromprà les connexions API existents.", + "disableApiServerConfirm": "Voleu desactivar el servidor REST API? Les connexions API existents s'interrompran.", "socketPathRequired": "Cal la ruta del sòcol", "serverRequired": "Es requereix el servidor", "portRangeError": "Introduïu un port entre 0 i 65535", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 34221c3f..9331a2ec 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -34,7 +34,7 @@ "advanced": "Fortschrittlich", "general": "Allgemein", "downloadDir": "Download-Verzeichnis", - "rememberLastDownloadDir": "Letztes Aufgabenverzeichnis automatisch speichern", + "rememberLastDownloadDir": "Als Standard-Downloadordner speichern", "downloadDirValid": "Bitte wählen Sie das Download-Verzeichnis", "connections": "Anschlüsse", "useServerCtime": "Serverzeit für die Dateierstellung verwenden", @@ -393,7 +393,17 @@ "deleteTasksTitle": "{count}-Aufgaben löschen?", "keepFilesDescription": "Entfernen Sie nur den Aufgabendatensatz aus Gopeed", "readingRuntimeStatus": "Laufzeitstatus wird gelesen…", - "apiConfigSaved": "API-Einstellungen gespeichert. Starten Sie die App neu, um sie anzuwenden", + "apiConfigSaved": "API-Einstellungen gespeichert", + "apiConfigPendingApply": "Gespeicherte Einstellungen wurden noch nicht auf den laufenden Dienst angewendet", + "apiServerRunning": "Wird ausgeführt", + "apiServerStopped": "Gestoppt", + "apiServerFailed": "Fehler", + "apiServerRuntimeStatus": "Laufzeitstatus", + "startApiServer": "Starten", + "stopApiServer": "Stoppen", + "saveAndRestartApiServer": "Speichern und neu starten", + "restartApiServerConfirm": "Der REST-API-Dienst läuft. Das Anwenden dieser Einstellungen startet ihn neu und unterbricht bestehende API-Verbindungen.", + "disableApiServerConfirm": "REST-API-Server deaktivieren? Bestehende API-Verbindungen werden unterbrochen.", "socketPathRequired": "Socket-Pfad ist erforderlich", "serverRequired": "-Server ist erforderlich", "portRangeError": "Geben Sie einen Port zwischen 0 und 65535 ein", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index ab493411..8b22f865 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -34,7 +34,7 @@ "advanced": "Advanced", "general": "General", "downloadDir": "Download Directory", - "rememberLastDownloadDir": "Automatically save last task directory", + "rememberLastDownloadDir": "Save as default download directory", "downloadDirValid": "Please select the download directory", "connections": "Connections", "useServerCtime": "Use server time for file creation", @@ -407,7 +407,17 @@ "deleteTasksTitle": "{count, plural, =1{Delete task?} other{Delete {count} tasks?}}", "keepFilesDescription": "Only remove the task record from Gopeed", "readingRuntimeStatus": "Reading runtime status…", - "apiConfigSaved": "API settings saved. Restart the app to apply them", + "apiConfigSaved": "API settings saved", + "apiConfigPendingApply": "Saved settings have not been applied to the running service", + "apiServerRunning": "Running", + "apiServerStopped": "Stopped", + "apiServerFailed": "Error", + "apiServerRuntimeStatus": "Runtime status", + "startApiServer": "Start", + "stopApiServer": "Stop", + "saveAndRestartApiServer": "Save and restart", + "restartApiServerConfirm": "The REST API service is running. Applying these settings will restart it and interrupt existing API connections.", + "disableApiServerConfirm": "Disable the REST API server? Existing API connections will be interrupted.", "socketPathRequired": "Socket path is required", "serverRequired": "Server is required", "portRangeError": "Enter a port between 0 and 65535", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index e00346bb..27e539b3 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -34,7 +34,7 @@ "advanced": "Avanzado", "general": "Generalidades", "downloadDir": "Directorio de Descarga", - "rememberLastDownloadDir": "Guardar automáticamente el último directorio de tarea", + "rememberLastDownloadDir": "Guardar como carpeta de descargas predeterminada", "downloadDirValid": "Por favor, selecciona el directorio de descarga", "connections": "Conexiones", "useServerCtime": "Usar tiempo del servidor para la creación de archivos", @@ -393,7 +393,17 @@ "deleteTasksTitle": "¿Eliminar tareas {count}?", "keepFilesDescription": "Eliminar únicamente el registro de tarea de Gopeed", "readingRuntimeStatus": "Leyendo el estado de la carrera...", - "apiConfigSaved": "Ajustes de API guardados. reiniciar la aplicación para aplicarlos", + "apiConfigSaved": "Ajustes de API guardados", + "apiConfigPendingApply": "Los ajustes guardados aún no se han aplicado al servicio en ejecución", + "apiServerRunning": "En ejecución", + "apiServerStopped": "Detenido", + "apiServerFailed": "Error", + "apiServerRuntimeStatus": "Estado de ejecución", + "startApiServer": "Iniciar", + "stopApiServer": "Detener", + "saveAndRestartApiServer": "Guardar y reiniciar", + "restartApiServerConfirm": "El servicio REST API está en ejecución. Aplicar estos ajustes lo reiniciará e interrumpirá las conexiones API existentes.", + "disableApiServerConfirm": "¿Desactivar el servidor REST API? Las conexiones API existentes se interrumpirán.", "socketPathRequired": "Se requiere un camino de bolsillo", "serverRequired": "Se requiere servidor", "portRangeError": "Introduzca un puerto entre 0 y 65535", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 0a5b2e74..86eee320 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -34,7 +34,7 @@ "advanced": "پیشرفته", "general": "عمومی", "downloadDir": "دایرکتوری دانلود", - "rememberLastDownloadDir": "ذخیره خودکار پوشه آخرین وظیفه", + "rememberLastDownloadDir": "ذخیره به‌عنوان پوشه پیش‌فرض دانلود", "downloadDirValid": "لطفا دایرکتوری دانلود را انتخاب کنید", "connections": "اتصالات", "useServerCtime": "استفاده از زمان سرور برای ایجاد فایل", @@ -393,7 +393,17 @@ "deleteTasksTitle": "وظایف {count} حذف شود؟", "keepFilesDescription": "فقط رکورد وظیفه را از Gopeed حذف کنید", "readingRuntimeStatus": "خواندن وضعیت زمان اجرا ...", - "apiConfigSaved": "تنظیمات API ذخیره شده است. دوباره برنامه را برای اعمال آنها شروع کنید", + "apiConfigSaved": "تنظیمات API ذخیره شد", + "apiConfigPendingApply": "تنظیمات ذخیره‌شده هنوز روی سرویس در حال اجرا اعمال نشده است", + "apiServerRunning": "در حال اجرا", + "apiServerStopped": "متوقف", + "apiServerFailed": "خطا", + "apiServerRuntimeStatus": "وضعیت اجرا", + "startApiServer": "شروع", + "stopApiServer": "توقف", + "saveAndRestartApiServer": "ذخیره و راه‌اندازی مجدد", + "restartApiServerConfirm": "سرویس REST API در حال اجرا است. اعمال تنظیمات، سرویس را دوباره راه‌اندازی کرده و اتصال‌های API موجود را قطع می‌کند.", + "disableApiServerConfirm": "سرور REST API غیرفعال شود؟ اتصال‌های فعلی API قطع خواهند شد.", "socketPathRequired": "مسیر سوکت مورد نیاز است", "serverRequired": "سرور مورد نیاز است", "portRangeError": "یک پورت بین 0 تا 65۳۵ را وارد کنید", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 41853054..517a929d 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -34,7 +34,7 @@ "advanced": "Avancé", "general": "Général", "downloadDir": "Répertoire de téléchargement", - "rememberLastDownloadDir": "Enregistrer automatiquement le dernier dossier de tâche", + "rememberLastDownloadDir": "Enregistrer comme dossier de téléchargement par défaut", "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", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Supprimer les tâches {count} ?", "keepFilesDescription": "Supprimez uniquement l'enregistrement de tâche de Gopeed", "readingRuntimeStatus": "Lire le statut d'exécution...", - "apiConfigSaved": "Paramètres de l'API sauvegardés. Redémarrer l'application pour les appliquer", + "apiConfigSaved": "Paramètres de l'API enregistrés", + "apiConfigPendingApply": "Les paramètres enregistrés ne sont pas encore appliqués au service en cours d'exécution", + "apiServerRunning": "En cours d'exécution", + "apiServerStopped": "Arrêté", + "apiServerFailed": "Erreur", + "apiServerRuntimeStatus": "État d'exécution", + "startApiServer": "Démarrer", + "stopApiServer": "Arrêter", + "saveAndRestartApiServer": "Enregistrer et redémarrer", + "restartApiServerConfirm": "Le service REST API est en cours d'exécution. L'application de ces paramètres le redémarrera et interrompra les connexions API existantes.", + "disableApiServerConfirm": "Désactiver le serveur REST API ? Les connexions API existantes seront interrompues.", "socketPathRequired": "Le chemin de la prise est nécessaire", "serverRequired": "Serveur requis", "portRangeError": "Entrez un port entre 0 et 65535", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index ca134dd6..26c060f2 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -34,7 +34,7 @@ "advanced": "Haladó", "general": "Általános", "downloadDir": "Letöltési könyvtár", - "rememberLastDownloadDir": "Legutóbbi feladatmappa automatikus mentése", + "rememberLastDownloadDir": "Mentés alapértelmezett letöltési mappaként", "downloadDirValid": "Kérjük, válassza ki a letöltési könyvtárat", "connections": "Kapcsolatok", "useServerCtime": "Használja a szerveridőt a fájl létrehozásához", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Törli a {count} feladatokat?", "keepFilesDescription": "Csak a feladatrekordot távolítsa el a Gopeed alkalmazásból", "readingRuntimeStatus": "Futási állapot olvasása…", - "apiConfigSaved": "API beállítások mentve. Az alkalmazáshoz indítsa újra az alkalmazást", + "apiConfigSaved": "Az API-beállítások mentve", + "apiConfigPendingApply": "A mentett beállítások még nincsenek alkalmazva a futó szolgáltatásra", + "apiServerRunning": "Fut", + "apiServerStopped": "Leállítva", + "apiServerFailed": "Hiba", + "apiServerRuntimeStatus": "Futási állapot", + "startApiServer": "Indítás", + "stopApiServer": "Leállítás", + "saveAndRestartApiServer": "Mentés és újraindítás", + "restartApiServerConfirm": "A REST API szolgáltatás fut. A beállítások alkalmazása újraindítja, és megszakítja a meglévő API-kapcsolatokat.", + "disableApiServerConfirm": "Letiltja a REST API-kiszolgálót? A meglévő API-kapcsolatok megszakadnak.", "socketPathRequired": "Aljzatútvonal szükséges", "serverRequired": "szerver szükséges", "portRangeError": "Adjon meg egy portot 0 és 65535 között", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 3e4f459c..81cd69d5 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -34,7 +34,7 @@ "advanced": "Lanjutan", "general": "Umum", "downloadDir": "Direktori Unduhan", - "rememberLastDownloadDir": "Simpan otomatis direktori tugas terakhir", + "rememberLastDownloadDir": "Simpan sebagai folder unduhan default", "downloadDirValid": "Silahkan pilih direktori unduhan", "connections": "Koneksi", "useServerCtime": "Gunakan waktu server untuk pembuatan file", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Hapus tugas {count}?", "keepFilesDescription": "Hanya menghapus catatan tugas dari Gopeed", "readingRuntimeStatus": "Membaca status waktu-jalan...", - "apiConfigSaved": "Pengaturan API disimpan. Jalankan ulang aplikasi untuk menerapkannya", + "apiConfigSaved": "Pengaturan API disimpan", + "apiConfigPendingApply": "Pengaturan tersimpan belum diterapkan ke layanan yang sedang berjalan", + "apiServerRunning": "Berjalan", + "apiServerStopped": "Berhenti", + "apiServerFailed": "Kesalahan", + "apiServerRuntimeStatus": "Status berjalan", + "startApiServer": "Mulai", + "stopApiServer": "Hentikan", + "saveAndRestartApiServer": "Simpan dan mulai ulang", + "restartApiServerConfirm": "Layanan REST API sedang berjalan. Menerapkan pengaturan ini akan memulai ulang layanan dan memutus koneksi API yang ada.", + "disableApiServerConfirm": "Nonaktifkan server REST API? Koneksi API yang ada akan terputus.", "socketPathRequired": "Path socket dibutuhkan", "serverRequired": "Server diperlukan", "portRangeError": "Masukkan sebuah port antara 0 dan 65535", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index a49aa95f..435d4fea 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -34,7 +34,7 @@ "advanced": "Avanzato", "general": "Generale", "downloadDir": "Directory di scaricamento", - "rememberLastDownloadDir": "Salva automaticamente l'ultima cartella dell'attività", + "rememberLastDownloadDir": "Salva come cartella di download predefinita", "downloadDirValid": "Seleziona la directory di scaricamento", "connections": "Connessioni", "useServerCtime": "Utilizza l'ora del server per la creazione dei file", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Eliminare le attività {count}?", "keepFilesDescription": "Rimuovi solo il record dell'attività da Gopeed", "readingRuntimeStatus": "Leggere lo stato di runtime...", - "apiConfigSaved": "Impostazioni API salvate. Riavviare l'app per applicarle", + "apiConfigSaved": "Impostazioni API salvate", + "apiConfigPendingApply": "Le impostazioni salvate non sono ancora state applicate al servizio in esecuzione", + "apiServerRunning": "In esecuzione", + "apiServerStopped": "Arrestato", + "apiServerFailed": "Errore", + "apiServerRuntimeStatus": "Stato di esecuzione", + "startApiServer": "Avvia", + "stopApiServer": "Arresta", + "saveAndRestartApiServer": "Salva e riavvia", + "restartApiServerConfirm": "Il servizio REST API è in esecuzione. L'applicazione di queste impostazioni lo riavvierà e interromperà le connessioni API esistenti.", + "disableApiServerConfirm": "Disattivare il server REST API? Le connessioni API esistenti verranno interrotte.", "socketPathRequired": "Il percorso di presa è richiesto", "serverRequired": "Il server è richiesto", "portRangeError": "Inserisci una porta tra 0 e 65535", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index c28c6ab0..d06b8a52 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -34,7 +34,7 @@ "advanced": "高度", "general": "全般", "downloadDir": "ダウンロードディレクトリ", - "rememberLastDownloadDir": "前回のタスク保存先を自動保存", + "rememberLastDownloadDir": "デフォルトのダウンロード先として保存", "downloadDirValid": "ダウンロードディレクトリを選択してください", "connections": "接続", "useServerCtime": "ファイル作成時にサーバー時間を使用する", @@ -393,7 +393,17 @@ "deleteTasksTitle": "{count}タスクを削除しますか?", "keepFilesDescription": "Gopeed からタスクレコードを削除するだけです", "readingRuntimeStatus": "実行時間ステータスを読み込みます...", - "apiConfigSaved": "API の設定を保存します。アプリを再起動して、それらを適用します", + "apiConfigSaved": "API 設定を保存しました", + "apiConfigPendingApply": "保存した設定は実行中のサービスにまだ適用されていません", + "apiServerRunning": "実行中", + "apiServerStopped": "停止中", + "apiServerFailed": "エラー", + "apiServerRuntimeStatus": "稼働状態", + "startApiServer": "開始", + "stopApiServer": "停止", + "saveAndRestartApiServer": "保存して再起動", + "restartApiServerConfirm": "REST API サービスは実行中です。新しい設定を適用するとサービスが再起動し、既存の API 接続が切断されます。", + "disableApiServerConfirm": "REST API サーバーを無効にしますか?既存の API 接続は切断されます。", "socketPathRequired": "ソケットパスが必要です", "serverRequired": "サーバが必要です", "portRangeError": "0と65535の間のポートを入力してください", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 722f96ed..020ba4e1 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -34,7 +34,7 @@ "advanced": "고급", "general": "일반", "downloadDir": "다운로드 폴더", - "rememberLastDownloadDir": "마지막 작업 디렉터리 자동 저장", + "rememberLastDownloadDir": "기본 다운로드 폴더로 저장", "downloadDirValid": "다운로드 폴더를 선택해 주세요", "connections": "연결 수", "useServerCtime": "파일 생성 시간에 서버 시간 사용", @@ -393,7 +393,17 @@ "deleteTasksTitle": "{count} 작업을 삭제?", "keepFilesDescription": "Gopeed에서 작업 기록을 제거합니다", "readingRuntimeStatus": "읽기 런타임 상태 ...", - "apiConfigSaved": "API 설정 저장. 앱을 다시 실행하여 적용할 수 있습니다", + "apiConfigSaved": "API 설정을 저장했습니다", + "apiConfigPendingApply": "저장된 설정이 실행 중인 서비스에 아직 적용되지 않았습니다", + "apiServerRunning": "실행 중", + "apiServerStopped": "중지됨", + "apiServerFailed": "오류", + "apiServerRuntimeStatus": "실행 상태", + "startApiServer": "시작", + "stopApiServer": "중지", + "saveAndRestartApiServer": "저장하고 다시 시작", + "restartApiServerConfirm": "REST API 서비스가 실행 중입니다. 새 설정을 적용하면 서비스가 다시 시작되고 기존 API 연결이 끊어집니다.", + "disableApiServerConfirm": "REST API 서버를 비활성화할까요? 기존 API 연결이 끊어집니다.", "socketPathRequired": "소켓 경로가 필요합니다", "serverRequired": "서버가 필요합니다", "portRangeError": "0과 65535 사이의 포트를 입력", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index cddb61c2..21890c60 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -34,7 +34,7 @@ "advanced": "Zaawansowane", "general": "Ogólne", "downloadDir": "Folder Pobierania", - "rememberLastDownloadDir": "Automatycznie zapisuj ostatni katalog zadania", + "rememberLastDownloadDir": "Zapisz jako domyślny folder pobierania", "downloadDirValid": "Wskaż folder pobierania", "connections": "Połączenia", "useServerCtime": "Użyj czasu serwera do utworzenia pliku", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Usuwa {count} zadania?", "keepFilesDescription": "Usuń tylko wpis zadania z Gopeed", "readingRuntimeStatus": "Odczyt statusu runtime...", - "apiConfigSaved": "Zapisane ustawienia API. Przywróć aplikację, aby je zastosować", + "apiConfigSaved": "Ustawienia API zapisano", + "apiConfigPendingApply": "Zapisane ustawienia nie zostały jeszcze zastosowane do działającej usługi", + "apiServerRunning": "Uruchomiono", + "apiServerStopped": "Zatrzymano", + "apiServerFailed": "Błąd", + "apiServerRuntimeStatus": "Stan działania", + "startApiServer": "Uruchom", + "stopApiServer": "Zatrzymaj", + "saveAndRestartApiServer": "Zapisz i uruchom ponownie", + "restartApiServerConfirm": "Usługa REST API działa. Zastosowanie ustawień uruchomi ją ponownie i przerwie istniejące połączenia API.", + "disableApiServerConfirm": "Wyłączyć serwer REST API? Istniejące połączenia API zostaną przerwane.", "socketPathRequired": "Ścieżka gniazda jest wymagana", "serverRequired": "Wymagany jest serwer", "portRangeError": "Wprowadź port pomiędzy 0 a 65535", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index b920c038..7ef0b81a 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -34,7 +34,7 @@ "advanced": "Configurações Avançada", "general": "Principal", "downloadDir": "Onde você deseja salvar?", - "rememberLastDownloadDir": "Salvar automaticamente o último diretório da tarefa", + "rememberLastDownloadDir": "Salvar como pasta de download padrão", "downloadDirValid": "Por favor, selecione a pasta", "connections": "Conexões", "useServerCtime": "Usar o tempo de servidor para a criação de ficheiros", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Apagar {count} tarefas?", "keepFilesDescription": "Remova apenas o registro de tarefas do Gopeed", "readingRuntimeStatus": "A ler o estado da execução...", - "apiConfigSaved": "Configurações da API salvas. Reinicie o aplicativo para aplicá- las", + "apiConfigSaved": "Configurações da API salvas", + "apiConfigPendingApply": "As configurações salvas ainda não foram aplicadas ao serviço em execução", + "apiServerRunning": "Em execução", + "apiServerStopped": "Parado", + "apiServerFailed": "Erro", + "apiServerRuntimeStatus": "Estado de execução", + "startApiServer": "Iniciar", + "stopApiServer": "Parar", + "saveAndRestartApiServer": "Salvar e reiniciar", + "restartApiServerConfirm": "O serviço REST API está em execução. Aplicar estas configurações reiniciará o serviço e interromperá as conexões API existentes.", + "disableApiServerConfirm": "Desativar o servidor REST API? As conexões API existentes serão interrompidas.", "socketPathRequired": "O caminho do 'socket' é necessário", "serverRequired": "O servidor é necessário", "portRangeError": "Digite uma porta entre 0 e 65535", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 48de2625..d9cdb3df 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -34,7 +34,7 @@ "advanced": "Расширенные", "general": "Общие", "downloadDir": "Папка загрузки", - "rememberLastDownloadDir": "Автоматически сохранять последнюю папку задачи", + "rememberLastDownloadDir": "Сохранить как папку загрузок по умолчанию", "downloadDirValid": "Пожалуйста, выберите папку для загрузки", "connections": "Соединений", "useServerCtime": "Использовать время сервера для создания файла", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Удалить {count} задачи?", "keepFilesDescription": "Удалить только запись задачи из Gopeed", "readingRuntimeStatus": "Статус чтения Runtime...", - "apiConfigSaved": "Сохранены настройки API. Перезагрузите приложение, чтобы применить их", + "apiConfigSaved": "Настройки API сохранены", + "apiConfigPendingApply": "Сохранённые настройки ещё не применены к работающему сервису", + "apiServerRunning": "Работает", + "apiServerStopped": "Остановлен", + "apiServerFailed": "Ошибка", + "apiServerRuntimeStatus": "Состояние работы", + "startApiServer": "Запустить", + "stopApiServer": "Остановить", + "saveAndRestartApiServer": "Сохранить и перезапустить", + "restartApiServerConfirm": "Сервис REST API работает. Применение настроек перезапустит его и прервёт текущие API-соединения.", + "disableApiServerConfirm": "Отключить сервер REST API? Текущие API-соединения будут прерваны.", "socketPathRequired": "Требуется Socket Path", "serverRequired": "Сервер необходим", "portRangeError": "Введите порт между 0 и 65535", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index a9f0b6c0..d6be4092 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -34,7 +34,7 @@ "advanced": "மேம்பட்ட", "general": "பொதுவான", "downloadDir": "பதிவிரக்குமிடம்", - "rememberLastDownloadDir": "கடைசி பணி கோப்பகத்தைத் தானாகச் சேமி", + "rememberLastDownloadDir": "இயல்புநிலை பதிவிறக்க கோப்புறையாக சேமி", "downloadDirValid": "பதிவிரக்குமிடத்தை தேர்வு செயுங்கள்", "connections": "தொடர்புகள்", "useServerCtime": "கோப்பு உருவாக்க சேவையக நேரத்தைப் பயன்படுத்தவும்", @@ -393,7 +393,17 @@ "deleteTasksTitle": "{count} பணிகளை நீக்கவா?", "keepFilesDescription": "Gopeed இலிருந்து பணி பதிவை மட்டுமே அகற்றவும்", "readingRuntimeStatus": "இயக்கநேர நிலையைப் படிக்கிறது...", - "apiConfigSaved": "API அமைப்புகள் சேமிக்கப்பட்டன. அவற்றைப் பயன்படுத்த ஆப்பை மறுதொடக்கம் செய்யவும்", + "apiConfigSaved": "API அமைப்புகள் சேமிக்கப்பட்டன", + "apiConfigPendingApply": "சேமித்த அமைப்புகள் இயங்கும் சேவையில் இன்னும் பயன்படுத்தப்படவில்லை", + "apiServerRunning": "இயங்குகிறது", + "apiServerStopped": "நிறுத்தப்பட்டது", + "apiServerFailed": "பிழை", + "apiServerRuntimeStatus": "இயக்க நிலை", + "startApiServer": "தொடங்கு", + "stopApiServer": "நிறுத்து", + "saveAndRestartApiServer": "சேமித்து மறுதொடக்கம் செய்", + "restartApiServerConfirm": "REST API சேவை இயங்குகிறது. புதிய அமைப்புகளைப் பயன்படுத்தினால் சேவை மறுதொடக்கம் செய்யப்பட்டு தற்போதைய API இணைப்புகள் துண்டிக்கப்படும்.", + "disableApiServerConfirm": "REST API சேவையகத்தை முடக்கவா? தற்போதைய API இணைப்புகள் துண்டிக்கப்படும்.", "socketPathRequired": "சாக்கெட் பாதை தேவை", "serverRequired": "சேவையகம் தேவை", "portRangeError": "0 மற்றும் 65535 க்கு இடையில் ஒரு போர்ட்டை உள்ளிடவும்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 074624b5..163a6554 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -34,7 +34,7 @@ "advanced": "Gelişmiş", "general": "Genel", "downloadDir": "İndirme Dizini", - "rememberLastDownloadDir": "Son görev dizinini otomatik kaydet", + "rememberLastDownloadDir": "Varsayılan indirme klasörü olarak kaydet", "downloadDirValid": "Lütfen indirme dizinini seçin", "connections": "Bağlantılar", "useServerCtime": "Dosya oluşturma tarihi için sunucu zamanını kullan", @@ -393,7 +393,17 @@ "deleteTasksTitle": "{count} görevi mi?", "keepFilesDescription": "Sadece Gopeed'den görev kayıtlarını kaldır", "readingRuntimeStatus": "Okuma runtime statüsü...", - "apiConfigSaved": "API ayarları kurtarıldı. Uygulamayı onları uygulamak için yeniden başlat", + "apiConfigSaved": "API ayarları kaydedildi", + "apiConfigPendingApply": "Kaydedilen ayarlar çalışan hizmete henüz uygulanmadı", + "apiServerRunning": "Çalışıyor", + "apiServerStopped": "Durduruldu", + "apiServerFailed": "Hata", + "apiServerRuntimeStatus": "Çalışma durumu", + "startApiServer": "Başlat", + "stopApiServer": "Durdur", + "saveAndRestartApiServer": "Kaydet ve yeniden başlat", + "restartApiServerConfirm": "REST API hizmeti çalışıyor. Bu ayarları uygulamak hizmeti yeniden başlatır ve mevcut API bağlantılarını keser.", + "disableApiServerConfirm": "REST API sunucusu devre dışı bırakılsın mı? Mevcut API bağlantıları kesilecek.", "socketPathRequired": "CPU yolu gereklidir", "serverRequired": "Server gereklidir", "portRangeError": "0 ile 65535 arasında bir liman girin", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 6b66ee7c..18291b65 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -34,7 +34,7 @@ "advanced": "Розширені", "general": "Загальні", "downloadDir": "Каталог завантажень", - "rememberLastDownloadDir": "Автоматично зберігати останню папку завдання", + "rememberLastDownloadDir": "Зберегти як типову папку завантажень", "downloadDirValid": "Будь ласка, виберіть каталог завантажень", "connections": "З’єднання", "useServerCtime": "Використовувати час сервера для створення файлу", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Видалення задач {count}?", "keepFilesDescription": "Тільки видаліть запис задачі від Надії", "readingRuntimeStatus": "Читання статусу робочого часу ...", - "apiConfigSaved": "Налаштування API, збережені. Перезапустіть додаток, щоб застосувати їх", + "apiConfigSaved": "Налаштування API збережено", + "apiConfigPendingApply": "Збережені налаштування ще не застосовано до запущеного сервісу", + "apiServerRunning": "Працює", + "apiServerStopped": "Зупинено", + "apiServerFailed": "Помилка", + "apiServerRuntimeStatus": "Стан роботи", + "startApiServer": "Запустити", + "stopApiServer": "Зупинити", + "saveAndRestartApiServer": "Зберегти й перезапустити", + "restartApiServerConfirm": "Сервіс REST API працює. Застосування налаштувань перезапустить його та перерве поточні API-з’єднання.", + "disableApiServerConfirm": "Вимкнути сервер REST API? Поточні API-з’єднання буде перервано.", "socketPathRequired": "План розетки необхідний", "serverRequired": "Сервер необхідно", "portRangeError": "Введіть порт між 0 і 65535", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index f1d3f6f6..57fa9d44 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -34,7 +34,7 @@ "advanced": "Nâng cao", "general": "Chung", "downloadDir": "Thư mục tải về", - "rememberLastDownloadDir": "Tự động lưu thư mục tác vụ gần nhất", + "rememberLastDownloadDir": "Lưu làm thư mục tải xuống mặc định", "downloadDirValid": "Vui lòng chọn thư mục tải về", "connections": "Kết nối", "useServerCtime": "Sử dụng thời gian máy chủ cho việc tạo tệp", @@ -393,7 +393,17 @@ "deleteTasksTitle": "Xóa {count} tác vụ?", "keepFilesDescription": "Chỉ gỡ bỏ hồ sơ tác vụ từ Gopeed", "readingRuntimeStatus": "Đang đọc trạng thái thời gian chạy...", - "apiConfigSaved": "Thiết lập i đã lưu. Hãy khởi động lại ứng dụng", + "apiConfigSaved": "Đã lưu cài đặt API", + "apiConfigPendingApply": "Cài đặt đã lưu chưa được áp dụng cho dịch vụ đang chạy", + "apiServerRunning": "Đang chạy", + "apiServerStopped": "Đã dừng", + "apiServerFailed": "Lỗi", + "apiServerRuntimeStatus": "Trạng thái hoạt động", + "startApiServer": "Khởi động", + "stopApiServer": "Dừng", + "saveAndRestartApiServer": "Lưu và khởi động lại", + "restartApiServerConfirm": "Dịch vụ REST API đang chạy. Áp dụng cài đặt sẽ khởi động lại dịch vụ và ngắt các kết nối API hiện có.", + "disableApiServerConfirm": "Tắt máy chủ REST API? Các kết nối API hiện có sẽ bị gián đoạn.", "socketPathRequired": "Cần thiết phải có đường dẫn ổ cắm điện", "serverRequired": "Cần thiết máy phục vụ", "portRangeError": "Nhập một cổng từ 0 đến 65535", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index c95d6f77..164ae6d5 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -34,7 +34,7 @@ "advanced": "高级", "general": "通用", "downloadDir": "下载目录", - "rememberLastDownloadDir": "自动保存上次任务目录", + "rememberLastDownloadDir": "保存为默认下载目录", "downloadDirValid": "请选择下载目录", "connections": "连接数", "useServerCtime": "创建文件使用服务器时间", @@ -393,7 +393,17 @@ "deleteTasksTitle": "删除 {count} 个任务?", "keepFilesDescription": "仅从 Gopeed 中移除任务记录", "readingRuntimeStatus": "正在读取运行状态…", - "apiConfigSaved": "API 配置已保存,重启应用后生效", + "apiConfigSaved": "API 配置已保存", + "apiConfigPendingApply": "配置已保存,但尚未应用到当前运行的服务", + "apiServerRunning": "运行中", + "apiServerStopped": "已停止", + "apiServerFailed": "异常", + "apiServerRuntimeStatus": "运行状态", + "startApiServer": "启动", + "stopApiServer": "停止", + "saveAndRestartApiServer": "保存并重启", + "restartApiServerConfirm": "REST API 服务正在运行,应用新配置需要重启服务,并会中断现有 API 连接。", + "disableApiServerConfirm": "确定关闭 REST API 服务吗?现有的 API 连接将会中断。", "socketPathRequired": "Socket 路径不能为空", "serverRequired": "服务器不能为空", "portRangeError": "请输入 0–65535 之间的端口", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 0d02cca4..f271c659 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -34,7 +34,7 @@ "advanced": "進階", "general": "一般", "downloadDir": "下載目錄", - "rememberLastDownloadDir": "自動儲存上次任務目錄", + "rememberLastDownloadDir": "儲存為預設下載目錄", "downloadDirValid": "請選擇下載目錄", "connections": "連接數", "useServerCtime": "使用伺服器時間作為檔案建立時間", @@ -393,7 +393,17 @@ "deleteTasksTitle": "刪除 {count} 個任務?", "keepFilesDescription": "僅從 Gopeed 中移除任務記錄", "readingRuntimeStatus": "正在讀取執行狀態…", - "apiConfigSaved": "API 配置已儲存,重啟應用後生效", + "apiConfigSaved": "API 配置已儲存", + "apiConfigPendingApply": "配置已儲存,但尚未套用到目前執行中的服務", + "apiServerRunning": "執行中", + "apiServerStopped": "已停止", + "apiServerFailed": "異常", + "apiServerRuntimeStatus": "執行狀態", + "startApiServer": "啟動", + "stopApiServer": "停止", + "saveAndRestartApiServer": "儲存並重新啟動", + "restartApiServerConfirm": "REST API 服務正在執行,套用新配置需要重新啟動服務,並會中斷現有 API 連線。", + "disableApiServerConfirm": "確定要關閉 REST API 服務嗎?現有的 API 連線將會中斷。", "socketPathRequired": "Socket 路徑不能為空", "serverRequired": "伺服器不能為空", "portRangeError": "請輸入 0–65535 之間的埠", diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 33cd1c77..5783d4fd 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -13,9 +13,6 @@ import 'core/entry/app_startup_options.dart'; import 'core/window/app_window_bootstrap.dart'; import 'core/window/app_window_launch_context.dart'; import 'core/window/window_capability_transport.dart'; -import 'database/database.dart'; -import 'util/analytics.dart'; -import 'util/log_util.dart'; import 'util/util.dart'; Future main(List args) async { @@ -46,18 +43,7 @@ Future main(List args) async { } catch (_) {} } - await AppInitializer.ensureDatabaseInitialized(); + await AppInitializer.ensureStorageInitialized(); await AppWindowBootstrap.setupMainWindow(hidden: startupOptions.hidden); runApp(const ProviderScope(child: GopeedApp())); - unawaited(_logAppOpen()); -} - -Future _logAppOpen() async { - if (!AnalyticsConfig.isConfigured || !Database.instance.getAnalyticsEnabled()) return; - try { - await Analytics.instance.init(); - await Analytics.instance.logAppOpen(); - } catch (error) { - logger.w('GA4 init failed: $error'); - } } diff --git a/ui/flutter/lib/util/analytics.dart b/ui/flutter/lib/util/analytics.dart index cd149974..9eca41f9 100644 --- a/ui/flutter/lib/util/analytics.dart +++ b/ui/flutter/lib/util/analytics.dart @@ -4,7 +4,6 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; -import '../database/database.dart'; import 'log_util.dart'; import 'package_info.dart'; @@ -18,8 +17,8 @@ class AnalyticsConfig { static bool get isConfigured => measurementId.isNotEmpty && apiSecret.isNotEmpty; } -/// Sends only anonymous application events. The preference is stored outside -/// downloader configuration so it remains local to the current installation. +/// Sends only anonymous application events. Its preference and fallback +/// client ID are persisted by the Go downloader configuration. class Analytics { Analytics._(); @@ -30,9 +29,11 @@ class Analytics { late final Dio _dio; bool _initialized = false; - Future init() async { + String get clientId => _initialized ? _clientId : ''; + + Future init({String fallbackClientId = ''}) async { if (_initialized || !AnalyticsConfig.isConfigured) return; - _clientId = await _getDeviceId(); + _clientId = await _getDeviceId(fallbackClientId); _sessionId = DateTime.now().millisecondsSinceEpoch ~/ 1000; _dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10))); _initialized = true; @@ -69,7 +70,7 @@ class Analytics { } } - Future _getDeviceId() async { + Future _getDeviceId(String fallbackClientId) async { String? deviceId; try { final info = DeviceInfoPlugin(); @@ -91,12 +92,11 @@ class Analytics { } if (deviceId == null || deviceId.isEmpty) { - deviceId = Database.instance.getAnalyticsClientId(); - if (deviceId == null || deviceId.isEmpty) { + deviceId = fallbackClientId; + if (deviceId.isEmpty) { final random = DateTime.now().microsecondsSinceEpoch % 2147483647; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; deviceId = '$random.$timestamp'; - Database.instance.saveAnalyticsClientId(deviceId); } } return deviceId; diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 1a50ebf7..8404335b 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -613,14 +613,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - hive: - dependency: "direct main" - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" hooks: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index a502a082..6542081b 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -42,7 +42,6 @@ dependencies: dio: ^5.9.0 json_annotation: ^4.12.0 ffi: ^2.1.3 - hive: ^2.2.3 crypto: ^3.0.6 logger: ^1.4.0 package_info_plus: ^8.1.0 @@ -191,6 +190,13 @@ ffigen: include: - "Start" - "Stop" + - "GetAPIServerState" + - "StartAPIServer" + - "StopAPIServer" + - "RestartAPIServer" + - "Invoke" + - "SubscribeTaskEvents" + - "FreeCString" structs: include: - "Start_return" diff --git a/ui/flutter/test/api/downloader_config_test.dart b/ui/flutter/test/api/downloader_config_test.dart index 6db8eea8..d0bea3c0 100644 --- a/ui/flutter/test/api/downloader_config_test.dart +++ b/ui/flutter/test/api/downloader_config_test.dart @@ -17,4 +17,36 @@ void main() { expect(DownloaderConfig.fromJson(json).autoStartTasks, isFalse); }); + + test('REST server settings round-trip through the Go downloader config', () { + final config = DownloaderConfig() + ..api = ApiServerConfig(enable: true, network: 'tcp', address: '127.0.0.1:4321', token: 'secret'); + + final decoded = DownloaderConfig.fromJson(config.toJson()).api; + expect(decoded.enable, isTrue); + expect(decoded.network, 'tcp'); + expect(decoded.address, '127.0.0.1:4321'); + expect(decoded.token, 'secret'); + }); + + test('Flutter preferences round-trip through the Go-owned extra config', () { + final config = DownloaderConfig(); + config.extra + ..windowState = WindowStateConfig(isMaximized: true, width: 1280, height: 720) + ..bookmarks = {'downloads': 'D:/Downloads'} + ..createHistory = ['https://example.com/file.zip'] + ..runAsMenubarApp = true + ..analyticsEnabled = false + ..analyticsClientId = 'client-id'; + + final decoded = DownloaderConfig.fromJson(config.toJson()).extra; + expect(decoded.windowState.isMaximized, isTrue); + expect(decoded.windowState.width, 1280); + expect(decoded.windowState.height, 720); + expect(decoded.bookmarks, {'downloads': 'D:/Downloads'}); + expect(decoded.createHistory, ['https://example.com/file.zip']); + expect(decoded.runAsMenubarApp, isTrue); + expect(decoded.analyticsEnabled, isFalse); + expect(decoded.analyticsClientId, 'client-id'); + }); } diff --git a/ui/flutter/test/api/resolve_test.dart b/ui/flutter/test/api/resolve_test.dart index ec31ddbc..14c40002 100644 --- a/ui/flutter/test/api/resolve_test.dart +++ b/ui/flutter/test/api/resolve_test.dart @@ -1,46 +1,26 @@ -import 'dart:async'; import 'dart:convert'; -import 'dart:io'; +import 'package:dio/dio.dart' as dio; import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/api.dart' as api; import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/request.dart'; import 'package:gopeed/api/model/resolve_task.dart'; +import 'package:gopeed/core/network/gopeed/gopeed_transport.dart'; void main() { test('resolve sends the complete resolve task for magnet links', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - addTearDown(server.close); - - late Map requestBody; - final responseHandled = Completer(); - server.listen((request) async { - requestBody = jsonDecode(await utf8.decoder.bind(request).join()) as Map; - request.response.headers.contentType = ContentType.json; - request.response.write( - jsonEncode({ - 'code': 0, - 'data': { - 'id': 'resolved-id', - 'res': {'name': 'sample', 'size': 0, 'range': false, 'files': [], 'hash': ''}, - }, - }), - ); - await request.response.close(); - responseHandled.complete(); - }); - - api.init('tcp', '127.0.0.1:${server.port}', ''); + final transport = _RecordingTransport(); + api.setTransportForTesting(transport); final result = await api.resolve( ResolveTask( req: Request(url: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'), opts: Options(path: '/downloads', selectFiles: const []), ), ); - await responseHandled.future; expect(result.id, 'resolved-id'); + final requestBody = transport.data as Map; expect(requestBody.keys, containsAll(['req', 'opts'])); expect( (requestBody['req'] as Map)['url'], @@ -49,3 +29,32 @@ void main() { expect((requestBody['opts'] as Map)['path'], '/downloads'); }); } + +class _RecordingTransport implements GopeedTransport { + dynamic data; + + @override + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? queryParameters, + }) async { + this.data = jsonDecode(jsonEncode(data)); + return { + 'code': 0, + 'data': { + 'id': 'resolved-id', + 'res': {'name': 'sample', 'size': 0, 'range': false, 'files': [], 'hash': ''}, + }, + }; + } + + @override + String join(String path) => path; + + @override + Future> proxyRequest(String uri, {dynamic data, dio.Options? options}) { + throw UnimplementedError(); + } +} diff --git a/ui/flutter/test/core/common/task_event_test.dart b/ui/flutter/test/core/common/task_event_test.dart new file mode 100644 index 00000000..e4062e77 --- /dev/null +++ b/ui/flutter/test/core/common/task_event_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/common/task_event.dart'; + +void main() { + test('task event mask combines subscribed event types', () { + expect({TaskEventType.done}.mask, 1); + expect({TaskEventType.error}.mask, 2); + expect({TaskEventType.done, TaskEventType.error}.mask, 3); + }); + + test('task event decodes terminal payload', () { + final event = TaskEvent.fromJson({ + 'type': 'task.error', + 'taskId': 'task-1', + 'name': 'archive.zip', + 'error': 'network error', + }); + + expect(event.type, TaskEventType.error); + expect(event.taskId, 'task-1'); + expect(event.name, 'archive.zip'); + expect(event.error, 'network error'); + }); +} diff --git a/ui/flutter/test/features/auth/application/web_auth_controller_test.dart b/ui/flutter/test/features/auth/application/web_auth_controller_test.dart new file mode 100644 index 00000000..3bae4cb3 --- /dev/null +++ b/ui/flutter/test/features/auth/application/web_auth_controller_test.dart @@ -0,0 +1,41 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/api.dart' as api; +import 'package:gopeed/core/network/gopeed/gopeed_transport.dart'; +import 'package:gopeed/features/auth/application/web_auth_controller.dart'; + +void main() { + test('successful login relies on the server cookie and unauthorized requires login', () async { + api.setTransportForTesting(_LoginTransport()); + final controller = WebAuthController(); + addTearDown(controller.dispose); + + final failure = await controller.login(username: 'gopeed', password: 'secret'); + + expect(failure, isNull); + controller.requireLogin(); + expect(controller.loginRequired, isTrue); + }); +} + +class _LoginTransport implements GopeedTransport { + @override + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? queryParameters, + }) async { + expect(path, 'api/web/login'); + expect(method, 'POST'); + return {'code': 0}; + } + + @override + Future> proxyRequest(String uri, {dynamic data, Options? options}) { + throw UnimplementedError(); + } + + @override + String join(String path) => path; +} diff --git a/ui/flutter/test/features/settings/application/settings_controller_test.dart b/ui/flutter/test/features/settings/application/settings_controller_test.dart index 7fc1bbcc..6b477da8 100644 --- a/ui/flutter/test/features/settings/application/settings_controller_test.dart +++ b/ui/flutter/test/features/settings/application/settings_controller_test.dart @@ -6,6 +6,7 @@ import 'package:gopeed/core/capabilities/app_capabilities.dart'; import 'package:gopeed/core/capabilities/capability_rpc.dart'; import 'package:gopeed/core/capabilities/gopeed_capability.dart'; import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; import 'package:gopeed/features/settings/application/settings_controller.dart'; void main() { @@ -36,13 +37,51 @@ void main() { expect((await container.read(settingsControllerProvider.future)).config.downloadDir, 'E:/Created task'); expect(getConfigCalls, 2); }); + + test('settings save preserves backend-owned client preferences', () async { + final backend = DownloaderConfig() + ..extra.windowState = WindowStateConfig(isMaximized: true, width: 1200, height: 800) + ..extra.createHistory = ['https://example.com/existing.zip'] + ..extra.analyticsClientId = 'existing-client'; + DownloaderConfig? saved; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson())) + ..bind(GopeedMethods.putConfig, (config) { + saved = DownloaderConfig.fromJson(config.toJson()); + return const RpcUnit(); + }); + final container = ProviderContainer( + overrides: [ + appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))), + appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new), + ], + ); + addTearDown(container.dispose); + + final controller = container.read(settingsControllerProvider.notifier); + await container.read(settingsControllerProvider.future); + await controller.save(DownloaderConfig(downloadDir: 'E:/New')); + + expect(saved?.downloadDir, 'E:/New'); + expect(saved?.extra.windowState.isMaximized, isTrue); + expect(saved?.extra.createHistory, ['https://example.com/existing.zip']); + expect(saved?.extra.analyticsClientId, 'existing-client'); + }); } class _TestRuntimeController extends AppRuntimeController { @override Future build() async => AppRuntimeState( startConfig: StartConfig(), - runningPort: 0, + apiServerState: const ApiServerState( + enabled: false, + running: false, + network: '', + address: '', + runningPort: 0, + pendingApply: false, + lastError: '', + ), downloaderConfig: DownloaderConfig(downloadDir: 'C:/Stale runtime snapshot'), ); } diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index aadca722..0de3f9e1 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -145,21 +145,30 @@ void main() { final rememberDirectory = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')); final directDownloadCheckbox = find.byKey(const ValueKey('create-task-direct-download-checkbox')); final rememberDirectoryRow = find.byKey(const ValueKey('create-task-remember-download-directory')); + final rememberDirectoryLabel = find.byKey(const ValueKey('create-task-remember-download-directory-label')); + final directDownloadLabel = find.descendant( + of: find.byKey(const ValueKey('create-task-direct-download-toggle')), + matching: find.text('Skip resolving and create tasks immediately'), + ); final directoryComponent = find.byKey(const ValueKey('create-task-directory-component')); final directoryOptionsRow = find.byKey(const ValueKey('create-task-directory-options-row')); final directoryOptionsLayout = find.byKey(const ValueKey('create-task-directory-options-layout')); final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker')); expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + expect(find.text('Save as default download directory'), findsOneWidget); + final rememberStyle = tester.widget(rememberDirectoryLabel).style!; + final directStyle = tester.widget(directDownloadLabel).style!; + expect(rememberStyle.color, directStyle.color); + expect(rememberStyle.fontSize, directStyle.fontSize); + expect(rememberStyle.fontWeight, directStyle.fontWeight); expect(tester.widget(rememberDirectory).size, isNull); expect(tester.getSize(rememberDirectory), const Size.square(AppDesignTokens.checkboxSize)); expect(tester.getCenter(rememberDirectory).dx, closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5)); - final rememberDirectoryLabel = tester.widget( - find.byKey(const ValueKey('create-task-remember-download-directory-label')), - ); - final directDownloadLabel = tester.widget(find.byKey(const ValueKey('create-task-direct-download-label'))); - expect(rememberDirectoryLabel.style, directDownloadLabel.style); - expect(rememberDirectoryLabel.maxLines, directDownloadLabel.maxLines); - expect(rememberDirectoryLabel.overflow, directDownloadLabel.overflow); + final rememberDirectoryText = tester.widget(rememberDirectoryLabel); + final directDownloadText = tester.widget(directDownloadLabel); + expect(rememberDirectoryText.style, directDownloadText.style); + expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); + expect(rememberDirectoryText.overflow, directDownloadText.overflow); expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); expect(tester.widget(directoryOptionsLayout).mainAxisAlignment, MainAxisAlignment.spaceBetween); expect(tester.getCenter(rememberDirectoryRow).dy, closeTo(tester.getCenter(shortcutRow).dy, 0.5)); diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index d985f1ca..badbdfa9 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -27,6 +27,7 @@ import 'package:gopeed/api/model/resource.dart'; import 'package:gopeed/api/model/store_extension.dart'; import 'package:gopeed/api/model/task.dart' as api_task; import 'package:gopeed/core/common/start_config.dart'; +import 'package:gopeed/core/common/api_server_state.dart'; import 'package:gopeed/core/icons/gopeed_icons.dart'; import 'package:gopeed/core/window/app_window_chrome.dart'; import 'package:gopeed/core/window/app_window_frame.dart'; @@ -1329,16 +1330,14 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Advanced')); await tester.pumpAndSettle(); + expect(find.text('Runtime status'), findsOneWidget); + expect(find.text('tcp://192.168.1.20:4321'), findsOneWidget); + expect(find.byKey(const ValueKey('api-server-status-dot')), findsOneWidget); final host = find.byKey(const ValueKey('api-host-input')); final port = find.byKey(const ValueKey('api-port-input')); final saveButton = find.byKey(const ValueKey('save-api-config-button')); - expect(tester.widget(saveButton).variant, AppLoadingButtonVariant.secondary); - final saveButtonClickable = find.descendant(of: saveButton, matching: find.byType(shad.Clickable)); - final disabledDecoration = - tester.widget(saveButtonClickable).decoration!.resolve({WidgetState.disabled}) as BoxDecoration; - expect(disabledDecoration.color, AppPalette.dark.surfaceSoft); - expect(disabledDecoration.color, isNot(AppPalette.dark.brand)); + expect(tester.widget(saveButton).variant, AppLoadingButtonVariant.primary); expect(tester.widget(host).controller!.text, '192.168.1.20'); expect(tester.widget(port).controller!.text, '4321'); expect(tester.widget(saveButton).onPressed, isNull); @@ -1359,6 +1358,10 @@ void main() { await tester.enterText(host, '127.0.0.1'); await tester.tap(saveButton); await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('restart-api-server-dialog')), findsOneWidget); + expect(runtimeController.savedStartConfig, isNull); + await tester.tap(find.byKey(const ValueKey('confirm-restart-api-server-button'))); + await tester.pumpAndSettle(); expect(runtimeController.savedStartConfig?.network, 'tcp'); expect(runtimeController.savedStartConfig?.address, '127.0.0.1:0'); expect(tester.widget(saveButton).onPressed, isNull); @@ -1378,6 +1381,54 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('disabling the API server requires confirmation and keeps listener fields visible', ( + WidgetTester tester, + ) async { + await _setTestSize(tester, const Size(1024, 900)); + final runtimeController = RecordingStartConfigRuntimeController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(() => runtimeController), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + final hostInput = find.byKey(const ValueKey('api-host-input')); + await tester.enterText(hostInput, 'unsaved.example.com'); + final toggleButton = find.byKey(const ValueKey('toggle-api-server-button')); + await tester.ensureVisible(toggleButton); + await tester.pumpAndSettle(); + await tester.tap(toggleButton); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('disable-api-server-dialog')), findsOneWidget); + expect(find.byKey(const ValueKey('api-host-input')), findsOneWidget); + expect(find.byKey(const ValueKey('api-port-input')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('cancel-disable-api-server-button'))); + await tester.pumpAndSettle(); + expect(runtimeController.savedStartConfig, isNull); + + await tester.tap(toggleButton); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('confirm-disable-api-server-button'))); + await tester.pumpAndSettle(); + expect(runtimeController.savedStartConfig?.apiEnable, isFalse); + expect(runtimeController.savedStartConfig?.address, '192.168.1.20:4321'); + expect(tester.widget(hostInput).controller!.text, 'unsaved.example.com'); + final statusDot = tester.widget(find.byKey(const ValueKey('api-server-status-dot'))); + expect((statusDot.decoration! as BoxDecoration).color, AppPalette.light.textMuted.withValues(alpha: 0.72)); + expect(find.text('tcp://192.168.1.20:4321'), findsNothing); + await tester.pump(const Duration(seconds: 6)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + testWidgets('shared toast appears at top center with content-driven width', (WidgetTester tester) async { await _setTestSize(tester, const Size(1024, 700)); await tester.pumpWidget( @@ -2995,10 +3046,9 @@ void main() { expect(urlValue.semanticsLabel, task.url); expect(find.text('Task Details'), findsOneWidget); expect(find.text('Task Name'), findsNothing); - final detailHeader = tester.widget( - find.descendant(of: find.byKey(const ValueKey('app-detail-drawer-header')), matching: find.byType(DecoratedBox)), - ); - expect(((detailHeader.decoration as BoxDecoration).border! as Border).bottom.width, 1); + final detailHeader = tester.widget(find.byKey(const ValueKey('app-detail-drawer-header'))); + final detailHeaderDecoration = (detailHeader.child! as DecoratedBox).decoration as BoxDecoration; + expect((detailHeaderDecoration.border! as Border).bottom.width, 1); expect(find.text('Details'), findsOneWidget); expect(find.text('Files'), findsOneWidget); expect(find.text('Statistics'), findsOneWidget); @@ -4367,9 +4417,14 @@ class _SettingsTestAppState extends State<_SettingsTestApp> { return shad.ShadcnApp.router( theme: widget.dark ? AppTheme.dark() : AppTheme.light(), materialTheme: widget.dark ? AppTheme.materialDark() : AppTheme.materialLight(), - builder: widget.wrapComponentThemes - ? (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()) - : null, + builder: (context, child) { + final content = widget.wrapComponentThemes + ? AppComponentThemes(child: child ?? const SizedBox.shrink()) + : child ?? const SizedBox.shrink(); + final mediaQuery = MediaQuery.maybeOf(context); + if (mediaQuery == null) return content; + return MediaQuery(data: mediaQuery.copyWith(disableAnimations: true), child: content); + }, routerConfig: _router, ); } @@ -4534,11 +4589,16 @@ class FakeRuntimeController extends AppRuntimeController { final cfg = StartConfig() ..network = 'tcp' ..address = '127.0.0.1:9999' + ..apiEnable = true ..apiToken = '' ..storage = 'bolt' ..storageDir = '' ..refreshInterval = 0; - return AppRuntimeState(startConfig: cfg, runningPort: 9999, downloaderConfig: DownloaderConfig()); + return AppRuntimeState( + startConfig: cfg, + apiServerState: _testApiServerState(running: true, port: 9999), + downloaderConfig: DownloaderConfig(), + ); } } @@ -4566,25 +4626,75 @@ class RecordingStartConfigRuntimeController extends AppRuntimeController { final config = StartConfig() ..network = 'tcp' ..address = '192.168.1.20:4321' + ..apiEnable = true ..apiToken = 'token' ..storage = 'bolt' ..storageDir = '' ..refreshInterval = 0; - return AppRuntimeState(startConfig: config, runningPort: 4321, downloaderConfig: DownloaderConfig()); + return AppRuntimeState( + startConfig: config, + apiServerState: _testApiServerState(running: true, port: 4321, address: config.address), + downloaderConfig: DownloaderConfig(), + ); } @override - Future saveStartConfig(StartConfig config) async { + Future saveApiServerConfig(StartConfig config) async => + _record(config, running: state.requireValue.apiServerState.running); + + @override + Future startApiServer() async => _record(state.requireValue.startConfig..apiEnable = true, running: true); + + @override + Future stopApiServer() async => _record(state.requireValue.startConfig..apiEnable = false, running: false); + + @override + Future restartApiServer(StartConfig config) async => _record(config..apiEnable = true, running: true); + + void _record(StartConfig config, {required bool running}) { savedStartConfig = StartConfig() ..network = config.network ..address = config.address + ..apiEnable = config.apiEnable ..apiToken = config.apiToken ..storage = config.storage ..storageDir = config.storageDir ..refreshInterval = config.refreshInterval; + final current = state.requireValue; + state = AsyncValue.data( + AppRuntimeState( + startConfig: savedStartConfig!, + apiServerState: _testApiServerState( + running: running, + port: running ? 54321 : 0, + address: config.address, + enabled: config.apiEnable, + ), + downloaderConfig: current.downloaderConfig, + localBackendStarted: current.localBackendStarted, + startupError: current.startupError, + ), + ); } } +ApiServerState _testApiServerState({ + required bool running, + required int port, + String address = '127.0.0.1:9999', + bool? enabled, +}) { + return ApiServerState( + enabled: enabled ?? running, + running: running, + network: running ? 'tcp' : '', + address: running ? address : '', + runningPort: port, + pendingApply: false, + lastError: '', + ); +} + class FakeSettingsController extends SettingsController { @override Future build() async => SettingsState(config: DownloaderConfig());