mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-11 19:04:08 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d315f01289 | ||
|
|
bb17c7d48a | ||
|
|
75ffe70a4d | ||
|
|
36f9f65384 | ||
|
|
5f89ae3516 | ||
|
|
0cc2b690f8 | ||
|
|
a45b6455be | ||
|
|
b991482617 | ||
|
|
f0aa44455c | ||
|
|
99ed770017 | ||
|
|
1f8906f952 | ||
|
|
0efc85d3c0 | ||
|
|
481675107f | ||
|
|
6568498850 | ||
|
|
67814348dc | ||
|
|
53d13dfe28 | ||
|
|
d511e66d56 | ||
|
|
16f2d30476 | ||
|
|
aafd039205 | ||
|
|
647e161431 |
No files matched your search
@@ -594,7 +594,7 @@ jobs:
|
|||||||
overwrite: true
|
overwrite: true
|
||||||
build-ios:
|
build-ios:
|
||||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
||||||
runs-on: macos-14
|
runs-on: macos-26
|
||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
@@ -6,15 +6,19 @@
|
|||||||
// What it checks:
|
// What it checks:
|
||||||
// - Every ARB file uses the repository's canonical two-space JSON formatting.
|
// - Every ARB file uses the repository's canonical two-space JSON formatting.
|
||||||
// - Every app_<locale>.arb file declares the matching @@locale value.
|
// - Every app_<locale>.arb file declares the matching @@locale value.
|
||||||
// - Message values are not empty.
|
// - Message values are not empty (warning only).
|
||||||
// - Every locale has exactly the same message keys as app_en.arb.
|
// - Every locale has the same message keys as app_en.arb (warning only).
|
||||||
// - Every translation preserves the placeholder variables used by app_en.arb.
|
// - Every translation preserves the placeholder variables used by app_en.arb
|
||||||
|
// (warning only).
|
||||||
//
|
//
|
||||||
// Notes:
|
// Notes:
|
||||||
// - This script does not judge translation quality.
|
// - This script does not judge translation quality.
|
||||||
// - It does not generate localization code or fully parse ICU MessageFormat.
|
// - It does not generate localization code or fully parse ICU MessageFormat.
|
||||||
// `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax
|
// `flutter gen-l10n` runs immediately afterwards in CI to validate ICU syntax
|
||||||
// and generate app_localizations*.dart.
|
// and generate app_localizations*.dart.
|
||||||
|
// - The script exits with a failure only for malformed JSON, invalid ARB
|
||||||
|
// formatting, or a mismatched @@locale value. Translation completeness is
|
||||||
|
// reported as warnings; generation and analysis remain the compile-time gate.
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -49,27 +53,24 @@ void main() {
|
|||||||
keys.where((key) => (messages[key] as String).trim().isEmpty).toList()
|
keys.where((key) => (messages[key] as String).trim().isEmpty).toList()
|
||||||
..sort();
|
..sort();
|
||||||
if (empty.isNotEmpty) {
|
if (empty.isNotEmpty) {
|
||||||
stderr.writeln('${file.path}: empty messages $empty');
|
_warning('${file.path}: empty messages $empty');
|
||||||
failed = true;
|
|
||||||
}
|
}
|
||||||
if (file.path == templateFile.path) continue;
|
if (file.path == templateFile.path) continue;
|
||||||
final missing = templateKeys.difference(keys);
|
final missing = templateKeys.difference(keys);
|
||||||
final extra = keys.difference(templateKeys);
|
final extra = keys.difference(templateKeys);
|
||||||
if (missing.isNotEmpty || extra.isNotEmpty) {
|
if (missing.isNotEmpty || extra.isNotEmpty) {
|
||||||
stderr.writeln(
|
_warning(
|
||||||
'${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}',
|
'${file.path}: missing ${missing.toList()..sort()}, extra ${extra.toList()..sort()}',
|
||||||
);
|
);
|
||||||
failed = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final key in templateKeys.intersection(keys)) {
|
for (final key in templateKeys.intersection(keys)) {
|
||||||
final expected = _placeholders(template[key] as String);
|
final expected = _placeholders(template[key] as String);
|
||||||
final actual = _placeholders(messages[key] as String);
|
final actual = _placeholders(messages[key] as String);
|
||||||
if (!_sameSet(expected, actual)) {
|
if (!_sameSet(expected, actual)) {
|
||||||
stderr.writeln(
|
_warning(
|
||||||
'${file.path}: $key placeholders $actual do not match $expected',
|
'${file.path}: $key placeholders $actual do not match $expected',
|
||||||
);
|
);
|
||||||
failed = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,3 +103,5 @@ Set<String> _placeholders(String message) => RegExp(
|
|||||||
/// Compares sets without depending on iteration order.
|
/// Compares sets without depending on iteration order.
|
||||||
bool _sameSet(Set<String> left, Set<String> right) =>
|
bool _sameSet(Set<String> left, Set<String> right) =>
|
||||||
left.length == right.length && left.containsAll(right);
|
left.length == right.length && left.containsAll(right);
|
||||||
|
|
||||||
|
void _warning(String message) => stderr.writeln('warning: $message');
|
||||||
@@ -213,7 +213,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ macos-14, ubuntu-latest ]
|
os: [ macos-26, ubuntu-latest ]
|
||||||
needs: [ test ]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 391 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 357 KiB |
+38
-5
@@ -6,9 +6,15 @@ package main
|
|||||||
|
|
||||||
typedef void (*TaskEventCallback)(char* payload);
|
typedef void (*TaskEventCallback)(char* payload);
|
||||||
|
|
||||||
|
typedef void (*InvokeResultCallback)(uint64_t request_id, int success, char* payload);
|
||||||
|
|
||||||
static void callTaskEventCallback(uintptr_t callback, char* payload) {
|
static void callTaskEventCallback(uintptr_t callback, char* payload) {
|
||||||
((TaskEventCallback)callback)(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 "C"
|
||||||
import (
|
import (
|
||||||
@@ -16,6 +22,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
@@ -35,12 +42,13 @@ func Start(cfg *C.char) (int, *C.char) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, C.CString(err.Error())
|
return 0, C.CString(err.Error())
|
||||||
}
|
}
|
||||||
|
nativebridge.ResumeInvokes()
|
||||||
return realPort, nil
|
return realPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//export Stop
|
//export Stop
|
||||||
func Stop() {
|
func Stop() {
|
||||||
rest.Stop()
|
nativebridge.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
//export GetAPIServerState
|
//export GetAPIServerState
|
||||||
@@ -75,14 +83,39 @@ func apiServerResult(state *model.APIServerState, err error) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
//export Invoke
|
//export InvokeAsync
|
||||||
func Invoke(method *C.char, path *C.char, query *C.char, body *C.char) *C.char {
|
func InvokeAsync(
|
||||||
return C.CString(rest.Invoke(
|
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(method),
|
||||||
C.GoString(path),
|
C.GoString(path),
|
||||||
C.GoString(query),
|
C.GoString(query),
|
||||||
C.GoString(body),
|
C.GoString(body),
|
||||||
))
|
complete,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
//export SubscribeTaskEvents
|
//export SubscribeTaskEvents
|
||||||
|
|||||||
+22
-4
@@ -5,6 +5,7 @@ import "C"
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
|
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||||
"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"
|
||||||
@@ -18,11 +19,15 @@ func Start(cfg string) (int, error) {
|
|||||||
config.ProductionMode = true
|
config.ProductionMode = true
|
||||||
config.NativeMode = true
|
config.NativeMode = true
|
||||||
applyWebViewProvider(&config)
|
applyWebViewProvider(&config)
|
||||||
return rest.Start(&config)
|
port, err := rest.Start(&config)
|
||||||
|
if err == nil {
|
||||||
|
nativebridge.ResumeInvokes()
|
||||||
|
}
|
||||||
|
return port, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func Stop() {
|
func Stop() {
|
||||||
rest.Stop()
|
nativebridge.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAPIServerState() string {
|
func GetAPIServerState() string {
|
||||||
@@ -50,8 +55,21 @@ func apiServerResult(state *model.APIServerState, err error) string {
|
|||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Invoke(method, path, query, body string) string {
|
type InvokeResultListener interface {
|
||||||
return rest.Invoke(method, path, query, body)
|
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 {
|
type TaskEventListener interface {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -13,10 +13,13 @@ const (
|
|||||||
TaskEventError = uint64(goapi.TaskEventError)
|
TaskEventError = uint64(goapi.TaskEventError)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invoke dispatches a Core API request without using an HTTP listener. It is
|
// Dispatch executes one in-process API request. Native scheduling and callback
|
||||||
// the shared entry point for Desktop FFI and gomobile bindings.
|
// behavior belong to bind/native; this function only bridges into the shared
|
||||||
func Invoke(method, path, rawQuery, body string) string {
|
// API Service without opening an HTTP listener.
|
||||||
|
func Dispatch(method, path, rawQuery, body string) string {
|
||||||
|
runtimeMu.RLock()
|
||||||
service := APIService
|
service := APIService
|
||||||
|
runtimeMu.RUnlock()
|
||||||
if service == nil {
|
if service == nil {
|
||||||
return marshalInvokeResult(model.NewErrorResult("service not started"))
|
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)) {
|
func SubscribeTaskEvents(mask uint64, listener func(payload string)) {
|
||||||
|
runtimeMu.RLock()
|
||||||
|
defer runtimeMu.RUnlock()
|
||||||
if APIService == nil {
|
if APIService == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-4
@@ -4,9 +4,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
@@ -27,7 +30,7 @@ func TestNativeInvokeWithoutRESTListener(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
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) {
|
func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T) {
|
||||||
port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem})
|
port, err := Start(&model.StartConfig{NativeMode: true, Storage: model.StorageMem})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,7 +178,7 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
if result.Code != model.CodeOk {
|
||||||
@@ -153,7 +225,7 @@ func TestRestartAPIServerFailsFastAndLeavesListenerStopped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
if result.Code != model.CodeOk {
|
||||||
@@ -229,7 +301,7 @@ func TestNativeAPIServerAutoStartFailureKeepsCoreAvailable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result model.Result[json.RawMessage]
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Code != model.CodeOk {
|
if result.Code != model.CodeOk {
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
runtimeMu sync.Mutex
|
runtimeMu sync.RWMutex
|
||||||
|
|
||||||
Downloader *download.Downloader
|
Downloader *download.Downloader
|
||||||
APIService *goapi.Service
|
APIService *goapi.Service
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ Guidelines:
|
|||||||
- forbidden Material product-component usage
|
- forbidden Material product-component usage
|
||||||
- If a screen already uses `shadcn_flutter`, do not mix in Material product widgets for convenience. Replace them with `shadcn_flutter` equivalents or shared wrappers built on top of `shadcn_flutter`.
|
- If a screen already uses `shadcn_flutter`, do not mix in Material product widgets for convenience. Replace them with `shadcn_flutter` equivalents or shared wrappers built on top of `shadcn_flutter`.
|
||||||
|
|
||||||
|
### 3.3.1 Text Input Rules
|
||||||
|
|
||||||
|
- All product-facing editable text controls must use the shared `AppTextField` from `lib/shared/widgets/app_text_field.dart`.
|
||||||
|
- This rule applies to every input variant, including search fields, login and password fields, URL fields, numeric fields, multiline editors, settings controls, dialog inputs, and feature-specific form fields.
|
||||||
|
- Do not instantiate Flutter Material `TextField` or `shadcn_flutter`'s `TextField` directly in feature or page code. Direct construction is allowed only while implementing or extending a shared input primitive, with the reason documented in that primitive.
|
||||||
|
- `AppTextField` owns the cross-platform text-selection menu policy. Android, iOS, desktop, and Web must all use Flutter's adaptive native context menu so long-press and right-click selection actions follow platform behavior without `shadcn_flutter`'s full-screen context-menu overlay.
|
||||||
|
- Do not use `shadcn_flutter`'s editable-text context menu on any platform.
|
||||||
|
- Feature and page code must not override `contextMenuBuilder`. Any exception must be implemented centrally in `AppTextField`, justified by a platform requirement, and covered by Android/iOS and desktop tests.
|
||||||
|
- Preserve existing field behavior when migrating to `AppTextField`, including controllers, focus nodes, input formatters, autofill hints, password visibility, read-only state, input features, keyboard actions, and multiline sizing.
|
||||||
|
- Prefer stable keys or `AppTextField` when locating app inputs in widget tests. Do not couple product tests to the exact runtime type of the underlying `shadcn_flutter` implementation.
|
||||||
|
- Changes to the shared input policy must include regression coverage for Android long-press behavior and desktop right-click behavior. Web builds must keep the same shared native-menu policy.
|
||||||
|
|
||||||
## 3.4) Desktop Design Metrics
|
## 3.4) Desktop Design Metrics
|
||||||
|
|
||||||
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
/>
|
/>
|
||||||
|
<!-- app_links and share_handler own external download intents. Letting
|
||||||
|
Flutter's Router handle the same URI races with the explicit
|
||||||
|
navigation to the create-task page. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutter_deeplinking_enabled"
|
||||||
|
android:value="false" />
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
|||||||
@@ -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.InvokeResultListener
|
||||||
import com.gopeed.libgopeed.TaskEventListener
|
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
|
||||||
@@ -38,13 +39,23 @@ class MainActivity : FlutterActivity() {
|
|||||||
"restartApiServer" -> result.success(Libgopeed.restartAPIServer())
|
"restartApiServer" -> result.success(Libgopeed.restartAPIServer())
|
||||||
"invoke" -> {
|
"invoke" -> {
|
||||||
try {
|
try {
|
||||||
result.success(
|
Libgopeed.invokeAsync(
|
||||||
Libgopeed.invoke(
|
call.argument<String>("method") ?: "",
|
||||||
call.argument<String>("method") ?: "",
|
call.argument<String>("path") ?: "",
|
||||||
call.argument<String>("path") ?: "",
|
call.argument<String>("query") ?: "",
|
||||||
call.argument<String>("query") ?: "",
|
call.argument<String>("body") ?: "",
|
||||||
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) {
|
} catch (error: Exception) {
|
||||||
result.error("ERROR", error.message, null)
|
result.error("ERROR", error.message, null)
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ GetAPIServerState
|
|||||||
StartAPIServer
|
StartAPIServer
|
||||||
StopAPIServer
|
StopAPIServer
|
||||||
RestartAPIServer
|
RestartAPIServer
|
||||||
Invoke
|
InvokeAsync
|
||||||
FreeCString
|
FreeCString
|
||||||
SubscribeTaskEvents
|
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.
|
- 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`.
|
- Memory allocated by Go must be released with `FreeCString`.
|
||||||
- Dart-owned input memory must be released after the synchronous native call returns.
|
- `InvokeAsync` is the only native invocation entry point. Desktop and gomobile adapters both submit to the shared bounded-concurrency executor in `bind/native`.
|
||||||
- `Invoke` returns the same Result JSON as REST; business errors do not use a separate ABI error model.
|
- 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.
|
||||||
- Run blocking Desktop FFI work outside the Flutter UI isolate. Mobile MethodChannel handlers likewise run blocking Go work on a background task queue.
|
- 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.
|
- 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.
|
- 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()
|
StartAPIServer()
|
||||||
StopAPIServer()
|
StopAPIServer()
|
||||||
RestartAPIServer()
|
RestartAPIServer()
|
||||||
Invoke(method, path, query, body)
|
InvokeAsync(method, path, query, body, requestID, listener)
|
||||||
SubscribeTaskEvents(mask, listener)
|
SubscribeTaskEvents(mask, listener)
|
||||||
```
|
```
|
||||||
|
|
||||||
Flutter bridge rules:
|
Flutter bridge rules:
|
||||||
|
|
||||||
- Normal calls use MethodChannel.
|
- Normal calls use MethodChannel, while `InvokeAsync` completes the pending MethodChannel result through a gomobile listener.
|
||||||
- Android performs normal calls on a background task queue.
|
- 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.
|
- 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.
|
- 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
|
### 11.3 Platform smoke tests
|
||||||
|
|
||||||
- Windows, macOS, Linux: startup, Invoke, events, shutdown, and repeated startup.
|
- Windows, macOS, Linux: startup, InvokeAsync, events, shutdown, and repeated startup.
|
||||||
- Android, iOS: gomobile Invoke, reverse MethodChannel events, and background/foreground transitions.
|
- Android, iOS: gomobile InvokeAsync callbacks, reverse MethodChannel events, and background/foreground transitions.
|
||||||
- Web: REST, login, proxy, and task-file downloads.
|
- Web: REST, login, proxy, and task-file downloads.
|
||||||
- Native: enabling and disabling REST does not interrupt download tasks.
|
- 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
|
### Phase 2: Native in-process calls — implemented on this branch
|
||||||
|
|
||||||
- Integrate the FFI Transport on Desktop.
|
- 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.
|
- 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.
|
- Do not create a socket listener by default on native clients.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
#ifndef GO_CGO_GOSTRING_TYPEDEF
|
||||||
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
||||||
|
extern size_t _GoStringLen(_GoString_ s);
|
||||||
|
extern const char *_GoStringPtr(_GoString_ s);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -19,6 +21,24 @@ typedef struct { const char *p; ptrdiff_t n; } _GoString_;
|
|||||||
/* Start of preamble from import "C" comments. */
|
/* 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. */
|
/* End of preamble from import "C" comments. */
|
||||||
@@ -44,10 +64,16 @@ typedef size_t GoUintptr;
|
|||||||
typedef float GoFloat32;
|
typedef float GoFloat32;
|
||||||
typedef double GoFloat64;
|
typedef double GoFloat64;
|
||||||
#ifdef _MSC_VER
|
#ifdef _MSC_VER
|
||||||
|
#if !defined(__cplusplus) || _MSVC_LANG <= 201402L
|
||||||
#include <complex.h>
|
#include <complex.h>
|
||||||
typedef _Fcomplex GoComplex64;
|
typedef _Fcomplex GoComplex64;
|
||||||
typedef _Dcomplex GoComplex128;
|
typedef _Dcomplex GoComplex128;
|
||||||
#else
|
#else
|
||||||
|
#include <complex>
|
||||||
|
typedef std::complex<float> GoComplex64;
|
||||||
|
typedef std::complex<double> GoComplex128;
|
||||||
|
#endif
|
||||||
|
#else
|
||||||
typedef float _Complex GoComplex64;
|
typedef float _Complex GoComplex64;
|
||||||
typedef double _Complex GoComplex128;
|
typedef double _Complex GoComplex128;
|
||||||
#endif
|
#endif
|
||||||
@@ -81,13 +107,13 @@ struct Start_return {
|
|||||||
char* r1;
|
char* r1;
|
||||||
};
|
};
|
||||||
extern struct Start_return Start(char* cfg);
|
extern struct Start_return Start(char* cfg);
|
||||||
extern void Stop();
|
extern void Stop(void);
|
||||||
extern char* GetAPIServerState();
|
extern char* GetAPIServerState(void);
|
||||||
extern char* StartAPIServer();
|
extern char* StartAPIServer(void);
|
||||||
extern char* StopAPIServer();
|
extern char* StopAPIServer(void);
|
||||||
extern char* RestartAPIServer();
|
extern char* RestartAPIServer(void);
|
||||||
extern char* Invoke(char* method, char* path, char* query, char* body);
|
extern void InvokeAsync(char* method, char* path, char* query, char* body, long long unsigned int requestID, uintptr_t callback);
|
||||||
extern void SubscribeTaskEvents(GoUint64 mask, GoUintptr callback);
|
extern void SubscribeTaskEvents(long long unsigned int mask, uintptr_t callback);
|
||||||
extern void FreeCString(char* value);
|
extern void FreeCString(char* value);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
platform :ios, '13.0'
|
platform :ios, '14.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@@ -693,7 +693,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -823,7 +823,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -874,7 +874,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ import UIKit
|
|||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
let messenger = engineBridge.applicationRegistrar.messenger()
|
let messenger = engineBridge.applicationRegistrar.messenger()
|
||||||
|
let libgopeedTaskQueue = messenger.makeBackgroundTaskQueue?()
|
||||||
|
|
||||||
let libgopeedChannel = FlutterMethodChannel(
|
let libgopeedChannel = FlutterMethodChannel(
|
||||||
name: "gopeed.com/libgopeed",
|
name: "gopeed.com/libgopeed",
|
||||||
binaryMessenger: messenger
|
binaryMessenger: messenger,
|
||||||
|
codec: FlutterStandardMethodCodec.sharedInstance(),
|
||||||
|
taskQueue: libgopeedTaskQueue
|
||||||
)
|
)
|
||||||
let taskEventForwarder = GopeedTaskEventForwarder(channel: libgopeedChannel)
|
let taskEventForwarder = GopeedTaskEventForwarder(channel: libgopeedChannel)
|
||||||
libgopeedChannel.setMethodCallHandler { call, result in
|
libgopeedChannel.setMethodCallHandler { call, result in
|
||||||
@@ -56,7 +59,8 @@ import UIKit
|
|||||||
let path = arguments?["path"] as? String ?? ""
|
let path = arguments?["path"] as? String ?? ""
|
||||||
let query = arguments?["query"] as? String ?? ""
|
let query = arguments?["query"] as? String ?? ""
|
||||||
let body = arguments?["body"] 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":
|
case "subscribeTaskEvents":
|
||||||
let arguments = call.arguments as? [String: Any]
|
let arguments = call.arguments as? [String: Any]
|
||||||
let mask = (arguments?["mask"] as? NSNumber)?.int64Value ?? 0
|
let mask = (arguments?["mask"] as? NSNumber)?.int64Value ?? 0
|
||||||
|
|||||||
@@ -13,4 +13,12 @@ FOUNDATION_EXPORT void GopeedSubscribeTaskEventsWithForwarder(
|
|||||||
int64_t mask,
|
int64_t mask,
|
||||||
GopeedTaskEventForwarder * _Nullable listener);
|
GopeedTaskEventForwarder * _Nullable listener);
|
||||||
|
|
||||||
|
FOUNDATION_EXPORT void GopeedInvokeAsyncWithResult(
|
||||||
|
NSString *method,
|
||||||
|
NSString *path,
|
||||||
|
NSString *query,
|
||||||
|
NSString *body,
|
||||||
|
int64_t requestID,
|
||||||
|
FlutterResult result);
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
@@ -8,6 +8,45 @@
|
|||||||
|
|
||||||
@end
|
@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
|
@implementation GopeedTaskEventForwarder
|
||||||
|
|
||||||
- (instancetype)initWithChannel:(FlutterMethodChannel *)channel {
|
- (instancetype)initWithChannel:(FlutterMethodChannel *)channel {
|
||||||
@@ -33,3 +72,15 @@ void GopeedSubscribeTaskEventsWithForwarder(
|
|||||||
GopeedTaskEventForwarder * _Nullable listener) {
|
GopeedTaskEventForwarder * _Nullable listener) {
|
||||||
LibgopeedSubscribeTaskEvents(mask, 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);
|
||||||
|
}
|
||||||
@@ -78,7 +78,8 @@ class _GopeedAppState extends ConsumerState<GopeedApp> with WidgetsBindingObserv
|
|||||||
|
|
||||||
return shad.ShadcnApp.router(
|
return shad.ShadcnApp.router(
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
onGenerateTitle: (context) => context.l10n.appTitle,
|
disableBrowserContextMenu: false,
|
||||||
|
title: 'Gopeed',
|
||||||
theme: AppTheme.light(themeColor),
|
theme: AppTheme.light(themeColor),
|
||||||
darkTheme: AppTheme.dark(themeColor),
|
darkTheme: AppTheme.dark(themeColor),
|
||||||
materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor),
|
materialTheme: isLightTheme ? AppTheme.materialLight(themeColor) : AppTheme.materialDark(themeColor),
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ class AndroidForegroundService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Android 13+ requires runtime permission to show the service notification.
|
||||||
|
// A denial only hides it from the notification drawer; the service can run.
|
||||||
|
final permission = await FlutterForegroundTask.checkNotificationPermission();
|
||||||
|
if (permission == NotificationPermission.denied) {
|
||||||
|
await FlutterForegroundTask.requestNotificationPermission();
|
||||||
|
}
|
||||||
|
|
||||||
if (await FlutterForegroundTask.isRunningService) {
|
if (await FlutterForegroundTask.isRunningService) {
|
||||||
_throwOnFailure(await FlutterForegroundTask.restartService());
|
_throwOnFailure(await FlutterForegroundTask.restartService());
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ class AppDeepLinkController extends AsyncNotifier<AppDeepLinkState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AppDeepLinkState> build() async {
|
Future<AppDeepLinkState> build() async {
|
||||||
final runtime = ref.watch(appRuntimeControllerProvider).value;
|
if (_started || kIsWeb) {
|
||||||
if (runtime == null || _started || kIsWeb) {
|
|
||||||
return AppDeepLinkState(started: _started);
|
return AppDeepLinkState(started: _started);
|
||||||
}
|
}
|
||||||
|
// Deep-link handling depends on the runtime being ready, but it must not
|
||||||
|
// rebuild whenever runtime settings change. A rebuild disposes the active
|
||||||
|
// platform subscriptions while `_started` remains true, leaving Android
|
||||||
|
// unable to receive later browser takeover intents (for example after the
|
||||||
|
// API server is started or stopped from Settings).
|
||||||
|
await ref.read(appRuntimeControllerProvider.future);
|
||||||
_started = true;
|
_started = true;
|
||||||
final appLinks = AppLinks();
|
final appLinks = AppLinks();
|
||||||
_subscription = appLinks.uriLinkStream.listen((uri) {
|
_subscription = appLinks.uriLinkStream.listen((uri) {
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
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/path.dart' as path;
|
||||||
|
|
||||||
import '../../core/common/task_event.dart';
|
import '../../core/common/task_event.dart';
|
||||||
import '../../core/libgopeed_boot.dart';
|
import '../../core/libgopeed_boot.dart';
|
||||||
import '../../l10n/l10n.dart';
|
import '../../l10n/l10n.dart';
|
||||||
|
import '../../util/log_util.dart';
|
||||||
import '../../util/util.dart';
|
import '../../util/util.dart';
|
||||||
import 'app_runtime_controller.dart';
|
import 'app_runtime_controller.dart';
|
||||||
|
|
||||||
@@ -28,13 +28,19 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
|||||||
StreamSubscription<TaskEvent>? _taskEventSubscription;
|
StreamSubscription<TaskEvent>? _taskEventSubscription;
|
||||||
var _notificationId = 0;
|
var _notificationId = 0;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Stream<TaskEvent> get taskEvents => LibgopeedBoot.instance.taskEvents;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AppNotificationState> build() async {
|
Future<AppNotificationState> build() async {
|
||||||
final runtime = ref.watch(appRuntimeControllerProvider).value;
|
final runtime = ref.watch(appRuntimeControllerProvider).value;
|
||||||
if (runtime == null || kIsWeb || !Util.isDesktop()) {
|
if (runtime == null || kIsWeb || !Util.isDesktop()) {
|
||||||
return const AppNotificationState();
|
return const AppNotificationState();
|
||||||
}
|
}
|
||||||
await _initNotifications(appLocalizationsFor(runtime.downloaderConfig.extra.locale));
|
await _initNotifications(
|
||||||
|
appLocalizationsFor(runtime.downloaderConfig.extra.locale),
|
||||||
|
requestPermissions: runtime.downloaderConfig.extra.desktopNotification,
|
||||||
|
);
|
||||||
_listenTaskEvents();
|
_listenTaskEvents();
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
unawaited(_taskEventSubscription?.cancel());
|
unawaited(_taskEventSubscription?.cancel());
|
||||||
@@ -42,11 +48,13 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
|||||||
return const AppNotificationState(started: true);
|
return const AppNotificationState(started: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initNotifications(AppLocalizations locale) async {
|
Future<void> _initNotifications(AppLocalizations locale, {required bool requestPermissions}) async {
|
||||||
const darwin = DarwinInitializationSettings(
|
// The runtime is watched, so enabling notifications in settings also
|
||||||
requestAlertPermission: false,
|
// requests authorization. macOS remembers previously granted/denied access.
|
||||||
|
final darwin = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: requestPermissions,
|
||||||
requestBadgePermission: false,
|
requestBadgePermission: false,
|
||||||
requestSoundPermission: false,
|
requestSoundPermission: requestPermissions,
|
||||||
);
|
);
|
||||||
final linux = LinuxInitializationSettings(
|
final linux = LinuxInitializationSettings(
|
||||||
defaultActionName: locale.open,
|
defaultActionName: locale.open,
|
||||||
@@ -56,13 +64,27 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
|||||||
String? windowsIconPath;
|
String? windowsIconPath;
|
||||||
try {
|
try {
|
||||||
if (Util.isWindows()) {
|
if (Util.isWindows()) {
|
||||||
final byteData = await rootBundle.load('assets/icon/icon.ico');
|
// Windows requires a file path for IconUri, so use the bundled PNG
|
||||||
final tempDir = await getTemporaryDirectory();
|
// beside the executable rather than copying it to another directory.
|
||||||
final file = File('${tempDir.path}/notification_icon.ico');
|
final file = File(
|
||||||
await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));
|
path.join(
|
||||||
windowsIconPath = file.path;
|
path.dirname(Platform.resolvedExecutable),
|
||||||
|
'data',
|
||||||
|
'flutter_assets',
|
||||||
|
'assets',
|
||||||
|
'icon',
|
||||||
|
'icon_512.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (await file.exists()) {
|
||||||
|
windowsIconPath = file.absolute.path;
|
||||||
|
} else {
|
||||||
|
logger.w('Windows notification icon not found: ${file.path}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (error, stackTrace) {
|
||||||
|
logger.w('prepare Windows notification icon failed', error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
final windows = WindowsInitializationSettings(
|
final windows = WindowsInitializationSettings(
|
||||||
appName: 'Gopeed',
|
appName: 'Gopeed',
|
||||||
@@ -78,7 +100,7 @@ class AppNotificationController extends AsyncNotifier<AppNotificationState> {
|
|||||||
|
|
||||||
void _listenTaskEvents() {
|
void _listenTaskEvents() {
|
||||||
_taskEventSubscription?.cancel();
|
_taskEventSubscription?.cancel();
|
||||||
_taskEventSubscription = LibgopeedBoot.instance.taskEvents.listen((event) async {
|
_taskEventSubscription = taskEvents.listen((event) async {
|
||||||
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) return;
|
||||||
final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? '');
|
final locale = appLocalizationsFor(runtime?.downloaderConfig.extra.locale ?? '');
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class AppPlatformState {
|
|||||||
enum AppUpdateStatus { idle, checking, upToDate, available, failed }
|
enum AppUpdateStatus { idle, checking, upToDate, available, failed }
|
||||||
|
|
||||||
class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowListener, TrayListener {
|
class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowListener, TrayListener {
|
||||||
|
@protected
|
||||||
|
bool get isDesktop => Util.isDesktop();
|
||||||
|
|
||||||
bool _started = false;
|
bool _started = false;
|
||||||
String? _trayLocaleConfig;
|
String? _trayLocaleConfig;
|
||||||
bool _updateInitialized = false;
|
bool _updateInitialized = false;
|
||||||
@@ -74,30 +77,54 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AppPlatformState> build() async {
|
Future<AppPlatformState> build() async {
|
||||||
final runtime = ref.watch(appRuntimeControllerProvider).value;
|
ref.listen(appRuntimeControllerProvider, (_, next) {
|
||||||
if (runtime == null || kIsWeb) {
|
unawaited(
|
||||||
return _snapshot();
|
_handleRuntimeChange(next.value).catchError((Object error, StackTrace stackTrace) {
|
||||||
}
|
if (ref.mounted) state = AsyncValue.error(error, stackTrace);
|
||||||
if (!Util.isDesktop()) {
|
}),
|
||||||
_scheduleInitialUpdateCheck();
|
);
|
||||||
return _snapshot();
|
});
|
||||||
}
|
|
||||||
if (_started) {
|
|
||||||
final localeConfig = runtime.downloaderConfig.extra.locale;
|
|
||||||
if (_trayLocaleConfig != localeConfig) await _initTray();
|
|
||||||
return _snapshot();
|
|
||||||
}
|
|
||||||
await _start();
|
|
||||||
_scheduleInitialUpdateCheck();
|
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
|
if (!_started) return;
|
||||||
|
_started = false;
|
||||||
windowManager.removeListener(this);
|
windowManager.removeListener(this);
|
||||||
trayManager.removeListener(this);
|
trayManager.removeListener(this);
|
||||||
unawaited(HostRpcService.instance.stop());
|
unawaited(HostRpcService.instance.stop());
|
||||||
unawaited(AppWindowCapabilityHost.instance.stop());
|
unawaited(AppWindowCapabilityHost.instance.stop());
|
||||||
});
|
});
|
||||||
|
final runtime = ref.read(appRuntimeControllerProvider).value;
|
||||||
|
if (runtime == null || kIsWeb) {
|
||||||
|
return _snapshot();
|
||||||
|
}
|
||||||
|
if (!isDesktop) {
|
||||||
|
_scheduleInitialUpdateCheck();
|
||||||
|
return _snapshot();
|
||||||
|
}
|
||||||
|
if (_started) {
|
||||||
|
return _snapshot();
|
||||||
|
}
|
||||||
|
await _start();
|
||||||
|
_scheduleInitialUpdateCheck();
|
||||||
return _snapshot();
|
return _snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRuntimeChange(AppRuntimeState? runtime) async {
|
||||||
|
if (runtime == null || kIsWeb) return;
|
||||||
|
if (!isDesktop) {
|
||||||
|
_scheduleInitialUpdateCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_started) {
|
||||||
|
await _start();
|
||||||
|
_scheduleInitialUpdateCheck();
|
||||||
|
_emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_trayLocaleConfig != runtime.downloaderConfig.extra.locale) {
|
||||||
|
await _initTray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _scheduleInitialUpdateCheck() {
|
void _scheduleInitialUpdateCheck() {
|
||||||
if (_updateInitialized) return;
|
if (_updateInitialized) return;
|
||||||
_updateInitialized = true;
|
_updateInitialized = true;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../features/tasks/application/task_list_navigation.dart';
|
||||||
|
|
||||||
import '../../features/extensions/presentation/pages/extensions_page.dart';
|
import '../../features/extensions/presentation/pages/extensions_page.dart';
|
||||||
import '../../features/extensions/presentation/pages/extension_details_page.dart';
|
import '../../features/extensions/presentation/pages/extension_details_page.dart';
|
||||||
@@ -17,6 +19,13 @@ import 'shells/main_shell.dart';
|
|||||||
class AppRouter {
|
class AppRouter {
|
||||||
static final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
static final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
static void showDownloadingTasks() {
|
||||||
|
final context = rootNavigatorKey.currentContext;
|
||||||
|
if (context == null || !context.mounted) return;
|
||||||
|
ProviderScope.containerOf(context, listen: false).read(taskListNavigationProvider.notifier).showDownloading();
|
||||||
|
GoRouter.of(context).go('/');
|
||||||
|
}
|
||||||
|
|
||||||
static GoRouter build(WebAuthController webAuthController) {
|
static GoRouter build(WebAuthController webAuthController) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
navigatorKey: rootNavigatorKey,
|
navigatorKey: rootNavigatorKey,
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ import '../window/app_window_appearance.dart';
|
|||||||
import 'capability_rpc.dart';
|
import 'capability_rpc.dart';
|
||||||
import 'gopeed_capability.dart';
|
import 'gopeed_capability.dart';
|
||||||
import 'storage_capability.dart';
|
import 'storage_capability.dart';
|
||||||
|
import 'app_navigation_capability.dart';
|
||||||
|
import '../../app/router/app_router.dart';
|
||||||
|
|
||||||
class AppCapabilities {
|
class AppCapabilities {
|
||||||
AppCapabilities(CapabilityInvoker invoker) : gopeed = GopeedService(invoker), storage = AppStorageService(invoker);
|
AppCapabilities(CapabilityInvoker invoker)
|
||||||
|
: gopeed = GopeedService(invoker),
|
||||||
|
storage = AppStorageService(invoker),
|
||||||
|
navigation = AppNavigationService(invoker);
|
||||||
|
|
||||||
final GopeedService gopeed;
|
final GopeedService gopeed;
|
||||||
final AppStorageService storage;
|
final AppStorageService storage;
|
||||||
|
final AppNavigationService navigation;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAppCapabilities {
|
class LocalAppCapabilities {
|
||||||
@@ -29,6 +35,10 @@ class LocalAppCapabilities {
|
|||||||
registry = CapabilityRegistry(codecs);
|
registry = CapabilityRegistry(codecs);
|
||||||
_bindGopeed(registry);
|
_bindGopeed(registry);
|
||||||
_bindStorage(registry);
|
_bindStorage(registry);
|
||||||
|
registry.bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||||
|
AppRouter.showDownloadingTasks();
|
||||||
|
return const RpcUnit();
|
||||||
|
});
|
||||||
capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'capability_rpc.dart';
|
||||||
|
|
||||||
|
abstract final class NavigationMethods {
|
||||||
|
static const showDownloadingTasks = RpcMethod<RpcUnit, RpcUnit>('navigation.tasks.downloading');
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppNavigationService {
|
||||||
|
const AppNavigationService(this._invoker);
|
||||||
|
|
||||||
|
final CapabilityInvoker _invoker;
|
||||||
|
|
||||||
|
Future<void> showDownloadingTasks() async {
|
||||||
|
await _invoker.invoke(NavigationMethods.showDownloadingTasks, const RpcUnit());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ 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();
|
final _taskEvents = StreamController<TaskEvent>.broadcast();
|
||||||
|
int _requestID = 0;
|
||||||
|
|
||||||
LibgopeedChannel() {
|
LibgopeedChannel() {
|
||||||
_channel.setMethodCallHandler((call) async {
|
_channel.setMethodCallHandler((call) async {
|
||||||
@@ -50,7 +51,9 @@ class LibgopeedChannel implements LibgopeedInterface {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
|
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
|
||||||
|
final requestID = _requestID++;
|
||||||
final result = await _channel.invokeMethod<String>('invoke', {
|
final result = await _channel.invokeMethod<String>('invoke', {
|
||||||
|
'requestID': requestID,
|
||||||
'method': method,
|
'method': method,
|
||||||
'path': path,
|
'path': path,
|
||||||
'query': query,
|
'query': query,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:ffi';
|
|
||||||
|
|
||||||
import 'package:ffi/ffi.dart';
|
|
||||||
|
|
||||||
import '../ffi/libgopeed_bind.dart';
|
|
||||||
import '../ffi/libgopeed_worker.dart';
|
import '../ffi/libgopeed_worker.dart';
|
||||||
import 'api_server_state.dart';
|
import 'api_server_state.dart';
|
||||||
import 'libgopeed_interface.dart';
|
import 'libgopeed_interface.dart';
|
||||||
@@ -12,51 +8,22 @@ import 'start_config.dart';
|
|||||||
import 'task_event.dart';
|
import 'task_event.dart';
|
||||||
|
|
||||||
class LibgopeedFFi implements LibgopeedInterface {
|
class LibgopeedFFi implements LibgopeedInterface {
|
||||||
late LibgopeedBind _libgopeed;
|
LibgopeedFFi() {
|
||||||
|
_worker.taskEvents.listen(_onTaskEvent, onError: _taskEvents.addError);
|
||||||
|
}
|
||||||
|
|
||||||
final _taskEvents = StreamController<TaskEvent>.broadcast();
|
final _taskEvents = StreamController<TaskEvent>.broadcast();
|
||||||
final _worker = LibgopeedWorker();
|
final _worker = LibgopeedWorker();
|
||||||
late final NativeCallable<Void Function(Pointer<Char>)> _taskEventCallback;
|
|
||||||
|
|
||||||
LibgopeedFFi(LibgopeedBind libgopeed) {
|
void _onTaskEvent(Map<String, dynamic> payload) {
|
||||||
_libgopeed = libgopeed;
|
_taskEvents.add(TaskEvent.fromJson(payload));
|
||||||
_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) => _worker.start(jsonEncode(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stop() {
|
Future<void> stop() => _worker.stop();
|
||||||
var completer = Completer<void>();
|
|
||||||
_libgopeed.Stop();
|
|
||||||
completer.complete();
|
|
||||||
return completer.future;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ApiServerOperationResult> _apiServerOperation(String operation) async {
|
Future<ApiServerOperationResult> _apiServerOperation(String operation) async {
|
||||||
final payload = await _worker.apiServer(operation);
|
final payload = await _worker.apiServer(operation);
|
||||||
@@ -77,33 +44,12 @@ class LibgopeedFFi implements LibgopeedInterface {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
|
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
|
||||||
final methodPtr = method.toNativeUtf8();
|
return jsonEncode(await _worker.invoke(method, path, query, body));
|
||||||
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
|
@override
|
||||||
Stream<TaskEvent> get taskEvents => _taskEvents.stream;
|
Stream<TaskEvent> get taskEvents => _taskEvents.stream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> subscribeTaskEvents(Set<TaskEventType> events) async {
|
Future<void> subscribeTaskEvents(Set<TaskEventType> events) => _worker.subscribeTaskEvents(events.mask);
|
||||||
_libgopeed.SubscribeTaskEvents(events.mask, events.isEmpty ? 0 : _taskEventCallback.nativeFunction.address);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ffi';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import '../../util/util.dart';
|
import '../../util/util.dart';
|
||||||
import '../common/libgopeed_channel.dart';
|
import '../common/libgopeed_channel.dart';
|
||||||
@@ -9,7 +7,6 @@ import '../common/libgopeed_interface.dart';
|
|||||||
import '../common/api_server_state.dart';
|
import '../common/api_server_state.dart';
|
||||||
import '../common/start_config.dart';
|
import '../common/start_config.dart';
|
||||||
import '../common/task_event.dart';
|
import '../common/task_event.dart';
|
||||||
import '../ffi/libgopeed_bind.dart';
|
|
||||||
import '../libgopeed_boot.dart';
|
import '../libgopeed_boot.dart';
|
||||||
|
|
||||||
LibgopeedBoot create() => LibgopeedBootNative();
|
LibgopeedBoot create() => LibgopeedBootNative();
|
||||||
@@ -19,17 +16,7 @@ class LibgopeedBootNative implements LibgopeedBoot {
|
|||||||
|
|
||||||
LibgopeedBootNative() {
|
LibgopeedBootNative() {
|
||||||
if (Util.isDesktop()) {
|
if (Util.isDesktop()) {
|
||||||
var libName = "libgopeed.";
|
_libgopeed = LibgopeedFFi();
|
||||||
if (Platform.isWindows) {
|
|
||||||
libName += "dll";
|
|
||||||
}
|
|
||||||
if (Platform.isMacOS) {
|
|
||||||
libName += "dylib";
|
|
||||||
}
|
|
||||||
if (Platform.isLinux) {
|
|
||||||
libName += "so";
|
|
||||||
}
|
|
||||||
_libgopeed = LibgopeedFFi(LibgopeedBind(DynamicLibrary.open(libName)));
|
|
||||||
} else {
|
} else {
|
||||||
_libgopeed = LibgopeedChannel();
|
_libgopeed = LibgopeedChannel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,53 +30,67 @@ 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();
|
ffi.Pointer<ffi.Char> GetAPIServerState() {
|
||||||
|
return _GetAPIServerState();
|
||||||
|
}
|
||||||
|
|
||||||
late final _GetAPIServerStatePtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('GetAPIServerState');
|
late final _GetAPIServerStatePtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('GetAPIServerState');
|
||||||
late final _GetAPIServerState = _GetAPIServerStatePtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
|
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 _StartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StartAPIServer');
|
||||||
late final _StartAPIServer = _StartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
|
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 _StopAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StopAPIServer');
|
||||||
late final _StopAPIServer = _StopAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
|
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 _RestartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('RestartAPIServer');
|
||||||
late final _RestartAPIServer = _RestartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
|
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> method,
|
||||||
ffi.Pointer<ffi.Char> path,
|
ffi.Pointer<ffi.Char> path,
|
||||||
ffi.Pointer<ffi.Char> query,
|
ffi.Pointer<ffi.Char> query,
|
||||||
ffi.Pointer<ffi.Char> body,
|
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<
|
_lookup<
|
||||||
ffi.NativeFunction<
|
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.Pointer<ffi.Char>,
|
ffi.Pointer<ffi.Char>,
|
||||||
ffi.Pointer<ffi.Char>,
|
ffi.Pointer<ffi.Char>,
|
||||||
|
ffi.UnsignedLongLong,
|
||||||
|
ffi.UintPtr,
|
||||||
)
|
)
|
||||||
>
|
>
|
||||||
>('Invoke');
|
>('InvokeAsync');
|
||||||
late final _Invoke =
|
late final _InvokeAsync =
|
||||||
_InvokePtr.asFunction<
|
_InvokeAsyncPtr.asFunction<
|
||||||
ffi.Pointer<ffi.Char> Function(
|
void Function(
|
||||||
ffi.Pointer<ffi.Char>,
|
ffi.Pointer<ffi.Char>,
|
||||||
ffi.Pointer<ffi.Char>,
|
ffi.Pointer<ffi.Char>,
|
||||||
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);
|
return _SubscribeTaskEvents(mask, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _SubscribeTaskEventsPtr = _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Uint64, ffi.UintPtr)>>(
|
late final _SubscribeTaskEventsPtr =
|
||||||
'SubscribeTaskEvents',
|
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.UnsignedLongLong, ffi.UintPtr)>>('SubscribeTaskEvents');
|
||||||
);
|
|
||||||
late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction<void Function(int, int)>();
|
late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction<void Function(int, int)>();
|
||||||
|
|
||||||
void FreeCString(ffi.Pointer<ffi.Char> value) {
|
void FreeCString(ffi.Pointer<ffi.Char> value) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:ffi';
|
import 'dart:ffi';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
@@ -7,38 +8,74 @@ import 'package:ffi/ffi.dart';
|
|||||||
|
|
||||||
import 'libgopeed_bind.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 {
|
class LibgopeedWorker {
|
||||||
LibgopeedWorker() {
|
factory LibgopeedWorker() => _instance;
|
||||||
|
|
||||||
|
LibgopeedWorker._() {
|
||||||
_receivePort.listen(_handleWorkerMessage);
|
_receivePort.listen(_handleWorkerMessage);
|
||||||
_errorPort.listen(_handleWorkerError);
|
_errorPort.listen(_handleWorkerError);
|
||||||
_isolateReady = Isolate.spawn(_workerMain, _receivePort.sendPort, onError: _errorPort.sendPort);
|
_isolateReady = Isolate.spawn(_workerMain, _receivePort.sendPort, onError: _errorPort.sendPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final LibgopeedWorker _instance = LibgopeedWorker._();
|
||||||
|
|
||||||
final ReceivePort _receivePort = ReceivePort();
|
final ReceivePort _receivePort = ReceivePort();
|
||||||
final ReceivePort _errorPort = 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;
|
late final Future<Isolate> _isolateReady;
|
||||||
final Completer<SendPort> _sendPort = Completer<SendPort>();
|
final Completer<SendPort> _sendPort = Completer<SendPort>();
|
||||||
|
Future<void>? _stopFuture;
|
||||||
int _requestID = 0;
|
int _requestID = 0;
|
||||||
|
bool _stopping = false;
|
||||||
|
bool _stopped = false;
|
||||||
|
|
||||||
Future<String> invoke(String method, String path, String query, String body) async {
|
Stream<Map<String, dynamic>> get taskEvents => _taskEvents.stream;
|
||||||
await _isolateReady;
|
|
||||||
final sendPort = await _sendPort.future;
|
Future<int> start(String config) => _request<int>('start', [config]);
|
||||||
final requestID = _requestID++;
|
|
||||||
final completer = Completer<String>();
|
Future<void> stop() => _stopFuture ??= _stop();
|
||||||
_pending[requestID] = completer;
|
|
||||||
sendPort.send(['invoke', requestID, method, path, query, body]);
|
Future<void> _stop() async {
|
||||||
return completer.future;
|
_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;
|
await _isolateReady;
|
||||||
final sendPort = await _sendPort.future;
|
final sendPort = await _sendPort.future;
|
||||||
final requestID = _requestID++;
|
final requestID = _requestID++;
|
||||||
final completer = Completer<String>();
|
final completer = Completer<Object?>();
|
||||||
_pending[requestID] = completer;
|
_pending[requestID] = completer;
|
||||||
sendPort.send(['apiServer', requestID, operation]);
|
sendPort.send([type, requestID, ...arguments]);
|
||||||
return completer.future;
|
return (await completer.future) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleWorkerMessage(dynamic message) {
|
void _handleWorkerMessage(dynamic message) {
|
||||||
@@ -47,13 +84,18 @@ class LibgopeedWorker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final response = message as List<dynamic>;
|
final response = message as List<dynamic>;
|
||||||
final requestID = response[0] as int;
|
switch (response[0] as String) {
|
||||||
final completer = _pending.remove(requestID);
|
case 'response':
|
||||||
if (completer == null) return;
|
final requestID = response[1] as int;
|
||||||
if (response[1] as bool) {
|
final completer = _pending.remove(requestID);
|
||||||
completer.complete(response[2] as String);
|
if (completer == null) return;
|
||||||
} else {
|
if (response[2] as bool) {
|
||||||
completer.completeError(StateError(response[2] as String));
|
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);
|
completer.completeError(error);
|
||||||
}
|
}
|
||||||
_pending.clear();
|
_pending.clear();
|
||||||
|
_taskEvents.addError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _workerMain(SendPort mainSendPort) {
|
static void _workerMain(SendPort mainSendPort) {
|
||||||
final requests = ReceivePort();
|
final requests = ReceivePort();
|
||||||
final bindings = LibgopeedBind(DynamicLibrary.open(_libraryName()));
|
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);
|
mainSendPort.send(requests.sendPort);
|
||||||
requests.listen((dynamic message) {
|
requests.listen((dynamic message) {
|
||||||
final request = message as List<dynamic>;
|
final request = message as List<dynamic>;
|
||||||
final type = request[0] as String;
|
final type = request[0] as String;
|
||||||
final requestID = request[1] as int;
|
final requestID = request[1] as int;
|
||||||
try {
|
if (type == 'invoke') {
|
||||||
final response = switch (type) {
|
try {
|
||||||
'invoke' => _invoke(
|
_invokeAsync(
|
||||||
bindings,
|
bindings,
|
||||||
|
invokeResultCallback.nativeFunction.address,
|
||||||
|
requestID,
|
||||||
request[2] as String,
|
request[2] as String,
|
||||||
request[3] as String,
|
request[3] as String,
|
||||||
request[4] as String,
|
request[4] as String,
|
||||||
request[5] 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),
|
'apiServer' => _apiServer(bindings, request[2] as String),
|
||||||
|
'subscribeTaskEvents' => _subscribeTaskEvents(
|
||||||
|
bindings,
|
||||||
|
taskEventCallback.nativeFunction.address,
|
||||||
|
request[2] as int,
|
||||||
|
),
|
||||||
_ => throw StateError('Unknown libgopeed worker request: $type'),
|
_ => 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) {
|
} 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) {
|
static String _apiServer(LibgopeedBind bindings, String operation) {
|
||||||
final resultPtr = switch (operation) {
|
final resultPtr = switch (operation) {
|
||||||
'get' => bindings.GetAPIServerState(),
|
'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 methodPtr = method.toNativeUtf8();
|
||||||
final pathPtr = path.toNativeUtf8();
|
final pathPtr = path.toNativeUtf8();
|
||||||
final queryPtr = query.toNativeUtf8();
|
final queryPtr = query.toNativeUtf8();
|
||||||
final bodyPtr = body.toNativeUtf8();
|
final bodyPtr = body.toNativeUtf8();
|
||||||
Pointer<Char>? resultPtr;
|
|
||||||
try {
|
try {
|
||||||
resultPtr = bindings.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast());
|
bindings.InvokeAsync(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast(), requestID, callback);
|
||||||
if (resultPtr == nullptr) {
|
|
||||||
throw StateError('Gopeed Invoke returned a null response');
|
|
||||||
}
|
|
||||||
return resultPtr.cast<Utf8>().toDartString();
|
|
||||||
} finally {
|
} finally {
|
||||||
malloc.free(methodPtr);
|
malloc.free(methodPtr);
|
||||||
malloc.free(pathPtr);
|
malloc.free(pathPtr);
|
||||||
malloc.free(queryPtr);
|
malloc.free(queryPtr);
|
||||||
malloc.free(bodyPtr);
|
malloc.free(bodyPtr);
|
||||||
if (resultPtr != null && resultPtr != nullptr) {
|
|
||||||
bindings.FreeCString(resultPtr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../libgopeed_boot.dart';
|
|
||||||
import '../../../ffi/libgopeed_worker.dart';
|
|
||||||
import '../../../../util/util.dart';
|
import '../../../../util/util.dart';
|
||||||
|
import '../../../ffi/libgopeed_worker.dart';
|
||||||
|
import '../../../libgopeed_boot.dart';
|
||||||
import '../gopeed_transport.dart';
|
import '../gopeed_transport.dart';
|
||||||
|
|
||||||
GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport();
|
GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport();
|
||||||
@@ -32,14 +32,16 @@ class NativeGopeedTransport implements GopeedTransport {
|
|||||||
}) async {
|
}) async {
|
||||||
final request = _RequestParts.from(path, queryParameters);
|
final request = _RequestParts.from(path, queryParameters);
|
||||||
final requestBody = data == null ? '' : jsonEncode(data);
|
final requestBody = data == null ? '' : jsonEncode(data);
|
||||||
final payload = _ffiWorker != null
|
final worker = _ffiWorker;
|
||||||
? await _ffiWorker.invoke(method.toUpperCase(), request.path, request.query, requestBody)
|
if (worker != null) {
|
||||||
: await LibgopeedBoot.instance.invoke(
|
return worker.invoke(method.toUpperCase(), request.path, request.query, requestBody);
|
||||||
method.toUpperCase(),
|
}
|
||||||
request.path,
|
final payload = await LibgopeedBoot.instance.invoke(
|
||||||
query: request.query,
|
method.toUpperCase(),
|
||||||
body: requestBody,
|
request.path,
|
||||||
);
|
query: request.query,
|
||||||
|
body: requestBody,
|
||||||
|
);
|
||||||
return jsonDecode(payload);
|
return jsonDecode(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class AppWindowBootstrap {
|
|||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
|
|
||||||
final windowOptions = WindowOptions(
|
final windowOptions = WindowOptions(
|
||||||
|
title: 'Gopeed',
|
||||||
size: const Size(1024, 768),
|
size: const Size(1024, 768),
|
||||||
center: true,
|
center: true,
|
||||||
skipTaskbar: false,
|
skipTaskbar: false,
|
||||||
@@ -74,8 +75,8 @@ class AppWindowBootstrap {
|
|||||||
|
|
||||||
static String subWindowTitle(AppWindowType type, AppLocalizations l10n) {
|
static String subWindowTitle(AppWindowType type, AppLocalizations l10n) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
AppWindowType.createTask => '${l10n.create} - Gopeed',
|
AppWindowType.createTask => 'Gopeed - ${l10n.create}',
|
||||||
_ => l10n.appTitle,
|
_ => 'Gopeed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../../l10n/l10n.dart';
|
|||||||
import '../../../../shared/theme/app_design_tokens.dart';
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_loading_button.dart';
|
import '../../../../shared/widgets/app_loading_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
|
|
||||||
class LoginForm extends StatelessWidget {
|
class LoginForm extends StatelessWidget {
|
||||||
const LoginForm({
|
const LoginForm({
|
||||||
@@ -159,7 +160,7 @@ class _LoginField extends StatelessWidget {
|
|||||||
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w600),
|
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppDesignTokens.space8),
|
const SizedBox(height: AppDesignTokens.space8),
|
||||||
shad.TextField(
|
AppTextField(
|
||||||
key: fieldKey,
|
key: fieldKey,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../../../../core/window/app_window_chrome.dart';
|
|||||||
import '../../../../shared/theme/app_design_tokens.dart';
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_primary_button.dart';
|
import '../../../../shared/widgets/app_primary_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/app_tooltip.dart';
|
import '../../../../shared/widgets/app_tooltip.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
@@ -209,7 +210,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _installFromFolder() async {
|
Future<void> _installFromFolder() async {
|
||||||
final folder = await FilePicker.platform.getDirectoryPath();
|
final folder = await FilePicker.getDirectoryPath();
|
||||||
if (folder == null || folder.isEmpty) return;
|
if (folder == null || folder.isEmpty) return;
|
||||||
await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(folder, devInstall: true));
|
await _runAction(() => ref.read(extensionsControllerProvider.notifier).installFromUrl(folder, devInstall: true));
|
||||||
}
|
}
|
||||||
@@ -629,9 +630,10 @@ class _Toolbar extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey);
|
final manualInstallBusy = state.busyExtensionIds.contains(ExtensionsController.manualInstallBusyKey);
|
||||||
final search = shad.TextField(
|
final search = AppTextField(
|
||||||
key: const ValueKey('extension-search-input'),
|
key: const ValueKey('extension-search-input'),
|
||||||
controller: searchController,
|
controller: searchController,
|
||||||
|
padding: MediaQuery.sizeOf(context).width >= Breakpoints.mobile ? AppDesignTokens.compactTextFieldPadding : null,
|
||||||
placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)),
|
placeholder: Text(context.l10n.searchExtensions, style: TextStyle(color: palette.searchHint)),
|
||||||
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))],
|
features: [shad.InputFeature.leading(Icon(Icons.search_rounded, size: 16, color: palette.textMuted))],
|
||||||
onSubmitted: onSearch,
|
onSubmitted: onSearch,
|
||||||
@@ -826,7 +828,7 @@ class _InstallPopoverState extends State<_InstallPopover> {
|
|||||||
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700),
|
style: TextStyle(color: palette.textPrimary, fontSize: 13, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
shad.TextField(
|
AppTextField(
|
||||||
key: const ValueKey('extension-install-url-input'),
|
key: const ValueKey('extension-install-url-input'),
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
@@ -1222,9 +1224,9 @@ class _ExtensionSettingField extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (setting.type == api_extension.SettingType.boolean)
|
if (setting.type == api_extension.SettingType.boolean)
|
||||||
shad.TextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint))
|
AppTextField(controller: controller, placeholder: Text(context.l10n.booleanValueHint))
|
||||||
else
|
else
|
||||||
shad.TextField(
|
AppTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text,
|
keyboardType: setting.type == api_extension.SettingType.number ? TextInputType.number : TextInputType.text,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import '../../../../shared/widgets/responsive_navigation_layout.dart';
|
|||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
import '../../../../util/util.dart';
|
import '../../../../util/util.dart';
|
||||||
import '../../../tasks/application/tasks_controller.dart';
|
import '../../../tasks/application/tasks_controller.dart';
|
||||||
|
import '../../../tasks/application/task_list_navigation.dart';
|
||||||
import '../../../tasks/application/pending_update_task.dart';
|
import '../../../tasks/application/pending_update_task.dart';
|
||||||
import '../../../tasks/application/task_batch_selection_controller.dart';
|
import '../../../tasks/application/task_batch_selection_controller.dart';
|
||||||
import '../../../tasks/domain/task_record.dart';
|
import '../../../tasks/domain/task_record.dart';
|
||||||
@@ -86,6 +87,11 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
final tasksAsync = ref.watch(tasksControllerProvider);
|
final tasksAsync = ref.watch(tasksControllerProvider);
|
||||||
|
ref.listen(taskListNavigationProvider, (_, _) {
|
||||||
|
_searchController.clear();
|
||||||
|
_setActiveFilter(_TaskFilter.downloading);
|
||||||
|
setState(() => _batchMode = false);
|
||||||
|
});
|
||||||
final tasksState = tasksAsync.value;
|
final tasksState = tasksAsync.value;
|
||||||
final tasks = tasksState?.tasks ?? const <TaskRecord>[];
|
final tasks = tasksState?.tasks ?? const <TaskRecord>[];
|
||||||
final filteredTasks = _filteredTasks(tasks);
|
final filteredTasks = _filteredTasks(tasks);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
|||||||
import '../../../../shared/theme/app_design_tokens.dart';
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_primary_button.dart';
|
import '../../../../shared/widgets/app_primary_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
|
|
||||||
class TasksTopBar extends StatelessWidget {
|
class TasksTopBar extends StatelessWidget {
|
||||||
@@ -110,7 +111,7 @@ class _SearchField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 40,
|
height: 40,
|
||||||
child: shad.TextField(
|
child: AppTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
style: TextStyle(color: palette.textPrimary, fontSize: 14, height: 1),
|
style: TextStyle(color: palette.textPrimary, fontSize: 14, height: 1),
|
||||||
placeholder: Text(
|
placeholder: Text(
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation;
|
import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation;
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
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';
|
||||||
@@ -23,7 +21,9 @@ import '../../../../shared/theme/app_design_tokens.dart';
|
|||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
||||||
import '../../../../shared/widgets/app_loading_button.dart';
|
import '../../../../shared/widgets/app_loading_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_number_input.dart';
|
||||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/app_tooltip.dart';
|
import '../../../../shared/widgets/app_tooltip.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
import '../../../../shared/widgets/responsive_menu_layout.dart';
|
import '../../../../shared/widgets/responsive_menu_layout.dart';
|
||||||
@@ -1375,12 +1375,7 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
|||||||
title: index == null ? context.l10n.addScript : context.l10n.editScript,
|
title: index == null ? context.l10n.addScript : context.l10n.editScript,
|
||||||
fieldLabel: context.l10n.scriptPath,
|
fieldLabel: context.l10n.scriptPath,
|
||||||
initialValue: index == null ? '' : paths[index],
|
initialValue: index == null ? '' : paths[index],
|
||||||
pickPath: Util.isDesktop()
|
pickFile: true,
|
||||||
? () async {
|
|
||||||
final result = await FilePicker.platform.pickFiles();
|
|
||||||
return result?.files.firstOrNull?.path;
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
if (value == null || !mounted) return;
|
if (value == null || !mounted) return;
|
||||||
_mutateConfig((config) {
|
_mutateConfig((config) {
|
||||||
@@ -1849,7 +1844,7 @@ class _TextSettingControl extends StatelessWidget {
|
|||||||
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity,
|
width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity,
|
||||||
child: shad.TextField(
|
child: AppTextField(
|
||||||
key: fieldKey,
|
key: fieldKey,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
@@ -1884,7 +1879,7 @@ class _NumberSettingControl extends StatelessWidget {
|
|||||||
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: desktop ? AppDesignTokens.settingsNumberControlWidth : double.infinity,
|
width: desktop ? AppDesignTokens.settingsNumberControlWidth : double.infinity,
|
||||||
child: _NumberInput(
|
child: AppNumberInput(
|
||||||
fieldKey: fieldKey,
|
fieldKey: fieldKey,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
min: min,
|
min: min,
|
||||||
@@ -1919,7 +1914,7 @@ class _HostPortControl extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: shad.TextField(
|
child: AppTextField(
|
||||||
key: hostKey,
|
key: hostKey,
|
||||||
controller: hostController,
|
controller: hostController,
|
||||||
hintText: context.l10n.server,
|
hintText: context.l10n.server,
|
||||||
@@ -1929,7 +1924,7 @@ class _HostPortControl extends StatelessWidget {
|
|||||||
const SizedBox(width: AppDesignTokens.space8),
|
const SizedBox(width: AppDesignTokens.space8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: AppDesignTokens.settingsNumberControlWidth,
|
width: AppDesignTokens.settingsNumberControlWidth,
|
||||||
child: _NumberInput(
|
child: AppNumberInput(
|
||||||
fieldKey: portKey,
|
fieldKey: portKey,
|
||||||
controller: portController,
|
controller: portController,
|
||||||
min: 0,
|
min: 0,
|
||||||
@@ -1946,141 +1941,6 @@ class _HostPortControl extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NumberInput extends StatefulWidget {
|
|
||||||
const _NumberInput({
|
|
||||||
required this.controller,
|
|
||||||
required this.min,
|
|
||||||
required this.step,
|
|
||||||
required this.decimalPlaces,
|
|
||||||
this.fieldKey,
|
|
||||||
this.max,
|
|
||||||
this.hintText,
|
|
||||||
this.enabled = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
final TextEditingController controller;
|
|
||||||
final num min;
|
|
||||||
final num? max;
|
|
||||||
final double step;
|
|
||||||
final int decimalPlaces;
|
|
||||||
final Key? fieldKey;
|
|
||||||
final String? hintText;
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_NumberInput> createState() => _NumberInputState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NumberInputState extends State<_NumberInput> {
|
|
||||||
bool _normalizing = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
widget.controller.addListener(_normalizeDecimalStepValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant _NumberInput oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.controller == widget.controller) return;
|
|
||||||
oldWidget.controller.removeListener(_normalizeDecimalStepValue);
|
|
||||||
widget.controller.addListener(_normalizeDecimalStepValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
widget.controller.removeListener(_normalizeDecimalStepValue);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _normalizeDecimalStepValue() {
|
|
||||||
if (_normalizing || widget.decimalPlaces <= 0) return;
|
|
||||||
final text = widget.controller.text;
|
|
||||||
final separator = text.indexOf('.');
|
|
||||||
if (separator < 0 || text.length - separator - 1 <= widget.decimalPlaces) return;
|
|
||||||
final value = double.tryParse(text);
|
|
||||||
if (value == null) return;
|
|
||||||
|
|
||||||
var normalized = value.toStringAsFixed(widget.decimalPlaces);
|
|
||||||
normalized = normalized.replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), '');
|
|
||||||
if (normalized == text) return;
|
|
||||||
|
|
||||||
_normalizing = true;
|
|
||||||
widget.controller.value = TextEditingValue(
|
|
||||||
text: normalized,
|
|
||||||
selection: TextSelection.collapsed(offset: normalized.length),
|
|
||||||
);
|
|
||||||
_normalizing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final formatters = widget.decimalPlaces > 0
|
|
||||||
? <TextInputFormatter>[_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)]
|
|
||||||
: <TextInputFormatter>[
|
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
|
||||||
_NumericalRangeFormatter(min: widget.min.toInt(), max: widget.max?.toInt()),
|
|
||||||
];
|
|
||||||
return shad.TextField(
|
|
||||||
key: widget.fieldKey,
|
|
||||||
controller: widget.controller,
|
|
||||||
hintText: widget.hintText,
|
|
||||||
keyboardType: widget.decimalPlaces > 0
|
|
||||||
? const TextInputType.numberWithOptions(decimal: true)
|
|
||||||
: TextInputType.number,
|
|
||||||
inputFormatters: formatters,
|
|
||||||
enabled: widget.enabled,
|
|
||||||
features: [
|
|
||||||
shad.InputFeature.spinner(
|
|
||||||
step: widget.step,
|
|
||||||
min: widget.min.toDouble(),
|
|
||||||
max: widget.max?.toDouble(),
|
|
||||||
invalidValue: widget.min.toDouble(),
|
|
||||||
enableGesture: false,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keeps integer text within the same inclusive bounds as the legacy settings UI.
|
|
||||||
/// Empty text is allowed while editing; the previous valid value remains in config
|
|
||||||
/// until the user enters a number again.
|
|
||||||
class _NumericalRangeFormatter extends TextInputFormatter {
|
|
||||||
const _NumericalRangeFormatter({required this.min, this.max});
|
|
||||||
|
|
||||||
final int min;
|
|
||||||
final int? max;
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
|
||||||
if (newValue.text.isEmpty) return newValue;
|
|
||||||
final parsed = int.tryParse(newValue.text);
|
|
||||||
if (parsed == null) return oldValue;
|
|
||||||
final bounded = parsed.clamp(min, max ?? parsed).toInt();
|
|
||||||
if (bounded == parsed) return newValue;
|
|
||||||
final text = bounded.toString();
|
|
||||||
return TextEditingValue(
|
|
||||||
text: text,
|
|
||||||
selection: TextSelection.collapsed(offset: text.length),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allows a non-negative decimal number with a bounded number of fractional digits.
|
|
||||||
class _DecimalNumberFormatter extends TextInputFormatter {
|
|
||||||
_DecimalNumberFormatter({required this.decimalPlaces}) : _pattern = RegExp('^\\d*(?:\\.\\d{0,$decimalPlaces})?\$');
|
|
||||||
|
|
||||||
final int decimalPlaces;
|
|
||||||
final RegExp _pattern;
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
|
||||||
return _pattern.hasMatch(newValue.text) ? newValue : oldValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ExternalTextLink extends StatefulWidget {
|
class _ExternalTextLink extends StatefulWidget {
|
||||||
const _ExternalTextLink({super.key, required this.label, required this.onPressed});
|
const _ExternalTextLink({super.key, required this.label, required this.onPressed});
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ import '../../../../shared/theme/app_design_tokens.dart';
|
|||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||||
import '../../../../shared/widgets/app_primary_button.dart';
|
import '../../../../shared/widgets/app_primary_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
|
|
||||||
class DownloadCategoryDraft {
|
class DownloadCategoryDraft {
|
||||||
@@ -169,7 +170,7 @@ Future<DownloadCategoryDraft?> showDownloadCategoryDialog(
|
|||||||
children: [
|
children: [
|
||||||
Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
shad.TextField(key: const ValueKey('download-category-name'), controller: nameController),
|
AppTextField(key: const ValueKey('download-category-name'), controller: nameController),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../../shared/theme/app_palette.dart';
|
|||||||
import '../../../../shared/widgets/app_loading_button.dart';
|
import '../../../../shared/widgets/app_loading_button.dart';
|
||||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||||
import '../../../../shared/widgets/app_primary_button.dart';
|
import '../../../../shared/widgets/app_primary_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
|
|
||||||
class SettingsListEntry {
|
class SettingsListEntry {
|
||||||
@@ -121,7 +122,7 @@ Future<String?> showTextSettingDialog(
|
|||||||
required String fieldLabel,
|
required String fieldLabel,
|
||||||
String initialValue = '',
|
String initialValue = '',
|
||||||
bool requireHttpUrl = false,
|
bool requireHttpUrl = false,
|
||||||
Future<String?> Function()? pickPath,
|
bool pickFile = false,
|
||||||
Future<void> Function(String value)? onTest,
|
Future<void> Function(String value)? onTest,
|
||||||
}) async {
|
}) async {
|
||||||
final controller = TextEditingController(text: initialValue);
|
final controller = TextEditingController(text: initialValue);
|
||||||
@@ -188,16 +189,12 @@ Future<String?> showTextSettingDialog(
|
|||||||
children: [
|
children: [
|
||||||
Text(fieldLabel, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
Text(fieldLabel, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
if (pickPath == null)
|
if (!pickFile)
|
||||||
shad.TextField(controller: controller)
|
AppTextField(controller: controller)
|
||||||
else
|
else
|
||||||
AppPathPickerField(
|
AppPathPickerField.file(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
desktopWidth: AppDesignTokens.settingsFormControlWidth,
|
desktopWidth: AppDesignTokens.settingsFormControlWidth,
|
||||||
onPick: () async {
|
|
||||||
final path = await pickPath();
|
|
||||||
if (path != null && path.isNotEmpty) controller.text = path;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (validationMessage != null || testMessage != null) ...[
|
if (validationMessage != null || testMessage != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -302,7 +299,7 @@ Future<GithubMirrorDraft?> showGithubMirrorDialog(BuildContext context, {GithubM
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Text(dialogContext.l10n.githubMirrorUrl, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
Text(dialogContext.l10n.githubMirrorUrl, style: TextStyle(color: palette.textSecondary, fontSize: 12)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
shad.TextField(controller: controller),
|
AppTextField(controller: controller),
|
||||||
if (validationMessage != null) ...[
|
if (validationMessage != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
|
Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
final taskListNavigationProvider = NotifierProvider<TaskListNavigation, int>(TaskListNavigation.new);
|
||||||
|
|
||||||
|
// A request also resets an already-visible list's filter, search and selection.
|
||||||
|
class TaskListNavigation extends Notifier<int> {
|
||||||
|
@override
|
||||||
|
int build() => 0;
|
||||||
|
|
||||||
|
void showDownloading() => state++;
|
||||||
|
}
|
||||||
@@ -29,14 +29,17 @@ import '../../../../shared/theme/app_design_tokens.dart';
|
|||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
import '../../../../shared/widgets/app_choice_segmented_control.dart';
|
||||||
import '../../../../shared/widgets/app_http_headers_editor.dart';
|
import '../../../../shared/widgets/app_http_headers_editor.dart';
|
||||||
|
import '../../../../shared/widgets/app_loading_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_number_input.dart';
|
||||||
import '../../../../shared/widgets/app_path_picker_field.dart';
|
import '../../../../shared/widgets/app_path_picker_field.dart';
|
||||||
import '../../../../shared/widgets/app_primary_button.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/app_tooltip.dart';
|
import '../../../../shared/widgets/app_tooltip.dart';
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
import '../../../../util/util.dart';
|
import '../../../../util/util.dart';
|
||||||
import '../../application/pending_create_task.dart';
|
import '../../application/pending_create_task.dart';
|
||||||
import '../widgets/resolve_file_tree.dart';
|
import '../widgets/resolve_file_tree.dart';
|
||||||
|
import '../widgets/resolve_task_actions.dart';
|
||||||
|
|
||||||
class CreateTaskWindowPage extends ConsumerStatefulWidget {
|
class CreateTaskWindowPage extends ConsumerStatefulWidget {
|
||||||
const CreateTaskWindowPage({super.key, this.windowController, this.initialTask});
|
const CreateTaskWindowPage({super.key, this.windowController, this.initialTask});
|
||||||
@@ -85,17 +88,28 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
String _configuredDownloadDirectory = '';
|
String _configuredDownloadDirectory = '';
|
||||||
String _fileDataUri = '';
|
String _fileDataUri = '';
|
||||||
bool _programmaticUrlChange = false;
|
bool _programmaticUrlChange = false;
|
||||||
|
String _lastUrlText = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_urlController.addListener(_handleUrlChanged);
|
_urlController.addListener(_handleUrlChanged);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider);
|
final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider);
|
||||||
_applyInitialTask(task);
|
_applyInitialTask(task);
|
||||||
if (widget.initialTask == null) {
|
if (widget.initialTask == null) {
|
||||||
ref.read(pendingCreateTaskProvider.notifier).clear();
|
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) {
|
if (task == null || _urlController.text.trim().isEmpty) {
|
||||||
unawaited(_loadClipboardUrl());
|
unawaited(_loadClipboardUrl());
|
||||||
}
|
}
|
||||||
@@ -160,10 +174,15 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleUrlChanged() {
|
void _handleUrlChanged() {
|
||||||
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
final urlText = _urlController.text;
|
||||||
_fileDataUri = '';
|
if (urlText != _lastUrlText) {
|
||||||
|
_lastUrlText = urlText;
|
||||||
|
_renameController.clear();
|
||||||
|
if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) {
|
||||||
|
_fileDataUri = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_recognizeMagnetUri(_urlController.text.trim());
|
_recognizeMagnetUri(urlText.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyInitialTask(CreateTask? task) {
|
void _applyInitialTask(CreateTask? task) {
|
||||||
@@ -172,14 +191,18 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
final opts = task.opts;
|
final opts = task.opts;
|
||||||
setState(() {
|
setState(() {
|
||||||
if (req?.url.isNotEmpty == true) {
|
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;
|
_urlController.text = req!.url;
|
||||||
_initialRawUrl = req.rawUrl;
|
_initialRawUrl = req.rawUrl;
|
||||||
_initialLabels = req.labels == null ? null : Map<String, String>.of(req.labels!);
|
_initialLabels = req.labels == null ? null : Map<String, String>.of(req.labels!);
|
||||||
_applyInitialProxy(req.proxy);
|
_applyInitialProxy(req.proxy);
|
||||||
_skipVerifyCert = req.skipVerifyCert;
|
_skipVerifyCert = req.skipVerifyCert;
|
||||||
if (req.proxy != null || req.skipVerifyCert) {
|
|
||||||
_showAdvanced = true;
|
|
||||||
}
|
|
||||||
switch (_parseProtocol(req.url)) {
|
switch (_parseProtocol(req.url)) {
|
||||||
case _TaskProtocol.http:
|
case _TaskProtocol.http:
|
||||||
if (req.extra is Map) {
|
if (req.extra is Map) {
|
||||||
@@ -189,7 +212,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
if (extra.header.isNotEmpty) {
|
if (extra.header.isNotEmpty) {
|
||||||
_replaceHttpHeaders(extra.header);
|
_replaceHttpHeaders(extra.header);
|
||||||
}
|
}
|
||||||
_showAdvanced = true;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case _TaskProtocol.bt:
|
case _TaskProtocol.bt:
|
||||||
@@ -197,7 +219,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
if (req.extra is Map) {
|
if (req.extra is Map) {
|
||||||
final extra = ReqExtraBt.fromJson(Map<String, dynamic>.from(req.extra! as Map));
|
final extra = ReqExtraBt.fromJson(Map<String, dynamic>.from(req.extra! as Map));
|
||||||
_trackersController.text = extra.trackers.join('\n');
|
_trackersController.text = extra.trackers.join('\n');
|
||||||
_showAdvanced = true;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case _TaskProtocol.ed2k:
|
case _TaskProtocol.ed2k:
|
||||||
@@ -206,9 +227,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (opts != null) {
|
if (opts != null) {
|
||||||
if (opts.name.isNotEmpty) {
|
|
||||||
_renameController.text = opts.name;
|
|
||||||
}
|
|
||||||
if (opts.path.isNotEmpty) {
|
if (opts.path.isNotEmpty) {
|
||||||
_directoryController.text = opts.path;
|
_directoryController.text = opts.path;
|
||||||
}
|
}
|
||||||
@@ -222,13 +240,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
_autoExtract = extra.autoExtract;
|
_autoExtract = extra.autoExtract;
|
||||||
_archivePasswordController.text = extra.archivePassword;
|
_archivePasswordController.text = extra.archivePassword;
|
||||||
_deleteAfterExtract = extra.deleteAfterExtract;
|
_deleteAfterExtract = extra.deleteAfterExtract;
|
||||||
if (extra.autoTorrent != null ||
|
|
||||||
extra.deleteTorrentAfterDownload != null ||
|
|
||||||
extra.autoExtract != null ||
|
|
||||||
extra.archivePassword.isNotEmpty ||
|
|
||||||
extra.deleteAfterExtract) {
|
|
||||||
_showAdvanced = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -268,7 +279,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
final page = Scaffold(
|
final page = Scaffold(
|
||||||
backgroundColor: palette.sideBg,
|
backgroundColor: palette.sideBg,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(top: AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0),
|
key: const ValueKey('create-task-safe-content'),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: AppWindowChrome.reservesHeaderInset
|
||||||
|
? AppDesignTokens.windowHeaderHeight
|
||||||
|
: MediaQuery.paddingOf(context).top,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -311,7 +327,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 120,
|
height: 120,
|
||||||
child: TextField(
|
child: AppTextField(
|
||||||
|
key: const ValueKey('create-task-url-input'),
|
||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
hintText: context.l10n.pasteDownloadLinks,
|
hintText: context.l10n.pasteDownloadLinks,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
@@ -357,7 +374,13 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormRow(
|
_FormRow(
|
||||||
label: context.l10n.connections,
|
label: context.l10n.connections,
|
||||||
child: _WindowTextField(controller: _connectionsController, hintText: context.l10n.enterCount),
|
child: AppNumberInput(
|
||||||
|
fieldKey: const ValueKey('create-task-connections-input'),
|
||||||
|
controller: _connectionsController,
|
||||||
|
min: 1,
|
||||||
|
max: 256,
|
||||||
|
hintText: context.l10n.enterCount,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FormRow(
|
_FormRow(
|
||||||
@@ -371,10 +394,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
pickerKey: const ValueKey('create-task-directory-picker'),
|
pickerKey: const ValueKey('create-task-directory-picker'),
|
||||||
controller: _directoryController,
|
controller: _directoryController,
|
||||||
hintText: context.l10n.chooseDownloadDirectory,
|
hintText: context.l10n.chooseDownloadDirectory,
|
||||||
filled: true,
|
|
||||||
border: Border.all(color: palette.border),
|
|
||||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
if (_asDefaultPath) {
|
if (_asDefaultPath) {
|
||||||
setState(() => _asDefaultPath = false);
|
setState(() => _asDefaultPath = false);
|
||||||
@@ -434,6 +453,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
|
key: const ValueKey('create-task-advanced-section'),
|
||||||
duration: _advancedExpandDuration,
|
duration: _advancedExpandDuration,
|
||||||
firstCurve: Curves.easeOutCubic,
|
firstCurve: Curves.easeOutCubic,
|
||||||
secondCurve: Curves.easeInCubic,
|
secondCurve: Curves.easeInCubic,
|
||||||
@@ -643,7 +663,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
label: context.l10n.trackers,
|
label: context.l10n.trackers,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 96,
|
height: 96,
|
||||||
child: TextField(
|
child: AppTextField(
|
||||||
controller: _trackersController,
|
controller: _trackersController,
|
||||||
hintText: context.l10n.oneTrackerPerLine,
|
hintText: context.l10n.oneTrackerPerLine,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
@@ -681,16 +701,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))),
|
child: SizedBox(width: 68, child: Center(child: Text(context.l10n.cancel))),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
AppPrimaryButton(
|
AppLoadingButton(
|
||||||
onPressed: _creating ? null : _confirm,
|
key: const ValueKey('create-task-confirm-button'),
|
||||||
child: SizedBox(
|
onPressed: _confirm,
|
||||||
width: 68,
|
loading: _creating,
|
||||||
child: Center(
|
variant: AppLoadingButtonVariant.primary,
|
||||||
child: _creating
|
child: SizedBox(width: 68, child: Center(child: Text(context.l10n.confirm))),
|
||||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
|
|
||||||
: Text(context.l10n.confirm),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -711,6 +727,19 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
await windowManager.close();
|
await windowManager.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _finishCreation() async {
|
||||||
|
if (widget.windowController != null) {
|
||||||
|
// Navigation is best-effort after a successful submission. A navigation
|
||||||
|
// failure must not leave a completed form available for resubmission.
|
||||||
|
try {
|
||||||
|
await ref.read(appCapabilitiesProvider).navigation.showDownloadingTasks();
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('Unable to select downloading tasks: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _closeWindow();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadDefaults() async {
|
Future<void> _loadDefaults() async {
|
||||||
try {
|
try {
|
||||||
final config = await ref.read(gopeedServiceProvider).getConfig();
|
final config = await ref.read(gopeedServiceProvider).getConfig();
|
||||||
@@ -755,15 +784,10 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickTorrentFile() async {
|
Future<void> _pickTorrentFile() async {
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final file = await FilePicker.pickFile(type: FileType.custom, allowedExtensions: const ['torrent']);
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: const ['torrent'],
|
|
||||||
withData: kIsWeb,
|
|
||||||
);
|
|
||||||
final file = result?.files.single;
|
|
||||||
if (file == null) return;
|
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 {
|
Future<void> _handleDroppedTorrent(DropDoneDetails details) async {
|
||||||
@@ -842,7 +866,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await _closeWindow();
|
await _finishCreation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,7 +874,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
final options = _buildOptions();
|
final options = _buildOptions();
|
||||||
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options));
|
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request, opts: options));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_syncResolvedName(result);
|
|
||||||
final created = await _showResolveDialog(request, result);
|
final created = await _showResolveDialog(request, result);
|
||||||
if (!created) {
|
if (!created) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -858,7 +881,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _closeWindow();
|
await _finishCreation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_showToast(error.toString());
|
_showToast(error.toString());
|
||||||
@@ -1135,19 +1158,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
SecondaryButton(
|
flutter.Flexible(
|
||||||
onPressed: submitting ? null : () => closeOverlay(dialogContext, false),
|
child: ResolveTaskActions(
|
||||||
child: SizedBox(width: 68, child: Center(child: Text(dialogContext.l10n.cancel))),
|
submitting: submitting,
|
||||||
),
|
onCancel: () => closeOverlay(dialogContext, false),
|
||||||
AppPrimaryButton(
|
onCreate: submit,
|
||||||
onPressed: submitting ? null : submit,
|
|
||||||
child: SizedBox(
|
|
||||||
width: 68,
|
|
||||||
child: Center(
|
|
||||||
child: submitting
|
|
||||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator())
|
|
||||||
: Text(dialogContext.l10n.create),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1231,7 +1246,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 38,
|
height: 38,
|
||||||
child: TextField(
|
child: AppTextField(
|
||||||
key: const ValueKey('create-history-filter'),
|
key: const ValueKey('create-history-filter'),
|
||||||
controller: filterController,
|
controller: filterController,
|
||||||
placeholder: Text(
|
placeholder: Text(
|
||||||
@@ -1310,12 +1325,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
|||||||
filterController.dispose();
|
filterController.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncResolvedName(ResolveResult result) {
|
|
||||||
if (_renameController.text.trim().isEmpty && result.res.name.trim().isNotEmpty) {
|
|
||||||
_renameController.text = result.res.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setUrlText(String value, {String fileDataUri = ''}) {
|
void _setUrlText(String value, {String fileDataUri = ''}) {
|
||||||
_programmaticUrlChange = true;
|
_programmaticUrlChange = true;
|
||||||
_urlController.text = value;
|
_urlController.text = value;
|
||||||
@@ -1657,17 +1666,12 @@ class _WindowTextField extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = AppPalette.of(context);
|
return AppTextField(
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
inputFormatters: inputFormatters,
|
inputFormatters: inputFormatters,
|
||||||
obscureText: obscureText,
|
obscureText: obscureText,
|
||||||
filled: true,
|
|
||||||
border: Border.all(color: palette.border),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
|
|||||||
child: FileTreeView<int>(
|
child: FileTreeView<int>(
|
||||||
items: _treeItems,
|
items: _treeItems,
|
||||||
keyPrefix: 'resolve-tree',
|
keyPrefix: 'resolve-tree',
|
||||||
|
rowHeight: _resolveTreeRowHeight,
|
||||||
|
contentTextStyle: _resolveFileNameStyle,
|
||||||
headerLeading: ExcludeFocus(
|
headerLeading: ExcludeFocus(
|
||||||
child: Checkbox(
|
child: Checkbox(
|
||||||
state: allSelected
|
state: allSelected
|
||||||
@@ -336,3 +338,5 @@ extension on _FileTypeFilter {
|
|||||||
const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'};
|
const _videoExts = <String>{'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'};
|
||||||
const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'};
|
const _audioExts = <String>{'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'ape'};
|
||||||
const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'};
|
const _imageExts = <String>{'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'};
|
||||||
|
const _resolveTreeRowHeight = 36.0;
|
||||||
|
const _resolveFileNameStyle = TextStyle(fontSize: 14, height: 1.25);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
|
import 'package:flutter/widgets.dart' as flutter;
|
||||||
|
|
||||||
|
import '../../../../l10n/l10n.dart';
|
||||||
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
|
import '../../../../shared/widgets/app_loading_button.dart';
|
||||||
|
|
||||||
|
class ResolveTaskActions extends StatelessWidget {
|
||||||
|
const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate});
|
||||||
|
|
||||||
|
final bool submitting;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
final VoidCallback onCreate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Size both actions from the wider translated label, including while the
|
||||||
|
// create button replaces its label with a loader.
|
||||||
|
return IntrinsicWidth(
|
||||||
|
child: flutter.Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
flutter.Expanded(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
|
||||||
|
child: SecondaryButton(
|
||||||
|
key: const ValueKey('resolve-cancel-button'),
|
||||||
|
onPressed: submitting ? null : onCancel,
|
||||||
|
child: Text(context.l10n.cancel, maxLines: 1, softWrap: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppDesignTokens.space8),
|
||||||
|
flutter.Expanded(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
|
||||||
|
child: AppLoadingButton(
|
||||||
|
key: const ValueKey('resolve-create-button'),
|
||||||
|
onPressed: onCreate,
|
||||||
|
loading: submitting,
|
||||||
|
variant: AppLoadingButtonVariant.primary,
|
||||||
|
child: Text(context.l10n.createAction, maxLines: 1, softWrap: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
|||||||
import '../../../../shared/theme/app_design_tokens.dart';
|
import '../../../../shared/theme/app_design_tokens.dart';
|
||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_copy_icon_button.dart';
|
import '../../../../shared/widgets/app_copy_icon_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../shared/widgets/app_tooltip.dart';
|
import '../../../../shared/widgets/app_tooltip.dart';
|
||||||
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
import '../../../../shared/widgets/detail/app_detail_surface.dart';
|
||||||
import '../../../../core/utils/text_wrap.dart';
|
import '../../../../core/utils/text_wrap.dart';
|
||||||
@@ -512,7 +513,7 @@ class _EditableUrlBlock extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 76,
|
height: 76,
|
||||||
child: shad.TextField(
|
child: AppTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
minLines: null,
|
minLines: null,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class _TaskFileManagerViewState extends State<TaskFileManagerView> {
|
|||||||
try {
|
try {
|
||||||
final renderBox = actionContext.findRenderObject();
|
final renderBox = actionContext.findRenderObject();
|
||||||
final origin = renderBox is RenderBox ? renderBox.localToGlobal(Offset.zero) & renderBox.size : null;
|
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) {
|
} catch (error) {
|
||||||
if (mounted) showAppToast(context, error.toString(), type: AppToastType.error);
|
if (mounted) showAppToast(context, error.toString(), type: AppToastType.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
|
|||||||
import '../../../../shared/theme/app_palette.dart';
|
import '../../../../shared/theme/app_palette.dart';
|
||||||
import '../../../../shared/widgets/app_http_headers_editor.dart';
|
import '../../../../shared/widgets/app_http_headers_editor.dart';
|
||||||
import '../../../../shared/widgets/app_primary_button.dart';
|
import '../../../../shared/widgets/app_primary_button.dart';
|
||||||
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../../l10n/l10n.dart';
|
import '../../../../l10n/l10n.dart';
|
||||||
import '../../domain/task_record.dart';
|
import '../../domain/task_record.dart';
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ Future<TaskUrlUpdate?> showTaskUpdateUrlDialog(BuildContext context, TaskRecord
|
|||||||
children: [
|
children: [
|
||||||
Text(dialogContext.l10n.downloadLink, style: TextStyle(color: palette.textPrimary, fontSize: 12)),
|
Text(dialogContext.l10n.downloadLink, style: TextStyle(color: palette.textPrimary, fontSize: 12)),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
TextField(controller: urlController),
|
AppTextField(controller: urlController),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
AppHttpHeadersEditor(
|
AppHttpHeadersEditor(
|
||||||
controller: headersController,
|
controller: headersController,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "خروج",
|
"exit": "خروج",
|
||||||
"pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج",
|
"pressBackAgainToExit": "اضغط زر الرجوع مرة أخرى للخروج",
|
||||||
"create": "إنشاء مهمة",
|
"create": "إنشاء مهمة",
|
||||||
|
"createAction": "إنشاء",
|
||||||
"directDownload": "تنزيل مباشر",
|
"directDownload": "تنزيل مباشر",
|
||||||
"advancedOptions": "خيارات متقدمة",
|
"advancedOptions": "خيارات متقدمة",
|
||||||
"followSettings": "اتباع الإعدادات",
|
"followSettings": "اتباع الإعدادات",
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
{
|
||||||
|
"@@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",
|
||||||
|
"createAction": "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."
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Surt",
|
"exit": "Surt",
|
||||||
"pressBackAgainToExit": "Torna a prémer enrere per sortir",
|
"pressBackAgainToExit": "Torna a prémer enrere per sortir",
|
||||||
"create": "Crea una tasca",
|
"create": "Crea una tasca",
|
||||||
|
"createAction": "Crea",
|
||||||
"directDownload": "Baixada directa",
|
"directDownload": "Baixada directa",
|
||||||
"advancedOptions": "Opcions avançades",
|
"advancedOptions": "Opcions avançades",
|
||||||
"followSettings": "Segueix la configuració",
|
"followSettings": "Segueix la configuració",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Beenden",
|
"exit": "Beenden",
|
||||||
"pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden",
|
"pressBackAgainToExit": "Drücken Sie erneut Zurück, um die App zu beenden",
|
||||||
"create": "Aufgabe Erstellen",
|
"create": "Aufgabe Erstellen",
|
||||||
|
"createAction": "Erstellen",
|
||||||
"directDownload": "Direkter Download",
|
"directDownload": "Direkter Download",
|
||||||
"advancedOptions": "Erweiterte Einstellungen",
|
"advancedOptions": "Erweiterte Einstellungen",
|
||||||
"followSettings": "Einstellungen Folgen",
|
"followSettings": "Einstellungen Folgen",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Exit",
|
"exit": "Exit",
|
||||||
"pressBackAgainToExit": "Press back again to exit",
|
"pressBackAgainToExit": "Press back again to exit",
|
||||||
"create": "Create Task",
|
"create": "Create Task",
|
||||||
|
"createAction": "Create",
|
||||||
"directDownload": "Direct Download",
|
"directDownload": "Direct Download",
|
||||||
"advancedOptions": "Advanced Options",
|
"advancedOptions": "Advanced Options",
|
||||||
"followSettings": "Follow Settings",
|
"followSettings": "Follow Settings",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Salir",
|
"exit": "Salir",
|
||||||
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir",
|
"pressBackAgainToExit": "Pulsa Atrás de nuevo para salir",
|
||||||
"create": "Crear Tarea",
|
"create": "Crear Tarea",
|
||||||
|
"createAction": "Crear",
|
||||||
"directDownload": "Descarga Directa",
|
"directDownload": "Descarga Directa",
|
||||||
"advancedOptions": "Opciones Avanzadas",
|
"advancedOptions": "Opciones Avanzadas",
|
||||||
"followSettings": "Siga las opciones",
|
"followSettings": "Siga las opciones",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "خروج",
|
"exit": "خروج",
|
||||||
"pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید",
|
"pressBackAgainToExit": "برای خروج دوباره دکمه بازگشت را فشار دهید",
|
||||||
"create": "ایجاد کار",
|
"create": "ایجاد کار",
|
||||||
|
"createAction": "ایجاد",
|
||||||
"directDownload": "دانلود مستقیم",
|
"directDownload": "دانلود مستقیم",
|
||||||
"advancedOptions": "گزینه های پیشرفته",
|
"advancedOptions": "گزینه های پیشرفته",
|
||||||
"followSettings": "تنظیمات را دنبال کنید",
|
"followSettings": "تنظیمات را دنبال کنید",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Quitter",
|
"exit": "Quitter",
|
||||||
"pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter",
|
"pressBackAgainToExit": "Appuyez à nouveau sur Retour pour quitter",
|
||||||
"create": "Créer une tâche",
|
"create": "Créer une tâche",
|
||||||
|
"createAction": "Créer",
|
||||||
"directDownload": "Téléchargement direct",
|
"directDownload": "Téléchargement direct",
|
||||||
"advancedOptions": "Options avancées",
|
"advancedOptions": "Options avancées",
|
||||||
"followSettings": "Suivre les paramètres",
|
"followSettings": "Suivre les paramètres",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Kilépés",
|
"exit": "Kilépés",
|
||||||
"pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez",
|
"pressBackAgainToExit": "Nyomja meg újra a Vissza gombot a kilépéshez",
|
||||||
"create": "Feladat létrehozása",
|
"create": "Feladat létrehozása",
|
||||||
|
"createAction": "Létrehozás",
|
||||||
"directDownload": "Közvetlen letöltés",
|
"directDownload": "Közvetlen letöltés",
|
||||||
"advancedOptions": "Haladó beállítások",
|
"advancedOptions": "Haladó beállítások",
|
||||||
"followSettings": "Beállítások követése",
|
"followSettings": "Beállítások követése",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Keluar",
|
"exit": "Keluar",
|
||||||
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
|
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
|
||||||
"create": "Membuat Tugas",
|
"create": "Membuat Tugas",
|
||||||
|
"createAction": "Buat",
|
||||||
"directDownload": "Unduhan Langsung",
|
"directDownload": "Unduhan Langsung",
|
||||||
"advancedOptions": "Pengaturan Lanjutan",
|
"advancedOptions": "Pengaturan Lanjutan",
|
||||||
"followSettings": "Mengikuti Pengaturan",
|
"followSettings": "Mengikuti Pengaturan",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Uscita",
|
"exit": "Uscita",
|
||||||
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire",
|
"pressBackAgainToExit": "Premi di nuovo Indietro per uscire",
|
||||||
"create": "Crea attività",
|
"create": "Crea attività",
|
||||||
|
"createAction": "Crea",
|
||||||
"directDownload": "Scaricamento diretto",
|
"directDownload": "Scaricamento diretto",
|
||||||
"advancedOptions": "Opzioni avanzate",
|
"advancedOptions": "Opzioni avanzate",
|
||||||
"followSettings": "Seguire le impostazioni",
|
"followSettings": "Seguire le impostazioni",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "終了",
|
"exit": "終了",
|
||||||
"pressBackAgainToExit": "もう一度戻るボタンを押すと終了します",
|
"pressBackAgainToExit": "もう一度戻るボタンを押すと終了します",
|
||||||
"create": "タスクを作成",
|
"create": "タスクを作成",
|
||||||
|
"createAction": "作成",
|
||||||
"directDownload": "直接ダウンロード",
|
"directDownload": "直接ダウンロード",
|
||||||
"advancedOptions": "詳細オプション",
|
"advancedOptions": "詳細オプション",
|
||||||
"followSettings": "設定に従う",
|
"followSettings": "設定に従う",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "종료",
|
"exit": "종료",
|
||||||
"pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요",
|
"pressBackAgainToExit": "종료하려면 뒤로 버튼을 한 번 더 누르세요",
|
||||||
"create": "작업 생성",
|
"create": "작업 생성",
|
||||||
|
"createAction": "생성",
|
||||||
"directDownload": "바로 다운로드",
|
"directDownload": "바로 다운로드",
|
||||||
"advancedOptions": "고급 옵션",
|
"advancedOptions": "고급 옵션",
|
||||||
"followSettings": "설정값 사용",
|
"followSettings": "설정값 사용",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Wyjście",
|
"exit": "Wyjście",
|
||||||
"pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść",
|
"pressBackAgainToExit": "Naciśnij ponownie Wstecz, aby wyjść",
|
||||||
"create": "Utwórz zadanie",
|
"create": "Utwórz zadanie",
|
||||||
|
"createAction": "Utwórz",
|
||||||
"directDownload": "Pobieranie bezpośrednie",
|
"directDownload": "Pobieranie bezpośrednie",
|
||||||
"advancedOptions": "Zaawansowane",
|
"advancedOptions": "Zaawansowane",
|
||||||
"followSettings": "Podążaj za ustawieniami",
|
"followSettings": "Podążaj za ustawieniami",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Sair",
|
"exit": "Sair",
|
||||||
"pressBackAgainToExit": "Prima novamente Voltar para sair",
|
"pressBackAgainToExit": "Prima novamente Voltar para sair",
|
||||||
"create": "Criar Tarefa",
|
"create": "Criar Tarefa",
|
||||||
|
"createAction": "Criar",
|
||||||
"directDownload": "Download Direto",
|
"directDownload": "Download Direto",
|
||||||
"advancedOptions": "Opções Avançadas",
|
"advancedOptions": "Opções Avançadas",
|
||||||
"followSettings": "Seguir as Configurações",
|
"followSettings": "Seguir as Configurações",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Выход",
|
"exit": "Выход",
|
||||||
"pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти",
|
"pressBackAgainToExit": "Нажмите «Назад» ещё раз, чтобы выйти",
|
||||||
"create": "Создать задачу",
|
"create": "Создать задачу",
|
||||||
|
"createAction": "Создать",
|
||||||
"directDownload": "Прямая загрузка",
|
"directDownload": "Прямая загрузка",
|
||||||
"advancedOptions": "Расширенные опции",
|
"advancedOptions": "Расширенные опции",
|
||||||
"followSettings": "По умолчанию",
|
"followSettings": "По умолчанию",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "வெளியேறு",
|
"exit": "வெளியேறு",
|
||||||
"pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்",
|
"pressBackAgainToExit": "வெளியேற மீண்டும் பின்செல் பொத்தானை அழுத்தவும்",
|
||||||
"create": "பணியை உருவாக்கு",
|
"create": "பணியை உருவாக்கு",
|
||||||
|
"createAction": "உருவாக்கு",
|
||||||
"directDownload": "நேரடி பதிவிறக்கம்",
|
"directDownload": "நேரடி பதிவிறக்கம்",
|
||||||
"advancedOptions": "மேம்பட்ட விருப்பங்கள்",
|
"advancedOptions": "மேம்பட்ட விருப்பங்கள்",
|
||||||
"followSettings": "அமைப்புகளைப் பின்தொடரவும்",
|
"followSettings": "அமைப்புகளைப் பின்தொடரவும்",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Çıkış",
|
"exit": "Çıkış",
|
||||||
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın",
|
"pressBackAgainToExit": "Çıkmak için tekrar geri tuşuna basın",
|
||||||
"create": "İşlem Oluştur",
|
"create": "İşlem Oluştur",
|
||||||
|
"createAction": "Oluştur",
|
||||||
"directDownload": "Direkt İndir",
|
"directDownload": "Direkt İndir",
|
||||||
"advancedOptions": "Gelişmiş Seçenekler",
|
"advancedOptions": "Gelişmiş Seçenekler",
|
||||||
"followSettings": "Ayarları Takip Et",
|
"followSettings": "Ayarları Takip Et",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Вихід",
|
"exit": "Вихід",
|
||||||
"pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти",
|
"pressBackAgainToExit": "Натисніть «Назад» ще раз, щоб вийти",
|
||||||
"create": "Створити завдання",
|
"create": "Створити завдання",
|
||||||
|
"createAction": "Створити",
|
||||||
"directDownload": "Пряме завантаження",
|
"directDownload": "Пряме завантаження",
|
||||||
"advancedOptions": "Додаткові параметри",
|
"advancedOptions": "Додаткові параметри",
|
||||||
"followSettings": "За замовчуванням",
|
"followSettings": "За замовчуванням",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "Thoát",
|
"exit": "Thoát",
|
||||||
"pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát",
|
"pressBackAgainToExit": "Nhấn quay lại lần nữa để thoát",
|
||||||
"create": "Tạo nhiệm vụ",
|
"create": "Tạo nhiệm vụ",
|
||||||
|
"createAction": "Tạo",
|
||||||
"directDownload": "Tải trực tiếp",
|
"directDownload": "Tải trực tiếp",
|
||||||
"advancedOptions": "Tùy chọn nâng cao",
|
"advancedOptions": "Tùy chọn nâng cao",
|
||||||
"followSettings": "Theo thiết lập",
|
"followSettings": "Theo thiết lập",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "退出",
|
"exit": "退出",
|
||||||
"pressBackAgainToExit": "再按一次返回键退出应用",
|
"pressBackAgainToExit": "再按一次返回键退出应用",
|
||||||
"create": "创建任务",
|
"create": "创建任务",
|
||||||
|
"createAction": "创建",
|
||||||
"directDownload": "直接下载",
|
"directDownload": "直接下载",
|
||||||
"advancedOptions": "高级选项",
|
"advancedOptions": "高级选项",
|
||||||
"followSettings": "跟随设置",
|
"followSettings": "跟随设置",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"exit": "退出",
|
"exit": "退出",
|
||||||
"pressBackAgainToExit": "再按一次返回鍵退出應用程式",
|
"pressBackAgainToExit": "再按一次返回鍵退出應用程式",
|
||||||
"create": "建立任務",
|
"create": "建立任務",
|
||||||
|
"createAction": "建立",
|
||||||
"directDownload": "直接下載",
|
"directDownload": "直接下載",
|
||||||
"advancedOptions": "進階選項",
|
"advancedOptions": "進階選項",
|
||||||
"followSettings": "跟隨設定",
|
"followSettings": "跟隨設定",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:app_links/app_links.dart';
|
import 'package:app_links/app_links.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -17,6 +18,11 @@ import 'util/util.dart';
|
|||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
if (Util.isMacos()) {
|
||||||
|
// Gopeed is not sandboxed. Configure each engine before the main/child
|
||||||
|
// window split so native file and directory dialogs work in both windows.
|
||||||
|
await FilePicker.skipEntitlementsChecks();
|
||||||
|
}
|
||||||
if (Util.isAndroid()) {
|
if (Util.isAndroid()) {
|
||||||
FlutterForegroundTask.initCommunicationPort();
|
FlutterForegroundTask.initCommunicationPort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class DownloadDirectoryPicker {
|
|||||||
return _pickAndroid(context, currentPath: currentPath);
|
return _pickAndroid(context, currentPath: currentPath);
|
||||||
}
|
}
|
||||||
if (_platform == TargetPlatform.iOS) return null;
|
if (_platform == TargetPlatform.iOS) return null;
|
||||||
return FilePicker.platform.getDirectoryPath();
|
return FilePicker.getDirectoryPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> _pickAndroid(BuildContext context, {required String currentPath}) async {
|
static Future<String?> _pickAndroid(BuildContext context, {required String currentPath}) async {
|
||||||
|
|||||||
@@ -18,42 +18,50 @@ class AppComponentThemes extends StatelessWidget {
|
|||||||
align: 1,
|
align: 1,
|
||||||
border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1),
|
border: Border.all(color: theme.colorScheme.ring.withValues(alpha: 0.65), width: 1),
|
||||||
),
|
),
|
||||||
child: shad.ComponentTheme<shad.SecondaryButtonTheme>(
|
child: shad.ComponentTheme<shad.TextFieldTheme>(
|
||||||
data: shad.SecondaryButtonTheme(
|
data: shad.TextFieldTheme(
|
||||||
decoration: (context, states, value) {
|
filled: true,
|
||||||
final decoration = switch (value) {
|
border: Border.all(color: palette.border),
|
||||||
final BoxDecoration decoration => decoration,
|
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||||
_ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)),
|
padding: AppDesignTokens.textFieldPadding,
|
||||||
};
|
|
||||||
if (states.contains(WidgetState.disabled)) {
|
|
||||||
return decoration.copyWith(color: palette.surfaceSoft);
|
|
||||||
}
|
|
||||||
if (theme.brightness == Brightness.light) {
|
|
||||||
final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft);
|
|
||||||
final hoverBackground = Color.alphaBlend(
|
|
||||||
palette.textPrimary.withValues(alpha: 0.13),
|
|
||||||
palette.surfaceSoft,
|
|
||||||
);
|
|
||||||
return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
textStyle: (context, states, value) => states.contains(WidgetState.disabled)
|
|
||||||
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
|
||||||
: value,
|
|
||||||
iconTheme: (context, states, value) => states.contains(WidgetState.disabled)
|
|
||||||
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
|
||||||
: value,
|
|
||||||
),
|
),
|
||||||
child: shad.ComponentTheme<shad.CheckboxTheme>(
|
child: shad.ComponentTheme<shad.SecondaryButtonTheme>(
|
||||||
data: shad.CheckboxTheme(
|
data: shad.SecondaryButtonTheme(
|
||||||
size: AppDesignTokens.checkboxSize,
|
decoration: (context, states, value) {
|
||||||
gap: AppDesignTokens.checkboxLabelGap,
|
final decoration = switch (value) {
|
||||||
borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border,
|
final BoxDecoration decoration => decoration,
|
||||||
|
_ => BoxDecoration(borderRadius: BorderRadius.circular(theme.radiusMd)),
|
||||||
|
};
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return decoration.copyWith(color: palette.surfaceSoft);
|
||||||
|
}
|
||||||
|
if (theme.brightness == Brightness.light) {
|
||||||
|
final background = Color.alphaBlend(palette.textPrimary.withValues(alpha: 0.08), palette.surfaceSoft);
|
||||||
|
final hoverBackground = Color.alphaBlend(
|
||||||
|
palette.textPrimary.withValues(alpha: 0.13),
|
||||||
|
palette.surfaceSoft,
|
||||||
|
);
|
||||||
|
return decoration.copyWith(color: states.contains(WidgetState.hovered) ? hoverBackground : background);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
textStyle: (context, states, value) => states.contains(WidgetState.disabled)
|
||||||
|
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
||||||
|
: value,
|
||||||
|
iconTheme: (context, states, value) => states.contains(WidgetState.disabled)
|
||||||
|
? value.copyWith(color: palette.textMuted.withValues(alpha: 0.62))
|
||||||
|
: value,
|
||||||
),
|
),
|
||||||
child: shad.ComponentTheme<shad.ToastTheme>(
|
child: shad.ComponentTheme<shad.CheckboxTheme>(
|
||||||
data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)),
|
data: shad.CheckboxTheme(
|
||||||
child: child,
|
size: AppDesignTokens.checkboxSize,
|
||||||
|
gap: AppDesignTokens.checkboxLabelGap,
|
||||||
|
borderColor: theme.brightness == Brightness.dark ? palette.textMuted : palette.border,
|
||||||
|
),
|
||||||
|
child: shad.ComponentTheme<shad.ToastTheme>(
|
||||||
|
data: const shad.ToastTheme(toastConstraints: BoxConstraints(maxWidth: 420)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class AppDesignTokens {
|
|||||||
static const double contentHeaderHeight = 48;
|
static const double contentHeaderHeight = 48;
|
||||||
static const double taskRowHeight = 72;
|
static const double taskRowHeight = 72;
|
||||||
static const double controlRadius = 4;
|
static const double controlRadius = 4;
|
||||||
|
static const double dialogActionMinWidth = 100;
|
||||||
static const double checkboxSize = 18;
|
static const double checkboxSize = 18;
|
||||||
static const double checkboxLabelGap = 10;
|
static const double checkboxLabelGap = 10;
|
||||||
static const double taskDetailsDrawerMinWidth = 440;
|
static const double taskDetailsDrawerMinWidth = 440;
|
||||||
@@ -21,6 +22,9 @@ class AppDesignTokens {
|
|||||||
static const double settingsFormControlWidth = 420;
|
static const double settingsFormControlWidth = 420;
|
||||||
static const double settingsNumberControlWidth = 168;
|
static const double settingsNumberControlWidth = 168;
|
||||||
static const double settingsItemMinLabelWidth = 160;
|
static const double settingsItemMinLabelWidth = 160;
|
||||||
|
static const EdgeInsets textFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 11);
|
||||||
|
static const EdgeInsets compactTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 6);
|
||||||
|
static const EdgeInsets numberTextFieldPadding = EdgeInsets.symmetric(horizontal: 12, vertical: 10);
|
||||||
|
|
||||||
static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32);
|
static const EdgeInsets pagePadding = EdgeInsets.symmetric(horizontal: 32);
|
||||||
static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16);
|
static const EdgeInsets sidebarPadding = EdgeInsets.symmetric(horizontal: 16);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
|||||||
import '../theme/app_design_tokens.dart';
|
import '../theme/app_design_tokens.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
import '../../l10n/l10n.dart';
|
import '../../l10n/l10n.dart';
|
||||||
|
import 'app_text_field.dart';
|
||||||
|
|
||||||
class AppHttpHeadersController extends ChangeNotifier {
|
class AppHttpHeadersController extends ChangeNotifier {
|
||||||
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
|
AppHttpHeadersController({Map<String, String>? headers, Iterable<String> defaultNames = const []}) {
|
||||||
@@ -171,7 +172,7 @@ class _HeaderTextField extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
return shad.TextField(
|
return AppTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
filled: true,
|
filled: true,
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ class AppLoadingButton extends StatelessWidget {
|
|||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
final replaceChild = loading && icon == null;
|
final replaceChild = loading && icon == null;
|
||||||
final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon;
|
final leading = loading && icon != null ? _LoadingIcon(variant: variant) : icon;
|
||||||
final effectiveChild = replaceChild ? _LoadingIcon(variant: variant) : child;
|
final effectiveChild = replaceChild
|
||||||
|
? Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
ExcludeSemantics(child: Opacity(opacity: 0, child: child)),
|
||||||
|
Positioned.fill(child: _LoadingIcon(variant: variant)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: child;
|
||||||
final content = leading == null
|
final content = leading == null
|
||||||
? effectiveChild
|
? effectiveChild
|
||||||
: Row(
|
: Row(
|
||||||
@@ -38,7 +46,7 @@ class AppLoadingButton extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
leading,
|
leading,
|
||||||
const SizedBox(width: AppDesignTokens.space8),
|
const SizedBox(width: AppDesignTokens.space8),
|
||||||
effectiveChild,
|
Flexible(child: effectiveChild),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
final effectiveOnPressed = loading ? null : onPressed;
|
final effectiveOnPressed = loading ? null : onPressed;
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
|
import '../theme/app_design_tokens.dart';
|
||||||
|
import 'app_text_field.dart';
|
||||||
|
|
||||||
|
/// A bounded numeric text field with the app's standard spinner controls.
|
||||||
|
class AppNumberInput extends StatefulWidget {
|
||||||
|
const AppNumberInput({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.min,
|
||||||
|
this.fieldKey,
|
||||||
|
this.max,
|
||||||
|
this.step = 1,
|
||||||
|
this.decimalPlaces = 0,
|
||||||
|
this.hintText,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final num min;
|
||||||
|
final Key? fieldKey;
|
||||||
|
final num? max;
|
||||||
|
final double step;
|
||||||
|
final int decimalPlaces;
|
||||||
|
final String? hintText;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AppNumberInput> createState() => _AppNumberInputState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppNumberInputState extends State<AppNumberInput> {
|
||||||
|
bool _normalizing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.controller.addListener(_normalizeDecimalStepValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant AppNumberInput oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.controller == widget.controller) return;
|
||||||
|
oldWidget.controller.removeListener(_normalizeDecimalStepValue);
|
||||||
|
widget.controller.addListener(_normalizeDecimalStepValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller.removeListener(_normalizeDecimalStepValue);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _normalizeDecimalStepValue() {
|
||||||
|
if (_normalizing || widget.decimalPlaces <= 0) return;
|
||||||
|
final text = widget.controller.text;
|
||||||
|
final separator = text.indexOf('.');
|
||||||
|
if (separator < 0 || text.length - separator - 1 <= widget.decimalPlaces) return;
|
||||||
|
final value = double.tryParse(text);
|
||||||
|
if (value == null) return;
|
||||||
|
|
||||||
|
var normalized = value.toStringAsFixed(widget.decimalPlaces);
|
||||||
|
normalized = normalized.replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), '');
|
||||||
|
if (normalized == text) return;
|
||||||
|
|
||||||
|
_normalizing = true;
|
||||||
|
widget.controller.value = TextEditingValue(
|
||||||
|
text: normalized,
|
||||||
|
selection: TextSelection.collapsed(offset: normalized.length),
|
||||||
|
);
|
||||||
|
_normalizing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final formatters = widget.decimalPlaces > 0
|
||||||
|
? <TextInputFormatter>[_DecimalNumberFormatter(decimalPlaces: widget.decimalPlaces)]
|
||||||
|
: <TextInputFormatter>[
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
_NumericalRangeFormatter(min: widget.min.toInt(), max: widget.max?.toInt()),
|
||||||
|
];
|
||||||
|
return AppTextField(
|
||||||
|
key: widget.fieldKey,
|
||||||
|
controller: widget.controller,
|
||||||
|
hintText: widget.hintText,
|
||||||
|
keyboardType: widget.decimalPlaces > 0
|
||||||
|
? const TextInputType.numberWithOptions(decimal: true)
|
||||||
|
: TextInputType.number,
|
||||||
|
inputFormatters: formatters,
|
||||||
|
enabled: widget.enabled,
|
||||||
|
padding: AppDesignTokens.numberTextFieldPadding,
|
||||||
|
features: [
|
||||||
|
shad.InputFeature.spinner(
|
||||||
|
step: widget.step,
|
||||||
|
min: widget.min.toDouble(),
|
||||||
|
max: widget.max?.toDouble(),
|
||||||
|
invalidValue: widget.min.toDouble(),
|
||||||
|
enableGesture: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps integer text within the same inclusive bounds as the settings UI.
|
||||||
|
/// Empty text is allowed while editing.
|
||||||
|
class _NumericalRangeFormatter extends TextInputFormatter {
|
||||||
|
const _NumericalRangeFormatter({required this.min, this.max});
|
||||||
|
|
||||||
|
final int min;
|
||||||
|
final int? max;
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||||
|
if (newValue.text.isEmpty) return newValue;
|
||||||
|
final parsed = int.tryParse(newValue.text);
|
||||||
|
if (parsed == null) return oldValue;
|
||||||
|
final bounded = parsed.clamp(min, max ?? parsed).toInt();
|
||||||
|
if (bounded == parsed) return newValue;
|
||||||
|
final text = bounded.toString();
|
||||||
|
return TextEditingValue(
|
||||||
|
text: text,
|
||||||
|
selection: TextSelection.collapsed(offset: text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allows a non-negative decimal number with a bounded number of fractional digits.
|
||||||
|
class _DecimalNumberFormatter extends TextInputFormatter {
|
||||||
|
_DecimalNumberFormatter({required this.decimalPlaces}) : _pattern = RegExp('^\\d*(?:\\.\\d{0,$decimalPlaces})?\$');
|
||||||
|
|
||||||
|
final int decimalPlaces;
|
||||||
|
final RegExp _pattern;
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||||
|
return _pattern.hasMatch(newValue.text) ? newValue : oldValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart' show Icons;
|
import 'package:flutter/material.dart' show Icons;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
import '../../core/utils/breakpoints.dart';
|
import '../../core/utils/breakpoints.dart';
|
||||||
|
import '../../util/util.dart';
|
||||||
import '../services/download_directory_picker.dart';
|
import '../services/download_directory_picker.dart';
|
||||||
|
import 'app_text_field.dart';
|
||||||
import 'app_tooltip.dart';
|
import 'app_tooltip.dart';
|
||||||
|
|
||||||
enum AppPathPickerButtonStyle { ghost, outline }
|
enum AppPathPickerButtonStyle { ghost, outline }
|
||||||
@@ -23,7 +26,28 @@ class AppPathPickerField extends StatefulWidget {
|
|||||||
this.padding,
|
this.padding,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.pickerButtonStyle = AppPathPickerButtonStyle.ghost,
|
this.pickerButtonStyle = AppPathPickerButtonStyle.ghost,
|
||||||
}) : platformDownloadDirectory = false,
|
}) : platformFile = false,
|
||||||
|
platformDownloadDirectory = false,
|
||||||
|
onDirectoryPicked = null,
|
||||||
|
allowAndroidEditing = false;
|
||||||
|
|
||||||
|
/// Selects a native desktop file path; Web and mobile accept manual paths only.
|
||||||
|
const AppPathPickerField.file({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.fieldKey,
|
||||||
|
this.pickerKey,
|
||||||
|
this.hintText,
|
||||||
|
this.desktopWidth,
|
||||||
|
this.filled,
|
||||||
|
this.border,
|
||||||
|
this.borderRadius,
|
||||||
|
this.padding,
|
||||||
|
this.onChanged,
|
||||||
|
this.pickerButtonStyle = AppPathPickerButtonStyle.outline,
|
||||||
|
}) : onPick = null,
|
||||||
|
platformFile = true,
|
||||||
|
platformDownloadDirectory = false,
|
||||||
onDirectoryPicked = null,
|
onDirectoryPicked = null,
|
||||||
allowAndroidEditing = false;
|
allowAndroidEditing = false;
|
||||||
|
|
||||||
@@ -43,6 +67,7 @@ class AppPathPickerField extends StatefulWidget {
|
|||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.pickerButtonStyle = AppPathPickerButtonStyle.outline,
|
this.pickerButtonStyle = AppPathPickerButtonStyle.outline,
|
||||||
}) : onPick = null,
|
}) : onPick = null,
|
||||||
|
platformFile = false,
|
||||||
platformDownloadDirectory = true;
|
platformDownloadDirectory = true;
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
@@ -57,6 +82,7 @@ class AppPathPickerField extends StatefulWidget {
|
|||||||
final EdgeInsetsGeometry? padding;
|
final EdgeInsetsGeometry? padding;
|
||||||
final ValueChanged<String>? onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
final AppPathPickerButtonStyle pickerButtonStyle;
|
final AppPathPickerButtonStyle pickerButtonStyle;
|
||||||
|
final bool platformFile;
|
||||||
final bool platformDownloadDirectory;
|
final bool platformDownloadDirectory;
|
||||||
final ValueChanged<String>? onDirectoryPicked;
|
final ValueChanged<String>? onDirectoryPicked;
|
||||||
final bool allowAndroidEditing;
|
final bool allowAndroidEditing;
|
||||||
@@ -96,6 +122,16 @@ class _AppPathPickerFieldState extends State<AppPathPickerField> {
|
|||||||
setState(() => _tooltipPath = path);
|
setState(() => _tooltipPath = path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
if (!Util.isDesktop()) return;
|
||||||
|
final file = await FilePicker.pickFile();
|
||||||
|
if (!mounted) return;
|
||||||
|
final path = file?.path;
|
||||||
|
if (path == null || path.isEmpty || path == widget.controller.text) return;
|
||||||
|
widget.controller.text = path;
|
||||||
|
widget.onChanged?.call(path);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickPlatformDirectory() async {
|
Future<void> _pickPlatformDirectory() async {
|
||||||
final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim());
|
final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim());
|
||||||
if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return;
|
if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return;
|
||||||
@@ -109,6 +145,8 @@ class _AppPathPickerFieldState extends State<AppPathPickerField> {
|
|||||||
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile;
|
||||||
final onPick = widget.platformDownloadDirectory
|
final onPick = widget.platformDownloadDirectory
|
||||||
? (DownloadDirectoryPicker.canPick ? _pickPlatformDirectory : null)
|
? (DownloadDirectoryPicker.canPick ? _pickPlatformDirectory : null)
|
||||||
|
: widget.platformFile
|
||||||
|
? (Util.isDesktop() ? _pickFile : null)
|
||||||
: widget.onPick;
|
: widget.onPick;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: desktop ? widget.desktopWidth : double.infinity,
|
width: desktop ? widget.desktopWidth : double.infinity,
|
||||||
@@ -119,7 +157,7 @@ class _AppPathPickerFieldState extends State<AppPathPickerField> {
|
|||||||
onEnter: (_) => _syncTooltipPath(),
|
onEnter: (_) => _syncTooltipPath(),
|
||||||
child: AppTooltip(
|
child: AppTooltip(
|
||||||
message: _tooltipPath,
|
message: _tooltipPath,
|
||||||
child: shad.TextField(
|
child: AppTextField(
|
||||||
key: widget.fieldKey,
|
key: widget.fieldKey,
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
hintText: widget.hintText,
|
hintText: widget.hintText,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar;
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
|
/// The app-wide text field.
|
||||||
|
///
|
||||||
|
/// shadcn_flutter's custom context menu is rendered in a full-screen overlay
|
||||||
|
/// that can cover the app with an opaque surface. Use Flutter's adaptive
|
||||||
|
/// platform toolbar everywhere so long-press and right-click text actions have
|
||||||
|
/// consistent native behavior on mobile, desktop, and web.
|
||||||
|
class AppTextField extends shad.TextField {
|
||||||
|
const AppTextField({
|
||||||
|
super.key,
|
||||||
|
super.groupId,
|
||||||
|
super.controller,
|
||||||
|
super.initialValue,
|
||||||
|
super.focusNode,
|
||||||
|
super.undoController,
|
||||||
|
super.decoration,
|
||||||
|
super.padding,
|
||||||
|
super.placeholder,
|
||||||
|
super.crossAxisAlignment,
|
||||||
|
super.clearButtonSemanticLabel,
|
||||||
|
super.keyboardType,
|
||||||
|
super.textInputAction,
|
||||||
|
super.textCapitalization,
|
||||||
|
super.style,
|
||||||
|
super.strutStyle,
|
||||||
|
super.textAlign,
|
||||||
|
super.textAlignVertical,
|
||||||
|
super.textDirection,
|
||||||
|
super.readOnly,
|
||||||
|
super.showCursor,
|
||||||
|
super.autofocus,
|
||||||
|
super.obscuringCharacter,
|
||||||
|
super.obscureText,
|
||||||
|
super.autocorrect,
|
||||||
|
super.smartDashesType,
|
||||||
|
super.smartQuotesType,
|
||||||
|
super.enableSuggestions,
|
||||||
|
super.maxLines,
|
||||||
|
super.minLines,
|
||||||
|
super.expands,
|
||||||
|
super.maxLength,
|
||||||
|
super.maxLengthEnforcement,
|
||||||
|
super.onChanged,
|
||||||
|
super.onEditingComplete,
|
||||||
|
super.onSubmitted,
|
||||||
|
super.onTapOutside,
|
||||||
|
super.onTapUpOutside,
|
||||||
|
super.inputFormatters,
|
||||||
|
super.enabled,
|
||||||
|
super.cursorWidth,
|
||||||
|
super.cursorHeight,
|
||||||
|
super.cursorRadius,
|
||||||
|
super.cursorOpacityAnimates,
|
||||||
|
super.cursorColor,
|
||||||
|
super.selectionHeightStyle,
|
||||||
|
super.selectionWidthStyle,
|
||||||
|
super.keyboardAppearance,
|
||||||
|
super.scrollPadding,
|
||||||
|
super.enableInteractiveSelection,
|
||||||
|
super.selectionControls,
|
||||||
|
super.dragStartBehavior,
|
||||||
|
super.scrollController,
|
||||||
|
super.scrollPhysics,
|
||||||
|
super.onTap,
|
||||||
|
super.autofillHints,
|
||||||
|
super.clipBehavior,
|
||||||
|
super.restorationId,
|
||||||
|
super.stylusHandwritingEnabled,
|
||||||
|
super.enableIMEPersonalizedLearning,
|
||||||
|
super.contentInsertionConfiguration,
|
||||||
|
super.contextMenuBuilder = appTextFieldContextMenuBuilder,
|
||||||
|
super.hintText,
|
||||||
|
super.border,
|
||||||
|
super.borderRadius,
|
||||||
|
super.filled,
|
||||||
|
super.statesController,
|
||||||
|
super.magnifierConfiguration,
|
||||||
|
super.spellCheckConfiguration,
|
||||||
|
super.features,
|
||||||
|
super.submitFormatters,
|
||||||
|
super.skipInputFeatureFocusTraversal,
|
||||||
|
super.onDragSelectionStart,
|
||||||
|
super.onDragSelectionUpdate,
|
||||||
|
super.onDragSelectionEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget appTextFieldContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
|
||||||
|
// Match Flutter's platform text-menu policy without falling back to
|
||||||
|
// shadcn_flutter's full-screen overlay. EditableText bypasses this builder
|
||||||
|
// on Web while the browser context menu is enabled.
|
||||||
|
if (SystemContextMenu.isSupportedByField(editableTextState)) {
|
||||||
|
return SystemContextMenu.editableText(editableTextState: editableTextState);
|
||||||
|
}
|
||||||
|
return material.AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState);
|
||||||
|
}
|
||||||
@@ -251,7 +251,7 @@ Future<String?> _getManifestPath(Browser browser) async {
|
|||||||
|
|
||||||
Future<bool> _checkWindowsRegistry(String keyPath) async {
|
Future<bool> _checkWindowsRegistry(String keyPath) async {
|
||||||
try {
|
try {
|
||||||
final key = Registry.openPath(RegistryHive.localMachine, path: keyPath);
|
final key = LOCAL_MACHINE.open(keyPath);
|
||||||
key.close();
|
key.close();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ String logsDir() {
|
|||||||
return path.join(Util.getStorageDir(), 'logs');
|
return path.join(Util.getStorageDir(), 'logs');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Directory ensureLogDirectory(String logDirPath) {
|
||||||
|
return Directory(logDirPath)..createSync(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
LogOutput? _buildOutput() {
|
LogOutput? _buildOutput() {
|
||||||
// if is debug mode, don't log to file
|
// if is debug mode, don't log to file
|
||||||
if (!kDebugMode && Util.isDesktop()) {
|
if (!kDebugMode && Util.isDesktop()) {
|
||||||
final logDirPath = logsDir();
|
final logDirPath = logsDir();
|
||||||
var logDir = Directory(logsDir());
|
ensureLogDirectory(logDirPath);
|
||||||
if (!logDir.existsSync()) {
|
|
||||||
logDir.createSync();
|
|
||||||
}
|
|
||||||
return FileOutput(file: File('$logDirPath/client.log'));
|
return FileOutput(file: File('$logDirPath/client.log'));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ void doRegisterUrlScheme(String scheme) {
|
|||||||
|
|
||||||
void doUnregisterUrlScheme(String scheme) {
|
void doUnregisterUrlScheme(String scheme) {
|
||||||
if (Util.isWindows()) {
|
if (Util.isWindows()) {
|
||||||
Registry.currentUser.deleteKey('Software\\Classes\\$scheme', recursive: true);
|
CURRENT_USER.removeSubkey('Software\\Classes\\$scheme');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +66,8 @@ void doUnregisterDefaultTorrentClient() {
|
|||||||
if (Util.isWindows()) {
|
if (Util.isWindows()) {
|
||||||
doUnregisterUrlScheme("magnet");
|
doUnregisterUrlScheme("magnet");
|
||||||
|
|
||||||
Registry.currentUser.deleteKey(_torrentRegKey, recursive: true);
|
CURRENT_USER.removeSubkey(_torrentRegKey);
|
||||||
Registry.currentUser.deleteKey(_torrentAppRegKey, recursive: true);
|
CURRENT_USER.removeSubkey(_torrentAppRegKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import 'package:win32_registry/win32_registry.dart';
|
|||||||
/// Check registry key
|
/// Check registry key
|
||||||
/// If the key does not exist or the value is different, return false
|
/// If the key does not exist or the value is different, return false
|
||||||
bool checkRegistry(String keyPath, String valueName, String value) {
|
bool checkRegistry(String keyPath, String valueName, String value) {
|
||||||
RegistryKey regKey;
|
RegistryKey? regKey;
|
||||||
try {
|
try {
|
||||||
regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath);
|
regKey = CURRENT_USER.open(keyPath);
|
||||||
return regKey.getValueAsString(valueName) == value;
|
return regKey.getString(valueName) == value;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
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 key does not exist, create it
|
||||||
/// If the value does not exist or is different, update it
|
/// If the value does not exist or is different, update it
|
||||||
void upsertRegistry(String keyPath, String valueName, String value) {
|
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 {
|
try {
|
||||||
regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath, desiredAccessRights: AccessRights.allAccess);
|
if (regKey.getString(valueName) != value) {
|
||||||
} catch (e) {
|
regKey.setValue(valueName, RegistryValue.string(value));
|
||||||
regKey = Registry.currentUser.createKey(keyPath);
|
}
|
||||||
|
} finally {
|
||||||
|
regKey.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (regKey.getValueAsString(valueName) != value) {
|
|
||||||
regKey.createValue(RegistryValue(valueName, RegistryValueType.string, value));
|
|
||||||
}
|
|
||||||
regKey.close();
|
|
||||||
}
|
}
|
||||||
@@ -48,11 +48,11 @@ static void my_application_activate(GApplication* application) {
|
|||||||
if (use_header_bar) {
|
if (use_header_bar) {
|
||||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||||
gtk_header_bar_set_title(header_bar, "gopeed");
|
gtk_header_bar_set_title(header_bar, "Gopeed");
|
||||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||||
} else {
|
} else {
|
||||||
gtk_window_set_title(window, "gopeed");
|
gtk_window_set_title(window, "Gopeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
gtk_window_set_default_size(window, 1280, 720);
|
gtk_window_set_default_size(window, 1280, 720);
|
||||||
|
|||||||
@@ -26,16 +26,18 @@ class AppDelegate: FlutterAppDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||||
if !flag {
|
// Closed child windows can remain in NSApp.windows. Restoring every
|
||||||
for window in NSApp.windows {
|
// window resurrects completed create-task forms with their last frame.
|
||||||
if !window.isVisible {
|
// Use the main window even when an unfinished child is still visible.
|
||||||
window.setIsVisible(true)
|
if let window = sender.windows.first(where: { $0 is MainFlutterWindow }) {
|
||||||
}
|
if window.isMiniaturized {
|
||||||
window.makeKeyAndOrderFront(self)
|
window.deminiaturize(self)
|
||||||
}
|
}
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
window.makeKeyAndOrderFront(self)
|
||||||
|
sender.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
return true
|
// We handled reopening; AppKit must not restore other retained windows.
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func application(_ sender: NSApplication, openFile filename: String) -> Bool {
|
override func application(_ sender: NSApplication, openFile filename: String) -> Bool {
|
||||||
|
|||||||
@@ -330,7 +330,7 @@
|
|||||||
</items>
|
</items>
|
||||||
<point key="canvasLocation" x="142" y="-258"/>
|
<point key="canvasLocation" x="142" y="-258"/>
|
||||||
</menu>
|
</menu>
|
||||||
<window title="APP_NAME" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
|
<window title="Gopeed" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="MainFlutterWindow" customModule="Runner" customModuleProvider="target">
|
||||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||||
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
|
<rect key="contentRect" x="335" y="390" width="800" height="600"/>
|
||||||
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
|
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1577"/>
|
||||||
|
|||||||
+95
-38
@@ -17,6 +17,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.1"
|
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:
|
animation_kit:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -42,7 +50,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "1.0.3"
|
||||||
app_links_platform_interface:
|
app_links_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: app_links_platform_interface
|
name: app_links_platform_interface
|
||||||
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
|
||||||
@@ -245,10 +253,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dart_ipc
|
name: dart_ipc
|
||||||
sha256: "6cad558cda5304017c1f581df4c96fd4f8e4ee212aae7bfa4357716236faa9ba"
|
sha256: "3b372078dcc542b97189da4df2b08c1fc5ab0123ecdce37d400ce85ab651b273"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "2.0.2"
|
||||||
dart_style:
|
dart_style:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -293,18 +301,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da"
|
sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.3.0"
|
version: "13.2.0"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: device_info_plus_platform_interface
|
name: device_info_plus_platform_interface
|
||||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.3"
|
version: "8.1.0"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -361,30 +369,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
ffigen:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: ffigen
|
name: ffigen
|
||||||
sha256: d3e76c2ad48a4e7f93a29a162006f00eba46ce7c08194a77bb5c5e97d1b5ff0a
|
sha256: "56fc0be88937409839a59c133dfdee4165e80da324c9e39f59013060249ffb44"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.2"
|
version: "21.0.0"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: file
|
name: file
|
||||||
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.4"
|
version: "7.0.1"
|
||||||
file_picker:
|
file_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "7423298f08f6fc8cce05792bae329f9a93653fc9c08712831b1a55540127995d"
|
sha256: "0631f51bbc2e1184bdc9e79384db158dc7830e15b3001feeca700e86563f4d24"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -541,14 +589,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.12"
|
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:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -755,11 +795,12 @@ packages:
|
|||||||
launch_at_startup:
|
launch_at_startup:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: launch_at_startup
|
path: "."
|
||||||
sha256: "1f8a75520913d1038630049e6c44a2575a23ffd28cc8b14fdf37401d1d21de84"
|
ref: "7ac6f842be974aa08dcd21cab6efa66a3749f2b2"
|
||||||
url: "https://pub.dev"
|
resolved-ref: "7ac6f842be974aa08dcd21cab6efa66a3749f2b2"
|
||||||
source: hosted
|
url: "https://github.com/GopeedLab/launch_at_startup.git"
|
||||||
version: "0.3.1"
|
source: git
|
||||||
|
version: "0.6.0"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -856,6 +897,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -932,18 +981,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.3.1"
|
version: "10.2.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: package_info_plus_platform_interface
|
name: package_info_plus_platform_interface
|
||||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "4.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1237,18 +1286,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.1.4"
|
version: "13.3.0"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: share_plus_platform_interface
|
name: share_plus_platform_interface
|
||||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.2"
|
version: "7.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1659,18 +1708,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.15.0"
|
version: "6.4.0"
|
||||||
win32_registry:
|
win32_registry:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: win32_registry
|
name: win32_registry
|
||||||
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
|
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.5"
|
version: "3.0.3"
|
||||||
window_manager:
|
window_manager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1679,6 +1728,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.2"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+13
-9
@@ -44,21 +44,24 @@ dependencies:
|
|||||||
ffi: ^2.1.3
|
ffi: ^2.1.3
|
||||||
crypto: ^3.0.6
|
crypto: ^3.0.6
|
||||||
logger: ^1.4.0
|
logger: ^1.4.0
|
||||||
package_info_plus: ^8.1.0
|
package_info_plus: ^10.2.1
|
||||||
pub_semver: ^2.2.0
|
pub_semver: ^2.2.0
|
||||||
device_info_plus: ^11.1.0
|
device_info_plus: ^13.2.0
|
||||||
win32_registry: ^1.1.5
|
win32_registry: ^3.0.3
|
||||||
launch_at_startup: ^0.3.1
|
launch_at_startup:
|
||||||
|
git:
|
||||||
|
url: https://github.com/GopeedLab/launch_at_startup.git
|
||||||
|
ref: 7ac6f842be974aa08dcd21cab6efa66a3749f2b2
|
||||||
app_links: ^6.3.3
|
app_links: ^6.3.3
|
||||||
share_handler: ^0.0.22
|
share_handler: ^0.0.22
|
||||||
share_plus: ^10.1.0
|
share_plus: ^13.3.0
|
||||||
uri_content: ^3.1.3
|
uri_content: ^3.1.3
|
||||||
dart_ipc: ^1.0.1
|
dart_ipc: ^2.0.2
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
flutter_local_notifications: 20.1.0
|
flutter_local_notifications: 20.1.0
|
||||||
desktop_drop: ^0.4.4
|
desktop_drop: ^0.4.4
|
||||||
external_path: ^2.2.0
|
external_path: ^2.2.0
|
||||||
file_picker: 9.0.2
|
file_picker: 12.2.0
|
||||||
open_dir: ^0.0.2+1
|
open_dir: ^0.0.2+1
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
@@ -105,6 +108,7 @@ dependency_overrides:
|
|||||||
path: permission_handler_windows
|
path: permission_handler_windows
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
app_links_platform_interface: ^2.0.2
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ dev_dependencies:
|
|||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
ffigen: ^8.0.2
|
ffigen: ^21.0.0
|
||||||
build_runner: ^2.15.1
|
build_runner: ^2.15.1
|
||||||
json_serializable: ^6.14.1
|
json_serializable: ^6.14.1
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
@@ -208,7 +212,7 @@ ffigen:
|
|||||||
- "StartAPIServer"
|
- "StartAPIServer"
|
||||||
- "StopAPIServer"
|
- "StopAPIServer"
|
||||||
- "RestartAPIServer"
|
- "RestartAPIServer"
|
||||||
- "Invoke"
|
- "InvokeAsync"
|
||||||
- "SubscribeTaskEvents"
|
- "SubscribeTaskEvents"
|
||||||
- "FreeCString"
|
- "FreeCString"
|
||||||
structs:
|
structs:
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/model/downloader_config.dart';
|
||||||
|
import 'package:gopeed/app/application/app_notification_controller.dart';
|
||||||
|
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||||
|
import 'package:gopeed/core/common/api_server_state.dart';
|
||||||
|
import 'package:gopeed/core/common/start_config.dart';
|
||||||
|
import 'package:gopeed/core/common/task_event.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
var permissionGranted = true;
|
||||||
|
late StreamController<TaskEvent> events;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||||
|
MacOSFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
calls.clear();
|
||||||
|
permissionGranted = true;
|
||||||
|
events = StreamController<TaskEvent>.broadcast();
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return permissionGranted;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
unawaited(events.close());
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<ProviderContainer> start({required bool enabled}) async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)),
|
||||||
|
appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
await container.read(appRuntimeControllerProvider.future);
|
||||||
|
await container.read(appNotificationControllerProvider.future);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<dynamic, dynamic> initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map;
|
||||||
|
|
||||||
|
test('macOS requests alerts and sound when download notifications are enabled at startup', () async {
|
||||||
|
await start(enabled: true);
|
||||||
|
|
||||||
|
expect(initialization()['requestAlertPermission'], isTrue);
|
||||||
|
expect(initialization()['requestSoundPermission'], isTrue);
|
||||||
|
expect(initialization()['requestBadgePermission'], isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled notifications defer authorization until the setting is enabled', () async {
|
||||||
|
final container = await start(enabled: false);
|
||||||
|
expect(initialization()['requestAlertPermission'], isFalse);
|
||||||
|
expect(initialization()['requestSoundPermission'], isFalse);
|
||||||
|
|
||||||
|
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||||
|
runtime.setNotificationsEnabled(true);
|
||||||
|
await container.pump();
|
||||||
|
await container.read(appNotificationControllerProvider.future);
|
||||||
|
|
||||||
|
expect(initialization()['requestAlertPermission'], isTrue);
|
||||||
|
expect(initialization()['requestSoundPermission'], isTrue);
|
||||||
|
|
||||||
|
runtime.setNotificationsEnabled(false);
|
||||||
|
await container.pump();
|
||||||
|
await container.read(appNotificationControllerProvider.future);
|
||||||
|
expect(initialization()['requestAlertPermission'], isFalse);
|
||||||
|
expect(initialization()['requestSoundPermission'], isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('denied macOS authorization does not fail notification controller startup', () async {
|
||||||
|
permissionGranted = false;
|
||||||
|
final container = await start(enabled: true);
|
||||||
|
|
||||||
|
expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal task events notify once after enabling and stop after disabling', () async {
|
||||||
|
final container = await start(enabled: false);
|
||||||
|
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||||
|
const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip');
|
||||||
|
events.add(done);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(calls.where((call) => call.method == 'show'), isEmpty);
|
||||||
|
|
||||||
|
runtime.setNotificationsEnabled(true);
|
||||||
|
await container.pump();
|
||||||
|
await container.read(appNotificationControllerProvider.future);
|
||||||
|
events.add(done);
|
||||||
|
events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip'));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final notifications = calls.where((call) => call.method == 'show').toList();
|
||||||
|
expect(notifications, hasLength(2));
|
||||||
|
expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']);
|
||||||
|
|
||||||
|
runtime.setNotificationsEnabled(false);
|
||||||
|
await container.pump();
|
||||||
|
await container.read(appNotificationControllerProvider.future);
|
||||||
|
events.add(done);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(calls.where((call) => call.method == 'show'), hasLength(2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestNotificationController extends AppNotificationController {
|
||||||
|
_TestNotificationController(this.taskEvents);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Stream<TaskEvent> taskEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestRuntimeController extends AppRuntimeController {
|
||||||
|
_TestRuntimeController(this.enabled);
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppRuntimeState> build() async => _runtime(enabled);
|
||||||
|
|
||||||
|
void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled));
|
||||||
|
|
||||||
|
AppRuntimeState _runtime(bool enabled) => AppRuntimeState(
|
||||||
|
startConfig: StartConfig(),
|
||||||
|
apiServerState: ApiServerState.fromJson({}),
|
||||||
|
downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/model/downloader_config.dart';
|
||||||
|
import 'package:gopeed/app/application/app_platform_controller.dart';
|
||||||
|
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||||
|
import 'package:gopeed/core/common/api_server_state.dart';
|
||||||
|
import 'package:gopeed/core/common/start_config.dart';
|
||||||
|
import 'package:gopeed/util/updater.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('runtime updates do not rebuild the platform controller', () async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
|
||||||
|
appPlatformControllerProvider.overrideWith(_TestPlatformController.new),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
final subscription = container.listen(appPlatformControllerProvider, (_, _) {}, fireImmediately: true);
|
||||||
|
addTearDown(subscription.close);
|
||||||
|
|
||||||
|
await container.read(appPlatformControllerProvider.future);
|
||||||
|
final platformController = container.read(appPlatformControllerProvider.notifier) as _TestPlatformController;
|
||||||
|
final runtimeController = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||||
|
|
||||||
|
expect(platformController.buildCount, 1);
|
||||||
|
|
||||||
|
runtimeController.completeRuntime();
|
||||||
|
await container.read(appRuntimeControllerProvider.future);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(platformController.updateCheckCount, 1);
|
||||||
|
|
||||||
|
final config = DownloaderConfig.fromJson(runtimeController.state.requireValue.downloaderConfig.toJson());
|
||||||
|
config.extra.locale = 'zh';
|
||||||
|
runtimeController.replaceDownloaderConfig(config);
|
||||||
|
await container.pump();
|
||||||
|
|
||||||
|
expect(platformController.buildCount, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestPlatformController extends AppPlatformController {
|
||||||
|
int buildCount = 0;
|
||||||
|
int updateCheckCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isDesktop => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppPlatformState> build() {
|
||||||
|
buildCount++;
|
||||||
|
return super.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<VersionInfo?> checkForUpdate() async {
|
||||||
|
updateCheckCount++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestRuntimeController extends AppRuntimeController {
|
||||||
|
final _pendingRuntime = Completer<AppRuntimeState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AppRuntimeState> build() => _pendingRuntime.future;
|
||||||
|
|
||||||
|
void completeRuntime() {
|
||||||
|
_pendingRuntime.complete(
|
||||||
|
AppRuntimeState(
|
||||||
|
startConfig: StartConfig(),
|
||||||
|
apiServerState: const ApiServerState(
|
||||||
|
enabled: false,
|
||||||
|
mcpEnabled: false,
|
||||||
|
running: false,
|
||||||
|
network: '',
|
||||||
|
address: '',
|
||||||
|
runningPort: 0,
|
||||||
|
pendingApply: false,
|
||||||
|
lastError: '',
|
||||||
|
),
|
||||||
|
downloaderConfig: DownloaderConfig(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:app_links_platform_interface/app_links_platform_interface.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/api/model/downloader_config.dart';
|
||||||
|
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||||
import 'package:gopeed/app/application/app_deep_link_controller.dart';
|
import 'package:gopeed/app/application/app_deep_link_controller.dart';
|
||||||
|
import 'package:gopeed/core/common/api_server_state.dart';
|
||||||
|
import 'package:gopeed/core/common/start_config.dart';
|
||||||
import 'package:share_handler/share_handler.dart';
|
import 'package:share_handler/share_handler.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -27,4 +35,71 @@ void main() {
|
|||||||
|
|
||||||
expect(sharedMediaUri(media), Uri.parse('magnet:?xt=urn:btih:example'));
|
expect(sharedMediaUri(media), Uri.parse('magnet:?xt=urn:btih:example'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps listening for browser takeover links after runtime settings change', () async {
|
||||||
|
final originalPlatform = AppLinksPlatform.instance;
|
||||||
|
final appLinksPlatform = _FakeAppLinksPlatform();
|
||||||
|
AppLinksPlatform.instance = appLinksPlatform;
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)],
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await container.read(appDeepLinkControllerProvider.future);
|
||||||
|
expect(appLinksPlatform.hasListener, isTrue);
|
||||||
|
|
||||||
|
container.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(DownloaderConfig());
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(appLinksPlatform.hasListener, isTrue);
|
||||||
|
} finally {
|
||||||
|
container.dispose();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
AppLinksPlatform.instance = originalPlatform;
|
||||||
|
await appLinksPlatform.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeAppLinksPlatform extends AppLinksPlatform {
|
||||||
|
final _links = StreamController<Uri>.broadcast();
|
||||||
|
|
||||||
|
bool get hasListener => _links.hasListener;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uri?> getInitialLink() async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Uri> get uriLinkStream => _links.stream;
|
||||||
|
|
||||||
|
Future<void> close() => _links.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeRuntimeController extends AppRuntimeController {
|
||||||
|
@override
|
||||||
|
Future<AppRuntimeState> build() async {
|
||||||
|
final config = StartConfig()
|
||||||
|
..network = 'tcp'
|
||||||
|
..address = '127.0.0.1:9999'
|
||||||
|
..apiEnable = true
|
||||||
|
..apiToken = ''
|
||||||
|
..storage = 'bolt'
|
||||||
|
..storageDir = ''
|
||||||
|
..refreshInterval = 0;
|
||||||
|
return AppRuntimeState(
|
||||||
|
startConfig: config,
|
||||||
|
apiServerState: const ApiServerState(
|
||||||
|
enabled: true,
|
||||||
|
mcpEnabled: false,
|
||||||
|
running: true,
|
||||||
|
network: 'tcp',
|
||||||
|
address: '127.0.0.1:9999',
|
||||||
|
runningPort: 9999,
|
||||||
|
pendingApply: false,
|
||||||
|
lastError: '',
|
||||||
|
),
|
||||||
|
downloaderConfig: DownloaderConfig(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ void main() {
|
|||||||
test('create-task child window has a localized native title', () {
|
test('create-task child window has a localized native title', () {
|
||||||
expect(
|
expect(
|
||||||
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
||||||
'Create Task - Gopeed',
|
'Gopeed-Create Task',
|
||||||
);
|
);
|
||||||
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed');
|
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create-task payload only carries initial task data', () {
|
test('create-task payload only carries initial task data', () {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/widgets.dart' as flutter show Row;
|
import 'package:flutter/widgets.dart' as flutter show Row;
|
||||||
|
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
||||||
import 'package:flutter/material.dart' show Icons;
|
import 'package:flutter/material.dart' show Icons;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -8,19 +11,193 @@ import 'package:gopeed/api/model/create_task.dart';
|
|||||||
import 'package:gopeed/api/model/downloader_config.dart';
|
import 'package:gopeed/api/model/downloader_config.dart';
|
||||||
import 'package:gopeed/api/model/options.dart';
|
import 'package:gopeed/api/model/options.dart';
|
||||||
import 'package:gopeed/api/model/request.dart';
|
import 'package:gopeed/api/model/request.dart';
|
||||||
|
import 'package:gopeed/api/model/resolve_result.dart';
|
||||||
|
import 'package:gopeed/api/model/resource.dart';
|
||||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||||
|
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
|
||||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||||
import 'package:gopeed/core/capabilities/storage_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/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/services/download_directory_picker.dart';
|
||||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||||
import 'package:gopeed/shared/theme/app_design_tokens.dart';
|
import 'package:gopeed/shared/theme/app_design_tokens.dart';
|
||||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||||
|
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
for (final navigationFails in [false, true]) {
|
||||||
|
testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1024, 720);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final events = <String>[];
|
||||||
|
const channel = MethodChannel('window_manager');
|
||||||
|
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
events.add(call.method);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||||
|
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getConfig, (_) => config)
|
||||||
|
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
|
||||||
|
..bind(GopeedMethods.createTask, (_) {
|
||||||
|
events.add('created');
|
||||||
|
return 'task-id';
|
||||||
|
})
|
||||||
|
..bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||||
|
events.add('downloads');
|
||||||
|
if (navigationFails) throw StateError('Navigation unavailable');
|
||||||
|
return const RpcUnit();
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||||
|
child: shad.ShadcnApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: AppComponentThemes(
|
||||||
|
child: CreateTaskWindowPage(
|
||||||
|
windowController: WindowController.fromWindowId('child'),
|
||||||
|
initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Confirm'));
|
||||||
|
// The mocked native close deliberately leaves the widget mounted.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(events, ['created', 'downloads', 'close']);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.padding = const FakeViewPadding(top: 32, bottom: 24);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetPadding);
|
||||||
|
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
|
||||||
|
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||||
|
child: shad.ShadcnApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final contentPadding = tester.widget<Padding>(find.byKey(const ValueKey('create-task-safe-content')));
|
||||||
|
expect((contentPadding.padding as EdgeInsets).top, 32);
|
||||||
|
} finally {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('browser extension request parameters populate the form and survive submission', (
|
testWidgets('browser extension request parameters populate the form and survive submission', (
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -119,6 +296,22 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final connectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
|
||||||
|
expect(connectionsInput, findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.getSize(connectionsInput).width,
|
||||||
|
tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width,
|
||||||
|
);
|
||||||
|
final connectionsField = tester.widget<shad.TextField>(connectionsInput);
|
||||||
|
expect(connectionsField.controller!.text, '12');
|
||||||
|
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
|
||||||
|
|
||||||
|
final advancedSection = find.byKey(const ValueKey('create-task-advanced-section'));
|
||||||
|
expect(tester.widget<AnimatedCrossFade>(advancedSection).crossFadeState, CrossFadeState.showSecond);
|
||||||
|
await tester.tap(find.text('Advanced'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<AnimatedCrossFade>(advancedSection).crossFadeState, CrossFadeState.showFirst);
|
||||||
|
|
||||||
expect(_fieldText(tester, 'create-task-http-header-name-0'), 'User-Agent');
|
expect(_fieldText(tester, 'create-task-http-header-name-0'), 'User-Agent');
|
||||||
expect(_fieldText(tester, 'create-task-http-header-value-0'), 'Browser UA');
|
expect(_fieldText(tester, 'create-task-http-header-value-0'), 'Browser UA');
|
||||||
expect(_fieldText(tester, 'create-task-http-header-name-3'), 'Authorization');
|
expect(_fieldText(tester, 'create-task-http-header-name-3'), 'Authorization');
|
||||||
@@ -267,6 +460,57 @@ void main() {
|
|||||||
expect(submitted?.opts?.asDefaultPath, isTrue);
|
expect(submitted?.opts?.asDefaultPath, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(700, 700);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||||
|
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||||
|
..bind(
|
||||||
|
GopeedMethods.resolve,
|
||||||
|
(_) => ResolveResult(
|
||||||
|
id: 'resolved-id',
|
||||||
|
res: Resource(
|
||||||
|
name: 'server-generated-name',
|
||||||
|
files: [FileInfo(name: 'video.mp4', size: 1024)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||||
|
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||||
|
child: shad.ShadcnApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4');
|
||||||
|
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(_fieldText(tester, 'create-task-rename-input'), isEmpty);
|
||||||
|
final createButton = tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button')));
|
||||||
|
expect(createButton.child, isA<Text>());
|
||||||
|
final createText = createButton.child as Text;
|
||||||
|
expect(createText.data, 'Create');
|
||||||
|
expect(createText.maxLines, 1);
|
||||||
|
expect(createText.softWrap, isFalse);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))),
|
||||||
|
tester.getSize(find.byKey(const ValueKey('resolve-create-button'))),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('create history supports filtering, deleting one entry, and clearing all entries', (
|
testWidgets('create history supports filtering, deleting one entry, and clearing all entries', (
|
||||||
WidgetTester tester,
|
WidgetTester tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/features/tasks/presentation/widgets/resolve_task_actions.dart';
|
||||||
|
import 'package:gopeed/l10n/l10n.dart';
|
||||||
|
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||||
|
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||||
|
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||||
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final width in [390.0, 1024.0]) {
|
||||||
|
for (final language in ['en', 'zh', 'de']) {
|
||||||
|
testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async {
|
||||||
|
tester.view.physicalSize = Size(width, 760);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
var submitting = false;
|
||||||
|
var cancelled = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
shad.ShadcnApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => Localizations.override(
|
||||||
|
context: context,
|
||||||
|
locale: Locale(language),
|
||||||
|
delegates: const [AppLocalizations.delegate],
|
||||||
|
child: AppComponentThemes(
|
||||||
|
child: Center(
|
||||||
|
child: StatefulBuilder(
|
||||||
|
builder: (context, setState) => shad.AlertDialog(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100),
|
||||||
|
actions: [
|
||||||
|
Flexible(
|
||||||
|
child: ResolveTaskActions(
|
||||||
|
submitting: submitting,
|
||||||
|
onCancel: () => cancelled = true,
|
||||||
|
onCreate: () => setState(() => submitting = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final cancel = find.byKey(const ValueKey('resolve-cancel-button'));
|
||||||
|
final create = find.byKey(const ValueKey('resolve-create-button'));
|
||||||
|
final cancelRect = tester.getRect(cancel);
|
||||||
|
final createRect = tester.getRect(create);
|
||||||
|
expect(cancelRect.size, createRect.size);
|
||||||
|
expect(cancelRect.center.dy, createRect.center.dy);
|
||||||
|
expect(cancelRect.left, greaterThanOrEqualTo(0));
|
||||||
|
expect(createRect.right, lessThanOrEqualTo(width));
|
||||||
|
expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget);
|
||||||
|
await tester.tap(cancel);
|
||||||
|
expect(cancelled, isTrue);
|
||||||
|
await tester.tap(create);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
expect(tester.widget<AppLoadingButton>(create).loading, isTrue);
|
||||||
|
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
|
||||||
|
expect(tester.getRect(cancel), cancelRect);
|
||||||
|
expect(tester.getRect(create), createRect);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:gopeed/util/log_util.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory temporaryDirectory;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
temporaryDirectory = await Directory.systemTemp.createTemp('gopeed-log-util-');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await temporaryDirectory.delete(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates the log directory when its parents do not exist', () {
|
||||||
|
final logDirPath = path.join(temporaryDirectory.path, 'storage', 'logs');
|
||||||
|
|
||||||
|
final logDirectory = ensureLogDirectory(logDirPath);
|
||||||
|
|
||||||
|
expect(logDirectory.path, logDirPath);
|
||||||
|
expect(logDirectory.existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows the log directory to be initialized repeatedly', () {
|
||||||
|
final logDirPath = path.join(temporaryDirectory.path, 'storage', 'logs');
|
||||||
|
|
||||||
|
ensureLogDirectory(logDirPath);
|
||||||
|
final logDirectory = ensureLogDirectory(logDirPath);
|
||||||
|
|
||||||
|
expect(logDirectory.existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart';
|
|||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('provides the standard text field appearance', (tester) async {
|
||||||
|
late shad.TextFieldTheme textFieldTheme;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
shad.ShadcnApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
materialTheme: AppTheme.materialLight(),
|
||||||
|
home: AppComponentThemes(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
textFieldTheme = shad.ComponentTheme.of<shad.TextFieldTheme>(context);
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(textFieldTheme.filled, isTrue);
|
||||||
|
expect(textFieldTheme.border, Border.all(color: AppPalette.light.border));
|
||||||
|
expect(textFieldTheme.borderRadius, BorderRadius.circular(AppDesignTokens.controlRadius));
|
||||||
|
expect(textFieldTheme.padding, AppDesignTokens.textFieldPadding);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('provides the standard checkbox metrics', (tester) async {
|
testWidgets('provides the standard checkbox metrics', (tester) async {
|
||||||
late shad.CheckboxTheme checkboxTheme;
|
late shad.CheckboxTheme checkboxTheme;
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user