fix: restore only main macOS window and select active downloads

This commit is contained in:
Levi committed 2026-09-08 18:14:16 +08:00
1 parent 6568498850
commit 31fa0e5bc9
11 files changed
+219 -14

No files matched your search

+6
View File
@@ -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
@@ -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<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
static void showDownloadingTasks() {
final context = rootNavigatorKey.currentContext;
if (context == null || !context.mounted) return;
ProviderScope.containerOf(context, listen: false).read(taskListNavigationProvider.notifier).showDownloading();
GoRouter.of(context).go('/');
}
static GoRouter build(WebAuthController webAuthController) {
return GoRouter(
navigatorKey: rootNavigatorKey,
@@ -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));
}
@@ -0,0 +1,15 @@
import 'capability_rpc.dart';
abstract final class NavigationMethods {
static const showDownloadingTasks = RpcMethod<RpcUnit, RpcUnit>('navigation.tasks.downloading');
}
class AppNavigationService {
const AppNavigationService(this._invoker);
final CapabilityInvoker _invoker;
Future<void> showDownloadingTasks() async {
await _invoker.invoke(NavigationMethods.showDownloadingTasks, const RpcUnit());
}
}
@@ -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<HomePage> {
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 <TaskRecord>[];
final filteredTasks = _filteredTasks(tasks);
@@ -0,0 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
final taskListNavigationProvider = NotifierProvider<TaskListNavigation, int>(TaskListNavigation.new);
// A request also resets an already-visible list's filter, search and selection.
class TaskListNavigation extends Notifier<int> {
@override
int build() => 0;
void showDownloading() => state++;
}
@@ -719,6 +719,19 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
await windowManager.close();
}
Future<void> _finishCreation() async {
if (widget.windowController != null) {
// Navigation is best-effort after a successful submission. A navigation
// failure must not leave a completed form available for resubmission.
try {
await ref.read(appCapabilitiesProvider).navigation.showDownloadingTasks();
} catch (error) {
debugPrint('Unable to select downloading tasks: $error');
}
}
await _closeWindow();
}
Future<void> _loadDefaults() async {
try {
final config = await ref.read(gopeedServiceProvider).getConfig();
@@ -845,7 +858,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
);
}),
);
await _closeWindow();
await _finishCreation();
return;
}
@@ -861,7 +874,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
return;
}
await _closeWindow();
await _finishCreation();
} catch (error) {
if (mounted) {
_showToast(error.toString());
+10 -8
View File
@@ -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 {
+46 -3
View File
@@ -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
}
}
@@ -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 = <String>[];
const channel = MethodChannel('window_manager');
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
events.add(call.method);
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.createTask, (_) {
events.add('created');
return 'task-id';
})
..bind(NavigationMethods.showDownloadingTasks, (_) {
events.add('downloads');
if (navigationFails) throw StateError('Navigation unavailable');
return const RpcUnit();
});
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: CreateTaskWindowPage(
windowController: WindowController.fromWindowId('child'),
initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
// The mocked native close deliberately leaves the widget mounted.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(events, ['created', 'downloads', 'close']);
await tester.pumpWidget(const SizedBox());
});
}
testWidgets('mobile create task page starts below the system status bar', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
tester.view.physicalSize = const Size(700, 760);
+35
View File
@@ -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')),