mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 00:50:27 +00:00
fix: restore only main macOS window and select active downloads
This commit is contained in:
1 parent
6568498850
commit
31fa0e5bc9
11 files changed
+219
-14
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')),
|
||||
|
||||
Reference in new issue
Block a user