diff --git a/Cargo.lock b/Cargo.lock index 55a117387..b6b927eb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6944,6 +6944,7 @@ dependencies = [ "tracing", "webm", "winapi 0.3.9", + "zbus", ] [[package]] diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index 431a36b04..e31196dc8 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -690,9 +690,20 @@ class _ImagePaintState extends State { Widget _buildScrollAutoNonTextureRender( ImageModel m, CanvasModel c, double s) { + double sizeScale = s; + if (widget.ffi.ffiModel.isPeerLinux) { + final displays = widget.ffi.ffiModel.pi.getCurDisplays(); + if (displays.isNotEmpty) { + sizeScale = s / displays[0].scale; + } + } return CustomPaint( size: Size(c.size.width, c.size.height), - painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s), + painter: ImagePainter( + image: m.image, + x: c.x / sizeScale, + y: c.y / sizeScale, + scale: sizeScale), ); } @@ -705,17 +716,19 @@ class _ImagePaintState extends State { if (rect == null) { return Container(); } + final isPeerLinux = ffiModel.isPeerLinux; final curDisplay = ffiModel.pi.currentDisplay; for (var i = 0; i < displays.length; i++) { final textureId = widget.ffi.textureModel .getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay); if (true) { // both "textureId.value != -1" and "true" seems ok + final sizeScale = isPeerLinux ? s / displays[i].scale : s; children.add(Positioned( left: (displays[i].x - rect.left) * s + offset.dx, top: (displays[i].y - rect.top) * s + offset.dy, - width: displays[i].width * s, - height: displays[i].height * s, + width: displays[i].width * sizeScale, + height: displays[i].height * sizeScale, child: Obx(() => Texture( textureId: textureId.value, filterQuality: diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 3a8eacb0a..dd783055a 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -577,7 +577,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { color: MyTheme.canvasColor, child: Stack(children: () { final paints = [ - ImagePaint(), + ImagePaint(ffiModel: gFFI.ffiModel), Positioned( top: 10, right: 10, @@ -635,7 +635,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { Widget getBodyForDesktopWithListener() { final ffiModel = Provider.of(context); - var paints = [ImagePaint()]; + var paints = [ImagePaint(ffiModel: ffiModel)]; if (showCursorPaint) { final cursor = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: 'show-remote-cursor'); @@ -1055,11 +1055,20 @@ class _KeyHelpToolsState extends State { } class ImagePaint extends StatelessWidget { + final FfiModel ffiModel; + ImagePaint({Key? key, required this.ffiModel}) : super(key: key); + @override Widget build(BuildContext context) { final m = Provider.of(context); final c = Provider.of(context); var s = c.scale; + if (ffiModel.isPeerLinux) { + final displays = ffiModel.pi.getCurDisplays(); + if (displays.isNotEmpty) { + s = s / displays[0].scale; + } + } final adjust = c.getAdjustY(); return CustomPaint( painter: ImagePainter( diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 9c6993632..b6d98a01c 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -159,6 +159,8 @@ class FfiModel with ChangeNotifier { bool get isPeerAndroid => _pi.platform == kPeerPlatformAndroid; bool get isPeerMobile => isPeerAndroid; + bool get isPeerLinux => _pi.platform == kPeerPlatformLinux; + bool get viewOnly => _viewOnly; bool get showMyCursor => _showMyCursor; @@ -179,6 +181,9 @@ class FfiModel with ChangeNotifier { if (displays.isEmpty) { return null; } + if (isPeerLinux) { + useDisplayScale = true; + } int scale(int len, double s) { if (useDisplayScale) { return len.toDouble() ~/ s; @@ -1076,18 +1081,17 @@ class FfiModel with ChangeNotifier { if (displays.length == 1) { bind.sessionSetSize( sessionId: sessionId, - display: - pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay, - width: _rect!.width.toInt(), - height: _rect!.height.toInt(), + display: pi.currentDisplay == kAllDisplayValue ? 0 : pi.currentDisplay, + width: displays[0].width, + height: displays[0].height, ); } else { for (int i = 0; i < displays.length; ++i) { bind.sessionSetSize( sessionId: sessionId, display: i, - width: displays[i].width.toInt(), - height: displays[i].height.toInt(), + width: displays[i].width, + height: displays[i].height, ); } } @@ -1436,8 +1440,17 @@ class FfiModel with ChangeNotifier { d.cursorEmbedded = evt['cursor_embedded'] == 1; d.originalWidth = evt['original_width'] ?? kInvalidResolutionValue; d.originalHeight = evt['original_height'] ?? kInvalidResolutionValue; - double v = (evt['scale']?.toDouble() ?? 100.0) / 100; - d._scale = v > 1.0 ? v : 1.0; + d._scale = 1.0; + final scaledWidth = evt['scaled_width']; + if (scaledWidth != null) { + final sw = int.tryParse(scaledWidth.toString()); + if (sw != null && sw > 0 && d.width > 0) { + d._scale = max(d.width.toDouble() / sw, 1.0); + } else { + debugPrint( + "Invalid scaled_width ($scaledWidth) or width (${d.width}), using default scale 1.0"); + } + } return d; } @@ -2438,11 +2451,6 @@ class CanvasModel with ChangeNotifier { notifyListeners(); } - set scale(v) { - _scale = v; - notifyListeners(); - } - panX(double dx) { _x += dx; if (isMobile) { @@ -2976,9 +2984,10 @@ class CursorModel with ChangeNotifier { var cx = r.center.dx; var cy = r.center.dy; var tryMoveCanvasX = false; + final displayRect = parent.target?.ffiModel.rect; if (dx > 0) { final maxCanvasCanMove = _displayOriginX + - (parent.target?.imageModel.image!.width ?? 1280) - + (displayRect?.width ?? 1280) - r.right.roundToDouble(); tryMoveCanvasX = _x + dx > cx && maxCanvasCanMove > 0; if (tryMoveCanvasX) { @@ -3000,7 +3009,7 @@ class CursorModel with ChangeNotifier { var tryMoveCanvasY = false; if (dy > 0) { final mayCanvasCanMove = _displayOriginY + - (parent.target?.imageModel.image!.height ?? 720) - + (displayRect?.height ?? 720) - r.bottom.roundToDouble(); tryMoveCanvasY = _y + dy > cy && mayCanvasCanMove > 0; if (tryMoveCanvasY) { diff --git a/libs/scrap/Cargo.toml b/libs/scrap/Cargo.toml index 16196d11f..505eca2de 100644 --- a/libs/scrap/Cargo.toml +++ b/libs/scrap/Cargo.toml @@ -10,7 +10,7 @@ authors = ["Ram "] edition = "2018" [features] -wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing"] +wayland = ["gstreamer", "gstreamer-app", "gstreamer-video", "dbus", "tracing", "zbus"] mediacodec = ["ndk"] linux-pkg-config = ["dep:pkg-config"] hwcodec = ["dep:hwcodec"] @@ -57,6 +57,7 @@ tracing = { version = "0.1", optional = true } gstreamer = { version = "0.16", optional = true } gstreamer-app = { version = "0.16", features = ["v1_10"], optional = true } gstreamer-video = { version = "0.16", optional = true } +zbus = { version = "3.15", optional = true } [dependencies.hwcodec] git = "https://github.com/rustdesk-org/hwcodec" diff --git a/libs/scrap/src/common/linux.rs b/libs/scrap/src/common/linux.rs index 4e83e6e7c..ba5e8e7ff 100644 --- a/libs/scrap/src/common/linux.rs +++ b/libs/scrap/src/common/linux.rs @@ -88,6 +88,27 @@ impl Display { } } + pub fn scale(&self) -> f64 { + match self { + Display::X11(_d) => 1.0, + Display::WAYLAND(d) => d.scale(), + } + } + + pub fn logical_width(&self) -> usize { + match self { + Display::X11(d) => d.width(), + Display::WAYLAND(d) => d.logical_width(), + } + } + + pub fn logical_height(&self) -> usize { + match self { + Display::X11(d) => d.height(), + Display::WAYLAND(d) => d.logical_height(), + } + } + pub fn origin(&self) -> (i32, i32) { match self { Display::X11(d) => d.origin(), diff --git a/libs/scrap/src/common/wayland.rs b/libs/scrap/src/common/wayland.rs index afcfc4a53..30b5f4d54 100644 --- a/libs/scrap/src/common/wayland.rs +++ b/libs/scrap/src/common/wayland.rs @@ -8,7 +8,6 @@ use super::x11::PixelBuffer; pub struct Capturer(Display, Box, Vec); - lazy_static::lazy_static! { static ref MAP_ERR: RwLock io::Error>> = Default::default(); } @@ -61,7 +60,7 @@ impl TraitCapturer for Capturer { } } -pub struct Display(pipewire::PipeWireCapturable); +pub struct Display(pub(crate) pipewire::PipeWireCapturable); impl Display { pub fn primary() -> io::Result { @@ -81,11 +80,35 @@ impl Display { } pub fn width(&self) -> usize { - self.0.size.0 + self.physical_width() } pub fn height(&self) -> usize { - self.0.size.1 + self.physical_height() + } + + pub fn physical_width(&self) -> usize { + self.0.physical_size.0 + } + + pub fn physical_height(&self) -> usize { + self.0.physical_size.1 + } + + pub fn logical_width(&self) -> usize { + self.0.logical_size.0 + } + + pub fn logical_height(&self) -> usize { + self.0.logical_size.1 + } + + pub fn scale(&self) -> f64 { + if self.logical_width() == 0 { + 1.0 + } else { + self.physical_width() as f64 / self.logical_width() as f64 + } } pub fn origin(&self) -> (i32, i32) { @@ -97,7 +120,7 @@ impl Display { } pub fn is_primary(&self) -> bool { - false + self.0.primary } pub fn name(&self) -> String { diff --git a/libs/scrap/src/wayland.rs b/libs/scrap/src/wayland.rs index 501fec859..341f2b800 100644 --- a/libs/scrap/src/wayland.rs +++ b/libs/scrap/src/wayland.rs @@ -1,5 +1,6 @@ pub mod capturable; pub mod pipewire; +pub mod display; mod screencast_portal; mod request_portal; pub mod remote_desktop_portal; diff --git a/libs/scrap/src/wayland/display.rs b/libs/scrap/src/wayland/display.rs new file mode 100644 index 000000000..a5c937491 --- /dev/null +++ b/libs/scrap/src/wayland/display.rs @@ -0,0 +1,256 @@ +use hbb_common::regex::Regex; +use lazy_static::lazy_static; +use std::sync::Mutex; +use std::{ + process::{Command, Output, Stdio}, + sync::Arc, + time::{Duration, Instant}, +}; +use tracing::warn; + +use hbb_common::platform::linux::{get_wayland_displays, WaylandDisplayInfo}; + +lazy_static! { + static ref DISPLAYS: Mutex>> = Mutex::new(None); +} + +const COMMAND_TIMEOUT: Duration = Duration::from_millis(1000); + +pub struct Displays { + pub primary: usize, + pub displays: Vec, +} + +// We need this helper to run commands with a timeout, as some commands may hang. +// `kscreen-doctor -o` is known to hang when: +// 1. On Archlinux, Both GNOME and KDE Plasma are installed. +// 2. Run this command in a GNOME session. +fn run_with_timeout( + program: &str, + args: &[&str], + timeout: Duration, + label: &str, +) -> Option { + let mut child = Command::new(program) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .ok()?; + + let start = Instant::now(); + loop { + if let Ok(Some(_)) = child.try_wait() { + break; + } + if start.elapsed() >= timeout { + warn!("{} command timed out after {:?}", label, timeout); + if let Err(e) = child.kill() { + warn!("Failed to kill child process for '{}': {}", label, e); + } + if let Err(e) = child.wait() { + warn!("Failed to wait for child process for '{}': {}", label, e); + } + return None; + } + std::thread::sleep(Duration::from_millis(30)); + } + + match child.wait_with_output() { + Ok(output) => { + if !output.status.success() { + warn!("{} command failed with status: {}", label, output.status); + return None; + } + Some(output) + } + Err(_) => None, + } +} + +// There are some limitations with xrandr method: +// 1. It only works when XWayland is running. +// 2. The distro may not have xrandr installed by default. +// 3. xrandr may not report "primary" in its output. eg. openSUSE Leap 15.6 KDE Plasma. +fn try_xrandr_primary() -> Option { + let output = Command::new("xrandr").output().ok()?; + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("primary") && line.contains("connected") { + if let Some(name) = line.split_whitespace().next() { + return Some(name.to_string()); + } + } + } + None +} + +fn try_kscreen_primary() -> Option { + if !hbb_common::platform::linux::is_kde_session() { + return None; + } + + let output = run_with_timeout( + "kscreen-doctor", + &["-o"], + COMMAND_TIMEOUT, + "kscreen-doctor -o", + )?; + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + + // Remove ANSI color codes + let re_ansi = Regex::new(r"\x1b\[[0-9;]*m").ok()?; + let clean_text = re_ansi.replace_all(&text, ""); + + // Split the text into blocks, each starting with "Output:". + // The first element of the split will be empty, so we skip it. + for block in clean_text.split("Output:").skip(1) { + // Check if this block describes the primary monitor. + if block.contains("priority 1") { + // The monitor name is the second piece of text in the block, after the ID. + // e.g., " 1 eDP-1 enabled..." -> "eDP-1" + if let Some(name) = block.split_whitespace().nth(1) { + return Some(name.to_string()); + } + } + } + + None +} + +fn try_gdbus_primary() -> Option { + let output = run_with_timeout( + "gdbus", + &[ + "call", + "--session", + "--dest", + "org.gnome.Mutter.DisplayConfig", + "--object-path", + "/org/gnome/Mutter/DisplayConfig", + "--method", + "org.gnome.Mutter.DisplayConfig.GetCurrentState", + ], + COMMAND_TIMEOUT, + "gdbus DisplayConfig.GetCurrentState", + )?; + + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + + // Match logical monitor entries with primary=true + // Pattern: (x, y, scale, transform, true, [('connector-name', ...), ...], ...) + // Use regex to find entries where 5th field is true, then extract connector name + // Example matched text: "(0, 0, 1.5, 0, true, [('HDMI-1', 'MHH', 'Monitor', '0x00000000')], ...)" + let re = Regex::new(r"\([^()]*,\s*true,\s*\[\('([^']+)'").ok()?; + + if let Some(captures) = re.captures(&text) { + return captures.get(1).map(|m| m.as_str().to_string()); + } + + None +} + +fn get_primary_monitor() -> Option { + try_xrandr_primary() + .or_else(try_kscreen_primary) + .or_else(try_gdbus_primary) +} + +pub fn get_displays() -> Arc { + let mut lock = DISPLAYS.lock().unwrap(); + match lock.as_ref() { + Some(displays) => displays.clone(), + None => match get_wayland_displays() { + Ok(displays) => { + let mut primary_index = None; + if let Some(name) = get_primary_monitor() { + for (i, display) in displays.iter().enumerate() { + if display.name == name { + primary_index = Some(i); + break; + } + } + }; + if primary_index.is_none() { + for (i, display) in displays.iter().enumerate() { + if display.x == 0 && display.y == 0 { + primary_index = Some(i); + break; + } + } + } + let displays = Arc::new(Displays { + primary: primary_index.unwrap_or(0), + displays, + }); + *lock = Some(displays.clone()); + displays + } + Err(err) => { + warn!("Failed to get wayland displays: {}", err); + Arc::new(Displays { + primary: 0, + displays: Vec::new(), + }) + } + }, + } +} + +#[inline] +pub fn clear_wayland_displays_cache() { + let _ = DISPLAYS.lock().unwrap().take(); +} + +// 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; + if displays.is_empty() { + return None; + } + + // For compatibility, if only one display, we use the physical size for `uinput`. + // Otherwise, we use the logical size for `uinput`. + if displays.len() == 1 { + let d = &displays[0]; + return Some((d.x, d.x + d.width, d.y, d.y + d.height)); + } + + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = i32::MIN; + let mut max_y = i32::MIN; + for d in displays.iter() { + min_x = min_x.min(d.x); + min_y = min_y.min(d.y); + let size = if let Some(logical_size) = d.logical_size { + logical_size + } else { + // When `logical_size` is None, we cannot obtain the correct desktop rectangle. + // 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!( + "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); + max_y = max_y.max(d.y + size.1); + } + Some((min_x, max_x, min_y, max_y)) +} diff --git a/libs/scrap/src/wayland/pipewire.rs b/libs/scrap/src/wayland/pipewire.rs index cb650fb1c..20b43ea08 100644 --- a/libs/scrap/src/wayland/pipewire.rs +++ b/libs/scrap/src/wayland/pipewire.rs @@ -2,9 +2,12 @@ use std::collections::HashMap; use std::error::Error; use std::os::unix::io::AsRawFd; use std::process::Command; -use std::sync::{atomic::AtomicBool, Arc, Mutex}; +use std::sync::{ + atomic::{AtomicBool, AtomicU8, Ordering}, + Arc, Mutex, +}; use std::time::Duration; -use tracing::{debug, trace, warn}; +use tracing::{debug, error, trace, warn}; use dbus::{ arg::{OwnedFd, PropMap, RefArg, Variant}, @@ -17,23 +20,63 @@ use gstreamer as gst; use gstreamer::prelude::*; use gstreamer_app::AppSink; -use hbb_common::config; +use lazy_static::lazy_static; + +use hbb_common::{bail, config, platform::linux::CMD_SH, tokio, ResultType}; use super::capturable::PixelProvider; use super::capturable::{Capturable, Recorder}; +use super::display::{clear_wayland_displays_cache, get_displays, Displays}; use super::remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop as remote_desktop_portal; use super::request_portal::OrgFreedesktopPortalRequestResponse; use super::screencast_portal::OrgFreedesktopPortalScreenCast as screencast_portal; -use hbb_common::platform::linux::CMD_SH; -use lazy_static::lazy_static; lazy_static! { pub static ref RDP_SESSION_INFO: Mutex> = Mutex::new(None); + // Maybe it's better to save this cache in config file? + // Because "--server" process may be restarted frequently, then the cache will be lost. + // But the users have to know where to find and delete the config file when they want to clear the cache, + // or we have to add a UI for that. + // For simplicity, we just keep it in memory for now. + static ref PIPEWIRE_DISPLAY_OFFSET_CACHE: Mutex> = + Mutex::new(None); +} + +// For KDE Plasma only, because GNOME provides position info. +struct PipewireDisplayOffsetCache { + // We need to compare the displays, because: + // 1. On Archlinux KDE Plasma + // 2. One display, and connect, remember share choice. + // 3. Plug in another monitor. + // 4. The portal will reuse the restore token, no new share choice dialog, but the share screen is different. + // The controlling side will see the new monitor. + // All displays as one string for easy comparison + // name1-x1-y1-width1-height1;name2-x2-y2-width2-height2;... + display_key: String, + restore_token: String, + offsets: Vec<(i32, i32)>, +} + +// KDE Plasma may not provide position info +static HAS_POSITION_ATTR: AtomicBool = AtomicBool::new(false); +static IS_SERVER_RUNNING: AtomicU8 = AtomicU8::new(0); // 0: uninitialized, 1:true, 2: false + +impl PipewireDisplayOffsetCache { + fn displays_to_key(displays: &Arc) -> String { + displays + .displays + .iter() + .map(|d| format!("{}-{}-{}-{}-{}", d.name, d.x, d.y, d.width, d.height)) + .collect::>() + .join(";") + } } #[inline] pub fn close_session() { let _ = RDP_SESSION_INFO.lock().unwrap().take(); + clear_wayland_displays_cache(); + HAS_POSITION_ATTR.store(false, Ordering::SeqCst); } #[inline] @@ -52,6 +95,8 @@ pub fn try_close_session() { } if close { *rdp_info = None; + clear_wayland_displays_cache(); + HAS_POSITION_ATTR.store(false, Ordering::SeqCst); } } @@ -75,6 +120,10 @@ impl PwStreamInfo { pub fn get_size(&self) -> (usize, usize) { self.size } + + pub fn get_position(&self) -> (i32, i32) { + self.position + } } #[derive(Debug)] @@ -108,8 +157,10 @@ pub struct PipeWireCapturable { fd: OwnedFd, path: u64, source_type: u64, + pub primary: bool, pub position: (i32, i32), - pub size: (usize, usize), + pub logical_size: (usize, usize), + pub physical_size: (usize, usize), } impl PipeWireCapturable { @@ -117,27 +168,31 @@ impl PipeWireCapturable { conn: Arc, fd: OwnedFd, resolution: Arc>>, - stream: PwStreamInfo, + stream: &PwStreamInfo, ) -> Self { // alternative to get screen resolution as stream.size is not always correct ex: on fractional scaling // https://github.com/rustdesk/rustdesk/issues/6116#issuecomment-1817724244 - let size = get_res(Self { + let physical_size = get_res(Self { dbus_conn: conn.clone(), fd: fd.clone(), path: stream.path, source_type: stream.source_type, + primary: false, position: stream.position, - size: stream.size, + logical_size: stream.size, + physical_size: (0, 0), }) .unwrap_or(stream.size); - *resolution.lock().unwrap() = Some(size); + *resolution.lock().unwrap() = Some(physical_size); Self { dbus_conn: conn, fd, path: stream.path, source_type: stream.source_type, + primary: false, position: stream.position, - size, + logical_size: stream.size, + physical_size, } } } @@ -214,7 +269,7 @@ pub struct PipeWireRecorder { } impl PipeWireRecorder { - pub fn new(capturable: PipeWireCapturable) -> Result> { + pub fn new(capturable: PipeWireCapturable) -> ResultType { let pipeline = gst::Pipeline::new(None); let src = gst::ElementFactory::make("pipewiresrc", None)?; @@ -247,7 +302,36 @@ impl PipeWireRecorder { )); appsink.set_caps(Some(&caps)); + // [Workaround] + // Crash may occur if there are multiple pipelines started at the same time. + // `pipeline.get_state()` can significantly reduce the probability of crashes, + // but cannot completely resolve this issue. + // Adding a short sleep period can also reduce the probability of crashes. + debug!( + "[gstreamer] Setting pipeline {} to PLAYING state...", + capturable.fd.as_raw_fd() + ); pipeline.set_state(gst::State::Playing)?; + + // Wait for the state change to actually complete before proceeding. + // The 2000ms timeout for pipeline state change was chosen based on empirical testing. + let state_change = pipeline.get_state(gst::ClockTime::from_mseconds(2000)); + match state_change { + (Ok(_), gst::State::Playing, _) => { + debug!( + "[gstreamer] Pipeline {} state confirmed as PLAYING.", + capturable.fd.as_raw_fd() + ); + } + (result, state, pending) => { + warn!( + "[gstreamer] Pipeline {} state change incomplete: result={:?}, state={:?}, pending={:?}", + capturable.fd.as_raw_fd(), result, state, pending + ); + } + } + std::thread::sleep(std::time::Duration::from_millis(150)); + Ok(Self { pipeline, appsink, @@ -366,6 +450,8 @@ impl Drop for PipeWireRecorder { if let Err(err) = self.pipeline.set_state(gst::State::Null) { warn!("Failed to stop GStreamer pipeline: {}.", err); } + // Wait for state change to complete to avoid races during PipeWire teardown. + let _ = self.pipeline.get_state(gst::ClockTime::from_mseconds(2000)); } } @@ -396,18 +482,18 @@ where 0 => {} 1 => { warn!("DBus response: User cancelled interaction."); - failure_out.store(true, std::sync::atomic::Ordering::Relaxed); + failure_out.store(true, Ordering::SeqCst); return true; } c => { warn!("DBus response: Unknown error, code: {}.", c); - failure_out.store(true, std::sync::atomic::Ordering::Relaxed); + failure_out.store(true, Ordering::SeqCst); return true; } } if let Err(err) = f(r, c, m) { warn!("Error requesting screen capture via dbus: {}", err); - failure_out.store(true, std::sync::atomic::Ordering::Relaxed); + failure_out.store(true, Ordering::SeqCst); } true }) @@ -488,6 +574,7 @@ fn streams_from_response(response: OrgFreedesktopPortalRequestResponse) -> Vec

Result { } // mostly inspired by https://gitlab.gnome.org/-/snippets/39 -pub fn request_remote_desktop() -> Result< - ( - SyncConnection, - OwnedFd, - Vec, - dbus::Path<'static>, - bool, - ), - Box, -> { +pub fn request_remote_desktop( + capture_cursor: bool, +) -> ResultType<( + SyncConnection, + OwnedFd, + Vec, + dbus::Path<'static>, + bool, +)> { unsafe { if !INIT { gstreamer::init()?; @@ -574,6 +660,7 @@ pub fn request_remote_desktop() -> Result< session.clone(), failure.clone(), is_support_restore_token, + capture_cursor, ), failure_res.clone(), )?; @@ -586,7 +673,7 @@ pub fn request_remote_desktop() -> Result< break; } - if failure_res.load(std::sync::atomic::Ordering::Relaxed) { + if failure_res.load(Ordering::SeqCst) { break; } } @@ -607,9 +694,7 @@ pub fn request_remote_desktop() -> Result< } } } - Err(Box::new(DBusError( -"Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.".into() - ))) + bail!("Failed to obtain screen capture. You may need to upgrade the PipeWire library for better compatibility. Please check https://github.com/rustdesk/rustdesk/issues/8600#issuecomment-2254720954 for more details.") } fn on_create_session_response( @@ -618,6 +703,7 @@ fn on_create_session_response( session: Arc>>>, failure: Arc, is_support_restore_token: bool, + capture_cursor: bool, ) -> impl Fn( OrgFreedesktopPortalRequestResponse, &SyncConnection, @@ -666,6 +752,14 @@ fn on_create_session_response( } args.insert("types".into(), Variant(Box::new(1u32))); //| 2u32))); + if capture_cursor { + get_available_cursor_modes().ok().map(|modes| { + if modes & 0x2 != 0 { + args.insert("cursor_mode".to_string(), Variant(Box::new(2u32))); + } + }); + } + let path = portal.select_sources(ses.clone(), args)?; handle_response( c, @@ -838,7 +932,7 @@ pub fn get_capturables() -> Result, Box> { }; if rdp_connection.is_none() { - let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop()?; + let (conn, fd, streams, session, is_support_restore_token) = request_remote_desktop(false)?; let conn = Arc::new(conn); let rdp_info = RdpSessionInfo { @@ -852,7 +946,7 @@ pub fn get_capturables() -> Result, Box> { *rdp_connection = Some(rdp_info); } - let rdp_info = match rdp_connection.as_ref() { + let rdp_info = match rdp_connection.as_mut() { Some(res) => res, None => { return Err(Box::new(DBusError("RDP response is None.".into()))); @@ -861,8 +955,7 @@ pub fn get_capturables() -> Result, Box> { Ok(rdp_info .streams - .clone() - .into_iter() + .iter() .map(|s| { PipeWireCapturable::new( rdp_info.conn.clone(), @@ -883,7 +976,12 @@ pub fn get_capturables() -> Result, Box> { // // `screencast_portal` supports restore_token and persist_mode if the version is greater than or equal to 4. // `remote_desktop_portal` does not support restore_token and persist_mode. -fn is_server_running() -> bool { +pub(crate) fn is_server_running() -> bool { + let v = IS_SERVER_RUNNING.load(Ordering::SeqCst); + if v > 0 { + return v == 1; + } + let app_name = config::APP_NAME.read().unwrap().clone().to_lowercase(); let output = match Command::new(CMD_SH.as_str()) .arg("-c") @@ -898,5 +996,525 @@ fn is_server_running() -> bool { let output_str = String::from_utf8_lossy(&output.stdout); let is_running = output_str.contains(&format!("{} --server", app_name)); + IS_SERVER_RUNNING.store(if is_running { 1 } else { 2 }, Ordering::SeqCst); is_running } + +// The logical size reported by portal may be different from the size reported by `get_displays()`. +// So we need to use the workaround here. +// 1. openSUSE, KDE Plasma +// 2. Kubuntu 24.04 TLS, after running `sudo apt install plasma-workspace-wayland` +// Maybe it's a bug, and we can remove this workaround in the future. +pub fn try_fix_logical_size(shared_displays: &mut Vec) { + if !is_server_running() { + return; + } + + let wayland_displays = get_displays(); + if wayland_displays.displays.is_empty() { + return; + } + + for sd in shared_displays.iter_mut() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &mut d.0; + for wd in wayland_displays.displays.iter() { + if capturable.position.0 == wd.x && capturable.position.1 == wd.y { + if let Some(logical_size) = wd.logical_size { + if capturable.physical_size.0 != wd.width as usize + || capturable.physical_size.1 != wd.height as usize + { + // If "Full Workspace" is selected in the portal dialog, + // the physical size reported by portal may not match the display info. + debug!( + "Physical size of capturable ({:?}) does not match display info: ({:?}) - ({:?}). Skipping logical size fix.", + capturable.position, + capturable.physical_size, + (wd.width as usize, wd.height as usize) + ); + break; + } + + if capturable.logical_size.0 != logical_size.0 as usize + || capturable.logical_size.1 != logical_size.1 as usize + { + warn!( + "Fixing logical size of capturable from {:?} to {:?} based on display info {:?}.", + capturable.logical_size, + logical_size, + wd + ); + capturable.logical_size = + (logical_size.0 as usize, logical_size.1 as usize); + } + } + break; + } + } + } + } +} + +pub fn fill_displays( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + shared_displays: &mut Vec, +) -> ResultType<()> { + if !is_server_running() { + return Ok(()); + } + + let mut rdp_connection = RDP_SESSION_INFO.lock().unwrap(); + let rdp_info = match rdp_connection.as_mut() { + Some(res) => res, + None => { + // Unreachable + bail!("RDP session info is None when filling display positions."); + } + }; + + let all_displays = get_displays(); + if !HAS_POSITION_ATTR.load(Ordering::SeqCst) { + if all_displays.displays.len() > 1 { + debug!("Multiple Wayland displays detected, adjusting stream positions accordingly."); + try_fill_positions( + mouse_move_to, + get_cursor_pos, + &all_displays, + shared_displays, + &mut rdp_info.streams, + )?; + } + HAS_POSITION_ATTR.store(true, Ordering::SeqCst); + } + + if all_displays.displays.len() > 1 { + sort_streams(&all_displays, shared_displays, &mut rdp_info.streams); + } + + shared_displays.iter_mut().next().map(|d| { + if let crate::Display::WAYLAND(d) = d { + d.0.primary = true; + } + }); + + Ok(()) +} + +fn try_fill_positions( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, +) -> ResultType<()> { + if try_fill_positions_from_cache(displays, shared_displays, streams) { + return Ok(()); + } + + let mut multi_matched_indices = Vec::new(); + for (i, sd) in shared_displays.iter_mut().enumerate() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &mut d.0; + let mut match_count = 0; + for wd in displays.displays.iter() { + if capturable.physical_size.0 == wd.width as usize + && capturable.physical_size.1 == wd.height as usize + { + capturable.position = (wd.x, wd.y); + if let Some(pw_stream) = streams.get_mut(i) { + pw_stream.position = (wd.x, wd.y); + } + match_count += 1; + } + } + if match_count == 0 { + warn!( + "No matching display found for capturable with size {:?}.", + capturable.physical_size + ); + } else if match_count > 1 { + multi_matched_indices.push(i); + } + } + } + + if !multi_matched_indices.is_empty() { + fill_multi_matched_positions( + mouse_move_to, + get_cursor_pos, + displays, + shared_displays, + streams, + multi_matched_indices, + )?; + } + + save_positions_to_cache(displays, shared_displays); + Ok(()) +} + +fn try_fill_positions_from_cache( + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, +) -> bool { + let mut lock = PIPEWIRE_DISPLAY_OFFSET_CACHE.lock().unwrap(); + let Some(cache) = lock.as_ref() else { + return false; + }; + + if cache.offsets.len() != shared_displays.len() { + let _ = lock.take(); + return false; + } + + let display_key = PipewireDisplayOffsetCache::displays_to_key(displays); + if cache.display_key != display_key { + let _ = lock.take(); + return false; + } + + let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY); + if cache.restore_token != restore_token { + let _ = lock.take(); + return false; + } + + for (i, sd) in shared_displays.iter_mut().enumerate() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &mut d.0; + if let Some((x_off, y_off)) = cache.offsets.get(i) { + capturable.position = (*x_off, *y_off); + if let Some(pw_stream) = streams.get_mut(i) { + pw_stream.position = (*x_off, *y_off); + } + } + } + } + true +} + +fn save_positions_to_cache(displays: &Arc, shared_displays: &Vec) { + let restore_token = config::LocalConfig::get_option(RESTORE_TOKEN_CONF_KEY); + if restore_token.is_empty() { + return; + } + + let mut offsets = Vec::new(); + for sd in shared_displays.iter() { + if let crate::Display::WAYLAND(d) = sd { + let capturable = &d.0; + offsets.push((capturable.position.0, capturable.position.1)); + } + } + + let display_key = PipewireDisplayOffsetCache::displays_to_key(displays); + let cache = PipewireDisplayOffsetCache { + display_key, + restore_token, + offsets, + }; + + *PIPEWIRE_DISPLAY_OFFSET_CACHE.lock().unwrap() = Some(cache); +} + +fn compare_left_up_corner(w: usize, d1: &[u8], d2: &[u8]) -> bool { + if w == 0 { + return false; + } + if d1.len() != d2.len() { + return false; + } + let bpp = 4; // BGR0/RGB0 + let stride = w.saturating_mul(bpp); + if stride == 0 || d1.len() < stride || d2.len() < stride { + return false; + } + let h = d1.len() / stride; + if h == 0 { + return false; + } + + let roi_w = std::cmp::min(36, w); + let roi_h = std::cmp::min(36, h); + let mut diff_px = 0usize; + let total_px = roi_w * roi_h; + // Minimum number of differing pixels required to consider images different. + const MIN_DIFF_PIXELS: usize = 8; + // Divisor for threshold calculation: allows up to 1/8 of ROI pixels to differ before returning true. + const DIFF_THRESHOLD_DIVISOR: usize = 8; + let threshold = std::cmp::max(MIN_DIFF_PIXELS, total_px / DIFF_THRESHOLD_DIVISOR); + + for y in 0..roi_h { + let row_off = y * stride; + for x in 0..roi_w { + let i = row_off + x * bpp; + let a = &d1[i..i + bpp]; + let b = &d2[i..i + bpp]; + if a != b { + diff_px += 1; + if diff_px >= threshold { + return true; + } + } + } + } + false +} + +fn fill_multi_matched_positions( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, + multi_matched_indices: Vec, +) -> ResultType<()> { + debug!( + "Multiple capturables ({:?}) match the same display size, attempting to disambiguate positions.", + &multi_matched_indices); + if multi_matched_indices.is_empty() { + return Ok(()); + } + + let is_support_embeded_cursor = get_available_cursor_modes() + .ok() + .map(|modes| modes & 0x2 != 0) + .unwrap_or(false); + if is_support_embeded_cursor { + fill_multi_matched_positions_cursor( + mouse_move_to, + get_cursor_pos, + displays, + shared_displays, + streams, + multi_matched_indices, + )?; + } + + Ok(()) +} + +fn mouse_move_to_( + mouse_move_to: &impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + x: i32, + y: i32, +) { + const MOVE_MOUSE_TIMEOUT: Duration = Duration::from_millis(150); + let start = std::time::Instant::now(); + while start.elapsed() < MOVE_MOUSE_TIMEOUT { + mouse_move_to(x, y); + std::thread::sleep(Duration::from_millis(20)); + if let Some((x1, y1)) = get_cursor_pos() { + if x1 == x && y1 == y { + return; + } + } + } + warn!( + "Failed to move mouse to ({}, {}) within timeout: {:?}.", + x, y, &MOVE_MOUSE_TIMEOUT + ); +} + +fn fill_multi_matched_positions_cursor( + mouse_move_to: impl Fn(i32, i32), + get_cursor_pos: fn() -> Option<(i32, i32)>, + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, + multi_matched_indices: Vec, +) -> ResultType<()> { + // This creates a new remote desktop session for cursor-based position detection. + // The session is temporary, used only for disambiguation, and is dropped after detection completes. + let (conn, fd, streams_with_cursor, _session, _is_support_restore_token) = + request_remote_desktop(true)?; + let conn = Arc::new(conn); + + let mut matched_indices = Vec::new(); + const CAPTURE_TIMEOUT_MS: u64 = 1_000; + for idx in multi_matched_indices { + match ( + shared_displays.get_mut(idx), + streams.get_mut(idx), + streams_with_cursor.get(idx), + ) { + (Some(crate::Display::WAYLAND(d)), Some(pw_stream), Some(pw_stream_with_cursor)) => { + // Check if only one display matches the size + let mut match_count = 0; + for (i, wd) in displays.displays.iter().enumerate() { + if matched_indices.contains(&i) { + continue; + } + if d.0.physical_size.0 == wd.width as usize + && d.0.physical_size.1 == wd.height as usize + { + match_count += 1; + } + } + if match_count == 0 { + error!( + "No matching display found for capturable with size {:?}.", + d.0.physical_size + ); + continue; + } + if match_count == 1 { + for (i, wd) in displays.displays.iter().enumerate() { + if matched_indices.contains(&i) { + continue; + } + if d.0.physical_size.0 == wd.width as usize + && d.0.physical_size.1 == wd.height as usize + { + d.0.position = (wd.x, wd.y); + pw_stream.position = (wd.x, wd.y); + matched_indices.push(i); + debug!( + "Disambiguated position for capturable with size {:?} to ({}, {}).", + d.0.physical_size, wd.x, wd.y + ); + break; + } + } + continue; + } + + // Move the mouse to a neutral position first, + // to avoid interference from previous position. + mouse_move_to_(&mouse_move_to, get_cursor_pos, 300, 300); + + let mut rec = PipeWireRecorder::new(PipeWireCapturable { + dbus_conn: conn.clone(), + fd: fd.clone(), + path: pw_stream_with_cursor.path, + source_type: pw_stream_with_cursor.source_type, + primary: false, + position: pw_stream_with_cursor.position, + logical_size: pw_stream_with_cursor.size, + physical_size: (0, 0), + })?; + // Take first frame and copy owned buffer to avoid borrow across second capture + let (is_bgr, w, first_buf): (bool, usize, Vec) = + match rec.capture(CAPTURE_TIMEOUT_MS) { + Ok(PixelProvider::BGR0(w, _, data1)) => (true, w, data1.to_vec()), + Ok(PixelProvider::RGB0(w, _, data1)) => (false, w, data1.to_vec()), + Ok(_) => { + error!("Unexpected pixel format on first capture."); + continue; + } + Err(e) => { + error!( + "Failed to capture screen for position disambiguation: {}", + e + ); + continue; + } + }; + + let matched_len = matched_indices.len(); + for (i, wd) in displays.displays.iter().enumerate() { + if matched_indices.contains(&i) { + continue; + } + + if wd.width as usize == d.0.physical_size.0 + && wd.height as usize == d.0.physical_size.1 + { + mouse_move_to_(&mouse_move_to, get_cursor_pos, wd.x + 8, wd.y + 8); + rec.saved_raw_data.clear(); + match rec.capture(CAPTURE_TIMEOUT_MS) { + Ok(PixelProvider::BGR0(_, _, data2)) if is_bgr => { + if compare_left_up_corner(w, &first_buf, data2) { + d.0.position = (wd.x, wd.y); + pw_stream.position = (wd.x, wd.y); + matched_indices.push(i); + debug!( + "Disambiguated position for capturable with size {:?} to ({}, {}).", + d.0.physical_size, wd.x, wd.y + ); + break; + } + } + Ok(PixelProvider::RGB0(_, _, data2)) if !is_bgr => { + if compare_left_up_corner(w, &first_buf, data2) { + d.0.position = (wd.x, wd.y); + pw_stream.position = (wd.x, wd.y); + matched_indices.push(i); + debug!( + "Disambiguated position for capturable with size {:?} to ({}, {}).", + d.0.physical_size, wd.x, wd.y + ); + break; + } + } + Ok(_) => { + // unreachable + error!("Pixel format changed between captures, cannot disambiguate position."); + } + Err(e) => { + error!( + "Failed to capture screen for position disambiguation: {}", + e + ); + } + } + } + } + if matched_len == matched_indices.len() { + error!( + "Failed to disambiguate position for capturable with size {:?}.", + d.0.physical_size + ); + } + } + _ => {} + } + } + + Ok(()) +} + +fn sort_streams( + displays: &Arc, + shared_displays: &mut Vec, + streams: &mut Vec, +) { + if streams.is_empty() { + // unreachable + error!("No streams available to sort."); + return; + } + + // put the main display first, then the rest by the order of displays + let mut display_order: Vec<(i32, i32)> = Vec::new(); + if let Some(d) = displays.displays.get(displays.primary) { + display_order.push((d.x, d.y)); + } + for (i, d) in displays.displays.iter().enumerate() { + if i != displays.primary { + display_order.push((d.x, d.y)); + } + } + + let mut sorted_streams = Vec::new(); + let mut sorted_shared_displays = Vec::new(); + // Move matching items in order without cloning + for (x, y) in display_order.into_iter() { + for i in 0..streams.len() { + if streams[i].position.0 == x && streams[i].position.1 == y { + sorted_streams.push(streams.remove(i)); + // shared_displays.len() must be equal to streams.len() + // But we still check the length to avoid panic + if shared_displays.len() > i { + sorted_shared_displays.push(shared_displays.remove(i)); + } + break; + } + } + } + *streams = sorted_streams; + *shared_displays = sorted_shared_displays; +} diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index b85e864f3..3f30949bd 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1755,6 +1755,13 @@ impl Remote { thread.video_sender.send(MediaData::Reset).ok(); } + let mut scale = 1.0; + if let Some(pi) = &self.handler.lc.read().unwrap().peer_info { + if let Some(d) = pi.displays.get(s.display as usize) { + scale = d.scale; + } + } + if s.width > 0 && s.height > 0 { self.handler.set_display( s.x, @@ -1762,6 +1769,7 @@ impl Remote { s.width, s.height, s.cursor_embedded, + scale, ); } } diff --git a/src/clipboard.rs b/src/clipboard.rs index 9cea0c0f4..4280cd124 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -427,17 +427,8 @@ impl ClipboardContext { // Don't use `hbb_common::platform::linux::is_kde()` here. // It's not correct in the server process. #[cfg(target_os = "linux")] - let is_kde_x11 = { - use hbb_common::platform::linux::CMD_SH; - let is_kde = std::process::Command::new(CMD_SH.as_str()) - .arg("-c") - .arg("ps -e | grep -E kded[0-9]+ | grep -v grep") - .stdout(std::process::Stdio::piped()) - .output() - .map(|o| !o.stdout.is_empty()) - .unwrap_or(false); - is_kde && crate::platform::linux::is_x11() - }; + let is_kde_x11 = hbb_common::platform::linux::is_kde_session() + && crate::platform::linux::is_x11(); #[cfg(target_os = "macos")] let is_kde_x11 = false; let clear_holder_text = if is_kde_x11 { diff --git a/src/flutter.rs b/src/flutter.rs index f45e4c920..c7e07f892 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -609,7 +609,22 @@ impl FlutterHandler { h.insert("original_width", original_resolution.width); h.insert("original_height", original_resolution.height); } - h.insert("scale", (d.scale * 100.0f64) as i32); + // Don't convert scale (x 100) to i32 directly. + // (d.scale * 100.0f64) as i32 may produces inaccuracies. + // + // Example: GNOME Wayland with Fractional Scaling enabled: + // - Physical resolution: 2560x1600 + // - Logical resolution: 1074x1065 + // - Scale factor: 150% + // Passing physical dimensions and scale factor prevents accurate logical resolution calculation + // since 2560/1.5 = 1706.666... (rounded to 1706.67) and 1600/1.5 = 1066.666... (rounded to 1066.67) + // h.insert("scale", (d.scale * 100.0f64) as i32); + + // Send scaled_width for accurate logical scale calculation. + if d.scale > 0.0 { + let scaled_width = (d.width as f64 / d.scale).round() as i32; + h.insert("scaled_width", scaled_width); + } msg_vec.push(h); } serde_json::ser::to_string(&msg_vec).unwrap_or("".to_owned()) @@ -679,7 +694,7 @@ impl InvokeUiSession for FlutterHandler { } /// unused in flutter, use switch_display or set_peer_info - fn set_display(&self, _x: i32, _y: i32, _w: i32, _h: i32, _cursor_embedded: bool) {} + fn set_display(&self, _x: i32, _y: i32, _w: i32, _h: i32, _cursor_embedded: bool, _scale: f64) {} fn update_privacy_mode(&self) { self.push_event::<&str>("update_privacy_mode", &[], &[]); diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 0a6e62a53..ff74b8b79 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1,5 +1,7 @@ #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::keyboard::input_source::{change_input_source, get_cur_session_input_source}; +#[cfg(target_os = "linux")] +use crate::platform::linux::is_x11; use crate::{ client::file_trait::FileManager, common::{make_fd_to_json, make_vec_fd_to_json}, @@ -1471,19 +1473,45 @@ pub fn main_get_main_display() -> SyncReturn { #[cfg(not(target_os = "ios"))] let mut display_info = "".to_owned(); #[cfg(not(target_os = "ios"))] - if let Ok(displays) = crate::display_service::try_get_displays() { - // to-do: Need to detect current display index. - if let Some(display) = displays.iter().next() { - display_info = serde_json::to_string(&HashMap::from([ - ("w", display.width()), - ("h", display.height()), - ])) - .unwrap_or_default(); + { + #[cfg(not(target_os = "linux"))] + let is_linux_wayland = false; + #[cfg(target_os = "linux")] + let is_linux_wayland = !is_x11(); + + if !is_linux_wayland { + if let Ok(displays) = crate::display_service::try_get_displays() { + // to-do: Need to detect current display index. + if let Some(display) = displays.iter().next() { + display_info = serde_json::to_string(&HashMap::from([ + ("w", display.width()), + ("h", display.height()), + ])) + .unwrap_or_default(); + } + } + } + + #[cfg(target_os = "linux")] + if is_linux_wayland { + let displays = scrap::wayland::display::get_displays(); + if let Some(display) = displays.displays.get(displays.primary) { + let logical_size = display + .logical_size + .unwrap_or((display.width, display.height)); + display_info = serde_json::to_string(&HashMap::from([ + ("w", logical_size.0), + ("h", logical_size.1), + ])) + .unwrap_or_default(); + } } } SyncReturn(display_info) } +// No need to check if is on Wayland in this function. +// The Flutter side gets display information on Wayland using a different method. pub fn main_get_displays() -> SyncReturn { #[cfg(target_os = "ios")] let display_info = "".to_owned(); diff --git a/src/server/display_service.rs b/src/server/display_service.rs index 6a52cbbea..fe3621f26 100644 --- a/src/server/display_service.rs +++ b/src/server/display_service.rs @@ -304,6 +304,12 @@ 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) { + // 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")] + let use_logical_scale = !is_x11() + && crate::is_server() + && scrap::wayland::display::get_displays().displays.len() > 1; let displays = all .iter() .map(|d| { @@ -315,6 +321,12 @@ pub(super) fn check_update_displays(all: &Vec) { { scale = d.scale(); } + #[cfg(target_os = "linux")] + { + if use_logical_scale { + scale = d.scale(); + } + } let original_resolution = get_original_resolution( &display_name, ((d.width() as f64) / scale).round() as usize, diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 6a6c6e3a6..203651b58 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -20,7 +20,10 @@ use scrap::wayland::pipewire::RDP_SESSION_INFO; use std::{ convert::TryFrom, ops::{Deref, DerefMut}, - sync::atomic::{AtomicBool, Ordering}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, + }, thread, time::{self, Duration, Instant}, }; @@ -1834,6 +1837,51 @@ pub fn wayland_use_rdp_input() -> bool { !crate::platform::is_x11() && !crate::is_server() } +#[cfg(target_os = "linux")] +pub struct TemporaryMouseMoveHandle { + thread_handle: Option>, + tx: Option>, +} + +#[cfg(target_os = "linux")] +impl TemporaryMouseMoveHandle { + pub fn new() -> Self { + let (tx, rx) = mpsc::channel::<(i32, i32)>(); + let thread_handle = std::thread::spawn(move || { + log::debug!("TemporaryMouseMoveHandle thread started"); + for (x, y) in rx { + ENIGO.lock().unwrap().mouse_move_to(x, y); + } + log::debug!("TemporaryMouseMoveHandle thread exiting"); + }); + TemporaryMouseMoveHandle { + thread_handle: Some(thread_handle), + tx: Some(tx), + } + } + + pub fn move_mouse_to(&self, x: i32, y: i32) { + if let Some(tx) = &self.tx { + let _ = tx.send((x, y)); + } + } +} + +#[cfg(target_os = "linux")] +impl Drop for TemporaryMouseMoveHandle { + fn drop(&mut self) { + log::debug!("Dropping TemporaryMouseMoveHandle"); + // Close the channel to signal the thread to exit. + self.tx.take(); + // Wait for the thread to finish. + if let Some(thread_handle) = self.thread_handle.take() { + if let Err(e) = thread_handle.join() { + log::error!("Error joining TemporaryMouseMoveHandle thread: {:?}", e); + } + } + } +} + lazy_static::lazy_static! { static ref MODIFIER_MAP: HashMap = [ (ControlKey::Alt, Key::Alt), diff --git a/src/server/rdp_input.rs b/src/server/rdp_input.rs index 854ae7fce..d9e11aca4 100644 --- a/src/server/rdp_input.rs +++ b/src/server/rdp_input.rs @@ -71,6 +71,7 @@ pub mod client { stream: PwStreamInfo, resolution: (usize, usize), scale: Option, + position: (f64, f64), } impl RdpInputMouse { @@ -98,12 +99,14 @@ pub mod client { } else { None }; + let pos = stream.get_position(); Ok(Self { conn, session, stream, resolution, scale, + position: (pos.0 as f64, pos.1 as f64), }) } } @@ -128,6 +131,8 @@ pub mod client { } else { y as f64 }; + let x = x - self.position.0; + let y = y - self.position.1; let portal = get_portal(&self.conn); let _ = remote_desktop_portal::notify_pointer_motion_absolute( &portal, diff --git a/src/server/wayland.rs b/src/server/wayland.rs index 253b7016a..6eb6a97bf 100644 --- a/src/server/wayland.rs +++ b/src/server/wayland.rs @@ -1,12 +1,12 @@ use super::*; -use hbb_common::{ - allow_err, - platform::linux::{CMD_SH, DISTRO}, +use hbb_common::{allow_err, anyhow, platform::linux::DISTRO}; +use scrap::{ + is_cursor_embedded, set_map_err, + wayland::pipewire::{fill_displays, try_fix_logical_size}, + Capturer, Display, Frame, TraitCapturer, }; -use scrap::{is_cursor_embedded, set_map_err, Capturer, Display, Frame, TraitCapturer}; use std::collections::HashMap; use std::io; -use std::process::{Command, Output}; use crate::{ client::{ @@ -127,45 +127,28 @@ pub(super) fn is_inited() -> Option { } } -fn get_max_desktop_resolution() -> Option { - // works with Xwayland - let output: Output = Command::new(CMD_SH.as_str()) - .arg("-c") - .arg("xrandr | awk '/current/ { print $8,$9,$10 }'") - .output() - .ok()?; - - if output.status.success() { - let result = String::from_utf8_lossy(&output.stdout); - Some(result.trim().to_string()) - } else { - None - } -} - -fn calculate_max_resolution_from_displays(displays: &[Display]) -> (i32, i32) { - // TODO: this doesn't work in most situations other than sharing all displays - // this is because the function only gets called with the displays being shared with pipewire - // the xrandr method does work otherwise we could get this correctly using xdg-output-unstable-v1 when xrandr isn't available - // log::warn!("using incorrect max resolution calculation uinput may not work correctly"); - let (mut max_x, mut max_y) = (0, 0); - for d in displays { - let (x, y) = d.origin(); - max_x = max_x.max(x + d.width() as i32); - max_y = max_y.max(y + d.height() as i32); - } - (max_x, max_y) -} - pub(super) async fn check_init() -> ResultType<()> { if !is_x11() { - let mut minx = 0; - let mut maxx = 0; - let mut miny = 0; - let mut maxy = 0; - let use_uinput = crate::input_service::wayland_use_uinput(); - if CAP_DISPLAY_INFO.read().unwrap().is_empty() { + if crate::input_service::wayland_use_uinput() { + if let Some((minx, maxx, miny, maxy)) = + scrap::wayland::display::get_desktop_rect_for_uinput() + { + log::info!( + "update mouse resolution: ({}, {}), ({}, {})", + minx, + maxx, + miny, + maxy + ); + allow_err!( + input_service::update_mouse_resolution(minx, maxx, miny, maxy).await + ); + } else { + log::warn!("Failed to get desktop rect for uinput"); + } + } + let mut lock = CAP_DISPLAY_INFO.write().unwrap(); if lock.is_empty() { // Check if PipeWire is already initialized to prevent duplicate recorder creation @@ -173,8 +156,16 @@ pub(super) async fn check_init() -> ResultType<()> { log::warn!("wayland_diag: Preventing duplicate PipeWire initialization"); return Ok(()); } - - let all = Display::all()?; + + let mut all = Display::all()?; + log::debug!("Initializing displays with fill_displays()"); + { + let temp_mouse_move_handle = input_service::TemporaryMouseMoveHandle::new(); + let move_mouse_to = |x, y| temp_mouse_move_handle.move_mouse_to(x, y); + fill_displays(move_mouse_to, crate::get_cursor_pos, &mut all)?; + } + log::debug!("Attempting to fix logical size with try_fix_logical_size()"); + try_fix_logical_size(&mut all); *PIPEWIRE_INITIALIZED.write().unwrap() = true; let num = all.len(); let primary = super::display_service::get_primary_2(&all); @@ -189,40 +180,23 @@ pub(super) async fn check_init() -> ResultType<()> { rects.push((d.origin(), d.width(), d.height())); } - log::debug!("#displays={}, primary={}, rects: {:?}, cpus={}/{}", num, primary, rects, num_cpus::get_physical(), num_cpus::get()); - - if use_uinput { - let (max_width, max_height) = match get_max_desktop_resolution() { - Some(result) if !result.is_empty() => { - let resolution: Vec<&str> = result.split(" ").collect(); - if let (Ok(w), Ok(h)) = ( - resolution[0].parse::(), - resolution.get(2) - .unwrap_or(&"0") - .trim_end_matches(",") - .parse::() - ) { - (w, h) - } else { - calculate_max_resolution_from_displays(&all) - } - } - _ => calculate_max_resolution_from_displays(&all), - }; - - minx = 0; - maxx = max_width; - miny = 0; - maxy = max_height; - } + log::debug!( + "#displays={}, primary={}, rects: {:?}, cpus={}/{}", + num, + primary, + rects, + num_cpus::get_physical(), + num_cpus::get() + ); // Create individual CapDisplayInfo for each display with its own capturer for (idx, display) in all.into_iter().enumerate() { - let capturer = Box::into_raw(Box::new( - Capturer::new(display).with_context(|| format!("Failed to create capturer for display {}", idx))?, - )); + let capturer = + Box::into_raw(Box::new(Capturer::new(display).with_context(|| { + format!("Failed to create capturer for display {}", idx) + })?)); let capturer = CapturerPtr(capturer); - + let cap_display_info = Box::into_raw(Box::new(CapDisplayInfo { rects: rects.clone(), displays: displays.clone(), @@ -231,24 +205,11 @@ pub(super) async fn check_init() -> ResultType<()> { current: idx, capturer, })); - + lock.insert(idx, cap_display_info as u64); } } } - - if use_uinput { - if minx != maxx && miny != maxy { - log::info!( - "update mouse resolution: ({}, {}), ({}, {})", - minx, - maxx, - miny, - maxy - ); - allow_err!(input_service::update_mouse_resolution(minx, maxx, miny, maxy).await); - } - } } Ok(()) } @@ -293,12 +254,14 @@ pub fn clear() { } } write_lock.clear(); - + // Reset PipeWire initialization flag to allow recreation on next init *PIPEWIRE_INITIALIZED.write().unwrap() = false; } -pub(super) fn get_capturer_for_display(display_idx: usize) -> ResultType { +pub(super) fn get_capturer_for_display( + display_idx: usize, +) -> ResultType { if is_x11() { bail!("Do not call this function if not wayland"); } @@ -307,7 +270,7 @@ pub(super) fn get_capturer_for_display(display_idx: usize) -> ResultType ResultType= w && sh > h) { var hh = $(header).box(#height, #border); @@ -71,6 +88,10 @@ function adaptDisplay() { } } } + if (isRemoteLinux()) { + cursor_scale = display_scale * display_remote_scale; + if (cursor_scale <= 0.0001) cursor_scale = 1.; + } refreshCursor(); handler.style.set { width: w / scaleFactor + "px", @@ -279,7 +300,7 @@ function handler.onMouse(evt) entered = false; stdout.println("leave"); handler.leave(handler.get_keyboard_mode()); - if (is_left_down && handler.peer_platform() == "Android") { + if (is_left_down && get_peer_platform() == "Android") { is_left_down = false; handler.send_mouse((1 << 3) | 2, 0, 0, evt.altKey, evt.ctrlKey, evt.shiftKey, evt.commandKey); @@ -303,8 +324,8 @@ function handler.onMouse(evt) resetWheel(); } if (!keyboard_enabled) return false; - x = (x / display_scale).toInteger(); - y = (y / display_scale).toInteger(); + x = (x / cursor_scale).toInteger(); + y = (y / cursor_scale).toInteger(); // insert down between two up, osx has this behavior for triple click if (last_mouse_mask == 2 && mask == 2) { handler.send_mouse((evt.buttons << 3) | 1, 0, 0, evt.altKey, @@ -339,14 +360,18 @@ var cursors = {}; var image_binded; function scaleCursorImage(img) { - var w = (img.width * display_scale).toInteger(); - var h = (img.height * display_scale).toInteger(); + var factor = cursor_scale; + if (cursor_img.style#display != 'none') { + factor /= scaleFactor; + } + var w = (img.width * factor).toInteger(); + var h = (img.height * factor).toInteger(); cursor_img.style.set { width: w + "px", height: h + "px", }; self.bindImage("in-memory:cursor", img); - if (display_scale == 1) return img; + if (factor == 1) return img; function paint(gfx) { gfx.drawImage(img, 0, 0, w, h); } @@ -360,7 +385,7 @@ function updateCursor(system=false) { if (system) { handler.style#cursor = undefined; } else if (cur_img) { - handler.style.cursor(cur_img, (cur_hotx * display_scale).toInteger(), (cur_hoty * display_scale).toInteger()); + handler.style.cursor(cur_img, (cur_hotx * cursor_scale).toInteger(), (cur_hoty * cursor_scale).toInteger()); } } @@ -413,14 +438,15 @@ handler.setCursorPosition = function(x, y) { cur_y = y - display_origin_y; var x = cur_x - cur_hotx; var y = cur_y - cur_hoty; - x *= display_scale / scaleFactor; - y *= display_scale / scaleFactor; + x *= cursor_scale / scaleFactor; + y *= cursor_scale / scaleFactor; cursor_img.style.set { left: x + "px", top: y + "px", }; if (cursor_img.style#display == 'none') { cursor_img.style#display = "block"; + refreshCursor(); } } diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index a082a8a78..c58fe8959 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1658,7 +1658,7 @@ pub trait InvokeUiSession: Send + Sync + Clone + 'static + Sized + Default { fn set_cursor_data(&self, cd: CursorData); fn set_cursor_id(&self, id: String); fn set_cursor_position(&self, cp: CursorPosition); - fn set_display(&self, x: i32, y: i32, w: i32, h: i32, cursor_embedded: bool); + fn set_display(&self, x: i32, y: i32, w: i32, h: i32, cursor_embedded: bool, scale: f64); fn switch_display(&self, display: &SwitchDisplay); fn set_peer_info(&self, peer_info: &PeerInfo); // flutter fn set_displays(&self, displays: &Vec); @@ -1804,6 +1804,7 @@ impl Interface for Session { current.width, current.height, current.cursor_embedded, + current.scale, ); } self.update_privacy_mode();