diff --git a/lib/bootstrap/platform/desktop_platform_wrapper.dart b/lib/bootstrap/platform/desktop_platform_wrapper.dart index 9a84a4b1d..ea7cee1c7 100644 --- a/lib/bootstrap/platform/desktop_platform_wrapper.dart +++ b/lib/bootstrap/platform/desktop_platform_wrapper.dart @@ -25,6 +25,10 @@ class DesktopAppWrapper extends BaseAppWrapper { } class _DesktopAppWrapperState extends BaseAppWrapperState with WindowListener { + bool _windowPlacementInitialized = false; + bool _windowIsMaximized = false; + bool _windowIsFullScreen = false; + @override Future platformInit() async { if (defaultTargetPlatform == TargetPlatform.windows) { @@ -37,17 +41,33 @@ class _DesktopAppWrapperState extends BaseAppWrapperState wit ApplicationMenu.setUp(ApplicationMenuImp()); await WindowManager.instance.ensureInitialized(); + _windowPlacementInitialized = defaultTargetPlatform != TargetPlatform.windows; + if (_windowPlacementInitialized) { + _windowIsMaximized = await windowManager.isMaximized(); + _windowIsFullScreen = await windowManager.isFullScreen(); + } windowManager.addListener(this); final packageInfo = await PackageInfo.fromPlatform(); final clientSettings = ref.read(clientSettingsProvider); final startupArguments = ref.read(argumentsStateProvider); - await windowManager.setupFladderWindowChrome( - startupArguments, - clientSettings, - packageInfo, - ); - await toggleMacTrafficLights(await windowManager.isFullScreen()); + await windowManager.setupFladderWindowChrome(startupArguments, clientSettings, packageInfo); + if (defaultTargetPlatform == TargetPlatform.windows) { + unawaited(_enableWindowPlacementPersistenceAfterStartup()); + } + if (defaultTargetPlatform == TargetPlatform.macOS) { + await toggleMacTrafficLights(await windowManager.isFullScreen()); + } + } + + Future _enableWindowPlacementPersistenceAfterStartup() async { + await Future.delayed(windowsWindowPlacementPersistenceDelay); + if (!mounted) return; + + _windowIsMaximized = await windowManager.isMaximized(); + _windowIsFullScreen = await windowManager.isFullScreen(); + if (!mounted) return; + _windowPlacementInitialized = true; } @override @@ -63,36 +83,65 @@ class _DesktopAppWrapperState extends BaseAppWrapperState wit super.onWindowClose(); } - @override - void onWindowResize() async { + bool get _canPersistWindowBounds => shouldPersistWindowBounds( + startupSettled: _windowPlacementInitialized, + isFullScreen: _windowIsFullScreen, + isMaximized: _windowIsMaximized, + ); + + Future _persistWindowSize() async { + if (!_canPersistWindowBounds) return; final size = await windowManager.getSize(); + if (!_canPersistWindowBounds) return; ref.read(clientSettingsProvider.notifier).setWindowSize(size); + } + + Future _persistWindowPosition() async { + if (!_canPersistWindowBounds) return; + final position = await windowManager.getPosition(); + if (!_canPersistWindowBounds) return; + ref.read(clientSettingsProvider.notifier).setWindowPosition(position); + } + + @override + void onWindowResize() { + unawaited(_persistWindowSize()); super.onWindowResize(); } @override - void onWindowResized() async { - final size = await windowManager.getSize(); - ref.read(clientSettingsProvider.notifier).setWindowSize(size); + void onWindowResized() { + unawaited(_persistWindowSize()); super.onWindowResized(); } @override - void onWindowMove() async { - final position = await windowManager.getPosition(); - ref.read(clientSettingsProvider.notifier).setWindowPosition(position); + void onWindowMove() { + unawaited(_persistWindowPosition()); super.onWindowMove(); } @override - void onWindowMoved() async { - final position = await windowManager.getPosition(); - ref.read(clientSettingsProvider.notifier).setWindowPosition(position); + void onWindowMoved() { + unawaited(_persistWindowPosition()); super.onWindowMoved(); } + @override + void onWindowMaximize() { + _windowIsMaximized = true; + super.onWindowMaximize(); + } + + @override + void onWindowUnmaximize() { + _windowIsMaximized = false; + super.onWindowUnmaximize(); + } + @override void onWindowEnterFullScreen() { + _windowIsFullScreen = true; ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(fullScreen: true)); unawaited(toggleMacTrafficLights(true)); super.onWindowEnterFullScreen(); @@ -100,6 +149,7 @@ class _DesktopAppWrapperState extends BaseAppWrapperState wit @override void onWindowLeaveFullScreen() { + _windowIsFullScreen = false; unawaited(toggleMacTrafficLights(false)); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(fullScreen: false)); super.onWindowLeaveFullScreen(); diff --git a/lib/util/window_helper.dart b/lib/util/window_helper.dart index 3eb33c5d2..1ab0ce95d 100644 --- a/lib/util/window_helper.dart +++ b/lib/util/window_helper.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -8,12 +10,80 @@ import 'package:fladder/models/settings/arguments_model.dart'; import 'package:fladder/models/settings/client_settings_model.dart'; import 'package:fladder/util/string_extensions.dart'; +const windowsNativeStartupBounds = Rect.fromLTWH(10, 10, 1280, 720); +const windowsExternalPlacementSettleDelay = Duration(milliseconds: 750); +const windowsWindowPlacementPersistenceDelay = Duration(milliseconds: 2500); + +@visibleForTesting +bool hasExternalWindowsPlacement(Rect bounds, {double tolerance = 2}) => + (bounds.left - windowsNativeStartupBounds.left).abs() > tolerance || + (bounds.top - windowsNativeStartupBounds.top).abs() > tolerance || + (bounds.width - windowsNativeStartupBounds.width).abs() > tolerance || + (bounds.height - windowsNativeStartupBounds.height).abs() > tolerance; + +@visibleForTesting +bool shouldRestoreStoredWindowsBounds({ + required Rect currentBounds, + required bool isFullScreen, + required bool isMaximized, +}) => + !isFullScreen && !isMaximized && !hasExternalWindowsPlacement(currentBounds); + +bool shouldPersistWindowBounds({required bool startupSettled, required bool isFullScreen, required bool isMaximized}) => + startupSettled && !isFullScreen && !isMaximized; + extension WindowHelperSetup on WindowManager { + Future _applyWindowsWindowStateAfterSettle({ + required ArgumentsModel startupArguments, + required ClientSettingsModel clientSettings, + required PackageInfo packageInfo, + }) async { + await Future.delayed(windowsExternalPlacementSettleDelay); + + final isCurrentlyFullScreen = await windowManager.isFullScreen(); + final isCurrentlyMaximized = await windowManager.isMaximized(); + final currentBounds = await windowManager.getBounds(); + final shouldRestoreBounds = shouldRestoreStoredWindowsBounds( + currentBounds: currentBounds, + isFullScreen: isCurrentlyFullScreen, + isMaximized: isCurrentlyMaximized, + ); + + // These calls are deliberately deferred until Flutter's first frame and + // external window placement have both had time to complete. A new Windows + // window is taskbar-visible by default, so setSkipTaskbar(false) is not + // needed here. + await windowManager.setBackgroundColor(Colors.transparent); + await windowManager.setTitleBarStyle(TitleBarStyle.hidden); + await windowManager.setTitle(packageInfo.appName.capitalize()); + + if (startupArguments.htpcMode && !isCurrentlyFullScreen) { + await windowManager.setFullScreen(true); + return; + } + + if (shouldRestoreBounds) { + await windowManager.setSize(Size(clientSettings.size.x, clientSettings.size.y)); + await windowManager.center(); + } + } + Future setupFladderWindowChrome( ArgumentsModel startupArguments, ClientSettingsModel clientSettings, PackageInfo packageInfo, ) async { + if (defaultTargetPlatform == TargetPlatform.windows) { + unawaited( + _applyWindowsWindowStateAfterSettle( + startupArguments: startupArguments, + clientSettings: clientSettings, + packageInfo: packageInfo, + ), + ); + return; + } + final isFullScreen = await windowManager.isFullScreen(); final isMacDebug = defaultTargetPlatform == TargetPlatform.macOS && kDebugMode; final shouldResizeAndShow = !isMacDebug || !isFullScreen; diff --git a/test/window_helper_test.dart b/test/window_helper_test.dart new file mode 100644 index 000000000..4202ca1bf --- /dev/null +++ b/test/window_helper_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fladder/util/window_helper.dart'; + +void main() { + test('native startup bounds are not treated as external placement', () { + expect(hasExternalWindowsPlacement(windowsNativeStartupBounds), isFalse); + }); + + test('detects placement by an external window manager', () { + expect(hasExternalWindowsPlacement(const Rect.fromLTWH(-7, 0, 2574, 1393)), isTrue); + }); + + test('minor native rounding differences are tolerated', () { + expect(hasExternalWindowsPlacement(const Rect.fromLTWH(10, 10, 1281, 721)), isFalse); + }); + + test('stored bounds restore only while the native startup placement remains', () { + expect( + shouldRestoreStoredWindowsBounds( + currentBounds: windowsNativeStartupBounds, + isFullScreen: false, + isMaximized: false, + ), + isTrue, + ); + expect( + shouldRestoreStoredWindowsBounds( + currentBounds: const Rect.fromLTWH(-7, 0, 2574, 1393), + isFullScreen: false, + isMaximized: false, + ), + isFalse, + ); + expect( + shouldRestoreStoredWindowsBounds( + currentBounds: windowsNativeStartupBounds, + isFullScreen: false, + isMaximized: true, + ), + isFalse, + ); + expect( + shouldRestoreStoredWindowsBounds( + currentBounds: windowsNativeStartupBounds, + isFullScreen: true, + isMaximized: false, + ), + isFalse, + ); + }); + + test('window bounds persist only after startup and outside maximized modes', () { + expect(shouldPersistWindowBounds(startupSettled: false, isFullScreen: false, isMaximized: false), isFalse); + expect(shouldPersistWindowBounds(startupSettled: true, isFullScreen: false, isMaximized: false), isTrue); + expect(shouldPersistWindowBounds(startupSettled: true, isFullScreen: false, isMaximized: true), isFalse); + expect(shouldPersistWindowBounds(startupSettled: true, isFullScreen: true, isMaximized: false), isFalse); + }); +} diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index bf32e8a19..1f7129958 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -35,8 +35,18 @@ bool FlutterWindow::OnCreate() { }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); + flutter_controller_->engine()->SetNextFrameCallback([this]() { + const HWND window = GetHandle(); + const HWND flutter_view = + flutter_controller_->view()->GetNativeWindow(); + + // An external window manager can reveal or resize the parent while + // Flutter is attaching its child view. Ensure the rendered child remains + // visible without resetting placement on an already-visible parent. + ::ShowWindow(flutter_view, SW_SHOW); + if (window != nullptr && !::IsWindowVisible(window)) { + this->Show(); + } }); // Flutter can complete the first frame before the "show window" callback is @@ -64,6 +74,16 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); + if (message == WM_SIZE) { + // Plugins may report WM_SIZE as handled before Win32Window can resize + // the hosted FLUTTERVIEW. Always forward this message to the base host. + const LRESULT resize_result = + Win32Window::MessageHandler(hwnd, message, wparam, lparam); + if (wparam != SIZE_MINIMIZED) { + flutter_controller_->ForceRedraw(); + } + return result.value_or(resize_result); + } if (result) { return *result; } diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index 60608d0fe..a3849c7f3 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -246,6 +246,9 @@ void Win32Window::SetChildContent(HWND content) { MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); + // Keep the hosted Flutter surface visible even if an external window + // manager shows or resizes the parent during first-frame attachment. + ShowWindow(content, SW_SHOW); SetFocus(child_content_); }