diff --git a/ui/flutter/lib/core/window/window_capability_transport.dart b/ui/flutter/lib/core/window/window_capability_transport.dart index 45506801..3fa2c6fc 100644 --- a/ui/flutter/lib/core/window/window_capability_transport.dart +++ b/ui/flutter/lib/core/window/window_capability_transport.dart @@ -52,6 +52,7 @@ class AppWindowCapabilityHost { CapabilityRegistry? _registry; AppWindowAppearance _appearance = const AppWindowAppearance.defaults(); final Map _subscribers = {}; + StreamSubscription? _windowsChanged; bool get started => _registry != null; @@ -59,14 +60,33 @@ class AppWindowCapabilityHost { if (_registry != null) return; _registry = registry; await _hostChannel.setMethodCallHandler(_handleCall); + _windowsChanged = onWindowsChanged.listen((_) => unawaited(_removeClosedSubscribers())); } Future stop() async { _registry = null; + await _windowsChanged?.cancel(); + _windowsChanged = null; _subscribers.clear(); await _hostChannel.setMethodCallHandler(null); } + Future _removeClosedSubscribers() async { + // Engine shutdown does not run Widget.dispose, so a closed child cannot + // always send window.unsubscribe itself. + final registry = _registry; + final candidates = _subscribers.keys.toSet(); + if (registry == null || candidates.isEmpty) return; + try { + final windows = await WindowController.getAll(); + if (!identical(registry, _registry)) return; + final openIds = windows.map((window) => window.windowId).toSet(); + _subscribers.removeWhere((id, _) => candidates.contains(id) && !openIds.contains(id)); + } catch (_) { + // The native window registry can already be gone during app shutdown. + } + } + void updateAppearance(AppWindowAppearance appearance) { if (_appearance == appearance) return; _appearance = appearance; diff --git a/ui/flutter/macos/Runner/MainFlutterWindow.swift b/ui/flutter/macos/Runner/MainFlutterWindow.swift index 718f4c04..f4354059 100644 --- a/ui/flutter/macos/Runner/MainFlutterWindow.swift +++ b/ui/flutter/macos/Runner/MainFlutterWindow.swift @@ -4,6 +4,8 @@ import desktop_multi_window import window_manager class MainFlutterWindow: NSWindow { + private var childWindowCloseObservers: [ObjectIdentifier: NSObjectProtocol] = [:] + override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -11,13 +13,45 @@ class MainFlutterWindow: NSWindow { self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) - FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in + FlutterMultiWindowPlugin.setOnWindowCreatedCallback { [weak self] controller in RegisterGeneratedPlugins(registry: controller) + self?.releaseChildEngineOnClose(controller) } super.awakeFromNib() } + // desktop_multi_window removes closed windows from its registry, but plugin + // channels can still retain the window/controller/engine cycle. NSWindow.close + // alone therefore leaves each child Dart isolate and its heap running. + private func releaseChildEngineOnClose(_ controller: FlutterViewController) { + guard let window = controller.view.window else { return } + let key = ObjectIdentifier(window) + childWindowCloseObservers[key] = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: window, queue: .main + ) { [weak self, weak controller, weak window] _ in + if let observer = self?.childWindowCloseObservers.removeValue(forKey: key) { + NotificationCenter.default.removeObserver(observer) + } + // Let close notifications and the plugin's registry removal finish before + // tearing down the engine that delivered the close request. + DispatchQueue.main.async { [weak controller, weak window] in + // window_manager's onEvent closure retains its plugin, which retains + // the manager and NSWindow even after the engine has stopped. + (window?.delegate as? WindowManager)?.onEvent = nil + window?.delegate = nil + controller?.engine.shutDownEngine() + window?.contentViewController = nil + } + } + } + + deinit { + for observer in childWindowCloseObservers.values { + NotificationCenter.default.removeObserver(observer) + } + } + override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) { super.order(place, relativeTo: otherWin) hiddenWindowAtLaunch() diff --git a/ui/flutter/test/core/window/window_capability_lifecycle_test.dart b/ui/flutter/test/core/window/window_capability_lifecycle_test.dart new file mode 100644 index 00000000..d50e0a97 --- /dev/null +++ b/ui/flutter/test/core/window/window_capability_lifecycle_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/capability_rpc.dart'; +import 'package:gopeed/core/window/app_window_appearance.dart'; +import 'package:gopeed/core/window/window_capability_transport.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const windows = MethodChannel('mixin.one/desktop_multi_window'); + const channels = MethodChannel('mixin.one/desktop_multi_window/channels'); + const codec = StandardMethodCodec(); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() async { + await AppWindowCapabilityHost.instance.stop(); + messenger.setMockMethodCallHandler(windows, null); + messenger.setMockMethodCallHandler(channels, null); + }); + + testWidgets('closed engines lose appearance subscriptions without a Dart unsubscribe', (tester) async { + final deliveries = []; + messenger.setMockMethodCallHandler(channels, (call) async { + if (call.method == 'invokeMethod') { + final args = Map.from(call.arguments as Map); + if (args['method'] == AppWindowRpcProtocol.event) deliveries.add(args['channel'] as String); + } + return null; + }); + messenger.setMockMethodCallHandler(windows, (call) async { + if (call.method == 'getAllWindows') { + return [ + {'windowId': 'live-child', 'windowArgument': ''}, + ]; + } + return null; + }); + final host = AppWindowCapabilityHost.instance; + await host.start(CapabilityRegistry(createAppCapabilityCodecs())); + for (final id in ['closed-child', 'live-child']) { + await messenger.handlePlatformMessage( + channels.name, + codec.encodeMethodCall( + MethodCall('methodCall', { + 'channel': AppWindowRpcProtocol.channelName, + 'method': AppWindowRpcProtocol.subscribe, + 'arguments': {'windowId': id}, + }), + ), + (_) {}, + ); + } + await tester.pump(); + deliveries.clear(); + await messenger.handlePlatformMessage( + windows.name, + codec.encodeMethodCall(const MethodCall('onWindowsChanged')), + (_) {}, + ); + await tester.pump(); + host.updateAppearance(const AppWindowAppearance(themeMode: 'dark', themeColor: 'green', locale: 'zh')); + await tester.pump(); + expect(deliveries, ['mixin.one/window_controller/live-child']); + }); +}