From 2eae45c584bf9bed705edc6818c9fba07a1e8ecd Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:49:41 -0700 Subject: [PATCH 1/6] Windows: prefer the virtual display and keep a game's cursor in the picture Work from 2026-10-03, not yet tried on Windows hardware (Hermes offline). - With capture on automatic, Latch prefers the Virtual Display Driver even while a physical monitor is connected; the engine restores the physical displays when the stream disconnects. - A windowless user task (LatchCursorUser) keeps a custom game cursor's whole shape inside the streamed display (crates/host/src/cursor.rs), so a cursor with a transparent top-left hotspot no longer disappears off the bottom or right edge; it follows resolution and shape changes, respects the game's own confinement and lets go when the game hides its cursor. - Mac bundle staged in dist.noindex, release workflow to match. Rebased onto PR #26 (fix/vps-stream-clipboard). Both changed how the stream profile is kept, and neither side's version could simply win: #26's one-time marker (now stream-profile-v4) would never run the virtual display choice on a host that already has it, and #27's reconcile every 30 s re-wrote all of the engine conf, so settings an owner picked in the engine's own page (nvenc_twopass, minimum_fps_target, the display option) were put back and the engine restarted, and on Linux #26's engine conf would have been enforced the same way. Now: - The full engine conf stays a versioned one-time migration, exactly as #26 has it, on every platform. - On Windows only, once that is in place, the service keeps just the capture display current every 30 s (virtual_display::capture/follow): output_name and dd_configuration_option, nothing else, and it logs which keys it changed whenever it restarts the engine. - The display ID Latch set is remembered (capture-display in the data directory), so a driver that comes back under a new ID after a reinstall still counts as automatic and capture follows it, instead of staying pointed at a display that no longer exists. When the owner picks another display, the display option Latch set goes back. - A PC that never had the driver is not asked anything. Co-Authored-By: Claude Opus 5.5 --- crates/client/src/stream.rs | 175 ++++++++++++-- crates/host/Cargo.toml | 3 + crates/host/src/cursor.rs | 361 +++++++++++++++++++++++++++++ crates/host/src/lib.rs | 2 + crates/host/src/main.rs | 12 + crates/host/src/service.rs | 18 ++ crates/host/src/streamer.rs | 62 ++++- crates/host/src/virtual_display.rs | 211 +++++++++++++++++ docs/MACOS.md | 3 +- docs/WINDOWS.md | 19 ++ 10 files changed, 838 insertions(+), 28 deletions(-) create mode 100644 crates/host/src/cursor.rs diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 8e44243..3ab63f5 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -203,7 +203,7 @@ enum Queued { #[derive(Debug, Clone, Copy)] struct At { keys_to_pc: bool, - over_video: bool, + over_stream: bool, } /// What this frame's input is sent through, and what it needs to know. @@ -330,7 +330,7 @@ impl View { }; ctx.send_viewport_cmd(ViewportCommand::CursorGrab(grab)); // The cursor is hidden through `set_cursor_icon(None)` alone, - // every frame the pointer is on the picture. A + // every frame the pointer is in the stream viewport. A // `CursorVisible(true)` here would show it behind egui's // back: egui only re-applies an icon when it changes, so the // Mac cursor would stay on top of the PC's until the pointer @@ -374,7 +374,7 @@ impl View { ), )); } - if bottom_gap >= STATUS || self.stats { + if self.stats { self.status_line(ui, screen, video, env, &stats, bottom_gap >= STATUS); } }); @@ -548,6 +548,10 @@ impl View { ui.add_space(space::SM); let rows = [ Kv::new("Resolution", format!("{} × {}", stats.width, stats.height)), + Kv::new( + "Requested resolution", + format!("{} × {}", live.requested.0, live.requested.1), + ), Kv::new( "Frame rate", format!("{:.1} of {} fps", stats.fps, live.requested.2), @@ -1215,8 +1219,10 @@ impl View { ctx.layer_id_at(p) .is_some_and(|layer| layer.order > egui::Order::Background) }); - let over_video = pointer_on_stream( - pointer.is_some_and(|p| video.contains(p)), + // The black bars are part of the stream too. Only Latch's controls + // should take the pointer away from the remote machine. + let over_stream = pointer_on_stream( + pointer.is_some_and(|p| ctx.screen_rect().contains(p)), over_bar, popup, overlay, @@ -1301,18 +1307,18 @@ impl View { self.held.release_all(out); } - if (over_video || self.captured) && connected { + if (over_stream || self.captured) && connected { ctx.set_cursor_icon(CursorIcon::None); } let at = At { keys_to_pc, - over_video, + over_stream, }; for ev in events { match ev { // The pointer is never held back behind a paste. - Event::PointerMoved(p) if !self.captured && over_video => self.moved_to = Some(p), + Event::PointerMoved(p) if !self.captured && over_stream => self.moved_to = Some(p), Event::MouseMoved(d) if self.captured => { // Deltas arrive in points; the PC moves in stream pixels. // Scale by the picture's size here so a hand movement @@ -1321,7 +1327,7 @@ impl View { self.motion.0 += d.x * motion_scale; self.motion.1 += d.y * motion_scale; } - Event::MouseWheel { unit, delta, .. } if self.captured || over_video => { + Event::MouseWheel { unit, delta, .. } if self.captured || over_stream => { let (h, v) = wheel_to_host(unit, delta); self.scroll.0 += h; self.scroll.1 += v; @@ -1429,7 +1435,7 @@ impl View { button, pressed, modifiers: m, - } if self.captured || at.over_video || (!pressed && self.held.any_down()) => { + } if self.captured || at.over_stream || (!pressed && self.held.any_down()) => { if at.keys_to_pc && !(m.ctrl && m.alt) { self.held.modifiers(out, m, cmd_is_ctrl); } @@ -1446,7 +1452,7 @@ impl View { // goes to the PC: what was clicked on is what was meant. // Not before the stream connects: the mouse would be held // with no way out but the host key, over the Cancel button. - if pressed && at.over_video && !self.captured && link.connected { + if pressed && at.over_stream && !self.captured && link.connected { self.bar_shown = false; if link.cfg.capture_mouse { self.grab(link.ctx, out); @@ -1505,16 +1511,11 @@ impl View { } } - /// Send a pointer position in the picture, held to its edges. + /// Send a pointer position in the picture: over the black bars, the + /// nearest pixel of the picture (see [`mouse_position_in_video`]). fn send_position(&self, link: &Link<'_, S>, p: Pos2) { - let (video, ppp) = (link.video, link.ppp); - let p = p.clamp(video.min, video.max); - link.out.position( - ((p.x - video.left()) * ppp).round() as i16, - ((p.y - video.top()) * ppp).round() as i16, - (video.width() * ppp).round() as i16, - (video.height() * ppp).round() as i16, - ); + let (x, y, width, height) = mouse_position_in_video(p, link.video, link.ppp); + link.out.position(x, y, width, height); } /// Send the whole pixels of captured movement, keeping the fraction. @@ -1578,17 +1579,32 @@ fn wheel_to_host(unit: egui::MouseWheelUnit, delta: Vec2) -> (f32, f32) { (-delta.x * scale, delta.y * scale) } -/// Whether a pointer in the picture should be sent to the PC. A real egui +/// Free mouse mode maps the bars to the nearest pixel in the picture. +fn mouse_position_in_video(p: Pos2, video: Rect, ppp: f32) -> (i16, i16, i16, i16) { + let width = ((video.width() * ppp).round() as i16).max(2); + let height = ((video.height() * ppp).round() as i16).max(2); + // Rect's far edges are inclusive, but pixels are zero-based. Moonlight + // sends width - 1 and height - 1 as the coordinate limits on the wire. + let x = ((p.x - video.left()) * ppp) + .round() + .clamp(0.0, f32::from(width - 1)) as i16; + let y = ((p.y - video.top()) * ppp) + .round() + .clamp(0.0, f32::from(height - 1)) as i16; + (x, y, width, height) +} + +/// Whether a pointer in the stream viewport should be sent to the PC. A real egui /// popup (More) sets `popup`; a hand-rolled `Area` at `Order::Background` /// would not, and clicks would leak through. fn pointer_on_stream( - in_video: bool, + in_viewport: bool, over_bar: bool, popup: bool, overlay: bool, over_overlay: bool, ) -> bool { - in_video && !over_bar && !popup && !overlay && !over_overlay + in_viewport && !over_bar && !popup && !overlay && !over_overlay } /// Whether `host_version` is too old for `/v1/update`, with the release @@ -1692,6 +1708,9 @@ pub(crate) mod tests { 16.0 / 9.0, ); assert_eq!(tall.width(), 800.0); + let matched = fit(screen, 3024.0 / 1964.0); + assert!((matched.height() - screen.height()).abs() < 0.1); + assert!((matched.width() - screen.width()).abs() < 0.1); } #[test] @@ -1744,6 +1763,92 @@ pub(crate) mod tests { ); } + #[test] + fn clicks_in_letterbox_and_pillarbox_bars_capture_the_mouse() { + for (size, points) in [ + ( + Vec2::new(1920.0, 1440.0), + [Pos2::new(960.0, 90.0), Pos2::new(960.0, 1350.0)], + ), + ( + Vec2::new(2400.0, 1080.0), + [Pos2::new(120.0, 600.0), Pos2::new(2280.0, 600.0)], + ), + ] { + let video = fit(Rect::from_min_size(Pos2::ZERO, size), 16.0 / 9.0); + for p in points { + assert!(!video.contains(p), "the regression must hit a black bar"); + // Connected: a click before the stream connects takes nothing. + let tap = std::rc::Rc::new(crate::input::Recorder::default()); + tap.connected.set(true); + let view = View { + tap: Some(tap.clone()), + ..view_with_bar() + }; + let extra = Extra { + os: "windows".into(), + ..Default::default() + }; + let mut h = harness_with(size, 1.0, false, fixture(view, extra)); + h.input_mut().events.extend([ + Event::PointerMoved(p), + Event::PointerButton { + pos: p, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ]); + h.run_steps(1); + assert!(h.state().view.captured, "black bar at {p} must capture"); + assert!(!h.state().view.bar_shown); + assert!(h.state().view.held.any_down(), "click must reach the PC"); + h.input_mut() + .events + .push(Event::MouseMoved(Vec2::new(3.25, -3.25))); + h.run_steps(1); + assert!(h.state().view.captured); + assert_eq!( + h.state().view.motion, + (0.25, -0.25), + "raw movement must continue in the bars" + ); + } + } + } + + #[test] + fn mouse_coordinates_stop_at_the_last_pixel_on_every_edge() { + for size in [Vec2::new(1512.0, 982.0), Vec2::new(2400.0, 1080.0)] { + let screen = Rect::from_min_size(Pos2::new(17.0, 33.0), size); + let video = fit(screen, 16.0 / 9.0); + for ppp in [1.0, 1.5, 2.0] { + let width = (video.width() * ppp).round() as i16; + let height = (video.height() * ppp).round() as i16; + for (p, expected) in [ + (video.min, (0, 0)), + (video.max, (width - 1, height - 1)), + (Pos2::new(video.right(), video.top()), (width - 1, 0)), + (Pos2::new(video.left(), video.bottom()), (0, height - 1)), + (screen.min - Vec2::splat(100.0), (0, 0)), + (screen.max + Vec2::splat(100.0), (width - 1, height - 1)), + ] { + let (x, y, w, h) = mouse_position_in_video(p, video, ppp); + assert_eq!((x, y), expected, "{size}, scale {ppp}, point {p}"); + assert_eq!((w, h), (width, height)); + } + let (x, y, _, _) = mouse_position_in_video(video.center(), video, ppp); + assert_eq!( + (x, y), + ( + (width as f32 / 2.0).round() as i16, + (height as f32 / 2.0).round() as i16 + ) + ); + } + } + } + #[test] fn the_power_menu_is_named_for_the_machine() { assert_eq!(machine_noun("windows"), "PC"); @@ -2086,6 +2191,28 @@ pub(crate) mod tests { crate::app::snapshots::assert_fits(&h, 1400.0); } + #[test] + fn stats_toggle_controls_the_bottom_line_with_and_without_letterboxing() { + for size in [Vec2::new(1920.0, 1200.0), Vec2::new(1920.0, 1080.0)] { + let mut h = harness(size); + let line_visible = |h: &egui_kittest::Harness<'_, Fixture>| { + h.output().shapes.iter().any(|s| { + matches!(&s.shape, egui::Shape::Text(t) if t.galley.job.text.contains("/60 fps")) + }) + }; + assert!(!line_visible(&h), "stats start hidden at {size}"); + h.get_by_label("Stats").simulate_click(); + h.run_steps(3); + assert!(line_visible(&h), "Stats shows the bottom line at {size}"); + h.get_by_label("Hide stats").simulate_click(); + h.run_steps(3); + assert!( + !line_visible(&h), + "Hide stats removes the bottom line at {size}" + ); + } + } + #[test] fn the_toolbar_fits_every_width_between_the_minimum_and_full_hd() { for w in [640.0, 800.0, 1039.0, 1040.0, 1100.0, 1299.0, 1300.0, 1920.0] { @@ -2998,7 +3125,7 @@ pub(crate) mod tests { key(Key::Enter, true, Modifiers::NONE), At { keys_to_pc: true, - over_video: false, + over_stream: false, }, )], }); diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 122673f..29b4145 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -45,11 +45,14 @@ windows = { version = "0.58", features = [ "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_System_DataExchange", + "Win32_System_Diagnostics_ToolHelp", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Power", "Win32_System_RemoteDesktop", + "Win32_UI_HiDpi", + "Win32_UI_WindowsAndMessaging", ] } [target.'cfg(windows)'.build-dependencies] diff --git a/crates/host/src/cursor.rs b/crates/host/src/cursor.rs new file mode 100644 index 0000000..27f936e --- /dev/null +++ b/crates/host/src/cursor.rs @@ -0,0 +1,361 @@ +//! Keep a visible Windows cursor inside the streamed display, including its +//! shape. A game's transparent, top-left hotspot can otherwise remain on +//! screen while the entire visible cursor is clipped off the bottom/right. + +fn cursor_clip( + desktop: [i32; 4], + original: [i32; 4], + shape: (i32, i32, i32, i32), +) -> Option<[i32; 4]> { + let (width, height, hot_x, hot_y) = shape; + if width <= 0 || height <= 0 || !(0..width).contains(&hot_x) || !(0..height).contains(&hot_y) { + return None; + } + // ClipCursor's far edges are exclusive. At the last allowed hotspot, + // the shape's last pixel must still fit inside the desktop. + let rect = [ + original[0].max(desktop[0] + hot_x), + original[1].max(desktop[1] + hot_y), + original[2].min(desktop[2] - width + hot_x + 1), + original[3].min(desktop[3] - height + hot_y + 1), + ]; + (rect[0] < rect[2] && rect[1] < rect[3]).then_some(rect) +} + +#[cfg(windows)] +mod win { + use super::cursor_clip; + use anyhow::{bail, Context, Result}; + use std::mem::size_of; + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::{Duration, Instant}; + use windows::Win32::Foundation::{CloseHandle, RECT}; + use windows::Win32::Graphics::Gdi::{DeleteObject, GetObjectW, BITMAP}; + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + use windows::Win32::UI::HiDpi::{ + SetThreadDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + ClipCursor, GetClipCursor, GetCursorInfo, GetIconInfo, GetSystemMetrics, LoadCursorW, + CURSORINFO, CURSOR_SHOWING, HICON, ICONINFO, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS, + IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE, + IDC_SIZEWE, IDC_UPARROW, IDC_WAIT, SM_CMONITORS, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, + SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + }; + + const TASK: &str = "LatchCursorUser"; + + /// The node can run in session 0. The cursor belongs to the logged-on + /// desktop, so run the same windowless executable there, without elevation. + pub fn start() -> Result<()> { + static REGISTERED: AtomicBool = AtomicBool::new(false); + let run = |program: &str, args: &[&str]| { + Command::new(program) + .args(args) + .creation_flags(0x0800_0000) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + }; + if !REGISTERED.load(Ordering::Relaxed) { + let exe = std::env::current_exe()?; + let tr = format!( + "\"{}\" --cursor-guard {}", + exe.display(), + std::process::id() + ); + let status = run( + "schtasks", + &[ + "/Create", "/TN", TASK, "/TR", &tr, "/SC", "ONLOGON", "/IT", "/F", "/RL", + "LIMITED", + ], + ) + .context("register cursor task")?; + if !status.success() { + bail!("could not register {TASK}"); + } + // Scheduled tasks otherwise stop after 72 hours or on battery. + // This helper must last as long as the node does. + let status = run("powershell.exe", &[ + "-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference='Stop'; $settings=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero); Set-ScheduledTask -TaskName LatchCursorUser -Settings $settings | Out-Null", + ]).context("configure cursor task")?; + if !status.success() { + bail!("could not configure {TASK}"); + } + REGISTERED.store(true, Ordering::Relaxed); + } + // A previous build's helper exits when its node exits. Retrying also + // starts this build after that handover or after a Windows logon. + if !run("powershell.exe", &[ + "-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference='Stop'; if ((Get-ScheduledTask -TaskName LatchCursorUser).State -ne 'Running') { Start-ScheduledTask -TaskName LatchCursorUser }", + ])?.success() { + bail!("could not run {TASK}"); + } + Ok(()) + } + + fn coordinates(r: RECT) -> [i32; 4] { + [r.left, r.top, r.right, r.bottom] + } + + fn rectangle(r: [i32; 4]) -> RECT { + RECT { + left: r[0], + top: r[1], + right: r[2], + bottom: r[3], + } + } + + #[derive(Default)] + struct Clip { + original: RECT, + applied: Option, + } + + impl Clip { + fn observe(&mut self, current: RECT) { + if self.applied != Some(current) { + // A game changed its own confinement. Respect the new bounds. + self.original = current; + self.applied = None; + } + } + + fn release(&mut self) { + if let Some(applied) = self.applied.take() { + let mut current = RECT::default(); + unsafe { + if GetClipCursor(&mut current).is_ok() && current == applied { + let _ = ClipCursor(Some(&self.original)); + } + } + } + } + } + + impl Drop for Clip { + fn drop(&mut self) { + self.release(); + } + } + + fn shape(cursor: HICON) -> Option<(i32, i32, i32, i32)> { + let mut icon = ICONINFO::default(); + unsafe { GetIconInfo(cursor, &mut icon).ok()? }; + let mut bitmap = BITMAP::default(); + let color = !icon.hbmColor.is_invalid(); + let read = unsafe { + GetObjectW( + if color { icon.hbmColor } else { icon.hbmMask }, + size_of::() as i32, + Some((&mut bitmap as *mut BITMAP).cast()), + ) + }; + unsafe { + if color { + let _ = DeleteObject(icon.hbmColor); + } + if !icon.hbmMask.is_invalid() { + let _ = DeleteObject(icon.hbmMask); + } + } + (read != 0).then_some(( + bitmap.bmWidth, + if color { + bitmap.bmHeight + } else { + bitmap.bmHeight / 2 + }, + icon.xHotspot as i32, + icon.yHotspot as i32, + )) + } + + fn streaming() -> bool { + latch_core::http::request( + ("127.0.0.1", latch_core::SUNSHINE_PORT), + "GET", + "/serverinfo", + None, + Duration::from_millis(200), + ) + .ok() + .filter(|r| r.status == 200) + .and_then(|r| crate::streamer::session_in_serverinfo(&r.body)) + .unwrap_or(false) + } + + fn parent_running(pid: u32) -> Result { + // A limited desktop task cannot open an elevated service process, + // even just to wait for its exit. A process snapshot needs no such + // access and lets the guard release its clip when that node exits. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?; + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut found = false; + unsafe { + let mut next = Process32FirstW(snapshot, &mut entry); + while next.is_ok() { + if entry.th32ProcessID == pid { + found = true; + break; + } + next = Process32NextW(snapshot, &mut entry); + } + let _ = CloseHandle(snapshot); + } + Ok(found) + } + + pub fn run(parent_pid: u32) -> Result<()> { + let dpi = + unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }; + if dpi.is_invalid() { + bail!("could not use physical cursor coordinates"); + } + tracing::info!("cursor guard for node {parent_pid}"); + let mut clip = Clip::default(); + let mut active = false; + let mut checked = Instant::now() - Duration::from_secs(1); + let mut cached = None; + let mut standard = Vec::new(); + loop { + std::thread::sleep(Duration::from_millis(16)); + if checked.elapsed() >= Duration::from_secs(1) { + if !parent_running(parent_pid)? { + break; + } + active = streaming(); + checked = Instant::now(); + // DPI changes can replace a cursor's bitmap without changing + // its handle. Refresh the shape along with the display state. + cached = None; + // Desktop pointers must still reach scrollbars and controls + // at the last pixel. Only custom game cursors need this guard. + standard = [ + IDC_ARROW, + IDC_IBEAM, + IDC_WAIT, + IDC_CROSS, + IDC_UPARROW, + IDC_SIZENWSE, + IDC_SIZENESW, + IDC_SIZEWE, + IDC_SIZENS, + IDC_SIZEALL, + IDC_NO, + IDC_HAND, + IDC_APPSTARTING, + IDC_HELP, + ] + .into_iter() + .filter_map(|id| unsafe { LoadCursorW(None, id).ok() }) + .collect(); + } + let mut current = RECT::default(); + let mut cursor = CURSORINFO { + cbSize: size_of::() as u32, + ..Default::default() + }; + unsafe { + if GetClipCursor(&mut current).is_err() { + continue; + } + clip.observe(current); + if !active + || GetSystemMetrics(SM_CMONITORS) != 1 + || GetCursorInfo(&mut cursor).is_err() + || cursor.flags != CURSOR_SHOWING + || standard.contains(&cursor.hCursor) + { + clip.release(); + cached = None; + continue; + } + if cached.as_ref().map(|(handle, _)| *handle) != Some(cursor.hCursor) { + cached = shape(HICON(cursor.hCursor.0)).map(|s| (cursor.hCursor, s)); + } + let Some((_, size)) = cached else { + clip.release(); + continue; + }; + let (x, y) = ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + ); + let desktop = [ + x, + y, + x + GetSystemMetrics(SM_CXVIRTUALSCREEN), + y + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ]; + let Some(bounds) = cursor_clip(desktop, coordinates(clip.original), size) else { + clip.release(); + continue; + }; + let bounds = rectangle(bounds); + if bounds != current && ClipCursor(Some(&bounds)).is_ok() { + clip.applied = Some(bounds); + } + } + } + // Restore before leaving the desktop, including node updates. + clip.release(); + unsafe { + SetThreadDpiAwarenessContext(dpi); + } + tracing::info!("cursor guard released after node {parent_pid} exited"); + Ok(()) + } +} + +#[cfg(windows)] +pub use win::{run, start}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_entire_shape_fits_at_all_four_edges() { + for desktop in [ + [0, 0, 1920, 1080], + [-2560, -1440, 0, 0], + [17, 33, 3041, 1997], + ] { + for shape in [(32, 32, 0, 0), (64, 48, 31, 20)] { + let r = cursor_clip(desktop, desktop, shape).unwrap(); + let (w, h, hx, hy) = shape; + assert_eq!((r[0] - hx, r[1] - hy), (desktop[0], desktop[1])); + assert_eq!( + (r[2] - 1 - hx + w, r[3] - 1 - hy + h), + (desktop[2], desktop[3]) + ); + } + } + } + + #[test] + fn game_bounds_are_preserved_and_invalid_shapes_are_ignored() { + let desktop = [0, 0, 1920, 1080]; + let game = [400, 300, 401, 301]; + assert_eq!(cursor_clip(desktop, game, (32, 32, 0, 0)), Some(game)); + assert_eq!(cursor_clip(desktop, desktop, (0, 32, 0, 0)), None); + assert_eq!(cursor_clip(desktop, desktop, (32, 32, 32, 0)), None); + assert_eq!( + cursor_clip([0, 0, 16, 16], [0, 0, 16, 16], (32, 32, 0, 0)), + None + ); + } +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 742e6fd..4f1becb 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -6,6 +6,8 @@ pub mod audio; pub mod brand; pub mod clipboard; pub mod config; +#[cfg(any(windows, test))] +pub mod cursor; pub mod display; pub mod legacy; pub mod logfile; diff --git a/crates/host/src/main.rs b/crates/host/src/main.rs index 808b15e..b65aa8c 100644 --- a/crates/host/src/main.rs +++ b/crates/host/src/main.rs @@ -26,6 +26,10 @@ struct Args { /// Run the control service with no window. #[arg(long)] background: bool, + /// Keep the streamed Windows cursor visible; runs in the user's desktop. + #[cfg(windows)] + #[arg(long, hide = true, conflicts_with_all = ["background", "setup", "setup_elevated", "brand_engine"])] + cursor_guard: Option, /// Started by an update: wait for this process to give up the port. #[arg(long, requires = "background")] replaces: Option, @@ -68,6 +72,14 @@ fn main() -> Result<()> { // Before anything can replace it: which file this process is. let _ = service::started_from(); let args = Args::parse(); + #[cfg(windows)] + if let Some(parent) = args.cursor_guard { + init_logging("cursor.log"); + return latch_host::cursor::run(parent).map_err(|e| { + tracing::error!("cursor guard: {e:#}"); + e + }); + } if let Some(dir) = &args.local_app_data { // Before any thread or data_dir() call; edition 2021, so set_var is safe. std::env::set_var("LOCALAPPDATA", dir); diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 5c730b1..7172cde 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -360,6 +360,20 @@ impl Service { Ok(false) => {} // Retried once nobody watches. Err(e) => self.log(format!("stream profile could not be applied: {e:#}")), } + } else if cfg!(windows) { + // Which display the engine captures is kept current all + // along: the virtual display can come or change any time. + let api = Api { + user: &cfg.sunshine_user, + pass: &cfg.sunshine_pass, + }; + if let Some(install) = &install { + match crate::virtual_display::follow(&api, install, &dir) { + Ok(Some(changed)) => self.log(changed), + Ok(None) => {} + Err(e) => self.log(format!("capture display could not be set: {e:#}")), + } + } } } } @@ -451,6 +465,10 @@ impl Service { if tick.is_multiple_of(6) { let install = self.install.lock().clone(); if let Some(install) = install { + #[cfg(windows)] + if let Err(e) = crate::cursor::start() { + tracing::debug!("cursor bounds: {e:#}"); + } let audio_problem = self.streamer.lock().audio_problem.clone(); if !streamer::running() { if let Err(e) = streamer::start(&install) { diff --git a/crates/host/src/streamer.rs b/crates/host/src/streamer.rs index 7beab06..849342c 100644 --- a/crates/host/src/streamer.rs +++ b/crates/host/src/streamer.rs @@ -452,21 +452,61 @@ impl Api<'_> { if s.app_running == Some(true) { self.close_app(); } - self.call("POST", "/api/config", Some(&desired.to_string()))?; + self.apply_config(&desired)?; + Ok(true) + } + + /// Keep the engine on the display [`crate::virtual_display::capture`] + /// picks, without interrupting a running app. Only the keys it names + /// are written, so engine settings the owner chose stay. `vdd` is the + /// Virtual Display Driver's ID from the engine's log, `picked` the ID + /// Latch set before. `None` while someone may be watching; otherwise what is now in + /// force. + pub fn follow_virtual_display( + &self, + install: Option<&Install>, + vdd: Option<&str>, + picked: Option<&str>, + ) -> Result> { + if session(install).maybe_watched() { + return Ok(None); + } + let current = self.call("GET", "/api/config", None)?; + let capture = crate::virtual_display::capture(¤t, vdd, picked); + if !capture.conf.is_empty() { + let desired = stream_profile(¤t, &capture.pairs())?; + // The restart would end a stream that started since the check + // above; a Desktop left for a viewer to resume is closed first, + // as in upgrade_stream_profile, so its undo commands run. + let s = session(install); + if s.maybe_watched() { + return Ok(None); + } + if s.app_running == Some(true) { + self.close_app(); + } + self.apply_config(&desired)?; + } + Ok(Some(capture)) + } + + /// Save `config` as the engine's settings and wait for it to come back + /// from the restart that puts them in force. + fn apply_config(&self, config: &serde_json::Value) -> Result<()> { + self.call("POST", "/api/config", Some(&config.to_string()))?; // Sunshine closes this HTTP connection as it restarts. let _ = self.raw("POST", "/api/restart"); std::thread::sleep(Duration::from_secs(1)); let deadline = std::time::Instant::now() + Duration::from_secs(20); loop { if self.ok() { - break; + return Ok(()); } if std::time::Instant::now() >= deadline { bail!("streaming engine did not return after applying the display profile"); } std::thread::sleep(Duration::from_millis(500)); } - Ok(true) } /// Accept the PIN the Mac is pairing with. Sunshine says no until the @@ -884,6 +924,22 @@ mod tests { assert!(stream_profile(&serde_json::Value::Null, crate::setup::ENGINE_CONF).is_err()); } + #[test] + fn the_capture_display_alone_leaves_every_other_engine_setting() { + // Engine settings the owner chose in its own web UI. + let owners = serde_json::json!({"status":true, "platform":"windows", "output_name":"", + "nvenc_twopass":"disabled", "minimum_fps_target":"30"}); + let capture = crate::virtual_display::capture(&owners, Some("vdd"), None); + let profile = stream_profile(&owners, &capture.pairs()).unwrap(); + assert_eq!(profile["output_name"], "vdd"); + assert_eq!(profile["dd_configuration_option"], "ensure_only_display"); + assert_eq!(profile["nvenc_twopass"], "disabled"); + assert_eq!(profile["minimum_fps_target"], "30"); + assert!(profile.get("platform").is_none()); + let again = crate::virtual_display::capture(&profile, Some("vdd"), Some("vdd")); + assert_eq!(stream_profile(&profile, &again.pairs()).unwrap(), profile); + } + #[test] fn a_linux_engine_drops_the_frame_rate_floor_and_reapplies_it_elsewhere() { // The node kit's VPS profile: a 60 fps floor under every stream. diff --git a/crates/host/src/virtual_display.rs b/crates/host/src/virtual_display.rs index 6a10ea6..89c93cc 100644 --- a/crates/host/src/virtual_display.rs +++ b/crates/host/src/virtual_display.rs @@ -20,6 +20,139 @@ const REG_KEY: &str = r"HKLM:\SOFTWARE\MikeTheTech\VirtualDisplayDriver"; /// Listed with every size: the default frame rate and ProMotion's. pub const REFRESH_RATES: [u32; 2] = [60, 120]; +/// Sunshine's stable ID for the Virtual Display Driver, including an +/// inactive display. Use the latest device list, so a removed display +/// from an older list is never selected. +// ponytail: Sunshine logs its display IDs; use an enumeration API if it gains one. +pub fn output_in(log: &str) -> Option { + let (_, list) = log.rsplit_once("Currently available ")?; + let json = list + .strip_prefix("display devices:") + .or_else(|| list.strip_prefix("devices:"))?; + let devices = serde_json::Deserializer::from_str(json.trim_start()) + .into_iter::>() + .next()? + .ok()?; + devices.iter().find_map(|d| { + let vdd = d["friendly_name"].as_str() == Some("VDD by MTT") + || (d["edid"]["manufacturer_id"].as_str() == Some("MTT") + && d["edid"]["product_code"].as_str() == Some("1337")); + d["device_id"] + .as_str() + .filter(|id| vdd && !id.is_empty()) + .map(String::from) + }) +} + +/// The engine key that picks the display a stream captures. +const OUTPUT: &str = "output_name"; +/// The engine key for what happens to the other displays during a stream. +const DD_OPTION: &str = "dd_configuration_option"; +/// The streamed display is the only one on while a stream runs. +const ONLY_DISPLAY: &str = "ensure_only_display"; +/// Where the service keeps the display ID it put in [`OUTPUT`], so that ID +/// still counts as automatic when the driver comes back with another one. +const PICKED: &str = "capture-display"; + +/// Which display the engine captures, as far as Latch manages it on Windows. +#[derive(Debug, PartialEq)] +pub struct Capture { + /// Engine settings to change, with their new values; empty when none need to. + pub conf: Vec<(&'static str, String)>, + /// The display ID Latch has set once `conf` is in force. + pub picked: Option, +} + +impl Capture { + /// `conf` as the engine-key pairs [`crate::streamer`] writes. + pub fn pairs(&self) -> Vec<(&str, &str)> { + self.conf.iter().map(|(k, v)| (*k, v.as_str())).collect() + } +} + +/// With capture on automatic, Latch prefers the Virtual Display Driver even +/// while a physical monitor is connected. Automatic is no display chosen +/// in the engine (`output_name` empty), the driver's own ID `vdd`, or the ID +/// Latch set before (`picked`): a reinstalled driver can come back with a +/// new ID, and capture follows it. Any other display is the owner's choice +/// and is kept; the engine settings Latch changed for its own pick go back. +pub fn capture(current: &serde_json::Value, vdd: Option<&str>, picked: Option<&str>) -> Capture { + let selected = current[OUTPUT].as_str().unwrap_or_default(); + let automatic = selected.is_empty() || Some(selected) == vdd || Some(selected) == picked; + let mut conf = Vec::new(); + let picked = match (automatic, vdd) { + (true, Some(id)) => { + conf.push((OUTPUT, id.to_string())); + // Games already on the physical monitor must move onto the + // streamed display too. The engine restores the physical + // displays when the stream disconnects. + conf.push((DD_OPTION, ONLY_DISPLAY.to_string())); + Some(id.to_string()) + } + // No device list in the log tail (a long stream pushes it out) or + // no driver right now: what is set stays. + (true, None) => picked.map(String::from), + (false, _) => { + if picked.is_some() && current[DD_OPTION] == ONLY_DISPLAY { + let default = crate::setup::ENGINE_CONF + .iter() + .find(|(k, _)| *k == DD_OPTION) + .map_or("ensure_active", |(_, v)| *v); + conf.push((DD_OPTION, default.to_string())); + } + None + } + }; + conf.retain(|(k, v)| current[*k].as_str() != Some(v.as_str())); + Capture { conf, picked } +} + +/// Keep the engine capturing the virtual display while capture is on +/// automatic (see [`capture`]); the service calls this every 30 seconds on +/// Windows, since the driver can be installed, or get a new ID, at any +/// time. Nothing is asked of the engine on a PC that has never had the +/// driver. Returns a line for the service log when settings changed. +pub fn follow( + api: &crate::streamer::Api, + install: &crate::streamer::Install, + data_dir: &std::path::Path, +) -> anyhow::Result> { + let vdd = crate::streamer::log_text(install).and_then(|log| output_in(&log)); + let file = data_dir.join(PICKED); + let picked = std::fs::read_to_string(&file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if vdd.is_none() && picked.is_none() { + return Ok(None); + } + let Some(capture) = + api.follow_virtual_display(Some(install), vdd.as_deref(), picked.as_deref())? + else { + return Ok(None); // Someone may be watching: the display stays until they are done. + }; + if capture.picked != picked { + let saved = match &capture.picked { + Some(id) => std::fs::write(&file, id), + None => std::fs::remove_file(&file), + }; + if let Err(e) = saved { + tracing::warn!("could not save the capture display: {e}"); + } + } + Ok((!capture.conf.is_empty()).then(|| { + let keys: Vec = capture + .conf + .iter() + .map(|(k, v)| format!("{k} = {v}")) + .collect(); + format!( + "capture display: {}; the streaming engine restarted", + keys.join(", ") + ) + })) +} + /// Every size as a PowerShell array of `@(width,height)` pairs. pub fn modes_ps() -> String { stream_modes() @@ -144,6 +277,84 @@ fn parse_probe(out: &str) -> Option { mod tests { use super::*; + #[test] + fn capture_uses_the_virtual_display_from_the_latest_device_list() { + let list = r#"[ + {"device_id":"physical", "friendly_name":"27E1QA", "info":{"primary":true}}, + {"device_id":"virtual", "friendly_name":"VDD by MTT", "info":null} + ]"#; + let log = format!("Info: Currently available display devices:\n{list}\nInfo: Ready"); + assert_eq!(output_in(&log).as_deref(), Some("virtual")); + let log = format!("{log}\nInfo: Currently available devices:\n[]\nInfo: Ready"); + assert_eq!(output_in(&log), None, "the old virtual display is gone"); + assert_eq!(output_in("Currently available devices:\n["), None); + assert_eq!(output_in("no devices reported"), None); + } + + #[test] + fn automatic_capture_selects_the_virtual_display_and_keeps_the_owners_choice() { + let fresh = + serde_json::json!({"encoder":"amdvce", "dd_configuration_option":"ensure_active"}); + let first = capture(&fresh, Some("vdd-a"), None); + assert_eq!( + first.conf, + [ + ("output_name", "vdd-a".to_string()), + ("dd_configuration_option", "ensure_only_display".to_string()) + ] + ); + assert_eq!(first.picked.as_deref(), Some("vdd-a")); + + // In force: nothing to change, with or without the device list. + let set = serde_json::json!({"output_name":"vdd-a", "dd_configuration_option":"ensure_only_display"}); + let same = capture(&set, Some("vdd-a"), Some("vdd-a")); + assert!(same.conf.is_empty(), "{same:?}"); + assert_eq!(same.picked.as_deref(), Some("vdd-a")); + let unlisted = capture(&set, None, Some("vdd-a")); + assert!(unlisted.conf.is_empty(), "{unlisted:?}"); + assert_eq!(unlisted.picked.as_deref(), Some("vdd-a")); + + // Set by an earlier build that kept no record: still Latch's. + let adopted = capture(&set, Some("vdd-a"), None); + assert!(adopted.conf.is_empty()); + assert_eq!(adopted.picked.as_deref(), Some("vdd-a")); + + // A reinstalled driver with a new ID: capture follows it. + let moved = capture(&set, Some("vdd-b"), Some("vdd-a")); + assert_eq!(moved.conf, [("output_name", "vdd-b".to_string())]); + assert_eq!(moved.picked.as_deref(), Some("vdd-b")); + + // No driver and no pick: nothing is touched. + let none = capture(&fresh, None, None); + assert_eq!( + none, + Capture { + conf: vec![], + picked: None + } + ); + } + + #[test] + fn an_explicit_display_is_kept_and_latchs_own_change_goes_back() { + let chosen = serde_json::json!({"output_name":"monitor", "dd_configuration_option":"ensure_only_display"}); + // The owner replaced Latch's pick: only the option Latch set goes back. + let replaced = capture(&chosen, Some("vdd-a"), Some("vdd-a")); + assert_eq!( + replaced.conf, + [("dd_configuration_option", "ensure_active".to_string())] + ); + assert_eq!(replaced.picked, None); + // Latch never picked a display here: the owner's settings stay. + let theirs = capture(&chosen, Some("vdd-a"), None); + assert!(theirs.conf.is_empty(), "{theirs:?}"); + assert_eq!(theirs.picked, None); + let primary = serde_json::json!({"output_name":"monitor", "dd_configuration_option":"ensure_primary"}); + assert!(capture(&primary, Some("vdd-a"), Some("vdd-a")) + .conf + .is_empty()); + } + #[test] fn the_step_lists_every_mode_keeps_the_file_and_restarts_only_on_change() { let s = setup_ps(); diff --git a/docs/MACOS.md b/docs/MACOS.md index f4c4edd..dae8af3 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -49,7 +49,8 @@ quarantine flag is how you tell macOS you fetched it deliberately. builds for `aarch64-apple-darwin` (Xcode command line tools and CMake are needed for the C parts), bundles `dist.noindex/Latch.app`, and installs it to -`/Applications`. A locally built app never carries the quarantine flag. +`/Applications`. The `.noindex` folder keeps build copies out of app search. +A locally built app never carries the quarantine flag. ### Signing diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 17b119f..3b98681 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -171,6 +171,25 @@ driver was installed after setup, choose **Run setup again** on the Sharing tab; its setup list says when sizes are missing. Installing a display does not require pairing the Mac again. +With capture set to automatic, Latch prefers the Virtual Display Driver +even while a physical monitor is connected. During a stream the virtual +display is the only active display, so games follow its requested size; +the engine restores the physical displays when the stream disconnects. +A driver reinstalled under a new ID is followed. A capture display chosen +in the engine's own settings is kept, and the display option Latch set for +the virtual display goes back to setup's; after setup, Latch changes no +other engine setting. Standard 1080p, 1440p and 4K streams are 16:9 and +leave bars on a taller Mac screen; **Match screen** uses the Mac's aspect +ratio. + +While a session uses a single display, Latch keeps a custom game cursor's +whole shape inside the picture. This prevents a cursor with a transparent +tip from disappearing at the bottom or right edge. The bounds follow changes +in resolution and cursor shape, respect the game's own confinement, and release +when the game hides its cursor for camera control. A windowless user task, +`LatchCursorUser`, runs this check in the desktop session without elevation. +Standard Windows pointers retain their normal access to controls at the edge. + ## Using an engine you installed yourself Latch streams through whichever compatible engine is already on the PC From c71a4fdf49fcd3e1f7858bd42e7d7b276a5e899d Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:03:56 -0700 Subject: [PATCH 2/6] Cursor guard: hold back only cursors that would vanish, only while a viewer watches The guard confined every custom cursor by its whole bitmap. A 32x32 or 64x64 game cursor with its tip at the hotspot (most RTS and MOBA cursors) stopped 31-63 px short of the right and bottom edges: edge panning never triggered and buttons in that band could not be clicked, for no gain, since that tip was always visible at the edge. It now reads the cursor's pixels (alpha, or the AND/XOR masks) and only keeps the visible pixel nearest the hotspot on screen; a cursor drawn at its hotspot is never clipped. The guard was on whenever the engine reported a running app, and the Desktop app keeps running after the viewer disconnects, so a local player's cursor stayed confined until the engine restarted. It now also needs the engine log's last word to be CLIENT CONNECTED (a disconnect or a ping timeout ends it). The node launched PowerShell every 30 s to check the guard's task, on the service's tick thread. A node in the desktop session (the Run-key start) now runs the guard as its own child process; only a node in session 0 uses the task, registered once, without a logon trigger that could start it for a dead node's process ID, and run with schtasks. The guard checks that its parent PID is still a latch-host process, and sleeps a second at a time while no viewer is connected. The clip bookkeeping is a small state machine with the Windows calls behind a trait, so its rules are tested: a game's own confinement is kept and put back, a clip someone else set is never lifted, a desktop that was free is freed again (not set to the old desktop's size), and a clip left by a guard that was killed is recognised and lifted. Co-Authored-By: Claude Opus 5.5 --- crates/host/src/cursor.rs | 879 ++++++++++++++++++++++++++++++-------- docs/WINDOWS.md | 19 +- 2 files changed, 706 insertions(+), 192 deletions(-) diff --git a/crates/host/src/cursor.rs b/crates/host/src/cursor.rs index 27f936e..9e6e978 100644 --- a/crates/host/src/cursor.rs +++ b/crates/host/src/cursor.rs @@ -1,38 +1,228 @@ -//! Keep a visible Windows cursor inside the streamed display, including its -//! shape. A game's transparent, top-left hotspot can otherwise remain on -//! screen while the entire visible cursor is clipped off the bottom/right. - -fn cursor_clip( - desktop: [i32; 4], - original: [i32; 4], - shape: (i32, i32, i32, i32), -) -> Option<[i32; 4]> { - let (width, height, hot_x, hot_y) = shape; - if width <= 0 || height <= 0 || !(0..width).contains(&hot_x) || !(0..height).contains(&hot_y) { +//! Keep a visible Windows cursor in the streamed picture. A game's cursor +//! can have a transparent hotspot with every visible pixel to its right or +//! below it: at the bottom or right edge the hotspot is still on screen, +//! but the whole visible cursor is off it. The guard stops such a cursor +//! where its nearest visible pixel is still in the picture. A cursor whose +//! hotspot is on its visible part, like every standard pointer and most +//! game cursors, is never held back from an edge, so edge scrolling and +//! controls in the last pixels keep working. + +/// A cursor's hotspot and the box around the pixels it draws (left, top, +/// right, bottom, all inclusive), in the cursor's own pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Shape { + hot: (i32, i32), + visible: [i32; 4], +} + +/// The confinement that keeps `shape`'s nearest visible pixel on `desktop`, +/// within the one already in force (`original`). `None` when the cursor +/// needs none: its hotspot is in line with visible pixels both ways, so +/// some of it shows wherever the hotspot can go. +fn cursor_clip(desktop: [i32; 4], original: [i32; 4], shape: Shape) -> Option<[i32; 4]> { + let (hot_x, hot_y) = shape.hot; + let [x0, y0, x1, y1] = shape.visible; + // How far the hotspot is from the visible pixels nearest to it, per side. + let left = (hot_x - x1).max(0); + let top = (hot_y - y1).max(0); + let right = (x0 - hot_x).max(0); + let bottom = (y0 - hot_y).max(0); + if left == 0 && top == 0 && right == 0 && bottom == 0 { return None; } // ClipCursor's far edges are exclusive. At the last allowed hotspot, - // the shape's last pixel must still fit inside the desktop. + // the nearest visible pixel is on the desktop's last pixel. let rect = [ - original[0].max(desktop[0] + hot_x), - original[1].max(desktop[1] + hot_y), - original[2].min(desktop[2] - width + hot_x + 1), - original[3].min(desktop[3] - height + hot_y + 1), + original[0].max(desktop[0] + left), + original[1].max(desktop[1] + top), + original[2].min(desktop[2] - right), + original[3].min(desktop[3] - bottom), ]; (rect[0] < rect[2] && rect[1] < rect[3]).then_some(rect) } +/// One of a cursor's bitmaps as `GetBitmapBits` copies it: `height` rows +/// of `stride` bytes, top row first. +struct Bitmap<'a> { + width: i32, + height: i32, + stride: usize, + bits_per_pixel: u16, + data: &'a [u8], +} + +impl Bitmap<'_> { + /// Whether the bitmap holds `rows` rows of `width` pixels. + fn holds(&self, width: i32, rows: i32) -> bool { + let (Ok(width), Ok(rows)) = (usize::try_from(width), usize::try_from(rows)) else { + return false; + }; + self.stride * 8 >= width * usize::from(self.bits_per_pixel) + && self.data.len() >= self.stride * rows + } + + /// A monochrome pixel: set or clear. + fn bit(&self, x: i32, y: i32) -> bool { + let at = y as usize * self.stride + x as usize / 8; + self.data + .get(at) + .is_some_and(|b| b & (0x80 >> (x % 8)) != 0) + } + + /// A 32-bit pixel: blue, green, red, alpha. + fn pixel(&self, x: i32, y: i32) -> [u8; 4] { + let at = y as usize * self.stride + x as usize * 4; + self.data + .get(at..at + 4) + .map_or([0; 4], |p| [p[0], p[1], p[2], p[3]]) + } +} + +/// The box around the pixels a cursor draws: left, top, right, bottom, all +/// inclusive. A colour cursor with an alpha channel draws where alpha is +/// not 0; one without draws where its AND mask is clear or its colour +/// inverts the screen. A monochrome cursor's mask is its AND mask above its +/// XOR mask; it draws where AND is clear or XOR is set. `None` when no +/// pixel shows, or for a format this does not read. +fn visible_box(color: Option<&Bitmap>, mask: &Bitmap) -> Option<[i32; 4]> { + let (width, height) = match color { + Some(c) if c.bits_per_pixel == 32 => (c.width, c.height), + Some(_) => return None, + None => (mask.width, mask.height / 2), + }; + let readable = mask.bits_per_pixel == 1 + && mask.holds(width, if color.is_some() { height } else { 2 * height }) + && color.is_none_or(|c| c.holds(width, height)); + if !readable || width <= 0 || height <= 0 { + return None; + } + let alpha = + color.is_some_and(|c| (0..height).any(|y| (0..width).any(|x| c.pixel(x, y)[3] != 0))); + let shows = |x: i32, y: i32| match color { + Some(c) if alpha => c.pixel(x, y)[3] != 0, + Some(c) => !mask.bit(x, y) || c.pixel(x, y)[..3] != [0, 0, 0], + None => !mask.bit(x, y) || mask.bit(x, y + height), + }; + let mut found: Option<[i32; 4]> = None; + for y in 0..height { + for x in (0..width).filter(|&x| shows(x, y)) { + let b = found.get_or_insert([x, y, x, y]); + *b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + } + found +} + +/// Whether the engine's log says a viewer is connected: its last word on +/// one is CLIENT CONNECTED, not CLIENT DISCONNECTED or a ping timeout +/// (which ends a session without the former). The engine keeps the +/// Desktop app running after a viewer leaves, so a running app alone does +/// not say a stream is on. `None` when the log says neither. +fn viewer_connected(log: &str) -> Option { + log.lines().rev().find_map(|line| { + if line.contains("CLIENT CONNECTED") { + Some(true) + } else if line.contains("CLIENT DISCONNECTED") || line.contains("Ping Timeout") { + Some(false) + } else { + None + } + }) +} + +/// What the guard reads and sets on Windows; a fake in the tests. +trait Desktop { + /// The virtual screen: left, top, right, bottom, far edges exclusive. + fn bounds(&self) -> [i32; 4]; + /// The confinement in force: the virtual screen when there is none. + fn clip(&self) -> Option<[i32; 4]>; + /// Confine the cursor to a rectangle, or free it with `None`. + fn set_clip(&mut self, rect: Option<[i32; 4]>) -> bool; + /// The custom cursor showing on a single display, if any. + fn custom_cursor(&mut self) -> Option; +} + +/// The guard's record of the cursor's confinement, so it only ever lifts +/// its own. +#[derive(Debug, Default)] +struct Clip { + /// The confinement someone else set (a game, usually); `None` for none. + original: Option<[i32; 4]>, + /// The rectangle the guard set, while it is in force. + applied: Option<[i32; 4]>, +} + +impl Clip { + /// One look at the cursor; `active` while a viewer is connected. + fn step(&mut self, desktop: &mut impl Desktop, active: bool) { + let Some(current) = desktop.clip() else { + return; + }; + let screen = desktop.bounds(); + let shape = if active { + desktop.custom_cursor() + } else { + None + }; + self.observe( + current, + screen, + shape.and_then(|s| cursor_clip(screen, screen, s)), + ); + match shape.and_then(|s| cursor_clip(screen, self.original.unwrap_or(screen), s)) { + Some(bounds) if bounds == current => {} + Some(bounds) => { + if desktop.set_clip(Some(bounds)) { + self.applied = Some(bounds); + } + } + None => self.release(desktop), + } + } + + /// Take note of the confinement in force. `ours` is what the guard + /// would set for this cursor on a free desktop. + fn observe(&mut self, current: [i32; 4], screen: [i32; 4], ours: Option<[i32; 4]>) { + if self.applied == Some(current) { + return; + } + if current != screen && ours == Some(current) { + // A guard that was ended abruptly left its clip behind. It is + // this one's to lift, not a game's to respect. + self.original = None; + self.applied = Some(current); + } else { + // A game changed its own confinement, or Windows reset it after + // a display change. Respect the new bounds. + self.original = (current != screen).then_some(current); + self.applied = None; + } + } + + /// Put back what the guard's clip replaced, unless someone has replaced + /// it since. No confinement before means none now, whatever size the + /// desktop has become. + fn release(&mut self, desktop: &mut impl Desktop) { + if let Some(applied) = self.applied.take() { + if desktop.clip() == Some(applied) { + desktop.set_clip(self.original); + } + } + } +} + #[cfg(windows)] mod win { - use super::cursor_clip; + use super::{visible_box, Bitmap, Clip, Desktop, Shape}; use anyhow::{bail, Context, Result}; use std::mem::size_of; use std::os::windows::process::CommandExt; - use std::process::{Command, Stdio}; + use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; use std::time::{Duration, Instant}; use windows::Win32::Foundation::{CloseHandle, RECT}; - use windows::Win32::Graphics::Gdi::{DeleteObject, GetObjectW, BITMAP}; + use windows::Win32::Graphics::Gdi::{DeleteObject, GetBitmapBits, GetObjectW, BITMAP, HBITMAP}; use windows::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, @@ -42,27 +232,69 @@ mod win { }; use windows::Win32::UI::WindowsAndMessaging::{ ClipCursor, GetClipCursor, GetCursorInfo, GetIconInfo, GetSystemMetrics, LoadCursorW, - CURSORINFO, CURSOR_SHOWING, HICON, ICONINFO, IDC_APPSTARTING, IDC_ARROW, IDC_CROSS, - IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, IDC_SIZENWSE, - IDC_SIZEWE, IDC_UPARROW, IDC_WAIT, SM_CMONITORS, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, - SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + CURSORINFO, CURSOR_SHOWING, HCURSOR, HICON, ICONINFO, IDC_APPSTARTING, IDC_ARROW, + IDC_CROSS, IDC_HAND, IDC_HELP, IDC_IBEAM, IDC_NO, IDC_SIZEALL, IDC_SIZENESW, IDC_SIZENS, + IDC_SIZENWSE, IDC_SIZEWE, IDC_UPARROW, IDC_WAIT, SM_CMONITORS, SM_CXVIRTUALSCREEN, + SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, }; const TASK: &str = "LatchCursorUser"; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + fn session_id() -> u32 { + let mut id = 0u32; + let _ = unsafe { + windows::Win32::System::RemoteDesktop::ProcessIdToSessionId(std::process::id(), &mut id) + }; + id + } - /// The node can run in session 0. The cursor belongs to the logged-on - /// desktop, so run the same windowless executable there, without elevation. + /// Keep the guard running beside this node; the service calls this + /// every 30 seconds. The cursor belongs to the logged-on desktop. A node + /// started there (from the Run key, as usual) runs the guard as its own + /// windowless child. One in session 0 (started over SSH) hands it to a + /// task that runs in the desktop session, without elevation. pub fn start() -> Result<()> { + static CHILD: Mutex> = Mutex::new(None); + if session_id() == 0 { + return start_task(); + } + let mut child = CHILD.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(running) = child.as_mut() { + if running.try_wait()?.is_none() { + return Ok(()); + } + } + let spawned = Command::new(std::env::current_exe()?) + .arg("--cursor-guard") + .arg(std::process::id().to_string()) + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("start cursor guard")?; + *child = Some(spawned); + Ok(()) + } + + /// The guard from session 0: a task registered once per node, with + /// this node's process ID. It has no trigger of its own, so it never + /// starts for a node that is gone; the node runs it, and running a task + /// that is already running leaves it alone. + fn start_task() -> Result<()> { static REGISTERED: AtomicBool = AtomicBool::new(false); let run = |program: &str, args: &[&str]| { Command::new(program) .args(args) - .creation_flags(0x0800_0000) + .creation_flags(CREATE_NO_WINDOW) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() }; - if !REGISTERED.load(Ordering::Relaxed) { + // One attempt per node: a failure here would fail the same way + // every 30 seconds. + if !REGISTERED.swap(true, Ordering::Relaxed) { let exe = std::env::current_exe()?; let tr = format!( "\"{}\" --cursor-guard {}", @@ -72,8 +304,8 @@ mod win { let status = run( "schtasks", &[ - "/Create", "/TN", TASK, "/TR", &tr, "/SC", "ONLOGON", "/IT", "/F", "/RL", - "LIMITED", + "/Create", "/TN", TASK, "/TR", &tr, "/SC", "ONCE", "/ST", "00:00", "/IT", "/F", + "/RL", "LIMITED", ], ) .context("register cursor task")?; @@ -89,14 +321,10 @@ mod win { if !status.success() { bail!("could not configure {TASK}"); } - REGISTERED.store(true, Ordering::Relaxed); } // A previous build's helper exits when its node exits. Retrying also // starts this build after that handover or after a Windows logon. - if !run("powershell.exe", &[ - "-NoProfile", "-NonInteractive", "-Command", - "$ErrorActionPreference='Stop'; if ((Get-ScheduledTask -TaskName LatchCursorUser).State -ne 'Running') { Start-ScheduledTask -TaskName LatchCursorUser }", - ])?.success() { + if !run("schtasks", &["/Run", "/TN", TASK])?.success() { bail!("could not run {TASK}"); } Ok(()) @@ -115,73 +343,153 @@ mod win { } } - #[derive(Default)] - struct Clip { - original: RECT, - applied: Option, - } - - impl Clip { - fn observe(&mut self, current: RECT) { - if self.applied != Some(current) { - // A game changed its own confinement. Respect the new bounds. - self.original = current; - self.applied = None; - } - } - - fn release(&mut self) { - if let Some(applied) = self.applied.take() { - let mut current = RECT::default(); - unsafe { - if GetClipCursor(&mut current).is_ok() && current == applied { - let _ = ClipCursor(Some(&self.original)); - } - } - } + /// A bitmap's description and a copy of its pixels. + fn bits(bitmap: HBITMAP) -> Option<(BITMAP, Vec)> { + let mut info = BITMAP::default(); + let read = unsafe { + GetObjectW( + bitmap, + size_of::() as i32, + Some((&mut info as *mut BITMAP).cast()), + ) + }; + if read == 0 { + return None; } + let size = i32::try_from(i64::from(info.bmWidthBytes) * i64::from(info.bmHeight)).ok()?; + let mut data = vec![0u8; usize::try_from(size).ok()?]; + let copied = unsafe { GetBitmapBits(bitmap, size, data.as_mut_ptr().cast()) }; + (copied > 0).then_some((info, data)) } - impl Drop for Clip { - fn drop(&mut self) { - self.release(); + fn bitmap<'a>(info: &BITMAP, data: &'a [u8]) -> Bitmap<'a> { + Bitmap { + width: info.bmWidth, + height: info.bmHeight, + stride: usize::try_from(info.bmWidthBytes).unwrap_or(0), + bits_per_pixel: info.bmBitsPixel, + data, } } - fn shape(cursor: HICON) -> Option<(i32, i32, i32, i32)> { + fn shape(cursor: HICON) -> Option { let mut icon = ICONINFO::default(); unsafe { GetIconInfo(cursor, &mut icon).ok()? }; - let mut bitmap = BITMAP::default(); - let color = !icon.hbmColor.is_invalid(); - let read = unsafe { - GetObjectW( - if color { icon.hbmColor } else { icon.hbmMask }, - size_of::() as i32, - Some((&mut bitmap as *mut BITMAP).cast()), - ) - }; + let has_color = !icon.hbmColor.is_invalid(); + let color = if has_color { bits(icon.hbmColor) } else { None }; + let mask = bits(icon.hbmMask); unsafe { - if color { + if has_color { let _ = DeleteObject(icon.hbmColor); } if !icon.hbmMask.is_invalid() { let _ = DeleteObject(icon.hbmMask); } } - (read != 0).then_some(( - bitmap.bmWidth, - if color { - bitmap.bmHeight - } else { - bitmap.bmHeight / 2 - }, - icon.xHotspot as i32, - icon.yHotspot as i32, - )) + let (mask_info, mask_data) = mask?; + let visible = match (has_color, &color) { + (true, Some((info, data))) => { + visible_box(Some(&bitmap(info, data)), &bitmap(&mask_info, &mask_data))? + } + (true, None) => return None, + (false, _) => visible_box(None, &bitmap(&mask_info, &mask_data))?, + }; + Some(Shape { + hot: (icon.xHotspot as i32, icon.yHotspot as i32), + visible, + }) + } + + /// The desktop as the guard sees it. + #[derive(Default)] + struct Screen { + /// Windows' own pointers, which must still reach scrollbars and + /// controls at the last pixel. + standard: Vec, + /// The last cursor looked at, and its shape. + cached: Option<(HCURSOR, Option)>, + } + + impl Screen { + fn refresh(&mut self) { + // DPI changes can replace a cursor's bitmap without changing + // its handle. Refresh the shape along with the display state. + self.cached = None; + self.standard = [ + IDC_ARROW, + IDC_IBEAM, + IDC_WAIT, + IDC_CROSS, + IDC_UPARROW, + IDC_SIZENWSE, + IDC_SIZENESW, + IDC_SIZEWE, + IDC_SIZENS, + IDC_SIZEALL, + IDC_NO, + IDC_HAND, + IDC_APPSTARTING, + IDC_HELP, + ] + .into_iter() + .filter_map(|id| unsafe { LoadCursorW(None, id).ok() }) + .collect(); + } } + impl Desktop for Screen { + fn bounds(&self) -> [i32; 4] { + unsafe { + let (x, y) = ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + ); + [ + x, + y, + x + GetSystemMetrics(SM_CXVIRTUALSCREEN), + y + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ] + } + } + + fn clip(&self) -> Option<[i32; 4]> { + let mut current = RECT::default(); + unsafe { GetClipCursor(&mut current) }.ok()?; + Some(coordinates(current)) + } + + fn set_clip(&mut self, rect: Option<[i32; 4]>) -> bool { + let rect = rect.map(rectangle); + unsafe { ClipCursor(rect.as_ref().map(|r| r as *const RECT)) }.is_ok() + } + + fn custom_cursor(&mut self) -> Option { + let mut cursor = CURSORINFO { + cbSize: size_of::() as u32, + ..Default::default() + }; + unsafe { + if GetSystemMetrics(SM_CMONITORS) != 1 || GetCursorInfo(&mut cursor).is_err() { + return None; + } + } + if cursor.flags != CURSOR_SHOWING || self.standard.contains(&cursor.hCursor) { + return None; + } + if self.cached.as_ref().map(|(handle, _)| *handle) != Some(cursor.hCursor) { + let found = shape(HICON(cursor.hCursor.0)); + self.cached = Some((cursor.hCursor, found)); + } + self.cached.as_ref().and_then(|(_, found)| *found) + } + } + + /// Whether a viewer is watching: the engine runs an app and its log + /// says a viewer is connected. A log that cannot be read leaves the + /// running app to decide. fn streaming() -> bool { - latch_core::http::request( + let running = latch_core::http::request( ("127.0.0.1", latch_core::SUNSHINE_PORT), "GET", "/serverinfo", @@ -191,13 +499,25 @@ mod win { .ok() .filter(|r| r.status == 200) .and_then(|r| crate::streamer::session_in_serverinfo(&r.body)) - .unwrap_or(false) + .unwrap_or(false); + running + && crate::streamer::find() + .and_then(|install| crate::streamer::log_text(&install)) + .and_then(|log| super::viewer_connected(&log)) + .unwrap_or(true) + } + + /// This program's file name, as process snapshots list it. + fn own_name() -> Option { + let exe = std::env::current_exe().ok()?; + Some(exe.file_name()?.to_string_lossy().to_lowercase()) } - fn parent_running(pid: u32) -> Result { + fn parent_running(pid: u32, name: Option<&str>) -> Result { // A limited desktop task cannot open an elevated service process, // even just to wait for its exit. A process snapshot needs no such // access and lets the guard release its clip when that node exits. + // The name check keeps a reused process ID from passing for the node. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?; let mut entry = PROCESSENTRY32W { dwSize: size_of::() as u32, @@ -208,7 +528,13 @@ mod win { let mut next = Process32FirstW(snapshot, &mut entry); while next.is_ok() { if entry.th32ProcessID == pid { - found = true; + let len = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let exe = String::from_utf16_lossy(&entry.szExeFile[..len]).to_lowercase(); + found = name.is_none_or(|name| exe == name); break; } next = Process32NextW(snapshot, &mut entry); @@ -225,93 +551,26 @@ mod win { bail!("could not use physical cursor coordinates"); } tracing::info!("cursor guard for node {parent_pid}"); + let name = own_name(); + let mut screen = Screen::default(); let mut clip = Clip::default(); let mut active = false; - let mut checked = Instant::now() - Duration::from_secs(1); - let mut cached = None; - let mut standard = Vec::new(); + let mut checked: Option = None; loop { - std::thread::sleep(Duration::from_millis(16)); - if checked.elapsed() >= Duration::from_secs(1) { - if !parent_running(parent_pid)? { + if checked.is_none_or(|at| at.elapsed() >= Duration::from_secs(1)) { + if !parent_running(parent_pid, name.as_deref()).unwrap_or(false) { break; } active = streaming(); - checked = Instant::now(); - // DPI changes can replace a cursor's bitmap without changing - // its handle. Refresh the shape along with the display state. - cached = None; - // Desktop pointers must still reach scrollbars and controls - // at the last pixel. Only custom game cursors need this guard. - standard = [ - IDC_ARROW, - IDC_IBEAM, - IDC_WAIT, - IDC_CROSS, - IDC_UPARROW, - IDC_SIZENWSE, - IDC_SIZENESW, - IDC_SIZEWE, - IDC_SIZENS, - IDC_SIZEALL, - IDC_NO, - IDC_HAND, - IDC_APPSTARTING, - IDC_HELP, - ] - .into_iter() - .filter_map(|id| unsafe { LoadCursorW(None, id).ok() }) - .collect(); - } - let mut current = RECT::default(); - let mut cursor = CURSORINFO { - cbSize: size_of::() as u32, - ..Default::default() - }; - unsafe { - if GetClipCursor(&mut current).is_err() { - continue; - } - clip.observe(current); - if !active - || GetSystemMetrics(SM_CMONITORS) != 1 - || GetCursorInfo(&mut cursor).is_err() - || cursor.flags != CURSOR_SHOWING - || standard.contains(&cursor.hCursor) - { - clip.release(); - cached = None; - continue; - } - if cached.as_ref().map(|(handle, _)| *handle) != Some(cursor.hCursor) { - cached = shape(HICON(cursor.hCursor.0)).map(|s| (cursor.hCursor, s)); - } - let Some((_, size)) = cached else { - clip.release(); - continue; - }; - let (x, y) = ( - GetSystemMetrics(SM_XVIRTUALSCREEN), - GetSystemMetrics(SM_YVIRTUALSCREEN), - ); - let desktop = [ - x, - y, - x + GetSystemMetrics(SM_CXVIRTUALSCREEN), - y + GetSystemMetrics(SM_CYVIRTUALSCREEN), - ]; - let Some(bounds) = cursor_clip(desktop, coordinates(clip.original), size) else { - clip.release(); - continue; - }; - let bounds = rectangle(bounds); - if bounds != current && ClipCursor(Some(&bounds)).is_ok() { - clip.applied = Some(bounds); - } + checked = Some(Instant::now()); + screen.refresh(); } + clip.step(&mut screen, active); + // With no viewer there is nothing to follow but the next check. + std::thread::sleep(Duration::from_millis(if active { 16 } else { 1000 })); } // Restore before leaving the desktop, including node updates. - clip.release(); + clip.release(&mut screen); unsafe { SetThreadDpiAwarenessContext(dpi); } @@ -327,35 +586,285 @@ pub use win::{run, start}; mod tests { use super::*; + /// The 1080p desktop most tests use. + const HD: [i32; 4] = [0, 0, 1920, 1080]; + + fn shape(hot: (i32, i32), visible: [i32; 4]) -> Shape { + Shape { hot, visible } + } + #[test] - fn the_entire_shape_fits_at_all_four_edges() { - for desktop in [ - [0, 0, 1920, 1080], - [-2560, -1440, 0, 0], - [17, 33, 3041, 1997], - ] { - for shape in [(32, 32, 0, 0), (64, 48, 31, 20)] { - let r = cursor_clip(desktop, desktop, shape).unwrap(); - let (w, h, hx, hy) = shape; - assert_eq!((r[0] - hx, r[1] - hy), (desktop[0], desktop[1])); - assert_eq!( - (r[2] - 1 - hx + w, r[3] - 1 - hy + h), - (desktop[2], desktop[3]) - ); - } + fn a_cursor_drawn_at_its_hotspot_reaches_every_edge() { + // A 64x64 game cursor with its tip at the hotspot, and one with a + // centred hotspot (a crosshair): edge scrolling and the last + // pixels' buttons stay reachable. + assert_eq!(cursor_clip(HD, HD, shape((0, 0), [0, 0, 40, 60])), None); + assert_eq!(cursor_clip(HD, HD, shape((31, 31), [0, 0, 63, 63])), None); + // In line with visible pixels both ways, though not on one itself. + assert_eq!(cursor_clip(HD, HD, shape((10, 10), [0, 0, 20, 20])), None); + } + + #[test] + fn the_nearest_visible_pixel_stays_on_screen_at_every_edge() { + for desktop in [HD, [-2560, -1440, 0, 0], [17, 33, 3041, 1997]] { + // Transparent top-left hotspot, visible pixels from (12, 9). + let s = shape((0, 0), [12, 9, 40, 30]); + let r = cursor_clip(desktop, desktop, s).unwrap(); + assert_eq!((r[0], r[1]), (desktop[0], desktop[1])); + // At the last allowed hotspot, the first visible column and row + // are on the desktop's last pixel. + assert_eq!( + (r[2] - 1 + 12, r[3] - 1 + 9), + (desktop[2] - 1, desktop[3] - 1) + ); + // Visible pixels above and left of the hotspot hold it back + // from the top and left edges instead. + let s = shape((50, 40), [0, 0, 20, 30]); + let r = cursor_clip(desktop, desktop, s).unwrap(); + assert_eq!((r[0] - 50 + 20, r[1] - 40 + 30), (desktop[0], desktop[1])); + assert_eq!((r[2], r[3]), (desktop[2], desktop[3])); } } #[test] - fn game_bounds_are_preserved_and_invalid_shapes_are_ignored() { - let desktop = [0, 0, 1920, 1080]; + fn game_bounds_are_preserved_and_impossible_bounds_are_dropped() { let game = [400, 300, 401, 301]; - assert_eq!(cursor_clip(desktop, game, (32, 32, 0, 0)), Some(game)); - assert_eq!(cursor_clip(desktop, desktop, (0, 32, 0, 0)), None); - assert_eq!(cursor_clip(desktop, desktop, (32, 32, 32, 0)), None); + let offset = shape((0, 0), [8, 8, 31, 31]); + assert_eq!(cursor_clip(HD, game, offset), Some(game)); assert_eq!( - cursor_clip([0, 0, 16, 16], [0, 0, 16, 16], (32, 32, 0, 0)), + cursor_clip( + [0, 0, 16, 16], + [0, 0, 16, 16], + shape((0, 0), [20, 0, 31, 31]) + ), None ); } + + #[test] + fn the_visible_box_is_read_from_every_cursor_format() { + // Monochrome, 8x2 (stride 2): AND above XOR. Row 0 shows an inverted + // pixel at x 6 (AND set, XOR set); row 1 a black pixel at x 2. + let data = [0xFF, 0, 0xDF, 0, 0x02, 0, 0, 0]; + let mask = Bitmap { + width: 8, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &data, + }; + assert_eq!(visible_box(None, &mask), Some([2, 0, 6, 1])); + + // 32-bit with alpha: only pixels with alpha count. + let mut pixels = vec![0u8; 4 * 4 * 4]; + pixels[(4 + 1) * 4 + 3] = 255; // (1, 1) + pixels[(3 * 4 + 2) * 4 + 3] = 10; // (2, 3) + let color = Bitmap { + width: 4, + height: 4, + stride: 16, + bits_per_pixel: 32, + data: &pixels, + }; + let and = [0u8, 0, 0, 0, 0, 0, 0, 0]; + let mask = Bitmap { + width: 4, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &and, + }; + assert_eq!(visible_box(Some(&color), &mask), Some([1, 1, 2, 3])); + + // 32-bit without alpha: AND clear draws, AND set with a colour inverts. + let mut pixels = vec![0u8; 4 * 4 * 4]; + pixels[(2 * 4 + 3) * 4] = 0x80; // (3, 2): blue, over a set AND bit + let color = Bitmap { + width: 4, + height: 4, + stride: 16, + bits_per_pixel: 32, + data: &pixels, + }; + // Every AND bit set but (1, 0). + let and = [0xBF, 0, 0xFF, 0, 0xFF, 0, 0xFF, 0]; + let mask = Bitmap { + width: 4, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &and, + }; + assert_eq!(visible_box(Some(&color), &mask), Some([1, 0, 3, 2])); + + // Nothing drawn, a format it does not read, and a short copy. + let clear = [0xFFu8, 0, 0xFF, 0, 0, 0, 0, 0]; + let mask = Bitmap { + width: 8, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &clear, + }; + assert_eq!(visible_box(None, &mask), None); + let color = Bitmap { + width: 4, + height: 4, + stride: 12, + bits_per_pixel: 24, + data: &[0; 48], + }; + assert_eq!(visible_box(Some(&color), &mask), None); + let short = Bitmap { + width: 8, + height: 4, + stride: 2, + bits_per_pixel: 1, + data: &[0; 6], + }; + assert_eq!(visible_box(None, &short), None); + } + + #[test] + fn only_a_connected_viewer_counts_as_a_stream() { + let log = "Info: Executing [Desktop]\nInfo: CLIENT CONNECTED\nInfo: Detecting displays"; + assert_eq!(viewer_connected(log), Some(true)); + let left = format!("{log}\nInfo: CLIENT DISCONNECTED\nInfo: Setting default sink"); + assert_eq!(viewer_connected(&left), Some(false)); + // A viewer that vanished: the session ends on a ping timeout. + let lost = format!("{log}\nInfo: 100.111.100.57: Ping Timeout"); + assert_eq!(viewer_connected(&lost), Some(false)); + let back = format!("{left}\nInfo: CLIENT CONNECTED"); + assert_eq!(viewer_connected(&back), Some(true)); + assert_eq!(viewer_connected("Info: Ready"), None); + } + + /// Windows' side of the guard, as far as the tests need it. + struct Fake { + screen: [i32; 4], + clip: Option<[i32; 4]>, + cursor: Option, + } + + impl Fake { + fn new(cursor: Option) -> Fake { + Fake { + screen: HD, + clip: None, + cursor, + } + } + + /// What `GetClipCursor` would answer. + fn current(&self) -> [i32; 4] { + self.clip.unwrap_or(self.screen) + } + } + + impl Desktop for Fake { + fn bounds(&self) -> [i32; 4] { + self.screen + } + + fn clip(&self) -> Option<[i32; 4]> { + Some(self.current()) + } + + fn set_clip(&mut self, rect: Option<[i32; 4]>) -> bool { + self.clip = rect; + true + } + + fn custom_cursor(&mut self) -> Option { + self.cursor + } + } + + const OFFSET: Shape = Shape { + hot: (0, 0), + visible: [16, 16, 47, 47], + }; + + #[test] + fn the_guard_clips_only_while_a_viewer_watches_and_lifts_only_its_own_clip() { + let mut desktop = Fake::new(Some(OFFSET)); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064])); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064]), "steady"); + // The viewer leaves: the cursor is free again. + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, None); + // A standard pointer needs nothing either. + clip.step(&mut desktop, true); + desktop.cursor = None; + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, None); + } + + #[test] + fn a_games_own_confinement_is_kept_and_restored() { + let mut desktop = Fake::new(Some(OFFSET)); + let window = [100, 100, 1920, 1080]; + desktop.clip = Some(window); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([100, 100, 1904, 1064])); + // The game re-clips to a smaller window while the guard's clip is on. + let smaller = [200, 200, 800, 600]; + desktop.clip = Some(smaller); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some(smaller), "inside the safe area already"); + // The guard lets go: the game's bounds stay, and a guard clip ends + // with the game's bounds back, not with none. + desktop.clip = Some(window); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([100, 100, 1904, 1064])); + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, Some(window)); + } + + #[test] + fn a_clip_someone_else_set_is_never_lifted() { + let mut desktop = Fake::new(Some(OFFSET)); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + // A game takes over the confinement; then no viewer is left. + let game = [10, 10, 500, 500]; + desktop.clip = Some(game); + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, Some(game)); + } + + #[test] + fn a_free_cursor_is_freed_again_whatever_the_desktop_became() { + let mut desktop = Fake::new(Some(OFFSET)); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064])); + // The stream switches to 1440p, and Windows keeps the old clip. + desktop.screen = [0, 0, 2560, 1440]; + clip.step(&mut desktop, true); + assert_eq!( + desktop.clip, + Some([0, 0, 2544, 1424]), + "follows the new size" + ); + clip.step(&mut desktop, false); + assert_eq!(desktop.clip, None, "not the 1080p desktop"); + } + + #[test] + fn a_clip_left_by_an_ended_guard_is_lifted() { + // The previous guard was killed with its clip on. + let mut desktop = Fake::new(Some(OFFSET)); + desktop.clip = Some([0, 0, 1904, 1064]); + let mut clip = Clip::default(); + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, Some([0, 0, 1904, 1064])); + desktop.cursor = None; + clip.step(&mut desktop, true); + assert_eq!(desktop.clip, None); + } } diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 3b98681..6fc08a7 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -182,13 +182,18 @@ other engine setting. Standard 1080p, 1440p and 4K streams are 16:9 and leave bars on a taller Mac screen; **Match screen** uses the Mac's aspect ratio. -While a session uses a single display, Latch keeps a custom game cursor's -whole shape inside the picture. This prevents a cursor with a transparent -tip from disappearing at the bottom or right edge. The bounds follow changes -in resolution and cursor shape, respect the game's own confinement, and release -when the game hides its cursor for camera control. A windowless user task, -`LatchCursorUser`, runs this check in the desktop session without elevation. -Standard Windows pointers retain their normal access to controls at the edge. +While a viewer is connected and the session uses a single display, Latch +keeps a custom game cursor visible in the picture. A cursor whose visible +pixels sit to the right of or below a transparent hotspot would otherwise +disappear at the bottom or right edge; Latch stops it where its nearest +visible pixel is still on screen. A cursor drawn at its hotspot, like every +standard Windows pointer and most game cursors, still reaches the last +pixel, so edge scrolling and controls at the edge keep working. The bounds +follow changes in resolution and cursor shape, respect the game's own +confinement, and release when the game hides its cursor for camera control +or the viewer disconnects. The check runs as a windowless process beside +Latch in the desktop session, without elevation; a Latch started over SSH +runs it through a user task, `LatchCursorUser`. ## Using an engine you installed yourself From 5ab3ddd1519e98bcb70169e70a9dc4ad9ac67120 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:04:11 -0700 Subject: [PATCH 3/6] Virtual display: read the device list Sunshine logs after a layout recovery After a stream, Sunshine's display recovery logs "Currently available devices:" with a list of " - " strings (display_device.cpp, try_revert_settings), not the objects it logs at startup. output_in() parsed that list, found no friendly_name on the strings and reported no virtual display, so a host still on automatic capture was not switched to the driver until a later engine start logged the object form. Both forms are read now; the latest device list still decides, and other text that begins "Currently available " is skipped instead of hiding the list before it. Co-Authored-By: Claude Opus 5.5 --- crates/host/src/virtual_display.rs | 36 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/host/src/virtual_display.rs b/crates/host/src/virtual_display.rs index 89c93cc..b31e886 100644 --- a/crates/host/src/virtual_display.rs +++ b/crates/host/src/virtual_display.rs @@ -20,21 +20,33 @@ const REG_KEY: &str = r"HKLM:\SOFTWARE\MikeTheTech\VirtualDisplayDriver"; /// Listed with every size: the default frame rate and ProMotion's. pub const REFRESH_RATES: [u32; 2] = [60, 120]; +/// The name the Virtual Display Driver's monitor reports. +const VDD_NAME: &str = "VDD by MTT"; + /// Sunshine's stable ID for the Virtual Display Driver, including an /// inactive display. Use the latest device list, so a removed display -/// from an older list is never selected. +/// from an older list is never selected. Sunshine lists the devices as +/// objects when it starts, and as `" - "` strings when it has +/// recovered the display layout after a stream. // ponytail: Sunshine logs its display IDs; use an enumeration API if it gains one. pub fn output_in(log: &str) -> Option { - let (_, list) = log.rsplit_once("Currently available ")?; - let json = list - .strip_prefix("display devices:") - .or_else(|| list.strip_prefix("devices:"))?; + let json = log + .rmatch_indices("Currently available ") + .find_map(|(at, key)| { + let list = &log[at + key.len()..]; + list.strip_prefix("display devices:") + .or_else(|| list.strip_prefix("devices:")) + })?; let devices = serde_json::Deserializer::from_str(json.trim_start()) .into_iter::>() .next()? .ok()?; devices.iter().find_map(|d| { - let vdd = d["friendly_name"].as_str() == Some("VDD by MTT") + if let Some(entry) = d.as_str() { + let (id, name) = entry.split_once(" - ")?; + return (name == VDD_NAME && !id.is_empty()).then(|| id.to_string()); + } + let vdd = d["friendly_name"].as_str() == Some(VDD_NAME) || (d["edid"]["manufacturer_id"].as_str() == Some("MTT") && d["edid"]["product_code"].as_str() == Some("1337")); d["device_id"] @@ -289,6 +301,18 @@ mod tests { assert_eq!(output_in(&log), None, "the old virtual display is gone"); assert_eq!(output_in("Currently available devices:\n["), None); assert_eq!(output_in("no devices reported"), None); + // After a stream, the layout recovery lists " - " strings. + let recovered = format!( + "{log}\nInfo: Recovered the available display layout, but settings for an \ + unavailable display are still pending. Will retry once devices are added or \ + removed. Currently available devices:\n[\"{{a1}} - 27E1QA\", \"{{b2}} - VDD by MTT\"]\n" + ); + assert_eq!(output_in(&recovered).as_deref(), Some("{b2}")); + let gone = format!("{recovered}Info: Currently available devices:\n[\"{{a1}} - 27E1QA\"]"); + assert_eq!(output_in(&gone), None, "the latest list decides"); + // Other text that starts the same way is not a device list. + let other = format!("{recovered}Info: Currently available encoders: none"); + assert_eq!(output_in(&other).as_deref(), Some("{b2}")); } #[test] From 1485721019e22aaf45d34477d89874399b93f9fd Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:45:22 -0700 Subject: [PATCH 4/6] Cursor guard: read the engine's log through the streamer's viewer check One reading of 'is a viewer connected' for the update deferral, setup and the cursor guard: the last CLIENT CONNECTED, CLIENT DISCONNECTED or Ping Timeout line decides. Co-Authored-By: Claude Opus 5.5 --- crates/host/src/cursor.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/host/src/cursor.rs b/crates/host/src/cursor.rs index 9e6e978..3d3fea2 100644 --- a/crates/host/src/cursor.rs +++ b/crates/host/src/cursor.rs @@ -119,15 +119,7 @@ fn visible_box(color: Option<&Bitmap>, mask: &Bitmap) -> Option<[i32; 4]> { /// Desktop app running after a viewer leaves, so a running app alone does /// not say a stream is on. `None` when the log says neither. fn viewer_connected(log: &str) -> Option { - log.lines().rev().find_map(|line| { - if line.contains("CLIENT CONNECTED") { - Some(true) - } else if line.contains("CLIENT DISCONNECTED") || line.contains("Ping Timeout") { - Some(false) - } else { - None - } - }) + crate::streamer::viewer_connected_in(log) } /// What the guard reads and sets on Windows; a fake in the tests. From 3bbc80e3828978bed276b228b91ff37a9f41d98c Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:05:29 -0700 Subject: [PATCH 5/6] Remove former branding from docs and installer messages --- .github/workflows/release.yml | 8 ++--- CHANGELOG.md | 32 +++++++++---------- README.md | 29 ++++++++--------- crates/core/src/legacy.rs | 5 ++- crates/host/src/legacy.rs | 4 +-- crates/host/src/unix_setup.rs | 6 ++-- deploy/native/README.md | 16 ++++------ ...rate-from-brolink.sh => migrate-legacy.sh} | 8 ++--- scripts/install-host.ps1 | 6 ++-- scripts/install-macos-release.sh | 2 +- scripts/install-macos.sh | 2 +- 11 files changed, 56 insertions(+), 62 deletions(-) rename deploy/native/{migrate-from-brolink.sh => migrate-legacy.sh} (92%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6501c90..1a36710 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,9 +39,9 @@ jobs: cp docs/MACOS.md dist/README-MACOS.md cp LICENSE NOTICE dist/ cd dist && tar czf latch-macos-arm64.tar.gz Latch.app README-MACOS.md LICENSE NOTICE - # A 4.0 updater downloads brolink-macos-arm64.tar.gz and installs the - # BroLink.app inside it. The same app under that folder name is what - # lets it update at all; on its first run it moves itself to Latch.app + # A 4.0 updater downloads only its original archive and app names. + # The same app under that folder name lets it update at all; on its + # first run it moves itself to Latch.app # and takes over the launch agent (crates/host/src/unix_setup.rs, # adopt_legacy_bundle). Nothing in 4.1 or later reads this asset. - name: package for 4.0 updaters @@ -100,7 +100,7 @@ jobs: throw "engine archive is not $($env:ENGINE_TAG): expected $($env:ENGINE_SHA256), got $got" } Compress-Archive -Path dist\* -DestinationPath latch-windows-x64.zip - # A 4.0 Mac takes brolink-host.exe out of brolink-windows-x64.zip and + # A 4.0 Mac takes the host executable from its original archive and # sends it to each PC. The new program under the old name is what lets # it update the PC; nothing in 4.1 or later reads this asset. - name: package for 4.0 updaters diff --git a/CHANGELOG.md b/CHANGELOG.md index d430318..27afe5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,36 +6,34 @@ GitHub release notes, so each version gets a heading of the form ## 4.1.1 (2026-10-02) -- Setting up a Windows PC that an earlier version had set up (when the - app was called BroLink) now registers the `LatchStream` service. Setup +- Setting up a Windows PC that a version before 4.1.0 had set up now + registers the `LatchStream` service. Setup moved the engine's folder and removed the old service, but treated the running engine as needing nothing more, so the new service was never registered and the engine ran only once someone had signed in. ## 4.1.0 (2026-10-02) -- **BroLink is now Latch, by Bardbro.** The app, the repository +- **Introducing Latch, by Bardbro.** The app, the repository (`github.com/MrBeldum/latch`), the release files (`latch-macos-arm64.tar.gz`, `latch-windows-x64.zip`), the executables (`Latch.app`, `latch-host.exe`), the services and login items, the environment variables (`LATCH_*`) and bardbro.com all carry the new name, with a new logo: a square with a corner cut away, and a blue square in the cut. Bardbro is the project behind it, for cloud gaming. -- An installed BroLink updates itself to Latch. Settings, the pairing - identity, paired devices and the streaming engine carry over. On a Mac - the app moves from `BroLink.app` to `Latch.app` the first time it runs - and its login item with it; on Linux `latch.service` replaces - `brolink.service`; on Windows the host keeps working in place, and - `install-host.ps1` followed by **Set up as administrator** moves it to - `%LOCALAPPDATA%\Latch`, the `LatchStream` service and the new firewall - rule names. Machines on either name still find each other, and a 4.1 Mac - can still update a 4.0 PC. The release also carries - `brolink-macos-arm64.tar.gz` and `brolink-windows-x64.zip`, which are the - same builds under the old file names, because a 4.0 updater looks for - nothing else. Everything that still says BroLink is in +- Older installs update to Latch. Settings, the pairing identity, paired + devices and the streaming engine carry over. On a Mac the app moves to + `Latch.app` the first time it runs and its login item with it; on Linux + `latch.service` replaces the old service; on Windows the host keeps + working in place, and `install-host.ps1` followed by **Set up as + administrator** moves it to `%LOCALAPPDATA%\Latch`, the `LatchStream` + service and the new firewall rule names. Updated and older machines + still find each other, and a 4.1 Mac can still update a 4.0 PC. Releases + also include compatibility downloads under the filenames required by + 4.0 updaters. Runtime compatibility identifiers are documented in `crates/core/src/legacy.rs` and `crates/host/src/legacy.rs`. -- `LATCH_DATA_DIR` and `LATCH_GITHUB_TOKEN` replace the `BROLINK_` names, - which still work. The Docker node and relay read `LATCH_*`. +- `LATCH_DATA_DIR` and `LATCH_GITHUB_TOKEN` replace the old environment + variable names, which still work. The Docker node and relay read `LATCH_*`. - The About page names Bardbro and links to bardbro.com. The accent colour for focus and progress is the logo's blue, where it was violet. - The window is redesigned around one design system, in the product diff --git a/README.md b/README.md index d2b63f1..93dfdab 100644 --- a/README.md +++ b/README.md @@ -107,24 +107,23 @@ relay, as this project's own VPS does: [deploy/native/README.md](deploy/native/R The packet relay (when two NATs cannot punch through) is a separate kit: [deploy/relay/RELAY.md](deploy/relay/RELAY.md). -## Coming from BroLink +## Upgrading from versions before 4.1.0 -Latch was called BroLink until 4.1.0. An installed BroLink updates itself -to Latch and keeps its settings, its pairing and its streaming engine, and -every machine that updates can still talk to one that has not yet. +An older install updates to Latch and keeps its settings, pairing and +streaming engine. Updated machines can still talk to ones that have not +yet updated. -- **Mac:** the app moves from `BroLink.app` to `Latch.app` the first time - it runs, and its login item with it. +- **Mac:** the app moves to `Latch.app` the first time it runs, and its + login item moves with it. - **Windows:** the host updates in place and keeps working. To move it to - the new folder and names (`%LOCALAPPDATA%\Latch`, `latch-host.exe`, the - `LatchStream` service), run `install-host.ps1` from the new zip, then - **Set up as administrator** once on the **Sharing** tab. -- **Linux:** the first start registers `latch.service` and retires - `brolink.service`. The files in `deploy/native/` have new names; see - [deploy/native/README.md](deploy/native/README.md). - -The environment variables beginning `BROLINK_` are now `LATCH_`; for -`LATCH_DATA_DIR` and `LATCH_GITHUB_TOKEN` the old names still work. + `%LOCALAPPDATA%\Latch`, `latch-host.exe` and the `LatchStream` service, + run `install-host.ps1` from the new zip, then **Set up as administrator** + once on the **Sharing** tab. +- **Linux:** the first start registers `latch.service` and retires the old + service. For a native desktop, see [deploy/native/README.md](deploy/native/README.md). + +Use `LATCH_DATA_DIR` and `LATCH_GITHUB_TOKEN` for environment overrides. +The equivalent names from versions before 4.1.0 still work. ## Updates diff --git a/crates/core/src/legacy.rs b/crates/core/src/legacy.rs index 811336d..eb37ebf 100644 --- a/crates/core/src/legacy.rs +++ b/crates/core/src/legacy.rs @@ -1,4 +1,4 @@ -//! What Latch answered to before 4.1.0, when it was called BroLink. +//! Compatibility identifiers used by versions before 4.1.0. //! //! Installed copies, other machines on the tailnet and old settings //! folders keep the old names for a while after an upgrade, so the code @@ -28,8 +28,7 @@ pub fn is_own_streamer_kind(kind: &str) -> bool { } /// A setting from the environment under its current name, else under the -/// old one: `LATCH_DATA_DIR`, then `BROLINK_DATA_DIR`. Unset and empty are -/// the same. +/// old one from before 4.1.0. Unset and empty are the same. pub fn env(name: &str) -> Option { let current = std::env::var_os(name).filter(|v| !v.is_empty()); current.or_else(|| { diff --git a/crates/host/src/legacy.rs b/crates/host/src/legacy.rs index e066902..8623453 100644 --- a/crates/host/src/legacy.rs +++ b/crates/host/src/legacy.rs @@ -1,5 +1,5 @@ -//! What this host registered with the operating system before 4.1.0, when -//! Latch was called BroLink, and how an upgraded copy retires it. +//! What this host registered with the operating system before 4.1.0, +//! and how an upgraded copy retires it. //! //! An upgrade by the updater swaps the program and nothing else, so the //! launch agent, the user unit, the Windows service, the scheduled task, diff --git a/crates/host/src/unix_setup.rs b/crates/host/src/unix_setup.rs index e034815..2d6b09d 100644 --- a/crates/host/src/unix_setup.rs +++ b/crates/host/src/unix_setup.rs @@ -71,8 +71,8 @@ const OLD_LOGIN_FILE: &str = "brolink-web.json"; const LOGIN_FILE: &str = "latch-web.json"; /// An install that an older version set up has two things in the engine's -/// `sunshine.conf` that name it: the path of the engine login file, which is -/// called `brolink-web.json` and sits wherever setup put it, and any path +/// `sunshine.conf` that name it: the engine login file under its old +/// filename wherever setup put it, and any path /// under the old data folder, which this copy has moved. Point both at what /// is there now, and rename the login file, so the engine finds the login /// it was set up with. Nothing happens on an install that was never moved. @@ -494,7 +494,7 @@ fn retire_legacy_launch_agent() { } } -/// A copy running from the app folder 4.0 and older used, `BroLink.app`, +/// A copy running from the app folder 4.0 and older used /// moves into `Latch.app` beside it and carries on from there. The release /// publishes the new app under the old name as well, because a 4.0 updater /// installs only a folder of that name, and the first run lands here. diff --git a/deploy/native/README.md b/deploy/native/README.md index 4ba9976..d600ac9 100644 --- a/deploy/native/README.md +++ b/deploy/native/README.md @@ -45,19 +45,17 @@ in the `input` group: Sunshine must use its XTest fallback for this Xvfb session Granting `/dev/uinput` access makes input go to kernel devices that Xvfb does not consume, resulting in visible video but no functioning mouse or keyboard. -## Upgrading from BroLink (before 4.1.0) +## Upgrading from versions before 4.1.0 -Latch was called BroLink until 4.1.0, and a node that was set up then has -`brolink*.service` units, `/usr/local/bin/brolink-host`, a `brolink.Xauthority` -cookie and `~/.local/share/brolink`. `migrate-from-brolink.sh` in this -directory swaps the units, the cookie name and the wait script for the new -ones and starts Latch; `latch-host` moves the data folder and the engine's -login file itself the first time it runs, keeping the pairing and the -Tailscale identity. +Nodes set up before 4.1.0 use earlier names for their services, host binary, +display cookie and data folder. `migrate-legacy.sh` in this directory swaps +the units, cookie name and wait script for the current ones and starts +Latch. On its first run, `latch-host` moves the data folder and the engine's +login file, keeping the pairing and Tailscale identity. ```sh sudo install -m 0755 latch-host /usr/local/bin/latch-host -bash migrate-from-brolink.sh +bash migrate-legacy.sh ``` ## Migration and recovery diff --git a/deploy/native/migrate-from-brolink.sh b/deploy/native/migrate-legacy.sh similarity index 92% rename from deploy/native/migrate-from-brolink.sh rename to deploy/native/migrate-legacy.sh index a52a49f..e788f15 100755 --- a/deploy/native/migrate-from-brolink.sh +++ b/deploy/native/migrate-legacy.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Move a native Linux node from the names Latch had before 4.1.0 (when it was -# called BroLink) to the new ones. Run it as the user that owns the desktop, +# Move a native Linux node from the names used before 4.1.0 to the current +# ones. Run it as the user that owns the desktop, # from this directory, after putting the new latch-host at /usr/local/bin: # # sudo install -m 0755 latch-host /usr/local/bin/latch-host -# bash migrate-from-brolink.sh +# bash migrate-legacy.sh # # It stops the old stack, swaps the user units, the display cookie name and # the wait script, and starts the new one. The data folder (settings, @@ -67,7 +67,7 @@ systemctl --user enable --now latch.service for _ in $(seq 1 30); do if status="$(curl -fsS --max-time 2 http://127.0.0.1:47850/v1/status 2>/dev/null)"; then echo "$status" | python3 -c 'import json,sys; s=json.load(sys.stdin); print("app=%s version=%s name=%s streamer=%s running=%s" % (s["app"], s["version"], s["name"], s["streamer"]["kind"], s["streamer"]["running"]))' - echo "done. The old binary /usr/local/bin/brolink-host can be removed once this looks right." + echo "done. The previous host binary can be removed once this looks right." exit 0 fi sleep 2 diff --git a/scripts/install-host.ps1 b/scripts/install-host.ps1 index 3537ca0..cdae8dc 100644 --- a/scripts/install-host.ps1 +++ b/scripts/install-host.ps1 @@ -26,7 +26,7 @@ if (-not $Exe -or -not (Test-Path $Exe)) { } $DestDir = Join-Path $env:LOCALAPPDATA "Latch" -# Where versions before 4.1 installed (they were called BroLink). Their +# Where versions before 4.1 installed. Their # settings, pairing identity and engine archive come across, and the folder # goes once this one is in place. $OldDir = Join-Path $env:LOCALAPPDATA "BroLink" @@ -43,7 +43,7 @@ if ((Test-Path $OldDir) -and -not (Test-Path (Join-Path $DestDir "host.toml"))) New-Item -ItemType Directory -Force -Path $DestDir | Out-Null Get-ChildItem -LiteralPath $OldDir -Force | Where-Object { $_.Name -notmatch '\.exe(\.old|\.new)?$' } | ForEach-Object { Copy-Item -LiteralPath $_.FullName -Destination $DestDir -Recurse -Force } - Write-Host "Settings carried over from $OldDir" + Write-Host "Settings carried over from the previous install." } New-Item -ItemType Directory -Force -Path $DestDir | Out-Null Copy-Item $Exe $DestExe -Force @@ -75,7 +75,7 @@ New-ItemProperty -Path $Run -Name "LatchHost" -Value "`"$DestExe`" --background" if (Test-Path $OldDir) { Remove-Item -LiteralPath $OldDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path $OldDir) { Write-Host "Could not remove $OldDir completely; it is safe to delete." } + if (Test-Path $OldDir) { Write-Host "Could not remove the previous install folder completely; it is safe to delete." } } Write-Host "Shortcut: Desktop\Latch Host.lnk" diff --git a/scripts/install-macos-release.sh b/scripts/install-macos-release.sh index 9d167f5..c0068b7 100644 --- a/scripts/install-macos-release.sh +++ b/scripts/install-macos-release.sh @@ -82,7 +82,7 @@ if [[ -d "$DEST" ]]; then else mv "$STAGE" "$DEST" fi -# An install from before 4.1 (the app was called BroLink): stop its +# An install from before 4.1: stop its # background job, drop its login item and remove its app, so only Latch is # left. The new app registers its own login item when it first runs. OLD_AGENT="gui/$(id -u)/dev.brolink.node" diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh index 33f5560..fbcdc58 100755 --- a/scripts/install-macos.sh +++ b/scripts/install-macos.sh @@ -21,7 +21,7 @@ LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchS "$LSREGISTER" -u "$APP" 2>/dev/null || true "$LSREGISTER" -f /Applications/Latch.app 2>/dev/null || true rm -rf "$APP" -# An install from before 4.1 (the app was called BroLink): stop its +# An install from before 4.1: stop its # background job, drop its login item and remove its app, so only Latch is # left. The new app registers its own login item when it first runs. OLD_AGENT="gui/$(id -u)/dev.brolink.node" From 383e33693d95b4e1e87c86d096c155661e839652 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:16:11 -0700 Subject: [PATCH 6/6] Stream: Caps Lock follows the Mac's egui reports neither a Caps Lock key nor a modifier for it, so with Caps Lock on the Mac the PC still typed lowercase. The Mac's state is read from AppKit (+[NSEvent modifierFlags]) each frame; the first state seen while the stream has the keys is taken as matching (the PC's own can't be read), and each change after it reaches the PC as one tap of Caps Lock, queued like any key so it keeps its place behind a pending paste. A change made while Latch or another app had the keys goes once the stream has them again. Co-Authored-By: Claude Opus 5.5 --- crates/client/Cargo.toml | 2 +- crates/client/src/input.rs | 1 + crates/client/src/macos.rs | 27 ++++++++++- crates/client/src/stream.rs | 95 ++++++++++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index a496a36..afa43d4 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -35,7 +35,7 @@ semver = "1" # pasteboard's change count. [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5" -objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSMenu", "NSMenuItem", "NSPasteboard", "NSResponder"] } +objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSPasteboard", "NSResponder"] } objc2-foundation = { version = "0.2", features = ["NSString", "NSThread"] } [dev-dependencies] diff --git a/crates/client/src/input.rs b/crates/client/src/input.rs index fe5614d..62dea03 100644 --- a/crates/client/src/input.rs +++ b/crates/client/src/input.rs @@ -17,6 +17,7 @@ pub const VK_INSERT: i16 = 0x2D; pub const VK_DELETE: i16 = 0x2E; pub const VK_ESCAPE: i16 = 0x1B; pub const VK_TAB: i16 = 0x09; +pub const VK_CAPITAL: i16 = 0x14; pub const VK_RETURN: i16 = 0x0D; pub const VK_SNAPSHOT: i16 = 0x2C; pub const VK_C: i16 = 0x43; diff --git a/crates/client/src/macos.rs b/crates/client/src/macos.rs index 38861e6..e86889a 100644 --- a/crates/client/src/macos.rs +++ b/crates/client/src/macos.rs @@ -7,7 +7,8 @@ //! place in the menu but lose their shortcuts. //! //! And the pasteboard's change count, which tells whether the Mac's -//! clipboard has changed since the PC last copied something. +//! clipboard has changed since the PC last copied something. And whether +//! Caps Lock is on: egui has neither a key nor a modifier for it. //! //! Elsewhere both do nothing. @@ -34,7 +35,7 @@ fn equivalent(item: Item, stream_owns_keys: bool) -> &'static str { mod imp { use super::{equivalent, Item}; use objc2::sel; - use objc2_app_kit::{NSApplication, NSPasteboard}; + use objc2_app_kit::{NSApplication, NSEvent, NSEventModifierFlags, NSPasteboard}; use objc2_foundation::{MainThreadMarker, NSString}; use std::cell::Cell; @@ -79,6 +80,13 @@ mod imp { // its change count. Some(unsafe { NSPasteboard::generalPasteboard().changeCount() }) } + + pub fn caps_lock() -> Option { + // SAFETY: a class getter for the modifier keys' state right now, + // usable from any thread. + let flags = unsafe { NSEvent::modifierFlags_class() }; + Some(flags.contains(NSEventModifierFlags::NSEventModifierFlagCapsLock)) + } } #[cfg(not(target_os = "macos"))] @@ -88,6 +96,10 @@ mod imp { pub fn pasteboard_changes() -> Option { None } + + pub fn caps_lock() -> Option { + None + } } /// While `owned`, ⌘Q, ⌘H and ⌥⌘H go to the stream (and so to the PC) @@ -102,6 +114,11 @@ pub fn pasteboard_changes() -> Option { imp::pasteboard_changes() } +/// Whether Caps Lock is on on this Mac. None where that is not known. +pub fn caps_lock() -> Option { + imp::caps_lock() +} + #[cfg(test)] mod tests { use super::*; @@ -119,4 +136,10 @@ mod tests { stream_owns_keys(true); stream_owns_keys(false); } + + #[test] + fn caps_lock_is_known_on_a_mac() { + // Read through the window server, from any thread. + assert_eq!(caps_lock().is_some(), cfg!(target_os = "macos")); + } } diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 3ab63f5..827baa6 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -170,6 +170,12 @@ pub struct View { /// The other machine is a Mac: ⌘ is Command there, whatever the /// "Command acts as Ctrl" setting says for Windows and Linux. host_mac: bool, + /// The Mac's Caps Lock as the PC was last brought in line with it (see + /// [`caps_tap`]); None until the stream first has the keys. + caps: Option, + /// Caps Lock as a test sets it, in place of the Mac's. + #[cfg(test)] + caps_now: Option, /// A video problem to show in place of the session's, for rendering /// the recovery overlay without a broken capture. #[cfg(test)] @@ -197,6 +203,8 @@ enum Queued { /// The modifiers held at the end of a frame: egui has no events for /// Shift, Ctrl, Option or ⌘ on their own. Modifiers(Modifiers), + /// A key pressed and let go, for one egui never reports (Caps Lock). + Tap(i16), } /// Where an event was going when it arrived. @@ -241,6 +249,9 @@ impl Default for View { pasted_since_v_up: false, cmd_since_v_up: false, host_mac: false, + caps: None, + #[cfg(test)] + caps_now: None, #[cfg(test)] problem: None, #[cfg(test)] @@ -1283,6 +1294,12 @@ impl View { } // A paste whose text has arrived goes before anything this frame. self.settle_paste(&link, false); + // Caps Lock, which egui never reports, as a tap of the PC's own. + let (caps, tap) = caps_tap(self.caps, self.mac_caps(), keys_to_pc); + self.caps = caps; + if tap { + self.dispatch(&link, Queued::Tap(input::VK_CAPITAL)); + } // Ctrl+Alt is Latch's host key. It is read before the keys-to-PC // gate so it works even if an overlay took focus, and while it is // held nothing is forwarded: the PC must not see a held chord after @@ -1378,6 +1395,11 @@ impl View { self.held.modifiers(out, m, cmd_is_ctrl); return; } + Queued::Tap(vk) => { + self.held.key(out, vk, true); + self.held.key(out, vk, false); + return; + } Queued::Event(ev, at) => (ev, at), }; match ev { @@ -1529,6 +1551,15 @@ impl View { } } + /// Whether Caps Lock is on on this Mac (what a test set, in tests). + fn mac_caps(&self) -> Option { + #[cfg(test)] + if self.tap.is_some() { + return self.caps_now; + } + crate::macos::caps_lock() + } + /// Whether ⌘ goes to the PC as Ctrl. A Mac there gets Command whatever /// the setting, which is about Windows and Linux. fn cmd_is_ctrl(&self, cfg: &ClientConfig) -> bool { @@ -1566,6 +1597,21 @@ fn swallowed_paste(v_seen_down: bool, pasted: bool, cmd: bool) -> bool { !v_seen_down && !pasted && cmd } +/// Caps Lock kept in step with the Mac's: `seen` is the Mac's state the PC +/// was last brought in line with, `now` the Mac's. The PC's own state can't +/// be read, so the first one seen while the stream has the keys is taken as +/// matching; after that each change reaches the PC as one tap of Caps Lock, +/// including one made while Latch or another app had the keys, once the +/// stream has them again. Returns the new `seen`, and whether to tap. +fn caps_tap(seen: Option, now: Option, keys_to_pc: bool) -> (Option, bool) { + match (seen, now) { + (_, None) => (seen, false), + _ if !keys_to_pc => (seen, false), + (None, now) => (now, false), + (Some(s), Some(n)) => (Some(n), s != n), + } +} + /// A wheel or trackpad scroll as the PC takes it, in 1/120ths of a click: /// (horizontal, vertical). egui's +x moves the content right, which is /// scrolling left; Moonlight's horizontal scroll, like Windows' and @@ -2727,7 +2773,7 @@ pub(crate) mod tests { // What the PC is sent: the order, the signs and the masks. use crate::input::{ - Recorder, Sent, VK_CONTROL, VK_LWIN, VK_MENU, VK_RETURN, VK_SHIFT, VK_TAB, VK_V, + Recorder, Sent, VK_CAPITAL, VK_CONTROL, VK_LWIN, VK_MENU, VK_RETURN, VK_SHIFT, VK_TAB, VK_V, }; use egui::{Key, PointerButton}; use latch_stream::ffi::{BUTTON_LEFT, MODIFIER_CTRL, MODIFIER_SHIFT}; @@ -2805,6 +2851,53 @@ pub(crate) mod tests { h.run_steps(1); } + #[test] + fn caps_lock_follows_the_mac_once_the_stream_has_the_keys() { + // Unknown on the Mac: nothing to do. + assert_eq!(caps_tap(None, None, true), (None, false)); + // Not the stream's keys yet: nothing is taken as matching. + assert_eq!(caps_tap(None, Some(true), false), (None, false)); + // The first state seen with the keys is the baseline. + assert_eq!(caps_tap(None, Some(true), true), (Some(true), false)); + assert_eq!(caps_tap(Some(true), Some(true), true), (Some(true), false)); + // A change is one tap; one made while Latch had the keys waits. + assert_eq!(caps_tap(Some(true), Some(false), true), (Some(false), true)); + assert_eq!( + caps_tap(Some(false), Some(true), false), + (Some(false), false) + ); + assert_eq!(caps_tap(Some(false), Some(true), true), (Some(true), true)); + } + + #[test] + fn caps_lock_on_the_mac_taps_caps_lock_on_the_pc() { + let (mut h, tap) = recording(View::default(), true); + h.state_mut().view.caps_now = Some(false); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!(tap.keys(), vec![], "the first state is the baseline"); + h.state_mut().view.caps_now = Some(true); + frame( + &mut h, + vec![ + key(Key::A, true, Modifiers::NONE), + key(Key::A, false, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.keys(), + vec![ + (VK_CAPITAL, true), + (VK_CAPITAL, false), + (0x41, true), + (0x41, false) + ], + "Caps Lock goes before the frame's keys" + ); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!(tap.keys(), vec![], "once per change"); + } + #[test] fn a_shift_let_go_in_the_same_frame_still_capitalises() { let (mut h, tap) = recording(View::default(), true);