From b9914826178f773822ac0afdce7020a03d43f9d7 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 00:19:49 +0800 Subject: [PATCH 1/3] fix(macos): prevent closed task windows from reopening (#1492) --- ui/flutter/lib/app/router/app_router.dart | 9 + .../core/capabilities/app_capabilities.dart | 12 +- .../app_navigation_capability.dart | 15 + .../home/presentation/pages/home_page.dart | 6 + .../application/task_list_navigation.dart | 11 + .../pages/create_task_window_page.dart | 17 +- ui/flutter/macos/Runner/AppDelegate.swift | 18 +- .../create_task_window_page_test.dart | 1164 +++++++++++------ ui/flutter/test/widget_test.dart | 35 + 9 files changed, 847 insertions(+), 440 deletions(-) create mode 100644 ui/flutter/lib/core/capabilities/app_navigation_capability.dart create mode 100644 ui/flutter/lib/features/tasks/application/task_list_navigation.dart diff --git a/ui/flutter/lib/app/router/app_router.dart b/ui/flutter/lib/app/router/app_router.dart index 29a1d9a9..05056135 100644 --- a/ui/flutter/lib/app/router/app_router.dart +++ b/ui/flutter/lib/app/router/app_router.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.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/extension_details_page.dart'; @@ -17,6 +19,13 @@ import 'shells/main_shell.dart'; class AppRouter { static final GlobalKey rootNavigatorKey = GlobalKey(); + 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) { return GoRouter( navigatorKey: rootNavigatorKey, diff --git a/ui/flutter/lib/core/capabilities/app_capabilities.dart b/ui/flutter/lib/core/capabilities/app_capabilities.dart index 6333ea5f..106a42c7 100644 --- a/ui/flutter/lib/core/capabilities/app_capabilities.dart +++ b/ui/flutter/lib/core/capabilities/app_capabilities.dart @@ -16,12 +16,18 @@ import '../window/app_window_appearance.dart'; import 'capability_rpc.dart'; import 'gopeed_capability.dart'; import 'storage_capability.dart'; +import 'app_navigation_capability.dart'; +import '../../app/router/app_router.dart'; 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 AppStorageService storage; + final AppNavigationService navigation; } class LocalAppCapabilities { @@ -29,6 +35,10 @@ class LocalAppCapabilities { registry = CapabilityRegistry(codecs); _bindGopeed(registry); _bindStorage(registry); + registry.bind(NavigationMethods.showDownloadingTasks, (_) { + AppRouter.showDownloadingTasks(); + return const RpcUnit(); + }); capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); } diff --git a/ui/flutter/lib/core/capabilities/app_navigation_capability.dart b/ui/flutter/lib/core/capabilities/app_navigation_capability.dart new file mode 100644 index 00000000..f1b18ccd --- /dev/null +++ b/ui/flutter/lib/core/capabilities/app_navigation_capability.dart @@ -0,0 +1,15 @@ +import 'capability_rpc.dart'; + +abstract final class NavigationMethods { + static const showDownloadingTasks = RpcMethod('navigation.tasks.downloading'); +} + +class AppNavigationService { + const AppNavigationService(this._invoker); + + final CapabilityInvoker _invoker; + + Future showDownloadingTasks() async { + await _invoker.invoke(NavigationMethods.showDownloadingTasks, const RpcUnit()); + } +} diff --git a/ui/flutter/lib/features/home/presentation/pages/home_page.dart b/ui/flutter/lib/features/home/presentation/pages/home_page.dart index 5d356b3b..f23af3ee 100644 --- a/ui/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/ui/flutter/lib/features/home/presentation/pages/home_page.dart @@ -19,6 +19,7 @@ import '../../../../shared/widgets/responsive_navigation_layout.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/util.dart'; import '../../../tasks/application/tasks_controller.dart'; +import '../../../tasks/application/task_list_navigation.dart'; import '../../../tasks/application/pending_update_task.dart'; import '../../../tasks/application/task_batch_selection_controller.dart'; import '../../../tasks/domain/task_record.dart'; @@ -86,6 +87,11 @@ class _HomePageState extends ConsumerState { Widget build(BuildContext context) { final palette = AppPalette.of(context); final tasksAsync = ref.watch(tasksControllerProvider); + ref.listen(taskListNavigationProvider, (_, _) { + _searchController.clear(); + _setActiveFilter(_TaskFilter.downloading); + setState(() => _batchMode = false); + }); final tasksState = tasksAsync.value; final tasks = tasksState?.tasks ?? const []; final filteredTasks = _filteredTasks(tasks); diff --git a/ui/flutter/lib/features/tasks/application/task_list_navigation.dart b/ui/flutter/lib/features/tasks/application/task_list_navigation.dart new file mode 100644 index 00000000..1bbbf0fb --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/task_list_navigation.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final taskListNavigationProvider = NotifierProvider(TaskListNavigation.new); + +// A request also resets an already-visible list's filter, search and selection. +class TaskListNavigation extends Notifier { + @override + int build() => 0; + + void showDownloading() => state++; +} diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 4ebc0680..89539624 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -737,6 +737,19 @@ class _CreateTaskWindowPageState extends ConsumerState { await windowManager.close(); } + Future _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 _loadDefaults() async { try { final config = await ref.read(gopeedServiceProvider).getConfig(); @@ -863,7 +876,7 @@ class _CreateTaskWindowPageState extends ConsumerState { ); }), ); - await _closeWindow(); + await _finishCreation(); return; } @@ -879,7 +892,7 @@ class _CreateTaskWindowPageState extends ConsumerState { } return; } - await _closeWindow(); + await _finishCreation(); } catch (error) { if (mounted) { _showToast(error.toString()); diff --git a/ui/flutter/macos/Runner/AppDelegate.swift b/ui/flutter/macos/Runner/AppDelegate.swift index 91c1ed49..5e0636a3 100644 --- a/ui/flutter/macos/Runner/AppDelegate.swift +++ b/ui/flutter/macos/Runner/AppDelegate.swift @@ -26,16 +26,18 @@ class AppDelegate: FlutterAppDelegate { } override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if !flag { - for window in NSApp.windows { - if !window.isVisible { - window.setIsVisible(true) - } - window.makeKeyAndOrderFront(self) + // Closed child windows can remain in NSApp.windows. Restoring every + // window resurrects completed create-task forms with their last frame. + // Use the main window even when an unfinished child is still visible. + if let window = sender.windows.first(where: { $0 is MainFlutterWindow }) { + if window.isMiniaturized { + 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 { diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index 96da7409..5ae33c45 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -1,6 +1,9 @@ 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/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/foundation.dart' + show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart' show Icons; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,6 +13,7 @@ import 'package:gopeed/api/model/downloader_config.dart'; import 'package:gopeed/api/model/options.dart'; import 'package:gopeed/api/model/request.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/gopeed_capability.dart'; import 'package:gopeed/core/capabilities/storage_capability.dart'; @@ -23,91 +27,184 @@ import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { - testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - try { - tester.view.physicalSize = const Size(700, 900); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - CreateTask? submitted; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-id'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final container = ProviderContainer( - overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], - ); - addTearDown(container.dispose); - final pending = container.read(pendingCreateTaskProvider.notifier); - pending.set( - CreateTask( - req: Request( - url: 'https://example.com/first.zip', - rawUrl: 'https://example.com/first', - labels: {'source': 'first'}, - extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(), - proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'), - skipVerifyCert: true, + 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 = []; + 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'), + ), + ), + ), + ), ), - ), - ); - 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; - } - }); + 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('mobile create task page starts below the system status bar', (WidgetTester tester) async { + 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; @@ -117,7 +214,10 @@ void main() { addTearDown(tester.view.resetPadding); final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); + ..bind( + GopeedMethods.getConfig, + (_) => DownloaderConfig(downloadDir: '/downloads'), + ); final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); try { @@ -133,367 +233,573 @@ void main() { ); await tester.pumpAndSettle(); - final contentPadding = tester.widget(find.byKey(const ValueKey('create-task-safe-content'))); + final contentPadding = tester.widget( + 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', ( - WidgetTester tester, - ) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; - final androidLocations = {'application': 'D:/Downloads', 'downloads': 'G:/Manually selected'}; - DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => androidLocations; - DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; - addTearDown(() { - DownloadDirectoryPicker.debugPlatformOverride = null; - DownloadDirectoryPicker.debugAndroidLocationsLoader = null; - DownloadDirectoryPicker.debugDownloadsPreparer = null; - }); - - CreateTask? submitted; - final config = DownloaderConfig(downloadDir: 'C:/Downloads') - ..extra.defaultDirectDownload = true - ..extra.downloadCategories = [ - DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), - DownloadCategory(name: 'Archives', path: 'F:/Archives'), - DownloadCategory(name: 'Long video category', path: 'G:/Videos'), - DownloadCategory(name: 'Long document category', path: 'H:/Documents'), - ] - ..protocolConfig.http.connections = 8; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => config) - ..bind(GopeedMethods.createTask, (task) { - submitted = task; - return 'created-task'; - }) - ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); - final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); - - final initialTask = CreateTask( - req: Request( - rawUrl: 'https://page.example/download', - url: 'https://cdn.example/archive.zip', - extra: ReqExtraHttp( - method: 'POST', - header: const { - 'User-Agent': 'Browser UA', - 'Cookie': 'session=abc', - 'Referer': 'https://page.example/', - 'Authorization': 'Bearer token', - }, - body: 'request-body', - ).toJson(), - labels: const {'source': 'browser-extension'}, - proxy: RequestProxy( - mode: RequestProxyMode.custom, - scheme: 'socks5', - host: '127.0.0.1:7890', - usr: 'proxy-user', - pwd: 'proxy-password', - ), - skipVerifyCert: true, - ), - opts: Options( - name: 'archive.zip', - path: 'D:/Downloads', - extra: OptsExtraHttp( - connections: 12, - autoTorrent: true, - deleteTorrentAfterDownload: true, - autoExtract: true, - archivePassword: 'archive-password', - deleteAfterExtract: true, - ).toJson(), - ), - ); - final router = GoRouter( - initialLocation: '/create', - routes: [ - GoRoute(path: '/', builder: (_, _) => const SizedBox()), - GoRoute( - path: '/create', - builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), - ), - ], - ); - addTearDown(router.dispose); - - await tester.pumpWidget( - ProviderScope( - overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], - child: shad.ShadcnApp.router( - theme: AppTheme.light(), - materialTheme: AppTheme.materialLight(), - builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()), - routerConfig: router, - ), - ), - ); - await tester.pumpAndSettle(); - - final connectionsInput = find.byKey(const ValueKey('create-task-connections-input')); - expect(connectionsInput, findsOneWidget); - expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth); - final connectionsField = tester.widget(connectionsInput); - expect(connectionsField.controller!.text, '12'); - expect(connectionsField.features.single, isA()); - - final advancedSection = find.byKey(const ValueKey('create-task-advanced-section')); - expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showSecond); - await tester.tap(find.text('Advanced')); - await tester.pumpAndSettle(); - expect(tester.widget(advancedSection).crossFadeState, CrossFadeState.showFirst); - - 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-name-3'), 'Authorization'); - expect(_fieldText(tester, 'create-task-http-header-value-3'), 'Bearer token'); - expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); - expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); - expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); - expect(_fieldText(tester, 'create-task-proxy-password'), 'proxy-password'); - expect(_fieldText(tester, 'create-task-archive-password'), 'archive-password'); - expect(find.byKey(const ValueKey('create-task-skip-verify-cert')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-auto-torrent')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-delete-torrent')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-auto-extract')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-delete-after-extract')), findsOneWidget); - - expect(find.text('Archives'), findsNWidgets(2)); - expect(find.text('Quick folders'), findsNothing); - expect(find.byKey(const ValueKey('create-task-category-0')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-category-1')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-category-2')), findsOneWidget); - expect(find.byKey(const ValueKey('create-task-category-3')), findsOneWidget); - final shortcutRow = find.byKey(const ValueKey('create-task-directory-shortcuts')); - expect(tester.getSize(shortcutRow).height, 28); - 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(rememberDirectory).state, shad.CheckboxState.unchecked); - expect(find.text('Save as default download directory'), findsOneWidget); - final rememberStyle = tester.widget(rememberDirectoryLabel).style!; - final directStyle = tester.widget(directDownloadLabel).style!; - expect(rememberStyle.color, directStyle.color); - expect(rememberStyle.fontSize, directStyle.fontSize); - expect(rememberStyle.fontWeight, directStyle.fontWeight); - expect(tester.widget(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 rememberDirectoryText = tester.widget(rememberDirectoryLabel); - final directDownloadText = tester.widget(directDownloadLabel); - expect(rememberDirectoryText.style, directDownloadText.style); - expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); - expect(rememberDirectoryText.overflow, directDownloadText.overflow); - expect(tester.widget(directoryPicker).variance, same(shad.ButtonVariance.outline)); - expect(tester.widget(directoryOptionsLayout).mainAxisAlignment, MainAxisAlignment.spaceBetween); - expect(tester.getCenter(rememberDirectoryRow).dy, closeTo(tester.getCenter(shortcutRow).dy, 0.5)); - expect(tester.getTopRight(directoryOptionsRow).dx, closeTo(tester.getTopRight(directoryComponent).dx, 0.5)); - expect( - tester.getTopRight(find.byKey(const ValueKey('create-task-category-3'))).dx, - closeTo(tester.getTopRight(directoryComponent).dx, 0.5), - ); - - tester.view.physicalSize = const Size(520, 720); - await tester.pumpAndSettle(); - final shortcutContent = find.byKey(const ValueKey('create-task-directory-shortcuts-content')); - expect(tester.getTopLeft(shortcutRow).dy, greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy)); - expect(tester.getSize(shortcutRow).width, closeTo(tester.getSize(directoryComponent).width, 0.5)); - expect(tester.getSize(shortcutContent).width, greaterThan(tester.getSize(shortcutRow).width)); - final shortcutScrollable = find.descendant(of: shortcutRow, matching: find.byType(Scrollable)); - expect(tester.state(shortcutScrollable).position.maxScrollExtent, greaterThan(0)); - - tester.view.physicalSize = const Size(719, 720); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))), - isA(), - ); - - tester.view.physicalSize = const Size(720, 720); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const ValueKey('create-task-directory-options-layout'))).mainAxisAlignment, - MainAxisAlignment.spaceBetween, - ); - - tester.view.physicalSize = const Size(1024, 720); - await tester.pumpAndSettle(); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-app-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); - - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); - - expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); - await tester.pump(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); - expect( - find.descendant( - of: find.byKey(const ValueKey('create-task-category-3')), - matching: find.byIcon(Icons.folder_outlined), - ), - findsOneWidget, - ); - - androidLocations['downloads'] = 'C:/Downloads'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); - - androidLocations['downloads'] = 'G:/Manually selected'; - await tester.tap(directoryPicker); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('android-downloads-directory-option'))); - await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); - - await tester.tap(find.text('Confirm')); - await tester.pumpAndSettle(); - - final request = submitted?.req; - expect(request, isNotNull); - expect(request?.rawUrl, initialTask.req?.rawUrl); - expect(request?.labels, initialTask.req?.labels); - expect(request?.proxy?.mode, RequestProxyMode.custom); - expect(request?.proxy?.scheme, 'socks5'); - expect(request?.proxy?.host, '127.0.0.1:7890'); - expect(request?.proxy?.usr, 'proxy-user'); - expect(request?.proxy?.pwd, 'proxy-password'); - expect(request?.skipVerifyCert, isTrue); - expect(ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), initialTask.req?.extra); - expect(submitted?.opts?.path, 'G:/Manually selected'); - final options = OptsExtraHttp.fromJson(submitted?.opts?.extra! as Map); - expect(options.connections, 12); - expect(options.autoTorrent, isTrue); - expect(options.deleteTorrentAfterDownload, isTrue); - expect(options.autoExtract, isTrue); - expect(options.archivePassword, 'archive-password'); - expect(options.deleteAfterExtract, isTrue); - expect(submitted?.opts?.asDefaultPath, isTrue); - }); - - testWidgets('create history supports filtering, deleting one entry, and clearing all entries', ( - WidgetTester tester, - ) async { - tester.view.physicalSize = const Size(1024, 720); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final histories = [ - 'https://example.com/alpha.zip', - 'https://example.com/beta.iso', - 'magnet:?xt=urn:btih:gamma', - ]; - String? removedHistory; - var cleared = false; - final registry = CapabilityRegistry(createAppCapabilityCodecs()) - ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) - ..bind(StorageMethods.getCreateHistory, (_) => List.unmodifiable(histories)) - ..bind(StorageMethods.removeCreateHistory, (value) { - removedHistory = value; - histories.remove(value); - return const RpcUnit(); - }) - ..bind(StorageMethods.clearCreateHistory, (_) { - cleared = true; - histories.clear(); - return const RpcUnit(); + testWidgets( + 'browser extension request parameters populate the form and survive submission', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; + final androidLocations = { + 'application': 'D:/Downloads', + 'downloads': 'G:/Manually selected', + }; + DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => + androidLocations; + DownloadDirectoryPicker.debugDownloadsPreparer = (path) async => path; + addTearDown(() { + DownloadDirectoryPicker.debugPlatformOverride = null; + DownloadDirectoryPicker.debugAndroidLocationsLoader = null; + DownloadDirectoryPicker.debugDownloadsPreparer = null; }); - 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()), + CreateTask? submitted; + final config = DownloaderConfig(downloadDir: 'C:/Downloads') + ..extra.defaultDirectDownload = true + ..extra.downloadCategories = [ + DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), + DownloadCategory(name: 'Archives', path: 'F:/Archives'), + DownloadCategory(name: 'Long video category', path: 'G:/Videos'), + DownloadCategory( + name: 'Long document category', + path: 'H:/Documents', + ), + ] + ..protocolConfig.http.connections = 8; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-task'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + final initialTask = CreateTask( + req: Request( + rawUrl: 'https://page.example/download', + url: 'https://cdn.example/archive.zip', + extra: ReqExtraHttp( + method: 'POST', + header: const { + 'User-Agent': 'Browser UA', + 'Cookie': 'session=abc', + 'Referer': 'https://page.example/', + 'Authorization': 'Bearer token', + }, + body: 'request-body', + ).toJson(), + labels: const {'source': 'browser-extension'}, + proxy: RequestProxy( + mode: RequestProxyMode.custom, + scheme: 'socks5', + host: '127.0.0.1:7890', + usr: 'proxy-user', + pwd: 'proxy-password', + ), + skipVerifyCert: true, ), - ), - ); - await tester.pumpAndSettle(); + opts: Options( + name: 'archive.zip', + path: 'D:/Downloads', + extra: OptsExtraHttp( + connections: 12, + autoTorrent: true, + deleteTorrentAfterDownload: true, + autoExtract: true, + archivePassword: 'archive-password', + deleteAfterExtract: true, + ).toJson(), + ), + ); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/create', + builder: (_, _) => CreateTaskWindowPage(initialTask: initialTask), + ), + ], + ); + addTearDown(router.dispose); - await tester.tap(find.byIcon(Icons.history)); - await tester.pumpAndSettle(); - expect( - tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width, - tester.getSize(find.byKey(const ValueKey('create-history-content'))).width, - ); - final alphaItem = find.byKey(const ValueKey('create-history-item-https://example.com/alpha.zip')); - final betaItem = find.byKey(const ValueKey('create-history-item-https://example.com/beta.iso')); - final gammaItem = find.byKey(const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma')); - expect(alphaItem, findsOneWidget); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); - final clearAction = find.byKey(const ValueKey('create-history-clear')); - final closeAction = find.byKey(const ValueKey('create-history-close')); - expect( - tester.widget(find.ancestor(of: clearAction, matching: find.byType(AppTooltip))).message, - 'Clear History', - ); - expect( - tester.widget(find.ancestor(of: closeAction, matching: find.byType(AppTooltip))).message, - 'Close', - ); - final historyText = tester.widget(find.descendant(of: alphaItem, matching: find.byType(Text))); - expect(historyText.data, contains('\u200B')); - expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + builder: (context, child) => + AppComponentThemes(child: child ?? const SizedBox.shrink()), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); - final filter = find.byKey(const ValueKey('create-history-filter')); - await tester.enterText(filter, 'beta'); - await tester.pump(); - expect(alphaItem, findsNothing); - expect(betaItem, findsOneWidget); - expect(gammaItem, findsNothing); + final connectionsInput = find.byKey( + const ValueKey('create-task-connections-input'), + ); + expect(connectionsInput, findsOneWidget); + expect( + tester.getSize(connectionsInput).width, + AppDesignTokens.settingsNumberControlWidth, + ); + final connectionsField = tester.widget(connectionsInput); + expect(connectionsField.controller!.text, '12'); + expect(connectionsField.features.single, isA()); - await tester.tap(find.byKey(const ValueKey('create-history-delete-https://example.com/beta.iso'))); - await tester.pumpAndSettle(); - expect(removedHistory, 'https://example.com/beta.iso'); - expect(find.text('No History Found'), findsOneWidget); + final advancedSection = find.byKey( + const ValueKey('create-task-advanced-section'), + ); + expect( + tester.widget(advancedSection).crossFadeState, + CrossFadeState.showSecond, + ); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect( + tester.widget(advancedSection).crossFadeState, + CrossFadeState.showFirst, + ); - await tester.enterText(filter, ''); - await tester.pump(); - expect(alphaItem, findsOneWidget); - expect(gammaItem, findsOneWidget); + 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-name-3'), + 'Authorization', + ); + expect( + _fieldText(tester, 'create-task-http-header-value-3'), + 'Bearer token', + ); + expect(_fieldText(tester, 'create-task-proxy-server'), '127.0.0.1'); + expect(_fieldText(tester, 'create-task-proxy-port'), '7890'); + expect(_fieldText(tester, 'create-task-proxy-username'), 'proxy-user'); + expect( + _fieldText(tester, 'create-task-proxy-password'), + 'proxy-password', + ); + expect( + _fieldText(tester, 'create-task-archive-password'), + 'archive-password', + ); + expect( + find.byKey(const ValueKey('create-task-skip-verify-cert')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-auto-torrent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-delete-torrent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-auto-extract')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-delete-after-extract')), + findsOneWidget, + ); - await tester.tap(find.byKey(const ValueKey('create-history-clear'))); - await tester.pumpAndSettle(); - expect(cleared, isTrue); - expect(find.text('No history'), findsOneWidget); - expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget); - expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget); - }); + expect(find.text('Archives'), findsNWidgets(2)); + expect(find.text('Quick folders'), findsNothing); + expect( + find.byKey(const ValueKey('create-task-category-0')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-category-1')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-category-2')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-task-category-3')), + findsOneWidget, + ); + final shortcutRow = find.byKey( + const ValueKey('create-task-directory-shortcuts'), + ); + expect(tester.getSize(shortcutRow).height, 28); + 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(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + expect(find.text('Save as default download directory'), findsOneWidget); + final rememberStyle = tester.widget(rememberDirectoryLabel).style!; + final directStyle = tester.widget(directDownloadLabel).style!; + expect(rememberStyle.color, directStyle.color); + expect(rememberStyle.fontSize, directStyle.fontSize); + expect(rememberStyle.fontWeight, directStyle.fontWeight); + expect(tester.widget(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 rememberDirectoryText = tester.widget(rememberDirectoryLabel); + final directDownloadText = tester.widget(directDownloadLabel); + expect(rememberDirectoryText.style, directDownloadText.style); + expect(rememberDirectoryText.maxLines, directDownloadText.maxLines); + expect(rememberDirectoryText.overflow, directDownloadText.overflow); + expect( + tester.widget(directoryPicker).variance, + same(shad.ButtonVariance.outline), + ); + expect( + tester.widget(directoryOptionsLayout).mainAxisAlignment, + MainAxisAlignment.spaceBetween, + ); + expect( + tester.getCenter(rememberDirectoryRow).dy, + closeTo(tester.getCenter(shortcutRow).dy, 0.5), + ); + expect( + tester.getTopRight(directoryOptionsRow).dx, + closeTo(tester.getTopRight(directoryComponent).dx, 0.5), + ); + expect( + tester + .getTopRight(find.byKey(const ValueKey('create-task-category-3'))) + .dx, + closeTo(tester.getTopRight(directoryComponent).dx, 0.5), + ); + + tester.view.physicalSize = const Size(520, 720); + await tester.pumpAndSettle(); + final shortcutContent = find.byKey( + const ValueKey('create-task-directory-shortcuts-content'), + ); + expect( + tester.getTopLeft(shortcutRow).dy, + greaterThan(tester.getBottomLeft(rememberDirectoryRow).dy), + ); + expect( + tester.getSize(shortcutRow).width, + closeTo(tester.getSize(directoryComponent).width, 0.5), + ); + expect( + tester.getSize(shortcutContent).width, + greaterThan(tester.getSize(shortcutRow).width), + ); + final shortcutScrollable = find.descendant( + of: shortcutRow, + matching: find.byType(Scrollable), + ); + expect( + tester + .state(shortcutScrollable) + .position + .maxScrollExtent, + greaterThan(0), + ); + + tester.view.physicalSize = const Size(719, 720); + await tester.pumpAndSettle(); + expect( + tester.widget( + find.byKey(const ValueKey('create-task-directory-options-layout')), + ), + isA(), + ); + + tester.view.physicalSize = const Size(720, 720); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey( + const ValueKey('create-task-directory-options-layout'), + ), + ) + .mainAxisAlignment, + MainAxisAlignment.spaceBetween, + ); + + tester.view.physicalSize = const Size(1024, 720); + await tester.pumpAndSettle(); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-app-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-downloads-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.checked, + ); + + expect(tester.takeException(), isNull); + await tester.tap(find.byKey(const ValueKey('create-task-category-3'))); + await tester.pump(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('create-task-category-3')), + matching: find.byIcon(Icons.folder_outlined), + ), + findsOneWidget, + ); + + androidLocations['downloads'] = 'C:/Downloads'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-downloads-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.unchecked, + ); + + androidLocations['downloads'] = 'G:/Manually selected'; + await tester.tap(directoryPicker); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('android-downloads-directory-option')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(rememberDirectory).state, + shad.CheckboxState.checked, + ); + + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + final request = submitted?.req; + expect(request, isNotNull); + expect(request?.rawUrl, initialTask.req?.rawUrl); + expect(request?.labels, initialTask.req?.labels); + expect(request?.proxy?.mode, RequestProxyMode.custom); + expect(request?.proxy?.scheme, 'socks5'); + expect(request?.proxy?.host, '127.0.0.1:7890'); + expect(request?.proxy?.usr, 'proxy-user'); + expect(request?.proxy?.pwd, 'proxy-password'); + expect(request?.skipVerifyCert, isTrue); + expect( + ReqExtraHttp.fromJson(request?.extra! as Map).toJson(), + initialTask.req?.extra, + ); + expect(submitted?.opts?.path, 'G:/Manually selected'); + final options = OptsExtraHttp.fromJson( + submitted?.opts?.extra! as Map, + ); + expect(options.connections, 12); + expect(options.autoTorrent, isTrue); + expect(options.deleteTorrentAfterDownload, isTrue); + expect(options.autoExtract, isTrue); + expect(options.archivePassword, 'archive-password'); + expect(options.deleteAfterExtract, isTrue); + expect(submitted?.opts?.asDefaultPath, isTrue); + }, + ); + + testWidgets( + 'create history supports filtering, deleting one entry, and clearing all entries', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final histories = [ + 'https://example.com/alpha.zip', + 'https://example.com/beta.iso', + 'magnet:?xt=urn:btih:gamma', + ]; + String? removedHistory; + var cleared = false; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind( + GopeedMethods.getConfig, + (_) => DownloaderConfig(downloadDir: '/downloads'), + ) + ..bind( + StorageMethods.getCreateHistory, + (_) => List.unmodifiable(histories), + ) + ..bind(StorageMethods.removeCreateHistory, (value) { + removedHistory = value; + histories.remove(value); + return const RpcUnit(); + }) + ..bind(StorageMethods.clearCreateHistory, (_) { + cleared = true; + histories.clear(); + return 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.tap(find.byIcon(Icons.history)); + await tester.pumpAndSettle(); + expect( + tester + .getSize(find.byKey(const ValueKey('create-history-title-bar'))) + .width, + tester + .getSize(find.byKey(const ValueKey('create-history-content'))) + .width, + ); + final alphaItem = find.byKey( + const ValueKey('create-history-item-https://example.com/alpha.zip'), + ); + final betaItem = find.byKey( + const ValueKey('create-history-item-https://example.com/beta.iso'), + ); + final gammaItem = find.byKey( + const ValueKey('create-history-item-magnet:?xt=urn:btih:gamma'), + ); + expect(alphaItem, findsOneWidget); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + final clearAction = find.byKey(const ValueKey('create-history-clear')); + final closeAction = find.byKey(const ValueKey('create-history-close')); + expect( + tester + .widget( + find.ancestor(of: clearAction, matching: find.byType(AppTooltip)), + ) + .message, + 'Clear History', + ); + expect( + tester + .widget( + find.ancestor(of: closeAction, matching: find.byType(AppTooltip)), + ) + .message, + 'Close', + ); + final historyText = tester.widget( + find.descendant(of: alphaItem, matching: find.byType(Text)), + ); + expect(historyText.data, contains('\u200B')); + expect(historyText.semanticsLabel, 'https://example.com/alpha.zip'); + + final filter = find.byKey(const ValueKey('create-history-filter')); + await tester.enterText(filter, 'beta'); + await tester.pump(); + expect(alphaItem, findsNothing); + expect(betaItem, findsOneWidget); + expect(gammaItem, findsNothing); + + await tester.tap( + find.byKey( + const ValueKey('create-history-delete-https://example.com/beta.iso'), + ), + ); + await tester.pumpAndSettle(); + expect(removedHistory, 'https://example.com/beta.iso'); + expect(find.text('No History Found'), findsOneWidget); + + await tester.enterText(filter, ''); + await tester.pump(); + expect(alphaItem, findsOneWidget); + expect(gammaItem, findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('create-history-clear'))); + await tester.pumpAndSettle(); + expect(cleared, isTrue); + expect(find.text('No history'), findsOneWidget); + expect( + find.byKey(const ValueKey('create-history-clear')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('create-history-close')), + findsOneWidget, + ); + }, + ); } String _fieldText(WidgetTester tester, String key) { - final editable = find.descendant(of: find.byKey(ValueKey(key)), matching: find.byType(EditableText)); + final editable = find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(EditableText), + ); return tester.widget(editable).controller.text; } diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index d7fddcbe..fc18a5a5 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -13,6 +13,9 @@ import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:window_manager/window_manager.dart'; import 'package:gopeed/app/app.dart'; +import 'package:gopeed/app/router/app_router.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/app_navigation_capability.dart'; import 'package:gopeed/app/application/app_appearance_controller.dart'; import 'package:gopeed/app/application/app_deep_link_controller.dart'; import 'package:gopeed/app/application/app_notification_controller.dart'; @@ -93,6 +96,38 @@ import 'package:gopeed/util/updater.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized().platformDispatcher.localeTestValue = const Locale('en'); + testWidgets('child success returns to downloading from completed and another page', (tester) async { + await _setTestSize(tester, const Size(1024, 768)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + tasksControllerProvider.overrideWith(CompletedTasksController.new), + settingsControllerProvider.overrideWith(FakeSettingsController.new), + ], + child: const GopeedApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Completed')); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsOneWidget); + + // Exercise the same serialized operation dispatched by a child window. + await LocalAppCapabilities.instance.registry.invoke(NavigationMethods.showDownloadingTasks.name, {}); + await tester.pumpAndSettle(); + expect(find.text('done.zip'), findsNothing); + expect(find.text('Create Task'), findsOneWidget); + + GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).go('/settings'); + await tester.pumpAndSettle(); + await LocalAppCapabilities.instance.capabilities.navigation.showDownloadingTasks(); + await tester.pumpAndSettle(); + expect(GoRouter.of(AppRouter.rootNavigatorKey.currentContext!).routeInformationProvider.value.uri.path, '/'); + expect(find.text('done.zip'), findsNothing); + expect(find.text('Create Task'), findsOneWidget); + }); + test('Web MCP endpoint excludes page path, query, and hash route', () { expect( webMcpEndpoint(Uri.parse('https://gopeed.example:8443/app?source=desktop#/settings/advanced')), From c4ae3330b82241a837cb0acf7227a39d2ba207fb Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:00:24 +0800 Subject: [PATCH 2/3] fix(windows): persist PNG icon for download notifications --- .../app_notification_controller.dart | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index 5ca4efb8..4ce0adc1 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -10,6 +10,7 @@ import 'package:path_provider/path_provider.dart'; import '../../core/common/task_event.dart'; import '../../core/libgopeed_boot.dart'; import '../../l10n/l10n.dart'; +import '../../util/log_util.dart'; import '../../util/util.dart'; import 'app_runtime_controller.dart'; @@ -56,13 +57,22 @@ class AppNotificationController extends AsyncNotifier { String? windowsIconPath; try { if (Util.isWindows()) { - final byteData = await rootBundle.load('assets/icon/icon.ico'); - final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/notification_icon.ico'); - await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes)); - windowsIconPath = file.path; + // Windows reads the toast header icon through the registered IconUri. + // Use a PNG in persistent storage so notification history can still + // resolve it after the app exits or temporary files are cleaned up. + final byteData = await rootBundle.load('assets/icon/icon_512.png'); + final supportDir = await getApplicationSupportDirectory(); + final file = File('${supportDir.path}/notification_icon.png'); + await file.parent.create(recursive: true); + await file.writeAsBytes( + byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes), + flush: true, + ); + windowsIconPath = file.absolute.path; } - } catch (_) {} + } catch (error, stackTrace) { + logger.w('prepare Windows notification icon failed', error, stackTrace); + } final windows = WindowsInitializationSettings( appName: 'Gopeed', From f254a82d9ce90476abcc56485e8e75056ba5bfd0 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 10 Sep 2026 23:07:14 +0800 Subject: [PATCH 3/3] refactor(windows): use bundled notification icon path directly --- .../app_notification_controller.dart | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index 4ce0adc1..bd09d189 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -2,10 +2,9 @@ import 'dart:async'; import 'dart:io'; 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:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; import '../../core/common/task_event.dart'; import '../../core/libgopeed_boot.dart'; @@ -57,18 +56,23 @@ class AppNotificationController extends AsyncNotifier { String? windowsIconPath; try { if (Util.isWindows()) { - // Windows reads the toast header icon through the registered IconUri. - // Use a PNG in persistent storage so notification history can still - // resolve it after the app exits or temporary files are cleaned up. - final byteData = await rootBundle.load('assets/icon/icon_512.png'); - final supportDir = await getApplicationSupportDirectory(); - final file = File('${supportDir.path}/notification_icon.png'); - await file.parent.create(recursive: true); - await file.writeAsBytes( - byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes), - flush: true, + // Windows requires a file path for IconUri, so use the bundled PNG + // beside the executable rather than copying it to another directory. + final file = File( + path.join( + path.dirname(Platform.resolvedExecutable), + 'data', + 'flutter_assets', + 'assets', + 'icon', + 'icon_512.png', + ), ); - windowsIconPath = file.absolute.path; + if (await file.exists()) { + windowsIconPath = file.absolute.path; + } else { + logger.w('Windows notification icon not found: ${file.path}'); + } } } catch (error, stackTrace) { logger.w('prepare Windows notification icon failed', error, stackTrace);