mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 21:03:41 +00:00
feat: move native communication to FFI (#1457)
This commit is contained in:
86 files changed
+4558
-886
No files matched your search
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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"}})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+95
-96
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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}');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user