Merge branch 'main' into fix/create-task-panel-mobile-ui

This commit is contained in:
Levi authored and GitHub committed 2026-09-09 23:27:11 +08:00
commit 9b9fd12884
36 files changed
+1884 -263

No files matched your search

+1 -1
View File
@@ -594,7 +594,7 @@ jobs:
overwrite: true
build-ios:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
runs-on: macos-14
runs-on: macos-26
needs: [get-release]
steps:
- uses: actions/checkout@v3
+1 -1
View File
@@ -213,7 +213,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ macos-14, ubuntu-latest ]
os: [ macos-26, ubuntu-latest ]
needs: [ test ]
steps:
- uses: actions/checkout@v3
+38 -5
View File
@@ -6,9 +6,15 @@ package main
typedef void (*TaskEventCallback)(char* payload);
typedef void (*InvokeResultCallback)(uint64_t request_id, int success, char* payload);
static void callTaskEventCallback(uintptr_t callback, char* payload) {
((TaskEventCallback)callback)(payload);
}
static void callInvokeResultCallback(uintptr_t callback, uint64_t request_id, int success, char* payload) {
((InvokeResultCallback)callback)(request_id, success, payload);
}
*/
import "C"
import (
@@ -16,6 +22,7 @@ import (
"fmt"
"unsafe"
nativebridge "github.com/GopeedLab/gopeed/bind/native"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
@@ -35,12 +42,13 @@ func Start(cfg *C.char) (int, *C.char) {
if err != nil {
return 0, C.CString(err.Error())
}
nativebridge.ResumeInvokes()
return realPort, nil
}
//export Stop
func Stop() {
rest.Stop()
nativebridge.Stop()
}
//export GetAPIServerState
@@ -75,14 +83,39 @@ func apiServerResult(state *model.APIServerState, err error) string {
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(
//export InvokeAsync
func InvokeAsync(
method *C.char,
path *C.char,
query *C.char,
body *C.char,
requestID C.ulonglong,
callback C.uintptr_t,
) {
if callback == 0 {
return
}
complete := func(result string, err error) {
success := C.int(1)
payload := result
if err != nil {
success = 0
payload = err.Error()
}
C.callInvokeResultCallback(
callback,
C.uint64_t(requestID),
success,
C.CString(payload),
)
}
nativebridge.InvokeAsync(
C.GoString(method),
C.GoString(path),
C.GoString(query),
C.GoString(body),
))
complete,
)
}
//export SubscribeTaskEvents
+22 -4
View File
@@ -5,6 +5,7 @@ import "C"
import (
"encoding/json"
nativebridge "github.com/GopeedLab/gopeed/bind/native"
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
@@ -18,11 +19,15 @@ func Start(cfg string) (int, error) {
config.ProductionMode = true
config.NativeMode = true
applyWebViewProvider(&config)
return rest.Start(&config)
port, err := rest.Start(&config)
if err == nil {
nativebridge.ResumeInvokes()
}
return port, err
}
func Stop() {
rest.Stop()
nativebridge.Stop()
}
func GetAPIServerState() string {
@@ -50,8 +55,21 @@ func apiServerResult(state *model.APIServerState, err error) string {
return string(data)
}
func Invoke(method, path, query, body string) string {
return rest.Invoke(method, path, query, body)
type InvokeResultListener interface {
OnResult(requestID int64, success bool, payload string)
}
func InvokeAsync(method, path, query, body string, requestID int64, listener InvokeResultListener) {
if listener == nil {
return
}
nativebridge.InvokeAsync(method, path, query, body, func(result string, err error) {
if err != nil {
listener.OnResult(requestID, false, err.Error())
return
}
listener.OnResult(requestID, true, result)
})
}
type TaskEventListener interface {
+240
View File
@@ -0,0 +1,240 @@
// Package nativebridge contains transport-neutral behavior shared by the
// Desktop C FFI and gomobile bridge adapters.
package nativebridge
import (
"errors"
"fmt"
"sync"
"time"
"github.com/GopeedLab/gopeed/pkg/rest"
)
// StopTimeout bounds native shutdown even when an in-process request never returns.
const StopTimeout = 3 * time.Second
var errNativeBridgeStopping = errors.New("native bridge is stopping")
type invokeRequest struct {
method string
path string
query string
body string
}
type invokeTask struct {
id uint64
request invokeRequest
complete func(result string, err error)
once sync.Once
}
func (t *invokeTask) finish(result string, err error) {
t.once.Do(func() {
completeCallback(t.complete, result, err)
})
}
// InvokeAsync starts an in-process API request and completes it through the
// supplied callback. Desktop and mobile adapters share this executor.
func InvokeAsync(method, path, query, body string, callback func(result string, err error)) {
if callback == nil {
return
}
task := invokeTask{
request: invokeRequest{
method: method,
path: path,
query: query,
body: body,
},
complete: callback,
}
if !getInvokeExecutor().submit(task) {
completeCallback(callback, "", errNativeBridgeStopping)
}
}
// ResumeInvokes enables request submission after the native runtime starts.
func ResumeInvokes() {
getInvokeExecutor().resume()
}
// PauseInvokesAndWait prevents new submissions and gives running requests a
// bounded opportunity to finish before a native runtime is stopped.
// Requests still pending after StopTimeout are failed and detached so shutdown
// can continue without invoking their callbacks a second time.
func PauseInvokesAndWait() bool {
return getInvokeExecutor().pauseAndWait(StopTimeout)
}
// Stop pauses native requests and shuts down the shared runtime, but never
// keeps the host application waiting longer than StopTimeout. Cleanup that
// cannot finish within the deadline is allowed to continue in the background.
func Stop() {
stopWithin(getInvokeExecutor(), rest.Stop, StopTimeout)
}
func stopWithin(executor *invokeExecutor, cleanup func(), timeout time.Duration) {
deadline := time.Now().Add(timeout)
executor.pauseAndWait(time.Until(deadline))
stopped := make(chan struct{})
go func() {
defer close(stopped)
cleanup()
}()
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
timer := time.NewTimer(remaining)
defer timer.Stop()
select {
case <-stopped:
case <-timer.C:
}
}
var (
invokeExecutorOnce sync.Once
sharedInvoker *invokeExecutor
)
func getInvokeExecutor() *invokeExecutor {
invokeExecutorOnce.Do(func() {
sharedInvoker = newInvokeExecutor(func(request invokeRequest) string {
return rest.Dispatch(request.method, request.path, request.query, request.body)
})
})
return sharedInvoker
}
// invokeExecutor starts every native API request in its own goroutine, matching
// net/http's request concurrency. It only tracks them to support bounded drain
// during shutdown; one stuck request must never prevent another from starting.
type invokeExecutor struct {
mu sync.Mutex
closed bool
accepting bool
nextID uint64
pending map[uint64]*invokeTask
drained chan struct{}
running sync.WaitGroup
handler func(invokeRequest) string
}
func newInvokeExecutor(handler func(invokeRequest) string) *invokeExecutor {
drained := make(chan struct{})
close(drained)
return &invokeExecutor{
handler: handler,
accepting: true,
pending: make(map[uint64]*invokeTask),
drained: drained,
}
}
func (e *invokeExecutor) submit(task invokeTask) bool {
e.mu.Lock()
if e.closed || !e.accepting {
e.mu.Unlock()
return false
}
if len(e.pending) == 0 {
e.drained = make(chan struct{})
}
taskID := e.nextID
e.nextID++
task.id = taskID
taskPtr := &task
e.pending[taskID] = taskPtr
e.running.Add(1)
e.mu.Unlock()
go e.run(taskPtr)
return true
}
func (e *invokeExecutor) resume() {
e.mu.Lock()
e.accepting = true
e.mu.Unlock()
}
func (e *invokeExecutor) pauseAndWait(timeout time.Duration) bool {
e.mu.Lock()
e.accepting = false
if len(e.pending) == 0 {
e.mu.Unlock()
return true
}
drained := e.drained
e.mu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-drained:
return true
case <-timer.C:
}
e.mu.Lock()
if len(e.pending) == 0 {
e.mu.Unlock()
return true
}
pending := make([]*invokeTask, 0, len(e.pending))
for _, task := range e.pending {
pending = append(pending, task)
}
e.pending = make(map[uint64]*invokeTask)
close(e.drained)
e.mu.Unlock()
for _, task := range pending {
task.finish("", errNativeBridgeStopping)
}
return false
}
func (e *invokeExecutor) close() {
e.mu.Lock()
e.closed = true
e.accepting = false
e.mu.Unlock()
e.running.Wait()
}
func (e *invokeExecutor) run(task *invokeTask) {
defer e.running.Done()
result, err := e.execute(task.request)
task.finish(result, err)
e.mu.Lock()
if _, exists := e.pending[task.id]; exists {
delete(e.pending, task.id)
if len(e.pending) == 0 {
close(e.drained)
}
}
e.mu.Unlock()
}
func (e *invokeExecutor) execute(request invokeRequest) (result string, err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("invoke panic: %v", recovered)
}
}()
return e.handler(request), nil
}
func completeCallback(callback func(string, error), result string, err error) {
defer func() {
_ = recover()
}()
callback(result, err)
}
+258
View File
@@ -0,0 +1,258 @@
package nativebridge
import (
"runtime"
"strconv"
"sync"
"testing"
"time"
)
func TestInvokeExecutorStartsEveryRequestIndependently(t *testing.T) {
const taskCount = 64
release := make(chan struct{})
started := make(chan struct{}, taskCount)
executor := newInvokeExecutor(func(request invokeRequest) string {
started <- struct{}{}
<-release
return request.path
})
var completed sync.WaitGroup
completed.Add(taskCount)
for i := range taskCount {
accepted := executor.submit(invokeTask{
request: invokeRequest{path: strconv.Itoa(i)},
complete: func(string, error) {
completed.Done()
},
})
if !accepted {
t.Fatalf("request %d was rejected", i)
}
}
// Every handler must start before any of them is released. A worker pool
// would stall here once all of its workers were occupied.
for range taskCount {
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("a request waited behind other blocked requests")
}
}
close(release)
completed.Wait()
executor.close()
}
func TestInvokeExecutorConvertsPanicsToErrors(t *testing.T) {
executor := newInvokeExecutor(func(invokeRequest) string {
panic("boom")
})
completed := make(chan error, 1)
executor.submit(invokeTask{complete: func(_ string, err error) {
completed <- err
}})
select {
case err := <-completed:
if err == nil || err.Error() != "invoke panic: boom" {
t.Fatalf("unexpected error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("panic result was not completed")
}
executor.close()
}
func TestInvokeExecutorSurvivesCallbackPanic(t *testing.T) {
executor := newInvokeExecutor(func(request invokeRequest) string {
return request.path
})
executor.submit(invokeTask{complete: func(string, error) {
panic("callback failed")
}})
completed := make(chan string, 1)
executor.submit(invokeTask{
request: invokeRequest{path: "next"},
complete: func(result string, _ error) {
completed <- result
},
})
select {
case result := <-completed:
if result != "next" {
t.Fatalf("unexpected result: %q", result)
}
case <-time.After(time.Second):
t.Fatal("worker stopped after callback panic")
}
executor.close()
}
func TestInvokeExecutorPausesAndDrains(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{})
executor := newInvokeExecutor(func(request invokeRequest) string {
if request.path == "running" {
close(started)
<-release
}
return request.path
})
completed := make(chan struct{})
if !executor.submit(invokeTask{
request: invokeRequest{path: "running"},
complete: func(string, error) {
close(completed)
},
}) {
t.Fatal("initial task was rejected")
}
<-started
drained := make(chan struct{})
go func() {
executor.pauseAndWait(time.Second)
close(drained)
}()
deadline := time.Now().Add(time.Second)
for {
executor.mu.Lock()
paused := !executor.accepting
executor.mu.Unlock()
if paused {
break
}
if time.Now().After(deadline) {
t.Fatal("executor did not pause")
}
runtime.Gosched()
}
select {
case <-drained:
t.Fatal("pause returned before the running request completed")
default:
}
if executor.submit(invokeTask{}) {
t.Fatal("paused executor accepted a new request")
}
close(release)
<-completed
select {
case <-drained:
case <-time.After(time.Second):
t.Fatal("pause did not return after pending requests drained")
}
executor.resume()
resumed := make(chan struct{})
if !executor.submit(invokeTask{complete: func(string, error) {
close(resumed)
}}) {
t.Fatal("resumed executor rejected a request")
}
<-resumed
executor.close()
}
func TestInvokeExecutorPauseTimesOutAndFailsPendingRequests(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{})
executor := newInvokeExecutor(func(request invokeRequest) string {
started <- struct{}{}
<-release
return request.path
})
errors := make(chan error, 2)
for _, path := range []string{"first", "second"} {
if !executor.submit(invokeTask{
request: invokeRequest{path: path},
complete: func(_ string, err error) {
errors <- err
},
}) {
t.Fatalf("request %q was rejected", path)
}
}
for range 2 {
<-started
}
const timeout = 30 * time.Millisecond
startedAt := time.Now()
if executor.pauseAndWait(timeout) {
t.Fatal("pause unexpectedly reported a complete drain")
}
if elapsed := time.Since(startedAt); elapsed < timeout || elapsed > 10*timeout {
t.Fatalf("pause returned after %s, want close to %s", elapsed, timeout)
}
for range 2 {
select {
case err := <-errors:
if err == nil || err.Error() != errNativeBridgeStopping.Error() {
t.Fatalf("unexpected timeout error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("pending request was not failed after the timeout")
}
}
close(release)
executor.close()
select {
case err := <-errors:
t.Fatalf("request callback ran more than once: %v", err)
default:
}
}
func TestStopWithinUsesOneDeadlineForRequestsAndCleanup(t *testing.T) {
releaseRequest := make(chan struct{})
requestStarted := make(chan struct{})
executor := newInvokeExecutor(func(invokeRequest) string {
close(requestStarted)
<-releaseRequest
return "done"
})
requestResult := make(chan error, 1)
executor.submit(invokeTask{complete: func(_ string, err error) {
requestResult <- err
}})
<-requestStarted
releaseCleanup := make(chan struct{})
cleanupStarted := make(chan struct{})
cleanup := func() {
close(cleanupStarted)
<-releaseCleanup
}
const timeout = 40 * time.Millisecond
startedAt := time.Now()
stopWithin(executor, cleanup, timeout)
if elapsed := time.Since(startedAt); elapsed < timeout || elapsed > 10*timeout {
t.Fatalf("stop returned after %s, want close to %s", elapsed, timeout)
}
select {
case <-cleanupStarted:
case <-time.After(time.Second):
t.Fatal("cleanup was not started after the request deadline")
}
select {
case err := <-requestResult:
if err == nil || err.Error() != errNativeBridgeStopping.Error() {
t.Fatalf("unexpected request result: %v", err)
}
case <-time.After(time.Second):
t.Fatal("timed-out request was not failed")
}
close(releaseRequest)
close(releaseCleanup)
executor.close()
}
+8 -3
View File
@@ -13,10 +13,13 @@ const (
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 {
// Dispatch executes one in-process API request. Native scheduling and callback
// behavior belong to bind/native; this function only bridges into the shared
// API Service without opening an HTTP listener.
func Dispatch(method, path, rawQuery, body string) string {
runtimeMu.RLock()
service := APIService
runtimeMu.RUnlock()
if service == nil {
return marshalInvokeResult(model.NewErrorResult("service not started"))
}
@@ -29,6 +32,8 @@ func Invoke(method, path, rawQuery, body string) string {
}
func SubscribeTaskEvents(mask uint64, listener func(payload string)) {
runtimeMu.RLock()
defer runtimeMu.RUnlock()
if APIService == nil {
return
}
+76 -4
View File
@@ -4,9 +4,12 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/rest/model"
@@ -27,7 +30,7 @@ func TestNativeInvokeWithoutRESTListener(t *testing.T) {
}
var result model.Result[json.RawMessage]
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
t.Fatal(err)
}
if result.Code != model.CodeOk {
@@ -35,6 +38,75 @@ func TestNativeInvokeWithoutRESTListener(t *testing.T) {
}
}
func TestStartAPIServerIsNotBlockedByPendingDispatch(t *testing.T) {
requestStarted := make(chan struct{})
releaseRequest := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseRequest) }) }
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
close(requestStarted)
<-releaseRequest
w.Header().Set("Content-Length", "0")
}))
t.Cleanup(func() {
release()
upstream.Close()
Stop()
})
if _, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem}); err != nil {
t.Fatal(err)
}
requestBody, err := json.Marshal(&model.ResolveTask{Req: &base.Request{URL: upstream.URL}})
if err != nil {
t.Fatal(err)
}
dispatchDone := make(chan struct{})
go func() {
defer close(dispatchDone)
Dispatch(http.MethodPost, "/api/v1/resolve", "", string(requestBody))
}()
select {
case <-requestStarted:
case <-time.After(time.Second):
t.Fatal("resolve request did not start")
}
config, err := Downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
config.API = &base.APIServerConfig{Enable: true, Network: "tcp", Address: "127.0.0.1:0"}
if err := Downloader.PutConfig(config); err != nil {
t.Fatal(err)
}
started := make(chan *model.APIServerState, 1)
startErrors := make(chan error, 1)
go func() {
state, err := StartAPIServer()
started <- state
startErrors <- err
}()
select {
case state := <-started:
if err := <-startErrors; err != nil {
t.Fatalf("start API server failed: %v", err)
}
if !state.Running || state.RunningPort == 0 {
t.Fatalf("API server did not start: %#v", state)
}
case <-time.After(time.Second):
t.Fatal("pending Resolve blocked API server startup")
}
release()
select {
case <-dispatchDone:
case <-time.After(time.Second):
t.Fatal("resolve request did not finish after release")
}
}
func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) {
port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem})
if err != nil {
@@ -106,7 +178,7 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T
}
var result model.Result[json.RawMessage]
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
t.Fatal(err)
}
if result.Code != model.CodeOk {
@@ -153,7 +225,7 @@ func TestRestartAPIServerFailsFastAndLeavesListenerStopped(t *testing.T) {
}
var result model.Result[json.RawMessage]
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
t.Fatal(err)
}
if result.Code != model.CodeOk {
@@ -229,7 +301,7 @@ func TestNativeAPIServerAutoStartFailureKeepsCoreAvailable(t *testing.T) {
}
var result model.Result[json.RawMessage]
if err := json.Unmarshal([]byte(Invoke(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
if err := json.Unmarshal([]byte(Dispatch(http.MethodGet, "/api/v1/info", "", "")), &result); err != nil {
t.Fatal(err)
}
if result.Code != model.CodeOk {
+1 -1
View File
@@ -29,7 +29,7 @@ import (
)
var (
runtimeMu sync.Mutex
runtimeMu sync.RWMutex
Downloader *download.Downloader
APIService *goapi.Service
@@ -1,6 +1,7 @@
package com.gopeed.gopeed
import com.gopeed.libgopeed.Libgopeed
import com.gopeed.libgopeed.InvokeResultListener
import com.gopeed.libgopeed.TaskEventListener
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -38,13 +39,23 @@ class MainActivity : FlutterActivity() {
"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") ?: "",
),
Libgopeed.invokeAsync(
call.argument<String>("method") ?: "",
call.argument<String>("path") ?: "",
call.argument<String>("query") ?: "",
call.argument<String>("body") ?: "",
call.argument<Number>("requestID")?.toLong() ?: 0L,
object : InvokeResultListener {
override fun onResult(requestID: Long, success: Boolean, payload: String?) {
runOnUiThread {
if (success) {
result.success(payload ?: "")
} else {
result.error("ERROR", payload ?: "InvokeAsync failed", null)
}
}
}
},
)
} catch (error: Exception) {
result.error("ERROR", error.message, null)
@@ -259,7 +259,7 @@ GetAPIServerState
StartAPIServer
StopAPIServer
RestartAPIServer
Invoke
InvokeAsync
FreeCString
SubscribeTaskEvents
```
@@ -268,9 +268,12 @@ Requirements:
- Use the cgo C-string ABI in the first phase. Reconsider pointer-and-length parameters only during a future ABI-breaking revision.
- Memory allocated by Go must be released with `FreeCString`.
- Dart-owned input memory must be released after the synchronous native call returns.
- `Invoke` returns the same Result JSON as REST; business errors do not use a separate ABI error model.
- Run blocking Desktop FFI work outside the Flutter UI isolate. Mobile MethodChannel handlers likewise run blocking Go work on a background task queue.
- `InvokeAsync` is the only native invocation entry point. Desktop and gomobile adapters both submit to the shared bounded-concurrency executor in `bind/native`.
- The shared executor uses `clamp(GOMAXPROCS * 2, 4, 32)` workers. Requests wait in FIFO order when every worker is busy; pool saturation is backpressure, not an API error.
- The Desktop adapter copies every Dart-owned input string before returning and completes through a `NativeCallable.listener` callback.
- Dart-owned input memory must be released after the non-blocking `InvokeAsync` native entry returns. Go-owned callback payloads remain valid until Dart copies them and calls `FreeCString`.
- `InvokeAsync` returns the same Result JSON as REST through its callback; business errors do not use a separate ABI error model.
- The Desktop bridge isolate owns the dynamic library and native callbacks, decodes callback JSON, and forwards sendable Dart values to the UI isolate. Mobile MethodChannel handlers run on background task queues, while invoke completion is delivered through the shared Go callback flow.
- Dart bindings are generated from `include/libgopeed.h` with `ffigen`. Every exported Desktop symbol must be added to the `ffigen.functions.include` allowlist.
- Never hand-edit `lib/core/ffi/libgopeed_bind.dart`; regenerate it with `flutter pub run ffigen` after changing the C header.
@@ -285,14 +288,14 @@ GetAPIServerState()
StartAPIServer()
StopAPIServer()
RestartAPIServer()
Invoke(method, path, query, body)
InvokeAsync(method, path, query, body, requestID, listener)
SubscribeTaskEvents(mask, listener)
```
Flutter bridge rules:
- Normal calls use MethodChannel.
- Android performs normal calls on a background task queue.
- Normal calls use MethodChannel, while `InvokeAsync` completes the pending MethodChannel result through a gomobile listener.
- Android and iOS run the Libgopeed MethodChannel handler on a serial background task queue; callback adapters marshal completion back to the platform UI thread.
- Events reuse the same MethodChannel through the reverse `taskEvent` message.
- Kotlin and Swift contain no business logic. They only forward arguments, dispatch callbacks to the UI thread, and translate errors.
@@ -589,8 +592,8 @@ Flutter converts all of them into `GopeedException`. Components do not handle Di
### 11.3 Platform smoke tests
- Windows, macOS, Linux: startup, Invoke, events, shutdown, and repeated startup.
- Android, iOS: gomobile Invoke, reverse MethodChannel events, and background/foreground transitions.
- Windows, macOS, Linux: startup, InvokeAsync, events, shutdown, and repeated startup.
- Android, iOS: gomobile InvokeAsync callbacks, reverse MethodChannel events, and background/foreground transitions.
- Web: REST, login, proxy, and task-file downloads.
- Native: enabling and disabling REST does not interrupt download tasks.
@@ -605,7 +608,7 @@ Flutter converts all of them into `GopeedException`. Components do not handle Di
### Phase 2: Native in-process calls — implemented on this branch
- Integrate the FFI Transport on Desktop.
- Extend gomobile Invoke and MethodChannel on Mobile.
- Extend gomobile InvokeAsync callbacks and MethodChannel on Mobile.
- Move Flutter API calls to the unified Transport so each typed method is maintained once.
- Do not create a socket listener by default on native clients.
+33 -7
View File
@@ -12,6 +12,8 @@
#ifndef GO_CGO_GOSTRING_TYPEDEF
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
extern size_t _GoStringLen(_GoString_ s);
extern const char *_GoStringPtr(_GoString_ s);
#endif
#endif
@@ -19,6 +21,24 @@ typedef struct { const char *p; ptrdiff_t n; } _GoString_;
/* Start of preamble from import "C" comments. */
#line 3 "main.go"
#include <stdlib.h>
#include <stdint.h>
typedef void (*TaskEventCallback)(char* payload);
typedef void (*InvokeResultCallback)(uint64_t request_id, int success, char* payload);
static void callTaskEventCallback(uintptr_t callback, char* payload) {
((TaskEventCallback)callback)(payload);
}
static void callInvokeResultCallback(uintptr_t callback, uint64_t request_id, int success, char* payload) {
((InvokeResultCallback)callback)(request_id, success, payload);
}
#line 1 "cgo-generated-wrapper"
/* End of preamble from import "C" comments. */
@@ -44,10 +64,16 @@ typedef size_t GoUintptr;
typedef float GoFloat32;
typedef double GoFloat64;
#ifdef _MSC_VER
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
#include <complex.h>
typedef _Fcomplex GoComplex64;
typedef _Dcomplex GoComplex128;
#else
#include <complex>
typedef std::complex<float> GoComplex64;
typedef std::complex<double> GoComplex128;
#endif
#else
typedef float _Complex GoComplex64;
typedef double _Complex GoComplex128;
#endif
@@ -81,13 +107,13 @@ struct Start_return {
char* r1;
};
extern struct Start_return Start(char* cfg);
extern void Stop();
extern char* GetAPIServerState();
extern char* StartAPIServer();
extern char* StopAPIServer();
extern char* RestartAPIServer();
extern char* Invoke(char* method, char* path, char* query, char* body);
extern void SubscribeTaskEvents(GoUint64 mask, GoUintptr callback);
extern void Stop(void);
extern char* GetAPIServerState(void);
extern char* StartAPIServer(void);
extern char* StopAPIServer(void);
extern char* RestartAPIServer(void);
extern void InvokeAsync(char* method, char* path, char* query, char* body, long long unsigned int requestID, uintptr_t callback);
extern void SubscribeTaskEvents(long long unsigned int mask, uintptr_t callback);
extern void FreeCString(char* value);
#ifdef __cplusplus
+1 -1
View File
@@ -1,4 +1,4 @@
platform :ios, '13.0'
platform :ios, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -693,7 +693,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -823,7 +823,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -874,7 +874,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+6 -2
View File
@@ -14,10 +14,13 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let messenger = engineBridge.applicationRegistrar.messenger()
let libgopeedTaskQueue = messenger.makeBackgroundTaskQueue?()
let libgopeedChannel = FlutterMethodChannel(
name: "gopeed.com/libgopeed",
binaryMessenger: messenger
binaryMessenger: messenger,
codec: FlutterStandardMethodCodec.sharedInstance(),
taskQueue: libgopeedTaskQueue
)
let taskEventForwarder = GopeedTaskEventForwarder(channel: libgopeedChannel)
libgopeedChannel.setMethodCallHandler { call, result in
@@ -56,7 +59,8 @@ import UIKit
let path = arguments?["path"] as? String ?? ""
let query = arguments?["query"] as? String ?? ""
let body = arguments?["body"] as? String ?? ""
result(LibgopeedInvoke(method, path, query, body))
let requestID = (arguments?["requestID"] as? NSNumber)?.int64Value ?? 0
GopeedInvokeAsyncWithResult(method, path, query, body, requestID, result)
case "subscribeTaskEvents":
let arguments = call.arguments as? [String: Any]
let mask = (arguments?["mask"] as? NSNumber)?.int64Value ?? 0
@@ -13,4 +13,12 @@ FOUNDATION_EXPORT void GopeedSubscribeTaskEventsWithForwarder(
int64_t mask,
GopeedTaskEventForwarder * _Nullable listener);
FOUNDATION_EXPORT void GopeedInvokeAsyncWithResult(
NSString *method,
NSString *path,
NSString *query,
NSString *body,
int64_t requestID,
FlutterResult result);
NS_ASSUME_NONNULL_END
@@ -8,6 +8,45 @@
@end
@interface GopeedInvokeResultForwarder : NSObject <LibgopeedInvokeResultListener>
@property(nonatomic, copy, nullable) FlutterResult result;
- (instancetype)initWithResult:(FlutterResult)result;
@end
@implementation GopeedInvokeResultForwarder
- (instancetype)initWithResult:(FlutterResult)result {
self = [super init];
if (self) {
_result = [result copy];
}
return self;
}
- (void)onResult:(int64_t)requestID
success:(BOOL)success
payload:(NSString * _Nullable)payload {
FlutterResult result = self.result;
self.result = nil;
if (result == nil) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (success) {
result(payload ?: @"");
} else {
result([FlutterError errorWithCode:@"ERROR"
message:payload ?: @"InvokeAsync failed"
details:nil]);
}
});
}
@end
@implementation GopeedTaskEventForwarder
- (instancetype)initWithChannel:(FlutterMethodChannel *)channel {
@@ -33,3 +72,15 @@ void GopeedSubscribeTaskEventsWithForwarder(
GopeedTaskEventForwarder * _Nullable listener) {
LibgopeedSubscribeTaskEvents(mask, listener);
}
void GopeedInvokeAsyncWithResult(
NSString *method,
NSString *path,
NSString *query,
NSString *body,
int64_t requestID,
FlutterResult result) {
GopeedInvokeResultForwarder *forwarder =
[[GopeedInvokeResultForwarder alloc] initWithResult:result];
LibgopeedInvokeAsync(method, path, query, body, requestID, forwarder);
}
@@ -11,6 +11,7 @@ import 'task_event.dart';
class LibgopeedChannel implements LibgopeedInterface {
static const _channel = MethodChannel('gopeed.com/libgopeed');
final _taskEvents = StreamController<TaskEvent>.broadcast();
int _requestID = 0;
LibgopeedChannel() {
_channel.setMethodCallHandler((call) async {
@@ -50,7 +51,9 @@ class LibgopeedChannel implements LibgopeedInterface {
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
final requestID = _requestID++;
final result = await _channel.invokeMethod<String>('invoke', {
'requestID': requestID,
'method': method,
'path': path,
'query': query,
+10 -64
View File
@@ -1,10 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import '../ffi/libgopeed_bind.dart';
import '../ffi/libgopeed_worker.dart';
import 'api_server_state.dart';
import 'libgopeed_interface.dart';
@@ -12,51 +8,22 @@ import 'start_config.dart';
import 'task_event.dart';
class LibgopeedFFi implements LibgopeedInterface {
late LibgopeedBind _libgopeed;
LibgopeedFFi() {
_worker.taskEvents.listen(_onTaskEvent, onError: _taskEvents.addError);
}
final _taskEvents = StreamController<TaskEvent>.broadcast();
final _worker = LibgopeedWorker();
late final NativeCallable<Void Function(Pointer<Char>)> _taskEventCallback;
LibgopeedFFi(LibgopeedBind 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);
}
void _onTaskEvent(Map<String, dynamic> payload) {
_taskEvents.add(TaskEvent.fromJson(payload));
}
@override
Future<int> start(StartConfig cfg) {
var completer = Completer<int>();
final cfgPtr = jsonEncode(cfg).toNativeUtf8();
try {
final result = _libgopeed.Start(cfgPtr.cast());
if (result.r1 != nullptr) {
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;
}
Future<int> start(StartConfig cfg) => _worker.start(jsonEncode(cfg));
@override
Future<void> stop() {
var completer = Completer<void>();
_libgopeed.Stop();
completer.complete();
return completer.future;
}
Future<void> stop() => _worker.stop();
Future<ApiServerOperationResult> _apiServerOperation(String operation) async {
final payload = await _worker.apiServer(operation);
@@ -77,33 +44,12 @@ class LibgopeedFFi implements LibgopeedInterface {
@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);
}
}
return jsonEncode(await _worker.invoke(method, path, query, body));
}
@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);
}
Future<void> subscribeTaskEvents(Set<TaskEventType> events) => _worker.subscribeTaskEvents(events.mask);
}
@@ -1,6 +1,4 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import '../../util/util.dart';
import '../common/libgopeed_channel.dart';
@@ -9,7 +7,6 @@ import '../common/libgopeed_interface.dart';
import '../common/api_server_state.dart';
import '../common/start_config.dart';
import '../common/task_event.dart';
import '../ffi/libgopeed_bind.dart';
import '../libgopeed_boot.dart';
LibgopeedBoot create() => LibgopeedBootNative();
@@ -19,17 +16,7 @@ class LibgopeedBootNative implements LibgopeedBoot {
LibgopeedBootNative() {
if (Util.isDesktop()) {
var libName = "libgopeed.";
if (Platform.isWindows) {
libName += "dll";
}
if (Platform.isMacOS) {
libName += "dylib";
}
if (Platform.isLinux) {
libName += "so";
}
_libgopeed = LibgopeedFFi(LibgopeedBind(DynamicLibrary.open(libName)));
_libgopeed = LibgopeedFFi();
} else {
_libgopeed = LibgopeedChannel();
}
+28 -15
View File
@@ -30,53 +30,67 @@ class LibgopeedBind {
late final _StopPtr = _lookup<ffi.NativeFunction<ffi.Void Function()>>('Stop');
late final _Stop = _StopPtr.asFunction<void Function()>();
ffi.Pointer<ffi.Char> GetAPIServerState() => _GetAPIServerState();
ffi.Pointer<ffi.Char> GetAPIServerState() {
return _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();
ffi.Pointer<ffi.Char> StartAPIServer() {
return _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();
ffi.Pointer<ffi.Char> StopAPIServer() {
return _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();
ffi.Pointer<ffi.Char> RestartAPIServer() {
return _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(
void InvokeAsync(
ffi.Pointer<ffi.Char> method,
ffi.Pointer<ffi.Char> path,
ffi.Pointer<ffi.Char> query,
ffi.Pointer<ffi.Char> body,
int requestID,
int callback,
) {
return _Invoke(method, path, query, body);
return _InvokeAsync(method, path, query, body, requestID, callback);
}
late final _InvokePtr =
late final _InvokeAsyncPtr =
_lookup<
ffi.NativeFunction<
ffi.Pointer<ffi.Char> Function(
ffi.Void Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.UnsignedLongLong,
ffi.UintPtr,
)
>
>('Invoke');
late final _Invoke =
_InvokePtr.asFunction<
ffi.Pointer<ffi.Char> Function(
>('InvokeAsync');
late final _InvokeAsync =
_InvokeAsyncPtr.asFunction<
void Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
int,
int,
)
>();
@@ -84,9 +98,8 @@ class LibgopeedBind {
return _SubscribeTaskEvents(mask, callback);
}
late final _SubscribeTaskEventsPtr = _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Uint64, ffi.UintPtr)>>(
'SubscribeTaskEvents',
);
late final _SubscribeTaskEventsPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.UnsignedLongLong, ffi.UintPtr)>>('SubscribeTaskEvents');
late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction<void Function(int, int)>();
void FreeCString(ffi.Pointer<ffi.Char> value) {
+161 -37
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
@@ -7,38 +8,74 @@ import 'package:ffi/ffi.dart';
import 'libgopeed_bind.dart';
typedef _InvokeResultNative = Void Function(Uint64 requestID, Int32 success, Pointer<Char> payload);
typedef _TaskEventNative = Void Function(Pointer<Char> payload);
class LibgopeedWorker {
LibgopeedWorker() {
factory LibgopeedWorker() => _instance;
LibgopeedWorker._() {
_receivePort.listen(_handleWorkerMessage);
_errorPort.listen(_handleWorkerError);
_isolateReady = Isolate.spawn(_workerMain, _receivePort.sendPort, onError: _errorPort.sendPort);
}
static final LibgopeedWorker _instance = LibgopeedWorker._();
final ReceivePort _receivePort = ReceivePort();
final ReceivePort _errorPort = ReceivePort();
final Map<int, Completer<String>> _pending = {};
final Map<int, Completer<Object?>> _pending = {};
final StreamController<Map<String, dynamic>> _taskEvents = StreamController<Map<String, dynamic>>.broadcast();
late final Future<Isolate> _isolateReady;
final Completer<SendPort> _sendPort = Completer<SendPort>();
Future<void>? _stopFuture;
int _requestID = 0;
bool _stopping = false;
bool _stopped = false;
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;
Stream<Map<String, dynamic>> get taskEvents => _taskEvents.stream;
Future<int> start(String config) => _request<int>('start', [config]);
Future<void> stop() => _stopFuture ??= _stop();
Future<void> _stop() async {
_stopping = true;
// The native bridge owns the bounded drain. Sending Stop immediately is
// important because an InvokeAsync handler such as Resolve may never return.
await _request<Object?>('stop', const [], allowWhileStopping: true);
_stopped = true;
final stoppingError = StateError('libgopeed worker stopped before the request completed');
for (final completer in _pending.values) {
completer.completeError(stoppingError);
}
_pending.clear();
await _taskEvents.close();
_receivePort.close();
_errorPort.close();
}
Future<String> apiServer(String operation) async {
Future<Object?> invoke(String method, String path, String query, String body) {
return _request<Object?>('invoke', [method, path, query, body]);
}
Future<String> apiServer(String operation) => _request<String>('apiServer', [operation]);
Future<void> subscribeTaskEvents(int mask) async {
await _request<Object?>('subscribeTaskEvents', [mask]);
}
Future<T> _request<T>(String type, List<Object?> arguments, {bool allowWhileStopping = false}) async {
if (_stopped || (_stopping && !allowWhileStopping)) {
throw StateError('libgopeed worker is stopping');
}
await _isolateReady;
final sendPort = await _sendPort.future;
final requestID = _requestID++;
final completer = Completer<String>();
final completer = Completer<Object?>();
_pending[requestID] = completer;
sendPort.send(['apiServer', requestID, operation]);
return completer.future;
sendPort.send([type, requestID, ...arguments]);
return (await completer.future) as T;
}
void _handleWorkerMessage(dynamic message) {
@@ -47,13 +84,18 @@ class LibgopeedWorker {
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));
switch (response[0] as String) {
case 'response':
final requestID = response[1] as int;
final completer = _pending.remove(requestID);
if (completer == null) return;
if (response[2] as bool) {
completer.complete(response[3]);
} else {
completer.completeError(StateError(response[3] as String));
}
case 'taskEvent':
_taskEvents.add((response[1] as Map<Object?, Object?>).cast<String, dynamic>());
}
}
@@ -65,35 +107,117 @@ class LibgopeedWorker {
completer.completeError(error);
}
_pending.clear();
_taskEvents.addError(error);
}
static void _workerMain(SendPort mainSendPort) {
final requests = ReceivePort();
final bindings = LibgopeedBind(DynamicLibrary.open(_libraryName()));
late final NativeCallable<_InvokeResultNative> invokeResultCallback;
late final NativeCallable<_TaskEventNative> taskEventCallback;
invokeResultCallback = NativeCallable<_InvokeResultNative>.listener((
int requestID,
int success,
Pointer<Char> payload,
) {
if (payload == nullptr) {
mainSendPort.send(['response', requestID, false, 'Gopeed InvokeAsync returned a null response']);
return;
}
try {
final result = payload.cast<Utf8>().toDartString();
mainSendPort.send(['response', requestID, success != 0, success != 0 ? jsonDecode(result) : result]);
} catch (error) {
mainSendPort.send(['response', requestID, false, error.toString()]);
} finally {
bindings.FreeCString(payload);
}
});
taskEventCallback = NativeCallable<_TaskEventNative>.listener((Pointer<Char> payload) {
if (payload == nullptr) return;
try {
final event = jsonDecode(payload.cast<Utf8>().toDartString()) as Map<String, dynamic>;
mainSendPort.send(['taskEvent', event]);
} finally {
bindings.FreeCString(payload);
}
});
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(
if (type == 'invoke') {
try {
_invokeAsync(
bindings,
invokeResultCallback.nativeFunction.address,
requestID,
request[2] as String,
request[3] as String,
request[4] as String,
request[5] as String,
),
);
} catch (error) {
mainSendPort.send(['response', requestID, false, error.toString()]);
}
return;
}
try {
final response = switch (type) {
'start' => _start(bindings, request[2] as String),
'stop' => _stopNative(bindings),
'apiServer' => _apiServer(bindings, request[2] as String),
'subscribeTaskEvents' => _subscribeTaskEvents(
bindings,
taskEventCallback.nativeFunction.address,
request[2] as int,
),
_ => throw StateError('Unknown libgopeed worker request: $type'),
};
mainSendPort.send([requestID, true, response]);
mainSendPort.send(['response', requestID, true, response]);
if (type == 'stop') {
invokeResultCallback.close();
taskEventCallback.close();
requests.close();
}
} catch (error) {
mainSendPort.send([requestID, false, error.toString()]);
mainSendPort.send(['response', requestID, false, error.toString()]);
}
});
}
static int _start(LibgopeedBind bindings, String config) {
final configPtr = config.toNativeUtf8();
try {
final result = bindings.Start(configPtr.cast());
if (result.r1 != nullptr) {
try {
throw StateError(result.r1.cast<Utf8>().toDartString());
} finally {
bindings.FreeCString(result.r1);
}
}
return result.r0;
} finally {
malloc.free(configPtr);
}
}
static Object? _stopNative(LibgopeedBind bindings) {
bindings.Stop();
return null;
}
static Object? _subscribeTaskEvents(LibgopeedBind bindings, int callback, int mask) {
bindings.SubscribeTaskEvents(mask, mask == 0 ? 0 : callback);
return null;
}
static String _apiServer(LibgopeedBind bindings, String operation) {
final resultPtr = switch (operation) {
'get' => bindings.GetAPIServerState(),
@@ -112,26 +236,26 @@ class LibgopeedWorker {
}
}
static String _invoke(LibgopeedBind bindings, String method, String path, String query, String body) {
static void _invokeAsync(
LibgopeedBind bindings,
int callback,
int requestID,
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();
bindings.InvokeAsync(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast(), requestID, callback);
} finally {
malloc.free(methodPtr);
malloc.free(pathPtr);
malloc.free(queryPtr);
malloc.free(bodyPtr);
if (resultPtr != null && resultPtr != nullptr) {
bindings.FreeCString(resultPtr);
}
}
}
@@ -2,9 +2,9 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../libgopeed_boot.dart';
import '../../../ffi/libgopeed_worker.dart';
import '../../../../util/util.dart';
import '../../../ffi/libgopeed_worker.dart';
import '../../../libgopeed_boot.dart';
import '../gopeed_transport.dart';
GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport();
@@ -32,14 +32,16 @@ class NativeGopeedTransport implements GopeedTransport {
}) 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,
);
final worker = _ffiWorker;
if (worker != null) {
return worker.invoke(method.toUpperCase(), request.path, request.query, requestBody);
}
final payload = await LibgopeedBoot.instance.invoke(
method.toUpperCase(),
request.path,
query: request.query,
body: requestBody,
);
return jsonDecode(payload);
}
@@ -210,7 +210,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
Future<void> _installFromFolder() async {
final folder = await FilePicker.platform.getDirectoryPath();
final folder = await FilePicker.getDirectoryPath();
if (folder == null || folder.isEmpty) return;
await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(folder, devInstall: true));
}
@@ -1378,8 +1378,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
initialValue: index == null ? '' : paths[index],
pickPath: Util.isDesktop()
? () async {
final result = await FilePicker.platform.pickFiles();
return result?.files.firstOrNull?.path;
final file = await FilePicker.pickFile();
return file?.path;
}
: null,
);
@@ -95,11 +95,21 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
super.initState();
_urlController.addListener(_handleUrlChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider);
_applyInitialTask(task);
if (widget.initialTask == null) {
ref.read(pendingCreateTaskProvider.notifier).clear();
}
if (widget.windowController == null && widget.initialTask == null) {
// Navigating to /create again reuses this page, so consume subsequent
// browser/share requests as well as the initial pending task.
ref.listenManual(pendingCreateTaskProvider, (_, next) {
if (next == null) return;
ref.read(pendingCreateTaskProvider.notifier).clear();
_applyInitialTask(next);
});
}
if (task == null || _urlController.text.trim().isEmpty) {
unawaited(_loadClipboardUrl());
}
@@ -181,6 +191,13 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final opts = task.opts;
setState(() {
if (req?.url.isNotEmpty == true) {
_fileDataUri = '';
_renameController.clear();
_httpMethod = 'GET';
_httpBody = '';
_replaceHttpHeaders(const {'User-Agent': '', 'Cookie': '', 'Referer': ''});
_trackersController.clear();
_protocolTab = 0;
_urlController.text = req!.url;
_initialRawUrl = req.rawUrl;
_initialLabels = req.labels == null ? null : Map<String, String>.of(req.labels!);
@@ -754,15 +771,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
Future<void> _pickTorrentFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: const ['torrent'],
withData: kIsWeb,
);
final file = result?.files.single;
final file = await FilePicker.pickFile(type: FileType.custom, allowedExtensions: const ['torrent']);
if (file == null) return;
await _applyTorrentFile(name: file.name, filePath: file.path, bytes: file.bytes);
await _applyTorrentFile(name: file.name, filePath: file.path, readBytes: file.readAsBytes);
}
Future<void> _handleDroppedTorrent(DropDoneDetails details) async {
@@ -109,7 +109,7 @@ class _TaskFileManagerViewState extends State<TaskFileManagerView> {
try {
final renderBox = actionContext.findRenderObject();
final origin = renderBox is RenderBox ? renderBox.localToGlobal(Offset.zero) & renderBox.size : null;
await Share.shareXFiles([XFile(_localPath(entry))], sharePositionOrigin: origin);
await SharePlus.instance.share(ShareParams(files: [XFile(_localPath(entry))], sharePositionOrigin: origin));
} catch (error) {
if (mounted) showAppToast(context, error.toString(), type: AppToastType.error);
}
+629
View File
@@ -0,0 +1,629 @@
{
"@@locale": "az",
"label": "Azərbaycan dili",
"error": "Xəta",
"tip": "Məsləhət",
"confirm": "Təsdiqlə",
"confirmDelete": "Silmək istədiyinizə əminsiniz?",
"cancel": "Ləğv et",
"on": "Açıq",
"off": "Qapalı",
"selectAll": "Hamısını seç",
"select": "Seç",
"task": "Tapşırıqlar",
"downloading": "Endirilir",
"downloaded": "Endirilib",
"setting": "Parametrlər",
"donate": "Bağış et",
"exit": "Çıxış",
"pressBackAgainToExit": "Çıxmaq üçün yenidən geri düyməsinə basın",
"create": "Tapşırıq yarat",
"directDownload": "Birbaşa Endirmə",
"advancedOptions": "Əlavə Seçimlər",
"followSettings": "Parametrlərə uyğun",
"downloadLink": "Endirmə Keçidi",
"downloadLinkValid": "Zəhmət olmasa endirmə keçidini daxil edin",
"downloadLinkHit": "Zəhmət olmasa endirmə keçidini daxil edin, hər sətirdə bir dənə@append",
"downloadLinkHitDesktop": ", və ya torrent faylını birbaşa buraya sürükləyin",
"download": "Endir",
"noFileSelected": "Davam etmək üçün ən azı bir fayl seçin",
"noStoragePermission": "Yaddaş icazəsi tələb olunur",
"selectFile": "Fayl Seç",
"rename": "Adını dəyiş",
"basic": "Əsas",
"advanced": "Əlavə",
"general": "Ümumi",
"downloadDir": "Endirmə Qovluğu",
"rememberLastDownloadDir": "Standart endirmə qovluğu kimi yadda saxla",
"downloadDirValid": "Zəhmət olmasa endirmə qovluğunu seçin",
"connections": "Əlaqələr",
"useServerCtime": "Fayl yaratma tarixi üçün server vaxtından istifadə et",
"maxRunning": "Paralel Endirmələr",
"defaultDirectDownload": "Standart olaraq Birbaşa Endirmə",
"autoStartTasks": "Başlanğıcda tamamlanmamış tapşırıqları avtomatik başlat",
"autoTorrentEnable": ".torrent fayllarından avtomatik BT tapşırığı yarat",
"autoTorrentDeleteAfterDownload": "BT tapşırığı yaradıldıqdan sonra .torrent faylını sil",
"autoDeleteMissingFileTasks": "Faylı tapılmayan tapşırıqları avtomatik sil",
"items": "{count} element",
"@items": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"subscribeTracker": "Trackerə Abunə Ol",
"subscribeFail": "Abunəlik alınmadı, şəbəkəni yoxlayın və ya sonra yenidən cəhd edin",
"update": "Yenilə",
"updateDaily": "Gündəlik yenilə",
"lastUpdate": "Son yenilənmə: @time",
"addTracker": "Tracker Əlavə Et",
"addTrackerHit": "Zəhmət olmasa tracker server ünvanını daxil edin, hər sətirdə bir dənə",
"ui": "İnterfeys",
"theme": "Tema",
"themeSystem": "Sistem",
"themeLight": "Açıq",
"themeDark": "Tünd",
"locale": "Dil",
"notifyWhenNewVersion": "Yeniləmələr barədə bildiriş göndər",
"analyticsEnabled": "İstifadə Analitikası",
"analyticsEnabledDesc": "Təkmilləşdirməyə kömək etmək üçün anonim istifadə məlumatlarını paylaşın",
"about": "Haqqında",
"homepage": "Əsas Səhifə",
"version": "Versiya",
"protocol": "Protokol",
"port": "Port",
"apiToken": "API Açarı",
"notSet": "TƏYİN EDİLMƏYİB",
"set": "TƏYİN ET",
"portInUse": "Port [@port] artıq istifadədədir, zəhmət olmasa portu dəyişin",
"effectAfterRestart": "Yenidən başladıldıqdan sonra qüvvəyə minir",
"developer": "Tərtibatçı",
"logDirectory": "Loq Qovluğu",
"webhook": "Webhook",
"webhookEnable": "Webhook-ları Aktivləşdir",
"webhookDesc": "Tapşırıqlar tamamlandıqda və ya uğursuz olduqda HTTP POST bildirişləri göndər",
"webhookUrlHint": "Webhook URL-ini daxil edin",
"webhookTest": "Test",
"webhookTestSuccess": "Webhook testi uğurlu oldu",
"webhookTestFail": "Webhook testi uğursuz oldu",
"script": "Skript",
"scriptEnable": "Skriptləri Aktivləşdir",
"scriptDesc": "Endirmələr uğurla tamamlandıqda fərdi skriptləri icra et",
"scriptPathHint": "Skript faylının yolunu daxil edin (məs., /path/to/script.sh)",
"urlInvalid": "Zəhmət olmasa düzgün HTTP və ya HTTPS URL-i daxil edin",
"required": "Bu sahə mütləqdir",
"show": "Göstər",
"continueAction": "Davam Et",
"pause": "Fasilə ver",
"startAll": "Hamısını Başlat",
"pauseAll": "Hamısına Fasilə Ver",
"deleteTask": "@count tapşırığı sil",
"deleteTaskTip": "Endirilmiş faylları saxla",
"delete": "Sil",
"add": "Əlavə et",
"edit": "Redaktə et",
"newVersionTitle": "Yeni versiya @version tapıldı",
"newVersionUpdate": "İndi Yenilə",
"newVersionLater": "Sonra",
"extensions": "Uzantılar",
"extensionInstallUrl": "Quraşdırma URL-i",
"extensionInstallSuccess": "Uğurla quraşdırıldı",
"extensionUpdateSuccess": "Uğurla yeniləndi",
"extensionDelete": "Uzantını Sil",
"extensionAlreadyLatest": "Artıq son versiyadır",
"extensionFind": "Uzantı Tap",
"extensionDevelop": "Uzantı Hazırla",
"extensionStoreTitle": "Uzantı Mağazası",
"extensionStoreSection": "Mağaza Uzantıları",
"extensionInstalledSection": "Quraşdırılmış Uzantılar",
"extensionInstalledEmpty": "Hələ heç bir uzantı quraşdırılmayıb",
"extensionStoreEmpty": "Uyğun uzantı tapılmadı",
"extensionSearchHint": "Ada, başlığa, təsvirə və ya müəllifə görə axtar",
"extensionSortStars": "Ən Çox Ulduzlu",
"extensionSortInstalls": "Ən Çox Quraşdırılan",
"extensionSortUpdated": "Son Yenilənən",
"extensionSortToggle": "Sıralama istiqamətini dəyiş",
"extensionLoadMore": "Daha çox yüklə",
"extensionNoMore": "Başqa uzantı yoxdur",
"extensionInstalled": "Quraşdırılıb",
"extensionCanUpdate": "Yeniləmə Mövcuddur",
"extensionInstall": "Quraşdır",
"extensionLoadLocal": "Lokal Qovluqdan Quraşdır",
"extensionFilterAll": "Hamısı",
"extensionFilterMarket": "Mağaza",
"extensionFilterInstalled": "Quraşdırılıb",
"extensionManualInstall": "Əl ilə Quraşdırma",
"extensionInstallTools": "Quraşdırma Seçimləri",
"history": "Tarixçə",
"clearHistory": "Tarixçəni Təmizlə",
"noHistoryFound": "Tarixçə Tapılmadı",
"serviceTitle": "Endirmə Xidməti",
"serviceText": "İşləyir",
"network": "Şəbəkə",
"proxy": "Proksi",
"noProxy": "Proksi Yoxdur",
"systemProxy": "Sistem Proksisi",
"customProxy": "Fərdi Proksi",
"server": "Server",
"username": "İstifadəçi adı",
"password": "Parol",
"thanks": "Təşəkkürlər",
"thanksDesc": "Gopeed icmasının qurulmasına və inkişafına kömək edən bütün töhfəçilərə təşəkkürlər!",
"browserExtension": "Brauzer Uzantısı",
"launchAtStartup": "Başlanğıcda İşə Sal",
"runAsMenubarApp": "Menyu Çubuğu Tətbiqi kimi İşlə",
"runAsMenubarAppDesc": "Dock ikonasını gizlət və yalnız menyu çubuğunda işlə",
"seedConfig": "Seed Parametrləri",
"seedKeep": "Əl ilə dayandırılana qədər seed etməyə davam et",
"seedRatio": "Seed nisbəti",
"seedTime": "Seed müddəti (dəqiqə)",
"setAsDefaultBtClient": "Standart BT müştərisi kimi təyin et",
"taskDetail": "Tapşırıq Detalları",
"taskName": "Tapşırığın Adı",
"taskUrl": "Tapşırığın URL-i",
"downloadPath": "Endirmə Yolu",
"skipVerifyCert": "Sertifikat Yoxlamasını Ötür",
"archives": "Arxivlər",
"autoExtract": "Arxivləri Avtomatik Çıxar",
"archivePassword": "Arxiv Parolu",
"archivePasswordHint": "Parolla qorunmayıbsa boş buraxın",
"deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil",
"extracting": "Çıxarılır",
"extractDone": "Çıxarma tamamlandı",
"extractError": "Çıxarma uğursuz oldu",
"waitingParts": "Parçalar gözlənilir",
"name": "Ad",
"size": "Ölçü",
"unknown": "Naməlum",
"fileSelectedCount": "Fayllar: ",
"fileSelectedSize": "Ölçü: ",
"httpHeader": "Başlıq",
"httpHeaderName": "Başlığın Adı",
"httpHeaderValue": "Başlığın Dəyəri",
"login": "Giriş",
"username_required": "Zəhmət olmasa istifadəçi adınızı daxil edin",
"password_required": "Zəhmət olmasa parolunuzu daxil edin",
"login_success": "Giriş uğurlu oldu",
"login_failed": "Giriş uğursuz oldu, istifadəçi adınızı və parolunuzu yoxlayın",
"login_failed_network": "Giriş uğursuz oldu, şəbəkə əlaqənizi yoxlayın",
"insertPlaceholder": "Yer Tutucu Əlavə Et",
"placeholderYear": "Cari il",
"placeholderMonth": "Cari ay (01-12)",
"placeholderDay": "Cari gün (01-31)",
"placeholderDate": "Tam tarix (YYYY-MM-DD)",
"example": "məs. @value",
"downloadCategories": "Endirmə Kateqoriyaları",
"categoryMusic": "Musiqi",
"categoryVideo": "Video",
"categoryDocument": "Sənəd",
"categoryProgram": "Proqram",
"categoryName": "Kateqoriyanın Adı",
"categoryPath": "Kateqoriyanın Yolu",
"builtInCategory": "Daxili kateqoriya silinə bilməz",
"selectCategory": "Kateqoriya Seç",
"quickDirectories": "Sürətli qovluqlar",
"githubMirror": "GitHub Güzgüsü",
"githubMirrorEnable": "GitHub Güzgüsünü Aktivləşdir",
"githubMirrorDesc": "GitHub məzmununun endirilməsini sürətləndirmək üçün güzgülərdən istifadə et",
"githubMirrorType": "Güzgü Növü",
"githubMirrorUrl": "Güzgü URL-i",
"githubMirrorUrlHint": "Güzgü URL-ini daxil edin",
"updateUrl": "Yeniləmə URL-i",
"updateUrlManual": "Əl ilə Yeniləmə",
"updateUrlListen": "Yeniləməni Dinlə",
"updateUrlListeningTip": "URL yeniləməsi gözlənilir",
"updateUrlCancelListen": "Dinləməni Ləğv Et",
"updateUrlDialogHint": "Yeni endirmə URL-ini daxil edin",
"pendingUpdateFound": "Gözləyən Yeniləmə Tapşırığı Tapıldı",
"pendingUpdateConfirm": "\"{name}\" tapşırığı URL yeniləməsini gözləyir. Onu yeni URL ilə yeniləmək istəyirsiniz?",
"@pendingUpdateConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pendingUpdateYes": "Tapşırığı Yenilə",
"pendingUpdateNo": "Yeni Tapşırıq Yarat",
"ed2k": "ED2K",
"ed2kTcpPort": "TCP Dinləmə Portu",
"ed2kUdpPort": "UDP Dinləmə Portu",
"ed2kServerList": "Server Ünvanları",
"ed2kServerMet": "Server.met Mənbələri",
"ed2kNodesDat": "nodes.dat Mənbələri",
"ed2kAutoPort": "Avtomatik təyin et",
"ed2kOnePerLine": "Hər sətirdə bir dənə",
"ed2kServersHint": "host:port, hər sətirdə bir dənə",
"ed2kServerMetHint": "Server.met URL-i və ya ed2k server siyahısı keçidi, hər sətirdə bir dənə",
"ed2kNodesDatHint": "nodes.dat yolu və ya URL-i, hər sətirdə bir dənə",
"desktopNotification": "Masaüstü Bildirişləri",
"backgroundLocationKeepAlive": "Fon Endirmələri",
"notificationTaskDone": "Tapşırıq tamamlandı",
"notificationTaskError": "Tapşırıq xətası",
"appTitle": "Gopeed Endirmə Meneceri",
"unsupportedWindowPayload": "Dəstəklənməyən pəncərə sorğusu",
"runtimeInitializationFailed": "Gopeed runtime-ını işə salmaq mümkün olmadı.\n{error}",
"save": "Yadda saxla",
"retry": "Yenidən cəhd et",
"close": "Bağla",
"back": "Geri",
"open": "Aç",
"openFile": "Faylı Aç",
"share": "Paylaş",
"copy": "Kopyala",
"copied": "Kopyalandı",
"refresh": "Yenilə",
"status": "Vəziyyət",
"createdAt": "Yaradılıb",
"speed": "Sürət",
"uploadSpeed": "Yükləmə sürəti",
"remaining": "Qalıb",
"downloadDuration": "Endirmə müddəti",
"progress": "İrəliləyiş",
"failed": "Uğursuz",
"completed": "Tamamlandı",
"waiting": "Gözləyir",
"taskFailed": "Tapşırıq uğursuz oldu",
"unknownSize": "Naməlum ölçü",
"secondsRemaining": "{count} saniyə qalır",
"minutesRemaining": "{count} dəqiqə qalır",
"hoursRemaining": "{count} saat qalır",
"secondsDuration": "{count} saniyə",
"minutesDuration": "{count} dəqiqə",
"hoursDuration": "{count} saat",
"followSystem": "Sistem Standartı",
"basicSettings": "Əsas",
"downloadSettings": "Endirmələr",
"advancedSettings": "Əlavə",
"themeColor": "Vurğu Rəngi",
"themeColorDescription": "Tətbiqin vurğu rəngini fərdiləşdirin",
"browserExtensionDescription": "Quraşdırdıqdan sonra brauzer endirmələrini birbaşa Gopeed-ə göndər",
"desktopNotificationDescription": "Tapşırıq tamamlandıqda və ya uğursuz olduqda sistem bildirişi göstər",
"backgroundLocationKeepAliveDescription": "Fonda endirmələri aktiv saxlamaq üçün məkan girişindən istifadə et",
"currentVersion": "Cari versiya {version}",
"contributors": "Töhfəçilər",
"viewContributors": "Töhfəçilərə bax",
"maxRunningDescription": "Eyni anda endirilə biləcək maksimum tapşırıq sayı",
"defaultUserAgentDescription": "HTTP və HTTPS endirmələri üçün standart User-Agent",
"listenPort": "Dinləmə Portu",
"onePerLine": "Hər sətirdə bir dənə",
"proxyProtocol": "Protokol",
"restartRequired": "Bu parametr tətbiq yenidən başladıldıqdan sonra qüvvəyə minir",
"socketPath": "Soket Yolu",
"listenAddress": "Dinləmə Ünvanı",
"currentConnection": "Cari Endpoint",
"webhookPush": "Endpoint-lər",
"webhookPushDescription": "Tapşırıqlar tamamlandıqda və ya uğursuz olduqda bu endpoint-lərə HTTP POST bildirişləri göndər",
"scriptExecution": "Skriptlər",
"scriptExecutionDescription": "Endirmə uğurla tamamlandıqdan sonra bu skriptləri işə sal",
"addWebhook": "Webhook Əlavə Et",
"editWebhook": "Webhook Redaktə Et",
"addScript": "Skript Əlavə Et",
"editScript": "Skript Redaktə Et",
"openDirectory": "Qovluğu Aç",
"browseFiles": "Faylları Gözdən Keçir",
"updateEveryDay": "Gündəlik yenilə",
"updateVersion": "Yenilə",
"checkForUpdates": "Yeniləmələri Yoxla",
"updating": "Yenilənir…",
"colorGreen": "Yaşıl",
"colorRed": "Qırmızı",
"colorRose": "Çəhrayı",
"colorOrange": "Narıncı",
"colorCyan": "Firuzəyi",
"colorBlue": "Mavi",
"colorAmber": "Kəhrəba",
"colorPurple": "Bənövşəyi",
"noExtensions": "Uzantı yoxdur",
"searchExtensions": "Uzantıları axtar…",
"extensionDeveloperGuide": "Uzantı tərtibatçı bələdçisi",
"extensionInstallFromUrl": "URL-dən uzantı quraşdır",
"unableOpenExtensionDocs": "Uzantı tərtibatçı bələdçisini açmaq mümkün olmadı",
"removeExtensionTitle": "Uzantı sistemdən silinsin?",
"removeExtensionConfirm": "“{name}” uzantısını sistemdən silmək istədiyinizə əminsiniz?",
"uninstall": "Sistemdən sil",
"unableLoadExtensions": "Uzantıları yükləmək mümkün olmadı",
"filters": "Süzgəclər",
"allTasks": "Bütün Tapşırıqlar",
"searchDownloads": "Endirmələri axtar…",
"searchHistory": "Tarixçədə axtar…",
"addTask": "Tapşırıq Əlavə Et",
"emptyTaskList": "Bu siyahıda tapşırıq yoxdur",
"noMatchingTasks": "Uyğun tapşırıq tapılmadı",
"taskList": "Tapşırıqlar",
"selectedCount": "{selected} / {total} seçilib",
"unableOpenPath": "{path} açmaq mümkün olmadı",
"unableLocatePath": "{path} tapmaq mümkün olmadı",
"clearSelection": "Seçimi təmizlə",
"deselectTask": "Seçimi ləğv et",
"selectTask": "Seç",
"updateManually": "Əl ilə yenilə",
"stopListening": "Dinləməni dayandır",
"listenForUrl": "URL üçün dinlə",
"waitingForReplacementUrl": "Əvəzedici URL gözlənilir",
"taskNotFound": "Tapşırıq tapılmadı",
"storagePath": "Yaddaş Yolu",
"directory": "Qovluq",
"mode": "Rejim",
"keepOriginalName": "Orijinal adı saxla",
"enterCount": "Sayı daxil edin",
"chooseDownloadDirectory": "Endirmə qovluğunu seçin",
"applicationStorage": "Tətbiq yaddaşı",
"applicationStorageDescription": "Etibarlı tətbiq yaddaşı; tətbiq silindikdə fayllar silinir",
"publicDownloadsDescription": "Silindikdən sonra faylları saxlayır; əlçatanlıq Android cihazından asılıdır",
"downloadsDirectoryUnavailable": "Gopeed bu cihazda Download/Gopeed qovluğuna yaza bilmir",
"enterValue": "Dəyəri daxil edin",
"trackers": "Tracker-lər",
"oneTrackerPerLine": "Hər sətirdə bir tracker URL-i",
"pasteDownloadLinks": "HTTP, HTTPS və ya MAGNET keçidlərini yapışdırın, ya da bura torrent faylı buraxın",
"unableReadTorrent": "Torrent faylı oxuna bilmədi: {error}",
"torrentOnly": "Yalnız .torrent faylları dəstəklənir",
"torrentDataEmpty": "Torrent fayl məlumatı boşdur",
"torrentPathUnavailable": "Torrent fayl yoluna giriş mümkün olmadı",
"selectAtLeastOneFile": "Ən azı bir fayl seçin",
"resolvedFiles": "Tapılmış Fayllar",
"resolvedFileCount": "{count} fayl tapıldı",
"resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur",
"noFiles": "Fayl yoxdur",
"createHistory": "Tarixçə Yarat",
"noHistory": "Tarixçə yoxdur",
"directDownloadDescription": "Təhlili keç və tapşırıqları dərhal yarat",
"taskProgress": "Tapşırığın irəliləyişi",
"updateAndResume": "Yenilə və davam et",
"httpHeaders": "HTTP başlıqları",
"piece": "Parça {number}",
"pieceRange": "Parçalar {start}-{end}",
"connectionStatus": "Əlaqə Vəziyyəti",
"connectionCount": "{count} əlaqə",
"retrying": "Yenidən cəhd edilir",
"connectionFailed": "Əlaqə uğursuz oldu",
"connectionFailedRetries": "{count} cəhddən sonra əlaqə uğursuz oldu",
"retryingCount": "Yenidən cəhd edilir · {count}-ci cəhd",
"pieceStatus": "Parça Vəziyyəti",
"peers": "Peer-lər",
"seeders": "Seed edənlər",
"leechers": "Endirənlər",
"downloadedAmount": "Endirilib",
"uploadedAmount": "Yüklənib",
"shareRatio": "Paylaşım Nisbəti",
"shareDuration": "Paylaşım Müddəti",
"connectedPeers": "{count} Peer qoşulub",
"active": "Aktiv",
"completedPieces": "Tamamlanmış Parçalar",
"activePeers": "{count} Peer aktivdir",
"incomplete": "Tamamlanmamış",
"address": "Ünvan",
"client": "Müştəri",
"pieces": "Parçalar",
"source": "Mənbə",
"fileRelevance": "Faylın uyğunluğu",
"taskDetails": "Tapşırığın Detalları",
"taskDetailsInfoTab": "Detallar",
"taskDetailsFilesTab": "Fayllar",
"taskDetailsStatsTab": "Statistika",
"noStatistics": "Statistika mövcud deyil",
"noPeers": "Heç bir Peer yoxdur",
"deleteTasksTitle": "{count} tapşırıq silinsin?",
"keepFilesDescription": "Yalnız tapşırıq qeydini Gopeed-dən sil",
"readingRuntimeStatus": "İşləmə vəziyyəti oxunur…",
"apiConfigSaved": "API parametrləri yadda saxlanıldı",
"apiConfigPendingApply": "Yadda saxlanılan parametrlər işləyən xidmətə hələ tətbiq edilməyib",
"apiServerRunning": "İşləyir",
"apiServerStopped": "Dayandırılıb",
"apiServerFailed": "Xəta",
"apiServerRuntimeStatus": "İşləmə vəziyyəti",
"startApiServer": "Başlat",
"stopApiServer": "Dayandır",
"saveAndRestartApiServer": "Yadda saxla və yenidən başlat",
"restartApiServerConfirm": "REST API xidməti işləyir. Bu parametrləri tətbiq etmək onu yenidən başladacaq və mövcud API əlaqələrini kəsəcək.",
"disableApiServerConfirm": "REST API serveri deaktiv edilsin? Mövcud API əlaqələri kəsiləcək.",
"socketPathRequired": "Soket yolu tələb olunur",
"serverRequired": "Server tələb olunur",
"portRangeError": "0 ilə 65535 arasında port daxil edin",
"portInUseValue": "Port {port} artıq istifadədədir",
"backgroundLocationPermissionRequired": "Fon endirmələri üçün 'Həmişə icazə ver' məkan girişi tələb olunur",
"scriptPath": "Skript Yolu",
"logDirectoryUnavailable": "Loq qovluğu işə salınmayıb",
"unableOpenExternalLink": "Xarici keçidi açmaq mümkün olmadı",
"neverUpdated": "Heç vaxt yenilənməyib",
"lastUpdated": "Son yenilənmə: {time}",
"unableLoadSettings": "Parametrləri yükləmək mümkün olmadı",
"fieldRequired": "{field} tələb olunur",
"addGithubMirror": "GitHub Güzgüsü Əlavə Et",
"editGithubMirror": "GitHub Güzgüsünü Redaktə Et",
"confirmDeleteTitle": "Silinmə təsdiqlənsin?",
"confirmDeleteNamed": "“{name}” silmək istədiyinizə əminsiniz?",
"categoryFieldsRequired": "Kateqoriya adı və yolu tələb olunur",
"addCategory": "Kateqoriya Əlavə Et",
"editCategory": "Kateqoriyanı Redaktə Et",
"deleteCategoryTitle": "Kateqoriya silinsin?",
"updateFailed": "Yeniləmə uğursuz oldu. Zəhmət olmasa sonra yenidən cəhd edin",
"newVersionAvailable": "Yeni versiya mövcuddur",
"releaseNotes": "Yeniliklər",
"noReleaseNotes": "Buraxılış qeydləri mövcud deyil",
"preparingDownload": "Endirmə hazırlanır…",
"booleanValueHint": "true / false",
"@runtimeInitializationFailed": {
"placeholders": {
"error": {}
}
},
"@secondsRemaining": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@minutesRemaining": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@hoursRemaining": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@secondsDuration": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@minutesDuration": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@hoursDuration": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@currentVersion": {
"placeholders": {
"version": {}
}
},
"@removeExtensionConfirm": {
"placeholders": {
"name": {}
}
},
"@selectedCount": {
"placeholders": {
"selected": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"@unableOpenPath": {
"placeholders": {
"path": {}
}
},
"@unableLocatePath": {
"placeholders": {
"path": {}
}
},
"@unableReadTorrent": {
"placeholders": {
"error": {}
}
},
"@resolvedFileCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@piece": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"@pieceRange": {
"placeholders": {
"start": {
"type": "int"
},
"end": {
"type": "int"
}
}
},
"@connectionCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@connectionFailedRetries": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@retryingCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@connectedPeers": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@activePeers": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@deleteTasksTitle": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"@portInUseValue": {
"placeholders": {
"port": {
"type": "int"
}
}
},
"@lastUpdated": {
"placeholders": {
"time": {}
}
},
"@fieldRequired": {
"placeholders": {
"field": {}
}
},
"@confirmDeleteNamed": {
"placeholders": {
"name": {}
}
},
"androidForegroundServiceChannel": "Gopeed Fon Xidməti",
"androidForegroundNotificationTitle": "Gopeed işləyir",
"androidForegroundNotificationText": "Endirmələr fonda davam edə bilər",
"mcpDescription": "AI agentləri ilə əməkdaşlıq edin, sonsuz imkanları kəşf edin",
"connectAgent": "Quraşdırma bələdçisi",
"connectMcpAgent": "MCP agenti qoş",
"mcpAgentDialogDescription": "İstifadə etdiyiniz agenti seçin, sonra onun əlaqə konfiqurasiyasını kopyalayın.",
"mcpNotRunningHint": "MCP xidməti aktiv deyil. Zəhmət olmasa aktivləşdirin",
"webMcpApiTokenHint": "Əgər API açarı konfiqurasiya etmisinizsə, agenti qoşarkən doğrulama məlumatı tələb olunur",
"useMcpApiTokenTemplate": "Doğrulamalı konfiqurasiyaya keç",
"webApiConfigReadOnlyHelp": "Veb versiyası API parametrlərinin onlayn dəyişdirilməsini dəstəkləmir. Server başlanğıc seçimlərini və ya konfiqurasiya faylını yeniləyin, sonra dəyişikliklərin qüvvəyə minməsi üçün xidməti yenidən başladın.",
"mcpRequiresTcp": "Agent əlaqə təlimatları API protokolu TCP olduqda mövcuddur."
}
@@ -58,7 +58,7 @@ class DownloadDirectoryPicker {
return _pickAndroid(context, currentPath: currentPath);
}
if (_platform == TargetPlatform.iOS) return null;
return FilePicker.platform.getDirectoryPath();
return FilePicker.getDirectoryPath();
}
static Future<String?> _pickAndroid(BuildContext context, {required String currentPath}) async {
@@ -251,7 +251,7 @@ Future<String?> _getManifestPath(Browser browser) async {
Future<bool> _checkWindowsRegistry(String keyPath) async {
try {
final key = Registry.openPath(RegistryHive.localMachine, path: keyPath);
final key = LOCAL_MACHINE.open(keyPath);
key.close();
return true;
} catch (e) {
@@ -26,7 +26,7 @@ void doRegisterUrlScheme(String scheme) {
void doUnregisterUrlScheme(String scheme) {
if (Util.isWindows()) {
Registry.currentUser.deleteKey('Software\\Classes\\$scheme', recursive: true);
CURRENT_USER.removeSubkey('Software\\Classes\\$scheme');
return;
}
@@ -66,8 +66,8 @@ void doUnregisterDefaultTorrentClient() {
if (Util.isWindows()) {
doUnregisterUrlScheme("magnet");
Registry.currentUser.deleteKey(_torrentRegKey, recursive: true);
Registry.currentUser.deleteKey(_torrentAppRegKey, recursive: true);
CURRENT_USER.removeSubkey(_torrentRegKey);
CURRENT_USER.removeSubkey(_torrentAppRegKey);
return;
}
+14 -12
View File
@@ -3,12 +3,14 @@ import 'package:win32_registry/win32_registry.dart';
/// Check registry key
/// If the key does not exist or the value is different, return false
bool checkRegistry(String keyPath, String valueName, String value) {
RegistryKey regKey;
RegistryKey? regKey;
try {
regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath);
return regKey.getValueAsString(valueName) == value;
regKey = CURRENT_USER.open(keyPath);
return regKey.getString(valueName) == value;
} catch (e) {
return false;
} finally {
regKey?.close();
}
}
@@ -16,15 +18,15 @@ bool checkRegistry(String keyPath, String valueName, String value) {
/// If the key does not exist, create it
/// If the value does not exist or is different, update it
void upsertRegistry(String keyPath, String valueName, String value) {
RegistryKey regKey;
final regKey = CURRENT_USER.create(
keyPath,
config: const RegistryOpenConfig(access: RegistryAccess.all, create: true),
);
try {
regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath, desiredAccessRights: AccessRights.allAccess);
} catch (e) {
regKey = Registry.currentUser.createKey(keyPath);
if (regKey.getString(valueName) != value) {
regKey.setValue(valueName, RegistryValue.string(value));
}
} finally {
regKey.close();
}
if (regKey.getValueAsString(valueName) != value) {
regKey.createValue(RegistryValue(valueName, RegistryValueType.string, value));
}
regKey.close();
}
+94 -37
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "10.0.1"
android_file_picker:
dependency: transitive
description:
name: android_file_picker
sha256: "0d84bb91fb78af33756107f00cf71ee084474169d481b882b335c3006077a913"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
animation_kit:
dependency: transitive
description:
@@ -245,10 +253,10 @@ packages:
dependency: "direct main"
description:
name: dart_ipc
sha256: "6cad558cda5304017c1f581df4c96fd4f8e4ee212aae7bfa4357716236faa9ba"
sha256: "3b372078dcc542b97189da4df2b08c1fc5ab0123ecdce37d400ce85ab651b273"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "2.0.2"
dart_style:
dependency: transitive
description:
@@ -293,18 +301,18 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da"
sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442"
url: "https://pub.dev"
source: hosted
version: "11.3.0"
version: "13.2.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
version: "8.1.0"
dio:
dependency: "direct main"
description:
@@ -361,30 +369,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
ffigen:
dependency: "direct dev"
description:
name: ffigen
sha256: d3e76c2ad48a4e7f93a29a162006f00eba46ce7c08194a77bb5c5e97d1b5ff0a
sha256: "56fc0be88937409839a59c133dfdee4165e80da324c9e39f59013060249ffb44"
url: "https://pub.dev"
source: hosted
version: "8.0.2"
version: "21.0.0"
file:
dependency: transitive
description:
name: file
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "6.1.4"
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "7423298f08f6fc8cce05792bae329f9a93653fc9c08712831b1a55540127995d"
sha256: "0631f51bbc2e1184bdc9e79384db158dc7830e15b3001feeca700e86563f4d24"
url: "https://pub.dev"
source: hosted
version: "9.0.2"
version: "12.2.0"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: "5c32c9400f5c8976c9dd7aa34693c7d2f0c853c28704a1c1409029cb760f5251"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9
url: "https://pub.dev"
source: hosted
version: "1.1.0"
file_picker_platform_interface:
dependency: transitive
description:
name: file_picker_platform_interface
sha256: d35d8cb68446fae921335bb61501d66cb2469d74c8f3103b8c6ccdbe3fe5afd8
url: "https://pub.dev"
source: hosted
version: "3.3.0"
file_picker_web:
dependency: transitive
description:
name: file_picker_web
sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
fixnum:
dependency: transitive
description:
@@ -541,14 +589,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.12"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_riverpod:
dependency: "direct main"
description:
@@ -755,11 +795,12 @@ packages:
launch_at_startup:
dependency: "direct main"
description:
name: launch_at_startup
sha256: "1f8a75520913d1038630049e6c44a2575a23ffd28cc8b14fdf37401d1d21de84"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
path: "."
ref: "7ac6f842be974aa08dcd21cab6efa66a3749f2b2"
resolved-ref: "7ac6f842be974aa08dcd21cab6efa66a3749f2b2"
url: "https://github.com/GopeedLab/launch_at_startup.git"
source: git
version: "0.6.0"
leak_tracker:
dependency: transitive
description:
@@ -856,6 +897,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
url: "https://pub.dev"
source: hosted
version: "0.19.2"
node_preamble:
dependency: transitive
description:
@@ -932,18 +981,18 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
version: "10.2.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "4.1.0"
path:
dependency: "direct main"
description:
@@ -1237,18 +1286,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
url: "https://pub.dev"
source: hosted
version: "10.1.4"
version: "13.3.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "7.2.0"
shared_preferences:
dependency: transitive
description:
@@ -1659,18 +1708,18 @@ packages:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "5.15.0"
version: "6.4.0"
win32_registry:
dependency: "direct main"
description:
name: win32_registry
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "1.1.5"
version: "3.0.3"
window_manager:
dependency: "direct main"
description:
@@ -1679,6 +1728,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.2"
windows_file_picker:
dependency: transitive
description:
name: windows_file_picker
sha256: d999c1c085374724668af0b674a9758d96255c50933faa2ac1cc51eff8308caf
url: "https://pub.dev"
source: hosted
version: "1.2.0"
xdg_directories:
dependency: transitive
description:
+12 -9
View File
@@ -44,21 +44,24 @@ dependencies:
ffi: ^2.1.3
crypto: ^3.0.6
logger: ^1.4.0
package_info_plus: ^8.1.0
package_info_plus: ^10.2.1
pub_semver: ^2.2.0
device_info_plus: ^11.1.0
win32_registry: ^1.1.5
launch_at_startup: ^0.3.1
device_info_plus: ^13.2.0
win32_registry: ^3.0.3
launch_at_startup:
git:
url: https://github.com/GopeedLab/launch_at_startup.git
ref: 7ac6f842be974aa08dcd21cab6efa66a3749f2b2
app_links: ^6.3.3
share_handler: ^0.0.22
share_plus: ^10.1.0
share_plus: ^13.3.0
uri_content: ^3.1.3
dart_ipc: ^1.0.1
dart_ipc: ^2.0.2
flutter_inappwebview: ^6.1.5
flutter_local_notifications: 20.1.0
desktop_drop: ^0.4.4
external_path: ^2.2.0
file_picker: 9.0.2
file_picker: 12.2.0
open_dir: ^0.0.2+1
open_filex: ^4.7.0
permission_handler: ^11.3.1
@@ -114,7 +117,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
ffigen: ^8.0.2
ffigen: ^21.0.0
build_runner: ^2.15.1
json_serializable: ^6.14.1
flutter_launcher_icons: ^0.14.4
@@ -208,7 +211,7 @@ ffigen:
- "StartAPIServer"
- "StopAPIServer"
- "RestartAPIServer"
- "Invoke"
- "InvokeAsync"
- "SubscribeTaskEvents"
- "FreeCString"
structs:
@@ -0,0 +1,29 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/core/common/libgopeed_channel.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('gopeed.com/libgopeed');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
});
test('mobile invokes carry unique request IDs for async callbacks', () async {
final requestIDs = <int>[];
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'invoke');
final arguments = (call.arguments as Map<Object?, Object?>).cast<String, Object?>();
requestIDs.add(arguments['requestID']! as int);
return '{"code":0}';
});
final bridge = LibgopeedChannel();
await Future.wait([bridge.invoke('GET', '/api/v1/info'), bridge.invoke('GET', '/api/v1/config')]);
expect(requestIDs, [0, 1]);
});
}
@@ -16,6 +16,7 @@ import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/capabilities/storage_capability.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
import 'package:gopeed/shared/services/download_directory_picker.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_design_tokens.dart';
@@ -25,6 +26,90 @@ import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
try {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'created-id';
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final pending = container.read(pendingCreateTaskProvider.notifier);
pending.set(
CreateTask(
req: Request(
url: 'https://example.com/first.zip',
rawUrl: 'https://example.com/first',
labels: {'source': 'first'},
extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(),
proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'),
skipVerifyCert: true,
),
),
);
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip');
expect(container.read(pendingCreateTaskProvider), isNull);
final pageState = tester.state(find.byType(CreateTaskWindowPage));
for (final url in [
'https://example.com/second.zip',
'https://example.com/third.zip',
'https://example.com/third.zip',
]) {
pending.set(CreateTask(req: Request(url: url)));
router.go('/create');
await tester.pumpAndSettle();
expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState));
expect(_fieldText(tester, 'create-task-url-input'), url);
expect(container.read(pendingCreateTaskProvider), isNull);
}
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.req?.url, 'https://example.com/third.zip');
expect(submitted?.req?.rawUrl, isNull);
expect(submitted?.req?.labels, isNull);
expect(submitted?.req?.extra, isNull);
expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow);
expect(submitted?.req?.skipVerifyCert, isFalse);
expect(tester.takeException(), isNull);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
tester.view.physicalSize = const Size(700, 760);