feat: move native communication to FFI (#1457)

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

No files matched your search

@@ -17,4 +17,36 @@ void main() {
expect(DownloaderConfig.fromJson(json).autoStartTasks, isFalse);
});
test('REST server settings round-trip through the Go downloader config', () {
final config = DownloaderConfig()
..api = ApiServerConfig(enable: true, network: 'tcp', address: '127.0.0.1:4321', token: 'secret');
final decoded = DownloaderConfig.fromJson(config.toJson()).api;
expect(decoded.enable, isTrue);
expect(decoded.network, 'tcp');
expect(decoded.address, '127.0.0.1:4321');
expect(decoded.token, 'secret');
});
test('Flutter preferences round-trip through the Go-owned extra config', () {
final config = DownloaderConfig();
config.extra
..windowState = WindowStateConfig(isMaximized: true, width: 1280, height: 720)
..bookmarks = {'downloads': 'D:/Downloads'}
..createHistory = ['https://example.com/file.zip']
..runAsMenubarApp = true
..analyticsEnabled = false
..analyticsClientId = 'client-id';
final decoded = DownloaderConfig.fromJson(config.toJson()).extra;
expect(decoded.windowState.isMaximized, isTrue);
expect(decoded.windowState.width, 1280);
expect(decoded.windowState.height, 720);
expect(decoded.bookmarks, {'downloads': 'D:/Downloads'});
expect(decoded.createHistory, ['https://example.com/file.zip']);
expect(decoded.runAsMenubarApp, isTrue);
expect(decoded.analyticsEnabled, isFalse);
expect(decoded.analyticsClientId, 'client-id');
});
}
+34 -25
View File
@@ -1,46 +1,26 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' as dio;
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resolve_task.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
test('resolve sends the complete resolve task for magnet links', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(server.close);
late Map<String, dynamic> requestBody;
final responseHandled = Completer<void>();
server.listen((request) async {
requestBody = jsonDecode(await utf8.decoder.bind(request).join()) as Map<String, dynamic>;
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode({
'code': 0,
'data': {
'id': 'resolved-id',
'res': {'name': 'sample', 'size': 0, 'range': false, 'files': <Object>[], 'hash': ''},
},
}),
);
await request.response.close();
responseHandled.complete();
});
api.init('tcp', '127.0.0.1:${server.port}', '');
final transport = _RecordingTransport();
api.setTransportForTesting(transport);
final result = await api.resolve(
ResolveTask(
req: Request(url: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'),
opts: Options(path: '/downloads', selectFiles: const []),
),
);
await responseHandled.future;
expect(result.id, 'resolved-id');
final requestBody = transport.data as Map<String, dynamic>;
expect(requestBody.keys, containsAll(<String>['req', 'opts']));
expect(
(requestBody['req'] as Map<String, dynamic>)['url'],
@@ -49,3 +29,32 @@ void main() {
expect((requestBody['opts'] as Map<String, dynamic>)['path'], '/downloads');
});
}
class _RecordingTransport implements GopeedTransport {
dynamic data;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
this.data = jsonDecode(jsonEncode(data));
return {
'code': 0,
'data': {
'id': 'resolved-id',
'res': {'name': 'sample', 'size': 0, 'range': false, 'files': <Object>[], 'hash': ''},
},
};
}
@override
String join(String path) => path;
@override
Future<dio.Response<String>> proxyRequest(String uri, {dynamic data, dio.Options? options}) {
throw UnimplementedError();
}
}
@@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/core/common/task_event.dart';
void main() {
test('task event mask combines subscribed event types', () {
expect({TaskEventType.done}.mask, 1);
expect({TaskEventType.error}.mask, 2);
expect({TaskEventType.done, TaskEventType.error}.mask, 3);
});
test('task event decodes terminal payload', () {
final event = TaskEvent.fromJson({
'type': 'task.error',
'taskId': 'task-1',
'name': 'archive.zip',
'error': 'network error',
});
expect(event.type, TaskEventType.error);
expect(event.taskId, 'task-1');
expect(event.name, 'archive.zip');
expect(event.error, 'network error');
});
}
@@ -0,0 +1,41 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
import 'package:gopeed/features/auth/application/web_auth_controller.dart';
void main() {
test('successful login relies on the server cookie and unauthorized requires login', () async {
api.setTransportForTesting(_LoginTransport());
final controller = WebAuthController();
addTearDown(controller.dispose);
final failure = await controller.login(username: 'gopeed', password: 'secret');
expect(failure, isNull);
controller.requireLogin();
expect(controller.loginRequired, isTrue);
});
}
class _LoginTransport implements GopeedTransport {
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
expect(path, 'api/web/login');
expect(method, 'POST');
return {'code': 0};
}
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
throw UnimplementedError();
}
@override
String join(String path) => path;
}
@@ -6,6 +6,7 @@ import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/features/settings/application/settings_controller.dart';
void main() {
@@ -36,13 +37,51 @@ void main() {
expect((await container.read(settingsControllerProvider.future)).config.downloadDir, 'E:/Created task');
expect(getConfigCalls, 2);
});
test('settings save preserves backend-owned client preferences', () async {
final backend = DownloaderConfig()
..extra.windowState = WindowStateConfig(isMaximized: true, width: 1200, height: 800)
..extra.createHistory = ['https://example.com/existing.zip']
..extra.analyticsClientId = 'existing-client';
DownloaderConfig? saved;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig.fromJson(backend.toJson()))
..bind(GopeedMethods.putConfig, (config) {
saved = DownloaderConfig.fromJson(config.toJson());
return const RpcUnit();
});
final container = ProviderContainer(
overrides: [
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
],
);
addTearDown(container.dispose);
final controller = container.read(settingsControllerProvider.notifier);
await container.read(settingsControllerProvider.future);
await controller.save(DownloaderConfig(downloadDir: 'E:/New'));
expect(saved?.downloadDir, 'E:/New');
expect(saved?.extra.windowState.isMaximized, isTrue);
expect(saved?.extra.createHistory, ['https://example.com/existing.zip']);
expect(saved?.extra.analyticsClientId, 'existing-client');
});
}
class _TestRuntimeController extends AppRuntimeController {
@override
Future<AppRuntimeState> build() async => AppRuntimeState(
startConfig: StartConfig(),
runningPort: 0,
apiServerState: const ApiServerState(
enabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
),
downloaderConfig: DownloaderConfig(downloadDir: 'C:/Stale runtime snapshot'),
);
}
@@ -145,21 +145,30 @@ void main() {
final rememberDirectory = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox'));
final directDownloadCheckbox = find.byKey(const ValueKey('create-task-direct-download-checkbox'));
final rememberDirectoryRow = find.byKey(const ValueKey('create-task-remember-download-directory'));
final rememberDirectoryLabel = find.byKey(const ValueKey('create-task-remember-download-directory-label'));
final directDownloadLabel = find.descendant(
of: find.byKey(const ValueKey('create-task-direct-download-toggle')),
matching: find.text('Skip resolving and create tasks immediately'),
);
final directoryComponent = find.byKey(const ValueKey('create-task-directory-component'));
final directoryOptionsRow = find.byKey(const ValueKey('create-task-directory-options-row'));
final directoryOptionsLayout = find.byKey(const ValueKey('create-task-directory-options-layout'));
final directoryPicker = find.byKey(const ValueKey('create-task-directory-picker'));
expect(tester.widget<shad.Checkbox>(rememberDirectory).state, shad.CheckboxState.unchecked);
expect(find.text('Save as default download directory'), findsOneWidget);
final rememberStyle = tester.widget<Text>(rememberDirectoryLabel).style!;
final directStyle = tester.widget<Text>(directDownloadLabel).style!;
expect(rememberStyle.color, directStyle.color);
expect(rememberStyle.fontSize, directStyle.fontSize);
expect(rememberStyle.fontWeight, directStyle.fontWeight);
expect(tester.widget<shad.Checkbox>(rememberDirectory).size, isNull);
expect(tester.getSize(rememberDirectory), const Size.square(AppDesignTokens.checkboxSize));
expect(tester.getCenter(rememberDirectory).dx, closeTo(tester.getCenter(directDownloadCheckbox).dx, 0.5));
final rememberDirectoryLabel = tester.widget<Text>(
find.byKey(const ValueKey('create-task-remember-download-directory-label')),
);
final directDownloadLabel = tester.widget<Text>(find.byKey(const ValueKey('create-task-direct-download-label')));
expect(rememberDirectoryLabel.style, directDownloadLabel.style);
expect(rememberDirectoryLabel.maxLines, directDownloadLabel.maxLines);
expect(rememberDirectoryLabel.overflow, directDownloadLabel.overflow);
final rememberDirectoryText = tester.widget<Text>(rememberDirectoryLabel);
final directDownloadText = tester.widget<Text>(directDownloadLabel);
expect(rememberDirectoryText.style, directDownloadText.style);
expect(rememberDirectoryText.maxLines, directDownloadText.maxLines);
expect(rememberDirectoryText.overflow, directDownloadText.overflow);
expect(tester.widget<shad.IconButton>(directoryPicker).variance, same(shad.ButtonVariance.outline));
expect(tester.widget<flutter.Row>(directoryOptionsLayout).mainAxisAlignment, MainAxisAlignment.spaceBetween);
expect(tester.getCenter(rememberDirectoryRow).dy, closeTo(tester.getCenter(shortcutRow).dy, 0.5));
+126 -16
View File
@@ -27,6 +27,7 @@ import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/store_extension.dart';
import 'package:gopeed/api/model/task.dart' as api_task;
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:gopeed/core/window/app_window_chrome.dart';
import 'package:gopeed/core/window/app_window_frame.dart';
@@ -1329,16 +1330,14 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
expect(find.text('Runtime status'), findsOneWidget);
expect(find.text('tcp://192.168.1.20:4321'), findsOneWidget);
expect(find.byKey(const ValueKey('api-server-status-dot')), findsOneWidget);
final host = find.byKey(const ValueKey('api-host-input'));
final port = find.byKey(const ValueKey('api-port-input'));
final saveButton = find.byKey(const ValueKey('save-api-config-button'));
expect(tester.widget<AppLoadingButton>(saveButton).variant, AppLoadingButtonVariant.secondary);
final saveButtonClickable = find.descendant(of: saveButton, matching: find.byType(shad.Clickable));
final disabledDecoration =
tester.widget<shad.Clickable>(saveButtonClickable).decoration!.resolve({WidgetState.disabled}) as BoxDecoration;
expect(disabledDecoration.color, AppPalette.dark.surfaceSoft);
expect(disabledDecoration.color, isNot(AppPalette.dark.brand));
expect(tester.widget<AppLoadingButton>(saveButton).variant, AppLoadingButtonVariant.primary);
expect(tester.widget<shad.TextField>(host).controller!.text, '192.168.1.20');
expect(tester.widget<shad.TextField>(port).controller!.text, '4321');
expect(tester.widget<AppLoadingButton>(saveButton).onPressed, isNull);
@@ -1359,6 +1358,10 @@ void main() {
await tester.enterText(host, '127.0.0.1');
await tester.tap(saveButton);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('restart-api-server-dialog')), findsOneWidget);
expect(runtimeController.savedStartConfig, isNull);
await tester.tap(find.byKey(const ValueKey('confirm-restart-api-server-button')));
await tester.pumpAndSettle();
expect(runtimeController.savedStartConfig?.network, 'tcp');
expect(runtimeController.savedStartConfig?.address, '127.0.0.1:0');
expect(tester.widget<AppLoadingButton>(saveButton).onPressed, isNull);
@@ -1378,6 +1381,54 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('disabling the API server requires confirmation and keeps listener fields visible', (
WidgetTester tester,
) async {
await _setTestSize(tester, const Size(1024, 900));
final runtimeController = RecordingStartConfigRuntimeController();
await tester.pumpWidget(
ProviderScope(
overrides: [
appRuntimeControllerProvider.overrideWith(() => runtimeController),
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
settingsControllerProvider.overrideWith(FakeSettingsController.new),
],
child: const _SettingsTestApp(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
final hostInput = find.byKey(const ValueKey('api-host-input'));
await tester.enterText(hostInput, 'unsaved.example.com');
final toggleButton = find.byKey(const ValueKey('toggle-api-server-button'));
await tester.ensureVisible(toggleButton);
await tester.pumpAndSettle();
await tester.tap(toggleButton);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('disable-api-server-dialog')), findsOneWidget);
expect(find.byKey(const ValueKey('api-host-input')), findsOneWidget);
expect(find.byKey(const ValueKey('api-port-input')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('cancel-disable-api-server-button')));
await tester.pumpAndSettle();
expect(runtimeController.savedStartConfig, isNull);
await tester.tap(toggleButton);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('confirm-disable-api-server-button')));
await tester.pumpAndSettle();
expect(runtimeController.savedStartConfig?.apiEnable, isFalse);
expect(runtimeController.savedStartConfig?.address, '192.168.1.20:4321');
expect(tester.widget<shad.TextField>(hostInput).controller!.text, 'unsaved.example.com');
final statusDot = tester.widget<AnimatedContainer>(find.byKey(const ValueKey('api-server-status-dot')));
expect((statusDot.decoration! as BoxDecoration).color, AppPalette.light.textMuted.withValues(alpha: 0.72));
expect(find.text('tcp://192.168.1.20:4321'), findsNothing);
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('shared toast appears at top center with content-driven width', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1024, 700));
await tester.pumpWidget(
@@ -2995,10 +3046,9 @@ void main() {
expect(urlValue.semanticsLabel, task.url);
expect(find.text('Task Details'), findsOneWidget);
expect(find.text('Task Name'), findsNothing);
final detailHeader = tester.widget<DecoratedBox>(
find.descendant(of: find.byKey(const ValueKey('app-detail-drawer-header')), matching: find.byType(DecoratedBox)),
);
expect(((detailHeader.decoration as BoxDecoration).border! as Border).bottom.width, 1);
final detailHeader = tester.widget<SizedBox>(find.byKey(const ValueKey('app-detail-drawer-header')));
final detailHeaderDecoration = (detailHeader.child! as DecoratedBox).decoration as BoxDecoration;
expect((detailHeaderDecoration.border! as Border).bottom.width, 1);
expect(find.text('Details'), findsOneWidget);
expect(find.text('Files'), findsOneWidget);
expect(find.text('Statistics'), findsOneWidget);
@@ -4367,9 +4417,14 @@ class _SettingsTestAppState extends State<_SettingsTestApp> {
return shad.ShadcnApp.router(
theme: widget.dark ? AppTheme.dark() : AppTheme.light(),
materialTheme: widget.dark ? AppTheme.materialDark() : AppTheme.materialLight(),
builder: widget.wrapComponentThemes
? (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink())
: null,
builder: (context, child) {
final content = widget.wrapComponentThemes
? AppComponentThemes(child: child ?? const SizedBox.shrink())
: child ?? const SizedBox.shrink();
final mediaQuery = MediaQuery.maybeOf(context);
if (mediaQuery == null) return content;
return MediaQuery(data: mediaQuery.copyWith(disableAnimations: true), child: content);
},
routerConfig: _router,
);
}
@@ -4534,11 +4589,16 @@ class FakeRuntimeController extends AppRuntimeController {
final cfg = StartConfig()
..network = 'tcp'
..address = '127.0.0.1:9999'
..apiEnable = true
..apiToken = ''
..storage = 'bolt'
..storageDir = ''
..refreshInterval = 0;
return AppRuntimeState(startConfig: cfg, runningPort: 9999, downloaderConfig: DownloaderConfig());
return AppRuntimeState(
startConfig: cfg,
apiServerState: _testApiServerState(running: true, port: 9999),
downloaderConfig: DownloaderConfig(),
);
}
}
@@ -4566,25 +4626,75 @@ class RecordingStartConfigRuntimeController extends AppRuntimeController {
final config = StartConfig()
..network = 'tcp'
..address = '192.168.1.20:4321'
..apiEnable = true
..apiToken = 'token'
..storage = 'bolt'
..storageDir = ''
..refreshInterval = 0;
return AppRuntimeState(startConfig: config, runningPort: 4321, downloaderConfig: DownloaderConfig());
return AppRuntimeState(
startConfig: config,
apiServerState: _testApiServerState(running: true, port: 4321, address: config.address),
downloaderConfig: DownloaderConfig(),
);
}
@override
Future<void> saveStartConfig(StartConfig config) async {
Future<void> saveApiServerConfig(StartConfig config) async =>
_record(config, running: state.requireValue.apiServerState.running);
@override
Future<void> startApiServer() async => _record(state.requireValue.startConfig..apiEnable = true, running: true);
@override
Future<void> stopApiServer() async => _record(state.requireValue.startConfig..apiEnable = false, running: false);
@override
Future<void> restartApiServer(StartConfig config) async => _record(config..apiEnable = true, running: true);
void _record(StartConfig config, {required bool running}) {
savedStartConfig = StartConfig()
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
..apiToken = config.apiToken
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval;
final current = state.requireValue;
state = AsyncValue.data(
AppRuntimeState(
startConfig: savedStartConfig!,
apiServerState: _testApiServerState(
running: running,
port: running ? 54321 : 0,
address: config.address,
enabled: config.apiEnable,
),
downloaderConfig: current.downloaderConfig,
localBackendStarted: current.localBackendStarted,
startupError: current.startupError,
),
);
}
}
ApiServerState _testApiServerState({
required bool running,
required int port,
String address = '127.0.0.1:9999',
bool? enabled,
}) {
return ApiServerState(
enabled: enabled ?? running,
running: running,
network: running ? 'tcp' : '',
address: running ? address : '',
runningPort: port,
pendingApply: false,
lastError: '',
);
}
class FakeSettingsController extends SettingsController {
@override
Future<SettingsState> build() async => SettingsState(config: DownloaderConfig());