From bdb38c4730f699ae1731533c3293af28e29b5e66 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 14 Jul 2026 15:35:52 +0800 Subject: [PATCH 01/21] fix: check valid id (#15535) Signed-off-by: fufesou --- src/common.rs | 37 +++++++++++++++++++++++++++++++++++++ src/lan.rs | 8 ++++++++ src/platform/windows.rs | 4 ++++ 3 files changed, 49 insertions(+) diff --git a/src/common.rs b/src/common.rs index b875d548c..76aac3874 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2623,6 +2623,20 @@ pub fn is_direct_ip_access(peer: &str) -> bool { hbb_common::is_ip_str(peer) || hbb_common::is_domain_port_str(peer) } +// Align the maximum length of the peer id to the maximum length of the peer id in the server. +const MAX_UNTRUSTED_PEER_ID_LEN: usize = 253; +const UNTRUSTED_PEER_ID_FORBIDDEN_CHARS: &[char] = &['"', '<', '>', '/', '\\', '|', '?', '*']; + +// Shared validation for peer/connect ids that cross untrusted boundaries before +// they are stored or written into command/script contexts. +pub fn is_valid_untrusted_peer_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= MAX_UNTRUSTED_PEER_ID_LEN + && !id.chars().any(|ch| { + ch.is_control() || ch.is_whitespace() || UNTRUSTED_PEER_ID_FORBIDDEN_CHARS.contains(&ch) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -2653,6 +2667,29 @@ mod tests { ) } + #[test] + fn untrusted_peer_id_validation() { + let cases = [ + ("123456789", true), + ("m\u{00FC}nchen-pc", true), + ("192.168.1.10:21118", true), + ("9123456234@public", true), + ( + r#"1" & oWS.Run("cmd.exe /k whoami /priv",1,False) & ""#, + false, + ), + ("", false), + ("peer id", false), + ("peer\nid", false), + ("peer/id", false), + ("peer?id", false), + ]; + + for (id, expected) in cases { + assert_eq!(is_valid_untrusted_peer_id(id), expected, "{id:?}"); + } + } + // ThrottledInterval tick at the same time as tokio interval, if no sleeps #[allow(non_snake_case)] #[tokio::test] diff --git a/src/lan.rs b/src/lan.rs index 38c31adf9..2a648aab0 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -241,6 +241,14 @@ fn wait_response( Some(rendezvous_message::Union::PeerDiscovery(p)) => { last_recv_time = Instant::now(); if p.cmd == "pong" { + if !crate::common::is_valid_untrusted_peer_id(&p.id) { + log::warn!( + "Ignoring LAN discovery response from {} with invalid peer id", + addr + ); + continue; + } + let local_mac = if try_get_ip_by_peer { if let Some(self_addr) = get_ipaddr_by_peer(&addr) { get_mac(&self_addr) diff --git a/src/platform/windows.rs b/src/platform/windows.rs index b6b5b39d9..161365cdc 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -2278,6 +2278,10 @@ fn get_shortcut_icon_location(install_dir: &str, exe: &str) -> String { } pub fn create_shortcut(id: &str) -> ResultType<()> { + if !crate::common::is_valid_untrusted_peer_id(id) { + bail!("Invalid peer id for shortcut"); + } + let exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned(); // https://github.com/rustdesk/rustdesk/issues/13735 // Replace ':' with '_' for filename since ':' is not allowed in Windows filenames From cf2b28faf934fedb498c33b9746cd289426d8645 Mon Sep 17 00:00:00 2001 From: jinqiang zhang Date: Wed, 15 Jul 2026 13:27:15 +0800 Subject: [PATCH 02/21] fix linux llvm22 build (#15565) bindgen-0.65 is incompatible with llvm 22, we should upgrade to a newer bindgen version error message: ``` error[E0609]: no field `g_w` on type `vpx_codec_enc_cfg` --> libs/scrap/src/common/vpxcodec.rs:66:19 | 66 | c.g_w = config.width; | ^^^ unknown field | = note: available field is: `_address` ``` --- Cargo.lock | 32 ++++++++++++++++++++++++++------ libs/scrap/Cargo.toml | 2 +- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57177174d..23cf35cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,26 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2 1.0.93", + "quote 1.0.36", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.98", +] + [[package]] name = "bit_field" version = "0.10.2" @@ -2329,7 +2349,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.4", ] [[package]] @@ -2694,7 +2714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4494,7 +4514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d" dependencies = [ "cfg-if 1.0.0", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -7434,7 +7454,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7491,7 +7511,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7578,7 +7598,7 @@ name = "scrap" version = "0.5.0" dependencies = [ "android_logger", - "bindgen 0.65.1", + "bindgen 0.72.1", "block", "cfg-if 1.0.0", "dbus", diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 505eca2de..0af7dfe0f 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -48,7 +48,7 @@ quest = "0.3" [build-dependencies] target_build_utils = "0.3" -bindgen = "0.65" +bindgen = "0.72.1" pkg-config = { version = "0.3.27", optional = true } [target.'cfg(target_os = "linux")'.dependencies] From 5abf4e9724dc9a8c9b45dfe13e71af11616eb2c8 Mon Sep 17 00:00:00 2001 From: 21pages Date: Thu, 16 Jul 2026 16:00:38 +0800 Subject: [PATCH 03/21] Fix disabled installation bypass (#15598) * Fix disabled installation bypass Prevent install.exe and --install from opening the install flow when disable-installation is set. Signed-off-by: 21pages * Refine disabled installation handling for portable clients Document why --install must be filtered from both Rust and Flutter runner arguments for portable wrappers such as no-install.exe. Remove redundant UI- layer installation checks because the install entry points are already gated upstream. --------- Signed-off-by: 21pages --- flutter/windows/runner/main.cpp | 18 +++++++++++++++++- src/common.rs | 2 +- src/core_main.rs | 7 +++++++ src/flutter.rs | 6 ++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/flutter/windows/runner/main.cpp b/flutter/windows/runner/main.cpp index cd9f386b1..ea9152ec7 100644 --- a/flutter/windows/runner/main.cpp +++ b/flutter/windows/runner/main.cpp @@ -14,6 +14,7 @@ typedef char** (*FUNC_RUSTDESK_CORE_MAIN)(int*); typedef void (*FUNC_RUSTDESK_FREE_ARGS)( char**, int); typedef int (*FUNC_RUSTDESK_GET_APP_NAME)(wchar_t*, int); +typedef int (*FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)(); /// Note: `--server`, `--service` are already handled in [core_main.rs]. const std::vector parameters_white_list = {"--install", "--cm"}; @@ -62,6 +63,22 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, } std::vector rust_args(c_args, c_args + args_len); free_c_args(c_args, args_len); + FUNC_RUSTDESK_IS_DISABLE_INSTALLATION rustdesk_is_disable_installation = + (FUNC_RUSTDESK_IS_DISABLE_INSTALLATION)GetProcAddress(hInstance, "rustdesk_is_disable_installation"); + bool is_disable_installation = + rustdesk_is_disable_installation && rustdesk_is_disable_installation() != 0; + const auto installParam = std::string("--install"); + // Flutter reads the original process command line, not only rust_args, so + // remove the `--install` injected by the portable wrapper here as well. This + // also lets `no-install.exe` continue as a portable app when installation is + // disabled. See: https://github.com/rustdesk/rustdesk-server-pro/issues/991#issuecomment-4978376890 + if (is_disable_installation) { + command_line_arguments.erase( + std::remove(command_line_arguments.begin(), + command_line_arguments.end(), + installParam), + command_line_arguments.end()); + } std::wstring app_name = L"RustDesk"; FUNC_RUSTDESK_GET_APP_NAME get_rustdesk_app_name = (FUNC_RUSTDESK_GET_APP_NAME)GetProcAddress(hInstance, "get_rustdesk_app_name"); @@ -118,7 +135,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, is_cm_page = true; } bool is_install_page = false; - auto installParam = std::string("--install"); if (!command_line_arguments.empty() && command_line_arguments.front().compare(0, installParam.size(), installParam.c_str()) == 0) { is_install_page = true; } diff --git a/src/common.rs b/src/common.rs index 76aac3874..cd35433e0 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1024,7 +1024,7 @@ pub fn get_full_name() -> String { } pub fn is_setup(name: &str) -> bool { - name.to_lowercase().ends_with("install.exe") + !config::is_disable_installation() && name.to_lowercase().ends_with("install.exe") } pub fn get_custom_rendezvous_server(custom: String) -> String { diff --git a/src/core_main.rs b/src/core_main.rs index 6b437a988..3f2f0d246 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -127,6 +127,13 @@ pub fn core_main() -> Option> { if args.contains(&"--noinstall".to_string()) { args.clear(); } + // The portable wrapper injects `--install` when its name ends with `install.exe`, + // including `no-install.exe`. Drop the argument instead of exiting so disabled + // clients can continue running as portable applications. + if config::is_disable_installation() { + args.retain(|arg| arg != "--install"); + flutter_args.retain(|arg| arg != "--install"); + } if args.len() > 0 { if args[0] == "--version" { println!("{}", crate::VERSION); diff --git a/src/flutter.rs b/src/flutter.rs index e6b325cbe..a07d7c598 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -136,6 +136,12 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c return std::ptr::null_mut() as _; } +#[cfg(windows)] +#[no_mangle] +pub extern "C" fn rustdesk_is_disable_installation() -> c_int { + hbb_common::config::is_disable_installation() as c_int +} + // https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6 #[cfg(windows)] fn rust_args_to_c_args(args: Vec, outlen: *mut c_int) -> *mut *mut c_char { From 96e2a330b86f1b2ffd31a70a9bb9565cace0d68f Mon Sep 17 00:00:00 2001 From: 21pages Date: Fri, 17 Jul 2026 15:52:49 +0800 Subject: [PATCH 04/21] restrict switch sides to remote desktop sessions (#15610) * fix: restrict switch sides to remote desktop sessions Reject switch sides requests outside authenticated remote desktop sessions, and reject switch sides responses that try to carry non-remote login types. Add scope coverage so file transfer, terminal, view camera, and port forward sessions cannot use switch sides. Signed-off-by: 21pages * fix review: consume switch sides UUID before rejecting response Signed-off-by: 21pages --------- Signed-off-by: 21pages --- src/server/connection.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/server/connection.rs b/src/server/connection.rs index bdf9cba8b..409d0e9fd 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2807,7 +2807,6 @@ impl Connection { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] if let Some(lr) = _s.lr.clone().take() { - self.handle_login_request_without_validation(&lr).await; SWITCH_SIDES_UUID .lock() .unwrap() @@ -2816,6 +2815,15 @@ impl Connection { if let Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) { if let Some((_instant, uuid_old)) = uuid_old { if uuid == uuid_old { + if lr.union.is_some() { + log::warn!( + "Rejected switch sides response for non-remote-desktop session; closing connection" + ); + self.send_login_error("Connection not allowed").await; + return false; + } + self.reset_session_scope_for_login(); + self.handle_login_request_without_validation(&lr).await; self.from_switch = true; self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides); if !self.send_logon_response_and_keep_alive().await { @@ -5656,6 +5664,7 @@ impl Connection { Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution", Some(misc::Union::MessageQuery(_)) => "misc.message_query", Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display", + Some(misc::Union::SwitchSidesRequest(_)) => "misc.switch_sides_request", Some(_) => "misc.other", None => "misc.empty", } @@ -6773,6 +6782,10 @@ mod test { misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())), Some("misc.capture_displays"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), (msg(|m| m.set_clipboard(Clipboard::new())), None), ( msg(|m| m.set_multi_clipboards(MultiClipboards::new())), @@ -6817,6 +6830,10 @@ mod test { misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())), Some("misc.toggle_privacy_mode"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), (misc_msg(|m| m.set_chat_message(ChatMessage::new())), None), (msg(|m| m.set_clipboard(Clipboard::new())), None), ( @@ -6902,6 +6919,10 @@ mod test { msg(|m| m.set_terminal_action(TerminalAction::new())), Some("terminal_action"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), ], ), ( @@ -6912,6 +6933,10 @@ mod test { None, ), (msg(|m| m.set_terminal_action(TerminalAction::new())), None), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + None, + ), ], ), ( @@ -6931,6 +6956,10 @@ mod test { msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), Some("screenshot_request"), ), + ( + misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())), + Some("misc.switch_sides_request"), + ), (misc_msg(|m| m.set_refresh_video(true)), None), (misc_msg(|m| m.set_refresh_video_display(0)), None), ( From 61f09449909241ad9b1ffa7d7c3ef47f4081a669 Mon Sep 17 00:00:00 2001 From: 21pages Date: Sat, 18 Jul 2026 16:32:58 +0800 Subject: [PATCH 05/21] Fix Adjust Window sizing across DPI (#15592) * Fix Adjust Window sizing across DPI and fullscreen transitions Signed-off-by: 21pages * Use visible screen frame for Adjust Window Signed-off-by: 21pages * Tolerate floating-point errors in Adjust Window sizing Signed-off-by: 21pages * Fix review, fix Adjust Window async metric guards Capture the current screen before awaiting window geometry so one target-frame calculation uses consistent screen metrics. Return early when adjusting without a context and the Flutter view list is empty, instead of calling views.first after the window or engine may have been torn down. Clarify the platform coordinate units used for Adjust Window scaling. * Fix Adjust Window for maximized Linux windows Unmaximize Linux remote windows before applying Adjust Window because native setFrame may be ignored while the window is maximized. * Fix Adjust Window screen refresh guards Refresh screen metrics before checking Adjust Window availability and again after exiting fullscreen so target-frame calculation uses current window geometry. Hide Adjust Window on web because resizing relies on desktop window APIs. * Fix review, handle missing window frame in Adjust Window Return null when WindowController.getFrame fails so Adjust Window availability checks and resize attempts skip cleanly if the window is hidden or disposed. --------- Signed-off-by: 21pages --- .../lib/desktop/widgets/remote_toolbar.dart | 138 +++++++++++++----- 1 file changed, 105 insertions(+), 33 deletions(-) diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 75fdbe1f8..6a4982daf 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1348,7 +1348,7 @@ class ScreenAdjustor { adjustWindow(BuildContext context) { return futureBuilder( - future: isWindowCanBeAdjusted(), + future: isWindowCanBeAdjusted(context), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); @@ -1364,20 +1364,29 @@ 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. + Future _getAdjustedWindowFrame(Size mediaSize) 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 width = (canvasModel.getDisplayWidth() * canvasModel.scale + CanvasModel.leftToEdge + CanvasModel.rightToEdge) * @@ -1391,9 +1400,36 @@ class ScreenAdjustor { 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; + // Adjust Window exits fullscreen before setting the window frame. + Rect frameRect = screen.visibleFrame; + if (isLinux && bind.mainCurrentIsWayland()) { + // In testing, Wayland at 200% reported an unscaled screen frame while + // GTK window sizes used logical units, so convert the frame first. + double screenScale = screen.scaleFactor; + if (screenScale > 1) { + frameRect = Rect.fromLTRB( + frameRect.left / screenScale, + frameRect.top / screenScale, + frameRect.right / screenScale, + frameRect.bottom / screenScale, + ); + } + } + // 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; + } + // Transient fullscreen metrics once produced a calculated 4.0x60.0 + // target frame. 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. Require the target + // frame to be strictly smaller than the available screen area. + if (width >= frameRect.width || height >= frameRect.height) { + return null; } if (left < frameRect.left) { left = frameRect.left; @@ -1407,8 +1443,45 @@ 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) { + if (await wc.isMaximized()) { + // setFrame may be ignored while the native window is maximized. + await wc.unmaximize(); + stateGlobal.setMaximized(false); + } + } + final mediaSize = MediaQueryData.fromView(view).size; + final frame = await _getAdjustedWindowFrame(mediaSize); + if (frame == null) { + return; + } + await wc.setFrame(frame); stateGlobal.setMaximized(false); } } @@ -1438,7 +1511,20 @@ class ScreenAdjustor { 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; + await updateScreen(); final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { @@ -1453,23 +1539,7 @@ class ScreenAdjustor { 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) != null; } } @@ -2177,7 +2247,9 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { } if (w == rect.width.toInt() && h == rect.height.toInt()) { if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { - widget.screenAdjustor.doAdjustWindow(context); + // This delayed callback can outlive the menu State, so its context + // is unsafe. + widget.screenAdjustor.doAdjustWindow(); } } }); From 082a5a2a4ee80df65665b2271af460635edad884 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:37:30 +0800 Subject: [PATCH 06/21] Revert "Fix Adjust Window sizing across DPI (#15592)" (#15620) This reverts commit 61f09449909241ad9b1ffa7d7c3ef47f4081a669. --- .../lib/desktop/widgets/remote_toolbar.dart | 138 +++++------------- 1 file changed, 33 insertions(+), 105 deletions(-) diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 6a4982daf..75fdbe1f8 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -1348,7 +1348,7 @@ class ScreenAdjustor { adjustWindow(BuildContext context) { return futureBuilder( - future: isWindowCanBeAdjusted(context), + future: isWindowCanBeAdjusted(), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); @@ -1364,29 +1364,20 @@ class ScreenAdjustor { }); } - Future _getAdjustedWindowFrame(Size mediaSize) 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. + 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. // 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 width = (canvasModel.getDisplayWidth() * canvasModel.scale + CanvasModel.leftToEdge + CanvasModel.rightToEdge) * @@ -1400,36 +1391,9 @@ class ScreenAdjustor { double left = wndRect.left + (wndRect.width - width) / 2; double top = wndRect.top + (wndRect.height - height) / 2; - // Adjust Window exits fullscreen before setting the window frame. - Rect frameRect = screen.visibleFrame; - if (isLinux && bind.mainCurrentIsWayland()) { - // In testing, Wayland at 200% reported an unscaled screen frame while - // GTK window sizes used logical units, so convert the frame first. - double screenScale = screen.scaleFactor; - if (screenScale > 1) { - frameRect = Rect.fromLTRB( - frameRect.left / screenScale, - frameRect.top / screenScale, - frameRect.right / screenScale, - frameRect.bottom / screenScale, - ); - } - } - // 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; - } - // Transient fullscreen metrics once produced a calculated 4.0x60.0 - // target frame. 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. Require the target - // frame to be strictly smaller than the available screen area. - if (width >= frameRect.width || height >= frameRect.height) { - return null; + Rect frameRect = _screen!.frame; + if (!isFullscreen) { + frameRect = _screen!.visibleFrame; } if (left < frameRect.left) { left = frameRect.left; @@ -1443,45 +1407,8 @@ class ScreenAdjustor { if ((top + height) > frameRect.bottom) { top = frameRect.bottom - 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) { - if (await wc.isMaximized()) { - // setFrame may be ignored while the native window is maximized. - await wc.unmaximize(); - stateGlobal.setMaximized(false); - } - } - final mediaSize = MediaQueryData.fromView(view).size; - final frame = await _getAdjustedWindowFrame(mediaSize); - if (frame == null) { - return; - } - await wc.setFrame(frame); + await WindowController.fromWindowId(windowId) + .setFrame(Rect.fromLTWH(left, top, width, height)); stateGlobal.setMaximized(false); } } @@ -1511,20 +1438,7 @@ class ScreenAdjustor { return v.result; } - 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; - await updateScreen(); + Future isWindowCanBeAdjusted() async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { @@ -1539,7 +1453,23 @@ class ScreenAdjustor { if (_screen == null) { return false; } - return await _getAdjustedWindowFrame(mediaSize) != null; + 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); } } @@ -2247,9 +2177,7 @@ class _ResolutionsMenuState extends State<_ResolutionsMenu> { } if (w == rect.width.toInt() && h == rect.height.toInt()) { if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { - // This delayed callback can outlive the menu State, so its context - // is unsafe. - widget.screenAdjustor.doAdjustWindow(); + widget.screenAdjustor.doAdjustWindow(context); } } }); From 5f015c9da13cb227a414c6d295a5c81e5360eccb Mon Sep 17 00:00:00 2001 From: cui fliter Date: Sat, 18 Jul 2026 17:56:27 +0800 Subject: [PATCH 07/21] Translate Continue into Simplified Chinese (#15621) Signed-off-by: cuishuang --- src/lang/cn.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/cn.rs b/src/lang/cn.rs index aac57d011..b030f086d 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "中继连接"), ("Secure Connection", "安全连接"), ("Insecure Connection", "非安全连接"), - ("Continue", ""), + ("Continue", "继续"), ("Scale original", "原始尺寸"), ("Scale adaptive", "适应窗口"), ("General", "常规"), From c01300be201525afd73d6ac3fcf19a2d8b74a65d Mon Sep 17 00:00:00 2001 From: CHarris Date: Sun, 19 Jul 2026 22:22:46 -0400 Subject: [PATCH 08/21] fix(ipc): never adopt an empty id from the main IPC (#15626) --- src/ipc.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/ipc.rs b/src/ipc.rs index 68c987f4e..01f4cda6e 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1689,19 +1689,24 @@ pub fn clear_trusted_devices() { } pub fn get_id() -> String { + // An empty id may come from a process that took over the main IPC with a + // config scope that has no id yet (e.g. a user GUI that became the server + // while the installed service was restarting). Treat it as no answer, + // otherwise the empty id is adopted below and wipes the local one. if let Ok(Some(v)) = get_config("id") { - // update salt also, so that next time reinstallation not causing first-time auto-login failure - if let Ok(Some(v2)) = get_config("salt") { - Config::set_salt(&v2); + if !v.is_empty() { + // update salt also, so that next time reinstallation not causing first-time auto-login failure + if let Ok(Some(v2)) = get_config("salt") { + Config::set_salt(&v2); + } + if v != Config::get_id() { + Config::set_key_confirmed(false); + Config::set_id(&v); + } + return v; } - if v != Config::get_id() { - Config::set_key_confirmed(false); - Config::set_id(&v); - } - v - } else { - Config::get_id() } + Config::get_id() } pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec) { From 20ab5ab0ad78a6ec6b92375f4cc483415ad75ff9 Mon Sep 17 00:00:00 2001 From: CHarris Date: Sun, 19 Jul 2026 22:58:28 -0400 Subject: [PATCH 09/21] fix(deploy): don't wipe local id when --deploy gets an empty --id (#15633) `rustdesk --deploy --id ""` (e.g. an unset variable in a deployment script) deploys a blank id, then wipes the local id and unconfirms the key through the IPC config write. The Android deploy flow already guards an empty id (#15146); apply the same guard to the CLI, and reject an empty id at the IPC write boundary the same way the read path was fixed in #15626. --- src/core_main.rs | 3 ++- src/ipc.rs | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/core_main.rs b/src/core_main.rs index 3f2f0d246..b20ecd92b 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -667,7 +667,8 @@ pub fn core_main() -> Option> { None } }; - let new_id = get_value("--id"); + // An empty --id (e.g. an unset var) would deploy a blank id; the Android flow guards this too (#15146). + let new_id = get_value("--id").filter(|s| !s.is_empty()); match crate::ui_interface::deploy_device(token, new_id) { crate::ui_interface::DeployResult::Ok => { println!("Device deployed."); diff --git a/src/ipc.rs b/src/ipc.rs index 01f4cda6e..e4b92be5f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -881,8 +881,14 @@ async fn handle(data: Data, stream: &mut Connection) { Some(value) => { let mut updated = true; if name == "id" { - Config::set_key_confirmed(false); - Config::set_id(&value); + // An empty id would wipe the local id and unconfirm the key (cf. #15626). + if value.is_empty() { + log::warn!("Ignoring empty id write over IPC"); + updated = false; + } else { + Config::set_key_confirmed(false); + Config::set_id(&value); + } } else if name == "temporary-password" { password::update_temporary_password(); } else if name == "permanent-password" { From 7696b0ee51a82133305cbcafec6fb1523f9df415 Mon Sep 17 00:00:00 2001 From: gateslu Date: Mon, 20 Jul 2026 13:24:48 +0800 Subject: [PATCH 10/21] fix(linux): forward forced display server to user server (#15627) Signed-off-by: Gateslu --- src/platform/linux.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9a4bb37ec..ab6b1879b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -646,6 +646,16 @@ fn try_start_server_(desktop: Option<&Desktop>) -> ResultType> { if !desktop.dbus.is_empty() { envs.push(("DBUS_SESSION_BUS_ADDRESS", desktop.dbus.clone())); } + if let Ok(forced_display_server) = + std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER") + { + if !forced_display_server.is_empty() { + envs.push(( + "RUSTDESK_FORCED_DISPLAY_SERVER", + forced_display_server, + )); + } + } envs.push(( "TERM", get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()), From 5b4d6baf47283068fbe03f2eacd2ec34d0c20c2c Mon Sep 17 00:00:00 2001 From: Kuksgauzen <47536339+Kuksgauzen@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:42:43 +0400 Subject: [PATCH 11/21] fix: wrap BackingScaleFactor in autoreleasepool to stop NSDictionary accumulation on macOS (#15623) Signed-off-by: Viktor Kuksgauzen --- src/platform/macos.mm | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/platform/macos.mm b/src/platform/macos.mm index 3303855a6..d4038da8e 100644 --- a/src/platform/macos.mm +++ b/src/platform/macos.mm @@ -118,16 +118,18 @@ extern "C" bool MacCheckAdminAuthorization() { // https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42 extern "C" float BackingScaleFactor(uint32_t display) { - NSArray *screens = [NSScreen screens]; - for (NSScreen *screen in screens) { - NSDictionary *deviceDescription = [screen deviceDescription]; - NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"]; - CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue]; - if (screenDisplayID == display) { - return [screen backingScaleFactor]; + @autoreleasepool { + NSArray *screens = [NSScreen screens]; + for (NSScreen *screen in screens) { + NSDictionary *deviceDescription = [screen deviceDescription]; + NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"]; + CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue]; + if (screenDisplayID == display) { + return [screen backingScaleFactor]; + } } + return 1; } - return 1; } // https://github.com/jhford/screenresolution/blob/master/cg_utils.c From 1c2dd71891cd476a8952eab95d682cd33f3188c9 Mon Sep 17 00:00:00 2001 From: hatterp Date: Tue, 21 Jul 2026 16:43:09 +0200 Subject: [PATCH 12/21] Translate 'Continue' to 'Kontynuuj' in Polish (#15641) --- src/lang/pl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 9de6cfd92..1123580a0 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Relay Connection", "Połączenie przez bramkę"), ("Secure Connection", "Połączenie szyfrowane"), ("Insecure Connection", "Połączenie nieszyfrowane"), - ("Continue", ""), + ("Continue", "Kontynuuj"), ("Scale original", "Skalowanie oryginalne"), ("Scale adaptive", "Dopasuj do wyświetlacza"), ("General", "Ogólne"), From 929e989f17ba92e1a8291c0616f705208b67e3ea Mon Sep 17 00:00:00 2001 From: bmmh1 Date: Wed, 22 Jul 2026 11:22:10 -0500 Subject: [PATCH 13/21] feat(macos): silent auto-update with security hardening (#15550) Co-authored-by: bmmh1 --- .../desktop/pages/desktop_setting_page.dart | 3 +- src/ipc.rs | 25 +- src/ipc/auth.rs | 26 + src/platform/macos.rs | 906 +++++++++++++++++- src/platform/privileges_scripts/daemon.plist | 4 +- src/platform/privileges_scripts/install.scpt | 10 +- src/service.rs | 8 + src/updater.rs | 316 +++++- 8 files changed, 1276 insertions(+), 22 deletions(-) diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 8cd640f97..e2e557437 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -485,7 +485,8 @@ class _GeneralState extends State<_General> { Widget other() { final incomingOnly = bind.isIncomingOnly(); final outgoingOnly = bind.isOutgoingOnly(); - final showAutoUpdate = isWindows && bind.mainIsInstalled(); + final showAutoUpdate = (isWindows && bind.mainIsInstalled()) || + (isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient()); final children = [ if (!isWeb && !incomingOnly) _OptionCheckBox(context, 'Confirm before closing multiple tabs', diff --git a/src/ipc.rs b/src/ipc.rs index e4b92be5f..4498ceb5f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -41,6 +41,8 @@ pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; #[cfg(any(target_os = "linux", target_os = "macos"))] use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; +#[cfg(target_os = "macos")] +use ipc_auth::authorize_user_server_process; #[cfg(windows)] use ipc_auth::{ authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, @@ -472,6 +474,8 @@ pub enum Data { #[cfg(target_os = "windows")] PortForwardSessionCount(Option), SocksWs(Option, String)>>), + #[cfg(target_os = "macos")] + HasNoActiveConns(Option), #[cfg(not(any(target_os = "android", target_os = "ios")))] Whiteboard((String, crate::whiteboard::CustomEvent)), ControlPermissionsRemoteModify(Option), @@ -1006,6 +1010,16 @@ async fn handle(data: Data, stream: &mut Connection) { .await ); } + #[cfg(target_os = "macos")] + Data::HasNoActiveConns(None) => { + allow_err!( + stream + .send(&Data::HasNoActiveConns(Some( + crate::updater::has_no_active_conns() + ))) + .await + ); + } #[cfg(all( feature = "flutter", not(any(target_os = "android", target_os = "ios")) @@ -1340,14 +1354,21 @@ pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType ResultType> { let path = Config::ipc_path_for_uid(uid, postfix); - connect_with_path(ms_timeout, &path).await + let conn = connect_with_path(ms_timeout, &path).await?; + #[cfg(target_os = "macos")] + if postfix.is_empty() + && !authorize_user_server_process(conn.peer_uid(), conn.peer_pid(), uid) + { + bail!("Rejected user IPC peer for uid {}", uid); + } + Ok(conn) } #[cfg(target_os = "linux")] diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 77fd148c6..0dd43855e 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -656,6 +656,32 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf true } +#[cfg(target_os = "macos")] +pub(crate) fn authorize_user_server_process( + peer_uid: Option, + peer_pid: Option, + expected_uid: u32, +) -> bool { + if peer_uid != Some(expected_uid) { + return false; + } + let Some(peer_pid) = peer_pid else { + return false; + }; + let Ok(peer_exe) = peer_exe_canonical_path_by_pid(peer_pid) else { + return false; + }; + let expected_path = PathBuf::from(format!( + "/Applications/{}.app/Contents/MacOS/{}", + crate::get_app_name(), + crate::get_app_name() + )); + let Ok(expected_path) = fs::canonicalize(expected_path) else { + return false; + }; + paths_refer_to_same_file(&peer_exe, &expected_path) +} + #[cfg(windows)] pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { let ( diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 2e68cf5d8..4f85a4b0f 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -312,6 +312,55 @@ fn correct_app_name(s: &str) -> String { s } +fn write_plist_atomically(path: &str, body: &str) -> ResultType<()> { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + let temporary = format!("{}.tmp.{}", path, std::process::id()); + let result = (|| { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.set_permissions(std::fs::Permissions::from_mode(0o644))?; + file.write_all(body.as_bytes())?; + file.sync_all()?; + std::fs::rename(&temporary, path)?; + Ok::<(), std::io::Error>(()) + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result.map_err(Into::into) +} + +pub fn write_plists() -> ResultType<()> { + let daemon_plist_path = format!( + "/Library/LaunchDaemons/com.carriez.{}_service.plist", + crate::get_app_name() + ); + let agent_plist_path = format!( + "/Library/LaunchAgents/com.carriez.{}_server.plist", + crate::get_app_name() + ); + let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else { + bail!("daemon.plist not found in embedded resources"); + }; + let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else { + bail!("Failed to read daemon.plist"); + }; + let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else { + bail!("agent.plist not found in embedded resources"); + }; + let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else { + bail!("Failed to read agent.plist"); + }; + write_plist_atomically(&daemon_plist_path, &daemon_plist_body)?; + write_plist_atomically(&agent_plist_path, &agent_plist_body)?; + log::info!("[write-plists] Wrote daemon and agent plists"); + Ok(()) +} + pub fn uninstall_service(show_new_window: bool, sync: bool) -> bool { // to-do: do together with win/linux about refactory start/stop service if !is_installed_daemon(false) { @@ -659,6 +708,61 @@ pub fn get_active_userid() -> String { get_active_user("-n") } +/// Return every UID with a login-window/session entry. Fast user switching +/// can leave several GUI bootstrap domains alive at once, so updating only +/// the console user can leave another user's agent on the old bundle. +pub(crate) fn get_logged_in_uids() -> Vec { + let mut uids = std::collections::BTreeSet::new(); + if let Ok(output) = std::process::Command::new("/usr/bin/who").output() { + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some(username) = line.split_whitespace().next() else { + continue; + }; + let Ok(output) = std::process::Command::new("/usr/bin/id") + .args(["-u", username]) + .output() + else { + continue; + }; + let Ok(uid) = String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + else { + continue; + }; + let gui_domain = format!("gui/{}", uid); + if std::process::Command::new("/bin/launchctl") + .args(["print", &gui_domain]) + .output() + .is_ok_and(|output| output.status.success()) + { + uids.insert(uid); + } + } + } + if let Ok(active_uid) = get_active_userid().parse::() { + if active_uid == 0 { + // UID 0 owns /dev/console while the LoginWindow session is active. + // Query that server even when fast-switched GUI domains also exist. + uids.insert(0); + } else { + let gui_domain = format!("gui/{}", active_uid); + if std::process::Command::new("/bin/launchctl") + .args(["print", &gui_domain]) + .output() + .is_ok_and(|output| output.status.success()) + { + uids.insert(active_uid); + } + } + } + if uids.is_empty() { + // The login window has no ordinary gui/0 bootstrap domain. + uids.insert(0); + } + uids.into_iter().collect() +} + pub fn get_active_user_home() -> Option { let username = get_active_username(); if !username.is_empty() { @@ -728,8 +832,12 @@ pub fn lock_screen() { .ok(); } +/// Starts the macOS system service IPC listener and the background +/// silent auto-update thread. pub fn start_os_service() { log::info!("Username: {}", crate::username()); + // Silent auto-update — runs as root via LaunchDaemon, no osascript dialog needed + crate::updater::start_auto_update_macos(); if let Err(err) = crate::ipc::start("_service") { log::error!("Failed to start ipc_service: {}", err); } @@ -912,6 +1020,760 @@ pub fn update_to(_file: &str) -> ResultType<()> { Ok(()) } +fn backup_update_plist(source: &str, backup: &str) -> ResultType<()> { + match std::fs::symlink_metadata(source) { + Ok(metadata) => { + if !metadata.file_type().is_file() { + bail!("[root-update] plist is not a regular file: {}", source); + } + std::fs::copy(source, backup)?; + Ok(()) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("[root-update] required installed plist is missing: {}", source) + } + Err(err) => Err(err.into()), + } +} + +fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultType<()> { + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + // Frameworks legitimately use internal symlinks (Resources, + // Versions/Current), but never allow a link to leave its framework. + let Some(framework_root) = framework_root else { + bail!("[root-update] symlink outside framework: {}", path.display()); + }; + let target = std::fs::read_link(path)?; + let target = if target.is_absolute() { + target + } else { + path.parent().unwrap_or(Path::new("/")).join(target) + }; + let target = std::fs::canonicalize(target)?; + let framework_root = std::fs::canonicalize(framework_root)?; + if target.starts_with(&framework_root) { + return Ok(()); + } + bail!("[root-update] symlink in update bundle: {}", path.display()); + } + if metadata.file_type().is_dir() { + for entry in std::fs::read_dir(path)? { + let child = entry?.path(); + let child_framework_root = if child + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".framework")) + { + Some(child.as_path()) + } else { + framework_root + }; + validate_update_tree(&child, child_framework_root)?; + } + } else if !metadata.file_type().is_file() { + bail!("[root-update] unsupported file in update bundle: {}", path.display()); + } + Ok(()) +} + +/// Performs a silent update from a DMG file without any osascript dialog. +/// Must be called from a process running as root (e.g. the service binary). +pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> ResultType<()> { + let app_name = crate::get_app_name(); + if app_name.is_empty() + || !app_name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + bail!("[root-update] unsafe application name"); + } + let app_bundle = format!("/Applications/{}.app", app_name); + let tmp_dir_output = std::process::Command::new("/usr/bin/mktemp") + .args(&["-d", "/tmp/.rustdeskupdate-root-XXXXXX"]) + .output()?; + let tmp_dir = String::from_utf8(tmp_dir_output.stdout) + .map_err(|e| anyhow!("[root-update] mktemp output error: {}", e))? + .trim() + .to_string(); + if tmp_dir.is_empty() { + bail!("[root-update] Failed to create temp directory"); + } + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))?; + } + let agent_plist = format!("/Library/LaunchAgents/com.carriez.{}_server.plist", app_name); + let daemon_plist = format!("/Library/LaunchDaemons/com.carriez.{}_service.plist", app_name); + + log::info!("[root-update] Starting silent root update from {}", dmg_path); + // Check sessions before extracting to avoid unnecessary work + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] Active session detected, deferring update."); + } + // Extract DMG to temp dir + extract_dmg_into_existing_dir(dmg_path, &tmp_dir)?; + let src_app = format!("{}/{}.app", tmp_dir, app_name); + log::info!("[root-update] DMG extracted to {}", tmp_dir); + validate_update_tree(Path::new(&src_app), None)?; + + // Bind the downloaded asset to the version returned by the update + // service before changing plists or executing anything from the staged + // bundle. A release asset with the right filename but the wrong bundle + // must not be allowed to replace the installed application. + let info_plist = format!("{}/Contents/Info.plist", src_app); + let staged_version_result = (|| -> ResultType { + let output = Command::new("/usr/libexec/PlistBuddy") + .args(["-c", "Print :CFBundleShortVersionString", &info_plist]) + .output()?; + if !output.status.success() { + bail!( + "[root-update] failed to read staged bundle version: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let version = String::from_utf8(output.stdout) + .map_err(|err| anyhow!("[root-update] staged bundle version is not UTF-8: {}", err))?; + if version.trim().is_empty() { + bail!("[root-update] staged bundle version is empty"); + } + Ok(version.trim().to_owned()) + })(); + let staged_version = match staged_version_result { + Ok(version) => version, + Err(err) => { + if let Err(cleanup_err) = std::fs::remove_dir_all(&tmp_dir) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + tmp_dir, + cleanup_err + ); + } + return Err(err); + } + }; + if staged_version != expected_version { + if let Err(err) = std::fs::remove_dir_all(&tmp_dir) { + log::warn!( + "[root-update] Failed to remove temp dir {}: {}", + tmp_dir, + err + ); + } + bail!( + "[root-update] staged bundle version mismatch: expected {:?}, found {:?}", + expected_version, + staged_version + ); + } + + // A leftover backup makes `mv app app.bak` nest the live bundle inside + // the old directory instead of creating a transaction backup. Never + // overwrite or guess at recovery state left by an earlier interrupted + // update; require an administrator to inspect it first. + let app_backup = format!("{}.bak", app_bundle); + let failed_bundle = format!("{}.failed-update", app_bundle); + for recovery_path in [&app_backup, &failed_bundle] { + match std::fs::symlink_metadata(recovery_path) { + Ok(_) => { + let _ = std::fs::remove_dir_all(&tmp_dir); + bail!( + "[root-update] stale application recovery path requires inspection: {}", + recovery_path + ); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + let _ = std::fs::remove_dir_all(&tmp_dir); + return Err(err.into()); + } + } + } + + // Backup current plists before overwriting — needed for restore on reload failure + let daemon_plist_bak = format!("{}/daemon_plist.bak", tmp_dir); + let agent_plist_bak = format!("{}/agent_plist.bak", tmp_dir); + // Backups are part of the update transaction. Do not allow the new + // service binary to overwrite either live plist unless both installed + // definitions have been captured successfully. + backup_update_plist(&daemon_plist, &daemon_plist_bak)?; + backup_update_plist(&agent_plist, &agent_plist_bak)?; + + // Ensure the staged release contains the service executable before we + // proceed. Plist generation itself is done in this already-root process; + // launching a freshly extracted service binary from /tmp is not required. + let new_service = format!("{}/Contents/MacOS/service", src_app); + if !std::path::Path::new(&new_service).is_file() { + bail!("[root-update] staged service binary is missing: {}", new_service); + } + // The new binary writes its own plist definitions after the bundle is + // moved into its final root-owned location. This avoids executing code + // directly from /tmp while ensuring the plist matches the new release. + + // Final session check after extraction — minimize race window + if !crate::updater::has_no_active_conns_ipc() { + let _ = std::fs::remove_dir_all(&tmp_dir); + bail!("[root-update] Active session detected after extraction, deferring update."); + } + + // Let the detached-script launch settle before taking the affected-user + // snapshot. The final IPC check then happens after the delay and as close + // as possible to stopping those exact launchd domains. + std::thread::sleep(std::time::Duration::from_secs(3)); + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] active session started before update launch"); + } + let logged_in_uids = get_logged_in_uids(); + // UIDs are parsed as integers before embedding in the root-run shell + // script, so they cannot alter its command structure. + let uid_list = logged_in_uids + .iter() + .map(u32::to_string) + .collect::>() + .join(" "); + + // Write a shell script that runs detached after this function returns. + // We cannot directly replace /Applications/RustDesk.app while it is running, + // so we spawn a script that waits, kills processes, copies, and restarts. + let daemon_label = format!("com.carriez.{}_service", app_name); + let agent_label = format!("com.carriez.{}_server", app_name); + let script_path = format!("{}/rustdesk_update.sh", tmp_dir); + let script = format!( + r#"#!/bin/sh +rollback_done=0 +bundle_swapped=0 +bootstrap_agent() {{ + agent_uid="$1" + if [ "$agent_uid" != "0" ]; then + launchctl bootstrap gui/"$agent_uid" "{agent_plist}" 2>/dev/null || \ + launchctl bootstrap user/"$agent_uid" "{agent_plist}" 2>/dev/null || \ + launchctl load -w "{agent_plist}" 2>/dev/null + else + # At the login window there is no gui/0 domain. launchctl load uses + # the plist's LoginWindow/Aqua session policy instead. + launchctl load -w -S LoginWindow "{agent_plist}" 2>/dev/null || \ + launchctl load -w "{agent_plist}" 2>/dev/null + fi +}} +bootstrap_agents() {{ + for agent_uid in {uid_list}; do + bootstrap_agent "$agent_uid" || return 1 + done +}} +loginwindow_asid() {{ + root_user_info=$(launchctl print user/0 2>/dev/null || true) + root_login_asid=$(printf '%s\n' "$root_user_info" | \ + awk '/^[[:space:]]*asid = [0-9]+[[:space:]]*$/ {{print $3; exit}}') + case "$root_login_asid" in + ''|*[!0-9]*) return 1 ;; + esac + printf '%s\n' "$root_login_asid" +}} +bootout_agents() {{ + # Legacy launchctl commands can report success despite operational + # failure. Treat these as requests; stop_agents verifies the result. + stopping_loginwindow_asid="" + for agent_uid in {uid_list}; do + if [ "$agent_uid" != "0" ]; then + launchctl bootout gui/"$agent_uid"/{agent_label} 2>/dev/null || true + launchctl bootout user/"$agent_uid"/{agent_label} 2>/dev/null || true + else + # LoginWindow jobs run in a login/ domain even though + # legacy root `launchctl load` is issued from the system context. + # Remove every applicable registration before killing the process + # so KeepAlive cannot immediately respawn it. + launchctl unload -w -S LoginWindow "{agent_plist}" 2>/dev/null || true + stopping_loginwindow_asid=$(loginwindow_asid || true) + if [ -n "$stopping_loginwindow_asid" ]; then + launchctl bootout login/"$stopping_loginwindow_asid"/{agent_label} 2>/dev/null || true + fi + launchctl bootout user/0/{agent_label} 2>/dev/null || true + launchctl bootout system/{agent_label} 2>/dev/null || true + launchctl unload -w "{agent_plist}" 2>/dev/null || true + fi + done +}} +find_agent_pid() {{ + agent_uid="$1" + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \ + printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null; then + printf '%s\n' "$candidate_pid" + return 0 + fi + done + return 1 +}} +launchd_agent_pid() {{ + agent_uid="$1" + agent_info=$(launchctl print gui/"$agent_uid"/{agent_label} 2>/dev/null || \ + launchctl print user/"$agent_uid"/{agent_label} 2>/dev/null || true) + agent_job_pid=$(printf '%s\n' "$agent_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + if [ -n "$agent_job_pid" ] && \ + printf '%s\n' "$agent_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null; then + printf '%s\n' "$agent_job_pid" + return 0 + fi + return 1 +}} +agent_pid_for_uid() {{ + agent_uid="$1" + if [ "$agent_uid" = "0" ]; then + # LoginWindow agents have no ordinary gui/0 bootstrap domain. Locate + # the root-owned --server process and validate it below instead. + find_agent_pid "$agent_uid" + else + launchd_agent_pid "$agent_uid" + fi +}} +agent_process_matches() {{ + agent_uid="$1" + agent_pid="$2" + process_uid=$(ps -p "$agent_pid" -o uid= 2>/dev/null | tr -d '[:space:]') + process_args=$(ps -p "$agent_pid" -o args= 2>/dev/null || true) + [ "$process_uid" = "$agent_uid" ] && \ + printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \ + printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null +}} +capture_stopping_agent_pids() {{ + stopping_agent_pids="" + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + if agent_process_matches "$agent_uid" "$candidate_pid"; then + stopping_agent_pids="$stopping_agent_pids $candidate_pid" + fi + done + done +}} +terminate_agent_processes() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + if agent_process_matches "$agent_uid" "$candidate_pid"; then + kill -KILL "$candidate_pid" 2>/dev/null || true + fi + done + done +}} +terminate_user_bundle_processes() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then + kill -KILL "$candidate_pid" 2>/dev/null || true + fi + done + done +}} +user_bundle_processes_absent() {{ + for agent_uid in {uid_list}; do + for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do + process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then + return 1 + fi + done + done + return 0 +}} +stop_user_bundle_processes() {{ + terminate_user_bundle_processes + for _ in $(/usr/bin/seq 1 30); do + if user_bundle_processes_absent; then + sleep 2 + user_bundle_processes_absent && return 0 + fi + terminate_user_bundle_processes + sleep 1 + done + return 1 +}} +agent_jobs_absent() {{ + for agent_uid in {uid_list}; do + if [ "$agent_uid" != "0" ]; then + if launchctl print gui/"$agent_uid"/{agent_label} >/dev/null 2>&1 || \ + launchctl print user/"$agent_uid"/{agent_label} >/dev/null 2>&1; then + return 1 + fi + else + if launchctl print system/{agent_label} >/dev/null 2>&1 || \ + launchctl print user/0/{agent_label} >/dev/null 2>&1; then + return 1 + fi + if [ -n "$stopping_loginwindow_asid" ] && \ + launchctl print login/"$stopping_loginwindow_asid"/{agent_label} >/dev/null 2>&1; then + return 1 + fi + fi + find_agent_pid "$agent_uid" >/dev/null 2>&1 && return 1 + done + return 0 +}} +captured_agent_pids_gone() {{ + for stopped_pid in $stopping_agent_pids; do + kill -0 "$stopped_pid" 2>/dev/null && return 1 + done + return 0 +}} +agents_stopped() {{ + captured_agent_pids_gone && agent_jobs_absent +}} +stop_agents() {{ + bootout_agents + terminate_agent_processes + for _ in $(/usr/bin/seq 1 30); do + if agents_stopped; then + sleep 2 + agents_stopped && return 0 + fi + terminate_agent_processes + sleep 1 + done + return 1 +}} +capture_agent_snapshot() {{ + agent_pids="" + for agent_uid in {uid_list}; do + agent_pid=$(agent_pid_for_uid "$agent_uid" || true) + [ -n "$agent_pid" ] || return 1 + [ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1 + kill -0 "$agent_pid" 2>/dev/null || return 1 + agent_process_matches "$agent_uid" "$agent_pid" || return 1 + agent_pids="$agent_pids $agent_uid:$agent_pid" + done + return 0 +}} +agent_snapshot_stable() {{ + for agent_entry in $agent_pids; do + agent_uid=$(printf '%s\n' "$agent_entry" | cut -d: -f1) + expected_pid=$(printf '%s\n' "$agent_entry" | cut -d: -f2) + current_pid=$(agent_pid_for_uid "$agent_uid" || true) + [ -n "$current_pid" ] && [ "$current_pid" = "$expected_pid" ] || return 1 + [ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1 + kill -0 "$current_pid" 2>/dev/null || return 1 + agent_process_matches "$agent_uid" "$current_pid" || return 1 + done + return 0 +}} +agent_ready() {{ + for _ in $(/usr/bin/seq 1 30); do + if capture_agent_snapshot; then + sleep 2 + agent_snapshot_stable && return 0 + fi + sleep 1 + done + return 1 +}} +daemon_snapshot_stable() {{ + stable_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + stable_daemon_pid=$(printf '%s\n' "$stable_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + [ -n "$daemon_pid" ] && \ + [ "$stable_daemon_pid" = "$daemon_pid" ] && \ + printf '%s\n' "$stable_daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \ + [ -S "/tmp/{app_name}-service/ipc_service" ] && \ + kill -0 "$daemon_pid" 2>/dev/null +}} +daemon_ready() {{ + daemon_pid="" + for _ in $(/usr/bin/seq 1 30); do + daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + daemon_pid=$(printf '%s\n' "$daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') + if [ -n "$daemon_pid" ] && \ + printf '%s\n' "$daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \ + [ -S "/tmp/{app_name}-service/ipc_service" ] && \ + kill -0 "$daemon_pid" 2>/dev/null; then + sleep 2 + daemon_snapshot_stable && return 0 + fi + sleep 1 + done + return 1 +}} +capture_stopping_daemon_pid() {{ + stopping_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true) + stopping_daemon_pid=$(printf '%s\n' "$stopping_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}') +}} +daemon_stopped() {{ + if [ -n "$stopping_daemon_pid" ] && kill -0 "$stopping_daemon_pid" 2>/dev/null; then + return 1 + fi + ! launchctl print system/{daemon_label} >/dev/null 2>&1 +}} +stop_daemon() {{ + capture_stopping_daemon_pid + # Command status is advisory. daemon_stopped verifies that both the + # captured process generation and launchd registration are gone. + launchctl bootout system/{daemon_label} 2>/dev/null || \ + launchctl unload -w "{daemon_plist}" 2>/dev/null || true + for _ in $(/usr/bin/seq 1 30); do + if daemon_stopped; then + sleep 2 + daemon_stopped && return 0 + fi + sleep 1 + done + return 1 +}} +write_new_plists() {{ + /Applications/{app_name}.app/Contents/MacOS/service --write-plists \ + >"{tmp_dir}/write-plists.log" 2>&1 & + write_pid=$! + for _ in $(/usr/bin/seq 1 60); do + if ! kill -0 "$write_pid" 2>/dev/null; then + wait "$write_pid" + return $? + fi + sleep 1 + done + kill -TERM "$write_pid" 2>/dev/null || true + sleep 1 + kill -KILL "$write_pid" 2>/dev/null || true + wait "$write_pid" 2>/dev/null || true + return 124 +}} +restore_old_bundle() {{ + [ "$bundle_swapped" -eq 1 ] || return 0 + if [ ! -d "{app_bundle}.bak" ] || [ -L "{app_bundle}.bak" ]; then + echo "[root-update] CRITICAL: valid application backup is unavailable" >> {tmp_dir}/rustdesk_root_update.log + return 1 + fi + if [ -e "{app_bundle}" ] || [ -L "{app_bundle}" ]; then + if [ -e "{app_bundle}.failed-update" ] || [ -L "{app_bundle}.failed-update" ] || \ + ! mv "{app_bundle}" "{app_bundle}.failed-update"; then + echo "[root-update] CRITICAL: could not vacate failed bundle safely" >> {tmp_dir}/rustdesk_root_update.log + return 1 + fi + fi + if ! mv "{app_bundle}.bak" "{app_bundle}"; then + echo "[root-update] CRITICAL: failed to restore application bundle" >> {tmp_dir}/rustdesk_root_update.log + if [ ! -e "{app_bundle}" ] && [ ! -L "{app_bundle}" ]; then + mv "{app_bundle}.failed-update" "{app_bundle}" 2>/dev/null || true + fi + return 1 + fi + rm -rf "{app_bundle}.failed-update" 2>/dev/null || true + bundle_swapped=0 + return 0 +}} +rollback_transaction() {{ + # Rollback restores and verifies unattended service state. It does not + # guarantee relaunching GUI windows that were stopped by the transaction. + [ "$rollback_done" -eq 0 ] || return 0 + rollback_done=1 + restore_failed=0 + stop_daemon || restore_failed=1 + capture_stopping_agent_pids + stop_agents || restore_failed=1 + restore_old_bundle || restore_failed=1 + cp "{daemon_plist_bak}" "{daemon_plist}" || restore_failed=1 + cp "{agent_plist_bak}" "{agent_plist}" || restore_failed=1 + touch /var/root/.rustdeskupdate_failed || restore_failed=1 + if ! launchctl load -w "{daemon_plist}" 2>/dev/null && \ + ! launchctl bootstrap system "{daemon_plist}" 2>/dev/null; then + restore_failed=1 + fi + daemon_ready || restore_failed=1 + bootstrap_agents || restore_failed=1 + agent_ready || restore_failed=1 + if [ "$restore_failed" -eq 0 ] && \ + {{ ! daemon_snapshot_stable || ! agent_snapshot_stable; }}; then + restore_failed=1 + fi + if [ "$restore_failed" -ne 0 ]; then + echo "[root-update] CRITICAL: rollback restoration failed" >> {tmp_dir}/rustdesk_root_update.log + else + echo "[root-update] Rollback daemon and agents verified healthy" >> {tmp_dir}/rustdesk_root_update.log + fi +}} +trap rollback_transaction EXIT +gui_uids="" +for agent_uid in {uid_list}; do + for pid in $(pgrep -u "$agent_uid" -x {app_name} || true); do + process_args=$(ps -p "$pid" -o args= 2>/dev/null || true) + if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null && \ + ! printf '%s\n' "$process_args" | grep -E "(^|[[:space:]])(--server|--service|--update)([[:space:]]|$)" >/dev/null; then + gui_uids="$gui_uids $agent_uid" + break + fi + done +done +if ! capture_agent_snapshot; then + echo "[root-update] old LaunchAgent readiness check failed before shutdown" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +capture_stopping_agent_pids +if ! stop_daemon; then + echo "[root-update] daemon did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! stop_agents; then + echo "[root-update] old LaunchAgent did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Agents have already been verified absent. Stop and verify any remaining GUI +# processes as well so no process keeps the old bundle mapped across the swap. +if ! stop_user_bundle_processes; then + echo "[root-update] RustDesk GUI process did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +staged_bundle="{tmp_dir}/staged.app" +if [ -e "$staged_bundle" ] || [ -L "$staged_bundle" ]; then + echo "[root-update] staged bundle path already exists, aborting" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! ditto {src_app} "$staged_bundle" 2>/dev/null; then + echo "[root-update] ditto failed, aborting update" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +# Validate staged bundle before atomic swap +if [ ! -d "$staged_bundle/Contents/MacOS" ] || \ + [ ! -f "$staged_bundle/Contents/MacOS/{app_name}" ] || \ + [ ! -f "$staged_bundle/Contents/MacOS/service" ] || \ + [ ! -f "$staged_bundle/Contents/Info.plist" ]; then + echo "[root-update] staged bundle validation failed, aborting" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +if ! mv {app_bundle} {app_bundle}.bak; then + echo "[root-update] backup mv failed, aborting" >> {tmp_dir}/rustdesk_root_update.log + rm -rf "$staged_bundle" + exit 1 +fi +bundle_swapped=1 +if ! mv "$staged_bundle" {app_bundle}; then + echo "[root-update] replacement mv failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Install the entire bundle as root-owned. The LaunchDaemon executes code +# from this bundle, so no nested framework, helper, or resource may remain +# user-writable. +if ! chown -R root:wheel {app_bundle} || ! chmod -R go-w {app_bundle}; then + echo "[root-update] chown failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +xattr -r -d com.apple.quarantine {app_bundle} || true +# Keep root-executed files AND entire ancestor chain root-owned — prevent privilege escalation +if ! chown root:wheel {app_bundle} || \ + ! chmod 755 {app_bundle} || \ + ! chown root:wheel {app_bundle}/Contents || \ + ! chmod 755 {app_bundle}/Contents || \ + ! chown root:wheel {app_bundle}/Contents/MacOS || \ + ! chmod 755 {app_bundle}/Contents/MacOS || \ + ! chown root:wheel {app_bundle}/Contents/MacOS/service || \ + ! chmod 755 {app_bundle}/Contents/MacOS/service || \ + ! chown root:wheel {app_bundle}/Contents/MacOS/{app_name} || \ + ! chmod 755 {app_bundle}/Contents/MacOS/{app_name}; then + echo "[root-update] hardening failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Generate launchd definitions from the new, final-location binary. The +# subprocess is bounded and its output is retained for diagnosis; failure +# causes the existing bundle/plists to be restored by the EXIT trap. +if ! write_new_plists; then + echo "[root-update] CRITICAL: new binary failed to write plists" >> {tmp_dir}/rustdesk_root_update.log + cat "{tmp_dir}/write-plists.log" >> {tmp_dir}/rustdesk_root_update.log 2>/dev/null || true + exit 1 +fi +echo "[root-update] Plist definitions written by new binary" >> {tmp_dir}/rustdesk_root_update.log +# Check daemon registration and readiness BEFORE removing backup. launchctl +# load/bootstrap only registers the job; the service can still exit immediately. +if ! launchctl load -w {daemon_plist} 2>/dev/null && \ + ! launchctl bootstrap system {daemon_plist} 2>/dev/null; then + echo "[root-update] CRITICAL: daemon reload failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +if ! daemon_ready; then + echo "[root-update] CRITICAL: daemon failed readiness check, restoring" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Bootstrap agent BEFORE removing backup — needed for rollback on failure. +# This also uses launchctl load for the login-window/no-console-user case. +if ! bootstrap_agents || ! agent_ready; then + echo "[root-update] CRITICAL: agent bootstrap failed, rolling back" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Recheck daemon liveness after the agent is restored and immediately before +# deleting the only rollback bundle. +if ! daemon_snapshot_stable || ! agent_snapshot_stable; then + echo "[root-update] CRITICAL: daemon or agent stopped before commit, restoring" >> {tmp_dir}/rustdesk_root_update.log + exit 1 +fi +# Only remove backup after BOTH daemon AND agent confirmed running +rollback_done=1 +bundle_swapped=0 +if ! rm -rf "{app_bundle}.bak"; then + echo "[root-update] WARNING: committed update but could not remove backup" >> {tmp_dir}/rustdesk_root_update.log +fi +for gui_uid in $gui_uids; do + launchctl asuser "$gui_uid" open -a "{app_bundle}" || true +done +echo "[root-update] Done!" >> {tmp_dir}/rustdesk_root_update.log +rm -rf {tmp_dir} +"#, + app_name = app_name, + app_bundle = app_bundle, + src_app = src_app, + uid_list = uid_list, + daemon_plist = daemon_plist, + agent_plist = agent_plist, + tmp_dir = tmp_dir, + daemon_label = daemon_label, + agent_label = agent_label, + daemon_plist_bak = daemon_plist_bak, + agent_plist_bak = agent_plist_bak, + ); + + { + use std::io::Write; + if let Err(err) = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&script_path) + .and_then(|mut f| f.write_all(script.as_bytes())) + { + return Err(err.into()); + } + } + match Command::new("/bin/chmod") + .args(&["+x", &script_path]) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => { + bail!( + "[root-update] failed to make update script executable: {}", + status + ); + } + Err(err) => { + return Err(err.into()); + } + } + // Reject session changes observed before launch, but this snapshot is + // best-effort: it is not atomic with shutdown in the detached script. + if get_logged_in_uids() != logged_in_uids { + bail!("[root-update] GUI session set changed before update launch"); + } + if !crate::updater::has_no_active_conns_ipc() { + bail!("[root-update] active session started before update launch"); + } + if let Err(err) = Command::new("/bin/bash") + .arg(&script_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0) + .spawn() + { + return Err(err.into()); + } + + log::info!("[root-update] Update script launched."); + Ok(()) +} + pub fn extract_update_dmg(file: &str) { let update_temp_dir = get_update_temp_dir_string(); let mut evt: HashMap<&str, String> = @@ -931,37 +1793,63 @@ pub fn extract_update_dmg(file: &str) { } fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> { - let mount_point = "/Volumes/RustDeskUpdate"; let target_path = Path::new(target_dir); - if target_path.exists() { std::fs::remove_dir_all(target_path)?; } std::fs::create_dir_all(target_path)?; + extract_dmg_inner(dmg_path, target_dir) +} - let status = Command::new("hdiutil") - .args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path]) +fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let target_path = Path::new(target_dir); + if !target_path.exists() { + bail!("[root-update] Temp directory does not exist: {:?}", target_path); + } + extract_dmg_inner(dmg_path, target_dir) +} + +fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> { + let mount_output = Command::new("/usr/bin/mktemp") + .args(["-d", "/tmp/.rustdeskmount-XXXXXX"]) + .output()?; + if !mount_output.status.success() { + bail!("Failed to create a private DMG mount directory"); + } + let mount_point = String::from_utf8(mount_output.stdout) + .map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))? + .trim() + .to_owned(); + if mount_point.is_empty() { + bail!("Failed to create a private DMG mount directory"); + } + let status = Command::new("/usr/bin/hdiutil") + .args(["attach", "-nobrowse", "-mountpoint"]) + .arg(&mount_point) + .arg(dmg_path) .status()?; if !status.success() { + let _ = std::fs::remove_dir(&mount_point); bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status); } - struct DmgGuard(&'static str); + struct DmgGuard(String); impl Drop for DmgGuard { fn drop(&mut self) { - let _ = Command::new("hdiutil") - .args(&["detach", self.0, "-force"]) + let _ = Command::new("/usr/bin/hdiutil") + .args(["detach", self.0.as_str(), "-force"]) .status(); + let _ = std::fs::remove_dir(&self.0); } } - let _guard = DmgGuard(mount_point); + let _guard = DmgGuard(mount_point.clone()); let app_name = format!("{}.app", crate::get_app_name()); let src_path = format!("{}/{}", mount_point, app_name); let dest_path = format!("{}/{}", target_dir, app_name); - let copy_status = Command::new("ditto") + let copy_status = Command::new("/usr/bin/ditto") .args(&[&src_path, &dest_path]) .status()?; diff --git a/src/platform/privileges_scripts/daemon.plist b/src/platform/privileges_scripts/daemon.plist index c003ea2be..dbd1aa70f 100644 --- a/src/platform/privileges_scripts/daemon.plist +++ b/src/platform/privileges_scripts/daemon.plist @@ -23,8 +23,8 @@ WorkingDirectory /Applications/RustDesk.app/Contents/MacOS/ StandardErrorPath - /tmp/rustdesk_service.err + /var/log/rustdesk_service.err StandardOutPath - /tmp/rustdesk_service.out + /var/log/rustdesk_service.out diff --git a/src/platform/privileges_scripts/install.scpt b/src/platform/privileges_scripts/install.scpt index 797d02c9e..acf86ab93 100644 --- a/src/platform/privileges_scripts/install.scpt +++ b/src/platform/privileges_scripts/install.scpt @@ -1,14 +1,18 @@ on run {daemon_file, agent_file, user} + set prefs_dir to "/Users/" & user & "/Library/Preferences/com.carriez.RustDesk/" + set prefs_toml to quoted form of (prefs_dir & "RustDesk.toml") + set prefs2_toml to quoted form of (prefs_dir & "RustDesk2.toml") + set sh1 to "echo " & quoted form of daemon_file & " > /Library/LaunchDaemons/com.carriez.RustDesk_service.plist && chown root:wheel /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" set sh2 to "echo " & quoted form of agent_file & " > /Library/LaunchAgents/com.carriez.RustDesk_server.plist && chown root:wheel /Library/LaunchAgents/com.carriez.RustDesk_server.plist;" - set sh3 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk.toml /var/root/Library/Preferences/com.carriez.RustDesk/;" + set sh3 to "cp -rf " & prefs_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;" - set sh4 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk2.toml /var/root/Library/Preferences/com.carriez.RustDesk/;" + set sh4 to "cp -rf " & prefs2_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;" - set sh5 to "launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" + set sh5 to "launchctl bootout system/com.carriez.RustDesk_service 2>/dev/null || launchctl unload -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || true; launchctl bootstrap system /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" set sh to sh1 & sh2 & sh3 & sh4 & sh5 diff --git a/src/service.rs b/src/service.rs index ce1855bdb..65be58302 100644 --- a/src/service.rs +++ b/src/service.rs @@ -5,6 +5,14 @@ fn main() {} #[cfg(target_os = "macos")] fn main() { + let args: Vec = std::env::args().collect(); + if args.len() > 1 && args[1] == "--write-plists" { + if let Err(e) = librustdesk::platform::write_plists() { + eprintln!("Failed to write plists: {}", e); + std::process::exit(1); + } + std::process::exit(0); + } crate::common::load_custom_client(); hbb_common::init_log(false, "service"); crate::start_os_service(); diff --git a/src/updater.rs b/src/updater.rs index bf923dd56..beab97e53 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -11,6 +11,51 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(target_os = "macos")] +use std::os::{ + fd::AsRawFd, + unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, +}; + +#[cfg(target_os = "macos")] +struct MacUpdateLock { + _file: std::fs::File, +} + +#[cfg(target_os = "macos")] +fn acquire_mac_update_lock() -> ResultType { + let path = std::path::PathBuf::from("/var/run/rustdesk-update.lock"); + let handle = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_CLOEXEC) + .open(&path)?; + let metadata = handle.metadata()?; + if !metadata.file_type().is_file() || metadata.uid() != 0 { + bail!("[root-update] update lock is not a root-owned regular file"); + } + handle.set_permissions(std::fs::Permissions::from_mode(0o600))?; + + // Keep the descriptor open through update preparation and detached-script + // launch. O_CLOEXEC means this lock does not cover the detached bundle + // swap; flock is released when this guard is dropped or the process exits. + let lock_result = unsafe { + hbb_common::libc::flock( + handle.as_raw_fd(), + hbb_common::libc::LOCK_EX | hbb_common::libc::LOCK_NB, + ) + }; + if lock_result != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + bail!("[root-update] another update is already running"); + } + return Err(err.into()); + } + Ok(MacUpdateLock { _file: handle }) +} + enum UpdateMsg { CheckUpdate, Exit, @@ -22,7 +67,17 @@ lazy_static::lazy_static! { static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); -const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); +/// Initial wait after startup before the first update check (30 seconds). +pub const INITIAL_CHECK_DELAY: Duration = Duration::from_secs(30); + +/// One full day — default interval between update checks. +pub const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); + +/// Minimum interval between consecutive update checks (10 minutes). +pub const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); + +/// Retry interval when an update check fails or a session is active (30 minutes). +pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); pub fn update_controlling_session_count(count: usize) { CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst); @@ -47,7 +102,9 @@ pub fn stop_auto_update() { } #[inline] -fn has_no_active_conns() -> bool { +/// Returns true when there are no active incoming or outgoing connections. +/// Used to avoid updating while a remote session is in progress. +pub fn has_no_active_conns() -> bool { let conns = crate::Connection::alive_conns(); conns.is_empty() && has_no_controlling_conns() } @@ -82,13 +139,11 @@ fn start_auto_update_check() -> Sender { } fn start_auto_update_check_(rx_msg: Receiver) { - std::thread::sleep(Duration::from_secs(30)); + std::thread::sleep(INITIAL_CHECK_DELAY); if let Err(e) = check_update(false) { log::error!("Error checking for updates: {}", e); } - const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); - const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); let mut last_check_time = Instant::now(); let mut check_interval = DUR_ONE_DAY; loop { @@ -118,6 +173,12 @@ fn start_auto_update_check_(rx_msg: Receiver) { } fn check_update(manually: bool) -> ResultType<()> { + // On macOS, auto-update is handled by check_update_as_root() in the service process. + // The shared check_update() path is only used for manual update checks from the GUI. + #[cfg(target_os = "macos")] + if !manually { + return Ok(()); + } #[cfg(target_os = "windows")] let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { @@ -348,6 +409,251 @@ pub fn get_download_file_from_url(url: &str) -> Option { get_update_download_file_from_url(url) } +/// Queries all active connections (remote, file-transfer, port-forward, camera, terminal) +/// from every logged-in user's --server process via IPC. +/// The root service cannot read connection state directly since connections +/// live in user --server processes. Handles fast user switching by querying +/// all GUI users, including the login-window server at UID 0. Falls back to +/// false (assumes sessions active) on any IPC error to avoid updating during +/// an unknown session state. +#[cfg(target_os = "macos")] +pub fn has_no_active_conns_ipc() -> bool { + let rt = match hbb_common::tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(_) => return false, + }; + rt.block_on(async { + // Use the same GUI-domain-filtered UID set as the update script. + // Shell-only SSH/TTY users are excluded, while an empty GUI set maps + // to UID 0 so the LoginWindow server is queried rather than assumed idle. + let uids = crate::platform::get_logged_in_uids(); + // Check each user's server — fail closed if any has active connections + for uid in uids { + if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await { + if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() { + match conn.next_timeout(1000).await { + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => { + // Explicit no active connections — safe to continue + } + Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => { + return false; // Explicit active connections + } + _ => { + return false; // Timeout/error/unexpected — fail closed + } + } + } else { + return false; // Send failed — fail closed + } + } else { + return false; // Connection failed — fail closed + } + } + true // All users explicitly confirmed no active connections + }) +} + +#[cfg(target_os = "macos")] +fn wait_for_failed_update_retry() { + const FAILURE_MARKER: &str = "/var/root/.rustdeskupdate_failed"; + let marker = std::path::Path::new(FAILURE_MARKER); + if !marker.exists() { + return; + } + + // The updater script records failure immediately before launchd restarts + // the old daemon. Preserve the retry deadline across that restart instead + // of consuming the marker and retrying the same broken release in 30 sec. + let remaining = std::fs::metadata(marker) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| { + std::time::SystemTime::now() + .duration_since(modified) + .ok() + }) + .map(|elapsed| RETRY_INTERVAL.saturating_sub(elapsed)) + .unwrap_or(RETRY_INTERVAL); + if !remaining.is_zero() { + log::info!( + "[root-update] Previous update failed; retrying in {} seconds.", + remaining.as_secs() + ); + std::thread::sleep(remaining); + } + match std::fs::remove_file(marker) { + Ok(()) => log::info!("[root-update] Previous update retry interval elapsed."), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => log::warn!("[root-update] Failed to clear failure marker: {}", err), + } +} + +/// Starts the background silent auto-update scheduler for macOS. +/// Called from `start_os_service()` which runs as root via LaunchDaemon. +#[cfg(target_os = "macos")] +pub fn start_auto_update_macos() { + let spawn_result = std::thread::Builder::new() + .name("rustdesk-auto-update".to_owned()) + .spawn(|| { + log::info!("[root-update] Auto-update scheduler thread started."); + std::thread::sleep(INITIAL_CHECK_DELAY); + wait_for_failed_update_retry(); + let mut interval = DUR_ONE_DAY; + loop { + log::info!("[root-update] Running scheduled update check..."); + let no_active_conns = has_no_active_conns_ipc(); + if !no_active_conns { + log::info!("[root-update] Active session in progress, retrying in 10 min."); + interval = MIN_INTERVAL; + } else { + match check_update_as_root() { + Ok(update_started) => { + if update_started { + // The replacement script is detached and may fail + // after this process returns. Always retry at the + // failure interval until the new daemon replaces us. + interval = RETRY_INTERVAL; + } else { + interval = DUR_ONE_DAY; + } + } + Err(e) => { + log::error!("[root-update] Update check failed: {}", e); + interval = RETRY_INTERVAL; + } + } + } + std::thread::sleep(interval); + } + }); + if let Err(err) = spawn_result { + log::error!("[root-update] Failed to start scheduler thread: {}", err); + } +} + +#[cfg(target_os = "macos")] +pub fn check_update_as_root() -> ResultType { + let _update_lock = acquire_mac_update_lock()?; + // Allow-auto-update setting + if !config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE) { + log::info!("[root-update] Auto update is disabled, skipping."); + return Ok(false); + } + if crate::is_custom_client() { + log::info!("[root-update] Custom client detected, skipping stock update."); + return Ok(false); + } + // Clean up only old temp dirs from previous failed updates. The detached + // installer keeps using its update directory after this process exits and + // releases the advisory lock, so a newly-started daemon must not remove a + // directory that still belongs to the active transaction. + if let Ok(entries) = std::fs::read_dir("/tmp") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(".rustdeskupdate-root-") + || name_str.starts_with(".rustdeskdownload-") + { + let path = entry.path(); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + let mode = metadata.mode() & 0o7777; + let is_stale = metadata + .modified() + .ok() + .and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age >= RETRY_INTERVAL); + if metadata.file_type().is_dir() && metadata.uid() == 0 && mode == 0o700 && is_stale + { + if let Err(err) = std::fs::remove_dir_all(&path) { + log::warn!( + "[root-update] Failed to remove stale temp dir {}: {}", + path.display(), + err + ); + } + } + } + } + } + if let Err(e) = do_check_software_update() { + bail!("[root-update] Failed to check for software update: {}", e); + } + let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); + if update_url.is_empty() { + log::info!("[root-update] No update available."); + return Ok(false); + } + let download_url = update_url.replace("tag", "download"); + let version = download_url.split('/').last().unwrap_or_default().to_string(); + let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" }; + let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch); + log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url); + // Validate URL against GitHub release allowlist before downloading as root + let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else { + bail!("[root-update] URL failed allowlist check: {}", dmg_url); + }; + drop(file_path_validated); + let client = create_http_client_with_url_strict(&dmg_url)?; + // Use mktemp so a local user cannot pre-create a predictable path and + // permanently deny updates for a reused service PID. + let private_tmp_output = std::process::Command::new("/usr/bin/mktemp") + .args(["-d", "/tmp/.rustdeskdownload-XXXXXX"]) + .output()?; + if !private_tmp_output.status.success() { + bail!( + "[root-update] Failed to create private download directory: {}", + String::from_utf8_lossy(&private_tmp_output.stderr).trim() + ); + } + let private_tmp = String::from_utf8(private_tmp_output.stdout) + .map_err(|err| hbb_common::anyhow::anyhow!("[root-update] mktemp output error: {}", err))? + .trim() + .to_owned(); + if private_tmp.is_empty() { + bail!("[root-update] mktemp returned an empty download directory"); + } + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?; + } + let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg"); + let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename)); + let tmp_path = file_path.to_string_lossy().to_string(); + // Download + let mut response = client.get(&dmg_url).send()?; + if !response.status().is_success() { + let _ = std::fs::remove_dir_all(&private_tmp); + bail!("[root-update] Failed to download: {}", response.status()); + } + // Create file exclusively (O_EXCL) and stream response directly into it + { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&file_path) + .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + std::io::copy(&mut response, &mut file) + .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; + } + log::info!("[root-update] Downloaded to {}", tmp_path); + // Recheck active sessions before installing — download can take minutes + if !has_no_active_conns_ipc() { + if let Err(e) = std::fs::remove_dir_all(&private_tmp) { + log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + } + bail!("[root-update] Active session started during download, deferring update."); + } + // Install silently as root + let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version); + // Clean up download directory + if let Err(e) = std::fs::remove_dir_all(&private_tmp) { + log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); + } + result.map(|_| true) +} + #[cfg(test)] mod tests { use super::get_download_file_from_url; From beaa754299feff6d89a89e2e9f71d838788e333a Mon Sep 17 00:00:00 2001 From: 21pages Date: Thu, 23 Jul 2026 17:17:01 +0800 Subject: [PATCH 14/21] fix stale primary display selection (#15460) * fix stale primary display selection Signed-off-by: 21pages * fix stale display selection during login and switching - resolve the primary display from the refreshed login snapshot - defer display enumeration until authentication succeeds - read Wayland displays and primary index from the same cache snapshot - reject stale monitor and camera indices during display switching Signed-off-by: 21pages * fix inconsistent display snapshots during login - return displays from the same enumeration used to select the primary - avoid re-reading the shared display cache after updating it - use the same converted snapshot during Wayland initialization Signed-off-by: 21pages * avoid cloning unchanged display snapshots Signed-off-by: 21pages * fix invalid display subset handling Signed-off-by: 21pages * minimize code churn in switch_display_to Signed-off-by: 21pages --------- Signed-off-by: 21pages --- src/server.rs | 30 +++++------ src/server/connection.rs | 99 ++++++++++++++++++++++++++++------- src/server/display_service.rs | 78 +++++++++++++++------------ src/server/wayland.rs | 21 ++------ 4 files changed, 143 insertions(+), 85 deletions(-) diff --git a/src/server.rs b/src/server.rs index 89a17a919..f02a15a7f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -357,15 +357,13 @@ impl Server { } } - pub fn try_add_primay_video_service(&mut self) { - let primary_video_service_name = video_service::get_service_name( - VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, - ); - if !self.contains(&primary_video_service_name) { + pub fn try_add_monitor_service(&mut self, display_idx: usize) { + let monitor_service_name = + video_service::get_service_name(VideoSource::Monitor, display_idx); + if !self.contains(&monitor_service_name) { self.add_service(Box::new(video_service::new( VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, + display_idx, ))); } } @@ -381,14 +379,17 @@ impl Server { self.connections.insert(conn.id(), conn); } - pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) { - let primary_video_service_name = video_service::get_service_name( - VideoSource::Monitor, - *display_service::PRIMARY_DISPLAY_IDX, - ); + pub fn add_monitor_connection( + &mut self, + conn: ConnInner, + noperms: &Vec<&'static str>, + display_idx: usize, + ) { + let monitor_service_name = + video_service::get_service_name(VideoSource::Monitor, display_idx); for s in self.services.values() { let name = s.name(); - if Self::is_video_service_name(&name) && name != primary_video_service_name { + if Self::is_video_service_name(&name) && name != monitor_service_name { continue; } if !noperms.contains(&(&name as _)) { @@ -783,8 +784,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option { res.set_error(format!("{}", err)); } - Ok(displays) => { + Ok((displays, primary_display_idx)) => { // For compatibility with old versions, we need to send the displays to the peer. // But the displays may be updated later, before creating the video capturer. #[cfg(target_os = "macos")] { self.retina.set_displays(&displays); } + // A separate primary lookup here could race with display hot-plug. + self.display_idx = primary_display_idx; pi.displays = displays; pi.current_display = self.display_idx as _; #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -2006,8 +2010,8 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] let _h = try_start_record_cursor_pos(); self.auto_disconnect_timer = Self::get_auto_disconenct_timer(); - s.try_add_primay_video_service(); - s.add_connection(self.inner.clone(), &noperms); + s.try_add_monitor_service(self.display_idx); + s.add_monitor_connection(self.inner.clone(), &noperms, self.display_idx); } } } @@ -4150,7 +4154,9 @@ impl Connection { let display_idx = s.display as usize; if self.display_idx != display_idx { if let Some(server) = self.server.upgrade() { - self.switch_display_to(display_idx, server.clone()); + if !self.switch_display_to(display_idx, server.clone()) { + return; + } #[cfg(not(any(target_os = "android", target_os = "ios")))] if !self.view_camera && s.width != 0 && s.height != 0 { @@ -4177,6 +4183,13 @@ impl Connection { } } + fn video_source_count(video_source: VideoSource) -> usize { + match video_source { + VideoSource::Monitor => display_service::get_sync_displays().len(), + VideoSource::Camera => camera::Cameras::get_sync_cameras().len(), + } + } + fn video_source(&self) -> VideoSource { if self.view_camera { VideoSource::Camera @@ -4185,18 +4198,28 @@ impl Connection { } } - fn switch_display_to(&mut self, display_idx: usize, server: Arc>) { + fn switch_display_to(&mut self, display_idx: usize, server: Arc>) -> bool { + let source_count = Self::video_source_count(self.video_source()); + if display_idx >= source_count { + // Do not remap an explicit switch: its resolution belongs to the requested source. + log::warn!( + "Ignore switch to invalid {:?} index {}, available source count: {}", + self.video_source(), + display_idx, + source_count + ); + return false; + } + let new_service_name = video_service::get_service_name(self.video_source(), display_idx); let old_service_name = video_service::get_service_name(self.video_source(), self.display_idx); let mut lock = server.write().unwrap(); - if display_idx != *display_service::PRIMARY_DISPLAY_IDX { - if !lock.contains(&new_service_name) { - lock.add_service(Box::new(video_service::new( - self.video_source(), - display_idx, - ))); - } + if !lock.contains(&new_service_name) { + lock.add_service(Box::new(video_service::new( + self.video_source(), + display_idx, + ))); } // For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately. // Unnecessary capturers will be removed then. @@ -4205,6 +4228,7 @@ impl Connection { } lock.subscribe(&new_service_name, self.inner.clone(), true); self.display_idx = display_idx; + true } #[cfg(windows)] @@ -4231,26 +4255,61 @@ impl Connection { async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) { let video_source = self.video_source(); - if let Some(sever) = self.server.upgrade() { - let mut lock = sever.write().unwrap(); - for display in add.iter() { + let source_count = Self::video_source_count(video_source); + // Only add/set can create services; sub only narrows existing subscriptions. + let valid_add = add + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let valid_sub = sub + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let valid_set = set + .iter() + .copied() + .filter(|display| *display < source_count) + .collect::>(); + let invalid_count = + add.len() + sub.len() + set.len() - valid_add.len() - valid_sub.len() - valid_set.len(); + if invalid_count != 0 { + log::warn!( + "Ignore {} invalid {:?} indices, available source count: {}", + invalid_count, + video_source, + source_count + ); + } + // Passing an invalid sub request as an empty exclude list would unsubscribe all services. + if (!add.is_empty() && valid_add.is_empty()) + || (add.is_empty() && !sub.is_empty() && valid_sub.is_empty()) + || (add.is_empty() && sub.is_empty() && !set.is_empty() && valid_set.is_empty()) + { + return; + } + + if let Some(server) = self.server.upgrade() { + let mut lock = server.write().unwrap(); + for display in valid_add.iter() { let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { lock.add_service(Box::new(video_service::new(video_source, *display))); } } - for display in set.iter() { + for display in valid_set.iter() { let service_name = video_service::get_service_name(video_source, *display); if !lock.contains(&service_name) { lock.add_service(Box::new(video_service::new(video_source, *display))); } } if !add.is_empty() { - lock.capture_displays(self.inner.clone(), video_source, add, true, false); + lock.capture_displays(self.inner.clone(), video_source, &valid_add, true, false); } else if !sub.is_empty() { - lock.capture_displays(self.inner.clone(), video_source, sub, false, true); + lock.capture_displays(self.inner.clone(), video_source, &valid_sub, false, true); } else { - lock.capture_displays(self.inner.clone(), video_source, set, true, true); + lock.capture_displays(self.inner.clone(), video_source, &valid_set, true, true); } self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1; if self.follow_remote_window { diff --git a/src/server/display_service.rs b/src/server/display_service.rs index fe3621f26..946952ccd 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -25,9 +25,6 @@ struct ChangedResolution { lazy_static::lazy_static! { static ref IS_CAPTURER_MAGNIFIER_SUPPORTED: bool = is_capturer_mag_supported(); static ref CHANGED_RESOLUTIONS: Arc>> = Default::default(); - // Initial primary display index. - // It should not be updated when displays changed. - pub static ref PRIMARY_DISPLAY_IDX: usize = get_primary(); static ref SYNC_DISPLAYS: Arc> = Default::default(); } @@ -41,22 +38,14 @@ struct SyncDisplaysInfo { } impl SyncDisplaysInfo { - fn check_changed(&mut self, displays: Vec) { - if self.displays.len() != displays.len() { - self.displays = displays; - if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { - self.is_synced = false; - } + fn check_changed(&mut self, displays: &[DisplayInfo]) { + if self.displays.as_slice() == displays { return; } - for (i, d) in displays.iter().enumerate() { - if d != &self.displays[i] { - self.displays = displays; - if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { - self.is_synced = false; - } - return; - } + + self.displays = displays.to_vec(); + if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) { + self.is_synced = false; } } @@ -304,6 +293,11 @@ pub(super) fn get_display_info(idx: usize) -> Option { // Display to DisplayInfo // The DisplayInfo is be sent to the peer. pub(super) fn check_update_displays(all: &Vec) { + let _ = update_sync_displays(all); +} + +// Return the converted input snapshot while updating the shared display cache. +pub(super) fn update_sync_displays(all: &Vec) -> Vec { // For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`. // If there are multiple displays, we use the logical size for `uinput` by setting scale to d.scale(). #[cfg(target_os = "linux")] @@ -346,7 +340,8 @@ pub(super) fn check_update_displays(all: &Vec) { } }) .collect::>(); - SYNC_DISPLAYS.lock().unwrap().check_changed(displays); + SYNC_DISPLAYS.lock().unwrap().check_changed(&displays); + displays } pub fn is_inited_msg() -> Option { @@ -357,34 +352,38 @@ pub fn is_inited_msg() -> Option { None } -pub async fn update_get_sync_displays_on_login() -> ResultType> { +// Return the primary index with the refreshed list so login cannot mix display snapshots. +pub async fn update_get_sync_displays_on_login() -> ResultType<(Vec, usize)> { #[cfg(target_os = "linux")] { if !is_x11() { - return super::wayland::get_displays().await; + let (displays, primary_display_idx) = + super::wayland::get_displays_and_primary().await?; + let primary_display_idx = + normalize_primary_display_idx(primary_display_idx, displays.len()); + return Ok((displays, primary_display_idx)); } } #[cfg(not(windows))] let displays = display_service::try_get_displays(); #[cfg(windows)] let displays = display_service::try_get_displays_add_amyuni_headless(); - check_update_displays(&displays?); - Ok(SYNC_DISPLAYS.lock().unwrap().displays.clone()) + let displays = displays?; + let primary_display_idx = get_primary_2(&displays); + let sync_displays = update_sync_displays(&displays); + let primary_display_idx = + normalize_primary_display_idx(primary_display_idx, sync_displays.len()); + Ok((sync_displays, primary_display_idx)) } #[inline] -pub fn get_primary() -> usize { - #[cfg(target_os = "linux")] - { - if !is_x11() { - return match super::wayland::get_primary() { - Ok(n) => n, - Err(_) => 0, - }; - } +fn normalize_primary_display_idx(primary_display_idx: usize, display_len: usize) -> usize { + // Zero is the protocol fallback when the list is empty or its primary index is stale. + if primary_display_idx < display_len { + primary_display_idx + } else { + 0 } - - try_get_displays().map(|d| get_primary_2(&d)).unwrap_or(0) } #[inline] @@ -486,3 +485,16 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType> } Ok(displays) } + +#[cfg(test)] +mod tests { + use super::normalize_primary_display_idx; + + #[test] + fn normalize_primary_display_idx_bounds() { + assert_eq!(normalize_primary_display_idx(0, 0), 0); + assert_eq!(normalize_primary_display_idx(0, 2), 0); + assert_eq!(normalize_primary_display_idx(1, 2), 1); + assert_eq!(normalize_primary_display_idx(2, 2), 0); + } +} diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 1e0efc0f4..7927096a6 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -175,8 +175,7 @@ pub(super) async fn check_init() -> ResultType<()> { *PIPEWIRE_INITIALIZED.write().unwrap() = true; let num = all.len(); let primary = super::display_service::get_primary_2(&all); - super::display_service::check_update_displays(&all); - let mut displays = super::display_service::get_sync_displays(); + let mut displays = super::display_service::update_sync_displays(&all); for display in displays.iter_mut() { display.cursor_embedded = is_cursor_embedded(); } @@ -220,27 +219,15 @@ pub(super) async fn check_init() -> ResultType<()> { Ok(()) } -pub(super) async fn get_displays() -> ResultType> { +pub(super) async fn get_displays_and_primary() -> ResultType<(Vec, usize)> { check_init().await?; + // Keep one read guard so clear/reinitialization cannot split these across cache snapshots. let cap_map = CAP_DISPLAY_INFO.read().unwrap(); if let Some(addr) = cap_map.values().next() { let cap_display_info: *const CapDisplayInfo = *addr as _; unsafe { let cap_display_info = &*cap_display_info; - Ok(cap_display_info.displays.clone()) - } - } else { - bail!("Failed to get capturer display info"); - } -} - -pub(super) fn get_primary() -> ResultType { - let cap_map = CAP_DISPLAY_INFO.read().unwrap(); - if let Some(addr) = cap_map.values().next() { - let cap_display_info: *const CapDisplayInfo = *addr as _; - unsafe { - let cap_display_info = &*cap_display_info; - Ok(cap_display_info.primary) + Ok((cap_display_info.displays.clone(), cap_display_info.primary)) } } else { bail!("Failed to get capturer display info"); From b4af82157bc5b44b62e66c1e7b50cc945bc42532 Mon Sep 17 00:00:00 2001 From: CHarris Date: Fri, 24 Jul 2026 06:35:49 -0400 Subject: [PATCH 15/21] fix: refresh wayland uinput range on compositor layout change (#15628) * fix: refresh wayland uinput range on compositor layout change The uinput absolute range is computed once at session init. If the compositor layout changes mid-session (monitor scale or position change, or a portal virtual output appearing once capture starts), injected coordinates are rescaled by the stale range and land offset. Poll the live desktop bounding box from the display service loop while subscribed (one wayland roundtrip, throttled to 1.5s, no subprocesses) and re-apply the uinput resolution when it changes. Also read a fresh layout when computing the initial range in check_init, since the cache is not cleared when a session closes through the restore-token path. This is the X component of #15601. The stale advertised origins (the Y component) are not touched here: re-advertising DisplayInfo mid-session trips the portal re-negotiation and can drop displays. Signed-off-by: Cody Harris * fix: bound the mouse resolution IPC wait during session init Wrap update_mouse_resolution in the same 3s timeout the periodic refresh uses, so a hung IPC response can't stall check_init. Co-Authored-By: Claude Fable 5 * fix: build timeout future inside runtime, split linux lazy_static Constructing the timeout future eagerly as the block_on argument panics with 'there is no reactor running'; move it into the async block so it is built inside the runtime context. Also move WAYLAND_UINPUT_RECT into its own cfg-gated lazy_static block, an attribute on a single item inside the shared block does not compile. * fix: confirm uinput mouse device adopted new range before caching rect send_refresh() now waits for the mouse service to ack that it recreated the device with the new range instead of firing and forgetting, and update_mouse_resolution() propagates that result. The layout poller only caches the rect after the device actually adopts the range, so a failed refresh errors and retries on the next check. The ack read is bounded by IPC_REQUEST_TIMEOUT, matching the keyboard get-key-state path. * fix: propagate refresh failures instead of caching a stale range - input_service: error when the custom-mouse downcast fails so the poller retries instead of caching an unconfirmed refresh - uinput: on device recreation failure, keep the current device and the IPC connection and withhold the ack so the client retries, instead of killing the mouse handler * fix: remap injected wayland coords onto the live layout after a monitor moves The range refresh corrects the uinput ABS bounds, but a single-display client sends whole-desktop coordinates offset by the origin of the display it follows, taken from the layout advertised at session init. When another monitor is rescaled or moved that origin shifts, so the coordinate lands offset before it reaches uinput and the range refresh cannot recover it. Snapshot the per-display layout at init, poll the live layout on the existing 1.5s throttle, and when they differ remap each injected move into the followed display's current rectangle (matched by connector name, index fallback when the compositor reports none). No-op and lock-free while the layout is unchanged. --------- Signed-off-by: Cody Harris Co-authored-by: Claude Fable 5 --- libs/scrap/src/wayland/display.rs | 315 +++++++++++++++++++++++++++++- src/server/display_service.rs | 144 ++++++++++++++ src/server/input_service.rs | 33 +++- src/server/uinput.rs | 34 +++- src/server/wayland.rs | 28 ++- 5 files changed, 531 insertions(+), 23 deletions(-) diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs index a5c937491..bed90fd76 100644 --- a/libs/scrap/src/wayland/display.rs +++ b/libs/scrap/src/wayland/display.rs @@ -14,6 +14,9 @@ lazy_static! { static ref DISPLAYS: Mutex>> = Mutex::new(None); } +static MISSING_LOGICAL_SIZE_WARNED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); pub struct Displays { @@ -217,7 +220,26 @@ pub fn clear_wayland_displays_cache() { // Return (min_x, max_x, min_y, max_y) pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { let wayland_displays = get_displays(); - let displays = &wayland_displays.displays; + desktop_rect_of(&wayland_displays.displays) +} + +// The desktop rect and per-display logical rects, always read live from the +// compositor in a single roundtrip. Skips the displays cache and the primary-monitor +// detection (which may spawn external commands), so it is cheap enough to poll for +// layout changes. https://github.com/rustdesk/rustdesk/issues/15601 +pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec)> { + match get_wayland_displays() { + Ok(displays) => { + desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays))) + } + Err(err) => { + warn!("Failed to get wayland displays: {}", err); + None + } + } +} + +fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i32)> { if displays.is_empty() { return None; } @@ -243,10 +265,13 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { // This may occur if the Wayland compositor does not provide logical size information, // or if display information is incomplete. We fall back to physical size, which provides // usable dimensions, but may not always be correct depending on compositor behavior. - warn!( + // Warn only once, the live path polls this while a session is active. + if !MISSING_LOGICAL_SIZE_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + warn!( "Display at ({}, {}) is missing logical_size; falling back to physical size ({}, {}).", d.x, d.y, d.width, d.height ); + } (d.width, d.height) }; max_x = max_x.max(d.x + size.0); @@ -254,3 +279,289 @@ pub fn get_desktop_rect_for_uinput() -> Option<(i32, i32, i32, i32)> { } Some((min_x, max_x, min_y, max_y)) } + +/// One display's logical rectangle in the desktop coordinate space the client uses: +/// logical origin plus logical size, falling back to physical size when the compositor +/// reports no logical size (matching `desktop_rect_of`). +#[derive(Clone, Debug, PartialEq)] +pub struct DisplayRect { + pub name: String, + pub x: i32, + pub y: i32, + pub w: i32, + pub h: i32, +} + +fn logical_rects_of(displays: &[WaylandDisplayInfo]) -> Vec { + // Match `desktop_rect_of`: a single display uses its physical size (its scale is + // reported as 1.0 to the client), multiple displays use logical size. This keeps a + // single display a no-op for the remap (its origin never shifts) and keeps the rects + // in the same coordinate space the client's coordinates are expressed in. + let single = displays.len() == 1; + displays + .iter() + .map(|d| { + let (w, h) = if single { + (d.width, d.height) + } else { + d.logical_size.unwrap_or((d.width, d.height)) + }; + DisplayRect { + name: d.name.clone(), + x: d.x, + y: d.y, + w, + h, + } + }) + .collect() +} + +// Per-display logical rects from the cached init snapshot. The client's injected +// coordinates are `local + origin` in this layout, so it is the baseline to map from. +pub fn get_display_rects_for_uinput() -> Vec { + logical_rects_of(&get_displays().displays) +} + +/// Remap an injected coordinate from the layout the client still believes in +/// (`baseline`, captured at session init) to the current compositor layout (`live`). +/// +/// A single-display client sends whole-desktop coordinates: `local + baseline_origin[d]` +/// for whichever display `d` it is following. If that display's origin or logical size +/// has since changed (e.g. another monitor was rescaled, shifting this one), the +/// coordinate lands offset. We find the baseline display the point falls in, then map +/// the point into the same display's live rectangle, matched by connector name (or, when +/// the compositor reports no names, by index while the display count is unchanged). +/// +/// Returns the input unchanged when the point is outside every baseline display or the +/// matched display is gone, so a failed match never moves the cursor further off than +/// leaving it alone. https://github.com/rustdesk/rustdesk/issues/15601 +pub fn remap_to_live_layout( + x: i32, + y: i32, + baseline: &[DisplayRect], + live: &[DisplayRect], +) -> (i32, i32) { + let Some((bi, b)) = baseline + .iter() + .enumerate() + .find(|(_, r)| x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) + else { + return (x, y); + }; + let matched = if b.name.is_empty() { + // Nameless compositor: index-match, but only while the count is unchanged. A + // named display that is simply gone from the live layout must fall through to + // "unchanged" below, not get index-matched to whatever now sits at its index. + if baseline.len() == live.len() { + live.get(bi) + } else { + None + } + } else { + live.iter().find(|r| r.name == b.name) + }; + let Some(l) = matched else { + return (x, y); + }; + // Map the point into the live rectangle, preserving position within the display so a + // scale change on the followed display itself is corrected too, not only a shift. + // Scale by (extent - 1) so both endpoints land exactly: the client clamps its + // coordinate to `[origin, origin + w - 1]`, and mapping that span to the live span's + // `[0, w' - 1]` keeps the far edge reachable (hot corners) in both directions, and + // stays an exact shift when the size is unchanged. + let nx = map_axis(x, b.x, b.w, l.x, l.w); + let ny = map_axis(y, b.y, b.h, l.y, l.h); + (nx, ny) +} + +fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_extent: i32) -> i32 { + if base_extent <= 1 || live_extent <= 1 { + return live_origin; + } + live_origin + ((v - base_origin) as i64 * (live_extent - 1) as i64 / (base_extent - 1) as i64) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn display( + x: i32, + y: i32, + width: i32, + height: i32, + logical_size: Option<(i32, i32)>, + ) -> WaylandDisplayInfo { + WaylandDisplayInfo { + name: "".to_owned(), + x, + y, + width, + height, + logical_size, + refresh_rate: 60, + } + } + + #[test] + fn test_desktop_rect_empty() { + assert_eq!(desktop_rect_of(&[]), None); + } + + #[test] + fn test_desktop_rect_single_display_uses_physical_size() { + let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))]; + assert_eq!(desktop_rect_of(&displays), Some((0, 2880, 0, 1800))); + } + + #[test] + fn test_desktop_rect_multi_display_uses_logical_size() { + // Laptop panel at 155% below two stacked externals at 100%. + let displays = [ + display(0, 718, 2880, 1800, Some((1859, 1162))), + display(1859, 0, 1920, 1080, Some((1920, 1080))), + display(1859, 1080, 1920, 1080, Some((1920, 1080))), + ]; + assert_eq!(desktop_rect_of(&displays), Some((0, 3779, 0, 2160))); + } + + #[test] + fn test_desktop_rect_missing_logical_size_falls_back_to_physical() { + let displays = [ + display(0, 0, 2560, 1440, None), + display(2560, 0, 2560, 1440, Some((2560, 1440))), + ]; + assert_eq!(desktop_rect_of(&displays), Some((0, 5120, 0, 1440))); + } + + fn rect(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayRect { + DisplayRect { + name: name.to_owned(), + x, + y, + w, + h, + } + } + + // The reported failure: connect to the second display, rescale the primary. + // Baseline: two 2560-wide displays side by side, both at 100%. + // Live: the primary (DP-1) rescaled to 125% -> 2048 logical wide, so the second + // display (DP-2) shifts left from x=2560 to x=2048. A client following DP-2 keeps + // sending coordinates offset by DP-2's old origin (2560). + #[test] + fn test_remap_primary_rescale_shifts_second_display() { + let baseline = [ + rect("DP-1", 0, 0, 2560, 1440), + rect("DP-2", 2560, 0, 2560, 1440), + ]; + let live = [ + rect("DP-1", 0, 0, 2048, 1440), + rect("DP-2", 2048, 0, 2560, 1440), + ]; + // Top-left of DP-2: client sends (2560, 0), should land at live DP-2 origin. + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0)); + // Middle of DP-2 keeps its fractional position. + assert_eq!( + remap_to_live_layout(3840, 720, &baseline, &live), + (3328, 720) + ); + } + + // A point on the rescaled display itself is squeezed to its new logical width. + #[test] + fn test_remap_scales_within_resized_display() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 2560, 1440)]; + // x=1280 across the 2560-wide baseline DP-1 -> proportionally across the 2048-wide + // live DP-1 (endpoint-preserving scale, so ~1px off the naive midpoint). + assert_eq!(remap_to_live_layout(1280, 500, &baseline, &live), (1023, 500)); + } + + // The far edge of the followed display stays reachable when it is enlarged, so hot + // corners keep working. Baseline DP-1 is 2048 wide, live DP-1 is 2560 wide; the + // client's last column (2047) must map to the live last column (2559), not 2558. + #[test] + fn test_remap_enlarged_display_reaches_far_edge() { + let baseline = [rect("DP-1", 0, 0, 2048, 1440), rect("DP-2", 2048, 0, 1920, 1080)]; + let live = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 1920, 1080)]; + assert_eq!(remap_to_live_layout(2047, 0, &baseline, &live), (2559, 0)); + assert_eq!(remap_to_live_layout(0, 0, &baseline, &live), (0, 0)); + } + + // No drift: identical layouts map every point to itself. + #[test] + fn test_remap_identity_when_unchanged() { + let layout = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(3000, 700, &layout, &layout), (3000, 700)); + } + + // Point outside every baseline display is left untouched. + #[test] + fn test_remap_point_outside_all_displays_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440)]; + assert_eq!(remap_to_live_layout(9000, 9000, &baseline, &live), (9000, 9000)); + } + + // Matched display gone from the live layout (e.g. unplugged): leave the point be + // rather than mapping it somewhere wrong. + #[test] + fn test_remap_display_removed_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); + } + + // Nameless compositor: fall back to index matching while the count is unchanged. + #[test] + fn test_remap_nameless_index_fallback() { + let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; + let live = [rect("", 0, 0, 2048, 1440), rect("", 2048, 0, 2560, 1440)]; + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2048, 0)); + } + + // Nameless compositor with a changed count: cannot index-match safely, so no-op. + #[test] + fn test_remap_nameless_count_changed_unchanged() { + let baseline = [rect("", 0, 0, 2560, 1440), rect("", 2560, 0, 2560, 1440)]; + let live = [rect("", 0, 0, 2048, 1440)]; + assert_eq!(remap_to_live_layout(2560, 0, &baseline, &live), (2560, 0)); + } + + // A named display absent from the live layout, but the count is unchanged (e.g. a + // monitor was swapped for a different one at the same index): the index fallback is + // for nameless layouts only, so a named miss stays unchanged rather than mapping to + // whatever now occupies that index. + #[test] + fn test_remap_named_miss_equal_count_unchanged() { + let baseline = [rect("DP-1", 0, 0, 2560, 1440), rect("DP-2", 2560, 0, 2560, 1440)]; + let live = [rect("DP-1", 0, 0, 2048, 1440), rect("HDMI-1", 2048, 0, 1920, 1080)]; + assert_eq!(remap_to_live_layout(2600, 100, &baseline, &live), (2600, 100)); + } + + // A single display uses physical size in both baseline and live (scale reported as + // 1.0), so it never drifts and the remap is a no-op even across a rescale. + #[test] + fn test_logical_rects_single_display_uses_physical() { + let displays = [display(0, 0, 2560, 1440, Some((2048, 1152)))]; + assert_eq!( + logical_rects_of(&displays), + vec![rect("", 0, 0, 2560, 1440)] + ); + } + + // Multiple displays use logical size, falling back to physical when absent. + #[test] + fn test_logical_rects_multi_display_uses_logical() { + let displays = [ + display(0, 0, 2560, 1440, Some((2048, 1152))), + display(2048, 0, 1920, 1080, None), + ]; + assert_eq!( + logical_rects_of(&displays), + vec![rect("", 0, 0, 2048, 1152), rect("", 2048, 0, 1920, 1080)] + ); + } +} diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 946952ccd..8531076a9 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -28,6 +28,144 @@ lazy_static::lazy_static! { static ref SYNC_DISPLAYS: Arc> = Default::default(); } +#[cfg(target_os = "linux")] +lazy_static::lazy_static! { + static ref WAYLAND_UINPUT_RECT: Mutex = Default::default(); + static ref WAYLAND_LAYOUT: Mutex = Default::default(); +} + +#[cfg(target_os = "linux")] +const WAYLAND_LAYOUT_CHECK_INTERVAL: Duration = Duration::from_millis(1500); + +#[cfg(target_os = "linux")] +#[derive(Default)] +struct WaylandUinputRect { + rect: Option<(i32, i32, i32, i32)>, + last_check: Option, +} + +// Per-display layout used to correct injected coordinates when the compositor moves a +// monitor mid-session. The client keeps sending coordinates offset by the layout it was +// told at session init (`baseline`); we remap them onto the current layout (`live`). +// https://github.com/rustdesk/rustdesk/issues/15601 +#[cfg(target_os = "linux")] +#[derive(Default)] +struct WaylandLayout { + baseline: Vec, + live: Vec, +} + +// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic: +// the common (no-drift) case never touches the layout mutex. +#[cfg(target_os = "linux")] +static WAYLAND_LAYOUT_DRIFTED: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "linux")] +pub(super) fn set_wayland_uinput_rect(rect: (i32, i32, i32, i32)) { + WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); +} + +#[cfg(target_os = "linux")] +pub(super) fn set_wayland_layout_baseline(baseline: Vec) { + WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed); + let mut lock = WAYLAND_LAYOUT.lock().unwrap(); + lock.baseline = baseline; + lock.live.clear(); +} + +// Remap an injected coordinate onto the live compositor layout when it has drifted from +// what the client was told at session init. Lock-free no-op otherwise. +#[cfg(target_os = "linux")] +pub(super) fn remap_wayland_uinput_coord(x: i32, y: i32) -> (i32, i32) { + if !WAYLAND_LAYOUT_DRIFTED.load(Ordering::Relaxed) { + return (x, y); + } + let lock = WAYLAND_LAYOUT.lock().unwrap(); + scrap::wayland::display::remap_to_live_layout(x, y, &lock.baseline, &lock.live) +} + +// The uinput absolute range is set when the session inits. If the compositor layout +// changes afterwards (monitor scale/position change, or a portal virtual output +// appearing once the capture starts), injected coordinates get rescaled by the stale +// range and land offset, https://github.com/rustdesk/rustdesk/issues/15601 +#[cfg(target_os = "linux")] +fn refresh_wayland_uinput_rect_if_changed() { + if is_x11() || !crate::input_service::wayland_use_uinput() { + return; + } + { + let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap(); + if let Some(last_check) = lock.last_check { + if last_check.elapsed() < WAYLAND_LAYOUT_CHECK_INTERVAL { + return; + } + } + lock.last_check = Some(std::time::Instant::now()); + } + let Some((rect, live_rects)) = scrap::wayland::display::get_layout_for_uinput_live() else { + return; + }; + // Refresh the per-display layout every poll: monitor origins can shift (e.g. two + // displays swap positions) without changing the overall desktop rect, and the mouse + // path needs the current per-display geometry to correct coordinates. + let drifted = { + let mut layout = WAYLAND_LAYOUT.lock().unwrap(); + let drifted = !layout.baseline.is_empty() + && !live_rects.is_empty() + && layout.baseline != live_rects; + layout.live = live_rects; + drifted + }; + // The remap corrects for per-display origin shifts; the uinput ABS range corrects for + // the overall bounding box. Only enable the remap once the range matches the live + // layout, otherwise moves would be remapped into a range the device is not yet using. + // A drift with no bbox change (origins swapped) needs no range update and enables now. + let mut range_ok = WAYLAND_UINPUT_RECT.lock().unwrap().rect == Some(rect); + if !range_ok { + let (minx, maxx, miny, maxy) = rect; + log::info!( + "desktop layout changed, update mouse resolution: ({}, {}), ({}, {})", + minx, + maxx, + miny, + maxy + ); + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => { + // Bound the IPC wait, this runs on the display service loop and + // `set_resolution()` has no timeout on the response read. + // timeout must be built inside the runtime, or it panics + // "there is no reactor running". See clipboard_service.rs. + match rt.block_on(async { + timeout( + 3_000, + crate::input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + }) { + // Record the rect only after a successful apply, so a transient + // failure is retried on the next check. + Ok(Ok(())) => { + WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect); + range_ok = true; + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } + } + Err(err) => { + log::error!("Failed to build tokio runtime: {}", err); + } + } + } + // Publish the flag last: a `true` read is always backed by a current `live` and a + // matching uinput range. A failed range apply leaves this false and retries next poll. + WAYLAND_LAYOUT_DRIFTED.store(drifted && range_ok, Ordering::Relaxed); +} + // https://github.com/rustdesk/rustdesk/pull/8537 static TEMP_IGNORE_DISPLAYS_CHANGED: AtomicBool = AtomicBool::new(false); @@ -231,6 +369,12 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> { sp.send(msg_out); log::info!("Displays changed"); } + + #[cfg(target_os = "linux")] + if sp.has_subscribes() { + refresh_wayland_uinput_rect_if_changed(); + } + std::thread::sleep(Duration::from_millis(300)); } diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 91a2901dc..1d4deeb65 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -661,20 +661,22 @@ pub async fn setup_rdp_input() -> ResultType<(), Box> { pub async fn update_mouse_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultType<()> { set_uinput_resolution(minx, maxx, miny, maxy).await?; - std::thread::spawn(|| { + // Confirm the device adopted the new range before the caller caches it. + // spawn_blocking because ENIGO is a std Mutex and send_refresh blocks on IPC. + tokio::task::spawn_blocking(move || { if let Some(mouse) = ENIGO.lock().unwrap().get_custom_mouse() { if let Some(mouse) = mouse .as_mut_any() .downcast_mut::() { - allow_err!(mouse.send_refresh()); - } else { - log::error!("failed downcast uinput mouse"); + return mouse.send_refresh(); } + bail!("failed to downcast custom mouse to UInputMouse"); } - }); - - Ok(()) + // No custom mouse: nothing to refresh. + Ok(()) + }) + .await? } #[cfg(target_os = "linux")] @@ -1098,12 +1100,23 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) { MOUSE_TYPE_MOVE => { // Switching back to absolute movement implicitly disables relative mouse mode. set_relative_mouse_active(conn, false); - en.mouse_move_to(evt.x, evt.y); + // On Wayland with uinput, the client sends coordinates in the layout it was + // told at session init. If the compositor has since moved a monitor, correct + // them onto the current layout. https://github.com/rustdesk/rustdesk/issues/15601 + #[cfg(target_os = "linux")] + let (mx, my) = if wayland_use_uinput() { + super::display_service::remap_wayland_uinput_coord(evt.x, evt.y) + } else { + (evt.x, evt.y) + }; + #[cfg(not(target_os = "linux"))] + let (mx, my) = (evt.x, evt.y); + en.mouse_move_to(mx, my); *LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input { conn, time: get_time(), - x: evt.x, - y: evt.y, + x: mx, + y: my, }; } // MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications. diff --git a/src/server/uinput.rs b/src/server/uinput.rs index a1947d79f..496da709f 100644 --- a/src/server/uinput.rs +++ b/src/server/uinput.rs @@ -130,7 +130,16 @@ pub mod client { } pub fn send_refresh(&mut self) -> ResultType<()> { - self.send(Data::Mouse(DataMouse::Refresh)) + self.rt + .block_on(self.conn.send(&Data::Mouse(DataMouse::Refresh)))?; + // Wait for the service to confirm it recreated the device, so a + // failed refresh is distinguishable from a good one. + match self.rt.block_on(self.conn.next_timeout(IPC_REQUEST_TIMEOUT)) { + Ok(Some(Data::Empty)) => Ok(()), + Ok(Some(resp)) => bail!("unexpected uinput mouse refresh response: {:?}", &resp), + Ok(None) => bail!("uinput mouse refresh failed, connection closed"), + Err(e) => bail!("uinput mouse refresh timeout {}, {}", IPC_REQUEST_TIMEOUT, e), + } } } @@ -851,9 +860,10 @@ pub mod service { match data { Data::Mouse(data) => { if let DataMouse::Refresh = data { - let resolution = RESOLUTION.lock().unwrap(); - let rng_x = resolution.0.clone(); - let rng_y = resolution.1.clone(); + let (rng_x, rng_y) = { + let resolution = RESOLUTION.lock().unwrap(); + (resolution.0.clone(), resolution.1.clone()) + }; log::info!( "Refresh uinput mouce with rng_x: ({}, {}), rng_y: ({}, {})", rng_x.0, @@ -861,11 +871,19 @@ pub mod service { rng_y.0, rng_y.1 ); - mouse = match mouce::UInputMouseManager::new(rng_x, rng_y) { - Ok(mouse) => mouse, + match mouce::UInputMouseManager::new(rng_x, rng_y) { + Ok(m) => { + mouse = m; + // Ack: device adopted the new range. + allow_err!(stream.send(&Data::Empty).await); + } Err(e) => { - log::error!("Failed to create mouse, {}", e); - return; + // Keep the current device; withhold the ack + // so the client times out and retries. + log::error!( + "Failed to recreate uinput mouse, keeping current: {}", + e + ); } } } else { diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 7927096a6..dacce9485 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -137,6 +137,9 @@ pub(super) async fn check_init() -> ResultType<()> { if !is_x11() { if CAP_DISPLAY_INFO.read().unwrap().is_empty() { if crate::input_service::wayland_use_uinput() { + // The cached layout may predate compositor changes made while no session + // was active, https://github.com/rustdesk/rustdesk/issues/15601 + scrap::wayland::display::clear_wayland_displays_cache(); if let Some((minx, maxx, miny, maxy)) = scrap::wayland::display::get_desktop_rect_for_uinput() { @@ -147,9 +150,28 @@ pub(super) async fn check_init() -> ResultType<()> { miny, maxy ); - allow_err!( - input_service::update_mouse_resolution(minx, maxx, miny, maxy).await - ); + // Bound the IPC wait like the periodic refresh does, so a hung + // response can't stall session init. + match timeout( + 3_000, + input_service::update_mouse_resolution(minx, maxx, miny, maxy), + ) + .await + { + Ok(Ok(())) => { + super::display_service::set_wayland_uinput_rect(( + minx, maxx, miny, maxy, + )); + // Snapshot the per-display layout the client's coordinates + // will be based on, so the mouse path can correct them if + // the compositor moves a monitor mid-session. + super::display_service::set_wayland_layout_baseline( + scrap::wayland::display::get_display_rects_for_uinput(), + ); + } + Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err), + Err(err) => log::error!("Failed to update mouse resolution: {}", err), + } } else { log::warn!("Failed to get desktop rect for uinput"); } From ad9dac100102008ba1ae20067c0a4dac0fc6847c Mon Sep 17 00:00:00 2001 From: fufesou Date: Sat, 25 Jul 2026 09:36:25 +0800 Subject: [PATCH 16/21] fix(keyboard): jis, macos, muhenkan henkan (#15669) Signed-off-by: fufesou --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 23cf35cbe..78ff9eb46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6940,7 +6940,7 @@ dependencies = [ [[package]] name = "rdev" version = "0.5.0-2" -source = "git+https://github.com/rustdesk-org/rdev#871bf1c856d6a30af2f56ab8848396a025140855" +source = "git+https://github.com/rustdesk-org/rdev#23e24dd6b35452a495dae0ae6d99395e9755ab0f" dependencies = [ "cocoa 0.24.1", "core-foundation 0.9.4", From cefff781d4994a306452dcd584336ee0896e2e15 Mon Sep 17 00:00:00 2001 From: 21pages Date: Sat, 25 Jul 2026 15:21:13 +0800 Subject: [PATCH 17/21] feat(recording): add visibility and service storage options (#15662) * feat(recording): add visibility and service storage options - support hide-recording-button in Flutter and Sciter - allow a custom save directory for Windows service recordings - sanitize peer IDs used in recording filenames Tested: - with hide-recording-button=Y and allow-auto-record-outgoing=Y, outgoing sessions are recorded automatically while the recording button remains hidden and cannot be stopped from the UI; verified on Flutter desktop, Sciter, and Android - windows-service-video-save-directory takes effect when the Windows client runs as an installed service - the Windows controlling side can save recordings for direct IP:port connections Signed-off-by: 21pages * update hbb_common Signed-off-by: 21pages * fix(recording): validate configured save directories - trim configured recording directory paths - reject non-absolute paths and fall back to defaults - warn when a non-empty path is invalid Signed-off-by: 21pages * fix(recording): validate configured save directories Signed-off-by: 21pages --------- Signed-off-by: 21pages --- flutter/lib/common/widgets/toolbar.dart | 1 + flutter/lib/consts.dart | 1 + .../lib/desktop/widgets/remote_toolbar.dart | 4 +- libs/hbb_common | 2 +- libs/scrap/src/common/record.rs | 39 +++++++++- src/ui/header.tis | 2 +- src/ui/remote.rs | 5 ++ src/ui/remote.tis | 1 + src/ui_interface.rs | 74 ++++++++++++++++++- 9 files changed, 123 insertions(+), 6 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 83638000b..0e4c5b7a5 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -583,6 +583,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { } // record if (!(isDesktop || isWeb) && + bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' && (ffi.recordingModel.start || (perms["recording"] != false))) { v.add(TTextMenu( child: Row( diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 69f4be59e..722f7a23c 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -104,6 +104,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout"; const String kOptionEnableHwcodec = "enable-hwcodec"; const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming"; const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing"; +const String kOptionHideRecordingButton = "hide-recording-button"; const String kOptionVideoSaveDirectory = "video-save-directory"; const String kOptionAccessMode = "access-mode"; const String kOptionEnableKeyboard = "enable-keyboard"; diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 75fdbe1f8..8f589b79a 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -2740,7 +2740,9 @@ class _RecordMenu extends StatelessWidget { Widget build(BuildContext context) { var ffi = Provider.of(context); var recordingModel = Provider.of(context); - final visible = + final hideRecordingButton = + bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y'; + final visible = !hideRecordingButton && (recordingModel.start || ffi.permissions['recording'] != false); if (!visible) return Offstage(); return _IconMenuButton( diff --git a/libs/hbb_common b/libs/hbb_common index 7e1c392c6..559176122 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 7e1c392c62d39c364127307cd408421dd5f8cfb0 +Subproject commit 559176122bdd5c8afa4e8fd5b706c3d901fb0c15 diff --git a/libs/scrap/src/common/record.rs b/libs/scrap/src/common/record.rs index d121984f1..ffeb25791 100644 --- a/libs/scrap/src/common/record.rs +++ b/libs/scrap/src/common/record.rs @@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer}; const MIN_SECS: u64 = 1; +// Replace characters that are invalid in Windows filename components so recordings remain portable. +// Control characters are also replaced because they can make filenames invalid +// on Windows or invisible and difficult to handle on Linux and macOS. +fn sanitize_filename_component(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { + '_' + } else { + c + } + }) + .collect() +} + #[derive(Debug, Clone)] pub struct RecorderContext { pub server: bool, @@ -45,7 +61,7 @@ impl RecorderContext2 { } let file = if ctx.server { "incoming" } else { "outgoing" }.to_string() + "_" - + &ctx.id.clone() + + &sanitize_filename_component(&ctx.id) + &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string() + &format!( "{}{}_", @@ -421,3 +437,24 @@ impl Drop for HwRecorder { self.ctx.tx.as_ref().map(|tx| tx.send(state)); } } + +#[cfg(test)] +mod tests { + use super::sanitize_filename_component; + + #[test] + fn sanitize_recording_filename_component() { + assert_eq!( + sanitize_filename_component("192.168.1.2:21118"), + "192.168.1.2_21118" + ); + assert_eq!( + sanitize_filename_component("[2001:db8::1]:21118"), + "[2001_db8__1]_21118" + ); + assert_eq!( + sanitize_filename_component("peer/name\\with?bad\nchars"), + "peer_name_with_bad_chars" + ); + } +} diff --git a/src/ui/header.tis b/src/ui/header.tis index 40ccbcbf2..231c71efe 100644 --- a/src/ui/header.tis +++ b/src/ui/header.tis @@ -151,7 +151,7 @@ class Header: Reactor.Component { {svg_action} {svg_display} {svg_keyboard} - {recording_enabled ? {recording ? svg_recording_on : svg_recording_off} : ""} + {recording_enabled && show_recording_button ? {recording ? svg_recording_on : svg_recording_off} : ""} {this.renderKeyboardPop()} {this.renderDisplayPop()} {this.renderActionPop()} diff --git a/src/ui/remote.rs b/src/ui/remote.rs index 1d5ceb139..3a2cca3e0 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession { fn get_id(); fn get_default_pi(); fn get_option(String); + fn get_local_option(String); fn t(String); fn set_option(String, String); fn input_os_password(String, bool); @@ -638,6 +639,10 @@ impl SciterSession { crate::client::translate(name) } + pub fn get_local_option(&self, key: String) -> String { + crate::ui_interface::get_local_option(key) + } + pub fn get_icon(&self) -> String { super::get_icon() } diff --git a/src/ui/remote.tis b/src/ui/remote.tis index 28fbc3763..87c543eb0 100644 --- a/src/ui/remote.tis +++ b/src/ui/remote.tis @@ -17,6 +17,7 @@ var audio_enabled = true; // server side var file_enabled = true; // server side var restart_enabled = true; // server side var recording_enabled = true; // server side +var show_recording_button = handler.get_local_option("hide-recording-button") != "Y"; var privacy_mode_enabled = true; // server side var scroll_body = $(body); var peer_platform = ""; diff --git a/src/ui_interface.rs b/src/ui_interface.rs index 1a8927840..94fde4392 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -911,6 +911,29 @@ pub fn get_langs() -> String { json!(x).to_string() } +// Preserve relative paths for existing configurations and only remove accidental +// surrounding whitespace. Config values are not shell-expanded (for example, `~`). +fn trim_video_save_directory(value: &str) -> Option<&str> { + let value = value.trim(); + if !value.is_empty() { + Some(value) + } else { + None + } +} + +// A Windows service typically runs with System32 as its working directory, so +// require an absolute path to avoid resolving recordings there unexpectedly. +#[cfg(any(windows, test))] +fn validate_windows_service_video_save_directory(value: &str) -> Option<&str> { + let value = trim_video_save_directory(value)?; + if std::path::Path::new(value).is_absolute() { + Some(value) + } else { + None + } +} + #[inline] pub fn video_save_directory(root: bool) -> String { let appname = crate::get_app_name(); @@ -930,6 +953,15 @@ pub fn video_save_directory(root: bool) -> String { // Currently, only installed windows run as root #[cfg(windows)] { + let dir = Config::get_option(OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY); + if let Some(dir) = validate_windows_service_video_save_directory(&dir) { + return dir.to_owned(); + } + if !dir.trim().is_empty() { + log::warn!( + "Ignoring {OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY}: path must be absolute" + ); + } let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned()); let dir = std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",)); @@ -941,8 +973,8 @@ pub fn video_save_directory(root: bool) -> String { let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY); #[cfg(not(any(target_os = "linux", target_os = "macos")))] let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY); - if !dir.is_empty() { - return dir; + if let Some(dir) = trim_video_save_directory(&dir) { + return dir.to_owned(); } #[cfg(any(target_os = "android", target_os = "ios"))] if let Ok(home) = config::APP_HOME_DIR.read() { @@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option { .lock() .unwrap() } + +#[cfg(test)] +mod tests { + use super::{trim_video_save_directory, validate_windows_service_video_save_directory}; + + #[test] + fn trim_configured_video_save_directory() { + assert_eq!( + trim_video_save_directory(" relative/recordings "), + Some("relative/recordings") + ); + assert_eq!(trim_video_save_directory(" "), None); + } + + #[test] + fn validate_service_video_save_directory() { + let absolute = if cfg!(windows) { + r"C:\recordings" + } else { + "/recordings" + }; + let padded = format!(" {absolute} "); + + assert_eq!( + validate_windows_service_video_save_directory(&padded), + Some(absolute) + ); + assert_eq!( + validate_windows_service_video_save_directory("recordings"), + None + ); + assert_eq!( + validate_windows_service_video_save_directory(&format!("\"{absolute}\"")), + None + ); + assert_eq!(validate_windows_service_video_save_directory(" "), None); + } +} From 57456f0b52a50888e60b218f709af06f2cb1b205 Mon Sep 17 00:00:00 2001 From: dongrencd <903151724@qq.com> Date: Sat, 25 Jul 2026 22:33:16 +0800 Subject: [PATCH 18/21] feat(terminal): add Ctrl and Alt toggles to mobile terminal keyboard (#15532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(terminal): add Ctrl toggle and Ctrl+X shortcut keys to mobile terminal floating keyboard Signed-off-by: dongrencd * refactor(terminal): restructure keyboard layout with collapse button - Move | from Row1 position 3 to Row1 end (aligned with collapse button) - Remove ~ from Row2, add collapse button (∨/∧) after PgDn - Row3: conditional render, add ~ and -, remove trailing placeholders - Collapse state persisted via kOptionEnableShowTerminalCtrlKeys - Row3 defaults to collapsed for compact layout Signed-off-by: dongrencd * fix(terminal): restore trailing placeholders in Row3 for alignment Row3 needs trailing placeholders to match Row1/Row2 width (348px) so Ctrl aligns with Tab in Row2 and Esc in Row1. Signed-off-by: dongrencd * fix(terminal): update mobile keyboard layout per review Signed-off-by: dong.ren.cd * fix(terminal): address mobile keyboard review regressions Signed-off-by: dong.ren.cd * fix(terminal): preserve ctrl-j newline mapping on mobile Signed-off-by: dong.ren.cd * fix(terminal): preserve pasted input with modifiers Signed-off-by: dong.ren.cd * fix(terminal): harden mobile modifier and paste input Signed-off-by: dong.ren.cd * fix(terminal): harden mobile paste shortcut handling Signed-off-by: dong.ren.cd * fix(terminal): preserve unicode graphemes under ctrl * fix(terminal): avoid modifier scan for inactive locks * fix(terminal): keep default hardware paste shortcuts * fix(terminal): guard hardware paste with modifier locks * fix(terminal): update mobile key button color role --------- Signed-off-by: dongrencd Signed-off-by: dong.ren.cd Co-authored-by: dongrencd Co-authored-by: dong.ren.cd --- flutter/lib/consts.dart | 1 + flutter/lib/mobile/pages/terminal_page.dart | 237 +++++++++-- .../lib/mobile/terminal_keyboard_utils.dart | 20 + flutter/lib/models/input_modifier_utils.dart | 152 +++++++ flutter/lib/models/terminal_model.dart | 86 +++- flutter/test/input_modifier_utils_test.dart | 390 ++++++++++++++++++ .../test/terminal_keyboard_utils_test.dart | 40 ++ .../test/terminal_model_lifecycle_test.dart | 51 +++ 8 files changed, 929 insertions(+), 48 deletions(-) create mode 100644 flutter/lib/mobile/terminal_keyboard_utils.dart create mode 100644 flutter/test/terminal_keyboard_utils_test.dart create mode 100644 flutter/test/terminal_model_lifecycle_test.dart diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 722f7a23c..ce5441ddf 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -178,6 +178,7 @@ const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note"; const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar"; const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; +const String kOptionShowTerminalCtrlKeys = "show-terminal-extra-ctrl-keys"; // network options const String kOptionAllowWebSocket = "allow-websocket"; diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index cbf47a7e9..a4a76f9af 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -5,8 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common/widgets/dialog.dart'; +import 'package:flutter_hbb/models/input_modifier_utils.dart'; import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/terminal_model.dart'; +import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:xterm/xterm.dart'; import '../../desktop/pages/terminal_connection_manager.dart'; @@ -42,6 +45,11 @@ class _TerminalPageState extends State final GlobalKey _keyboardKey = GlobalKey(); double _keyboardHeight = 0; late bool _showTerminalExtraKeys; + // Ctrl lock state for virtual keyboard: active key presses are mapped to control codes + bool _ctrlLocked = false; + bool _altLocked = false; + // Row3 expand/collapse state for compact keyboard layout + bool _row3Expanded = false; // For iOS edge swipe gesture double _swipeStartX = 0; double _swipeCurrentX = 0; @@ -94,6 +102,18 @@ class _TerminalPageState extends State // terminal extra keys bar is unnecessary and disabled. _showTerminalExtraKeys = !isWebDesktop && mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); + _terminalModel.isCtrlLocked = () => _ctrlLocked; + _terminalModel.clearCtrlLock = () { + if (_ctrlLocked) setState(() => _ctrlLocked = false); + }; + _terminalModel.isAltLocked = () => _altLocked; + _terminalModel.clearAltLock = () { + if (_altLocked) setState(() => _altLocked = false); + }; + // Load Row3 expand/collapse state from persistent storage. The raw option + // read keeps Row3 collapsed when no value has been saved yet. + _row3Expanded = + bind.mainGetLocalOption(key: kOptionShowTerminalCtrlKeys) == 'Y'; // Initialize terminal connection WidgetsBinding.instance.addPostFrameCallback((_) { _ffi.dialogManager @@ -148,6 +168,39 @@ class _TerminalPageState extends State return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight); } + /// Pastes clipboard text through TerminalModel so keyboard-only modifiers and + /// mobile Enter normalization never alter clipboard data. + Future _pasteClipboardText() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text == null || !mounted) return; + + await _terminalModel.pasteText(text); + if (mounted) { + _terminalModel.terminalController.clearSelection(); + } + } + + KeyEventResult _handleTerminalKeyEvent(FocusNode _, KeyEvent event) { + final hardwareKeyboard = HardwareKeyboard.instance; + final shouldPaste = shouldHandleTerminalPasteShortcut( + logicalKey: event.logicalKey, + isKeyDown: event is KeyDownEvent, + isKeyRepeat: event is KeyRepeatEvent, + controlPressed: hardwareKeyboard.isControlPressed, + metaPressed: hardwareKeyboard.isMetaPressed, + altPressed: hardwareKeyboard.isAltPressed, + shiftPressed: hardwareKeyboard.isShiftPressed, + modifierLockActive: _ctrlLocked || _altLocked, + ); + if (!shouldPaste) return KeyEventResult.ignored; + + // Only locked virtual modifiers need interception. Without a lock, keep + // xterm's default hardware paste behavior, including bracketed paste mode. + unawaited(_pasteClipboardText()); + return KeyEventResult.handled; + } + @override Widget build(BuildContext context) { super.build(context); @@ -185,6 +238,7 @@ class _TerminalPageState extends State // // Android works fine without this workaround. deleteDetection: isIOS, + onKeyEvent: _handleTerminalKeyEvent, padding: _calculatePadding(heightPx), onSecondaryTapDown: (details, offset) async { final selection = _terminalModel.terminalController.selection; @@ -193,11 +247,7 @@ class _TerminalPageState extends State _terminalModel.terminalController.clearSelection(); await Clipboard.setData(ClipboardData(text: text)); } else { - final data = await Clipboard.getData('text/plain'); - final text = data?.text; - if (text != null) { - _terminalModel.terminal.paste(text); - } + await _pasteClipboardText(); } }, ); @@ -324,66 +374,171 @@ class _TerminalPageState extends State mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ + // Row 1 follows the latest reviewed PR layout. + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: _buildKeyboardKeyButtons(terminalKeyboardRow1Keys), + ), + // Row 2 ends with the full-width Row3 collapse/expand toggle. Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildKeyButton('Esc'), - const SizedBox(width: 2), - _buildKeyButton('/'), - const SizedBox(width: 2), - _buildKeyButton('|'), - const SizedBox(width: 2), - _buildKeyButton('Home'), - const SizedBox(width: 2), - _buildKeyButton('↑'), - const SizedBox(width: 2), - _buildKeyButton('End'), - const SizedBox(width: 2), - _buildKeyButton('PgUp'), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildKeyButton('Tab'), - const SizedBox(width: 2), - _buildKeyButton('Ctrl+C'), - const SizedBox(width: 2), - _buildKeyButton('~'), - const SizedBox(width: 2), - _buildKeyButton('←'), - const SizedBox(width: 2), - _buildKeyButton('↓'), - const SizedBox(width: 2), - _buildKeyButton('→'), - const SizedBox(width: 2), - _buildKeyButton('PgDn'), + ..._buildKeyboardKeyButtons(terminalKeyboardRow2Keys), + const SizedBox(width: terminalKeyboardKeySpacing), + _buildCollapseButton(), ], ), + // Row 3 restores paging keys and trailing alignment placeholders. + if (_row3Expanded) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ..._buildKeyboardKeyButtons(terminalKeyboardRow3Keys), + for (var i = 0; + i < terminalKeyboardRow3TrailingPlaceholderCount; + i++) ...[ + const SizedBox(width: terminalKeyboardKeySpacing), + const SizedBox(width: terminalKeyboardKeyWidth), + ], + ], + ), ], ), ), ); } + // Ctrl toggle button with highlighted locked state + Widget _buildCtrlKeyButton() { + return _buildModifierToggleButton( + text: 'Ctrl', + semanticsLabel: 'Ctrl', + isLocked: _ctrlLocked, + onPressed: () => setState(() => _ctrlLocked = !_ctrlLocked), + ); + } + + // Alt toggle button with highlighted locked state + Widget _buildAltKeyButton() { + return _buildModifierToggleButton( + text: 'Alt', + semanticsLabel: 'Alt', + isLocked: _altLocked, + onPressed: () => setState(() => _altLocked = !_altLocked), + ); + } + + // Collapse/expand toggle button for Row3 + void _toggleRow3Expanded() { + final willExpand = !_row3Expanded; + final shouldClearModifiers = shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: _row3Expanded, + willExpand: willExpand, + ctrlLocked: _ctrlLocked, + altLocked: _altLocked, + ); + setState(() { + _row3Expanded = willExpand; + if (shouldClearModifiers) { + _ctrlLocked = false; + _altLocked = false; + } + }); + mainSetLocalBoolOption(kOptionShowTerminalCtrlKeys, willExpand); + + // The floating keyboard height changes after Row3 is inserted/removed. + // Re-measure on the next frame so terminal padding uses the new height. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_showTerminalExtraKeys) return; + setState(() { + _updateKeyboardHeight(); + }); + }); + } + + Widget _buildCollapseButton() { + return Semantics( + label: translate('Show terminal extra keys'), + toggled: _row3Expanded, + child: ElevatedButton( + onPressed: _toggleRow3Expanded, + child: Text(_row3Expanded ? '∧' : '∨'), + style: ElevatedButton.styleFrom( + minimumSize: const Size(terminalKeyboardKeyWidth, 32), + padding: EdgeInsets.zero, + textStyle: const TextStyle(fontSize: 12), + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + /// Builds a fixed-width key sequence with the reviewed 2dp spacing. + List _buildKeyboardKeyButtons(List labels) { + return [ + for (var i = 0; i < labels.length; i++) ...[ + _buildKeyButton(labels[i]), + if (i < labels.length - 1) + const SizedBox(width: terminalKeyboardKeySpacing), + ], + ]; + } + + /// Build a modifier toggle button (Ctrl/Alt) with one-shot behavior. + /// When [isLocked] is true, the button highlights in blue and the next + /// single-character input is mapped to its modified equivalent. + Widget _buildModifierToggleButton({ + required String text, + required String semanticsLabel, + required bool isLocked, + required VoidCallback onPressed, + }) { + return Semantics( + // Ctrl and Alt are technical key names and intentionally stay unchanged. + label: semanticsLabel, + toggled: isLocked, + child: ElevatedButton( + onPressed: onPressed, + child: Text(text), + style: ElevatedButton.styleFrom( + minimumSize: const Size(terminalKeyboardKeyWidth, 32), + padding: EdgeInsets.zero, + textStyle: const TextStyle(fontSize: 12), + backgroundColor: isLocked + ? Colors.blue + : Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: isLocked + ? Colors.white + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + Widget _buildKeyButton(String label) { + if (label == 'Ctrl') return _buildCtrlKeyButton(); + if (label == 'Alt') return _buildAltKeyButton(); + return ElevatedButton( onPressed: () { _sendKeyToTerminal(label); }, child: Text(label), style: ElevatedButton.styleFrom( - minimumSize: const Size(48, 32), + minimumSize: const Size(terminalKeyboardKeyWidth, 32), padding: EdgeInsets.zero, textStyle: const TextStyle(fontSize: 12), - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } void _sendKeyToTerminal(String key) { - String? send; + String send; switch (key) { case 'Esc': @@ -427,9 +582,7 @@ class _TerminalPageState extends State break; } - if (send != null) { - _terminalModel.sendVirtualKey(send); - } + _terminalModel.sendVirtualKey(send); } // https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472 diff --git a/flutter/lib/mobile/terminal_keyboard_utils.dart b/flutter/lib/mobile/terminal_keyboard_utils.dart new file mode 100644 index 000000000..9248d1d4a --- /dev/null +++ b/flutter/lib/mobile/terminal_keyboard_utils.dart @@ -0,0 +1,20 @@ +/// Reviewed mobile terminal keyboard layout from PR #15532. +/// +/// Keeping the key order outside the widget makes the intended layout explicit +/// and prevents behavior fixes from silently moving keys between rows. +const terminalKeyboardRow1Keys = ['Esc', '/', '|', 'Home', '↑', 'End', r'\']; +const terminalKeyboardRow2Keys = ['Tab', 'Ctrl+C', '~', '←', '↓', '→']; +const terminalKeyboardRow3Keys = ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn']; + +const terminalKeyboardKeyWidth = 48.0; +const terminalKeyboardKeySpacing = 2.0; + +/// Empty 48dp slots keep expanded Row3 aligned with the two rows above it. +const terminalKeyboardRow3TrailingPlaceholderCount = 2; + +/// Returns the fixed width occupied by a row of equally sized key slots. +double terminalKeyboardRowWidth(int slotCount) { + if (slotCount <= 0) return 0; + return slotCount * terminalKeyboardKeyWidth + + (slotCount - 1) * terminalKeyboardKeySpacing; +} diff --git a/flutter/lib/models/input_modifier_utils.dart b/flutter/lib/models/input_modifier_utils.dart index e65c32790..9b8aae881 100644 --- a/flutter/lib/models/input_modifier_utils.dart +++ b/flutter/lib/models/input_modifier_utils.dart @@ -1,4 +1,12 @@ import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Identifies where terminal input originated so paste data can bypass all +/// keyboard-only transformations. +enum TerminalInputSource { + keyboard, + paste, +} /// Returns true when a stale mobile one-shot Shift state should be released /// by replaying a tracked Shift key-down as a synthesized key-up. @@ -36,3 +44,147 @@ bool shouldReleaseStaleMobileShift({ } return true; } + +/// Applies the terminal Ctrl/Alt one-shot modifiers to a single input payload. +/// +String applyTerminalInputModifiers( + String data, { + required bool ctrlLocked, + required bool altLocked, +}) { + var result = data; + if (ctrlLocked) { + result = _applyTerminalCtrlModifier(result); + } + if (altLocked) { + result = '\x1B$result'; + } + return result; +} + +/// Builds the exact payload xterm sends for paste, without applying modifiers. +String terminalPastePayload(String text, {required bool bracketedPasteMode}) { + if (!bracketedPasteMode) { + return text; + } + return '\x1B[200~$text\x1B[201~'; +} + +/// Returns whether one-shot Ctrl/Alt may transform and consume this input. +/// +/// xterm emits terminal control keys as either one control byte or a longer +/// escape sequence. Neither form is ordinary text input, so a pending modifier +/// must survive until the user enters a printable character. +bool shouldApplyTerminalInputModifiers(String data) { + if (data.characters.length != 1) return false; + final codeUnit = data.codeUnitAt(0); + return codeUnit >= 0x20 && codeUnit != 0x7F; +} + +/// Builds the payload sent to the remote terminal for keyboard and paste input. +/// +/// Keyboard input keeps the mobile Enter workaround and one-shot Ctrl/Alt +/// mapping. Paste input deliberately bypasses both transformations so even a +/// one-character clipboard payload is preserved exactly. +String prepareTerminalInputPayload( + String data, { + required TerminalInputSource source, + required bool isMobileOrWebMobile, + required bool bracketedPasteMode, + required bool ctrlLocked, + required bool altLocked, +}) { + if (source == TerminalInputSource.paste) { + return terminalPastePayload( + data, + bracketedPasteMode: bracketedPasteMode, + ); + } + + var result = data; + if (isMobileOrWebMobile && result == '\n') { + result = '\r'; + } + if ((ctrlLocked || altLocked) && shouldApplyTerminalInputModifiers(result)) { + result = applyTerminalInputModifiers( + result, + ctrlLocked: ctrlLocked, + altLocked: altLocked, + ); + } + return result; +} + +/// Returns true when a hardware paste shortcut must bypass keyboard modifiers. +/// +/// xterm already handles hardware Ctrl/Cmd+V correctly in the common case. Only +/// intercept while a virtual Ctrl/Alt lock is active, because xterm can emit a +/// one-character paste as normal text when bracketed paste mode is disabled. +bool shouldHandleTerminalPasteShortcut({ + required LogicalKeyboardKey logicalKey, + required bool isKeyDown, + required bool isKeyRepeat, + required bool controlPressed, + required bool metaPressed, + required bool altPressed, + required bool shiftPressed, + required bool modifierLockActive, +}) { + if (!modifierLockActive) return false; + if (!isKeyDown && !isKeyRepeat) return false; + if (logicalKey != LogicalKeyboardKey.keyV) return false; + if (altPressed || shiftPressed) return false; + return controlPressed != metaPressed; +} + +/// Returns true when collapsing Row3 should also clear hidden modifier state. +bool shouldClearTerminalModifiersWhenRow3Collapses({ + required bool wasExpanded, + required bool willExpand, + required bool ctrlLocked, + required bool altLocked, +}) { + return wasExpanded && !willExpand && (ctrlLocked || altLocked); +} + +String _applyTerminalCtrlModifier(String data) { + // Ctrl mappings are defined only for ASCII scalars. A visible character can + // be multiple scalars (for example, a decomposed accent), so leave those + // graphemes untouched instead of rewriting only their ASCII base letter. + final graphemes = data.characters.toList(growable: false); + if (graphemes.length != 1) { + return data; + } + + final runes = graphemes.single.runes.toList(growable: false); + if (runes.length != 1) { + return data; + } + + final code = runes.single; + if (code >= 0x61 && code <= 0x7A) { + return String.fromCharCode(code - 0x60); + } + if (code >= 0x41 && code <= 0x5A) { + return String.fromCharCode(code - 0x40); + } + if (code == 0x20) { + return String.fromCharCode(0); + } + if (code == 0x5B) { + return String.fromCharCode(27); + } + if (code == 0x5C) { + return String.fromCharCode(28); + } + if (code == 0x5D) { + return String.fromCharCode(29); + } + if (code == 0x5E) { + return String.fromCharCode(30); + } + if (code == 0x5F || code == 0x2F) { + return String.fromCharCode(31); + } + return data; +} diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 2b3fd4837..6f179afe2 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -7,6 +7,7 @@ import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/main.dart'; import 'package:xterm/xterm.dart'; +import 'input_modifier_utils.dart'; import 'model.dart'; import 'platform_model.dart'; @@ -22,7 +23,25 @@ class TerminalModel with ChangeNotifier { bool _disposed = false; + /// Callback to check whether Ctrl modifier lock is currently active. + /// When active, keyboard input is mapped to control codes (e.g. 'b' → \x02). + bool Function()? isCtrlLocked; + + /// Callback to clear Ctrl lock after a key is pressed (one-shot mode). + void Function()? clearCtrlLock; + + /// Callback to check whether Alt modifier lock is currently active. + bool Function()? isAltLocked; + + /// Callback to clear Alt lock after a key is pressed (one-shot mode). + void Function()? clearAltLock; + final _inputBuffer = []; + + /// Exposes buffered input only for lifecycle regression tests. + @visibleForTesting + int get debugBufferedInputCount => _inputBuffer.length; + // Buffer for output data received before terminal view has valid dimensions. // This prevents NaN errors when writing to terminal before layout is complete. final _pendingOutputChunks = []; @@ -42,6 +61,10 @@ class TerminalModel with ChangeNotifier { VoidCallback? onClosed; Future _handleInput(String data) async { + // xterm can complete asynchronous input after the Flutter page has gone + // away. Stop before reading or clearing widget-owned modifier state. + if (_disposed) return; + // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. // - Peer Windows: '\r' works, '\n' is just a newline. @@ -49,13 +72,44 @@ class TerminalModel with ChangeNotifier { // (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'. // - Peer macOS: same as Linux, raw-mode apps expect '\r' // (https://github.com/rustdesk/rustdesk/issues/14907). - // So on mobile / web-mobile, always normalize a lone '\n' to '\r'. - // We deliberately do not touch multi-character payloads (e.g. pasted text) - // so embedded newlines in pasted content are preserved. - final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop)); - if (isMobileOrWebMobile && data == '\n') { - data = '\r'; + // So on mobile / web-mobile, normalize the original lone '\n' to '\r' + // before modifier mappings. This keeps Ctrl+J mapped to LF instead of + // having the generated control code rewritten to CR afterward. + // Multi-character keyboard payloads, such as terminal escape sequences, + // remain unchanged. Paste input follows a separate preprocessing path. + final ctrlLocked = isCtrlLocked?.call() ?? false; + final altLocked = isAltLocked?.call() ?? false; + final modifiersActive = ctrlLocked || altLocked; + // Use the same predicate for transformation and consumption. Control keys + // and escape sequences must not silently consume a pending one-shot lock. + final shouldConsumeModifiers = + modifiersActive && shouldApplyTerminalInputModifiers(data); + data = prepareTerminalInputPayload( + data, + // IME soft-keyboard paste prompts currently arrive from xterm as normal + // text input with no paste-origin metadata. Keep them on the keyboard path; + // clipboard-content heuristics can misclassify ordinary typing. + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: isMobile || (isWeb && !isWebDesktop), + bracketedPasteMode: terminal.bracketedPasteMode, + ctrlLocked: ctrlLocked, + altLocked: altLocked, + ); + if (shouldConsumeModifiers) { + if (ctrlLocked) clearCtrlLock?.call(); + if (altLocked) clearAltLock?.call(); } + return _sendInputPayload(data); + } + + /// Sends an already prepared payload without applying keyboard semantics. + /// Both normal input and paste use this transport path after their source- + /// specific preprocessing has completed. + Future _sendInputPayload(String data) async { + // Clipboard reads and native sends may complete after the terminal page has + // closed. Never send or re-buffer input once this model is disposed. + if (_disposed) return; + if (_terminalOpened) { // Send user input to remote terminal try { @@ -176,6 +230,18 @@ class TerminalModel with ChangeNotifier { return _handleInput(data); } + Future pasteText(String data) async { + final payload = prepareTerminalInputPayload( + data, + source: TerminalInputSource.paste, + isMobileOrWebMobile: false, + bracketedPasteMode: terminal.bracketedPasteMode, + ctrlLocked: false, + altLocked: false, + ); + return _sendInputPayload(payload); + } + Future closeTerminal() async { if (_terminalOpened) { try { @@ -516,6 +582,14 @@ class TerminalModel with ChangeNotifier { void dispose() { if (_disposed) return; _disposed = true; + terminal.onOutput = null; + terminal.onResize = null; + isCtrlLocked = null; + clearCtrlLock = null; + isAltLocked = null; + clearAltLock = null; + onResizeExternal = null; + onClosed = null; // Clear buffers to free memory _inputBuffer.clear(); _pendingOutputChunks.clear(); diff --git a/flutter/test/input_modifier_utils_test.dart b/flutter/test/input_modifier_utils_test.dart index 2e1971753..5a1a76a77 100644 --- a/flutter/test/input_modifier_utils_test.dart +++ b/flutter/test/input_modifier_utils_test.dart @@ -122,4 +122,394 @@ void main() { ); }); }); + + group('shouldApplyTerminalInputModifiers', () { + test('accepts ordinary single-character keyboard input', () { + expect(shouldApplyTerminalInputModifiers('a'), isTrue); + expect(shouldApplyTerminalInputModifiers(' '), isTrue); + expect(shouldApplyTerminalInputModifiers('/'), isTrue); + }); + + test('accepts supplementary-plane single-character keyboard input', () { + expect(shouldApplyTerminalInputModifiers('😀'), isTrue); + }); + + test('rejects terminal control bytes and multi-character sequences', () { + for (final input in ['\x00', '\x03', '\t', '\n', '\r', '\x1B', '\x7F']) { + expect( + shouldApplyTerminalInputModifiers(input), + isFalse, + reason: '${input.codeUnits} must not consume a one-shot modifier', + ); + } + expect(shouldApplyTerminalInputModifiers('\x1B[A'), isFalse); + }); + }); + + group('applyTerminalInputModifiers', () { + test('keeps decomposed graphemes intact under Ctrl', () { + const decomposedEAcute = 'e\u0301'; + + expect( + applyTerminalInputModifiers( + decomposedEAcute, + ctrlLocked: true, + altLocked: false, + ), + decomposedEAcute, + ); + }); + + test('keeps non-ASCII graphemes intact under Ctrl', () { + for (final input in ['é', '😀']) { + expect( + applyTerminalInputModifiers( + input, + ctrlLocked: true, + altLocked: false, + ), + input, + ); + } + }); + + test('maps Ctrl underscore to unit separator', () { + expect( + applyTerminalInputModifiers( + '_', + ctrlLocked: true, + altLocked: false, + ), + '\x1F', + ); + }); + + test('maps the complete Ctrl symbol range', () { + const mappings = { + '[': '\x1B', + r'\': '\x1C', + ']': '\x1D', + '^': '\x1E', + '_': '\x1F', + '/': '\x1F', + }; + + for (final entry in mappings.entries) { + expect( + applyTerminalInputModifiers( + entry.key, + ctrlLocked: true, + altLocked: false, + ), + entry.value, + reason: 'Ctrl+${entry.key} should map to ${entry.value.codeUnits}', + ); + } + }); + + test('applies Ctrl before Alt for combined modifiers', () { + expect( + applyTerminalInputModifiers( + 'b', + ctrlLocked: true, + altLocked: true, + ), + '\x1B\x02', + ); + }); + }); + + group('terminalPastePayload', () { + test('wraps paste text when bracketed paste mode is active', () { + expect( + terminalPastePayload('d', bracketedPasteMode: true), + '\x1B[200~d\x1B[201~', + ); + }); + + test('keeps a lone newline unchanged when bracketed paste is disabled', () { + expect( + terminalPastePayload('\n', bracketedPasteMode: false), + '\n', + ); + }); + }); + + group('prepareTerminalInputPayload', () { + test('normalizes a mobile keyboard Enter to carriage return', () { + expect( + prepareTerminalInputPayload( + '\n', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: false, + ), + '\r', + ); + }); + + test('keeps Ctrl+J as line feed on mobile', () { + expect( + prepareTerminalInputPayload( + 'j', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: false, + ), + '\n', + ); + }); + + test('does not apply Alt to a terminal control byte', () { + expect( + prepareTerminalInputPayload( + '\x1B', + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: true, + ), + '\x1B', + ); + }); + + test('keeps large keyboard payloads unchanged when modifiers are inactive', + () { + final payload = 'd' * (1024 * 1024); + + expect( + prepareTerminalInputPayload( + payload, + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: false, + bracketedPasteMode: false, + ctrlLocked: false, + altLocked: false, + ), + payload, + ); + }); + + test('keeps decomposed graphemes intact with locked keyboard modifiers', + () { + const decomposedEAcute = 'e\u0301'; + + expect( + prepareTerminalInputPayload( + decomposedEAcute, + source: TerminalInputSource.keyboard, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: false, + ), + decomposedEAcute, + ); + }); + + test('preserves a lone pasted newline when modifiers are locked', () { + expect( + prepareTerminalInputPayload( + '\n', + source: TerminalInputSource.paste, + isMobileOrWebMobile: true, + bracketedPasteMode: false, + ctrlLocked: true, + altLocked: true, + ), + '\n', + ); + }); + + test('wraps paste without applying locked modifiers', () { + expect( + prepareTerminalInputPayload( + 'd', + source: TerminalInputSource.paste, + isMobileOrWebMobile: true, + bracketedPasteMode: true, + ctrlLocked: true, + altLocked: true, + ), + '\x1B[200~d\x1B[201~', + ); + }); + }); + + group('shouldHandleTerminalPasteShortcut', () { + test( + 'keeps default xterm paste behavior when virtual modifiers are inactive', + () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: false, + ), + isFalse, + ); + }); + + test('handles Ctrl+V and Meta+V when a virtual modifier lock is active', + () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: false, + metaPressed: true, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + }); + + test('handles paste shortcut repeats while a virtual lock is active', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: false, + isKeyRepeat: true, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isTrue, + ); + }); + + test('ignores key-up and unmodified V events', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: false, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: false, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + + test('ignores paste shortcuts with extra modifiers', () { + for (final state in [ + (control: true, meta: false, alt: true, shift: false), + (control: true, meta: false, alt: false, shift: true), + (control: false, meta: true, alt: false, shift: true), + (control: true, meta: true, alt: false, shift: false), + ]) { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyV, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: state.control, + metaPressed: state.meta, + altPressed: state.alt, + shiftPressed: state.shift, + modifierLockActive: true, + ), + isFalse, + ); + } + }); + + test('ignores non-V key events', () { + expect( + shouldHandleTerminalPasteShortcut( + logicalKey: LogicalKeyboardKey.keyC, + isKeyDown: true, + isKeyRepeat: false, + controlPressed: true, + metaPressed: false, + altPressed: false, + shiftPressed: false, + modifierLockActive: true, + ), + isFalse, + ); + }); + }); + + group('shouldClearTerminalModifiersWhenRow3Collapses', () { + test('clears visible modifier state when expanded row is collapsed', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: true, + willExpand: false, + ctrlLocked: true, + altLocked: false, + ), + isTrue, + ); + }); + + test('does not clear modifiers when row expands', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: false, + willExpand: true, + ctrlLocked: true, + altLocked: true, + ), + isFalse, + ); + }); + + test('clears Alt state when expanded row is collapsed', () { + expect( + shouldClearTerminalModifiersWhenRow3Collapses( + wasExpanded: true, + willExpand: false, + ctrlLocked: false, + altLocked: true, + ), + isTrue, + ); + }); + }); } diff --git a/flutter/test/terminal_keyboard_utils_test.dart b/flutter/test/terminal_keyboard_utils_test.dart new file mode 100644 index 000000000..c93a42413 --- /dev/null +++ b/flutter/test/terminal_keyboard_utils_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_hbb/mobile/terminal_keyboard_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('mobile terminal keyboard layout', () { + test('keeps the latest key order from the reviewed PR layout', () { + expect( + terminalKeyboardRow1Keys, + ['Esc', '/', '|', 'Home', '↑', 'End', r'\'], + ); + expect( + terminalKeyboardRow2Keys, + ['Tab', 'Ctrl+C', '~', '←', '↓', '→'], + ); + expect( + terminalKeyboardRow3Keys, + ['Ctrl', 'Alt', '-', 'PgUp', 'PgDn'], + ); + }); + + test('keeps two trailing Row3 placeholders for row alignment', () { + expect(terminalKeyboardRow3TrailingPlaceholderCount, 2); + }); + + test('keeps every expanded row aligned at 348dp', () { + final rowWidths = [ + terminalKeyboardRowWidth(terminalKeyboardRow1Keys.length), + terminalKeyboardRowWidth(terminalKeyboardRow2Keys.length + 1), + terminalKeyboardRowWidth( + terminalKeyboardRow3Keys.length + + terminalKeyboardRow3TrailingPlaceholderCount, + ), + ]; + + expect(terminalKeyboardKeyWidth, 48); + expect(terminalKeyboardKeySpacing, 2); + expect(rowWidths, everyElement(348)); + }); + }); +} diff --git a/flutter/test/terminal_model_lifecycle_test.dart b/flutter/test/terminal_model_lifecycle_test.dart new file mode 100644 index 000000000..d00646b2b --- /dev/null +++ b/flutter/test/terminal_model_lifecycle_test.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/terminal_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeFFI implements FFI { + @override + String id = 'test-peer'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('ignores paste that completes after the terminal model is disposed', + () async { + final model = TerminalModel(_FakeFFI()); + final delayedClipboardText = Completer(); + + // This mirrors Ctrl/Cmd+V: clipboard access starts first, then the page and + // model are disposed before the asynchronous read supplies its text. + final paste = delayedClipboardText.future.then(model.pasteText); + model.dispose(); + delayedClipboardText.complete('late clipboard text'); + await paste; + + expect(model.debugBufferedInputCount, 0); + }); + + test('ignores terminal text input after the terminal model is disposed', () { + final model = TerminalModel(_FakeFFI()); + var checkedCtrlLock = false; + var clearedCtrlLock = false; + + model.isCtrlLocked = () { + checkedCtrlLock = true; + return true; + }; + model.clearCtrlLock = () { + clearedCtrlLock = true; + }; + + model.dispose(); + model.terminal.textInput('d'); + + expect(checkedCtrlLock, isFalse); + expect(clearedCtrlLock, isFalse); + expect(model.debugBufferedInputCount, 0); + }); +} From b1fad7bbed5f736e34c7a718ecc4f54c3c33f0aa Mon Sep 17 00:00:00 2001 From: FrederickStempfle Date: Sun, 26 Jul 2026 02:56:22 +0200 Subject: [PATCH 19/21] fix: validate RGBA clipboard dimensions (#15672) --- src/clipboard.rs | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/clipboard.rs b/src/clipboard.rs index 01dc0c9ed..c7c01d6c4 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -36,6 +36,17 @@ const CLIPBOARD_GET_MAX_RETRY: usize = 3; #[cfg(not(target_os = "android"))] const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33); +#[cfg(not(target_os = "android"))] +fn valid_rgba_dimensions(width: i32, height: i32, data_len: usize) -> Option<(usize, usize)> { + let width = usize::try_from(width).ok()?; + let height = usize::try_from(height).ok()?; + if width == 0 || height == 0 { + return None; + } + let expected_len = width.checked_mul(height)?.checked_mul(4)?; + (data_len == expected_len).then_some((width, height)) +} + #[cfg(not(target_os = "android"))] const SUPPORTED_FORMATS: &[ClipboardFormat] = &[ ClipboardFormat::Text, @@ -722,11 +733,15 @@ mod proto { Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text), Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf), Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html), - Ok(ClipboardFormat::ImageRgba) => Some(ClipboardData::Image(arboard::ImageData::rgba( - clipboard.width as _, - clipboard.height as _, - data.into(), - ))), + Ok(ClipboardFormat::ImageRgba) => { + let (width, height) = + super::valid_rgba_dimensions(clipboard.width, clipboard.height, data.len())?; + Some(ClipboardData::Image(arboard::ImageData::rgba( + width, + height, + data.into(), + ))) + } Ok(ClipboardFormat::ImagePng) => { Some(ClipboardData::Image(arboard::ImageData::png(data.into()))) } @@ -770,6 +785,22 @@ mod proto { } } +#[cfg(all(test, not(target_os = "android")))] +mod rgba_tests { + use super::valid_rgba_dimensions; + + #[test] + fn validates_dimensions_against_content_length() { + assert_eq!(valid_rgba_dimensions(1, 1, 4), Some((1, 1))); + assert_eq!(valid_rgba_dimensions(1, 1, 3), None); + assert_eq!(valid_rgba_dimensions(-1, 1, 4), None); + assert_eq!(valid_rgba_dimensions(0, 1, 0), None); + assert_eq!(valid_rgba_dimensions(i32::MAX, i32::MAX, 4), None); + #[cfg(target_pointer_width = "32")] + assert_eq!(valid_rgba_dimensions(i32::MAX, 2, 0), None); + } +} + #[cfg(target_os = "android")] pub fn handle_msg_clipboard(mut cb: Clipboard) { use hbb_common::protobuf::Message; From 5882346caa9211c16180be6540b72d671fd71215 Mon Sep 17 00:00:00 2001 From: FrederickStempfle Date: Sun, 26 Jul 2026 03:04:20 +0200 Subject: [PATCH 20/21] fix: validate remote audio channel count (#15673) --- src/client.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/client.rs b/src/client.rs index dcd5941df..5cafeadaf 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1401,6 +1401,10 @@ impl AudioHandler { /// Handle audio format and create an audio decoder. pub fn handle_format(&mut self, f: AudioFormat) { + if !is_supported_audio_channel_count(f.channels) { + log::error!("Unsupported audio channel count: {}", f.channels); + return; + } match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) { Ok(d) => { let buffer = vec![0.; f.sample_rate as usize * f.channels as usize]; @@ -1540,6 +1544,23 @@ impl AudioHandler { } } +fn is_supported_audio_channel_count(channels: u32) -> bool { + (1..=2).contains(&channels) +} + +#[cfg(test)] +mod audio_format_tests { + use super::is_supported_audio_channel_count; + + #[test] + fn only_mono_and_stereo_are_supported() { + assert!(is_supported_audio_channel_count(1)); + assert!(is_supported_audio_channel_count(2)); + assert!(!is_supported_audio_channel_count(0)); + assert!(!is_supported_audio_channel_count(u32::MAX)); + } +} + /// Video handler for the [`Client`]. pub struct VideoHandler { decoder: Decoder, From eefd22b2057ba057305b10a6b5bf93c79b686eb9 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 26 Jul 2026 11:54:01 +0800 Subject: [PATCH 21/21] fix(macos): prevent remote keyboard focus leaks (#15629) * fix(macos): prevent remote keyboard focus leaks Gate keyboard grabbing on window, tab, lifecycle, and primary focus state. Release grabs on focus loss or minimize and avoid duplicate grab transitions. Signed-off-by: fufesou * fix: macos, keyboard focus, comments known issue Signed-off-by: fufesou * fix: macos, keyboard, fullscreen space switch Signed-off-by: fufesou * fix: macos, keyboard, focus, relative mouse mode Signed-off-by: fufesou * fix(macOS): preserve local overlay focus during input recovery Prevent fullscreen and relative-mouse focus recovery from reclaiming remote keyboard input while a local chat or dialog overlay owns focus. Signed-off-by: fufesou * fix: macos, keyboard, comments trade-off Signed-off-by: fufesou --------- Signed-off-by: fufesou --- .../macos_full_screen_focus_recovery.dart | 24 ++ flutter/lib/desktop/pages/remote_page.dart | 321 +++++++++++++++++- .../lib/desktop/pages/remote_tab_page.dart | 6 +- .../lib/desktop/widgets/remote_toolbar.dart | 2 + 4 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart diff --git a/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart b/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart new file mode 100644 index 000000000..96493c62c --- /dev/null +++ b/flutter/lib/desktop/pages/macos_full_screen_focus_recovery.dart @@ -0,0 +1,24 @@ +class MacOSFullScreenFocusRecovery { + int _generation = 0; + int? _pendingGeneration; + + int? get pendingGeneration => _pendingGeneration; + + int queue() { + _generation += 1; + _pendingGeneration = _generation; + return _generation; + } + + void cancel() { + _pendingGeneration = null; + } + + bool isCurrent(int generation) => _pendingGeneration == generation; + + bool consume(int generation) { + if (!isCurrent(generation)) return false; + _pendingGeneration = null; + return true; + } +} diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index f4669644b..a9185d6a3 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -22,6 +22,7 @@ import '../../utils/image.dart'; import '../widgets/remote_toolbar.dart'; import '../widgets/kb_layout_type_chooser.dart'; import '../widgets/tabbar_widget.dart'; +import 'macos_full_screen_focus_recovery.dart'; import 'package:flutter_hbb/native/custom_cursor.dart' if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart'; @@ -64,6 +65,13 @@ class RemotePage extends StatefulWidget { FFI get ffi => (_lastState.value! as _RemotePageState)._ffi; + void releaseMacOSInputForTabTransfer() { + if (!isMacOS) return; + // Release before removing the source tab. Its delayed disposal must not + // disable a native keyboard hook already acquired by the destination page. + (_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput(); + } + @override State createState() { final state = _RemotePageState(id); @@ -76,10 +84,28 @@ class _RemotePageState extends State with AutomaticKeepAliveClientMixin, MultiWindowListener, + WidgetsBindingObserver, TickerProviderStateMixin { Timer? _timer; String keyboardMode = "legacy"; bool _isWindowBlur = false; + // Known macOS remote-input trade-offs (kept simple intentionally): + // 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog + // state. Reproduce: activate remote input, open a dialog, then type. + // 2. Delayed fullscreen recovery can race a local-control focus change; no + // owner state is added. Reproduce: focus the toolbar during a Space switch. + // 3. Input-source switching releases native input without updating this + // page's cache. Reproduce: switch sources, then type before and after + // clicking the remote image; the click reasserts input. + // These latches compensate for out-of-order macOS focus events. Treat them + // as coupled when changing a transition or _syncMacOSKeyboardGrab(). + AppLifecycleState? _macOSLifecycleState; + bool _macOSLocalFocusLost = false; + bool _macOSInputActive = false; + bool _macOSInputSuppressed = false; + final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery(); + bool _macOSExplicitFocusRequestPending = false; + StreamSubscription? _tabStateSubscription; final _cursorOverImage = false.obs; late RxBool _showRemoteCursor; late RxBool _zoomCursor; @@ -122,6 +148,13 @@ class _RemotePageState extends State void initState() { super.initState(); _ffi = FFI(widget.sessionId); + if (isMacOS) { + // SchedulerBinding.instance.lifecycleState is null in the first connection in a new window. + _macOSLifecycleState = SchedulerBinding.instance.lifecycleState; + WidgetsBinding.instance.addObserver(this); + _tabStateSubscription = + widget.tabController?.state.listen(_onMacOSTabStateChanged); + } Get.put(_ffi, tag: widget.id); _ffi.imageModel.addCallbackOnFirstImage((String peerId) { _ffi.canvasModel.activateLocalCursor(); @@ -231,19 +264,224 @@ class _RemotePageState extends State _pointerLockCenterDebounceTimer = null; } + bool get _isSelectedTab { + final controller = widget.tabController; + if (controller == null) return true; + final tabState = controller.state.value; + final selected = tabState.selected; + return selected >= 0 && + selected < tabState.tabs.length && + tabState.tabs[selected].key == widget.id; + } + + bool get _isMacOSKeyboardContextActive { + return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; + } + + void _onMacOSTabStateChanged(DesktopTabState _) { + if (!_isSelectedTab) { + _macOSFullScreenFocusRecovery.cancel(); + _syncMacOSKeyboardGrab(); + return; + } + // Tab listeners run synchronously. Defer the selected page so the previous + // page releases first; a late leave from it can disable the new session. + scheduleMicrotask(() { + if (mounted) { + _syncMacOSKeyboardGrab(reassert: true); + } + }); + } + + void _releaseMacOSRemoteInput() { + _macOSFullScreenFocusRecovery.cancel(); + _macOSExplicitFocusRequestPending = false; + _macOSInputSuppressed = true; + _macOSLocalFocusLost = true; + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + _rawKeyFocusNode.unfocus(); + } + + void _onMacOSFocusChange() { + // requestFocus() notifies later; only a recorded explicit request may clear + // the local-focus-loss latch. + if (_rawKeyFocusNode.hasPrimaryFocus) { + final explicitRequest = _macOSExplicitFocusRequestPending; + _macOSExplicitFocusRequestPending = false; + if (explicitRequest && _isMacOSKeyboardContextActive) { + _macOSLocalFocusLost = false; + } + _syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest); + } else { + if (_macOSInputActive) { + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + } + if (_isMacOSKeyboardContextActive) { + _macOSLocalFocusLost = true; + } + } + } + + // 1. Sync the keyboard grab state with the current context. + // 2. Call enterOrLeave() to update the input state in the FFI layer. + // 3. Request or unfocus the raw key focus node based on the current context. + // Flutter focus and native input are separate; native input activates only + // after the FocusNode has primary focus. + void _syncMacOSKeyboardGrab({ + bool reassert = false, + bool allowInactiveLifecycle = false, + }) { + if (!isMacOS) return; + // A secondary engine may stay hidden while its window is visible, so + // explicit pointer/fullscreen recovery must bypass the global lifecycle. + final lifecycleAllowsInput = allowInactiveLifecycle || + _macOSLifecycleState == null || + _macOSLifecycleState == AppLifecycleState.resumed; + // Input stays pointer-gated except for focused fullscreen recovery, which + // compensates when macOS omits PointerEnter during a Space switch. + final shouldFocus = lifecycleAllowsInput && + _isMacOSKeyboardContextActive && + !_macOSInputSuppressed && + _blockableOverlayState.middleBlocked.isFalse && + _cursorOverImage.value && + !_macOSLocalFocusLost; + final hasFocus = _rawKeyFocusNode.hasPrimaryFocus; + final shouldActivateInput = shouldFocus && hasFocus; + + if (shouldActivateInput != _macOSInputActive || + (shouldActivateInput && reassert)) { + _ffi.inputModel.enterOrLeave(shouldActivateInput); + } + _macOSInputActive = shouldActivateInput; + + if (!shouldFocus) { + _macOSExplicitFocusRequestPending = false; + if (hasFocus) _rawKeyFocusNode.unfocus(); + } else if (!hasFocus) { + _macOSExplicitFocusRequestPending = allowInactiveLifecycle; + _rawKeyFocusNode.requestFocus(); + } else { + _macOSExplicitFocusRequestPending = false; + } + } + + void _restoreMacOSKeyboardAfterFullScreen({ + required int generation, + bool allowHiddenLifecycle = false, + }) { + // Fullscreen callbacks preserve recovery while hidden. Native window focus + // may bypass a stale hidden lifecycle for the newly visible Space. + if (!_macOSFullScreenFocusRecovery.isCurrent(generation) || + (!allowHiddenLifecycle && + _macOSLifecycleState == AppLifecycleState.hidden)) { + return; + } + final contextActive = + stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab; + // macOS can focus a fullscreen Space without sending PointerEnter. Native + // window focus is authoritative here; a later blur cancels this generation + // before an off-screen window can restore input. + final shouldInferPointerInside = !_cursorOverImage.value && + allowHiddenLifecycle && + stateGlobal.fullscreen.isTrue && + contextActive; + final canRestore = contextActive && + _blockableOverlayState.middleBlocked.isFalse && + (_cursorOverImage.value || shouldInferPointerInside); + if (!_macOSFullScreenFocusRecovery.consume(generation)) return; + if (!canRestore) { + // Consuming recovery here requires a later pointer/window/tab event. + return; + } + if (shouldInferPointerInside) { + _cursorOverImage.value = true; + } + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); + } + + void _scheduleMacOSKeyboardAfterFullScreen({ + required int generation, + bool allowHiddenLifecycle = false, + }) { + // Fullscreen can deliver FocusNode loss after its callback; wait for frame + // completion and then advance one event-loop turn before restoring. + WidgetsBinding.instance.addPostFrameCallback((_) { + Timer.run(() { + if (mounted) { + _restoreMacOSKeyboardAfterFullScreen( + generation: generation, + allowHiddenLifecycle: allowHiddenLifecycle, + ); + } + }); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _queueMacOSKeyboardAfterFullScreen({ + bool allowHiddenLifecycle = false, + }) { + final generation = _macOSFullScreenFocusRecovery.queue(); + if (_macOSLifecycleState == AppLifecycleState.paused || + _macOSLifecycleState == AppLifecycleState.detached) { + _macOSFullScreenFocusRecovery.cancel(); + return; + } + _scheduleMacOSKeyboardAfterFullScreen( + generation: generation, + allowHiddenLifecycle: allowHiddenLifecycle, + ); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (!isMacOS || _macOSLifecycleState == state) return; + _macOSLifecycleState = state; + if (state == AppLifecycleState.resumed) { + _syncMacOSKeyboardGrab(reassert: true); + } else if (_macOSInputActive) { + _ffi.inputModel.enterOrLeave(false); + _macOSInputActive = false; + } + + final generation = _macOSFullScreenFocusRecovery.pendingGeneration; + if (generation == null) return; + if (state == AppLifecycleState.inactive || + state == AppLifecycleState.resumed) { + _scheduleMacOSKeyboardAfterFullScreen(generation: generation); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + _macOSFullScreenFocusRecovery.cancel(); + } + } + @override void onWindowBlur() { super.onWindowBlur(); // On windows, we use `focus` way to handle keyboard better. // Now on Linux, there's some rdev issues which will break the input. - // We disable the `focus` way for non-Windows temporarily. - if (isWindows) { + // We disable the `focus` way for Linux temporarily. + if (isWindows || isMacOS) { _isWindowBlur = true; + } + if (isMacOS) { + _macOSFullScreenFocusRecovery.cancel(); + // A blur or Space switch may not emit PointerExit, so cursor state alone + // cannot prevent the old remote surface from reclaiming the keyboard. + _macOSLocalFocusLost = true; + } + if (isWindows) { // unfocus the primary-focus when the whole window is lost focus, // and let OS to handle events instead. _rawKeyFocusNode.unfocus(); } stateGlobal.isFocused.value = false; + _syncMacOSKeyboardGrab(); // When window loses focus, temporarily release relative mouse mode constraints // to allow user to interact with other applications normally. @@ -257,16 +495,41 @@ class _RemotePageState extends State void onWindowFocus() { super.onWindowFocus(); // See [onWindowBlur]. - if (isWindows) { + if (isWindows || isMacOS) { _isWindowBlur = false; } + if (isMacOS) stateGlobal.getInputSource(force: true); stateGlobal.isFocused.value = true; + // Normal macOS windows wait for PointerEnter or PointerDown. A focused + // fullscreen Space queues delayed recovery; if this window blurs again, the + // pending recovery is cancelled before native input can reactivate. + // Regression: switch directly between fullscreen remote Spaces without + // moving or clicking; only the newly focused session may receive input. + if (isMacOS && + stateGlobal.fullscreen.isTrue && + !_ffi.inputModel.relativeMouseMode.value) { + // Native window focus is authoritative when a secondary engine retains a + // stale hidden lifecycle state after its fullscreen Space becomes visible. + _queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true); + } + // Restore relative mouse mode constraints when window regains focus. if (_ffi.inputModel.relativeMouseMode.value) { - _rawKeyFocusNode.requestFocus(); + if (isMacOS) { + // Native relative mode retains pointer capture and does not emit + // PointerEnter after window focus returns. Restore both latches unless + // a local overlay still owns input. + if (_blockableOverlayState.middleBlocked.isFalse) { + _cursorOverImage.value = true; + _macOSLocalFocusLost = false; + } + } else { + _rawKeyFocusNode.requestFocus(); + } _ffi.inputModel.onWindowFocus(); } + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); } @override @@ -327,6 +590,13 @@ class _RemotePageState extends State void onWindowMinimize() { super.onWindowMinimize(); WakelockManager.disable(_uniqueKey); + if (isMacOS) { + _macOSFullScreenFocusRecovery.cancel(); + _isWindowBlur = true; + _cursorOverImage.value = false; + stateGlobal.isFocused.value = false; + _syncMacOSKeyboardGrab(); + } // Release cursor constraints when minimized if (_ffi.inputModel.relativeMouseMode.value) { _ffi.inputModel.onWindowBlur(); @@ -338,6 +608,7 @@ class _RemotePageState extends State super.onWindowEnterFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(true); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -346,6 +617,7 @@ class _RemotePageState extends State super.onWindowLeaveFullScreen(); if (isMacOS) { stateGlobal.setFullscreen(false); + _queueMacOSKeyboardAfterFullScreen(); } } @@ -354,6 +626,14 @@ class _RemotePageState extends State final closeSession = closeSessionOnDispose.remove(widget.id) ?? true; // https://github.com/flutter/flutter/issues/64935 + if (isMacOS) { + // Tab moves release before transfer to avoid a late retained-session leave. + if (closeSession) { + _releaseMacOSRemoteInput(); + } + _tabStateSubscription?.cancel(); + WidgetsBinding.instance.removeObserver(this); + } super.dispose(); debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}"); @@ -368,8 +648,9 @@ class _RemotePageState extends State _ffi.inputModel.onRelativeMouseModeDisabled = null; // Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...). _ffi.textureModel.onRemotePageDispose(closeSession); - if (closeSession) { + if (closeSession && !isMacOS) { // ensure we leave this session, this is a double check + // enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS. _ffi.inputModel.enterOrLeave(false); } DesktopMultiWindow.removeListener(this); @@ -444,6 +725,8 @@ class _RemotePageState extends State } else { _ffi.inputModel.enterOrLeave(false); } + } else if (isMacOS) { + _onMacOSFocusChange(); } }, inputModel: _ffi.inputModel, @@ -549,7 +832,11 @@ class _RemotePageState extends State } // See [onWindowBlur]. - if (!isWindows) { + if (isMacOS) { + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true); + } else if (!isWindows) { if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } @@ -575,7 +862,9 @@ class _RemotePageState extends State } // See [onWindowBlur]. - if (!isWindows) { + if (isMacOS) { + _syncMacOSKeyboardGrab(); + } else if (!isWindows) { _ffi.inputModel.enterOrLeave(false); } } @@ -600,17 +889,29 @@ class _RemotePageState extends State onEnter: onEnter, onExit: onExit, onPointerDown: (event) { - // A double check for blur status. + // A double check for blur status on Windows and macOS. // Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false. // Sometimes the system does not send the necessary focus event to flutter. We should manually // handle this inconsistent status by setting `_isWindowBlur` to false. So we can // ensure the grab-key thread is running when our users are clicking the remote canvas. - if (_isWindowBlur) { + if ((isWindows || isMacOS) && _isWindowBlur) { debugPrint( "Unexpected status: onPointerDown is triggered while the remote window is in blur status"); _isWindowBlur = false; } - if (!_rawKeyFocusNode.hasFocus) { + if (isMacOS) { + // Regions without matching enter/exit callbacks cannot safely own + // keyboard state. + if (onEnter == null || onExit == null) return; + if (!stateGlobal.isFocused.value) { + stateGlobal.isFocused.value = true; + } + _cursorOverImage.value = true; + _macOSLocalFocusLost = false; + stateGlobal.getInputSource(force: true); + _syncMacOSKeyboardGrab( + reassert: !isInputSourceFlutter, allowInactiveLifecycle: true); + } else if (!_rawKeyFocusNode.hasFocus) { _rawKeyFocusNode.requestFocus(); } }, diff --git a/flutter/lib/desktop/pages/remote_tab_page.dart b/flutter/lib/desktop/pages/remote_tab_page.dart index ccd5935ce..0b94a4916 100644 --- a/flutter/lib/desktop/pages/remote_tab_page.dart +++ b/flutter/lib/desktop/pages/remote_tab_page.dart @@ -513,15 +513,17 @@ class _ConnectionTabPageState extends State { final args = jsonDecode(call.arguments); final id = args['id']; final close = args['close']; + RemotePage? remotePage; try { - final remotePage = tabController.state.value.tabs + remotePage = tabController.state.value.tabs .firstWhere((tab) => tab.key == id) .page as RemotePage; returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString(); } catch (e) { debugPrint('Failed to get cached session data: $e'); } - if (close && returnValue != null) { + if (close && returnValue != null && remotePage != null) { + remotePage.releaseMacOSInputForTabTransfer(); closeSessionOnDispose[id] = false; tabController.closeBy(id); } diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 8f589b79a..2373d016a 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -2484,6 +2484,8 @@ class _KeyboardMenu extends StatelessWidget { ? (v) async { if (v != null) { await stateGlobal.setInputSource(ffi.sessionId, v); + // Release native input; see the macOS trade-offs in RemotePage. + if (isMacOS) ffi.inputModel.enterOrLeave(false); await ffi.ffiModel.checkDesktopKeyboardMode(); await ffi.inputModel.updateKeyboardMode(); }