From 31fa0e5bc9e3a5a357d3874e7269cac9d4c19852 Mon Sep 17 00:00:00 2001 From: Levi Date: Tue, 8 Sep 2026 18:14:16 +0800 Subject: [PATCH] fix: restore only main macOS window and select active downloads --- ui/flutter/docs/window-capability-rpc.md | 6 ++ 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 +++--- .../macos/RunnerTests/RunnerTests.swift | 49 ++++++++++++++++- .../create_task_window_page_test.dart | 55 +++++++++++++++++++ ui/flutter/test/widget_test.dart | 35 ++++++++++++ 11 files changed, 219 insertions(+), 14 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/docs/window-capability-rpc.md b/ui/flutter/docs/window-capability-rpc.md index 1d90a8de..5b3a18ff 100644 --- a/ui/flutter/docs/window-capability-rpc.md +++ b/ui/flutter/docs/window-capability-rpc.md @@ -85,6 +85,12 @@ Appearance synchronization uses `appearance.changed` and currently contains: - Theme color - Locale +After successful task creation, a child calls the typed +`NavigationMethods.showDownloadingTasks` capability before closing. The host +routes to the task list and resets its filter to downloading, including any +search or selection on an already-mounted list. This does not show or focus a +hidden main window. Cancellation and failed creation do not request navigation. + State broadcasting must observe the owning Riverpod state centrally. Do not broadcast directly from a settings button callback, because updates can originate from configuration loading, system changes, or future entry points. ## 6. Storage 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 0ba96025..c2377196 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 @@ -719,6 +719,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(); @@ -845,7 +858,7 @@ class _CreateTaskWindowPageState extends ConsumerState { ); }), ); - await _closeWindow(); + await _finishCreation(); return; } @@ -861,7 +874,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/macos/RunnerTests/RunnerTests.swift b/ui/flutter/macos/RunnerTests/RunnerTests.swift index 61f3bd1f..b65faaf7 100644 --- a/ui/flutter/macos/RunnerTests/RunnerTests.swift +++ b/ui/flutter/macos/RunnerTests/RunnerTests.swift @@ -1,12 +1,55 @@ import Cocoa import FlutterMacOS import XCTest +@testable import Gopeed class RunnerTests: XCTestCase { - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testReopenDoesNotRestoreClosedCreateTaskWindows() throws { + let mainWindow = try XCTUnwrap(NSApp.windows.first { $0 is MainFlutterWindow }) + let delegate = try XCTUnwrap(NSApp.delegate as? AppDelegate) + let children = [makeChildWindow(), makeChildWindow()] + defer { + children.forEach { $0.close() } + mainWindow.makeKeyAndOrderFront(nil) + } + + mainWindow.orderOut(nil) + for child in children { + child.makeKeyAndOrderFront(nil) + child.close() + XCTAssertFalse(child.isVisible) + } + + XCTAssertFalse(delegate.applicationShouldHandleReopen(NSApp, hasVisibleWindows: false)) + XCTAssertTrue(mainWindow.isVisible) + XCTAssertTrue(children.allSatisfy { !$0.isVisible }) + } + + func testReopenRestoresMainWindowWithAnUnfinishedChildVisible() throws { + let mainWindow = try XCTUnwrap(NSApp.windows.first { $0 is MainFlutterWindow }) + let delegate = try XCTUnwrap(NSApp.delegate as? AppDelegate) + let child = makeChildWindow() + defer { + child.close() + mainWindow.makeKeyAndOrderFront(nil) + } + + mainWindow.orderOut(nil) + child.makeKeyAndOrderFront(nil) + + XCTAssertFalse(delegate.applicationShouldHandleReopen(NSApp, hasVisibleWindows: true)) + XCTAssertTrue(mainWindow.isVisible) + XCTAssertTrue(child.isVisible) + } + + private func makeChildWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + // Match desktop_multi_window's retained native child windows. + window.isReleasedWhenClosed = false + return window } } 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 c0eeae56..4f41139e 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,4 +1,6 @@ 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/material.dart' show Icons; @@ -10,6 +12,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'; @@ -22,6 +25,58 @@ import 'package:gopeed/shared/widgets/app_tooltip.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { + for (final navigationFails in [false, true]) { + testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final events = []; + const channel = MethodChannel('window_manager'); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + events.add(call.method); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()) + ..bind(GopeedMethods.createTask, (_) { + events.add('created'); + return 'task-id'; + }) + ..bind(NavigationMethods.showDownloadingTasks, (_) { + events.add('downloads'); + if (navigationFails) throw StateError('Navigation unavailable'); + return const RpcUnit(); + }); + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: CreateTaskWindowPage( + windowController: WindowController.fromWindowId('child'), + initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Confirm')); + // The mocked native close deliberately leaves the widget mounted. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(events, ['created', 'downloads', 'close']); + await tester.pumpWidget(const SizedBox()); + }); + } + testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.view.physicalSize = const Size(700, 760); 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')),