From a7dbac92a7b11357ae8555d47618d045f1426992 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Tue, 10 Nov 2026 12:38:39 +0200 Subject: [PATCH] fix(legacy): resolve the window late and add bindWindow legacy.dart addressed nativeapi.WindowManager.instance.getCurrent() and cached the first answer. On Windows that is GetActiveWindow(), which returns nothing while the app's window is still hidden. An app that keeps its window hidden until ready and calls addListener, setPreventClose(true) and show() during startup therefore lost all of them: native events were never wired, so close requests were not intercepted and onWindowClose never fired, and show() did nothing. - A missing window is never remembered: every call looks again, and the first window found gets the listeners and prevent-close set up before it existed. - On Windows, when there is no current window, the bridge takes the application's primary window if one is set, else this process's only unowned top-level window hosting a Flutter view (a child of window class FLUTTERVIEW), hidden or not, found with EnumWindows. With several candidates it answers nothing rather than guess. Linux and macOS are unchanged: their getCurrent() already falls back to a hidden window. - A show() without a window is kept and carried out on the first window found or bound; hide() cancels it. A one-time debug message points to bindWindow. - New public WindowManager.bindWindow(window) addresses a given nativeapi.Window and moves listeners and prevent-close to it; bindWindow(null) goes back to the current window. package:ffi becomes a direct dependency (it was already transitive through nativeapi). --- README.md | 33 +++ lib/src/legacy/flutter_host_window_win32.dart | 106 ++++++++++ lib/src/legacy/window_manager.dart | 142 ++++++++++++- pubspec.yaml | 1 + test/flutter_host_window_win32_test.dart | 186 +++++++++++++++++ test/legacy_late_window_test.dart | 195 ++++++++++++++++++ 6 files changed, 652 insertions(+), 11 deletions(-) create mode 100644 lib/src/legacy/flutter_host_window_win32.dart create mode 100644 test/flutter_host_window_win32_test.dart create mode 100644 test/legacy_late_window_test.dart diff --git a/README.md b/README.md index d8e2d184..1799e5e3 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,39 @@ What differs from 0.5.x: - New example on `package:flutter/widgets.dart` alone; the full one is nativeapi's [window_example](https://github.com/libnativeapi/nativeapi/tree/main/examples/flutter_window_example). +#### Choosing the window `legacy.dart` addresses + +By default every call addresses the window the platform currently considers the app's +own (nativeapi's `WindowManager.getCurrent()`). On Windows the current window is the +active one, which a window kept hidden during startup is not; there the bridge also +takes the application's primary window, else this process's top-level window that hosts +the Flutter view (window class `FLUTTERVIEW`), hidden or not, when exactly one exists. +Until a window is found the bridge looks again on every call: listeners and +`setPreventClose(true)` set up earlier are connected to the first window found, and a +`show()` that found no window is carried out on it. Other calls do nothing until then. + +To address a particular window, for example with several Flutter windows in one +process, bind it explicitly. On Windows, +`nativeapi.Window.createWithNativeWindow` wraps an `HWND`, for example one the runner +passes to Dart: + +```dart +import 'dart:ffi'; + +import 'package:nativeapi/nativeapi.dart' as nativeapi; +import 'package:window_manager/legacy.dart'; + +void bindMainWindow(int hwnd) { + windowManager.bindWindow( + nativeapi.Window.createWithNativeWindow(Pointer.fromAddress(hwnd)), + ); +} +``` + +An app on Flutter's multi-window API can bind `controller.nativeWindow` from +`package:window_manager/windowing.dart` instead. Listeners and prevent-close move to the +bound window; `bindWindow(null)` goes back to the current window. + #### Moving to the native API | 0.5.x (`legacy.dart`) | Native API (`window_manager.dart`) | diff --git a/lib/src/legacy/flutter_host_window_win32.dart b/lib/src/legacy/flutter_host_window_win32.dart new file mode 100644 index 00000000..4ce2552a --- /dev/null +++ b/lib/src/legacy/flutter_host_window_win32.dart @@ -0,0 +1,106 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; + +const _gwOwner = 4; + +typedef _EnumWindowsProcNative = Int32 Function(IntPtr hwnd, IntPtr lParam); + +/// The `HWND` of this process's only top-level window that hosts a Flutter +/// view, or 0 when there is none, more than one, or the platform is not +/// Windows. +/// +/// nativeapi finds the current window on Windows through `GetActiveWindow()`, +/// which knows nothing of a window that is still hidden. A Flutter runner +/// parents the engine's view (window class `FLUTTERVIEW`) to its top-level +/// window, visible or not; when that identifies a single window of this +/// process, it is the app's window. With several such windows the answer +/// would be a guess, so there is none. +int findSoleFlutterHostWindow() { + if (!Platform.isWindows) return 0; + final api = _Win32.instance; + final self = api.getCurrentProcessId(); + return using((arena) { + final viewClass = 'FLUTTERVIEW'.toNativeUtf16(allocator: arena); + final processId = arena(); + var found = 0; + // EnumWindows walks a snapshot of the desktop's top-level windows, hidden + // ones included, so windows that move in the z-order meanwhile are seen + // once. + for (final hwnd in _topLevelWindows(api)) { + api.getWindowThreadProcessId(hwnd, processId); + if (processId.value != self) continue; + if (api.getWindow(hwnd, _gwOwner) != 0) continue; + if (api.findWindowEx(hwnd, 0, viewClass, nullptr) == 0) continue; + if (found != 0) return 0; + found = hwnd; + } + return found; + }); +} + +// Filled by [_collectWindow] during one synchronous EnumWindows call. +List? _collected; + +int _collectWindow(int hwnd, int lParam) { + _collected?.add(hwnd); + return 1; +} + +List _topLevelWindows(_Win32 api) { + final windows = _collected = []; + try { + api.enumWindows( + Pointer.fromFunction<_EnumWindowsProcNative>(_collectWindow, 0), + 0, + ); + return windows; + } finally { + _collected = null; + } +} + +class _Win32 { + _Win32._() { + final user32 = DynamicLibrary.open('user32.dll'); + final kernel32 = DynamicLibrary.open('kernel32.dll'); + enumWindows = user32 + .lookupFunction< + Int32 Function( + Pointer>, + IntPtr, + ), + int Function(Pointer>, int) + >('EnumWindows'); + findWindowEx = user32 + .lookupFunction< + IntPtr Function(IntPtr, IntPtr, Pointer, Pointer), + int Function(int, int, Pointer, Pointer) + >('FindWindowExW'); + getWindowThreadProcessId = user32 + .lookupFunction< + Uint32 Function(IntPtr, Pointer), + int Function(int, Pointer) + >('GetWindowThreadProcessId'); + getWindow = user32 + .lookupFunction< + IntPtr Function(IntPtr, Uint32), + int Function(int, int) + >('GetWindow'); + getCurrentProcessId = kernel32 + .lookupFunction( + 'GetCurrentProcessId', + ); + } + + static final _Win32 instance = _Win32._(); + + late final int Function(Pointer>, int) + enumWindows; + late final int Function(int, int, Pointer, Pointer) + findWindowEx; + late final int Function(int, Pointer) getWindowThreadProcessId; + late final int Function(int, int) getWindow; + late final int Function() getCurrentProcessId; +} diff --git a/lib/src/legacy/window_manager.dart b/lib/src/legacy/window_manager.dart index 73396ab9..034194fa 100644 --- a/lib/src/legacy/window_manager.dart +++ b/lib/src/legacy/window_manager.dart @@ -1,10 +1,13 @@ // ignore_for_file: deprecated_member_use_from_same_package import 'dart:async'; +import 'dart:developer' as developer; +import 'dart:ffi' show Pointer, Void; 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' @@ -17,6 +20,7 @@ import 'package:nativeapi_flutter/nativeapi_flutter.dart' RectangleToRect, SizeToNative; import 'package:window_manager/src/legacy/calc_window_position.dart'; +import 'package:window_manager/src/legacy/flutter_host_window_win32.dart'; import 'package:window_manager/src/legacy/window_listener.dart'; import 'package:window_manager/src/legacy/window_options.dart'; import 'package:window_manager/src/native_guard.dart'; @@ -48,9 +52,9 @@ enum DockSide { left, right } /// for code that has not moved to the native API yet. /// /// Every call goes to the window the platform currently considers the app's -/// own. 0.5.x only ever addressed one window; an app with several of them -/// should use the native API, where each [nativeapi.Window] is addressed -/// directly. +/// own, or to the one passed to [WindowManager.bindWindow]. 0.5.x only ever +/// addressed one window; an app with several of them should use the native +/// API, where each [nativeapi.Window] is addressed directly. @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.', ) @@ -72,14 +76,100 @@ class WindowManager { bool _isMinimized = false; bool _isFullScreen = false; - /// The window every call below addresses, resolved once the platform has - /// one. Null before the first window exists. + // The addressed window is the one passed to [bindWindow], or the platform's + // current window once it could be resolved; "no window" is never cached. + // + // A show() that arrived while no window could be resolved is carried out on + // the window that is resolved or bound next. Null when none is pending. + bool? _pendingShowInactive; + bool _didWarnUnresolvedShow = false; + + /// Replaces how the platform's current window is looked up. For tests of + /// this bridge only; null restores the platform lookup. + @visibleForTesting + nativeapi.Window? Function()? debugCurrentWindowOverride; + + /// Makes every call address [window], or with null goes back to the window + /// the platform currently considers the app's own. + /// + /// By default the window is looked up with nativeapi's + /// `WindowManager.getCurrent()`, retried by every call until it answers. On + /// Windows that is the active window, so an app that keeps its window hidden + /// during startup may have none to address before it is shown. Binding the + /// window explicitly removes that dependency: + /// + /// ```dart + /// // hwnd: the HWND of the app's top-level window, from the runner. + /// windowManager.bindWindow( + /// nativeapi.Window.createWithNativeWindow(Pointer.fromAddress(hwnd)), + /// ); + /// ``` + /// + /// Listeners and [setPreventClose] move to the new window: native events + /// stop arriving from the previous one. A [show] requested while no window + /// could be found is carried out on [window]. + void bindWindow(nativeapi.Window? window) { + _unwireNativeEvents(); + _window = null; + if (window != null) { + _adopt(window); + } else if (_wantsNativeEvents) { + // Resolves the current window, if there is one yet, and wires it. + _wireNativeEvents(); + } + } + + bool get _wantsNativeEvents => _listeners.isNotEmpty || _isPreventClose; + + /// The window every call below addresses. + /// + /// Null while the platform has no current window for the app. Null is not + /// remembered: the next call looks again, and the first window found gets + /// the listeners and prevent-close that were set up before it existed. nativeapi.Window? get _target { - return _window ??= tryNative( - () => nativeapi.WindowManager.instance.getCurrent(), + final window = _window; + if (window != null) return window; + final resolved = _resolveCurrentWindow(); + if (resolved != null) _adopt(resolved); + return resolved; + } + + nativeapi.Window? _resolveCurrentWindow() { + final override = debugCurrentWindowOverride; + if (override != null) return override(); + return tryNative( + () => + nativeapi.WindowManager.instance.getCurrent() ?? + _findHiddenWindowOnWindows(), ); } + // Windows reports only the active window as current, so a window kept + // hidden until the app is ready would be out of reach until then. Linux and + // macOS already fall back to a hidden window of their own. + nativeapi.Window? _findHiddenWindowOnWindows() { + if (!Platform.isWindows) return null; + // An app that designated its primary window has one even while it is + // hidden. + final primary = nativeapi.Application.instance.getPrimaryWindow(); + if (primary != null) return primary; + final hwnd = findSoleFlutterHostWindow(); + if (hwnd == 0) return null; + return nativeapi.Window.createWithNativeWindow( + Pointer.fromAddress(hwnd), + ); + } + + void _adopt(nativeapi.Window window) { + _window = window; + if (_wantsNativeEvents) _wireNativeEvents(); + final inactive = _pendingShowInactive; + if (inactive != null) { + _pendingShowInactive = null; + _showWindow(window, inactive: inactive); + } + } + List get listeners { return List.from(_listeners); } @@ -107,7 +197,7 @@ class WindowManager { /// Connects listeners registered before the native window was available. Future ensureInitialized() async { - if (_listeners.isNotEmpty || _isPreventClose) _wireNativeEvents(); + if (_wantsNativeEvents) _wireNativeEvents(); } /// Returns `int` - The ID of the window. @@ -251,9 +341,32 @@ class WindowManager { } /// Shows and gives focus to the window. + /// + /// When no window can be found yet (see [bindWindow]), the request is kept + /// and carried out on the first window that is found or bound; a later + /// [hide] cancels it. Future show({bool inactive = false}) async { final window = _target; - if (window == null) return; + if (window == null) { + _pendingShowInactive = inactive; + assert(() { + if (!_didWarnUnresolvedShow) { + _didWarnUnresolvedShow = true; + developer.log( + 'show() found no window to show yet; it will be shown once one ' + 'is found. Call windowManager.bindWindow() to address the window ' + 'explicitly.', + name: 'window_manager', + ); + } + return true; + }()); + return; + } + _showWindow(window, inactive: inactive); + } + + void _showWindow(nativeapi.Window window, {required bool inactive}) { if (window.isMinimized) { window.restore(); } @@ -265,7 +378,10 @@ class WindowManager { } /// Hides the window. + /// + /// Also cancels a [show] that is waiting for a window to be found. Future hide() async { + _pendingShowInactive = null; _target?.hide(); } @@ -727,10 +843,14 @@ class WindowManager { // A listener on the window itself, not on nativeapi's WindowManager: close // requests reach only the window's own listeners, and other windows of the // app stay out of the 0.5.x events. + // + // Without a window yet this is a no-op; the window found later is wired + // when it is adopted. Safe to call any number of times. void _wireNativeEvents() { if (_nativeListenerId != null) return; final window = _target; - if (window == null) return; + // Adopting a newly found window may have wired it already. + if (window == null || _nativeListenerId != null) return; // State may have changed while there were no listeners, or before the // first listener was added. A restored event alone cannot tell us whether // the window was minimized or maximized. diff --git a/pubspec.yaml b/pubspec.yaml index 912c95e1..48a95863 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,6 +28,7 @@ environment: dependencies: flutter: sdk: flutter + ffi: ^2.1.0 nativeapi: ^0.5.5 nativeapi_flutter: ^0.5.5 diff --git a/test/flutter_host_window_win32_test.dart b/test/flutter_host_window_win32_test.dart new file mode 100644 index 00000000..d8596905 --- /dev/null +++ b/test/flutter_host_window_win32_test.dart @@ -0,0 +1,186 @@ +// The Windows lookup of a hidden window hosting a Flutter view, against real +// Win32 windows made here: a top-level window with a child of window class +// `FLUTTERVIEW`, as Flutter's runner sets it up. + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:window_manager/src/legacy/flutter_host_window_win32.dart'; + +final class _WndClass extends Struct { + @Uint32() + external int style; + external Pointer lpfnWndProc; + @Int32() + external int cbClsExtra; + @Int32() + external int cbWndExtra; + @IntPtr() + external int hInstance; + @IntPtr() + external int hIcon; + @IntPtr() + external int hCursor; + @IntPtr() + external int hbrBackground; + external Pointer lpszMenuName; + external Pointer lpszClassName; +} + +typedef _CreateWindowExNative = IntPtr Function( + Uint32, + Pointer, + Pointer, + Uint32, + Int32, + Int32, + Int32, + Int32, + IntPtr, + IntPtr, + IntPtr, + Pointer, +); +typedef _CreateWindowEx = int Function( + int, + Pointer, + Pointer, + int, + int, + int, + int, + int, + int, + int, + int, + Pointer, +); + +const _wsOverlapped = 0x00CF0000; // WS_OVERLAPPEDWINDOW, not WS_VISIBLE. +const _wsChild = 0x40000000; + +void main() { + if (!Platform.isWindows) { + test('the lookup answers nothing off Windows', () { + expect(findSoleFlutterHostWindow(), 0); + }); + return; + } + + final user32 = DynamicLibrary.open('user32.dll'); + final kernel32 = DynamicLibrary.open('kernel32.dll'); + final registerClass = user32 + .lookupFunction< + Uint16 Function(Pointer<_WndClass>), + int Function(Pointer<_WndClass>) + >('RegisterClassW'); + final createWindowEx = user32 + .lookupFunction<_CreateWindowExNative, _CreateWindowEx>( + 'CreateWindowExW', + ); + final destroyWindow = user32 + .lookupFunction( + 'DestroyWindow', + ); + final getModuleHandle = kernel32 + .lookupFunction< + IntPtr Function(Pointer), + int Function(Pointer) + >('GetModuleHandleW'); + final defWindowProc = user32.lookup('DefWindowProcW'); + + final instance = getModuleHandle(nullptr); + final viewClass = 'FLUTTERVIEW'.toNativeUtf16(); + final hostClass = 'STATIC'.toNativeUtf16(); + final created = []; + + setUpAll(() { + final wndClass = calloc<_WndClass>(); + wndClass.ref + ..lpfnWndProc = defWindowProc + ..hInstance = instance + ..lpszClassName = viewClass; + registerClass(wndClass); + calloc.free(wndClass); + }); + + tearDown(() { + // Children go with their parents, owned windows with their owners. + for (final hwnd in created.reversed) { + destroyWindow(hwnd); + } + created.clear(); + }); + + tearDownAll(() { + calloc.free(viewClass); + calloc.free(hostClass); + }); + + int topLevel({int owner = 0}) { + final hwnd = createWindowEx( + 0, + hostClass, + nullptr, + _wsOverlapped, + 0, + 0, + 100, + 100, + owner, + 0, + instance, + nullptr, + ); + expect(hwnd, isNot(0)); + created.add(hwnd); + return hwnd; + } + + void hostView(int parent) { + final hwnd = createWindowEx( + 0, + viewClass, + nullptr, + _wsChild, + 0, + 0, + 10, + 10, + parent, + 0, + instance, + nullptr, + ); + expect(hwnd, isNot(0)); + } + + test('a test process has no Flutter host window', () { + expect(findSoleFlutterHostWindow(), 0); + }); + + test('finds the only hidden window hosting a Flutter view', () { + topLevel(); // No Flutter view. + final host = topLevel(); + hostView(host); + expect(findSoleFlutterHostWindow(), host); + }); + + test('skips owned windows', () { + final host = topLevel(); + hostView(host); + final owned = topLevel(owner: host); + hostView(owned); + expect(findSoleFlutterHostWindow(), host); + }); + + test('answers nothing with several candidates', () { + final first = topLevel(); + hostView(first); + final second = topLevel(); + hostView(second); + expect(findSoleFlutterHostWindow(), 0); + }); +} diff --git a/test/legacy_late_window_test.dart b/test/legacy_late_window_test.dart new file mode 100644 index 00000000..e866e0b4 --- /dev/null +++ b/test/legacy_late_window_test.dart @@ -0,0 +1,195 @@ +// Windows looked up after listeners, prevent-close or show() were set up, as +// when the platform reports no current window yet, and bindWindow. +// 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; + FutureOr Function(nativeapi.WindowEvent)? _callback; + + bool get isListening => _callback != null; + + @override + bool get isVisible => visible; + + @override + bool get isMinimized => false; + + @override + bool get isMaximized => false; + + @override + bool get isFullScreen => false; + + @override + void show() => visible = true; + + @override + void showInactive() => visible = true; + + @override + void hide() => visible = 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 _FakeCloseRequest extends nativeapi.EventRequest { + _FakeCloseRequest() : super.borrowed(0); + + bool cancelled = false; + + @override + bool cancel() => cancelled = true; +} + +void main() { + nativeapi.Window? current; + var lookups = 0; + + setUp(() { + current = null; + lookups = 0; + windowManager.debugCurrentWindowOverride = () { + lookups++; + return current; + }; + windowManager.bindWindow(null); + }); + + tearDown(() async { + await windowManager.setPreventClose(false); + windowManager.debugCurrentWindowOverride = null; + windowManager.bindWindow(null); + }); + + test('a window found after the listener is wired then', () async { + final listener = _Recorder(); + windowManager.addListener(listener); + addTearDown(() => windowManager.removeListener(listener)); + await windowManager.setPreventClose(true); + expect(await windowManager.isVisible(), isFalse); + + final fake = _FakeNativeWindow(); + current = fake; + // Any call that addresses the window finds it now. + await windowManager.isMaximized(); + expect(fake.isListening, isTrue); + + final request = _FakeCloseRequest(); + await fake.fire( + nativeapi.WindowCloseRequestedEvent(windowId: 1, request: request), + ); + expect(request.cancelled, isTrue); + expect(listener.events, [kWindowEventClose]); + }); + + test('a missing window is looked up again on every call', () async { + await windowManager.isVisible(); + await windowManager.isVisible(); + expect(lookups, 2); + + current = _FakeNativeWindow(); + await windowManager.isVisible(); + await windowManager.isVisible(); + expect(lookups, 3); + }); + + test('ensureInitialized wires a window found late', () async { + final listener = _Recorder(); + windowManager.addListener(listener); + addTearDown(() => windowManager.removeListener(listener)); + + final fake = _FakeNativeWindow(); + current = fake; + await windowManager.ensureInitialized(); + expect(fake.isListening, isTrue); + }); + + test('show without a window is carried out on the bound one', () async { + await windowManager.show(); + + final fake = _FakeNativeWindow(); + windowManager.bindWindow(fake); + expect(fake.visible, isTrue); + }); + + test('show without a window is carried out on the one found', () async { + await windowManager.show(inactive: true); + + final fake = _FakeNativeWindow(); + current = fake; + await windowManager.isMaximized(); + expect(fake.visible, isTrue); + }); + + test('hide cancels a show that found no window', () async { + await windowManager.show(); + await windowManager.hide(); + + final fake = _FakeNativeWindow(); + current = fake; + await windowManager.isVisible(); + expect(fake.visible, isFalse); + }); + + test('bindWindow moves listeners and prevent-close', () async { + final listener = _Recorder(); + windowManager.addListener(listener); + addTearDown(() => windowManager.removeListener(listener)); + await windowManager.setPreventClose(true); + + final first = _FakeNativeWindow(); + current = first; + await windowManager.ensureInitialized(); + expect(first.isListening, isTrue); + + final second = _FakeNativeWindow(); + windowManager.bindWindow(second); + expect(first.isListening, isFalse); + expect(second.isListening, isTrue); + expect(await windowManager.isPreventClose(), isTrue); + + final request = _FakeCloseRequest(); + await second.fire( + nativeapi.WindowCloseRequestedEvent(windowId: 2, request: request), + ); + expect(request.cancelled, isTrue); + expect(listener.events, [kWindowEventClose]); + + // Back to the current window. + windowManager.bindWindow(null); + expect(second.isListening, isFalse); + expect(first.isListening, isTrue); + }); +}