fix(flutter): make Adjust Window reliable across desktop platforms (#15853)

* fix(flutter): make Adjust Window reliable across desktop platforms

  - Fix incorrect sizing on scaled displays by calculating the target from the
    rendered canvas scale and platform-specific window coordinate units.
  - Fix adjustments using the wrong monitor by querying the current remote
    window's screen, with the main window as fallback.
  - Fix stale geometry after fullscreen or maximized transitions by refreshing
    metrics before calculating and applying the target frame.
  - Fix fullscreen availability checks on Windows and macOS by predicting the
    restored window borders and caching each macOS window's pre-fullscreen work area.
  - Fix incorrect Linux work areas by handling GNOME Wayland fractional scaling
    and caching compositor/X11 work-area measurements when visibleFrame is wrong.
  - Prevent unsafe adjustments by rejecting invalid, oversized, or implausibly
    small target frames.
  - Avoid failures during window teardown by skipping adjustment when the view,
    screen, or native window frame is unavailable.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): harden Adjust Window handling

  - Use the dynamic Linux resize edge when predicting restored window bounds.
  - Treat GNOME fractional-scaling lookup failures as unknown without repeating
    the lookup for the remote window.
  - Stop adjustment safely when native window calls fail during window teardown.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): correct Linux monitor selection

Update window_size to use monitor height for vertical bounds, preventing incorrect screen selection with vertically stacked displays.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* docs(flutter): simplify Linux screen handling comments

Keep the source rationale concise and move platform measurements and investigation details out of the implementation.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): align Adjust Window resize padding

Use the shared drag-to-resize padding for Linux restored-window predictions so menu validation matches the applied frame dimensions.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): remove Adjust Window screen fallback

Return null when the current window screen is unavailable instead of using the main window's scale factor and work area.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(linux): query Mutter monitor layout mode

  Use DisplayConfig.GetCurrentState instead of inferring scaling from
  experimental features, and handle Ubuntu's UI-scaled logical mode.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): use native maximized state for Wayland cache

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): allow Adjust Window to fill work area

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): avoid racing screen info updates

Signed-off-by: 21pages <sunboeasy@gmail.com>

* refactor(flutter): remove dead Adjust Window web plumbing

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): tolerate near-unity Wayland scale factors

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): harden window screen detection

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(linux): drop deprecated GNOME session detection

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(flutter): remove GNOME monitor layout mode flutter cache

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
21pages
2026-08-19 15:08:58 +08:00
committed by GitHub
parent 630b531108
commit 5679670506
11 changed files with 483 additions and 99 deletions

View File

@@ -84,8 +84,6 @@ const double _kPositionEpsilon = 1e-6;
bool get isMainDesktopWindow =>
desktopType == DesktopType.main || desktopType == DesktopType.cm;
String get screenInfo => screenInfo_;
/// Check if the app is running with single view mode.
bool isSingleViewApp() {
return desktopType == DesktopType.cm;

View File

@@ -54,7 +54,6 @@ const String kAppTypeDesktopTerminal = "terminal";
const String kWindowMainWindowOnTop = "main_window_on_top";
const String kWindowRefreshCurrentUser = "refresh_current_user";
const String kWindowGetWindowInfo = "get_window_info";
const String kWindowGetScreenList = "get_screen_list";
// This method is not used, maybe it can be removed.
const String kWindowDisableGrabKeyboard = "disable_grab_keyboard";
@@ -324,10 +323,11 @@ double kNewWindowOffset = isWindows
? 30.0
: 50.0;
const kDragToResizeAreaPaddingSize = 5.0;
EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux
? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value
? EdgeInsets.zero
: EdgeInsets.all(5.0)
: EdgeInsets.all(kDragToResizeAreaPaddingSize)
: EdgeInsets.zero;
// https://en.wikipedia.org/wiki/Non-breaking_space
const int $nbsp = 0x00A0;

View File

@@ -780,13 +780,6 @@ class _DesktopHomePageState extends State<DesktopHomePage>
windowOnTop(null);
} else if (call.method == kWindowRefreshCurrentUser) {
gFFI.userModel.refreshCurrentUser();
} else if (call.method == kWindowGetWindowInfo) {
final screen = (await window_size.getWindowInfo()).screen;
if (screen == null) {
return '';
} else {
return jsonEncode(screenToMap(screen));
}
} else if (call.method == kWindowGetScreenList) {
return jsonEncode(
(await window_size.getScreenList()).map(screenToMap).toList());

View File

@@ -9,7 +9,6 @@ import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
@@ -1334,6 +1333,12 @@ class ScreenAdjustor {
final FFI ffi;
final VoidCallback cbExitFullscreen;
window_size.Screen? _screen;
Size? _waylandMaximizedWorkAreaSize;
Rect? _waylandWorkAreaScreenFrame;
double? _waylandWorkAreaScaleFactor;
Rect? _x11WorkArea;
Rect? _x11WorkAreaScreenFrame;
double? _x11WorkAreaScaleFactor;
ScreenAdjustor({
required this.id,
@@ -1344,9 +1349,18 @@ class ScreenAdjustor {
bool get isFullscreen => stateGlobal.fullscreen.isTrue;
int get windowId => stateGlobal.windowId;
Future<bool?> isWindowMaximized() async {
try {
return await WindowController.fromWindowId(windowId).isMaximized();
} catch (_) {
// The delayed resolution callback may run after the window is disposed.
return null;
}
}
adjustWindow(BuildContext context) {
return futureBuilder(
future: isWindowCanBeAdjusted(),
future: isWindowCanBeAdjusted(context),
hasData: (data) {
final visible = data as bool;
if (!visible) return Offstage();
@@ -1362,36 +1376,201 @@ class ScreenAdjustor {
});
}
doAdjustWindow(BuildContext context) async {
await updateScreen();
if (_screen != null) {
cbExitFullscreen();
double scale = _screen!.scaleFactor;
final wndRect = await WindowController.fromWindowId(windowId).getFrame();
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
// On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect.
// Linux screen and work-area coordinates can use different units or become
// unreliable across Wayland/X11 state changes, so normalize reported frames
// and cache usable work-area measurements before sizing the window.
Future<void> _updateLinuxWorkAreaCache({
required window_size.Screen screen,
required Rect wndRect,
required bool isWayland,
required bool isX11,
required bool forMenu,
}) async {
if (isWayland &&
(_waylandWorkAreaScreenFrame != screen.frame ||
_waylandWorkAreaScaleFactor != screen.scaleFactor)) {
_waylandMaximizedWorkAreaSize = null;
_waylandWorkAreaScreenFrame = screen.frame;
_waylandWorkAreaScaleFactor = screen.scaleFactor;
}
if (isWayland &&
forMenu &&
!isFullscreen &&
await isWindowMaximized() == true) {
_waylandMaximizedWorkAreaSize = wndRect.size;
}
if (isX11 &&
(_x11WorkAreaScreenFrame != screen.frame ||
_x11WorkAreaScaleFactor != screen.scaleFactor)) {
_x11WorkArea = null;
_x11WorkAreaScreenFrame = screen.frame;
_x11WorkAreaScaleFactor = screen.scaleFactor;
}
if (isX11 && forMenu && !isFullscreen) {
_x11WorkArea = screen.visibleFrame;
}
}
Future<Rect?> _getEffectiveScreenFrame({
required window_size.Screen screen,
required bool isWayland,
required bool isX11,
required bool forMenu,
}) async {
Rect frameRect = screen.visibleFrame;
if (isMacOS && forMenu && isFullscreen) {
List<double>? workArea;
try {
workArea = await kMacOSPermChannel
.invokeListMethod<double>('getMacOSWorkAreaSize');
} catch (_) {
return null;
}
if (workArea == null || workArea.length != 2) {
return null;
}
frameRect = Rect.fromLTWH(
frameRect.left,
frameRect.top,
workArea[0] < frameRect.width ? workArea[0] : frameRect.width,
workArea[1] < frameRect.height ? workArea[1] : frameRect.height,
);
}
final x11WorkArea = _x11WorkArea;
if (isX11 &&
forMenu &&
isFullscreen &&
x11WorkArea != null &&
(x11WorkArea.width < frameRect.width ||
x11WorkArea.height < frameRect.height)) {
frameRect = x11WorkArea;
}
final screenScale = screen.scaleFactor;
if (isWayland && screenScale > 1.01) {
String monitorLayoutMode;
try {
monitorLayoutMode =
await bind.mainGetCommon(key: 'gnome-monitor-layout-mode');
} catch (_) {
monitorLayoutMode = '';
}
if (monitorLayoutMode == 'physical') {
frameRect = Rect.fromLTRB(
frameRect.left / screenScale,
frameRect.top / screenScale,
frameRect.right / screenScale,
frameRect.bottom / screenScale,
);
}
}
return frameRect;
}
Future<Rect?> _getAdjustedWindowFrame(Size mediaSize,
{bool forMenu = false}) async {
final screen = _screen;
if (screen != null) {
// Windows window frames use physical pixels while Flutter view sizes are
// logical. macOS and Linux window frames use the same units as Flutter.
double scale = isWindows ? screen.scaleFactor : 1.0;
final Rect wndRect;
try {
wndRect = await WindowController.fromWindowId(windowId).getFrame();
} catch (e) {
debugPrint("Failed to get frame of window $windowId, it may be hidden");
return null;
}
// On Windows, wndRect is GetWindowRect while mediaSize is GetClientRect.
// https://stackoverflow.com/a/7561083
double magicWidth =
wndRect.right - wndRect.left - mediaSize.width * scale;
double magicHeight =
wndRect.bottom - wndRect.top - mediaSize.height * scale;
final canvasModel = ffi.canvasModel;
// canvasModel.scale is the rendered scale and already applies kIgnoreDpi.
// Use it instead of the remote source resolution.
final isWayland = isLinux && bind.mainCurrentIsWayland();
final isX11 = isLinux && !isWayland;
await _updateLinuxWorkAreaCache(
screen: screen,
wndRect: wndRect,
isWayland: isWayland,
isX11: isX11,
forMenu: forMenu,
);
if (isWindows && forMenu && isFullscreen) {
// desktop_multi_window's hidden title bar keeps 8 physical pixels on
// each horizontal edge and at the bottom, plus up to 1px at the top.
// Fullscreen removes these in WM_NCCALCSIZE, so predict the restored
// frame's worst-case padding when deciding whether to show the menu.
magicWidth = 16.0;
magicHeight = 9.0;
}
double horizontalEdges;
double verticalEdges;
if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) {
// Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen
// before resizing. Predict the restored normal-window edges when
// deciding whether to show the menu item.
final resizePadding = isLinux && !kUseCompatibleUiMode
? kDragToResizeAreaPaddingSize
: 0.0;
final windowEdge = kWindowBorderWidth + resizePadding;
horizontalEdges = windowEdge * 2;
verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2;
} else {
horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge;
verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge;
}
final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
CanvasModel.leftToEdge +
CanvasModel.rightToEdge) *
horizontalEdges) *
scale +
magicWidth;
final height = (canvasModel.getDisplayHeight() * canvasModel.scale +
CanvasModel.topToEdge +
CanvasModel.bottomToEdge) *
scale +
magicHeight;
final height =
(canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) *
scale +
magicHeight;
double left = wndRect.left + (wndRect.width - width) / 2;
double top = wndRect.top + (wndRect.height - height) / 2;
Rect frameRect = _screen!.frame;
if (!isFullscreen) {
frameRect = _screen!.visibleFrame;
final frameRect = await _getEffectiveScreenFrame(
screen: screen,
isWayland: isWayland,
isX11: isX11,
forMenu: forMenu,
);
if (frameRect == null) {
return null;
}
var availableSize = frameRect.size;
if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) {
final cachedSize = _waylandMaximizedWorkAreaSize!;
availableSize = Size(
cachedSize.width < availableSize.width
? cachedSize.width
: availableSize.width,
cachedSize.height < availableSize.height
? cachedSize.height
: availableSize.height,
);
}
// A window frame cannot be smaller than its client area. Tolerate small
// floating-point differences; larger negative values mean the native
// frame and Flutter view metrics are not synchronized.
if (magicWidth < -0.1 || magicHeight < -0.1) {
return null;
}
// Reject implausibly small targets to avoid hiding the window.
if (width < 300 || height < 300) {
return null;
}
// The remote size may change after the menu is built. Reject targets
// that exceed the available area.
final exceedsScreen =
width > availableSize.width || height > availableSize.height;
if (exceedsScreen) {
return null;
}
if (left < frameRect.left) {
left = frameRect.left;
@@ -1405,69 +1584,101 @@ class ScreenAdjustor {
if ((top + height) > frameRect.bottom) {
top = frameRect.bottom - height;
}
await WindowController.fromWindowId(windowId)
.setFrame(Rect.fromLTWH(left, top, width, height));
return Rect.fromLTWH(left, top, width, height);
}
return null;
}
doAdjustWindow([BuildContext? context]) async {
// A resolution change is adjusted after a delay, when the menu context may
// already be disposed. Each desktop_multi_window window has its own engine,
// so that engine's first view is the current window.
final views = WidgetsBinding.instance.platformDispatcher.views;
if (context == null && views.isEmpty) {
return;
}
final view = context != null ? View.of(context) : views.first;
await updateScreen();
if (_screen != null) {
final wc = WindowController.fromWindowId(windowId);
final wasFullscreen = isFullscreen;
cbExitFullscreen();
if (wasFullscreen) {
// Wait for the native fullscreen exit to update the window frame.
await Future.delayed(Duration(milliseconds: 700));
await updateScreen();
}
if (isLinux) {
final isMaximized = await isWindowMaximized();
if (isMaximized == null) {
return;
}
if (isMaximized == true) {
// setFrame may be ignored while the native window is maximized.
try {
await wc.unmaximize();
} catch (_) {
return;
}
stateGlobal.setMaximized(false);
// Wait for the window manager and Flutter view metrics to reflect
// the restored window before calculating and setting its frame.
await Future.delayed(Duration(milliseconds: 300));
await updateScreen();
}
}
final mediaSize = MediaQueryData.fromView(view).size;
final frame = await _getAdjustedWindowFrame(mediaSize);
if (frame == null) {
return;
}
try {
await wc.setFrame(frame);
} catch (_) {
return;
}
stateGlobal.setMaximized(false);
}
}
updateScreen() async {
final String info =
isWeb ? screenInfo : await _getScreenInfoDesktop() ?? '';
if (info.isEmpty) {
_screen = null;
} else {
final screenMap = jsonDecode(info);
_screen = window_size.Screen(
Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'],
screenMap['frame']['r'], screenMap['frame']['b']),
Rect.fromLTRB(
screenMap['visibleFrame']['l'],
screenMap['visibleFrame']['t'],
screenMap['visibleFrame']['r'],
screenMap['visibleFrame']['b']),
screenMap['scaleFactor']);
_screen = await _getCurrentScreen();
}
Future<window_size.Screen?> _getCurrentScreen() async {
try {
return (await window_size.getWindowInfo()).screen;
} catch (e) {
debugPrint('Failed to get current window screen: $e');
return null;
}
}
_getScreenInfoDesktop() async {
final v = await rustDeskWinManager.call(
WindowType.Main, kWindowGetWindowInfo, '');
return v.result;
}
Future<bool> isWindowCanBeAdjusted() async {
Future<bool> isWindowCanBeAdjusted([BuildContext? context]) async {
if (isWeb) {
return false;
}
// Capture the view before awaiting because the menu context may be disposed.
final views = WidgetsBinding.instance.platformDispatcher.views;
if (context == null && views.isEmpty) {
return false;
}
final view = context != null ? View.of(context) : views.first;
final mediaSize = MediaQueryData.fromView(view).size;
final viewStyle =
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
if (viewStyle != kRemoteViewStyleOriginal) {
return false;
}
if (!isWeb) {
final remoteCount = RemoteCountState.find().value;
if (remoteCount != 1) {
return false;
}
final remoteCount = RemoteCountState.find().value;
if (remoteCount != 1) {
return false;
}
await updateScreen();
if (_screen == null) {
return false;
}
final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor;
double selfWidth = _screen!.visibleFrame.width;
double selfHeight = _screen!.visibleFrame.height;
if (isFullscreen) {
selfWidth = _screen!.frame.width;
selfHeight = _screen!.frame.height;
}
final canvasModel = ffi.canvasModel;
final displayWidth = canvasModel.getDisplayWidth();
final displayHeight = canvasModel.getDisplayHeight();
final requiredWidth =
CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge;
final requiredHeight =
CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge;
return selfWidth > (requiredWidth * scale) &&
selfHeight > (requiredHeight * scale);
return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null;
}
}
@@ -1518,7 +1729,6 @@ class _DisplayMenuState extends State<_DisplayMenu> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
_screenAdjustor.updateScreen();
menuChildrenGetter(_IconSubmenuButtonState state) {
final menuChildren = <Widget>[
_screenAdjustor.adjustWindow(context),
@@ -2082,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
Future<void> _getLocalResolutionWayland() async {
if (!isWayland) return _getLocalResolution();
final window = await window_size.getWindowInfo();
final screen = window.screen;
if (screen != null) {
setState(() {
_localResolution = Resolution(
screen.frame.width.toInt(),
screen.frame.height.toInt(),
);
});
try {
final window = await window_size.getWindowInfo();
final screen = window.screen;
if (screen != null) {
setState(() {
_localResolution = Resolution(
screen.frame.width.toInt(),
screen.frame.height.toInt(),
);
});
}
} catch (e) {
debugPrint('Failed to get local resolution on Wayland: $e');
}
}
@@ -2162,8 +2376,16 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> {
return;
}
if (w == rect.width.toInt() && h == rect.height.toInt()) {
if (await widget.screenAdjustor.isWindowCanBeAdjusted()) {
widget.screenAdjustor.doAdjustWindow(context);
if (!await widget.screenAdjustor.isWindowCanBeAdjusted()) {
return;
}
if (widget.screenAdjustor.isFullscreen) {
return;
}
if ((await widget.screenAdjustor.isWindowMaximized()) == false) {
// This delayed callback can outlive the menu State, so its context
// is unsafe.
widget.screenAdjustor.doAdjustWindow();
}
}
});

View File

@@ -10,8 +10,6 @@ final isWebDesktop_ = false;
final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
String get screenInfo_ => '';
final isWebOnWindows_ = false;
final isWebOnLinux_ = false;
final isWebOnMacOS_ = false;

View File

@@ -1,5 +1,4 @@
import 'dart:js' as js;
import 'dart:html' as html;
// cycle imports, maybe we can improve this
import 'package:flutter_hbb/consts.dart';
@@ -13,8 +12,6 @@ final isWebDesktop_ = !js.context.callMethod('isMobile');
final isDesktop_ = false;
String get screenInfo_ => js.context.callMethod('getByName', ['screen_info']);
final _localOs = js.context.callMethod('getByName', ['local_os', '']);
final isWebOnWindows_ = _localOs == kPeerPlatformWindows;
final isWebOnLinux_ = _localOs == kPeerPlatformLinux;

View File

@@ -36,8 +36,28 @@ class RelativeMouseState {
}
class MainFlutterWindow: NSWindow {
private static let fullscreenWorkAreaSizes = NSMapTable<NSWindow, NSValue>(
keyOptions: [.weakMemory, .objectPointerPersonality],
valueOptions: .strongMemory
)
private static let fullscreenObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willEnterFullScreenNotification,
object: nil,
queue: .main
) { notification in
guard let window = notification.object as? NSWindow,
let screen = window.screen else {
return
}
fullscreenWorkAreaSizes.setObject(
NSValue(size: screen.visibleFrame.size),
forKey: window
)
}
override func awakeFromNib() {
rustdesk_core_main();
_ = MainFlutterWindow.fullscreenObserver
let flutterViewController = FlutterViewController.init()
let windowFrame = self.frame
self.contentViewController = flutterViewController
@@ -278,6 +298,16 @@ class MainFlutterWindow: NSWindow {
self.disableNativeRelativeMouseMode()
result(true)
case "getMacOSWorkAreaSize":
guard Thread.isMainThread,
let window = registrar.view?.window,
let size = MainFlutterWindow.fullscreenWorkAreaSizes
.object(forKey: window)?.sizeValue else {
result(nil)
break
}
result([Double(size.width), Double(size.height)])
default:
result(FlutterMethodNotImplemented)
}

View File

@@ -1597,9 +1597,9 @@ packages:
dependency: "direct main"
description:
path: "plugins/window_size"
ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601
resolved-ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601
url: "https://github.com/google/flutter-desktop-embedding.git"
ref: "51e67ce047c72b26810b99e8473ddb44612fe356"
resolved-ref: "51e67ce047c72b26810b99e8473ddb44612fe356"
url: "https://github.com/21pages/flutter-desktop-embedding.git"
source: git
version: "0.1.0"
xdg_directories:

View File

@@ -61,9 +61,9 @@ dependencies:
flutter_custom_cursor: ^0.0.4
window_size:
git:
url: https://github.com/google/flutter-desktop-embedding.git
url: https://github.com/21pages/flutter-desktop-embedding.git
path: plugins/window_size
ref: eb3964990cf19629c89ff8cb4a37640c7b3d5601
ref: 51e67ce047c72b26810b99e8473ddb44612fe356
get: ^4.6.5
visibility_detector: ^0.4.0+2
contextmenu: ^3.0.0