feat: move native communication to FFI (#1457)

This commit is contained in:
Levi authored and GitHub committed 2026-09-05 20:18:42 +08:00
1 parent 177c225f16
commit 39cd9034cd
86 files changed
+4558 -886

No files matched your search

+76
View File
@@ -1,8 +1,21 @@
package main package main
/*
#include <stdlib.h>
#include <stdint.h>
typedef void (*TaskEventCallback)(char* payload);
static void callTaskEventCallback(uintptr_t callback, char* payload) {
((TaskEventCallback)callback)(payload);
}
*/
import "C" import "C"
import ( import (
"encoding/json" "encoding/json"
"fmt"
"unsafe"
"github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model" "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()) return 0, C.CString(err.Error())
} }
config.ProductionMode = true config.ProductionMode = true
config.NativeMode = true
applyWebViewProvider(&config) applyWebViewProvider(&config)
realPort, err := rest.Start(&config) realPort, err := rest.Start(&config)
if err != nil { if err != nil {
@@ -28,3 +42,65 @@ func Start(cfg *C.char) (int, *C.char) {
func Stop() { func Stop() {
rest.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))
}
}
+43
View File
@@ -4,6 +4,7 @@ package libgopeed
import "C" import "C"
import ( import (
"encoding/json" "encoding/json"
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider" "github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
"github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model" "github.com/GopeedLab/gopeed/pkg/rest/model"
@@ -15,6 +16,7 @@ func Start(cfg string) (int, error) {
return 0, err return 0, err
} }
config.ProductionMode = true config.ProductionMode = true
config.NativeMode = true
applyWebViewProvider(&config) applyWebViewProvider(&config)
return rest.Start(&config) return rest.Start(&config)
} }
@@ -23,6 +25,47 @@ func Stop() {
rest.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) { func applyWebViewProvider(config *model.StartConfig) {
if config == nil || config.WebViewRPCConfig == nil || !config.WebViewRPCConfig.Enabled() { if config == nil || config.WebViewRPCConfig == nil || !config.WebViewRPCConfig.Enabled() {
return return
+82
View File
@@ -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))
}()
}
+37
View File
@@ -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"}})
}
+94
View File
@@ -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
}
+457
View File
@@ -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}
}
+21
View File
@@ -208,6 +208,7 @@ type DownloaderStoreConfig struct {
Script *ScriptConfig `json:"script"` // Script is the script execution configuration Script *ScriptConfig `json:"script"` // Script is the script execution configuration
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction 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 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 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 { if cfg.Archive == nil {
cfg.Archive = beforeCfg.Archive 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 return cfg
} }
+2 -1
View File
@@ -351,7 +351,8 @@ func DoProxy(w http.ResponseWriter, r *http.Request) {
r.RequestURI = "" r.RequestURI = ""
r.URL = targetUrl r.URL = targetUrl
r.Host = targetUrl.Host r.Host = targetUrl.Host
r.Header.Del("Authorization") r.Header.Del("Cookie")
r.Header.Del("X-Api-Token")
r.Header.Del("X-Target-Uri") r.Header.Del("X-Target-Uri")
resp, err := http.DefaultClient.Do(r) resp, err := http.DefaultClient.Do(r)
if err != nil { if err != nil {
+214
View File
@@ -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)
}
+44
View File
@@ -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)
}
+229
View File
@@ -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)
}
}
+41
View File
@@ -1,6 +1,8 @@
package model package model
import ( import (
"errors"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
"io/fs" "io/fs"
@@ -16,6 +18,7 @@ const (
type StartConfig struct { type StartConfig struct {
Network string `json:"network"` Network string `json:"network"`
Address string `json:"address"` Address string `json:"address"`
ApiEnable *bool `json:"apiEnable,omitempty"`
RefreshInterval int `json:"refreshInterval"` RefreshInterval int `json:"refreshInterval"`
Storage Storage `json:"storage"` Storage Storage `json:"storage"`
StorageDir string `json:"storageDir"` StorageDir string `json:"storageDir"`
@@ -25,6 +28,7 @@ type StartConfig struct {
WebViewRPCConfig *enginewebview.RPCConfig `json:"webViewRpcConfig,omitempty"` WebViewRPCConfig *enginewebview.RPCConfig `json:"webViewRpcConfig,omitempty"`
ProductionMode bool ProductionMode bool
NativeMode bool
WebViewProvider enginewebview.Provider `json:"-"` WebViewProvider enginewebview.Provider `json:"-"`
WebEnable bool WebEnable bool
@@ -32,6 +36,26 @@ type StartConfig struct {
WebAuth *WebAuth 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 { func (cfg *StartConfig) Init() *StartConfig {
if cfg.Network == "" { if cfg.Network == "" {
cfg.Network = "tcp" cfg.Network = "tcp"
@@ -55,3 +79,20 @@ type WebAuth struct {
Username string Username string
Password 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"`
}
+45
View File
@@ -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)
}
})
}
}
+223 -173
View File
File diff suppressed because it is too large. Load diff
+95 -96
View File
@@ -643,7 +643,7 @@ func TestWebFsEnhance(t *testing.T) {
func TestDoProxy(t *testing.T) { func TestDoProxy(t *testing.T) {
doTest(func() { 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", "X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md",
}, nil) }, nil)
if code != http.StatusOK { if code != http.StatusOK {
@@ -657,7 +657,7 @@ func TestDoProxy(t *testing.T) {
}) })
doTest(func() { 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", "X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND",
}, nil) }, nil)
if code != http.StatusNotFound { 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) { func TestAuthorization(t *testing.T) {
var cfg = &model.StartConfig{} var cfg = &model.StartConfig{}
cfg.Init() cfg.Init()
@@ -827,118 +849,74 @@ func TestAuthorization(t *testing.T) {
t.Errorf("TestAuthorization() got = %v, want %v", 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("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) status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
if status != http.StatusUnauthorized { if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", 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 { if status != http.StatusUnauthorized {
t.Errorf("Bearer-authenticated API status = %v, want %v", 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{ status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": "xxx", "Cookie": webAuthCookieName + "=invalid-session",
}, nil) }, nil)
if status != http.StatusUnauthorized { if status != http.StatusUnauthorized {
t.Errorf("TestAuthorization() got = %v, want %v", 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{ status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Authorization": "xxx", "Cookie": cookieHeader,
}, nil) }, nil)
if status != http.StatusUnauthorized { if status != http.StatusOK {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) t.Errorf("cookie authorization got = %v, want %v", status, http.StatusOK)
} }
status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", nil, nil)
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)
if status != http.StatusUnauthorized { 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)
}
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)
} }
status, _ = doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", map[string]string{ 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) }, nil)
if status != http.StatusUnauthorized { if status != http.StatusUnauthorized {
t.Errorf("expired-cookie file status = %d, want %d", status, http.StatusUnauthorized) t.Errorf("Bearer-only future /fs route got = %v, want %v", 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)
} }
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
@@ -947,17 +925,16 @@ func TestAuthorization(t *testing.T) {
if status != http.StatusOK { if status != http.StatusOK {
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
} }
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ 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, "X-Api-Token": cfg.ApiToken,
}, nil) }, nil)
if status != http.StatusOK { 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{ status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
"Cookie": fmt.Sprintf("%s=%s", authCookie.Name, authCookie.Value), "Cookie": cookieHeader,
"X-Api-Token": "", "X-Api-Token": "",
}, nil) }, nil)
if status != http.StatusUnauthorized { 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) { func TestBuildServerPropagatesWebViewProvider(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
-2
View File
@@ -80,5 +80,3 @@ windows/flutter/generated_plugins.cmake
# Runtime data # Runtime data
/extensions /extensions
database.hive
database.lock
@@ -1,6 +1,7 @@
package com.gopeed.gopeed package com.gopeed.gopeed
import com.gopeed.libgopeed.Libgopeed import com.gopeed.libgopeed.Libgopeed
import com.gopeed.libgopeed.TaskEventListener
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@@ -12,12 +13,13 @@ class MainActivity : FlutterActivity() {
val taskQueue = val taskQueue =
flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue() flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue()
MethodChannel( val libgopeedChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
LIBGOPEED_CHANNEL, LIBGOPEED_CHANNEL,
StandardMethodCodec.INSTANCE, StandardMethodCodec.INSTANCE,
taskQueue, taskQueue,
).setMethodCallHandler { call, result -> )
libgopeedChannel.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"start" -> { "start" -> {
try { try {
@@ -30,6 +32,42 @@ class MainActivity : FlutterActivity() {
Libgopeed.stop() Libgopeed.stop()
result.success(null) 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<String>("method") ?: "",
call.argument<String>("path") ?: "",
call.argument<String>("query") ?: "",
call.argument<String>("body") ?: "",
),
)
} catch (error: Exception) {
result.error("ERROR", error.message, null)
}
}
"subscribeTaskEvents" -> {
val mask = call.argument<Number>("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() else -> result.notImplemented()
} }
} }
File diff suppressed because it is too large. Load diff
+7
View File
@@ -82,6 +82,13 @@ struct Start_return {
}; };
extern struct Start_return Start(char* cfg); extern struct Start_return Start(char* cfg);
extern void Stop(); 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 #ifdef __cplusplus
} }
@@ -20,6 +20,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* LocationKeepAliveManager.swift */; }; 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 */; }; B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */; };
C164A2AA80F148015630B8E9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */; }; C164A2AA80F148015630B8E9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */; };
D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D081C25B294826C0006EB10B /* libc++.tbd */; }; 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 = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A10000000000000000000002 /* LocationKeepAliveManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationKeepAliveManager.swift; sourceTree = "<group>"; }; A10000000000000000000002 /* LocationKeepAliveManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationKeepAliveManager.swift; sourceTree = "<group>"; };
A20000000000000000000002 /* GopeedTaskEventForwarder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GopeedTaskEventForwarder.m; sourceTree = "<group>"; };
A20000000000000000000003 /* GopeedTaskEventForwarder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GopeedTaskEventForwarder.h; sourceTree = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
@@ -238,6 +241,8 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
A10000000000000000000002 /* LocationKeepAliveManager.swift */, A10000000000000000000002 /* LocationKeepAliveManager.swift */,
A20000000000000000000003 /* GopeedTaskEventForwarder.h */,
A20000000000000000000002 /* GopeedTaskEventForwarder.m */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
); );
path = Runner; path = Runner;
@@ -521,6 +526,7 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */,
A20000000000000000000001 /* GopeedTaskEventForwarder.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+21
View File
@@ -19,6 +19,7 @@ import UIKit
name: "gopeed.com/libgopeed", name: "gopeed.com/libgopeed",
binaryMessenger: messenger binaryMessenger: messenger
) )
let taskEventForwarder = GopeedTaskEventForwarder(channel: libgopeedChannel)
libgopeedChannel.setMethodCallHandler { call, result in libgopeedChannel.setMethodCallHandler { call, result in
switch call.method { switch call.method {
case "start": case "start":
@@ -41,6 +42,26 @@ import UIKit
case "stop": case "stop":
LibgopeedStop() LibgopeedStop()
result(nil) 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: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }
@@ -0,0 +1,16 @@
#import <Flutter/Flutter.h>
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
@@ -0,0 +1,35 @@
#import "GopeedTaskEventForwarder.h"
#import <Libgopeed/Libgopeed.h>
@interface GopeedTaskEventForwarder () <LibgopeedTaskEventListener>
@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);
}
@@ -1 +1,2 @@
#import "GeneratedPluginRegistrant.h" #import "GeneratedPluginRegistrant.h"
#import "GopeedTaskEventForwarder.h"
+45 -105
View File
@@ -1,8 +1,6 @@
import 'dart:io';
import 'package:dio/dio.dart'; 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.dart';
import 'model/create_task_batch.dart'; import 'model/create_task_batch.dart';
@@ -27,77 +25,35 @@ class ApiTimeoutException implements Exception {
String toString() => message; String toString() => message;
} }
class _Client { GopeedTransport? _transport;
_Client._();
static final _Client instance = _Client._(); void setTransportForTesting(GopeedTransport transport) {
_transport = transport;
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 init(String network, String address, String apiToken, {VoidCallback? onUnauthorized}) { void init(String network, String address, String apiToken, {void Function()? onUnauthorized}) {
_Client.instance.init(network, address, apiToken, onUnauthorized: onUnauthorized); _transport = createGopeedTransport(
GopeedTransportConfig(network: network, address: address, apiToken: apiToken, onUnauthorized: onUnauthorized),
);
} }
void initDefault({String address = '127.0.0.1:9999', String apiToken = ''}) { void initDefault({String address = '127.0.0.1:9999', String apiToken = ''}) {
if (!_Client.instance.initialized) { if (_transport == null) {
init('tcp', address, apiToken); init('tcp', address, apiToken);
} }
} }
Future<T> _parse<T>(Future<Response<dynamic>> Function() fetch, T Function(dynamic json)? fromJsonT) async { Future<dynamic> _request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters}) {
initDefault();
return _transport!.request(path, method: method, data: data, queryParameters: queryParameters);
}
Future<T> _parse<T>(Future<dynamic> Function() fetch, T Function(dynamic json)? fromJsonT) async {
initDefault(); initDefault();
try { try {
final resp = await fetch(); final resp = await fetch();
fromJsonT ??= (json) => null as T; fromJsonT ??= (json) => null as T;
final result = Result<T>.fromJson(resp.data, fromJsonT); final result = Result<T>.fromJson(resp as Map<String, dynamic>, fromJsonT);
if (result.code == 0) { if (result.code == 0) {
return result.data as T; return result.data as T;
} }
@@ -119,169 +75,153 @@ Future<ResolveResult> resolve(ResolveTask resolveTask) {
throw ArgumentError('resolve request is required'); throw ArgumentError('resolve request is required');
} }
return _parse<ResolveResult>( return _parse<ResolveResult>(
() => _Client.instance.dio.post('api/v1/resolve', data: resolveTask), () => _request('api/v1/resolve', method: 'POST', data: resolveTask),
(data) => ResolveResult.fromJson(data as Map<String, dynamic>), (data) => ResolveResult.fromJson(data as Map<String, dynamic>),
); );
} }
Future<String> createTask(CreateTask createTask) { Future<String> createTask(CreateTask createTask) {
return _parse<String>(() => _Client.instance.dio.post('api/v1/tasks', data: createTask), (data) => data as String); return _parse<String>(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String);
} }
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) { Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<List<String>>( return _parse<List<String>>(
() => _Client.instance.dio.post('api/v1/tasks/batch', data: createTaskBatch), () => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch),
(data) => (data as List<dynamic>).map((e) => e as String).toList(), (data) => (data as List<dynamic>).map((e) => e as String).toList(),
); );
} }
Future<void> patchTask(String id, ResolveTask patchTask) { Future<void> patchTask(String id, ResolveTask patchTask) {
return _parse<void>(() => _Client.instance.dio.patch('api/v1/tasks/$id', data: patchTask), null); return _parse<void>(() => _request('api/v1/tasks/$id', method: 'PATCH', data: patchTask), null);
} }
Future<List<Task>> getTasks(List<Status> statuses) { Future<List<Task>> getTasks(List<Status> statuses) {
final query = statuses.map((e) => 'status=${e.name}').join('&'); final query = statuses.map((e) => 'status=${e.name}').join('&');
return _parse<List<Task>>( return _parse<List<Task>>(
() => _Client.instance.dio.get('/api/v1/tasks?$query'), () => _request('/api/v1/tasks?$query'),
(data) => (data as List<dynamic>).map((e) => Task.fromJson(e as Map<String, dynamic>)).toList(), (data) => (data as List<dynamic>).map((e) => Task.fromJson(e as Map<String, dynamic>)).toList(),
); );
} }
Future<TaskRuntimeStatus> getTaskStatus(String id) { Future<TaskRuntimeStatus> getTaskStatus(String id) {
return _parse<TaskRuntimeStatus>( return _parse<TaskRuntimeStatus>(
() => _Client.instance.dio.get('/api/v1/tasks/$id/status'), () => _request('/api/v1/tasks/$id/status'),
(data) => TaskRuntimeStatus.fromJson(data as Map<String, dynamic>), (data) => TaskRuntimeStatus.fromJson(data as Map<String, dynamic>),
); );
} }
Future<Map<String, dynamic>> getTaskStats(String id) { Future<Map<String, dynamic>> getTaskStats(String id) {
return _parse<Map<String, dynamic>>( return _parse<Map<String, dynamic>>(
() => _Client.instance.dio.get('/api/v1/tasks/$id/stats'), () => _request('/api/v1/tasks/$id/stats'),
(data) => (data) =>
data is Map ? {for (final entry in data.entries) entry.key.toString(): entry.value} : const <String, dynamic>{}, data is Map ? {for (final entry in data.entries) entry.key.toString(): entry.value} : const <String, dynamic>{},
); );
} }
Future<void> pauseTask(String id) { Future<void> pauseTask(String id) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/$id/pause'), null); return _parse<void>(() => _request('api/v1/tasks/$id/pause', method: 'PUT'), null);
} }
Future<void> continueTask(String id) { Future<void> continueTask(String id) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/$id/continue'), null); return _parse<void>(() => _request('api/v1/tasks/$id/continue', method: 'PUT'), null);
} }
Future<void> pauseAllTasks(List<String>? ids) { Future<void> pauseAllTasks(List<String>? ids) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/pause', queryParameters: {'id': ids}), null); return _parse<void>(() => _request('api/v1/tasks/pause', method: 'PUT', queryParameters: {'id': ids}), null);
} }
Future<void> continueAllTasks(List<String>? ids) { Future<void> continueAllTasks(List<String>? ids) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/tasks/continue', queryParameters: {'id': ids}), null); return _parse<void>(() => _request('api/v1/tasks/continue', method: 'PUT', queryParameters: {'id': ids}), null);
} }
Future<void> deleteTask(String id, bool force) { Future<void> deleteTask(String id, bool force) {
return _parse<void>(() => _Client.instance.dio.delete('api/v1/tasks/$id?force=$force'), null); return _parse<void>(() => _request('api/v1/tasks/$id', method: 'DELETE', queryParameters: {'force': force}), null);
} }
Future<void> deleteTasks(List<String>? ids, bool force) { Future<void> deleteTasks(List<String>? ids, bool force) {
return _parse<void>( return _parse<void>(
() => _Client.instance.dio.delete('api/v1/tasks', queryParameters: {'id': ids, 'force': force}), () => _request('api/v1/tasks', method: 'DELETE', queryParameters: {'id': ids, 'force': force}),
null, null,
); );
} }
Future<DownloaderConfig> getConfig() { Future<DownloaderConfig> getConfig() {
return _parse<DownloaderConfig>( return _parse<DownloaderConfig>(
() => _Client.instance.dio.get('api/v1/config'), () => _request('api/v1/config'),
(data) => DownloaderConfig.fromJson(data as Map<String, dynamic>), (data) => DownloaderConfig.fromJson(data as Map<String, dynamic>),
); );
} }
Future<void> putConfig(DownloaderConfig config) { Future<void> putConfig(DownloaderConfig config) {
return _parse<void>(() => _Client.instance.dio.put('api/v1/config', data: config.toJson()), null); return _parse<void>(() => _request('api/v1/config', method: 'PUT', data: config.toJson()), null);
} }
Future<String> installExtension(InstallExtension installExtension) { Future<String> installExtension(InstallExtension installExtension) {
return _parse<String>( return _parse<String>(
() => _Client.instance.dio.post('api/v1/extensions', data: installExtension.toJson()), () => _request('api/v1/extensions', method: 'POST', data: installExtension.toJson()),
(data) => data as String, (data) => data as String,
); );
} }
Future<List<Extension>> getExtensions() { Future<List<Extension>> getExtensions() {
return _parse<List<Extension>>( return _parse<List<Extension>>(
() => _Client.instance.dio.get('api/v1/extensions'), () => _request('api/v1/extensions'),
(data) => (data as List<dynamic>).map((e) => Extension.fromJson(e as Map<String, dynamic>)).toList(), (data) => (data as List<dynamic>).map((e) => Extension.fromJson(e as Map<String, dynamic>)).toList(),
); );
} }
Future<void> updateExtensionSettings(String identity, UpdateExtensionSettings updateExtensionSettings) { Future<void> updateExtensionSettings(String identity, UpdateExtensionSettings updateExtensionSettings) {
return _parse<void>( return _parse<void>(
() => _Client.instance.dio.put('api/v1/extensions/$identity/settings', data: updateExtensionSettings.toJson()), () => _request('api/v1/extensions/$identity/settings', method: 'PUT', data: updateExtensionSettings.toJson()),
null, null,
); );
} }
Future<void> switchExtension(String identity, SwitchExtension switchExtension) { Future<void> switchExtension(String identity, SwitchExtension switchExtension) {
return _parse<void>( return _parse<void>(
() => _Client.instance.dio.put('api/v1/extensions/$identity/switch', data: switchExtension.toJson()), () => _request('api/v1/extensions/$identity/switch', method: 'PUT', data: switchExtension.toJson()),
null, null,
); );
} }
Future<void> deleteExtension(String identity) { Future<void> deleteExtension(String identity) {
return _parse<void>(() => _Client.instance.dio.delete('api/v1/extensions/$identity'), null); return _parse<void>(() => _request('api/v1/extensions/$identity', method: 'DELETE'), null);
} }
Future<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) { Future<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) {
return _parse<UpdateCheckExtensionResp>( return _parse<UpdateCheckExtensionResp>(
() => _Client.instance.dio.get('api/v1/extensions/$identity/update'), () => _request('api/v1/extensions/$identity/update'),
(data) => UpdateCheckExtensionResp.fromJson(data as Map<String, dynamic>), (data) => UpdateCheckExtensionResp.fromJson(data as Map<String, dynamic>),
); );
} }
Future<void> updateExtension(String identity) { Future<void> updateExtension(String identity) {
return _parse<void>(() => _Client.instance.dio.post('api/v1/extensions/$identity/update'), null); return _parse<void>(() => _request('api/v1/extensions/$identity/update', method: 'POST'), null);
} }
Future<void> testWebhook(String url) { Future<void> testWebhook(String url) {
return _parse<void>(() => _Client.instance.dio.post('api/v1/webhook/test', data: {'url': url}), null); return _parse<void>(() => _request('api/v1/webhook/test', method: 'POST', data: {'url': url}), null);
} }
Future<void> login(LoginReq loginReq) { Future<void> login(LoginReq loginReq) {
return _parse<void>(() => _Client.instance.dio.post('api/web/login', data: loginReq.toJson()), null); return _parse<void>(() => _request('api/web/login', method: 'POST', data: loginReq.toJson()), null);
} }
Future<Response<String>> proxyRequest<T>(String uri, {dynamic data, Options? options}) async { Future<Response<String>> proxyRequest<T>(String uri, {dynamic data, Options? options}) async {
initDefault(); initDefault();
options ??= Options(); return _transport!.proxyRequest(uri, data: data, options: options);
options.headers ??= {};
options.headers!['X-Target-Uri'] = uri;
return _Client.instance.dio.request<String>(
'/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}',
data: data,
options: options,
);
} }
String join(String path) { String join(String path) {
initDefault(); initDefault();
final baseUrl = _Client.instance.dio.options.baseUrl; return _transport!.join(path);
final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
return '$cleanBaseUrl/$cleanPath';
} }
Future<Response<dynamic>> forward( Future<dynamic> forward(
String path, { String path, {
String method = 'GET', String method = 'GET',
dynamic data, dynamic data,
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
}) async { }) async {
initDefault(); return _request(path, method: method, data: data, queryParameters: queryParameters);
return _Client.instance.dio.request<dynamic>(
path,
data: data,
queryParameters: queryParameters,
options: Options(method: method),
);
} }
@@ -13,6 +13,7 @@ class DownloaderConfig {
ScriptConfig script = ScriptConfig(); ScriptConfig script = ScriptConfig();
AutoTorrentConfig autoTorrent = AutoTorrentConfig(); AutoTorrentConfig autoTorrent = AutoTorrentConfig();
ArchiveConfig archive = ArchiveConfig(); ArchiveConfig archive = ArchiveConfig();
ApiServerConfig api = ApiServerConfig();
bool autoStartTasks; bool autoStartTasks;
bool autoDeleteMissingFileTasks; bool autoDeleteMissingFileTasks;
@@ -28,6 +29,21 @@ class DownloaderConfig {
Map<String, dynamic> toJson() => _$DownloaderConfigToJson(this); Map<String, dynamic> 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<String, dynamic>? json) =>
json == null ? ApiServerConfig() : _$ApiServerConfigFromJson(json);
Map<String, dynamic> toJson() => _$ApiServerConfigToJson(this);
}
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class ProtocolConfig { class ProtocolConfig {
HttpConfig http = HttpConfig(); HttpConfig http = HttpConfig();
@@ -102,6 +118,12 @@ class ExtraConfig {
bool notifyWhenNewVersion; bool notifyWhenNewVersion;
bool desktopNotification; bool desktopNotification;
bool backgroundLocationKeepAlive; bool backgroundLocationKeepAlive;
WindowStateConfig windowState;
Map<String, String> bookmarks;
List<String> createHistory;
bool runAsMenubarApp;
bool analyticsEnabled;
String analyticsClientId;
List<DownloadCategory> downloadCategories; List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt(); ExtraConfigBt bt = ExtraConfigBt();
@@ -117,8 +139,14 @@ class ExtraConfig {
this.notifyWhenNewVersion = true, this.notifyWhenNewVersion = true,
this.desktopNotification = true, this.desktopNotification = true,
this.backgroundLocationKeepAlive = false, this.backgroundLocationKeepAlive = false,
WindowStateConfig? windowState,
this.bookmarks = const {},
this.createHistory = const [],
this.runAsMenubarApp = false,
this.analyticsEnabled = true,
this.analyticsClientId = '',
this.downloadCategories = const [], this.downloadCategories = const [],
}); }) : windowState = windowState ?? WindowStateConfig();
factory ExtraConfig.fromJson(Map<String, dynamic>? json) => factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? ExtraConfig() : _$ExtraConfigFromJson(json); json == null ? ExtraConfig() : _$ExtraConfigFromJson(json);
@@ -126,6 +154,20 @@ class ExtraConfig {
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this); Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
} }
@JsonSerializable()
class WindowStateConfig {
bool isMaximized;
double? width;
double? height;
WindowStateConfig({this.isMaximized = false, this.width, this.height});
factory WindowStateConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? WindowStateConfig() : _$WindowStateConfigFromJson(json);
Map<String, dynamic> toJson() => _$WindowStateConfigToJson(this);
}
@JsonSerializable() @JsonSerializable()
class DownloadCategory { class DownloadCategory {
String name; String name;
@@ -19,7 +19,8 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?) ..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?) ..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..autoTorrent = AutoTorrentConfig.fromJson(json['autoTorrent'] as Map<String, dynamic>?) ..autoTorrent = AutoTorrentConfig.fromJson(json['autoTorrent'] as Map<String, dynamic>?)
..archive = ArchiveConfig.fromJson(json['archive'] as Map<String, dynamic>?); ..archive = ArchiveConfig.fromJson(json['archive'] as Map<String, dynamic>?)
..api = ApiServerConfig.fromJson(json['api'] as Map<String, dynamic>?);
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <String, dynamic>{ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <String, dynamic>{
'downloadDir': instance.downloadDir, 'downloadDir': instance.downloadDir,
@@ -31,10 +32,25 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
'script': instance.script.toJson(), 'script': instance.script.toJson(),
'autoTorrent': instance.autoTorrent.toJson(), 'autoTorrent': instance.autoTorrent.toJson(),
'archive': instance.archive.toJson(), 'archive': instance.archive.toJson(),
'api': instance.api.toJson(),
'autoStartTasks': instance.autoStartTasks, 'autoStartTasks': instance.autoStartTasks,
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks, 'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
}; };
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> 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<String, dynamic> _$ApiServerConfigToJson(ApiServerConfig instance) => <String, dynamic>{
'enable': instance.enable,
'network': instance.network,
'address': instance.address,
'token': instance.token,
};
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolConfig() ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolConfig()
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>) ..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>) ..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
@@ -101,6 +117,14 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) =>
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
desktopNotification: json['desktopNotification'] as bool? ?? true, desktopNotification: json['desktopNotification'] as bool? ?? true,
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false, backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
windowState: json['windowState'] == null
? null
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
createHistory: (json['createHistory'] as List<dynamic>?)?.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: downloadCategories:
(json['downloadCategories'] as List<dynamic>?) (json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>)) ?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
@@ -120,11 +144,29 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
'notifyWhenNewVersion': instance.notifyWhenNewVersion, 'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'desktopNotification': instance.desktopNotification, 'desktopNotification': instance.desktopNotification,
'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive, 'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive,
'windowState': instance.windowState.toJson(),
'bookmarks': instance.bookmarks,
'createHistory': instance.createHistory,
'runAsMenubarApp': instance.runAsMenubarApp,
'analyticsEnabled': instance.analyticsEnabled,
'analyticsClientId': instance.analyticsClientId,
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(), 'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(), 'bt': instance.bt.toJson(),
'githubMirror': instance.githubMirror.toJson(), 'githubMirror': instance.githubMirror.toJson(),
}; };
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
isMaximized: json['isMaximized'] as bool? ?? false,
width: (json['width'] as num?)?.toDouble(),
height: (json['height'] as num?)?.toDouble(),
);
Map<String, dynamic> _$WindowStateConfigToJson(WindowStateConfig instance) => <String, dynamic>{
'isMaximized': instance.isMaximized,
'width': ?instance.width,
'height': ?instance.height,
};
DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => DownloadCategory( DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => DownloadCategory(
name: json['name'] as String, name: json['name'] as String,
path: json['path'] as String, path: json['path'] as String,
@@ -7,8 +7,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../../api/model/task.dart'; import '../../core/common/task_event.dart';
import '../../core/capabilities/app_capabilities.dart'; import '../../core/libgopeed_boot.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import 'app_runtime_controller.dart'; import 'app_runtime_controller.dart';
@@ -25,8 +25,7 @@ class AppNotificationState {
class AppNotificationController extends AsyncNotifier<AppNotificationState> { class AppNotificationController extends AsyncNotifier<AppNotificationState> {
final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();
final Map<String, Status> _previousStatus = {}; StreamSubscription<TaskEvent>? _taskEventSubscription;
Timer? _timer;
var _notificationId = 0; var _notificationId = 0;
@override @override
@@ -36,9 +35,9 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
return const AppNotificationState(); return const AppNotificationState();
} }
await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale)); await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale));
_startPolling(); _listenTaskEvents();
ref.onDispose(() { ref.onDispose(() {
_timer?.cancel(); unawaited(_taskEventSubscription?.cancel());
}); });
return const AppNotificationState(started: true); return const AppNotificationState(started: true);
} }
@@ -77,30 +76,18 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
); );
} }
void _startPolling() { void _listenTaskEvents() {
_timer?.cancel(); _taskEventSubscription?.cancel();
_timer = Timer.periodic(const Duration(seconds: 2), (_) async { _taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async {
try { final runtime = ref.read(appRuntimeControllerProvider).value;
final runtime = ref.read(appRuntimeControllerProvider).value; if (runtime?.downloaderConfig.extra.desktopNotification == false) return;
if (runtime?.downloaderConfig.extra.desktopNotification == false) { final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? '');
return; switch (event.type) {
} case TaskEventType.done:
final tasks = await ref.read(gopeedServiceProvider).getTasks(Status.values); await _showNotification(title: locale.notificationTaskDone, body: event.name);
final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? ''); case TaskEventType.error:
for (final task in tasks) { await _showNotification(title: locale.notificationTaskError, body: event.name);
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 (_) {}
}); });
} }
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart' show Size;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:launch_at_startup/launch_at_startup.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/capabilities/app_capabilities.dart';
import '../../core/window/app_window_launcher.dart'; import '../../core/window/app_window_launcher.dart';
import '../../core/window/window_capability_transport.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_create_task.dart';
import '../../features/tasks/application/pending_update_task.dart'; import '../../features/tasks/application/pending_update_task.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
@@ -64,9 +63,13 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
VersionInfo? _latestVersion; VersionInfo? _latestVersion;
String? _updateError; String? _updateError;
Future<VersionInfo?>? _checkUpdateFuture; Future<VersionInfo?>? _checkUpdateFuture;
final _windowsResizeSave = Util.debounce(() async { late final void Function() _windowsResizeSave = Util.debounce(() async {
final size = await windowManager.getSize(); 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); }, 500);
@override @override
@@ -103,11 +106,13 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
Future<void> _start() async { Future<void> _start() async {
_started = true; _started = true;
final extra = ref.read(appRuntimeControllerProvider).value!.downloaderConfig.extra;
await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry); await AppWindowCapabilityHost.instance.start(LocalAppCapabilities.instance.registry);
await _restoreWindowState(extra.windowState);
windowManager.addListener(this); windowManager.addListener(this);
await windowManager.setPreventClose(true); await windowManager.setPreventClose(true);
if (Util.isMacos()) { if (Util.isMacos()) {
_runAsMenubarApp = Database.instance.getRunAsMenubarApp(); _runAsMenubarApp = extra.runAsMenubarApp;
await windowManager.setSkipTaskbar(_runAsMenubarApp); await windowManager.setSkipTaskbar(_runAsMenubarApp);
} }
await _initTray(); await _initTray();
@@ -245,7 +250,9 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
if (!Util.isMacos()) { if (!Util.isMacos()) {
return; return;
} }
Database.instance.saveRunAsMenubarApp(enabled); await ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) {
extra.runAsMenubarApp = enabled;
});
_runAsMenubarApp = enabled; _runAsMenubarApp = enabled;
await windowManager.setSkipTaskbar(enabled); await windowManager.setSkipTaskbar(enabled);
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
@@ -369,16 +376,36 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
@override @override
void onWindowMaximize() { void onWindowMaximize() {
Database.instance.saveWindowState(WindowStateEntity(isMaximized: true)); unawaited(
ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) {
extra.windowState.isMaximized = true;
}),
);
} }
@override @override
void onWindowUnmaximize() { void onWindowUnmaximize() {
Database.instance.saveWindowState(WindowStateEntity(isMaximized: false)); unawaited(
ref.read(appRuntimeControllerProvider.notifier).updateExtraConfig((extra) {
extra.windowState.isMaximized = false;
}),
);
} }
@override @override
void onWindowResize() { void onWindowResize() {
_windowsResizeSave(); _windowsResizeSave();
} }
Future<void> _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();
}
}
} }
@@ -8,14 +8,15 @@ import 'package:path_provider/path_provider.dart';
import '../../api/api.dart' as api; import '../../api/api.dart' as api;
import '../../api/model/downloader_config.dart'; import '../../api/model/downloader_config.dart';
import '../../core/capabilities/app_capabilities.dart'; import '../../core/capabilities/app_capabilities.dart';
import '../../core/common/api_server_state.dart';
import '../../core/common/start_config.dart'; import '../../core/common/start_config.dart';
import '../../core/common/task_event.dart';
import '../../core/entry/app_initializer.dart'; import '../../core/entry/app_initializer.dart';
import '../../core/libgopeed_boot.dart'; import '../../core/libgopeed_boot.dart';
import '../../database/database.dart';
import '../../database/entity.dart';
import '../../features/auth/application/web_auth_controller.dart'; import '../../features/auth/application/web_auth_controller.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../util/log_util.dart'; import '../../util/log_util.dart';
import '../../util/analytics.dart';
import '../../util/package_info.dart'; import '../../util/package_info.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import '../rpc/webview_rpc_service.dart'; import '../rpc/webview_rpc_service.dart';
@@ -31,28 +32,26 @@ final appRuntimeControllerProvider = AsyncNotifierProvider<AppRuntimeController,
class AppRuntimeState { class AppRuntimeState {
const AppRuntimeState({ const AppRuntimeState({
required this.startConfig, required this.startConfig,
required this.runningPort, required this.apiServerState,
required this.downloaderConfig, required this.downloaderConfig,
this.localBackendStarted = false, this.localBackendStarted = false,
this.startupError, this.startupError,
}); });
final StartConfig startConfig; final StartConfig startConfig;
final int runningPort; final ApiServerState apiServerState;
final DownloaderConfig downloaderConfig; final DownloaderConfig downloaderConfig;
final bool localBackendStarted; final bool localBackendStarted;
final Object? startupError; final Object? startupError;
String runningAddress() { int get runningPort => apiServerState.runningPort;
if (startConfig.network == 'unix') {
return startConfig.address; String runningAddress() => apiServerState.runningAddress();
}
return '${startConfig.address.split(':').first}:$runningPort';
}
} }
class AppRuntimeController extends AsyncNotifier<AppRuntimeState> { class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
static StartConfig? _defaultStartConfig; static StartConfig? _defaultStartConfig;
Future<void> _preferenceWrites = Future<void>.value();
@override @override
Future<AppRuntimeState> build() async { Future<AppRuntimeState> build() async {
@@ -63,11 +62,18 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
Future<void> reloadConfig() async { Future<void> reloadConfig() async {
final current = state.value; final current = state.value;
if (current == null) return; 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( state = AsyncValue.data(
AppRuntimeState( AppRuntimeState(
startConfig: current.startConfig, startConfig: startConfig,
runningPort: current.runningPort, apiServerState: apiState,
downloaderConfig: await ref.read(gopeedServiceProvider).getConfig(), downloaderConfig: config,
localBackendStarted: current.localBackendStarted, localBackendStarted: current.localBackendStarted,
startupError: current.startupError, startupError: current.startupError,
), ),
@@ -80,7 +86,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
state = AsyncValue.data( state = AsyncValue.data(
AppRuntimeState( AppRuntimeState(
startConfig: current.startConfig, startConfig: current.startConfig,
runningPort: current.runningPort, apiServerState: current.apiServerState,
downloaderConfig: DownloaderConfig.fromJson(config.toJson()), downloaderConfig: DownloaderConfig.fromJson(config.toJson()),
localBackendStarted: current.localBackendStarted, localBackendStarted: current.localBackendStarted,
startupError: current.startupError, startupError: current.startupError,
@@ -90,7 +96,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
} }
Future<AppRuntimeState> _init() async { Future<AppRuntimeState> _init() async {
await AppInitializer.ensureDatabaseInitialized(); await AppInitializer.ensureStorageInitialized();
try { try {
await initPackageInfo(); await initPackageInfo();
} catch (_) {} } catch (_) {}
@@ -104,37 +110,57 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
} }
Object? startupError; Object? startupError;
var runningPort = 0; var runningPort = 0;
var apiServerState = const ApiServerState(
enabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
);
var localBackendStarted = false; var localBackendStarted = false;
try { try {
// Go's rest.Start is already idempotent: when its server is running it // Go's rest.Start is already idempotent: when its server is running it
// returns the existing port before rebuilding or touching the Unix // returns the existing port before rebuilding or touching the Unix
// socket. This is also what keeps Riverpod hot reload safe. // socket. This is also what keeps Riverpod hot reload safe.
runningPort = await LibgopeedBoot.instance.start(startConfig); 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; localBackendStarted = true;
} catch (error, stackTrace) { } catch (error, stackTrace) {
startupError = error; logger.e('libgopeed init failed', error, stackTrace);
logger.w('libgopeed init failed, falling back to external API', error, stackTrace); rethrow;
if (startConfig.network == 'unix') {
startConfig.network = 'tcp';
startConfig.address = '127.0.0.1:9999';
}
runningPort = int.tryParse(startConfig.address.split(':').last) ?? 9999;
} }
api.init( if (kIsWeb) {
startConfig.network, final webAuth = ref.read(webAuthControllerProvider);
_runningAddress(startConfig, runningPort), api.init('tcp', Uri.base.authority, '', onUnauthorized: webAuth.requireLogin);
startConfig.apiToken, } else {
onUnauthorized: ref.read(webAuthControllerProvider).requireLogin, api.init(startConfig.network, _runningAddress(startConfig, runningPort), startConfig.apiToken);
); }
final config = await _loadDownloaderConfig(); 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(_initTrackerUpdate(config));
unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale))); unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale)));
await _initAnalytics(config);
final result = AppRuntimeState( final result = AppRuntimeState(
startConfig: startConfig, startConfig: startConfig,
runningPort: runningPort, apiServerState: apiServerState,
downloaderConfig: config, downloaderConfig: config,
localBackendStarted: localBackendStarted, localBackendStarted: localBackendStarted,
startupError: startupError, startupError: startupError,
@@ -158,11 +184,10 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
Future<StartConfig> _loadStartConfig() async { Future<StartConfig> _loadStartConfig() async {
final defaultCfg = await _initDefaultStartConfig(); final defaultCfg = await _initDefaultStartConfig();
final saved = Database.instance.getStartConfig();
final cfg = StartConfig() final cfg = StartConfig()
..network = saved?.network ?? defaultCfg.network ..network = defaultCfg.network
..address = saved?.address ?? defaultCfg.address ..address = defaultCfg.address
..apiToken = saved?.apiToken ?? defaultCfg.apiToken ..apiToken = defaultCfg.apiToken
..storage = defaultCfg.storage ..storage = defaultCfg.storage
..storageDir = defaultCfg.storageDir ..storageDir = defaultCfg.storageDir
..refreshInterval = defaultCfg.refreshInterval; ..refreshInterval = defaultCfg.refreshInterval;
@@ -178,6 +203,13 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
..storageDir = Util.getStorageDir() ..storageDir = Util.getStorageDir()
..refreshInterval = 0 ..refreshInterval = 0
..apiToken = ''; ..apiToken = '';
if (kIsWeb) {
cfg
..network = 'tcp'
..address = Uri.base.authority;
_defaultStartConfig = cfg;
return cfg;
}
if (!Util.supportUnixSocket()) { if (!Util.supportUnixSocket()) {
cfg cfg
..network = 'tcp' ..network = 'tcp'
@@ -270,6 +302,20 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
} }
} }
Future<void> _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<void> updateTrackers(DownloaderConfig config) async { Future<void> updateTrackers(DownloaderConfig config) async {
final btExtra = config.extra.bt; final btExtra = config.extra.bt;
final trackers = <String>[]; final trackers = <String>[];
@@ -290,7 +336,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
state = AsyncValue.data( state = AsyncValue.data(
AppRuntimeState( AppRuntimeState(
startConfig: current.startConfig, startConfig: current.startConfig,
runningPort: current.runningPort, apiServerState: current.apiServerState,
downloaderConfig: config, downloaderConfig: config,
localBackendStarted: current.localBackendStarted, localBackendStarted: current.localBackendStarted,
startupError: current.startupError, startupError: current.startupError,
@@ -322,11 +368,116 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
return '${config.address.split(':').first}:$runningPort'; return '${config.address.split(':').first}:$runningPort';
} }
Future<void> saveStartConfig(StartConfig config) async { Future<void> saveApiServerConfig(StartConfig config) async {
Database.instance.saveStartConfig( if (kIsWeb) return;
StartConfigEntity(network: config.network, address: config.address, apiToken: config.apiToken), await _persistApiServerConfig(config);
}
Future<void> startApiServer() async {
if (kIsWeb) return;
await _persistApiServerEnabled(true);
await _applyApiServerOperation(LibgopeedBoot.instance.startApiServer());
}
Future<void> stopApiServer() async {
if (kIsWeb) return;
await _persistApiServerEnabled(false);
await _applyApiServerOperation(LibgopeedBoot.instance.stopApiServer());
}
Future<void> restartApiServer(StartConfig config) async {
if (kIsWeb) return;
final desired = _copyStartConfig(config)..apiEnable = true;
await _persistApiServerConfig(desired);
await _applyApiServerOperation(LibgopeedBoot.instance.restartApiServer());
}
Future<void> refreshApiServerState() async {
if (kIsWeb) return;
await _applyApiServerOperation(LibgopeedBoot.instance.getApiServerState());
}
Future<void> _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<void> _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<void> _applyApiServerOperation(Future<ApiServerOperationResult> 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<void> 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<void>((_) {}, onError: (_, _) {});
return operation;
}
} }
const allTrackerSubscribeUrls = [ const allTrackerSubscribeUrls = [
@@ -12,7 +12,6 @@ import '../../api/model/switch_extension.dart';
import '../../api/model/task.dart'; import '../../api/model/task.dart';
import '../../api/model/update_check_extension_resp.dart'; import '../../api/model/update_check_extension_resp.dart';
import '../../api/model/update_extension_settings.dart'; import '../../api/model/update_extension_settings.dart';
import '../../database/database.dart';
import '../window/app_window_appearance.dart'; import '../window/app_window_appearance.dart';
import 'capability_rpc.dart'; import 'capability_rpc.dart';
import 'gopeed_capability.dart'; import 'gopeed_capability.dart';
@@ -147,19 +146,33 @@ void _bindGopeed(CapabilityRegistry registry) {
void _bindStorage(CapabilityRegistry registry) { void _bindStorage(CapabilityRegistry registry) {
registry registry
..bind(StorageMethods.getCreateHistory, (_) async => Database.instance.getCreateHistory() ?? const <String>[]) ..bind(StorageMethods.getCreateHistory, (_) async {
final config = await api.getConfig();
return config.extra.createHistory;
})
..bind(StorageMethods.saveCreateHistory, (urls) async { ..bind(StorageMethods.saveCreateHistory, (urls) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
for (final url in urls) { 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(); return const RpcUnit();
}) })
..bind(StorageMethods.removeCreateHistory, (url) async { ..bind(StorageMethods.removeCreateHistory, (url) async {
Database.instance.removeCreateHistory(url); final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory)..remove(url);
config.extra.createHistory = history;
await api.putConfig(config);
return const RpcUnit(); return const RpcUnit();
}) })
..bind(StorageMethods.clearCreateHistory, (_) async { ..bind(StorageMethods.clearCreateHistory, (_) async {
Database.instance.clearCreateHistory(); final config = await api.getConfig();
config.extra.createHistory = const [];
await api.putConfig(config);
return const RpcUnit(); return const RpcUnit();
}); });
} }
@@ -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<String, dynamic> 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<String, dynamic> json) {
final rawState = json['state'] as Map<String, dynamic>?;
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? ?? '',
);
}
}
@@ -1,12 +1,24 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'api_server_state.dart';
import 'libgopeed_interface.dart'; import 'libgopeed_interface.dart';
import 'start_config.dart'; import 'start_config.dart';
import 'task_event.dart';
class LibgopeedChannel implements LibgopeedInterface { class LibgopeedChannel implements LibgopeedInterface {
static const _channel = MethodChannel('gopeed.com/libgopeed'); static const _channel = MethodChannel('gopeed.com/libgopeed');
final _taskEvents = StreamController<TaskEvent>.broadcast();
LibgopeedChannel() {
_channel.setMethodCallHandler((call) async {
if (call.method != 'taskEvent') return;
final payload = jsonDecode(call.arguments as String) as Map<String, dynamic>;
_taskEvents.add(TaskEvent.fromJson(payload));
});
}
@override @override
Future<int> start(StartConfig cfg) async { Future<int> start(StartConfig cfg) async {
@@ -18,4 +30,40 @@ class LibgopeedChannel implements LibgopeedInterface {
Future<void> stop() async { Future<void> stop() async {
return await _channel.invokeMethod('stop'); return await _channel.invokeMethod('stop');
} }
Future<ApiServerOperationResult> _apiServerOperation(String method) async {
final payload = await _channel.invokeMethod<String>(method) ?? '';
return ApiServerOperationResult.fromJson(jsonDecode(payload) as Map<String, dynamic>);
}
@override
Future<ApiServerOperationResult> getApiServerState() => _apiServerOperation('getApiServerState');
@override
Future<ApiServerOperationResult> startApiServer() => _apiServerOperation('startApiServer');
@override
Future<ApiServerOperationResult> stopApiServer() => _apiServerOperation('stopApiServer');
@override
Future<ApiServerOperationResult> restartApiServer() => _apiServerOperation('restartApiServer');
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
final result = await _channel.invokeMethod<String>('invoke', {
'method': method,
'path': path,
'query': query,
'body': body,
});
return result ?? '';
}
@override
Stream<TaskEvent> get taskEvents => _taskEvents.stream;
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) {
return _channel.invokeMethod<void>('subscribeTaskEvents', {'mask': events.mask});
}
} }
+77 -5
View File
@@ -5,24 +5,47 @@ import 'dart:ffi';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import '../ffi/libgopeed_bind.dart'; import '../ffi/libgopeed_bind.dart';
import '../ffi/libgopeed_worker.dart';
import 'api_server_state.dart';
import 'libgopeed_interface.dart'; import 'libgopeed_interface.dart';
import 'start_config.dart'; import 'start_config.dart';
import 'task_event.dart';
class LibgopeedFFi implements LibgopeedInterface { class LibgopeedFFi implements LibgopeedInterface {
late LibgopeedBind _libgopeed; late LibgopeedBind _libgopeed;
final _taskEvents = StreamController<TaskEvent>.broadcast();
final _worker = LibgopeedWorker();
late final NativeCallable<Void Function(Pointer<Char>)> _taskEventCallback;
LibgopeedFFi(LibgopeedBind libgopeed) { LibgopeedFFi(LibgopeedBind libgopeed) {
_libgopeed = libgopeed; _libgopeed = libgopeed;
_taskEventCallback = NativeCallable<Void Function(Pointer<Char>)>.listener(_onTaskEvent);
}
void _onTaskEvent(Pointer<Char> payload) {
try {
final decoded = jsonDecode(payload.cast<Utf8>().toDartString()) as Map<String, dynamic>;
_taskEvents.add(TaskEvent.fromJson(decoded));
} finally {
_libgopeed.FreeCString(payload);
}
} }
@override @override
Future<int> start(StartConfig cfg) { Future<int> start(StartConfig cfg) {
var completer = Completer<int>(); var completer = Completer<int>();
var result = _libgopeed.Start(jsonEncode(cfg).toNativeUtf8().cast()); final cfgPtr = jsonEncode(cfg).toNativeUtf8();
if (result.r1 != nullptr) { try {
completer.completeError(Exception(result.r1.cast<Utf8>().toDartString())); final result = _libgopeed.Start(cfgPtr.cast());
} else { if (result.r1 != nullptr) {
completer.complete(result.r0); final message = result.r1.cast<Utf8>().toDartString();
_libgopeed.FreeCString(result.r1);
completer.completeError(Exception(message));
} else {
completer.complete(result.r0);
}
} finally {
malloc.free(cfgPtr);
} }
return completer.future; return completer.future;
} }
@@ -34,4 +57,53 @@ class LibgopeedFFi implements LibgopeedInterface {
completer.complete(); completer.complete();
return completer.future; return completer.future;
} }
Future<ApiServerOperationResult> _apiServerOperation(String operation) async {
final payload = await _worker.apiServer(operation);
return ApiServerOperationResult.fromJson(jsonDecode(payload) as Map<String, dynamic>);
}
@override
Future<ApiServerOperationResult> getApiServerState() => _apiServerOperation('get');
@override
Future<ApiServerOperationResult> startApiServer() => _apiServerOperation('start');
@override
Future<ApiServerOperationResult> stopApiServer() => _apiServerOperation('stop');
@override
Future<ApiServerOperationResult> restartApiServer() => _apiServerOperation('restart');
@override
Future<String> 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<Char>? 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<Utf8>().toDartString();
} finally {
malloc.free(methodPtr);
malloc.free(pathPtr);
malloc.free(queryPtr);
malloc.free(bodyPtr);
if (resultPtr != null && resultPtr != nullptr) {
_libgopeed.FreeCString(resultPtr);
}
}
}
@override
Stream<TaskEvent> get taskEvents => _taskEvents.stream;
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) async {
_libgopeed.SubscribeTaskEvents(events.mask, events.isEmpty ? 0 : _taskEventCallback.nativeFunction.address);
}
} }
@@ -1,7 +1,23 @@
import 'api_server_state.dart';
import 'start_config.dart'; import 'start_config.dart';
import 'task_event.dart';
abstract class LibgopeedInterface { abstract class LibgopeedInterface {
Future<int> start(StartConfig cfg); Future<int> start(StartConfig cfg);
Future<void> stop(); Future<void> stop();
Future<ApiServerOperationResult> getApiServerState();
Future<ApiServerOperationResult> startApiServer();
Future<ApiServerOperationResult> stopApiServer();
Future<ApiServerOperationResult> restartApiServer();
Future<String> invoke(String method, String path, {String query = '', String body = ''});
Stream<TaskEvent> get taskEvents;
Future<void> subscribeTaskEvents(Set<TaskEventType> events);
} }
@@ -19,6 +19,7 @@ class WebViewRpcConfig {
class StartConfig { class StartConfig {
late String network; late String network;
late String address; late String address;
bool apiEnable = false;
late String storage; late String storage;
late String storageDir; late String storageDir;
late int refreshInterval; late int refreshInterval;
@@ -22,6 +22,7 @@ Map<String, dynamic> _$WebViewRpcConfigToJson(WebViewRpcConfig instance) =>
StartConfig _$StartConfigFromJson(Map<String, dynamic> json) => StartConfig() StartConfig _$StartConfigFromJson(Map<String, dynamic> json) => StartConfig()
..network = json['network'] as String ..network = json['network'] as String
..address = json['address'] as String ..address = json['address'] as String
..apiEnable = json['apiEnable'] as bool
..storage = json['storage'] as String ..storage = json['storage'] as String
..storageDir = json['storageDir'] as String ..storageDir = json['storageDir'] as String
..refreshInterval = (json['refreshInterval'] as num).toInt() ..refreshInterval = (json['refreshInterval'] as num).toInt()
@@ -36,6 +37,7 @@ Map<String, dynamic> _$StartConfigToJson(StartConfig instance) =>
<String, dynamic>{ <String, dynamic>{
'network': instance.network, 'network': instance.network,
'address': instance.address, 'address': instance.address,
'apiEnable': instance.apiEnable,
'storage': instance.storage, 'storage': instance.storage,
'storageDir': instance.storageDir, 'storageDir': instance.storageDir,
'refreshInterval': instance.refreshInterval, 'refreshInterval': instance.refreshInterval,
@@ -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<TaskEventType> {
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<String, dynamic> 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?,
);
}
}
@@ -1,20 +1,9 @@
import '../../database/database.dart';
import '../../util/util.dart'; import '../../util/util.dart';
class AppInitializer { class AppInitializer {
static Future<void>? _storageInitialization; static Future<void>? _storageInitialization;
static Future<void>? _databaseInitialization;
static Future<void> ensureStorageInitialized() { static Future<void> ensureStorageInitialized() {
return _storageInitialization ??= Util.initStorageDir(); return _storageInitialization ??= Util.initStorageDir();
} }
static Future<void> ensureDatabaseInitialized() {
return _databaseInitialization ??= _initializeDatabase();
}
static Future<void> _initializeDatabase() async {
await ensureStorageInitialized();
await Database.instance.init();
}
} }
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import '../common/api_server_state.dart';
import '../common/start_config.dart'; import '../common/start_config.dart';
import '../common/task_event.dart';
import '../libgopeed_boot.dart'; import '../libgopeed_boot.dart';
@@ -13,4 +15,40 @@ class LibgopeedBootBrowser implements LibgopeedBoot {
@override @override
Future<void> stop() async {} Future<void> stop() async {}
@override
Future<ApiServerOperationResult> getApiServerState() async => _unsupportedApiServerResult;
@override
Future<ApiServerOperationResult> startApiServer() async => _unsupportedApiServerResult;
@override
Future<ApiServerOperationResult> stopApiServer() async => _unsupportedApiServerResult;
@override
Future<ApiServerOperationResult> restartApiServer() async => _unsupportedApiServerResult;
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) {
throw UnsupportedError('Native Gopeed invoke is unavailable on web');
}
@override
Stream<TaskEvent> get taskEvents => const Stream.empty();
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) async {}
} }
const _unsupportedApiServerResult = ApiServerOperationResult(
state: ApiServerState(
enabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
),
error: '',
);
@@ -6,7 +6,9 @@ import '../../util/util.dart';
import '../common/libgopeed_channel.dart'; import '../common/libgopeed_channel.dart';
import '../common/libgopeed_ffi.dart'; import '../common/libgopeed_ffi.dart';
import '../common/libgopeed_interface.dart'; import '../common/libgopeed_interface.dart';
import '../common/api_server_state.dart';
import '../common/start_config.dart'; import '../common/start_config.dart';
import '../common/task_event.dart';
import '../ffi/libgopeed_bind.dart'; import '../ffi/libgopeed_bind.dart';
import '../libgopeed_boot.dart'; import '../libgopeed_boot.dart';
@@ -43,6 +45,32 @@ class LibgopeedBootNative implements LibgopeedBoot {
@override @override
Future<void> stop() async { Future<void> stop() async {
await _libgopeed.subscribeTaskEvents({});
await _libgopeed.stop(); await _libgopeed.stop();
} }
@override
Future<ApiServerOperationResult> getApiServerState() => _libgopeed.getApiServerState();
@override
Future<ApiServerOperationResult> startApiServer() => _libgopeed.startApiServer();
@override
Future<ApiServerOperationResult> stopApiServer() => _libgopeed.stopApiServer();
@override
Future<ApiServerOperationResult> restartApiServer() => _libgopeed.restartApiServer();
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) {
return _libgopeed.invoke(method, path, query: query, body: body);
}
@override
Stream<TaskEvent> get taskEvents => _libgopeed.taskEvents;
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) {
return _libgopeed.subscribeTaskEvents(events);
}
} }
@@ -29,6 +29,72 @@ class LibgopeedBind {
late final _StopPtr = _lookup<ffi.NativeFunction<ffi.Void Function()>>('Stop'); late final _StopPtr = _lookup<ffi.NativeFunction<ffi.Void Function()>>('Stop');
late final _Stop = _StopPtr.asFunction<void Function()>(); late final _Stop = _StopPtr.asFunction<void Function()>();
ffi.Pointer<ffi.Char> GetAPIServerState() => _GetAPIServerState();
late final _GetAPIServerStatePtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('GetAPIServerState');
late final _GetAPIServerState = _GetAPIServerStatePtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> StartAPIServer() => _StartAPIServer();
late final _StartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StartAPIServer');
late final _StartAPIServer = _StartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> StopAPIServer() => _StopAPIServer();
late final _StopAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StopAPIServer');
late final _StopAPIServer = _StopAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> RestartAPIServer() => _RestartAPIServer();
late final _RestartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('RestartAPIServer');
late final _RestartAPIServer = _RestartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> Invoke(
ffi.Pointer<ffi.Char> method,
ffi.Pointer<ffi.Char> path,
ffi.Pointer<ffi.Char> query,
ffi.Pointer<ffi.Char> body,
) {
return _Invoke(method, path, query, body);
}
late final _InvokePtr =
_lookup<
ffi.NativeFunction<
ffi.Pointer<ffi.Char> Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
)
>
>('Invoke');
late final _Invoke =
_InvokePtr.asFunction<
ffi.Pointer<ffi.Char> Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
)
>();
void SubscribeTaskEvents(int mask, int callback) {
return _SubscribeTaskEvents(mask, callback);
}
late final _SubscribeTaskEventsPtr = _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Uint64, ffi.UintPtr)>>(
'SubscribeTaskEvents',
);
late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction<void Function(int, int)>();
void FreeCString(ffi.Pointer<ffi.Char> value) {
return _FreeCString(value);
}
late final _FreeCStringPtr = _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Char>)>>('FreeCString');
late final _FreeCString = _FreeCStringPtr.asFunction<void Function(ffi.Pointer<ffi.Char>)>();
} }
final class Start_return extends ffi.Struct { final class Start_return extends ffi.Struct {
@@ -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<int, Completer<String>> _pending = {};
late final Future<Isolate> _isolateReady;
final Completer<SendPort> _sendPort = Completer<SendPort>();
int _requestID = 0;
Future<String> invoke(String method, String path, String query, String body) async {
await _isolateReady;
final sendPort = await _sendPort.future;
final requestID = _requestID++;
final completer = Completer<String>();
_pending[requestID] = completer;
sendPort.send(['invoke', requestID, method, path, query, body]);
return completer.future;
}
Future<String> apiServer(String operation) async {
await _isolateReady;
final sendPort = await _sendPort.future;
final requestID = _requestID++;
final completer = Completer<String>();
_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<dynamic>;
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<dynamic> ? message : <dynamic>[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<dynamic>;
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<Utf8>().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<Char>? 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<Utf8>().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}');
}
}
+16
View File
@@ -1,4 +1,6 @@
import 'common/api_server_state.dart';
import 'common/start_config.dart'; import 'common/start_config.dart';
import 'common/task_event.dart';
import "libgopeed_boot_stub.dart" import "libgopeed_boot_stub.dart"
if (dart.library.html) 'entry/libgopeed_boot_browser.dart' if (dart.library.html) 'entry/libgopeed_boot_browser.dart'
if (dart.library.io) 'entry/libgopeed_boot_native.dart'; if (dart.library.io) 'entry/libgopeed_boot_native.dart';
@@ -16,4 +18,18 @@ abstract class LibgopeedBoot {
Future<int> start(StartConfig cfg); Future<int> start(StartConfig cfg);
Future<void> stop(); Future<void> stop();
Future<ApiServerOperationResult> getApiServerState();
Future<ApiServerOperationResult> startApiServer();
Future<ApiServerOperationResult> stopApiServer();
Future<ApiServerOperationResult> restartApiServer();
Future<String> invoke(String method, String path, {String query = '', String body = ''});
Stream<TaskEvent> get taskEvents;
Future<void> subscribeTaskEvents(Set<TaskEventType> events);
} }
@@ -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<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? 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<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
return _externalDio.request<String>(
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<String, dynamic>? 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<String, dynamic>? queryParameters) {
if (queryParameters == null || queryParameters.isEmpty) return '';
final segments = <String>[];
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('&');
}
}
@@ -0,0 +1,5 @@
import '../gopeed_transport.dart';
GopeedTransport createTransport(GopeedTransportConfig config) {
throw UnsupportedError('Gopeed transport is unavailable on this platform');
}
@@ -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<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
final response = await _dio.request<dynamic>(
path,
data: data,
queryParameters: queryParameters,
options: Options(method: method),
);
return response.data;
}
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
options ??= Options();
options.headers ??= {};
options.headers!['X-Target-Uri'] = uri;
return _dio.request<String>(
'/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';
}
}
@@ -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<dynamic> request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters});
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options});
String join(String path);
}
GopeedTransport createGopeedTransport(GopeedTransportConfig config) => createTransport(config);
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart' show Colors;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../../database/database.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import 'app_window_chrome.dart'; import 'app_window_chrome.dart';
import 'app_window_payload.dart'; import 'app_window_payload.dart';
@@ -16,9 +15,8 @@ class AppWindowBootstrap {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
final windowState = Database.instance.getWindowState();
final windowOptions = WindowOptions( final windowOptions = WindowOptions(
size: Size(windowState?.width ?? 1024, windowState?.height ?? 768), size: const Size(1024, 768),
center: true, center: true,
skipTaskbar: false, skipTaskbar: false,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
-144
View File
@@ -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<void> init() async {
if (_initialized) {
return;
}
Hive.init(Util.getStorageDir());
box = await Hive.openBox('database');
_initialized = true;
}
void save<T>(String key, T entity) {
box.put(key, jsonEncode(entity));
}
T? get<T>(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<StartConfigEntity>(_startConfig, entity);
}
StartConfigEntity? getStartConfig() {
return get<StartConfigEntity>(_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<WindowStateEntity>(_windowState, entity);
}
WindowStateEntity? getWindowState() {
return get<WindowStateEntity>(_windowState, (json) => WindowStateEntity.fromJson(json));
}
/// Use map to ensure that the same directory only saves the latest bookmark
void saveBookmark(MapEntry<String, String> entry) {
final map = getBookmark() ?? {};
map[entry.key] = entry.value;
save<Map<String, String>>(_bookmark, map);
}
Map<String, String>? getBookmark() {
return get<Map<String, String>>(_bookmark, (json) {
return (json as Map<String, dynamic>).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<List<String>>(_createHistory, list);
}
List<String>? getCreateHistory() {
return get<List<String>>(_createHistory, (json) {
return (json as List<dynamic>).map((e) => e.toString()).toList();
});
}
void removeCreateHistory(String url) {
final list = getCreateHistory() ?? [];
list.remove(url);
if (list.isEmpty) {
clearCreateHistory();
} else {
save<List<String>>(_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?;
}
}
Loaded 50 of 86 files, more files were not shown because too many files have changed in this diff. Show more