From 7c61a46dec8487c186a8b5fb7c92766dc6370d97 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Tue, 10 Nov 2026 12:27:28 +0200 Subject: [PATCH] fix(legacy): report show/hide and fix restore tracking nativeapi has no show/hide window event, so 0.5.x code that pauses and resumes work on onWindowEvent('show'/'hide') went silent under the legacy bridge. show() and hide() now emit those events when the call actually changes the window's visibility, re-checked after the native call. When hiding, a minimized window counts as shown, since macOS reports it as not visible. Visibility changes made outside the legacy API are still not reported: nativeapi offers no event for them. A minimized window that is maximized comes back with a maximized event alone, which left the bridge believing it was still minimized, so the next restored event was reported as restore instead of unmaximize. A maximized event now clears the minimized flag, and a restored event re-reads isMaximized from the window instead of guessing. Adds a @visibleForTesting debugBindWindow so the bridge can be driven by a simulated nativeapi.Window in tests. --- lib/src/legacy/window_manager.dart | 48 ++++++-- test/legacy_visibility_test.dart | 188 +++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 test/legacy_visibility_test.dart diff --git a/lib/src/legacy/window_manager.dart b/lib/src/legacy/window_manager.dart index 73396ab9..71cb5746 100644 --- a/lib/src/legacy/window_manager.dart +++ b/lib/src/legacy/window_manager.dart @@ -4,7 +4,8 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' show Brightness, Color, Offset, PlatformDispatcher, Rect, Size; -import 'package:flutter/foundation.dart' show ObserverList, VoidCallback; +import 'package:flutter/foundation.dart' + show ObserverList, VoidCallback, visibleForTesting; import 'package:flutter/painting.dart' show Alignment; import 'package:nativeapi/nativeapi.dart' as nativeapi; import 'package:nativeapi_flutter/nativeapi_flutter.dart' @@ -39,6 +40,10 @@ const kWindowEventLeaveFullScreen = 'leave-full-screen'; const kWindowEventDocked = 'docked'; const kWindowEventUndocked = 'undocked'; +// Reported by 0.5.x through onWindowEvent only, without a named constant. +const _kWindowEventShow = 'show'; +const _kWindowEventHide = 'hide'; + @Deprecated( 'The 0.5.x compatible API will be removed in a future release. Use the native API from package:window_manager/window_manager.dart.', ) @@ -66,6 +71,15 @@ class WindowManager { nativeapi.Window? _window; nativeapi.ListenerId? _nativeListenerId; + /// Addresses [window] instead of the platform's current window, or goes + /// back to the current window with null. For tests of this bridge only. + @visibleForTesting + void debugBindWindow(nativeapi.Window? window) { + _unwireNativeEvents(); + _window = window; + if (_listeners.isNotEmpty || _isPreventClose) _wireNativeEvents(); + } + bool _isPreventClose = false; bool _isDestroying = false; bool _isMaximized = false; @@ -251,9 +265,13 @@ class WindowManager { } /// Shows and gives focus to the window. + /// + /// Listeners get `onWindowEvent('show')` when this call makes a hidden + /// window visible. Future show({bool inactive = false}) async { final window = _target; if (window == null) return; + final wasVisible = window.isVisible; if (window.isMinimized) { window.restore(); } @@ -262,11 +280,23 @@ class WindowManager { } else { window.show(); } + // nativeapi has no show/hide event; 0.5.x reported both through + // onWindowEvent, which apps use to pause and resume work. + // Check the result: the platform may refuse or defer the change. + if (!wasVisible && window.isVisible) _emit(_kWindowEventShow); } /// Hides the window. + /// + /// Listeners get `onWindowEvent('hide')` when this call hides a window that + /// was visible or minimized. Future hide() async { - _target?.hide(); + final window = _target; + if (window == null) return; + // macOS reports a minimized window as not visible. + final wasShown = window.isVisible || window.isMinimized; + window.hide(); + if (wasShown && !window.isVisible) _emit(_kWindowEventHide); } /// Returns `bool` - Whether the window is visible to the user. @@ -770,17 +800,21 @@ class WindowManager { case nativeapi.WindowBlurredEvent(): _emit(kWindowEventBlur); case nativeapi.WindowMaximizedEvent(): + // A minimized window that was maximized comes back with this event + // alone, as 0.5.x reported it on Windows. + _isMinimized = false; _isMaximized = true; _emit(kWindowEventMaximize); case nativeapi.WindowMinimizedEvent(): _isMinimized = true; _emit(kWindowEventMinimize); case nativeapi.WindowRestoredEvent(): - if (_isMinimized) { - _isMinimized = false; - _emit(kWindowEventRestore); - } else if (_isMaximized) { - _isMaximized = false; + final wasMinimized = _isMinimized; + final wasMaximized = _isMaximized; + _isMinimized = false; + // Leaving the minimized state may land back in the maximized one. + _isMaximized = _target?.isMaximized ?? false; + if (!wasMinimized && wasMaximized && !_isMaximized) { _emit(kWindowEventUnmaximize); } else { _emit(kWindowEventRestore); diff --git a/test/legacy_visibility_test.dart b/test/legacy_visibility_test.dart new file mode 100644 index 00000000..bc728d82 --- /dev/null +++ b/test/legacy_visibility_test.dart @@ -0,0 +1,188 @@ +// ignore_for_file: deprecated_member_use_from_same_package + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nativeapi/nativeapi.dart' as nativeapi; +import 'package:window_manager/legacy.dart'; + +class _Recorder with WindowListener { + final List events = []; + + @override + void onWindowEvent(String eventName) => events.add(eventName); +} + +// A nativeapi window simulated in Dart; no native window is created. +class _FakeNativeWindow extends nativeapi.Window { + _FakeNativeWindow() : super.borrowed(0); + + bool visible = false; + bool minimized = false; + bool maximized = false; + bool refuseVisibilityChanges = false; + FutureOr Function(nativeapi.WindowEvent)? _callback; + + bool get isListening => _callback != null; + + // As nativeapi on macOS, a minimized window does not count as visible. + @override + bool get isVisible => visible && !minimized; + + @override + bool get isMinimized => minimized; + + @override + bool get isMaximized => maximized; + + @override + bool get isFullScreen => false; + + @override + void show() => visible = visible || !refuseVisibilityChanges; + + @override + void showInactive() => show(); + + @override + void hide() => visible = visible && refuseVisibilityChanges; + + @override + void restore() => minimized = false; + + @override + nativeapi.ListenerId addListener( + FutureOr Function(nativeapi.WindowEvent) listener, + ) { + _callback = listener; + return 1; + } + + @override + bool removeListener(nativeapi.ListenerId id) { + _callback = null; + return true; + } + + Future fire(nativeapi.WindowEvent event) async { + await _callback?.call(event); + } +} + +class _Named with WindowListener { + final List calls = []; + + @override + void onWindowMaximize() => calls.add('maximize'); + + @override + void onWindowUnmaximize() => calls.add('unmaximize'); + + @override + void onWindowRestore() => calls.add('restore'); +} + +void main() { + late _FakeNativeWindow fake; + late _Recorder listener; + + setUp(() { + fake = _FakeNativeWindow(); + listener = _Recorder(); + windowManager.debugBindWindow(fake); + windowManager.addListener(listener); + }); + + tearDown(() { + windowManager.removeListener(listener); + windowManager.debugBindWindow(null); + }); + + group('show and hide', () { + test('are reported only when the visibility changes', () async { + await windowManager.show(); + await windowManager.show(); + await windowManager.hide(); + await windowManager.hide(); + await windowManager.show(inactive: true); + expect(listener.events, ['show', 'hide', 'show']); + }); + + test('a minimized window counts as shown', () async { + fake.visible = true; + fake.minimized = true; + await windowManager.hide(); + expect(listener.events, ['hide']); + + fake.visible = true; + fake.minimized = true; + await windowManager.show(); + expect(fake.minimized, isFalse); + expect(listener.events, ['hide', 'show']); + }); + + test('are not reported when the platform keeps the visibility', () async { + fake.refuseVisibilityChanges = true; + await windowManager.show(); + fake.visible = true; + await windowManager.hide(); + expect(listener.events, isEmpty); + }); + }); + + group('restore tracking', () { + test( + 'a minimized window that comes back maximized is not minimized', + () async { + fake.minimized = true; + await fake.fire(const nativeapi.WindowMinimizedEvent(windowId: 1)); + fake.minimized = false; + fake.maximized = true; + await fake.fire(const nativeapi.WindowMaximizedEvent(windowId: 1)); + fake.maximized = false; + await fake.fire(const nativeapi.WindowRestoredEvent(windowId: 1)); + + expect(listener.events, [ + kWindowEventMinimize, + kWindowEventMaximize, + kWindowEventUnmaximize, + ]); + }, + ); + + test('restoring a minimized window keeps it maximized', () async { + fake.maximized = true; + await fake.fire(const nativeapi.WindowMaximizedEvent(windowId: 1)); + fake.minimized = true; + await fake.fire(const nativeapi.WindowMinimizedEvent(windowId: 1)); + fake.minimized = false; + await fake.fire(const nativeapi.WindowRestoredEvent(windowId: 1)); + fake.maximized = false; + await fake.fire(const nativeapi.WindowRestoredEvent(windowId: 1)); + + expect(listener.events, [ + kWindowEventMaximize, + kWindowEventMinimize, + kWindowEventRestore, + kWindowEventUnmaximize, + ]); + }); + + test('named callbacks follow the tracked state', () async { + final named = _Named(); + windowManager.addListener(named); + addTearDown(() => windowManager.removeListener(named)); + + fake.maximized = true; + await fake.fire(const nativeapi.WindowMaximizedEvent(windowId: 1)); + fake.maximized = false; + await fake.fire(const nativeapi.WindowRestoredEvent(windowId: 1)); + fake.minimized = true; + await fake.fire(const nativeapi.WindowMinimizedEvent(windowId: 1)); + fake.minimized = false; + await fake.fire(const nativeapi.WindowRestoredEvent(windowId: 1)); + + expect(named.calls, ['maximize', 'unmaximize', 'restore']); + }); + }); +}