Merge branch 'main' into codex/fix-settings-script-path-picker

This commit is contained in:
Levi authored and GitHub committed 2026-09-09 23:41:17 +08:00
commit f0aa44455c
27 files changed
+1210 -187

No files matched your search

@@ -37,10 +37,15 @@ class AppDeepLinkController extends AsyncNotifier<AppDeepLinkState> {
@override
Future<AppDeepLinkState> build() async {
final runtime = ref.watch(appRuntimeControllerProvider).value;
if (runtime == null || _started || kIsWeb) {
if (_started || kIsWeb) {
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;
final appLinks = AppLinks();
_subscription = appLinks.uriLinkStream.listen((uri) {
@@ -11,6 +11,7 @@ import 'task_event.dart';
class LibgopeedChannel implements LibgopeedInterface {
static const _channel = MethodChannel('gopeed.com/libgopeed');
final _taskEvents = StreamController<TaskEvent>.broadcast();
int _requestID = 0;
LibgopeedChannel() {
_channel.setMethodCallHandler((call) async {
@@ -50,7 +51,9 @@ class LibgopeedChannel implements LibgopeedInterface {
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
final requestID = _requestID++;
final result = await _channel.invokeMethod<String>('invoke', {
'requestID': requestID,
'method': method,
'path': path,
'query': query,
+10 -64
View File
@@ -1,10 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import '../ffi/libgopeed_bind.dart';
import '../ffi/libgopeed_worker.dart';
import 'api_server_state.dart';
import 'libgopeed_interface.dart';
@@ -12,51 +8,22 @@ import 'start_config.dart';
import 'task_event.dart';
class LibgopeedFFi implements LibgopeedInterface {
late LibgopeedBind _libgopeed;
LibgopeedFFi() {
_worker.taskEvents.listen(_onTaskEvent, onError: _taskEvents.addError);
}
final _taskEvents = StreamController<TaskEvent>.broadcast();
final _worker = LibgopeedWorker();
late final NativeCallable<Void Function(Pointer<Char>)> _taskEventCallback;
LibgopeedFFi(LibgopeedBind libgopeed) {
_libgopeed = libgopeed;
_taskEventCallback = NativeCallable<Void Function(Pointer<Char>)>.listener(_onTaskEvent);
}
void _onTaskEvent(Pointer<Char> payload) {
try {
final decoded = jsonDecode(payload.cast<Utf8>().toDartString()) as Map<String, dynamic>;
_taskEvents.add(TaskEvent.fromJson(decoded));
} finally {
_libgopeed.FreeCString(payload);
}
void _onTaskEvent(Map<String, dynamic> payload) {
_taskEvents.add(TaskEvent.fromJson(payload));
}
@override
Future<int> start(StartConfig cfg) {
var completer = Completer<int>();
final cfgPtr = jsonEncode(cfg).toNativeUtf8();
try {
final result = _libgopeed.Start(cfgPtr.cast());
if (result.r1 != nullptr) {
final message = result.r1.cast<Utf8>().toDartString();
_libgopeed.FreeCString(result.r1);
completer.completeError(Exception(message));
} else {
completer.complete(result.r0);
}
} finally {
malloc.free(cfgPtr);
}
return completer.future;
}
Future<int> start(StartConfig cfg) => _worker.start(jsonEncode(cfg));
@override
Future<void> stop() {
var completer = Completer<void>();
_libgopeed.Stop();
completer.complete();
return completer.future;
}
Future<void> stop() => _worker.stop();
Future<ApiServerOperationResult> _apiServerOperation(String operation) async {
final payload = await _worker.apiServer(operation);
@@ -77,33 +44,12 @@ class LibgopeedFFi implements LibgopeedInterface {
@override
Future<String> invoke(String method, String path, {String query = '', String body = ''}) async {
final methodPtr = method.toNativeUtf8();
final pathPtr = path.toNativeUtf8();
final queryPtr = query.toNativeUtf8();
final bodyPtr = body.toNativeUtf8();
Pointer<Char>? resultPtr;
try {
resultPtr = _libgopeed.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast());
if (resultPtr == nullptr) {
throw StateError('Gopeed Invoke returned a null response');
}
return resultPtr.cast<Utf8>().toDartString();
} finally {
malloc.free(methodPtr);
malloc.free(pathPtr);
malloc.free(queryPtr);
malloc.free(bodyPtr);
if (resultPtr != null && resultPtr != nullptr) {
_libgopeed.FreeCString(resultPtr);
}
}
return jsonEncode(await _worker.invoke(method, path, query, body));
}
@override
Stream<TaskEvent> get taskEvents => _taskEvents.stream;
@override
Future<void> subscribeTaskEvents(Set<TaskEventType> events) async {
_libgopeed.SubscribeTaskEvents(events.mask, events.isEmpty ? 0 : _taskEventCallback.nativeFunction.address);
}
Future<void> subscribeTaskEvents(Set<TaskEventType> events) => _worker.subscribeTaskEvents(events.mask);
}
@@ -1,6 +1,4 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import '../../util/util.dart';
import '../common/libgopeed_channel.dart';
@@ -9,7 +7,6 @@ import '../common/libgopeed_interface.dart';
import '../common/api_server_state.dart';
import '../common/start_config.dart';
import '../common/task_event.dart';
import '../ffi/libgopeed_bind.dart';
import '../libgopeed_boot.dart';
LibgopeedBoot create() => LibgopeedBootNative();
@@ -19,17 +16,7 @@ class LibgopeedBootNative implements LibgopeedBoot {
LibgopeedBootNative() {
if (Util.isDesktop()) {
var libName = "libgopeed.";
if (Platform.isWindows) {
libName += "dll";
}
if (Platform.isMacOS) {
libName += "dylib";
}
if (Platform.isLinux) {
libName += "so";
}
_libgopeed = LibgopeedFFi(LibgopeedBind(DynamicLibrary.open(libName)));
_libgopeed = LibgopeedFFi();
} else {
_libgopeed = LibgopeedChannel();
}
+28 -15
View File
@@ -30,53 +30,67 @@ class LibgopeedBind {
late final _StopPtr = _lookup<ffi.NativeFunction<ffi.Void Function()>>('Stop');
late final _Stop = _StopPtr.asFunction<void Function()>();
ffi.Pointer<ffi.Char> GetAPIServerState() => _GetAPIServerState();
ffi.Pointer<ffi.Char> GetAPIServerState() {
return _GetAPIServerState();
}
late final _GetAPIServerStatePtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('GetAPIServerState');
late final _GetAPIServerState = _GetAPIServerStatePtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> StartAPIServer() => _StartAPIServer();
ffi.Pointer<ffi.Char> StartAPIServer() {
return _StartAPIServer();
}
late final _StartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StartAPIServer');
late final _StartAPIServer = _StartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> StopAPIServer() => _StopAPIServer();
ffi.Pointer<ffi.Char> StopAPIServer() {
return _StopAPIServer();
}
late final _StopAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('StopAPIServer');
late final _StopAPIServer = _StopAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> RestartAPIServer() => _RestartAPIServer();
ffi.Pointer<ffi.Char> RestartAPIServer() {
return _RestartAPIServer();
}
late final _RestartAPIServerPtr = _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Char> Function()>>('RestartAPIServer');
late final _RestartAPIServer = _RestartAPIServerPtr.asFunction<ffi.Pointer<ffi.Char> Function()>();
ffi.Pointer<ffi.Char> Invoke(
void InvokeAsync(
ffi.Pointer<ffi.Char> method,
ffi.Pointer<ffi.Char> path,
ffi.Pointer<ffi.Char> query,
ffi.Pointer<ffi.Char> body,
int requestID,
int callback,
) {
return _Invoke(method, path, query, body);
return _InvokeAsync(method, path, query, body, requestID, callback);
}
late final _InvokePtr =
late final _InvokeAsyncPtr =
_lookup<
ffi.NativeFunction<
ffi.Pointer<ffi.Char> Function(
ffi.Void Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.UnsignedLongLong,
ffi.UintPtr,
)
>
>('Invoke');
late final _Invoke =
_InvokePtr.asFunction<
ffi.Pointer<ffi.Char> Function(
>('InvokeAsync');
late final _InvokeAsync =
_InvokeAsyncPtr.asFunction<
void Function(
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
ffi.Pointer<ffi.Char>,
int,
int,
)
>();
@@ -84,9 +98,8 @@ class LibgopeedBind {
return _SubscribeTaskEvents(mask, callback);
}
late final _SubscribeTaskEventsPtr = _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Uint64, ffi.UintPtr)>>(
'SubscribeTaskEvents',
);
late final _SubscribeTaskEventsPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.UnsignedLongLong, ffi.UintPtr)>>('SubscribeTaskEvents');
late final _SubscribeTaskEvents = _SubscribeTaskEventsPtr.asFunction<void Function(int, int)>();
void FreeCString(ffi.Pointer<ffi.Char> value) {
+161 -37
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
@@ -7,38 +8,74 @@ import 'package:ffi/ffi.dart';
import 'libgopeed_bind.dart';
typedef _InvokeResultNative = Void Function(Uint64 requestID, Int32 success, Pointer<Char> payload);
typedef _TaskEventNative = Void Function(Pointer<Char> payload);
class LibgopeedWorker {
LibgopeedWorker() {
factory LibgopeedWorker() => _instance;
LibgopeedWorker._() {
_receivePort.listen(_handleWorkerMessage);
_errorPort.listen(_handleWorkerError);
_isolateReady = Isolate.spawn(_workerMain, _receivePort.sendPort, onError: _errorPort.sendPort);
}
static final LibgopeedWorker _instance = LibgopeedWorker._();
final ReceivePort _receivePort = ReceivePort();
final ReceivePort _errorPort = ReceivePort();
final Map<int, Completer<String>> _pending = {};
final Map<int, Completer<Object?>> _pending = {};
final StreamController<Map<String, dynamic>> _taskEvents = StreamController<Map<String, dynamic>>.broadcast();
late final Future<Isolate> _isolateReady;
final Completer<SendPort> _sendPort = Completer<SendPort>();
Future<void>? _stopFuture;
int _requestID = 0;
bool _stopping = false;
bool _stopped = false;
Future<String> invoke(String method, String path, String query, String body) async {
await _isolateReady;
final sendPort = await _sendPort.future;
final requestID = _requestID++;
final completer = Completer<String>();
_pending[requestID] = completer;
sendPort.send(['invoke', requestID, method, path, query, body]);
return completer.future;
Stream<Map<String, dynamic>> get taskEvents => _taskEvents.stream;
Future<int> start(String config) => _request<int>('start', [config]);
Future<void> stop() => _stopFuture ??= _stop();
Future<void> _stop() async {
_stopping = true;
// The native bridge owns the bounded drain. Sending Stop immediately is
// important because an InvokeAsync handler such as Resolve may never return.
await _request<Object?>('stop', const [], allowWhileStopping: true);
_stopped = true;
final stoppingError = StateError('libgopeed worker stopped before the request completed');
for (final completer in _pending.values) {
completer.completeError(stoppingError);
}
_pending.clear();
await _taskEvents.close();
_receivePort.close();
_errorPort.close();
}
Future<String> apiServer(String operation) async {
Future<Object?> invoke(String method, String path, String query, String body) {
return _request<Object?>('invoke', [method, path, query, body]);
}
Future<String> apiServer(String operation) => _request<String>('apiServer', [operation]);
Future<void> subscribeTaskEvents(int mask) async {
await _request<Object?>('subscribeTaskEvents', [mask]);
}
Future<T> _request<T>(String type, List<Object?> arguments, {bool allowWhileStopping = false}) async {
if (_stopped || (_stopping && !allowWhileStopping)) {
throw StateError('libgopeed worker is stopping');
}
await _isolateReady;
final sendPort = await _sendPort.future;
final requestID = _requestID++;
final completer = Completer<String>();
final completer = Completer<Object?>();
_pending[requestID] = completer;
sendPort.send(['apiServer', requestID, operation]);
return completer.future;
sendPort.send([type, requestID, ...arguments]);
return (await completer.future) as T;
}
void _handleWorkerMessage(dynamic message) {
@@ -47,13 +84,18 @@ class LibgopeedWorker {
return;
}
final response = message as List<dynamic>;
final requestID = response[0] as int;
final completer = _pending.remove(requestID);
if (completer == null) return;
if (response[1] as bool) {
completer.complete(response[2] as String);
} else {
completer.completeError(StateError(response[2] as String));
switch (response[0] as String) {
case 'response':
final requestID = response[1] as int;
final completer = _pending.remove(requestID);
if (completer == null) return;
if (response[2] as bool) {
completer.complete(response[3]);
} else {
completer.completeError(StateError(response[3] as String));
}
case 'taskEvent':
_taskEvents.add((response[1] as Map<Object?, Object?>).cast<String, dynamic>());
}
}
@@ -65,35 +107,117 @@ class LibgopeedWorker {
completer.completeError(error);
}
_pending.clear();
_taskEvents.addError(error);
}
static void _workerMain(SendPort mainSendPort) {
final requests = ReceivePort();
final bindings = LibgopeedBind(DynamicLibrary.open(_libraryName()));
late final NativeCallable<_InvokeResultNative> invokeResultCallback;
late final NativeCallable<_TaskEventNative> taskEventCallback;
invokeResultCallback = NativeCallable<_InvokeResultNative>.listener((
int requestID,
int success,
Pointer<Char> payload,
) {
if (payload == nullptr) {
mainSendPort.send(['response', requestID, false, 'Gopeed InvokeAsync returned a null response']);
return;
}
try {
final result = payload.cast<Utf8>().toDartString();
mainSendPort.send(['response', requestID, success != 0, success != 0 ? jsonDecode(result) : result]);
} catch (error) {
mainSendPort.send(['response', requestID, false, error.toString()]);
} finally {
bindings.FreeCString(payload);
}
});
taskEventCallback = NativeCallable<_TaskEventNative>.listener((Pointer<Char> payload) {
if (payload == nullptr) return;
try {
final event = jsonDecode(payload.cast<Utf8>().toDartString()) as Map<String, dynamic>;
mainSendPort.send(['taskEvent', event]);
} finally {
bindings.FreeCString(payload);
}
});
mainSendPort.send(requests.sendPort);
requests.listen((dynamic message) {
final request = message as List<dynamic>;
final type = request[0] as String;
final requestID = request[1] as int;
try {
final response = switch (type) {
'invoke' => _invoke(
if (type == 'invoke') {
try {
_invokeAsync(
bindings,
invokeResultCallback.nativeFunction.address,
requestID,
request[2] as String,
request[3] as String,
request[4] as String,
request[5] as String,
),
);
} catch (error) {
mainSendPort.send(['response', requestID, false, error.toString()]);
}
return;
}
try {
final response = switch (type) {
'start' => _start(bindings, request[2] as String),
'stop' => _stopNative(bindings),
'apiServer' => _apiServer(bindings, request[2] as String),
'subscribeTaskEvents' => _subscribeTaskEvents(
bindings,
taskEventCallback.nativeFunction.address,
request[2] as int,
),
_ => throw StateError('Unknown libgopeed worker request: $type'),
};
mainSendPort.send([requestID, true, response]);
mainSendPort.send(['response', requestID, true, response]);
if (type == 'stop') {
invokeResultCallback.close();
taskEventCallback.close();
requests.close();
}
} catch (error) {
mainSendPort.send([requestID, false, error.toString()]);
mainSendPort.send(['response', requestID, false, error.toString()]);
}
});
}
static int _start(LibgopeedBind bindings, String config) {
final configPtr = config.toNativeUtf8();
try {
final result = bindings.Start(configPtr.cast());
if (result.r1 != nullptr) {
try {
throw StateError(result.r1.cast<Utf8>().toDartString());
} finally {
bindings.FreeCString(result.r1);
}
}
return result.r0;
} finally {
malloc.free(configPtr);
}
}
static Object? _stopNative(LibgopeedBind bindings) {
bindings.Stop();
return null;
}
static Object? _subscribeTaskEvents(LibgopeedBind bindings, int callback, int mask) {
bindings.SubscribeTaskEvents(mask, mask == 0 ? 0 : callback);
return null;
}
static String _apiServer(LibgopeedBind bindings, String operation) {
final resultPtr = switch (operation) {
'get' => bindings.GetAPIServerState(),
@@ -112,26 +236,26 @@ class LibgopeedWorker {
}
}
static String _invoke(LibgopeedBind bindings, String method, String path, String query, String body) {
static void _invokeAsync(
LibgopeedBind bindings,
int callback,
int requestID,
String method,
String path,
String query,
String body,
) {
final methodPtr = method.toNativeUtf8();
final pathPtr = path.toNativeUtf8();
final queryPtr = query.toNativeUtf8();
final bodyPtr = body.toNativeUtf8();
Pointer<Char>? resultPtr;
try {
resultPtr = bindings.Invoke(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast());
if (resultPtr == nullptr) {
throw StateError('Gopeed Invoke returned a null response');
}
return resultPtr.cast<Utf8>().toDartString();
bindings.InvokeAsync(methodPtr.cast(), pathPtr.cast(), queryPtr.cast(), bodyPtr.cast(), requestID, callback);
} finally {
malloc.free(methodPtr);
malloc.free(pathPtr);
malloc.free(queryPtr);
malloc.free(bodyPtr);
if (resultPtr != null && resultPtr != nullptr) {
bindings.FreeCString(resultPtr);
}
}
}
@@ -2,9 +2,9 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../libgopeed_boot.dart';
import '../../../ffi/libgopeed_worker.dart';
import '../../../../util/util.dart';
import '../../../ffi/libgopeed_worker.dart';
import '../../../libgopeed_boot.dart';
import '../gopeed_transport.dart';
GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport();
@@ -32,14 +32,16 @@ class NativeGopeedTransport implements GopeedTransport {
}) async {
final request = _RequestParts.from(path, queryParameters);
final requestBody = data == null ? '' : jsonEncode(data);
final payload = _ffiWorker != null
? await _ffiWorker.invoke(method.toUpperCase(), request.path, request.query, requestBody)
: await LibgopeedBoot.instance.invoke(
method.toUpperCase(),
request.path,
query: request.query,
body: requestBody,
);
final worker = _ffiWorker;
if (worker != null) {
return worker.invoke(method.toUpperCase(), request.path, request.query, requestBody);
}
final payload = await LibgopeedBoot.instance.invoke(
method.toUpperCase(),
request.path,
query: request.query,
body: requestBody,
);
return jsonDecode(payload);
}
@@ -93,11 +93,21 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
super.initState();
_urlController.addListener(_handleUrlChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider);
_applyInitialTask(task);
if (widget.initialTask == null) {
ref.read(pendingCreateTaskProvider.notifier).clear();
}
if (widget.windowController == null && widget.initialTask == null) {
// Navigating to /create again reuses this page, so consume subsequent
// browser/share requests as well as the initial pending task.
ref.listenManual(pendingCreateTaskProvider, (_, next) {
if (next == null) return;
ref.read(pendingCreateTaskProvider.notifier).clear();
_applyInitialTask(next);
});
}
if (task == null || _urlController.text.trim().isEmpty) {
unawaited(_loadClipboardUrl());
}
@@ -174,6 +184,13 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final opts = task.opts;
setState(() {
if (req?.url.isNotEmpty == true) {
_fileDataUri = '';
_renameController.clear();
_httpMethod = 'GET';
_httpBody = '';
_replaceHttpHeaders(const {'User-Agent': '', 'Cookie': '', 'Referer': ''});
_trackersController.clear();
_protocolTab = 0;
_urlController.text = req!.url;
_initialRawUrl = req.rawUrl;
_initialLabels = req.labels == null ? null : Map<String, String>.of(req.labels!);
@@ -307,6 +324,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
SizedBox(
height: 120,
child: AppTextField(
key: const ValueKey('create-task-url-input'),
controller: _urlController,
hintText: context.l10n.pasteDownloadLinks,
keyboardType: TextInputType.multiline,