From 56796705060a9f3297b542988928e4f15f125271 Mon Sep 17 00:00:00 2001 From: 21pages Date: Wed, 19 Aug 2026 15:08:58 +0800 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * fix(flutter): use native maximized state for Wayland cache Signed-off-by: 21pages * fix(flutter): allow Adjust Window to fill work area Signed-off-by: 21pages * fix(flutter): avoid racing screen info updates Signed-off-by: 21pages * refactor(flutter): remove dead Adjust Window web plumbing Signed-off-by: 21pages * fix(flutter): tolerate near-unity Wayland scale factors Signed-off-by: 21pages * fix(flutter): harden window screen detection Signed-off-by: 21pages * fix(linux): drop deprecated GNOME session detection Signed-off-by: 21pages * fix(flutter): remove GNOME monitor layout mode flutter cache Signed-off-by: 21pages --------- Signed-off-by: 21pages --- flutter/lib/common.dart | 2 - flutter/lib/consts.dart | 4 +- .../lib/desktop/pages/desktop_home_page.dart | 7 - .../lib/desktop/widgets/remote_toolbar.dart | 378 ++++++++++++++---- flutter/lib/native/common.dart | 2 - flutter/lib/web/common.dart | 3 - flutter/macos/Runner/MainFlutterWindow.swift | 30 ++ flutter/pubspec.lock | 6 +- flutter/pubspec.yaml | 4 +- src/flutter_ffi.rs | 8 + src/platform/linux.rs | 138 +++++++ 11 files changed, 483 insertions(+), 99 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 94c3c2a72..93c7a4d4b 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -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; diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 092873793..10459e782 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -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; diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 76d464198..6d370cbb0 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -780,13 +780,6 @@ class _DesktopHomePageState extends State 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()); diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 2627627a6..19b3fa985 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -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 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 _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 _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? workArea; + try { + workArea = await kMacOSPermChannel + .invokeListMethod('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 _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 _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 isWindowCanBeAdjusted() async { + Future 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 = [ _screenAdjustor.adjustWindow(context), @@ -2082,15 +2292,19 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { Future _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(); } } }); diff --git a/flutter/lib/native/common.dart b/flutter/lib/native/common.dart index 96d5bd6e8..1e76c70c5 100644 --- a/flutter/lib/native/common.dart +++ b/flutter/lib/native/common.dart @@ -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; diff --git a/flutter/lib/web/common.dart b/flutter/lib/web/common.dart index 4d539d5d4..a552752a8 100644 --- a/flutter/lib/web/common.dart +++ b/flutter/lib/web/common.dart @@ -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; diff --git a/flutter/macos/Runner/MainFlutterWindow.swift b/flutter/macos/Runner/MainFlutterWindow.swift index 1cc72419b..336d94f1d 100644 --- a/flutter/macos/Runner/MainFlutterWindow.swift +++ b/flutter/macos/Runner/MainFlutterWindow.swift @@ -36,8 +36,28 @@ class RelativeMouseState { } class MainFlutterWindow: NSWindow { + private static let fullscreenWorkAreaSizes = NSMapTable( + 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) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index cba9ba5ea..26fd3de72 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -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: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b9f8e1ccb..64c5018f5 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -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 diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f840ed282..4064162ff 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2655,6 +2655,14 @@ pub fn main_get_common(key: String) -> String { return crate::platform::linux::has_gnome_shortcuts_inhibitor_permission().to_string(); #[cfg(not(target_os = "linux"))] return false.to_string(); + } else if key == "gnome-monitor-layout-mode" { + #[cfg(target_os = "linux")] + return match crate::platform::linux::gnome_monitor_layout_mode() { + Some(mode) => mode.as_str().to_owned(), + None => String::new(), + }; + #[cfg(not(target_os = "linux"))] + return String::new(); } else if key == "permanent-password-set" { return ui_interface::is_permanent_password_set().to_string(); } else if key == "local-permanent-password-set" { diff --git a/src/platform/linux.rs b/src/platform/linux.rs index d187b0ec2..4fba6e669 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -96,6 +96,9 @@ lazy_static::lazy_static! { }; static ref ACTIVE_USER_LOOKUP_CACHE: std::sync::Mutex> = std::sync::Mutex::new(None); + static ref GNOME_MONITOR_LAYOUT_MODE_CACHE: std::sync::Mutex< + Option<(Instant, Option)>, + > = Default::default(); // https://github.com/rustdesk/rustdesk/issues/13705 // Check if `sudo -E` actually preserves environment. // @@ -128,6 +131,141 @@ lazy_static::lazy_static! { }; } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GnomeMonitorLayoutMode { + Logical, + Physical, +} + +impl GnomeMonitorLayoutMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Logical => "logical", + Self::Physical => "physical", + } + } +} + +fn gnome_monitor_layout_mode_from_value(value: u32) -> Option { + // Upstream: https://gitlab.gnome.org/GNOME/mutter/-/blob/main/data/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml + // Ubuntu mode 3: https://git.launchpad.net/ubuntu/+source/mutter/tree/debian/patches/x11-Add-support-for-fractional-scaling-using-Randr.patch + match value { + 1 | 3 => Some(GnomeMonitorLayoutMode::Logical), + 2 => Some(GnomeMonitorLayoutMode::Physical), + _ => None, + } +} + +pub fn gnome_monitor_layout_mode() -> Option { + if let Ok(cache) = GNOME_MONITOR_LAYOUT_MODE_CACHE.lock() { + if let Some((updated_at, result)) = *cache { + if updated_at.elapsed() < Duration::from_secs(10) { + return result; + } + } + } + + let result = (|| { + let is_gnome_desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .split(':') + .any(|desktop| { + desktop.eq_ignore_ascii_case("gnome") || desktop.eq_ignore_ascii_case("unity") + }); + let is_gnome_session = std::env::var("DESKTOP_SESSION") + .unwrap_or_default() + .to_ascii_lowercase(); + if !is_gnome_desktop && !is_gnome_session.contains("gnome") { + return None; + } + use dbus::{arg::PropMap, blocking::BlockingSender}; + + let conn = match dbus::blocking::Connection::new_session() { + Ok(conn) => conn, + Err(err) => { + log::warn!("Failed to connect to the session bus for GNOME monitor layout: {err}"); + return None; + } + }; + let message = match dbus::Message::new_method_call( + "org.gnome.Mutter.DisplayConfig", + "/org/gnome/Mutter/DisplayConfig", + "org.gnome.Mutter.DisplayConfig", + "GetCurrentState", + ) { + Ok(message) => message, + Err(err) => { + log::warn!("Failed to create GNOME monitor layout query: {err}"); + return None; + } + }; + let reply = match conn.send_with_reply_and_block(message, Duration::from_secs(2)) { + Ok(reply) => reply, + Err(err) => { + log::warn!("Failed to query GNOME monitor layout: {err}"); + return None; + } + }; + let mut args = reply.iter_init(); + for _ in 0..3 { + if !args.next() { + log::warn!("GNOME monitor layout reply is missing properties"); + return None; + } + } + let properties: PropMap = match args.read() { + Ok(properties) => properties, + Err(err) => { + log::warn!("Failed to read GNOME monitor layout properties: {err}"); + return None; + } + }; + let Some(value) = dbus::arg::prop_cast::(&properties, "layout-mode").copied() else { + log::warn!("GNOME monitor layout reply has no layout-mode"); + return None; + }; + let mode = gnome_monitor_layout_mode_from_value(value); + if mode.is_none() { + log::warn!("GNOME monitor layout reply has unknown layout-mode {value}"); + } + mode + })(); + if let Ok(mut cache) = GNOME_MONITOR_LAYOUT_MODE_CACHE.lock() { + *cache = Some((Instant::now(), result)); + } + result +} + +#[cfg(test)] +mod gnome_monitor_layout_tests { + use super::*; + + #[test] + fn maps_logical_layouts() { + assert_eq!( + gnome_monitor_layout_mode_from_value(1), + Some(GnomeMonitorLayoutMode::Logical) + ); + assert_eq!( + gnome_monitor_layout_mode_from_value(3), + Some(GnomeMonitorLayoutMode::Logical) + ); + } + + #[test] + fn maps_physical_layout() { + assert_eq!( + gnome_monitor_layout_mode_from_value(2), + Some(GnomeMonitorLayoutMode::Physical) + ); + } + + #[test] + fn rejects_unknown_layout() { + assert_eq!(gnome_monitor_layout_mode_from_value(4), None); + } +} + #[inline] fn update_active_user_lookup_cache(desktop: &Desktop) { if let Ok(mut cache) = ACTIVE_USER_LOOKUP_CACHE.lock() {