From 5cff35569a015300e68a816fd456cf0c86d5d2a8 Mon Sep 17 00:00:00 2001 From: VallierDev <24893073+VallierDev@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:19:13 +0800 Subject: [PATCH] X11: select one monitor with UU_X11_MULTISCREEN=1 (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X11 sends the whole root, so side-by-side monitors arrive as one wide picture. With UU_X11_MULTISCREEN=1 the host lists the RandR monitors (xrandr --listmonitors, read at start; primary or UU_X11_OUTPUT first, then left to right) through the existing Hyprland screen-switch path. x11grab reads display+x,y at the monitor size, XTest/uinput absolute input adds the monitor offset, and the in-process engine and the xrandr resize are skipped while a monitor region is captured (分辨率 scales the stream). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + README.en.md | 8 +++- README.md | 8 +++- src/capture.rs | 6 +++ src/input.rs | 30 ++++++++++++- src/video.rs | 9 ++-- src/wayland.rs | 120 +++++++++++++++++++++++++++++++++++++++++++------ src/webrtc.rs | 6 ++- 8 files changed, 164 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72ad331..c333a71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,7 @@ viewer does not reconnect them automatically. | `~/.config/uuremote/config.json` | user_id, device_id, client_id, system_id, machine_guid, token, csrf; optional `sign_key`, `ffmpeg_root`. `$UU_CONFIG` overrides the path | | `~/.config/uuremote/host_settings.json` | persisted 安全 settings: auto_lock, keep_mute, mute_permanent; optional `video_encoder` (`auto`/`cpu`/`vaapi`/`nvenc`) and `video_codec` (`auto`/`h264`/`h265`), written by `set-encoder` / `set-codec` (0600, merge write). Non-`auto` beats `UU_VIDEO_ENCODER` / `UU_VIDEO_CODEC`. `set-encoder` and `set-codec` both send `SIGUSR2` to the pid in `host.pid` (same binary). Live desktop streams restart the encoder (`EncoderTask::restart`, sequence kept, first frame IDR) without dropping the peer. Codec preference is logged and applies on the next desktop stream. `SIGUSR2` does not re-probe. Hand edits: `kill -USR2 $(cat ~/.config/uuremote/host.pid)` | | `UU_DESKTOP_BACKEND` | `auto` (default) / `x11` / `wayland` / `portal` / `kms` (experimental feature) / `none` (headless: no display; desktop offers ignored; tool peers get a nominal 1920x1080 screen + Allow permissions, which the port-mapping viewer requires) | +| `UU_X11_MULTISCREEN=1`, `UU_X11_OUTPUT` | X11: advertise each RandR monitor (`xrandr --listmonitors`, read once at start; primary or `UU_X11_OUTPUT` is id 0) through the same screen-switch path as `UU_WAYLAND_MULTISCREEN` (`wayland::outputs`/`select`). x11grab reads `display+x,y` at the monitor size, XTest/uinput absolute input adds the monitor offset, the in-process engine and `desktop_mode` resize are skipped (分辨率 scales). Needs two or more monitors | | `UU_DESKTOP_RESIZE=0` | X11: the viewer's 分辨率 choice scales the encoded stream instead of changing the display mode. Default: `src/desktop_mode.rs` switches the primary output via xrandr (adds a CVT-RB mode when missing, re-applies once if mutter resets it), restores the original mode for resolution_type 3 and 2 s after the last desktop stream ends | | `UU_NVENC_RGB=0` / `=1` | NVENC 4:2:0 without scaling takes packed BGRA/BGR0 directly (no CPU swscale). Default: on, except when a connected DRM connector belongs to an nvidia-bound card (desktop composited on the encoding GPU; #16 measured 82.6 vs 98 fps at 4K). `0` forces CPU conversion, `1` forces the direct path | | `UU_INPUT_BACKEND` | `auto` (uinput on Wayland/portal when `/dev/uinput` is accessible, udev `uaccess` rule in README) / `uinput` / `native`. X11 keeps XTest (held-key autorepeat handling) | diff --git a/README.en.md b/README.en.md index 34661ba..bdc14fc 100644 --- a/README.en.md +++ b/README.en.md @@ -104,19 +104,21 @@ failure falls back to SHM. The virgl VM verified fallback only: successful impor mapping and hardware acceleration remain unverified. This is not zero-copy VAAPI; `UU_VIDEO_ENCODER=vaapi` still uploads CPU frames through FFmpeg. -### Hyprland monitor switching (experimental) +### Monitor switching (experimental) Native Wayland absolute pointer motion now targets the captured output by default, fixing multi-monitor click offsets. uinput may still handle keyboard, buttons and relative motion. Set `UU_WAYLAND_MULTISCREEN=1` to select physical monitors from the phone client. `UU_WAYLAND_OUTPUT` chooses the initial output. Capture and absolute pointer targeting switch together across different resolutions, scales and desktop positions. +X11 sessions send all monitors as one combined picture by default. With `UU_X11_MULTISCREEN=1` the host lists the monitors from `xrandr --listmonitors`; the viewer selects one, only that region is captured, and absolute pointer positions are offset to that monitor. The primary monitor is the default; `UU_X11_OUTPUT` picks another (e.g. `HDMI-1`). The layout is read when the host starts; restart the service after changing it. In this mode the viewer's resolution setting scales the stream instead of changing the display mode. Experimental: unit tests and Xvfb virtual monitors only, not yet accepted with the official clients. + Switching reuses one video track and pauses input until the new source sends a frame. Capture/encoder failure or a first-frame timeout requests restoration of the previous source. The inventory uses `hyprctl` and supports unrotated outputs; restart the host after monitor hotplug or layout changes. Phone-side switching and clicks were tested on Omarchy / Hyprland with 2560×1440 at 125% and 1920×1080 at 133.33%. ## Not implemented / not supported | Feature | Status | |---|---| -| Switching between multiple monitors | Experimental on native Hyprland Wayland; see above | +| Switching between multiple monitors | Experimental on native Hyprland Wayland and on X11 (`UU_X11_MULTISCREEN=1`); see above | | True color (4:4:4) | Supported (v0.1.15): the default FFmpeg pipeline with NVENC / libx264 encodes H.264 High 4:4:4; `inproc-av` native NVENC encodes H.264 High 4:4:4 / HEVC FRExt; VAAPI stays 4:2:0 and reports that to the client | | HDR | Not implemented | | Super screen / virtual display | Not implemented | @@ -339,6 +341,8 @@ Common environment variables: | `UU_NVENC_RGB=0` / `=1` | NVENC encodes BGRA directly (saves CPU). Off by default when a monitor is connected to the NVIDIA card (slower when the desktop is composited on the same GPU); `0` forces off, `1` forces on | | `UU_WAYLAND_MULTISCREEN=1` | Enable Hyprland physical monitor switching (off by default) | | `UU_WAYLAND_OUTPUT` | Initial native Wayland output name, e.g. `HDMI-A-1` | +| `UU_X11_MULTISCREEN=1` | Select and capture X11 monitors separately (experimental, off by default, needs `xrandr`) | +| `UU_X11_OUTPUT` | Initial X11 monitor name (`xrandr --listmonitors`); default: the primary | | `UU_WAYLAND_NO_VIRTUAL_KEYBOARD=1` | Native Wayland skips the virtual keyboard (e.g. Denial reserves it for its input method); keys go through uinput. Without wlr-virtual-pointer the pointer also uses uinput (#72) | | `UU_INPUT_BACKEND` | `auto` (default: uinput on Wayland when accessible) / `uinput` / `native` | | `UU_TERMINAL_SHORTCUTS=0` | Disable the Ctrl+V paste rewrite in terminal windows | diff --git a/README.md b/README.md index db97cee..c42bcbe 100644 --- a/README.md +++ b/README.md @@ -90,19 +90,21 @@ GNOME Portal 的控制通道不再等待授权窗口或第一帧画面:先通 原生 Wayland 实验选项:`UU_WAYLAND_PIPELINE=0` 可关闭流水线做对照。`UU_WAYLAND_DMABUF=1` 显式尝试 dma-buf→GBM CPU 映射(默认关闭,需要运行时 libgbm;可用 `UU_WAYLAND_DMABUF_DEVICE` 指定 render node,默认 `/dev/dri/renderD128`)。仅支持显式线性修饰符、单平面 8-bit RGB,失败回退 SHM;virgl VM 中只验证了回退,成功导入/映射和硬件加速均未验证。这不是零拷贝 VAAPI 路径;选择 `UU_VIDEO_ENCODER=vaapi` 时仍由 FFmpeg 上传 CPU 帧。 首次连接时,桌面会弹窗请求「远程桌面 / 屏幕共享」授权,同意后会记住授权。 -### Hyprland 多显示器切换(实验性) +### 多显示器切换(实验性) 原生 Wayland 的绝对鼠标定位默认绑定正在采集的输出,修复双屏桌面下的点击偏移。uinput 可继续用于键盘、按键和相对移动。 设置 `UU_WAYLAND_MULTISCREEN=1` 后,手机端可选择并切换物理显示器;`UU_WAYLAND_OUTPUT` 指定默认屏幕。画面与绝对鼠标定位同步切换,支持不同分辨率、缩放和桌面位置。 +X11 会话默认把所有显示器拼成一张画面。设置 `UU_X11_MULTISCREEN=1` 后,主机按 `xrandr --listmonitors` 列出各个显示器,控制端可选择其中一块,只采集该区域,绝对鼠标坐标加上该屏幕在桌面中的偏移;默认屏幕为主显示器,`UU_X11_OUTPUT` 可指定其他屏幕(如 `HDMI-1`)。显示器布局在主机启动时读取,改动后需重启服务。此模式下控制端的「分辨率」改为缩放画面,不再修改显示模式。实验性:目前只有单元测试与 Xvfb 虚拟显示器实测,尚未经官方客户端实机验收。 + 切屏复用同一条视频轨道,过渡期间暂停输入,发送新屏首帧后恢复;采集或编码失败、等待首帧超时会请求恢复原屏。当前通过 `hyprctl` 枚举未旋转的显示器,显示器插拔或布局变化后需重启服务。已在 Omarchy / Hyprland 上用官方手机客户端实测 2560×1440(125%)与 1920×1080(133.33%)双屏切换及点击。 ## 尚未实现 / 不支持 | 功能 | 状态 | |---|---| -| 多显示器切换 | Hyprland 原生 Wayland 后端实验性支持,见上文 | +| 多显示器切换 | Hyprland 原生 Wayland 后端与 X11(`UU_X11_MULTISCREEN=1`)实验性支持,见上文 | | 真彩(4:4:4) | 支持(v0.1.15):NVENC / libx264 的默认 FFmpeg 管线输出 H.264 High 4:4:4;`inproc-av` 原生 NVENC 输出 H.264 High 4:4:4 / HEVC FRExt;VAAPI 管线仍为 4:2:0,并如实回报给客户端 | | HDR | 未实现 | | 超级屏 / 虚拟显示器 | 未实现 | @@ -307,6 +309,8 @@ systemctl --user restart uuremote-native.service | `UU_NVENC_RGB=0` / `=1` | NVENC 直接编码 BGRA(省 CPU)。显示器接在 NVIDIA 卡上时默认关闭(同卡合成时直吃反而更慢),`0` 强制关,`1` 强制开 | | `UU_WAYLAND_MULTISCREEN=1` | 启用 Hyprland 物理显示器切换(默认关闭) | | `UU_WAYLAND_OUTPUT` | 原生 Wayland 默认采集输出名称,例如 `HDMI-A-1` | +| `UU_X11_MULTISCREEN=1` | X11 下按显示器分别选择和采集(实验性,默认关闭,需要 `xrandr`) | +| `UU_X11_OUTPUT` | X11 默认采集的显示器名称(`xrandr --listmonitors`),默认主显示器 | | `UU_WAYLAND_NO_VIRTUAL_KEYBOARD=1` | 原生 Wayland 不创建虚拟键盘(如 Denial 只把它给自己的输入法),键盘改走 uinput。合成器没有 wlr-virtual-pointer 时鼠标也自动改走 uinput(#72) | | `UU_INPUT_BACKEND` | `auto`(默认,Wayland 下可用时用 uinput)/ `uinput` / `native` | | `UU_TERMINAL_SHORTCUTS=0` | 关闭终端窗口里的 Ctrl+V 粘贴转换 | diff --git a/src/capture.rs b/src/capture.rs index a5dc09b..610bce5 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -56,6 +56,12 @@ pub fn x11_size() -> Option<(u32, u32)> { } /// The root size from the last refresh, without an X round trip (pointer mapping). +/// Display the X11 engine opened, once a capture has initialized it. +pub fn x11_display() -> Option { + let name = unsafe { x11_display_name() }; + if name.is_null() { None } else { Some(unsafe { std::ffi::CStr::from_ptr(name) }.to_string_lossy().into_owned()) } +} + pub fn x11_last_size() -> Option<(u32, u32)> { let (mut w, mut h): (libc::c_int, libc::c_int) = (0, 0); if unsafe { x11_get_dimensions(&mut w, &mut h) } != 0 || w <= 0 || h <= 0 { return None; } diff --git a/src/input.rs b/src/input.rs index c2d126b..675940e 100644 --- a/src/input.rs +++ b/src/input.rs @@ -334,9 +334,12 @@ impl InputInjector { let result=crate::wayland::move_pointer(x,y,false); if result!=-2 { return result; } } + // X11 multi-screen: the point is inside the selected monitor; XTest and uinput span the root. + let placement=if relative || self.backend!=crate::desktop_backend::DesktopBackend::X11 {None} else {crate::wayland::x11_placement()}; + let (x,y)=placement.map_or((x,y),|(ox,oy)|(x+ox,y+oy)); if let Some(uinput)=&self.uinput { if relative { return Self::uinput_result(uinput.move_relative(x,y)); } - let (w,h)=*self.screen.lock().unwrap(); + let (w,h)=placement.and_then(|_|crate::capture::x11_last_size()).unwrap_or(*self.screen.lock().unwrap()); if w > 1 && h > 1 { let scale = |v: i32, extent: u32| (i64::from(v) * i64::from(crate::uinput::ABS_MAX) / i64::from(extent - 1)) as i32; return Self::uinput_result(uinput.move_absolute(scale(x,w),scale(y,h))); @@ -700,3 +703,28 @@ mod output_pointer_tests { } } } + +#[cfg(all(test,target_os="linux"))] +mod x11_monitor_pointer_tests { + use super::*; + #[test] + #[ignore="requires an X11 display with two or more RandR monitors and UU_X11_MULTISCREEN=1; moves pointer only"] + fn absolute_pointer_lands_on_the_selected_x11_monitor() { + let _capture=crate::capture::ScreenCapture::new().unwrap(); + let input=InputInjector::new(); + assert_eq!(input.backend,crate::desktop_backend::DesktopBackend::X11); + let outputs=crate::wayland::outputs();assert!(crate::wayland::x11_multiscreen() && outputs.len()>=2,"needs two X11 monitors"); + for (id,output) in outputs.iter().enumerate().chain(outputs.iter().enumerate().rev()) { + crate::wayland::select(id).unwrap(); + for (x,y) in [(0.0,0.0),(0.5,0.5),(0.999,0.999),(0.1,0.9)] { + let size=(output.width,output.height); + let pixels=crate::input_mapping::normalized_pointer(x,y,size.0,size.1).unwrap(); + input.mouse_absolute_normalized(x,y,size);input.flush_pointer(); + let (mut ax,mut ay,mut mask)=(0,0,0); + assert_eq!(unsafe {x11_pointer_state(&mut ax,&mut ay,&mut mask)},0); + assert_eq!((ax,ay),(output.x+pixels.0,output.y+pixels.1),"monitor={} normalized=({x},{y})",output.name); + } + } + crate::wayland::select(0).unwrap(); + } +} diff --git a/src/video.rs b/src/video.rs index e4cb501..eb4a820 100644 --- a/src/video.rs +++ b/src/video.rs @@ -814,10 +814,12 @@ async fn read_encoder_with( settings: VideoSettings, #[cfg_attr(not(feature = "inproc-av"), allow(unused_variables))] idr: Arc, budget: Arc, selection: crate::video_select::Selection, )->Result<(),String>{ - let selected_output=if display.starts_with("wayland:") {crate::wayland::source(settings.source_id)} else {None}; + let selected_output=crate::wayland::capture_source(&display,portal.is_some(),settings.source_id); let size=selected_output.map_or(size,|s|(s.width,s.height)); let kms=display.starts_with("kms:"); let wayland=kms || display.starts_with("wayland:"); + // X11 multi-screen: x11grab reads only the selected monitor's region of the root. + let x11_region=selected_output.filter(|_|!wayland); let (permits_tx,mut permits_rx)=mpsc::channel::(4); let kms_capture=if kms { let capture=tokio::task::spawn_blocking(crate::kms::Capture::open).await.map_err(|_|"KMS worker failed")??; @@ -825,7 +827,7 @@ async fn read_encoder_with( Some(Arc::new(capture)) } else { None }; #[cfg(feature = "inproc-av")] - if allow_inproc && native_inproc(&display, portal.is_some(), encoder) { + if allow_inproc && x11_region.is_none() && native_inproc(&display, portal.is_some(), encoder) { let max_kbps = settings.max_kbps().unwrap_or(30_000) as i64; let config = crate::inproc_encoder::Config { display: display.clone(), input: size, output: settings.output, fps: u32::from(settings.fps), @@ -859,8 +861,9 @@ async fn read_encoder_with( "-framerate",&fps,"-i","pipe:0","-an"]); } else { // The viewer draws its local pointer; do not bake a second into video. + let input=x11_region.map_or_else(||display.clone(),|o|format!("{display}+{},{}",o.x,o.y)); command.args(["-f","x11grab","-draw_mouse","0","-framerate",&fps, - "-video_size",&size,"-i",&display,"-an"]); + "-video_size",&size,"-i",&input,"-an"]); } // 其他分辨率: scale the captured desktop (the host display mode is left untouched). command.args(scale_options(&settings, encoder, Some(source_size))); diff --git a/src/wayland.rs b/src/wayland.rs index a7a77e4..968cd0d 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -204,11 +204,13 @@ pub fn text_probe()->Result<(),String> { Ok(()) } -// Opt-in single-track monitor switching for Hyprland. The existing native engine owns -// capture/pointer binding; no compositor layout or user credentials are modified. +// Opt-in single-track monitor switching: Hyprland outputs (UU_WAYLAND_MULTISCREEN) or X11 RandR +// monitors (UU_X11_MULTISCREEN). The existing engines own capture/pointer binding; X11 crops the +// root to the selected monitor. No compositor layout or user credentials are modified. #[derive(Clone, Debug)] -pub struct Output { pub name:String, pub width:u32, pub height:u32, pub fps:i32 } -static OUTPUTS: std::sync::OnceLock> = std::sync::OnceLock::new(); +pub struct Output { pub name:String, pub width:u32, pub height:u32, pub fps:i32, pub x:i32, pub y:i32 } +struct Inventory { outputs:Vec, x11:bool } +static OUTPUTS: std::sync::OnceLock = std::sync::OnceLock::new(); #[derive(Default)] struct Selection { id:usize, generation:u64, presented:Option, failed:bool } impl Selection { @@ -221,19 +223,77 @@ impl Selection { fn ready(&self)->bool {self.presented==Some(self.generation)} } static SELECTION:std::sync::Mutex=std::sync::Mutex::new(Selection {id:0,generation:0,presented:None,failed:false}); -pub fn outputs() -> &'static [Output] { +fn inventory() -> &'static Inventory { OUTPUTS.get_or_init(|| { + let backend=crate::desktop_backend::detect().ok(); + if backend==Some(crate::desktop_backend::DesktopBackend::X11) && !crate::desktop_backend::headless() + && std::env::var("UU_X11_MULTISCREEN").ok().as_deref()==Some("1") { + let outputs=x11_outputs(); + return Inventory {x11:!outputs.is_empty(),outputs}; + } if !cfg!(all(target_os="linux",feature="wayland")) || std::env::var("UU_WAYLAND_MULTISCREEN").ok().as_deref()!=Some("1") - || crate::desktop_backend::detect().ok()!=Some(crate::desktop_backend::DesktopBackend::Wayland) {return Vec::new();} + || backend!=Some(crate::desktop_backend::DesktopBackend::Wayland) {return Inventory {outputs:Vec::new(),x11:false};} let output=std::process::Command::new("hyprctl").args(["monitors","-j"]).output(); let result=output.map_err(|_|"hyprctl unavailable").and_then(|output| { if !output.status.success() {return Err("hyprctl failed");} parse_outputs(&output.stdout,&std::env::var("UU_WAYLAND_OUTPUT").unwrap_or_default()) }); - match result {Ok(outputs)=>outputs,Err(reason)=>{log::warn!("Wayland multi-screen disabled: {}",reason);Vec::new()}} + let outputs=match result {Ok(outputs)=>outputs,Err(reason)=>{log::warn!("Wayland multi-screen disabled: {}",reason);Vec::new()}}; + Inventory {outputs,x11:false} }) } +pub fn outputs() -> &'static [Output] {&inventory().outputs} +/// X11 monitors are advertised: the stream captures the selected monitor's region of the root. +pub fn x11_multiscreen()->bool {inventory().x11} +/// Read once, like the Hyprland inventory: a changed monitor layout needs a host restart. +fn x11_outputs()->Vec { + let mut command=std::process::Command::new("xrandr"); + if let Some(display)=crate::capture::x11_display().or_else(||std::env::var("DISPLAY").ok()).filter(|d|!d.is_empty()) { + command.arg("--display").arg(display); + } + let result=command.arg("--listmonitors").output().map_err(|_|"xrandr unavailable").and_then(|output| { + if !output.status.success() {return Err("xrandr failed");} + parse_x11_monitors(&String::from_utf8_lossy(&output.stdout),&std::env::var("UU_X11_OUTPUT").unwrap_or_default(),crate::video::display_fps() as i32) + }); + match result { + Ok(outputs) if outputs.len()>=2=>{log::info!("X11 multi-screen: {} monitors",outputs.len());outputs}, + Ok(_)=>{log::warn!("X11 multi-screen disabled: fewer than two monitors");Vec::new()}, + Err(reason)=>{log::warn!("X11 multi-screen disabled: {}",reason);Vec::new()}, + } +} +/// `xrandr --listmonitors`: ` 0: +*HDMI-0 1920/527x1080/296+1920+0 HDMI-0` (`*` = primary). +/// The preferred monitor (UU_X11_OUTPUT, else the primary) is id 0; the rest go left to right. +fn parse_x11_monitors(text:&str,preferred:&str,fps:i32)->Result,&'static str> { + fn geometry(text:&str)->Option<(u32,u32,i32,i32)> { + let (width,rest)=text.split_once('x')?; + let split=rest.find('+')?; + let (height,offsets)=rest.split_at(split); + let mut offsets=offsets[1..].split('+'); + let size=|v:&str|v.split('/').next()?.parse::().ok(); + let (x,y)=(offsets.next()?.parse::().ok()?,offsets.next()?.parse::().ok()?); + if offsets.next().is_some() || x<0 || y<0 {return None;} + Some((size(width)?,size(height)?,x,y)) + } + let mut seen=std::collections::HashSet::new(); + let mut primary=String::new(); + let mut outputs=Vec::new(); + for line in text.lines().skip(1) { + let mut fields=line.split_whitespace(); + let (Some(_),Some(tag),Some(rect))=(fields.next(),fields.next(),fields.next()) else {continue}; + let name=tag.trim_start_matches(['+','*']); + let Some((width,height,x,y))=geometry(rect) else {continue}; + if name.is_empty() || name.len()>127 || !seen.insert(name.to_owned()) + || width<2 || height<2 || width>16384 || height>16384 || u64::from(width)*u64::from(height)*4>256*1024*1024 {continue;} + if tag.contains('*') {primary=name.to_owned();} + outputs.push(Output {name:name.into(),width,height,fps,x,y}); + } + let first=if preferred.is_empty() {primary} else {preferred.to_owned()}; + outputs.sort_by_key(|m| (m.name!=first,m.x,m.y)); + outputs.truncate(16); + if outputs.is_empty() || (!preferred.is_empty() && outputs[0].name!=preferred) {return Err("No capturable preferred output");} + Ok(outputs) +} fn parse_outputs(json:&[u8],preferred:&str)->Result,&'static str> { let monitors=serde_json::from_slice::>(json).map_err(|_|"Invalid monitor inventory")?; let mut seen=std::collections::HashSet::new(); @@ -244,7 +304,7 @@ fn parse_outputs(json:&[u8],preferred:&str)->Result,&'static str> { let height=u32::try_from(m["height"].as_u64()?).ok()?; if name.is_empty() || name.len()>127 || name.contains('\0') || !seen.insert(name.to_owned()) || width<2 || height<2 || width>16384 || height>16384 || u64::from(width)*u64::from(height)*4>256*1024*1024 {return None;} - Some(Output {name:name.into(),width,height,fps:m["refreshRate"].as_f64()?.round().clamp(1.0,240.0) as i32}) + Some(Output {name:name.into(),width,height,fps:m["refreshRate"].as_f64()?.round().clamp(1.0,240.0) as i32,x:0,y:0}) }).collect(); outputs.sort_by_key(|m| (m.name!=preferred,m.name.clone())); outputs.truncate(16); @@ -255,6 +315,15 @@ pub fn selection()->(usize,u64) {let s=SELECTION.lock().unwrap();(s.id,s.generat pub fn selected()->usize {selection().0} pub fn source(id:usize)->Option<&'static Output> {outputs().get(id)} pub fn input_size(fallback:(u32,u32))->(u32,u32) {source(selected()).map_or(fallback,|s|(s.width,s.height))} +/// Region the encoder captures for this display: the Wayland output, or the selected X11 monitor. +pub fn capture_source(display:&str,portal:bool,id:usize)->Option<&'static Output> { + if display.starts_with("wayland:") || (!portal && !display.starts_with("kms:") && x11_multiscreen()) {source(id)} else {None} +} +/// Offset of the selected X11 monitor in the root: absolute input is relative to that monitor. +pub fn x11_placement()->Option<(i32,i32)> { + if !x11_multiscreen() {return None;} + source(selected()).map(|s|(s.x,s.y)) +} /// Pixel size of one advertised output. Unknown ids keep `fallback` (connection size). pub fn source_size(id:usize,fallback:(u32,u32))->(u32,u32) { #[cfg(test)] @@ -269,10 +338,11 @@ fn test_outputs()->&'static std::sync::Mutex> { SLOT.get_or_init(||std::sync::Mutex::new(None)) } /// Native size written into CaptureSettingSync. Wayland uses the output the encoder is reading -/// (`source_id`), which can differ from the size captured when the connection opened. X11, portal -/// and KMS keep the connection size (X11 mode changes go through `x11_resized`). +/// (`source_id`), which can differ from the size captured when the connection opened; so does an +/// X11 monitor with UU_X11_MULTISCREEN. Otherwise X11, portal and KMS keep the connection size +/// (X11 mode changes go through `x11_resized`). pub fn sync_native(display:&str,source_id:usize,connection:(u32,u32))->(u32,u32) { - if display.starts_with("wayland:") {source_size(source_id,connection)} else {connection} + if display.starts_with("wayland:") || (!display.starts_with("kms:") && x11_multiscreen()) {source_size(source_id,connection)} else {connection} } pub fn input_ready()->bool {outputs().is_empty() || SELECTION.lock().unwrap().ready()} pub fn switch_failed()->bool {SELECTION.lock().unwrap().failed} @@ -282,7 +352,12 @@ pub fn failed(id:usize,generation:u64) { if (state.id,state.generation)==(id,generation) {state.failed=true;} } pub fn select(id:usize)->Result<(),String> { - let output=source(id).ok_or("Unknown Wayland screen")?; + let output=source(id).ok_or("Unknown screen")?; + if x11_multiscreen() { + SELECTION.lock().unwrap().begin(id); + log::info!("X11 screen selected: id={} source={}x{}+{}+{}",id,output.width,output.height,output.x,output.y); + return Ok(()); + } #[cfg(all(target_os="linux",feature="wayland"))] { let name=std::ffi::CString::new(output.name.as_str()).map_err(|_|"Invalid output name")?; @@ -373,12 +448,29 @@ mod selection_regressions { assert!(parse_outputs(b"not JSON","").is_err()); } #[test] + fn x11_monitors_keep_their_root_offsets() { + // #98: GNOME/X11, two 1920x1080 monitors side by side, primary on the right. + let text="Monitors: 2\n 0: +*HDMI-0 1920/527x1080/296+1920+0 HDMI-0\n 1: +HDMI-1 1920/521x1080/293+0+0 HDMI-1\n"; + let outputs=parse_x11_monitors(text,"",60).unwrap(); + assert_eq!(outputs.iter().map(|m|(m.name.as_str(),m.width,m.height,m.x,m.y)).collect::>(), + vec![("HDMI-0",1920,1080,1920,0),("HDMI-1",1920,1080,0,0)]); + let outputs=parse_x11_monitors(text,"HDMI-1",60).unwrap(); + assert_eq!((outputs[0].name.as_str(),outputs[1].name.as_str()),("HDMI-1","HDMI-0")); + assert!(parse_x11_monitors(text,"DP-9",60).is_err()); + // xrandr --setmonitor virtual monitors (output "none"); no primary: left to right. + let virtual_monitors="Monitors: 3\n 0: +A 1280/338x1024/270+1280+0 none\n 1: +B 1280/338x1024/270+0+0 none\n 2: C 0/0x0/0+0+0 none\n"; + let outputs=parse_x11_monitors(virtual_monitors,"",30).unwrap(); + assert_eq!(outputs.iter().map(|m|(m.name.as_str(),m.x)).collect::>(),vec![("B",0),("A",1280)]); + assert!(parse_x11_monitors("Monitors: 1\n 0: +*X 1920/1x1080/1-10+0 X\n","",60).is_err()); + assert!(parse_x11_monitors("garbage","",60).is_err()); + } + #[test] fn sync_native_follows_the_wayland_source_id() { assert_eq!(sync_native(":0",1,(1920,1080)),(1920,1080)); assert_eq!(sync_native("kms:/dev/dri/card0",1,(1920,1080)),(1920,1080)); let inventory:&'static [Output]=Box::leak(vec![ - Output {name:"eDP-1".into(),width:1920,height:1080,fps:60}, - Output {name:"HDMI-A-1".into(),width:2560,height:1440,fps:75}, + Output {name:"eDP-1".into(),width:1920,height:1080,fps:60,x:0,y:0}, + Output {name:"HDMI-A-1".into(),width:2560,height:1440,fps:75,x:0,y:0}, ].into_boxed_slice()); *test_outputs().lock().unwrap()=Some(inventory); // Connection opened on the first output; the encoder is reading the second. diff --git a/src/webrtc.rs b/src/webrtc.rs index 1cc3c60..d1e7613 100644 --- a/src/webrtc.rs +++ b/src/webrtc.rs @@ -606,9 +606,11 @@ impl WebRtcSession { }) else { continue; }; let Ok(rpc_fields) = crate::protocol::decode_wire(rpc) else { continue; }; // X11: 分辨率 changes the display mode itself, like the official Windows host; the stream - // then carries the new native size instead of a scaled one (desktop_mode). + // then carries the new native size instead of a scaled one (desktop_mode). With + // UU_X11_MULTISCREEN the monitor layout stays as it is and the stream is scaled. let resized = if desktop && active_screen.init_error.is_none() && active_screen.portal.is_none() - && active_screen.backend == crate::desktop_backend::DesktopBackend::X11 && crate::desktop_mode::enabled() { + && active_screen.backend == crate::desktop_backend::DesktopBackend::X11 && crate::desktop_mode::enabled() + && !crate::wayland::x11_multiscreen() { match crate::capture_setting::requested_resolution(&rpc_fields) { Some(request) => { let display = active_screen.display.clone();