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); + }); +}