From 34607f591fd3bb7538e31907e1f2dd1ca4a7a65c Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:48:43 -0700 Subject: [PATCH 01/34] VPS desktops: own size, sizes below 1080p, Linux clipboard, pairing fix Streaming a Linux VPS desktop (software x264 on two cores): - Latch Host reports the X screen it captures and, for software encoding on fewer than four cores, a 30 fps ceiling; LATCH_DISPLAY_FOLLOWS_VIEWER=1 stops reporting a fixed size for a desktop that resizes to each viewer. The Linux engine profile drops the 60 fps floor and uses superfast. - New 540p, 720p and 900p resolutions. The host's ceiling holds at 1080p and rises in proportion for fewer pixels (720p gets 60 fps); a fixed desktop is only replaced when the wanted size is larger, and captured mouse movement over a shrunk fixed desktop is scaled to its pixels. - The Mac relaunches the Desktop when it asks for a different size than it last launched it at; resuming kept the old desktop size, since a prep command runs only at a launch. - Latch Host answers the PIN as soon as the engine has taken it: Sunshine from October 2026 holds the PIN post open until the handshake ends, which the Mac only started once Latch Host had answered. - Clipboard with Linux: Ctrl+Insert / Shift+Delete / Shift+Insert, and pasted text also in the X selection. - A Mac can be only a viewer (Share this Mac off). - The Mac bundle is staged in dist.noindex (release workflow follows). - connect_real: an ignored live test through the window's connect path. Verified live against stuzha (Sunshine 2026.1014.154843): every size relaunches and resizes correctly, absolute and relative pointer land on the exact pixel, keys reach a terminal, 720p and 540p hold ~60 fps with scrolling text (1080p 30), and a new Mac pairs once. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 20 +- .gitignore | 1 + CHANGELOG.md | 69 ++++++ Cargo.lock | 1 + crates/client/src/app.rs | 1 + crates/client/src/clipboard.rs | 109 +++++++++ crates/client/src/config.rs | 35 ++- crates/client/src/input.rs | 138 ++++++++++- crates/client/src/session.rs | 363 +++++++++++++++++++++++++++- crates/client/src/settings.rs | 9 +- crates/client/src/stream.rs | 101 +++++++- crates/core/src/api.rs | 39 +++ crates/core/src/screens.rs | 14 +- crates/host/Cargo.toml | 5 + crates/host/src/app.rs | 99 +++++++- crates/host/src/clipboard.rs | 21 +- crates/host/src/main.rs | 4 +- crates/host/src/service.rs | 85 ++++++- crates/host/src/setup.rs | 85 ++++++- crates/host/src/streamer.rs | 198 +++++++++++---- deploy/native/README.md | 28 +++ deploy/native/latch-display.service | 2 + deploy/native/latch-engine.service | 4 + deploy/node/entrypoint.sh | 9 +- deploy/node/selftest.sh | 3 +- docs/MACOS.md | 33 ++- scripts/bundle-macos.sh | 6 +- scripts/install-macos.sh | 5 +- 28 files changed, 1347 insertions(+), 140 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6501c90..198e58c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,9 +36,9 @@ jobs: run: bash scripts/bundle-macos.sh - name: package run: | - 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 + cp docs/MACOS.md dist.noindex/README-MACOS.md + cp LICENSE NOTICE dist.noindex/ + cd dist.noindex && 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 @@ -46,17 +46,17 @@ jobs: # adopt_legacy_bundle). Nothing in 4.1 or later reads this asset. - name: package for 4.0 updaters run: | - mkdir dist/legacy - ditto dist/Latch.app dist/legacy/BroLink.app - cp docs/MACOS.md dist/legacy/README-MACOS.md - cp LICENSE NOTICE dist/legacy/ - cd dist/legacy && tar czf ../brolink-macos-arm64.tar.gz BroLink.app README-MACOS.md LICENSE NOTICE + mkdir dist.noindex/legacy + ditto dist.noindex/Latch.app dist.noindex/legacy/BroLink.app + cp docs/MACOS.md dist.noindex/legacy/README-MACOS.md + cp LICENSE NOTICE dist.noindex/legacy/ + cd dist.noindex/legacy && tar czf ../brolink-macos-arm64.tar.gz BroLink.app README-MACOS.md LICENSE NOTICE - uses: actions/upload-artifact@v4 with: name: latch-macos-arm64 path: | - dist/latch-macos-arm64.tar.gz - dist/brolink-macos-arm64.tar.gz + dist.noindex/latch-macos-arm64.tar.gz + dist.noindex/brolink-macos-arm64.tar.gz if-no-files-found: error windows: diff --git a/.gitignore b/.gitignore index d23ab66..6a04eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /target /dist +/dist.noindex **/*.rs.bk .idea/ .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d430318..f688e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,75 @@ The release workflow publishes the section that matches the tag as the GitHub release notes, so each version gets a heading of the form `## X.Y.Z (date)`. +## Unreleased + +- **Resolutions below 1080p: 900p, 720p and 540p** (1600 × 900, 1280 × 720, + 960 × 540), for a machine that encodes in software or a slow network. + The frame-rate ceiling a small-CPU host reports (30 fps) now holds at + 1080p and rises in proportion for fewer pixels, so 720p gets 60 fps + from a two-core VPS; larger sizes keep 30. A host with a fixed desktop + is still never asked for more than its own size, but a smaller size is + now honoured (scaled down) instead of replaced by the desktop's. + Windows PCs on the Virtual Display Driver need **Repair setup** once so + the driver lists the three new sizes. +- **A new size starts the Desktop again.** Connecting while the PC's + Desktop still ran from an earlier stream resumed it, and a Linux desktop + that resizes itself to the viewer only does so at a launch: a 540p stream + showed the 3024 x 1964 desktop scaled down, with captured mouse movement + crossing it at a third of the speed. The Mac now ends and relaunches the + Desktop whenever it asks for a different size than it last launched it + at (or does not know), and resumes it at the same size. Games and other + apps are never ended for this. +- Captured mouse movement over a desktop of fixed size that the stream + shrinks (a 1080p VPS desktop streamed at 720p) is scaled to the + desktop's pixels, so it keeps the Mac's speed. +- **Pairing with Sunshine from October 2026.** Those engines keep the PIN + request open until the Mac has finished the pairing handshake, and the + Mac only went on with it once Latch Host had answered, so pairing a new + Mac timed out. Latch Host now answers as soon as the engine has taken the + PIN, and answers the Mac's retries for that PIN without asking the + engine again. +- `connect_real` (an ignored test in `crates/client/src/session.rs`) + connects to a machine through the window's own path at a list of sizes, + with optional pointer probes, typing and frame-rate sampling. +- **Clipboard with a Linux desktop.** ⌘C, ⌘X and ⌘V reach a Linux machine + as Ctrl+Insert, Shift+Delete and Shift+Insert, the shortcuts GTK, Qt, + Firefox, Chromium and the terminals all honour. They were Ctrl+C and + Ctrl+V, which in a terminal interrupt the running program and type a + literal instead of copying and pasting. Held modifiers are let go for + the chord, so ⌘ held as Ctrl does not turn a paste into Ctrl+Shift+Insert. + Latch Host on Linux puts pasted text in the selection as well as the + clipboard, since xterm and the VTE terminals paste the selection on + Shift+Insert, and says so in `/v1/clipboard` (`selection`); against an + older Linux host the Mac keeps Ctrl+V for paste, which would otherwise + paste the terminal's last selection. +- **A VPS desktop streams at its own size and a frame rate it can keep.** + Latch Host reports the X screen it captures and, when it encodes in + software on fewer than four cores, a 30 fps ceiling; the Mac asks for + exactly that. "Match screen" used to ask a 1920 × 1080 desktop for the + Mac's size, which Sunshine met by scaling the same pixels up (blurrier, + and more work for the encoder). On a two-core VPS, 60 fps desktop + sessions averaged 33 to 47 encoded frames a second; Sunshine took 136% + CPU for a still desktop at 60 fps and 78% for a busier one at 30. +- On Linux the engine no longer holds a 60 fps floor + (`minimum_fps_target = 0`): X11 capture already delivers a frame every + interval, so the floor could only re-encode the last picture whenever a + frame was done early. Part of the CPU the 30 fps ceiling frees goes to + the `superfast` preset, whose keyframes keep text legible (QP 21 instead + of 38 at 20 Mbps, for 19% more encoding time a frame). Existing + hosts pick both up once (`stream-profile-v4`) while nothing streams, + checked through GameStream's `/serverinfo`, which reports the running + app; `/api/apps` does not. +- The native VPS units give the engine and the X server ten times the CPU + share of other user services (`CPUWeight=1000`). +- **A Mac can be only a viewer.** On a Mac, **Share this Mac** on the + Sharing page (stored as the old start-at-login switch) decides whether + Latch runs in the background. Off, opening Latch no longer starts the + background service or the streaming engine (a service started anyway, + say by an older window, exits at once), turning it off stops both, and + nothing of Latch runs once the window closes. Connecting to other + machines needs neither. + ## 4.1.1 (2026-10-02) - Setting up a Windows PC that an earlier version had set up (when the diff --git a/Cargo.lock b/Cargo.lock index a5c05c9..251adfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2176,6 +2176,7 @@ dependencies = [ "tracing-subscriber", "windows 0.58.0", "winresource", + "x11rb", ] [[package]] diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index 34ed685..19df712 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -2154,6 +2154,7 @@ pub(crate) mod snapshots { api_ok: true, encoder: "nvenc".into(), audio_problem: String::new(), + ..Default::default() }, ..Default::default() } diff --git a/crates/client/src/clipboard.rs b/crates/client/src/clipboard.rs index 264581c..fb7592f 100644 --- a/crates/client/src/clipboard.rs +++ b/crates/client/src/clipboard.rs @@ -8,7 +8,12 @@ //! the Mac's clipboard; and a paste first sends the Mac's text to the PC, //! then presses Ctrl+V there. Hosts before 3.1 have no such route; the //! worker notices the 404 and says so once. +//! +//! A Linux desktop is sent the X11 shortcuts instead (see [`Shortcuts`]): +//! in its terminals Ctrl+C interrupts the running program and Ctrl+V types +//! a literal, so ⌘C and ⌘V did harm there instead of copying. +use crate::input::{VK_C, VK_CONTROL, VK_DELETE, VK_INSERT, VK_SHIFT, VK_V, VK_X}; use latch_core::api::{Ack, Clipboard, CLIPBOARD_PATH}; use latch_core::{http, CONTROL_PORT}; use parking_lot::Mutex; @@ -23,6 +28,59 @@ const POLL: Duration = Duration::from_millis(1500); const POKE_DELAY: Duration = Duration::from_millis(400); const TIMEOUT: Duration = Duration::from_secs(5); +/// A clipboard shortcut pressed on the Mac and replayed on the PC. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edit { + Copy, + Cut, + Paste, +} + +/// How the PC spells copy, cut and paste. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Shortcuts { + /// What ⌘ stands for there (Ctrl, or the Windows key) with C, X or V: + /// Windows and macOS. + #[default] + Letters, + /// Ctrl+Insert, Shift+Delete and Shift+Insert: the X11 shortcuts GTK, + /// Qt, Firefox, Chromium, Electron and the terminals all honour. Ctrl+C + /// in a terminal interrupts the running program and Ctrl+V types a + /// literal. Shift+Insert pastes the selection in xterm and VTE + /// terminals, which is why Latch Host puts pasted text in both (and + /// why paste stays Ctrl+V with a host that does not say it does). + Insert, +} + +impl Shortcuts { + /// From Tailscale's OS string for the PC. + pub fn for_os(os: &str) -> Self { + if os.eq_ignore_ascii_case("linux") { + Shortcuts::Insert + } else { + Shortcuts::Letters + } + } + + /// The chord for `edit`, given what ⌘ stands for on the PC, and whether + /// it must arrive without other held modifiers: ⌘ held as Ctrl would + /// turn Shift+Insert into Ctrl+Shift+Insert. `selection` is whether the + /// host puts pasted text in the X selection; a Linux host that does not + /// (4.1.1 and older) keeps Ctrl+V for paste, since Shift+Insert in a + /// terminal would paste whatever was last selected there. + pub fn keys(self, edit: Edit, modifier: i16, selection: bool) -> (Vec, bool) { + match (self, edit) { + (Shortcuts::Insert, Edit::Paste) if !selection => (vec![modifier, VK_V], false), + (Shortcuts::Letters, Edit::Copy) => (vec![modifier, VK_C], false), + (Shortcuts::Letters, Edit::Cut) => (vec![modifier, VK_X], false), + (Shortcuts::Letters, Edit::Paste) => (vec![modifier, VK_V], false), + (Shortcuts::Insert, Edit::Copy) => (vec![VK_CONTROL, VK_INSERT], true), + (Shortcuts::Insert, Edit::Cut) => (vec![VK_SHIFT, VK_DELETE], true), + (Shortcuts::Insert, Edit::Paste) => (vec![VK_SHIFT, VK_INSERT], true), + } + } +} + #[derive(Default)] struct Shared { /// Text the PC copied that the window has not taken yet. @@ -32,6 +90,8 @@ struct Shared { last_seq: Option, /// The host answered 404: older than 3.1. unsupported: bool, + /// The host puts pasted text in the X selection too (`Clipboard::selection`). + selection: bool, /// Something the window can show about the last exchange. note: Option<(String, Instant)>, pokes: u32, @@ -83,6 +143,12 @@ impl Sync { self.shared.lock().unsupported } + /// Whether the host has said it puts pasted text in the X selection, + /// which is what makes Shift+Insert paste it in a terminal. + pub fn pastes_to_selection(&self) -> bool { + self.shared.lock().selection + } + /// A short line for the toolbar, while it is fresh. pub fn note(&self) -> Option { let sh = self.shared.lock(); @@ -201,6 +267,7 @@ fn poll_loop(ip: Ipv4Addr, shared: Arc>, stop: Arc, ct Ok(c) => { failures = 0; let mut sh = shared.lock(); + sh.selection = c.selection; if !primed { // The PC's existing clipboard is not a copy that happened // during this stream. Seed last/seq so we do not overwrite @@ -257,6 +324,48 @@ pub fn worth_sending(text: &str) -> bool { mod tests { use super::*; + #[test] + fn a_linux_pc_gets_the_insert_shortcuts_and_the_rest_keep_the_letters() { + use crate::input::VK_LWIN; + assert_eq!(Shortcuts::for_os("linux"), Shortcuts::Insert); + assert_eq!(Shortcuts::for_os("windows"), Shortcuts::Letters); + assert_eq!(Shortcuts::for_os("macOS"), Shortcuts::Letters); + assert_eq!(Shortcuts::for_os(""), Shortcuts::Letters); + let linux = Shortcuts::Insert; + assert_eq!( + linux.keys(Edit::Copy, VK_LWIN, true), + (vec![VK_CONTROL, VK_INSERT], true) + ); + assert_eq!( + linux.keys(Edit::Cut, VK_CONTROL, true), + (vec![VK_SHIFT, VK_DELETE], true) + ); + assert_eq!( + linux.keys(Edit::Paste, VK_CONTROL, true), + (vec![VK_SHIFT, VK_INSERT], true) + ); + // A host that leaves the selection alone: Shift+Insert would paste + // the terminal's last selection, so paste stays Ctrl+V. Copy and + // cut need nothing from the host. + assert_eq!( + linux.keys(Edit::Paste, VK_CONTROL, false), + (vec![VK_CONTROL, VK_V], false) + ); + assert_eq!( + linux.keys(Edit::Copy, VK_CONTROL, false), + (vec![VK_CONTROL, VK_INSERT], true) + ); + let windows = Shortcuts::Letters; + assert_eq!( + windows.keys(Edit::Copy, VK_CONTROL, true), + (vec![VK_CONTROL, VK_C], false) + ); + assert_eq!( + windows.keys(Edit::Paste, VK_LWIN, false), + (vec![VK_LWIN, VK_V], false) + ); + } + #[test] fn a_push_of_the_same_text_runs_the_paste_at_once() { let s = Sync { diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 8c781f0..5e9f7a6 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -8,6 +8,9 @@ pub const FILE: &str = "client.toml"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Resolution { + P540, + P720, + P900, P1080, P1440, P2160, @@ -16,12 +19,26 @@ pub enum Resolution { } impl Resolution { + /// Every choice, in the order the list shows them. + pub const ALL: [Resolution; 7] = [ + Resolution::Native, + Resolution::P540, + Resolution::P720, + Resolution::P900, + Resolution::P1080, + Resolution::P1440, + Resolution::P2160, + ]; + /// This choice applied to a screen of `native` pixels: the standard /// 16:9 sizes are exactly that, and "Match screen" is the screen /// itself. See `latch_core::screens`. pub fn pixels(self, native: (u32, u32)) -> (u32, u32) { - let [p1080, p1440, p2160] = latch_core::screens::STANDARD; + let [p540, p720, p900, p1080, p1440, p2160] = latch_core::screens::STANDARD; match self { + Resolution::P540 => p540, + Resolution::P720 => p720, + Resolution::P900 => p900, Resolution::P1080 => p1080, Resolution::P1440 => p1440, Resolution::P2160 => p2160, @@ -92,6 +109,9 @@ impl Preset { impl Resolution { pub fn label(self) -> &'static str { match self { + Resolution::P540 => "540p", + Resolution::P720 => "720p", + Resolution::P900 => "900p", Resolution::P1080 => "1080p", Resolution::P1440 => "1440p", Resolution::P2160 => "4K", @@ -308,6 +328,9 @@ mod tests { assert_eq!(Resolution::P1080.pixels((3024, 1964)), (1920, 1080)); assert_eq!(Resolution::P1440.pixels((3024, 1964)), (2560, 1440)); assert_eq!(Resolution::P2160.pixels((1920, 1080)), (3840, 2160)); + assert_eq!(Resolution::P900.pixels((3024, 1964)), (1600, 900)); + assert_eq!(Resolution::P720.pixels((3024, 1964)), (1280, 720)); + assert_eq!(Resolution::P540.pixels((3024, 1964)), (960, 540)); assert_eq!( Resolution::Native.describe((3024, 1964)), "Match screen · 3024 × 1964" @@ -316,13 +339,9 @@ mod tests { Resolution::P1080.describe((3024, 1964)), "1080p · 1920 × 1080" ); + assert_eq!(Resolution::P720.describe((3024, 1964)), "720p · 1280 × 720"); let modes = latch_core::screens::stream_modes(); - for r in [ - Resolution::P1080, - Resolution::P1440, - Resolution::P2160, - Resolution::Native, - ] { + for r in Resolution::ALL { for &screen in latch_core::screens::SCREENS { assert!( modes.contains(&r.pixels(screen)), @@ -339,6 +358,8 @@ mod tests { let c: ClientConfig = toml::from_str("[stream]\nfps = 90\n").unwrap(); assert_eq!(c.stream.quality, Quality::Auto); assert_eq!(c.stream.fps, 90); + let c: ClientConfig = toml::from_str("[stream]\nresolution = \"P720\"\n").unwrap(); + assert_eq!(c.stream.resolution, Resolution::P720); let mut st = StreamSettings::default(); assert_eq!(st.preset(), Some(Preset::Balanced)); st.apply_preset(Preset::Smooth); diff --git a/crates/client/src/input.rs b/crates/client/src/input.rs index 611d537..16af58d 100644 --- a/crates/client/src/input.rs +++ b/crates/client/src/input.rs @@ -13,6 +13,7 @@ pub const VK_SHIFT: i16 = 0x10; pub const VK_CONTROL: i16 = 0x11; pub const VK_MENU: i16 = 0x12; pub const VK_LWIN: i16 = 0x5B; +pub const VK_INSERT: i16 = 0x2D; pub const VK_DELETE: i16 = 0x2E; pub const VK_ESCAPE: i16 = 0x1B; pub const VK_TAB: i16 = 0x09; @@ -36,7 +37,7 @@ pub fn vk(key: Key) -> Option { Backspace => 0x08, Enter => 0x0D, Space => 0x20, - Insert => 0x2D, + Insert => VK_INSERT, Delete => VK_DELETE, Home => 0x24, End => 0x23, @@ -128,19 +129,72 @@ pub fn button(b: PointerButton) -> Option { }) } +/// One key event as Moonlight takes it: the key, down or up, and the +/// modifier mask sent with it. +type KeyEvent = (i16, bool, c_char); + +/// The modifier keys, in the order their mask bits are listed. +const MODIFIERS: [i16; 4] = [VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN]; + +fn send(input: &Input, events: &[KeyEvent]) { + for &(vk, down, mask) in events { + input.key(vk, down, mask); + } +} + /// Press `keys` in order and release them in reverse, with `mask` for the /// modifiers already held. Usable from any thread, which is what a paste /// that first has to reach the PC's clipboard needs. pub fn press_chord(input: &Input, keys: &[i16], mask: c_char) { + send(input, &chord_events(keys, mask)); +} + +fn chord_events(keys: &[i16], mask: c_char) -> Vec { + let mut events = Vec::new(); let mut held = Vec::new(); for &k in keys { held.push(k); - input.key(k, true, mask | mask_of(&held)); + events.push((k, true, mask | mask_of(&held))); } for &k in keys.iter().rev() { held.retain(|&h| h != k); - input.key(k, false, mask | mask_of(&held)); + events.push((k, false, mask | mask_of(&held))); + } + events +} + +/// [`press_chord`] for a chord that must arrive with no other modifier: +/// those in `mask` that `keys` does not use are let go first, so ⌘ held as +/// Ctrl does not turn Shift+Insert into Ctrl+Shift+Insert. They stay up; +/// the window presses a modifier the user still holds back down once the +/// paste is done (see `clipboard::Sync::pastes_done`). +pub fn press_chord_alone(input: &Input, keys: &[i16], mask: c_char) { + send(input, &alone_events(keys, keys, mask, false)); +} + +/// The events that send `keys` with no other modifier down. Modifiers in +/// `mask` outside `keys` are released first and, with `restore`, pressed +/// again after; `press` is the part of `keys` to press, all of it when +/// `mask` may be stale (a worker thread's copy). +fn alone_events(keys: &[i16], press: &[i16], mask: c_char, restore: bool) -> Vec { + let others: Vec = MODIFIERS + .into_iter() + .filter(|&vk| mask & mask_of(&[vk]) != 0 && !keys.contains(&vk)) + .collect(); + let mut mask = mask; + let mut events = Vec::new(); + for &vk in &others { + mask &= !mask_of(&[vk]); + events.push((vk, false, mask)); + } + events.extend(chord_events(press, mask)); + if restore { + for &vk in &others { + mask |= mask_of(&[vk]); + events.push((vk, true, mask)); + } } + events } fn mask_of(keys: &[i16]) -> c_char { @@ -240,6 +294,17 @@ impl Held { press_chord(input, &fresh, self.mask()); } + /// [`Held::chord`] for a chord that must arrive with no other modifier: + /// held modifiers it does not use are let go for it and pressed again + /// after, so the PC ends up holding what the user holds. + pub fn chord_alone(&mut self, input: &Input, keys: &[i16]) { + send(input, &self.chord_alone_events(keys)); + } + + fn chord_alone_events(&self, keys: &[i16]) -> Vec { + alone_events(keys, &self.chord_plan(keys), self.mask(), true) + } + /// The keys of `keys` a chord would have to press: those not held. pub fn chord_plan(&self, keys: &[i16]) -> Vec { keys.iter() @@ -331,6 +396,73 @@ mod tests { assert_eq!(mask_of(&[VK_C]), 0); } + #[test] + fn a_chord_alone_lets_go_of_other_modifiers_and_restores_them() { + // ⌘ is down as Ctrl. Copy on Linux is Ctrl+Insert: Ctrl stays down. + let mut held = Held::default(); + held.keys.insert(VK_CONTROL); + assert_eq!( + held.chord_alone_events(&[VK_CONTROL, VK_INSERT]), + vec![ + (VK_INSERT, true, MODIFIER_CTRL), + (VK_INSERT, false, MODIFIER_CTRL) + ] + ); + // Cut is Shift+Delete: Ctrl+Shift+Delete would be another shortcut + // (Firefox clears history), so Ctrl goes up for it and back down. + assert_eq!( + held.chord_alone_events(&[VK_SHIFT, VK_DELETE]), + vec![ + (VK_CONTROL, false, 0), + (VK_SHIFT, true, MODIFIER_SHIFT), + (VK_DELETE, true, MODIFIER_SHIFT), + (VK_DELETE, false, MODIFIER_SHIFT), + (VK_SHIFT, false, 0), + (VK_CONTROL, true, MODIFIER_CTRL), + ] + ); + // ⌘ as the Windows key: Super+Ctrl+Insert would copy nothing. + let mut held = Held::default(); + held.keys.insert(VK_LWIN); + assert_eq!( + held.chord_alone_events(&[VK_CONTROL, VK_INSERT]), + vec![ + (VK_LWIN, false, 0), + (VK_CONTROL, true, MODIFIER_CTRL), + (VK_INSERT, true, MODIFIER_CTRL), + (VK_INSERT, false, MODIFIER_CTRL), + (VK_CONTROL, false, 0), + (VK_LWIN, true, MODIFIER_META), + ] + ); + } + + #[test] + fn a_paste_alone_from_the_worker_presses_the_whole_chord_and_restores_nothing() { + // The mask is the worker's copy from when ⌘V arrived; the window + // puts Ctrl back once the paste is counted. + assert_eq!( + alone_events( + &[VK_SHIFT, VK_INSERT], + &[VK_SHIFT, VK_INSERT], + MODIFIER_CTRL, + false + ), + vec![ + (VK_CONTROL, false, 0), + (VK_SHIFT, true, MODIFIER_SHIFT), + (VK_INSERT, true, MODIFIER_SHIFT), + (VK_INSERT, false, MODIFIER_SHIFT), + (VK_SHIFT, false, 0), + ] + ); + assert_eq!( + alone_events(&[VK_CONTROL, VK_V], &[VK_CONTROL, VK_V], 0, false), + chord_events(&[VK_CONTROL, VK_V], 0), + "nothing held: the same as a plain chord" + ); + } + #[test] fn letters_digits_and_symbols_map_to_us_layout_codes() { assert_eq!(vk(Key::A), Some(0x41)); diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index ef00e62..17196af 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -454,12 +454,18 @@ pub struct Live { pub started: Instant, pub codec: &'static str, pub requested: (u32, u32, u32), + /// The PC's desktop, when it is a fixed size other than the stream's + /// (the stream is that desktop scaled down). Relative mouse movement + /// lands in its pixels, not the stream's. + pub desktop: Option<(u32, u32)>, /// The settings the stream was started with, after Auto had its say. pub settings: StreamSettings, /// The path as measured right before connecting. pub path: Path, /// Clipboard both ways, through Latch Host on the PC. pub clipboard: clipboard::Sync, + /// How the PC spells copy, cut and paste. + pub shortcuts: clipboard::Shortcuts, } impl Drop for Live { @@ -481,6 +487,12 @@ pub struct Target { pub server_cert: Option>, /// What discovery last knew about the path; re-measured at connect. pub path: Path, + /// Tailscale's OS string for the PC. + pub os: String, + /// The PC's fixed capture size and frame-rate ceiling, from its last + /// status; see [`fit_to_host`]. + pub capture_size: Option<[u32; 2]>, + pub max_fps: u32, } impl Target { @@ -501,10 +513,75 @@ impl Target { .and_then(|k| k.server_cert.as_deref()) .and_then(latch_stream::nvhttp::unhex), path: pc.path.clone(), + os: pc.os.clone(), + capture_size: pc.host.as_ref().and_then(|h| h.streamer.capture_size), + max_fps: pc.host.as_ref().map_or(0, |h| h.streamer.max_fps), }) } } +/// What to ask a PC for, from what was wanted: its own capture size when +/// that is fixed and the wanted size is larger in either direction +/// (Sunshine would only scale the same desktop up, blurring it and costing +/// the encoder more; a smaller size is scaled down and is cheaper to +/// encode), and no more frames a second than it keeps up with. The host's +/// ceiling is for its capture size, or 1920 × 1080 when that is not fixed; +/// a smaller picture gets a ceiling higher in proportion to the pixels it +/// saves, since software encoding time follows the pixel count. A larger +/// one keeps the host's ceiling. Implausible sizes are ignored. +pub fn fit_to_host( + wanted: (u32, u32), + fps: u32, + capture_size: Option<[u32; 2]>, + max_fps: u32, +) -> (u32, u32, u32) { + let fixed = fixed_desktop(capture_size); + let (w, h) = match fixed { + Some(c) if wanted.0 > c.0 || wanted.1 > c.1 => c, + _ => wanted, + }; + let fps = if max_fps > 0 { + let (rw, rh) = fixed.unwrap_or((1920, 1080)); + let budget = u64::from(max_fps) * u64::from(rw) * u64::from(rh); + let ceiling = (budget / (u64::from(w) * u64::from(h)).max(1)).max(max_fps.into()); + fps.min(u32::try_from(ceiling).unwrap_or(u32::MAX)) + } else { + fps + }; + (w, h, fps) +} + +/// A host's fixed capture size, when it reported a plausible one. +fn fixed_desktop(capture_size: Option<[u32; 2]>) -> Option<(u32, u32)> { + match capture_size { + Some([w, h]) if (320..=8192).contains(&w) && (240..=8192).contains(&h) => Some((w, h)), + _ => None, + } +} + +/// The size each PC's Desktop was last launched at from here, by node. +static LAUNCHED: Mutex> = Mutex::new(BTreeMap::new()); + +/// Whether the running app has to be ended before this launch. Another app +/// always does. The Desktop does when asked to (a broken capture) and when +/// it may be running at another size: resuming keeps the PC's display as +/// the launch left it, since a host's prep command (a Linux desktop +/// resizing itself to the viewer) runs only at a launch, so a stream at a +/// new size would be the old desktop scaled, with the mouse moving over +/// the old one. A Desktop this Mac did not launch, or launched before it +/// was last opened, is of unknown size. +fn must_relaunch( + current_game: u32, + app_id: u32, + desktop: bool, + restart_capture: bool, + launched_at: Option<(u32, u32)>, + size: (u32, u32), +) -> bool { + current_game != 0 + && (current_game != app_id || (desktop && (restart_capture || launched_at != Some(size)))) +} + pub struct Connect { pub target: Target, pub settings: StreamSettings, @@ -663,12 +740,20 @@ fn run(c: &Connect, generation: u64) -> Result<()> { // carry. report(Step::Launching, "Measuring the path…".into()); let path = measure_path(&t.node_id, t.ip, &t.path); - let settings = path::effective(&c.settings); + let mut settings = path::effective(&c.settings); + let wanted = settings.resolution.pixels(c.native); + let (w, h, fps) = fit_to_host(wanted, settings.fps, t.capture_size, t.max_fps); + settings.fps = fps; tracing::info!( - "path to {}: {} · {}", + "path to {}: {} · {}{}", t.name, path.label(), - settings.describe() + settings.describe(), + if (w, h) != wanted { + format!(" · {w} × {h}, the size {} draws", t.name) + } else { + String::new() + } ); report(Step::Launching, "Starting the stream…".into()); let apps = client.app_list()?; @@ -679,10 +764,16 @@ fn run(c: &Connect, generation: u64) -> Result<()> { .or_else(|| apps.iter().find(|a| a.title == "Desktop")) .or_else(|| apps.first()) .ok_or_else(|| anyhow!("{} offers nothing to stream", t.name))?; - if info.current_game != 0 - && (info.current_game != app.id - || (c.restart_capture && app.title.eq_ignore_ascii_case("Desktop"))) - { + let desktop = app.title.eq_ignore_ascii_case("Desktop"); + let launched_at = LAUNCHED.lock().get(&t.node_id).copied(); + if must_relaunch( + info.current_game, + app.id, + desktop, + c.restart_capture, + launched_at, + (w, h), + ) { client.quit()?; let stopped = Instant::now(); loop { @@ -702,8 +793,6 @@ fn run(c: &Connect, generation: u64) -> Result<()> { std::thread::sleep(Duration::from_millis(250)); } } - let (w, h) = settings.resolution.pixels(c.native); - let fps = settings.fps; let ri_key: [u8; 16] = rand::random(); let ri_id: u32 = rand::random(); let mut ri_iv = [0u8; 16]; @@ -718,6 +807,9 @@ fn run(c: &Connect, generation: u64) -> Result<()> { ri_id, info.current_game != 0, )?; + if desktop { + LAUNCHED.lock().insert(t.node_id.clone(), (w, h)); + } // 5. Connect. if stale(&c.progress, generation) { @@ -766,9 +858,11 @@ fn run(c: &Connect, generation: u64) -> Result<()> { started: Instant::now(), codec: if hevc { "HEVC" } else { "H.264" }, requested: (w, h, fps), + desktop: fixed_desktop(t.capture_size).filter(|&d| d != (w, h)), settings, path, clipboard: clipboard::Sync::spawn(t.ip, c.ctx.clone()), + shortcuts: clipboard::Shortcuts::for_os(&t.os), }); report(Step::Connecting, "Connecting…".into()); Ok(()) @@ -827,7 +921,9 @@ fn submit_pin(ip: Ipv4Addr, pin: &str, progress: &Mutex, ctx: &egui::C if progress.lock().cancel { return; } - match http::post_json::<_, Ack>((ip, CONTROL_PORT), "/v1/pin", &req, Duration::from_secs(1)) + // Latch Host answers once the engine has the PIN: up to a little + // over half a second with an engine that holds the post open. + match http::post_json::<_, Ack>((ip, CONTROL_PORT), "/v1/pin", &req, Duration::from_secs(3)) { Ok(a) if a.ok => return, Ok(a) => { @@ -896,6 +992,127 @@ pub fn wake_only(pc: &Pc) -> Result { mod tests { use super::*; + #[test] + fn a_desktop_running_at_another_size_is_launched_again() { + const DESKTOP: u32 = 7; + let p720 = (1280, 720); + // Nothing running: a plain launch. + assert!(!must_relaunch(0, DESKTOP, true, true, None, p720)); + // Launched from here at this size: resumed. + assert!(!must_relaunch( + DESKTOP, + DESKTOP, + true, + false, + Some(p720), + p720 + )); + // At another size, or at a size this Mac does not know: launched + // again, so the PC's display follows the new size. + assert!(must_relaunch( + DESKTOP, + DESKTOP, + true, + false, + Some((3024, 1964)), + p720 + )); + assert!(must_relaunch(DESKTOP, DESKTOP, true, false, None, p720)); + // A broken capture, or another app, always. + assert!(must_relaunch( + DESKTOP, + DESKTOP, + true, + true, + Some(p720), + p720 + )); + assert!(must_relaunch(9, DESKTOP, true, false, Some(p720), p720)); + // A game is never ended for a size change. + assert!(!must_relaunch(9, 9, false, false, None, p720)); + assert!(!must_relaunch(9, 9, false, true, None, p720)); + } + + #[test] + fn a_fixed_desktop_is_asked_for_at_its_own_size_and_frame_rate() { + // A VPS draws 1920 × 1080 and encodes in software on two cores. + let vps = (Some([1920, 1080]), 30); + assert_eq!( + fit_to_host((2284, 1208), 60, vps.0, vps.1), + (1920, 1080, 30) + ); + assert_eq!( + fit_to_host((1920, 1080), 30, vps.0, vps.1), + (1920, 1080, 30) + ); + assert_eq!( + fit_to_host((3840, 2160), 24, vps.0, vps.1), + (1920, 1080, 24) + ); + assert_eq!( + fit_to_host((2560, 1080), 30, vps.0, vps.1), + (1920, 1080, 30), + "wider than the desktop is scaling up too" + ); + // A smaller size is scaled down, and fewer pixels keep up with more + // frames: 720p is 2.25 times fewer than 1080p. + assert_eq!(fit_to_host((1280, 720), 60, vps.0, vps.1), (1280, 720, 60)); + assert_eq!(fit_to_host((1600, 900), 60, vps.0, vps.1), (1600, 900, 43)); + assert_eq!(fit_to_host((960, 540), 144, vps.0, vps.1), (960, 540, 120)); + // A desktop that takes the viewer's size: the ceiling is for 1080p. + let follows = (None, 30); + assert_eq!( + fit_to_host((1280, 720), 60, follows.0, follows.1), + (1280, 720, 60) + ); + assert_eq!( + fit_to_host((1920, 1080), 60, follows.0, follows.1), + (1920, 1080, 30) + ); + assert_eq!( + fit_to_host((3024, 1964), 60, follows.0, follows.1), + (3024, 1964, 30), + "a larger picture keeps the host's ceiling" + ); + // A Windows PC matches the viewer's size itself and has no ceiling. + assert_eq!(fit_to_host((2284, 1208), 60, None, 0), (2284, 1208, 60)); + // Nonsense from a host is ignored. + assert_eq!( + fit_to_host((1920, 1080), 60, Some([0, 0]), 0), + (1920, 1080, 60) + ); + assert_eq!( + fit_to_host((1920, 1080), 60, Some([100_000, 1080]), 0), + (1920, 1080, 60) + ); + } + + #[test] + fn a_target_carries_the_hosts_limits_and_os() { + let pc = Pc { + node_id: "n".into(), + name: "artemis".into(), + os: "linux".into(), + ip: Some("100.64.0.9".parse().unwrap()), + online: true, + host: Some(Status { + streamer: latch_core::api::Streamer { + capture_size: Some([1920, 1080]), + max_fps: 30, + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }; + let t = Target::from_pc(&pc).unwrap(); + assert_eq!(t.os, "linux"); + assert_eq!(t.capture_size, Some([1920, 1080])); + assert_eq!(t.max_fps, 30); + let old = Target::from_pc(&Pc { host: None, ..pc }).unwrap(); + assert_eq!((old.capture_size, old.max_fps), (None, 0)); + } + #[test] fn update_reservation_blocks_connect_in_every_idle_state() { for step in [Step::Idle, Step::Ended { error: None }] { @@ -988,6 +1205,132 @@ mod tests { } /// `cargo test -p latch-client scan_real -- --ignored --nocapture` + /// Connects to `LATCH_TEST_PC` (its Tailscale name) through the + /// window's own path, once for each of `LATCH_TEST_RESOLUTIONS` in turn + /// ("540p,720p,1080p,native"), with this Mac's identity and pairing, and + /// leaves the PC's Desktop running as the window does. At each size it + /// puts the PC's pointer at the stream's centre and a quarter in, then + /// moves it 100 stream pixels right, running `LATCH_TEST_PROBE` (a shell + /// command, say one that prints the pointer and the display size over + /// ssh) after each step. + #[test] + #[ignore = "needs Tailscale and a paired PC"] + fn connect_real() { + use crate::config::{Quality, Resolution}; + let _ = tracing_subscriber::fmt().with_target(false).try_init(); + let name = std::env::var("LATCH_TEST_PC").expect("LATCH_TEST_PC"); + let probe = || { + let Ok(cmd) = std::env::var("LATCH_TEST_PROBE") else { + return; + }; + std::thread::sleep(Duration::from_millis(400)); + let out = std::process::Command::new("sh") + .args(["-c", &cmd]) + .output() + .expect("probe"); + eprint!(" probe: {}", String::from_utf8_lossy(&out.stdout)); + }; + let list = std::env::var("LATCH_TEST_RESOLUTIONS").unwrap_or_else(|_| "720p".into()); + for label in list.split(',') { + let resolution = Resolution::ALL + .into_iter() + .find(|r| r.label() == label || (label == "native" && *r == Resolution::Native)) + .unwrap_or_else(|| panic!("no resolution {label}")); + // Afresh each time, as the window does, so a pairing made by the + // first connect is used by the next. + let cfg = ClientConfig::load(); + let discovery = scan(&cfg); + let pc = discovery + .pcs + .iter() + .find(|p| p.name == name) + .unwrap_or_else(|| panic!("{name} is not on the tailnet")); + let c = Connect { + target: Target::from_pc(pc).expect("target"), + settings: StreamSettings { + quality: Quality::Custom, + resolution, + fps: 60, + bitrate_kbps: 10_000, + ..cfg.stream.clone() + }, + restart_capture: false, + native: (3024, 1964), + progress: Arc::default(), + live: Arc::default(), + ctx: egui::Context::default(), + }; + let generation = NEXT_CONNECT.fetch_add(1, Ordering::Relaxed); + c.progress.lock().generation = generation; + run(&c, generation).expect("connect"); + let live = c.live.lock().take().expect("a stream"); + let start = Instant::now(); + loop { + match live.events.recv_timeout(Duration::from_secs(20)) { + Ok(Event::Connected) => break, + Ok(Event::Failed { .. } | Event::Terminated { .. }) | Err(_) => { + panic!("no stream at {label}") + } + Ok(_) => {} + } + } + let (w, h, fps) = live.requested; + eprintln!( + "{label}: asked {w}x{h} at {fps} fps, desktop {:?}, connected in {:?}", + live.desktop, + start.elapsed() + ); + std::thread::sleep(Duration::from_secs(2)); + let (iw, ih) = (w as i16, h as i16); + eprintln!(" pointer to the centre, {}x{}", w / 2, h / 2); + live.input.mouse_position(iw / 2, ih / 2, iw, ih); + probe(); + eprintln!(" pointer to a quarter in, {}x{}", w / 4, h / 4); + live.input.mouse_position(iw / 4, ih / 4, iw, ih); + probe(); + eprintln!(" 100 stream pixels right, in steps of 10"); + for _ in 0..10 { + live.input.mouse_move(10, 0); + std::thread::sleep(Duration::from_millis(16)); + } + probe(); + eprintln!(" 100 stream pixels right at once"); + live.input.mouse_move(100, 0); + probe(); + // Letters, digits and spaces as key presses, then Return. + if let Ok(line) = std::env::var("LATCH_TEST_TYPE") { + eprintln!(" typing {line:?}"); + for c in line.chars().map(|c| c.to_ascii_uppercase()) { + assert!(c.is_ascii_alphanumeric() || c == ' ', "can't type {c:?}"); + for down in [true, false] { + live.input.key(c as i16, down, 0); + std::thread::sleep(Duration::from_millis(15)); + } + } + for down in [true, false] { + live.input.key(crate::input::VK_RETURN, down, 0); + } + probe(); + } + let seconds: u64 = + std::env::var("LATCH_TEST_SECONDS").map_or(0, |s| s.parse().expect("seconds")); + for second in 1..=seconds { + std::thread::sleep(Duration::from_secs(1)); + let s = live.session.stats(); + eprintln!( + " {second:>2}s {:.1} fps, {:.1} Mbps, loss {:.1}%, host {:.1} ms", + s.fps, s.mbps, s.loss_pct, s.host_ms + ); + } + live.session.stop(); + let t = Instant::now(); + while !live.session.finished() && t.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(20)); + } + drop(live); + } + } + #[test] #[ignore = "needs Tailscale"] fn scan_real_tailnet() { diff --git a/crates/client/src/settings.rs b/crates/client/src/settings.rs index b9d0a38..d21b18a 100644 --- a/crates/client/src/settings.rs +++ b/crates/client/src/settings.rs @@ -62,16 +62,11 @@ pub fn stream_controls(ui: &mut egui::Ui, s: &mut StreamSettings, native: (u32, ui::setting_row( ui, "Resolution", - Some("Match screen fills this display exactly. The 16:9 sizes leave bars on a display of another shape."), + Some("Match screen fills this display exactly. The 16:9 sizes leave bars on a display of another shape. Below 1080p, a PC that encodes in software keeps a higher frame rate."), |ui| { let mut resolution = s.resolution; ui::select(ui, "stream-resolution", resolution.describe(native), 200.0, |ui| { - for r in [ - Resolution::Native, - Resolution::P1080, - Resolution::P1440, - Resolution::P2160, - ] { + for r in Resolution::ALL { ui::select_option(ui, &mut resolution, r, r.describe(native)); } }); diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 6d5d416..9614ac4 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -10,7 +10,7 @@ //! menus and the panels are only laid out while they show, and every //! timed notice asks for a bounded, slow repaint only while it is up. -use crate::clipboard; +use crate::clipboard::{self, Edit}; use crate::config::{ClientConfig, StreamSettings}; use crate::input::{self, press_chord, Held}; use crate::path::Path; @@ -1079,13 +1079,13 @@ impl View { let ppp = ctx.pixels_per_point(); let motion_scale = { let stats = live.session.stats(); - let stream_w = if stats.width > 0 { - stats.width as f32 + let stream = if stats.width > 0 && stats.height > 0 { + (stats.width, stats.height) } else { - live.requested.0 as f32 + (live.requested.0, live.requested.1) }; if video.width() >= 1.0 { - stream_w / video.width() + stream.0 as f32 / video.width() * desktop_per_stream_pixel(live.desktop, stream) } else { ppp } @@ -1150,11 +1150,11 @@ impl View { } } Event::Copy if keys_to_pc => { - self.clipboard_chord(live, input::VK_C, cfg); + self.clipboard_chord(live, Edit::Copy, cfg); live.clipboard.poke(); } Event::Cut if keys_to_pc => { - self.clipboard_chord(live, input::VK_X, cfg); + self.clipboard_chord(live, Edit::Cut, cfg); live.clipboard.poke(); } Event::Paste(text) if keys_to_pc => { @@ -1162,7 +1162,11 @@ impl View { // what was copied here, not what it had. The chord is // sent whole: by the time the text has crossed the // network a quick tap has let go of ⌘. - let keys = vec![self.clipboard_modifier(cfg), input::VK_V]; + let (keys, alone) = live.shortcuts.keys( + Edit::Paste, + self.clipboard_modifier(cfg), + live.clipboard.pastes_to_selection(), + ); let mask = self.held.mask(); let handle = input.clone(); let text = if clipboard::worth_sending(&text) { @@ -1170,8 +1174,13 @@ impl View { } else { String::new() }; - live.clipboard - .push(text, move || press_chord(&handle, &keys, mask)); + live.clipboard.push(text, move || { + if alone { + input::press_chord_alone(&handle, &keys, mask); + } else { + press_chord(&handle, &keys, mask); + } + }); } Event::PointerMoved(p) if !self.captured && over_video => position = Some(p), Event::MouseMoved(d) if self.captured => { @@ -1251,9 +1260,17 @@ impl View { /// egui swallows Cmd/Ctrl+C/X/V into clipboard events; replay them as /// the key chord the PC expects. - fn clipboard_chord(&mut self, live: &Live, vk: i16, cfg: &ClientConfig) { - let modifier = self.clipboard_modifier(cfg); - self.held.chord(&live.input, &[modifier, vk]); + fn clipboard_chord(&mut self, live: &Live, edit: Edit, cfg: &ClientConfig) { + let (keys, alone) = live.shortcuts.keys( + edit, + self.clipboard_modifier(cfg), + live.clipboard.pastes_to_selection(), + ); + if alone { + self.held.chord_alone(&live.input, &keys); + } else { + self.held.chord(&live.input, &keys); + } } } @@ -1293,6 +1310,19 @@ pub fn fit(area: Rect, aspect: f32) -> Rect { Rect::from_center_size(area.center(), Vec2::new(w.floor(), h.floor())) } +/// How many of the PC's desktop pixels one stream pixel stands for. 1 +/// unless the desktop is a fixed size the stream shrinks, keeping its +/// proportions, to fit inside; relative mouse movement lands in the +/// desktop's pixels, so it is scaled by this too. +fn desktop_per_stream_pixel(desktop: Option<(u32, u32)>, stream: (u32, u32)) -> f32 { + match desktop { + Some((dw, dh)) if stream.0 > 0 && stream.1 > 0 => { + (dw as f32 / stream.0 as f32).max(dh as f32 / stream.1 as f32) + } + _ => 1.0, + } +} + /// The quality profile a stream runs at, named as Settings names it. fn profile_label(s: &StreamSettings) -> String { match (s.quality, s.preset()) { @@ -1360,6 +1390,20 @@ pub(crate) mod tests { assert_eq!(tall.width(), 800.0); } + #[test] + fn mouse_movement_crosses_a_scaled_desktop_at_the_desktops_scale() { + assert_eq!(desktop_per_stream_pixel(None, (1280, 720)), 1.0); + // 720p of a fixed 1080p desktop: each stream pixel is 1.5 of its. + assert_eq!( + desktop_per_stream_pixel(Some((1920, 1080)), (1280, 720)), + 1.5 + ); + // A 16:10 desktop in a 16:9 stream is fitted by its height. + let s = desktop_per_stream_pixel(Some((1920, 1200)), (1280, 720)); + assert!((s - 1200.0 / 720.0).abs() < 1e-6, "{s}"); + assert_eq!(desktop_per_stream_pixel(Some((1920, 1080)), (0, 0)), 1.0); + } + #[test] fn a_3_0_host_is_offered_an_install_through_the_stream() { let latest = Version::new(3, 1, 0); @@ -1476,9 +1520,11 @@ pub(crate) mod tests { started: Instant::now(), codec: "HEVC", requested: (1920, 1080, 60), + desktop: None, settings, path, clipboard: crate::clipboard::Sync::spawn(ip, ctx.clone()), + shortcuts: Default::default(), } } @@ -1999,6 +2045,35 @@ pub(crate) mod tests { h.render().unwrap(), &file_name("stream", "fps-open", min, ppp), ); + // The resolution list, the first select in the panel, reaches 4K. + let view = View { + settings: Some(ClientConfig::default().stream), + ..view_with_bar() + }; + let mut h = harness_with(min, ppp, true, fixture(view, windows_old_host())); + for _ in 0..30 { + if focused(&h, Role::ComboBox) { + break; + } + h.press_key(egui::Key::Tab); + h.run_steps(1); + } + assert!(focused(&h, Role::ComboBox), "Tab reaches the select"); + h.query_all(By::new().predicate(|n| n.is_focused())) + .next() + .unwrap() + .simulate_click(); + h.run_steps(3); + let list = latch_ui::open_menu_rect(&h.ctx).expect("the list is open"); + assert_inside("the resolution list", list, screen); + let last = "4K · 3840 × 2160"; + tab_to(&mut h, last); + let list = latch_ui::open_menu_rect(&h.ctx).expect("Tab keeps the list open"); + assert_inside(last, node_rect(&h.get_by_label(last)), list); + save( + h.render().unwrap(), + &file_name("stream", "resolution-open", min, ppp), + ); } } diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index 2bd8420..7339370 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -56,6 +56,9 @@ pub struct Clipboard { pub seq: u64, /// The text was cut to [`CLIPBOARD_MAX_BYTES`]. pub truncated: bool, + /// A write also sets the X selection (PRIMARY), so Shift+Insert pastes + /// it in a terminal. Linux hosts newer than 4.1.1; false from any other. + pub selection: bool, } impl Clipboard { @@ -69,6 +72,7 @@ impl Clipboard { text: text[..end].to_string(), seq, truncated: end < text.len(), + selection: false, } } } @@ -157,6 +161,19 @@ pub struct Streamer { /// with no monitor or speakers has no audio device, and Sunshine then /// streams silence. pub audio_problem: String, + /// The desktop the engine captures, `[width, height]`, when its size is + /// fixed whatever a viewer asks for: a Linux X screen. Absent when the + /// host matches the viewer's size itself (Windows' virtual display), and + /// from 4.1.1 and older hosts. A viewer asks for no more than this size: + /// a larger one is the same picture scaled up, blurrier and dearer to + /// encode; a smaller one is scaled down and cheaper. + pub capture_size: Option<[u32; 2]>, + /// The highest frame rate the engine keeps up with on this machine at + /// `capture_size`, or at 1920 × 1080 when that is absent; 0 when + /// nothing limits it. Set for software encoding on a small CPU, where + /// asking for more only drops frames. A viewer asking for fewer pixels + /// raises it in proportion. + pub max_fps: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -232,6 +249,21 @@ mod tests { assert_eq!(back, s); } + #[test] + fn an_older_host_reports_no_capture_size_or_frame_rate_limit() { + let s: Status = + serde_json::from_str(r#"{"streamer":{"running":true,"encoder":"software"}}"#).unwrap(); + assert_eq!(s.streamer.capture_size, None); + assert_eq!(s.streamer.max_fps, 0); + let mut s = s; + s.streamer.capture_size = Some([1920, 1080]); + s.streamer.max_fps = 30; + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains(r#""capture_size":[1920,1080]"#), "{json}"); + let back: Status = serde_json::from_str(&json).unwrap(); + assert_eq!(back, s); + } + #[test] fn power_actions_are_lowercase_on_the_wire() { let r = PowerRequest { @@ -246,6 +278,13 @@ mod tests { assert!(serde_json::from_str::(r#"{"action":"Sleep"}"#).is_err()); } + #[test] + fn an_older_hosts_clipboard_does_not_claim_the_selection() { + let c: Clipboard = serde_json::from_str(r#"{"text":"x","seq":1}"#).unwrap(); + assert!(!c.selection); + assert!(!Clipboard::fit("x", 1).selection); + } + #[test] fn clipboard_is_cut_on_a_character_boundary() { let c = Clipboard::fit("héllo", 3); diff --git a/crates/core/src/screens.rs b/crates/core/src/screens.rs index cb22437..f5fc633 100644 --- a/crates/core/src/screens.rs +++ b/crates/core/src/screens.rs @@ -3,8 +3,18 @@ //! 16:9 sizes, and a PC whose only display is virtual can only switch to a //! size that display lists. Setup on the PC lists these. -/// The standard sizes behind the quality choices: 1080p, 1440p and 4K. -pub const STANDARD: [(u32, u32); 3] = [(1920, 1080), (2560, 1440), (3840, 2160)]; +/// The standard sizes behind the quality choices, smallest first: 540p, +/// 720p and 900p for a PC that cannot encode many pixels quickly (software +/// encoding on a small CPU) or a network that cannot carry them, then +/// 1080p, 1440p and 4K. +pub const STANDARD: [(u32, u32); 6] = [ + (960, 540), + (1280, 720), + (1600, 900), + (1920, 1080), + (2560, 1440), + (3840, 2160), +]; /// Native pixel sizes of Apple displays since 2015, then the common /// external monitors. Points times the native scale factor, whatever diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index e9174a9..122673f 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -33,6 +33,11 @@ eframe.workspace = true [target.'cfg(not(windows))'.dependencies] arboard = "3" +# The X screen size the engine captures (see streamer::capture_size). +# arboard already links it on Linux. +[target.'cfg(target_os = "linux")'.dependencies] +x11rb = "0.13" + [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Devices_Display", diff --git a/crates/host/src/app.rs b/crates/host/src/app.rs index fbabb86..e765069 100644 --- a/crates/host/src/app.rs +++ b/crates/host/src/app.rs @@ -30,6 +30,9 @@ pub struct Shared { pub setup_running: bool, pub setup_result: Option>, pub setup_log: Vec, + /// A Mac whose owner turned sharing off: it only connects to the + /// others, so the window neither starts nor restarts the service. + pub viewer_only: bool, /// Moves whenever anything above changes, so a reader can skip copying /// what it already has. pub rev: u64, @@ -98,10 +101,17 @@ pub struct HostApp { impl HostApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - let shared = Arc::new(Mutex::new(Shared::default())); + let cfg = HostConfig::load(); + let viewer_only = !crate::service::starts_with_window(&cfg); + let shared = Arc::new(Mutex::new(Shared { + viewer_only, + // Down on purpose, as if stopped here: nothing restarts it. + service_error: viewer_only.then(|| STOPPED.into()), + ..Default::default() + })); spawn_poller(shared.clone(), cc.egui_ctx.clone()); Self { - cfg: HostConfig::load(), + cfg, autostart: setup::starts_with_windows(), bundled_engine: std::env::current_exe().is_ok_and(|e| setup::bundled_engine(&e)), live: true, @@ -255,6 +265,15 @@ impl HostApp { self.autostart = on; return; } + if self.os == Os::Mac && on { + // Loading the login item starts the service at once, and on a + // Mac it exits unless it reads that this Mac is shared. + let mut cfg = self.cfg.clone(); + cfg.start_with_windows = true; + if let Err(e) = cfg.save() { + tracing::warn!("could not save host config: {e:#}"); + } + } let result = std::env::current_exe() .map_err(anyhow::Error::from) .and_then(|exe| setup::set_start_with_windows(on, &exe)); @@ -263,6 +282,16 @@ impl HostApp { self.autostart = on; self.cfg.start_with_windows = on; self.dirty = true; + if self.os == Os::Mac { + // On a Mac this is the switch between sharing and only + // connecting to the others: it takes effect now. + self.shared.lock().viewer_only = !on; + if on { + self.start_service(); + } else { + self.stop_sharing(); + } + } } Err(e) => { tracing::warn!("autostart: {e:#}"); @@ -294,6 +323,20 @@ impl HostApp { s.touch(); } + /// Stop the service and the engine it started: nothing is shared and + /// nothing of Latch keeps running once the window closes. + fn stop_sharing(&mut self) { + self.stop_service(); + #[cfg(not(windows))] + if self.live { + std::thread::spawn(|| { + // After the service has gone, or it starts the engine again. + std::thread::sleep(Duration::from_millis(800)); + crate::unix_setup::stop_engine(); + }); + } + } + fn start_service(&mut self) { { let mut s = self.shared.lock(); @@ -407,6 +450,23 @@ impl HostApp { fn no_service(&mut self, ui: &mut egui::Ui, error: Option<&str>) { ui::page_header(ui, "Sharing", None, |_| {}); + if self.shared.lock().viewer_only { + ui::banner( + ui, + Tone::Neutral, + &format!("This {} is not shared", self.os.noun()), + Some(&format!( + "It connects to your other machines, but they can't connect to it, and nothing of Latch runs once this window closes. Share this {} to let them in, also after a restart.", + self.os.noun() + )), + |ui| { + if ui::primary_button(ui, &format!("Share this {}", self.os.noun())).clicked() { + self.set_autostart(true); + } + }, + ); + return; + } if error == Some(STOPPED) { ui::banner( ui, @@ -832,14 +892,20 @@ impl HostApp { } ui::row_separator(ui); let mut auto = self.autostart; - if ui::toggle_row( - ui, - &mut auto, - "Start Latch when you log in", - Some(&format!( - "So others can connect after this {noun} restarts, with nobody at the keyboard." - )), - ) { + let (title, detail) = if self.os == Os::Mac { + ( + "Share this Mac".to_string(), + "Others can connect whenever it is on, also after a restart. Off, this Mac only connects to your other machines and nothing of Latch runs once its window closes.".to_string(), + ) + } else { + ( + "Start Latch when you log in".to_string(), + format!( + "So others can connect after this {noun} restarts, with nobody at the keyboard." + ), + ) + }; + if ui::toggle_row(ui, &mut auto, &title, Some(&detail)) { self.set_autostart(auto); } }); @@ -1095,7 +1161,8 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { Err(e) => { failures += 1; let mut s = shared.lock(); - let stopped_by_user = s.service_error.as_deref() == Some(STOPPED); + let stopped_by_user = + s.service_error.as_deref() == Some(STOPPED) || s.viewer_only; changed |= s.status.is_some(); s.status = None; if !stopped_by_user { @@ -1240,6 +1307,7 @@ mod snapshots { "videotoolbox".into() }, audio_problem: String::new(), + ..Default::default() }, power_allowed: true, nat: Some(latch_core::api::NatReport { @@ -1363,6 +1431,15 @@ mod snapshots { ..Default::default() }, ), + ( + "mac-viewer", + Os::Mac, + Shared { + service_error: Some(STOPPED.into()), + viewer_only: true, + ..Default::default() + }, + ), ] } diff --git a/crates/host/src/clipboard.rs b/crates/host/src/clipboard.rs index 5caeb79..c4c5b9a 100644 --- a/crates/host/src/clipboard.rs +++ b/crates/host/src/clipboard.rs @@ -141,12 +141,29 @@ pub fn read() -> Result { Err(arboard::Error::ContentNotAvailable) => Ok(String::new()), other => other, })?; - Ok(Clipboard::fit(&text, text_seq(&text))) + Ok(Clipboard { + // write() below puts pasted text in the selection too. + selection: cfg!(target_os = "linux"), + ..Clipboard::fit(&text, text_seq(&text)) + }) } +/// On Linux the text also becomes the X selection (PRIMARY): a Mac sends +/// ⌘V to a Linux PC as Shift+Insert, which pastes the selection rather +/// than the clipboard in xterm and the VTE terminals (GNOME's, XFCE's). #[cfg(not(windows))] pub fn write(text: &str) -> Result<()> { - with_clipboard(|c| c.set_text(text.to_string())) + with_clipboard(|c| { + c.set_text(text.to_string())?; + #[cfg(target_os = "linux")] + { + use arboard::{LinuxClipboardKind, SetExtLinux}; + c.set() + .clipboard(LinuxClipboardKind::Primary) + .text(text.to_string())?; + } + Ok(()) + }) } /// macOS and X11 have no sequence number a program can read, so the text diff --git a/crates/host/src/main.rs b/crates/host/src/main.rs index 3b27f1b..e24f02b 100644 --- a/crates/host/src/main.rs +++ b/crates/host/src/main.rs @@ -88,7 +88,9 @@ fn main() -> Result<()> { e }); } - service::ensure_service_running(); + if service::starts_with_window(&latch_host::config::HostConfig::load()) { + service::ensure_service_running(); + } eframe::run_native( "Latch", latch_client::native_options(), diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 1f719e2..56f081a 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -35,6 +35,9 @@ const AUTH_TTL: Duration = Duration::from_secs(60); /// How long an answer is remembered, so the same peer is not logged again. const AUTH_REMEMBER: Duration = Duration::from_secs(3600); const AUTH_CACHE_LIMIT: usize = 256; +/// How long a PIN the engine took counts as entered for the Mac's retries: +/// longer than the engine waits for the handshake. +const PIN_TAKEN_FOR: Duration = Duration::from_secs(30); /// Ticks (of 5 s) between runs of `tailscale netcheck`: it takes seconds /// and the network does not move often. const NETCHECK_TICKS: u64 = 120; @@ -62,6 +65,10 @@ pub struct Service { nat: Mutex>, nat_running: AtomicBool, update_running: Arc, + /// A PIN the engine took and is pairing with, and when: the Mac's + /// retries for it are answered without asking the engine again, which + /// is busy with that very handshake. + pin_taken: Mutex>, } impl Service { @@ -81,6 +88,7 @@ impl Service { nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), + pin_taken: Mutex::new(None), } } @@ -107,6 +115,13 @@ impl Service { /// update just started: the old one is still answering its last request, /// so wait for the port rather than give up. pub fn run(self: Arc, replacing: bool) -> Result<()> { + if !starts_with_window(&self.cfg.lock()) { + // A Mac that is only a viewer: an older window (or anything else) + // that starts the service must not share it. Exit 0, so launchd + // does not try again. + self.log("this Mac is not shared (Share this Mac is off); the service exits"); + return Ok(()); + } let addr = SocketAddr::from((Ipv4Addr::UNSPECIFIED, CONTROL_PORT)); let listener = if replacing { let deadline = Instant::now() + Duration::from_secs(60); @@ -236,10 +251,11 @@ impl Service { running && self.streamer.lock().api_ok }; // Apply the streaming profile on any engine Latch can log into, - // after any old app has ended. The marker survives panel/config saves. + // after any old app has ended. The marker survives panel/config saves; + // v4 drops Linux's frame-rate floor (see setup::LINUX_ENGINE_CONF). if tick.is_multiple_of(6) && api_ok && install.is_some() { if let Ok(dir) = latch_core::config::data_dir() { - let marker = dir.join("stream-profile-v3"); + let marker = dir.join("stream-profile-v4"); if !marker.exists() { let api = Api { user: &cfg.sunshine_user, @@ -250,7 +266,11 @@ impl Service { if let Err(e) = std::fs::write(&marker, "1") { self.log(format!("could not save stream profile version: {e}")); } - self.log("stream profile ready: match client display, constant bitrate, 60 fps"); + self.log(if cfg!(target_os = "linux") { + "stream profile ready: constant bitrate, the viewer's frame rate" + } else { + "stream profile ready: match client display, constant bitrate, 60 fps" + }); } Ok(false) => {} // A running app owns the display until it ends. Err(e) => self.log(format!("stream profile could not be applied: {e:#}")), @@ -261,7 +281,9 @@ impl Service { // The encoder Sunshine picked and whether it could capture audio, // from its log; re-read now and then since Sunshine restarts on // its own after setup and every session tries the audio device. - let (encoder, audio_problem) = + // The X screen it captures is re-read with them: a VPS desktop can + // be restarted at another size. + let (encoder, audio_problem, capture_size) = if tick.is_multiple_of(12) || self.streamer.lock().encoder.is_empty() { let log = install.as_ref().and_then(streamer::log_text); ( @@ -271,11 +293,18 @@ impl Service { log.as_deref() .and_then(streamer::audio_problem_in) .unwrap_or_default(), + streamer::capture_size(), ) } else { let cur = self.streamer.lock(); - (cur.encoder.clone(), cur.audio_problem.clone()) + ( + cur.encoder.clone(), + cur.audio_problem.clone(), + cur.capture_size, + ) }; + let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); + let max_fps = streamer::max_fps(&encoder, cores); let st = Streamer { kind: install .as_ref() @@ -286,6 +315,8 @@ impl Service { api_ok, encoder, audio_problem, + capture_size, + max_fps, }; { let mut cur = self.streamer.lock(); @@ -691,15 +722,31 @@ impl Service { ), ); } + if self + .pin_taken + .lock() + .as_ref() + .is_some_and(|(pin, at)| *pin == p.pin && at.elapsed() < PIN_TAKEN_FOR) + { + return Response::json(200, &Ack::ok()); + } let api = Api { user: &cfg.sunshine_user, pass: &cfg.sunshine_pass, }; match api.submit_pin(&p.pin, &p.name) { - Ok(()) => { + Ok(streamer::Pin::Paired) => { self.log(format!("paired \"{}\"", p.name)); Response::json(200, &Ack::ok()) } + Ok(streamer::Pin::Taken) => { + *self.pin_taken.lock() = Some((p.pin.clone(), Instant::now())); + self.log(format!( + "PIN from \"{}\" entered; it finishes pairing itself", + p.name + )); + Response::json(200, &Ack::ok()) + } Err(e) => { self.log(format!("PIN from \"{}\" refused: {e:#}", p.name)); Response::json(502, &Ack::err(e.to_string())) @@ -883,6 +930,18 @@ pub fn service_alive() -> bool { .unwrap_or(false) } +/// Whether opening the window starts the background service. A PC must +/// stay reachable, so on Windows and Linux it always does. A Mac does only +/// while it is shared ("Share this Mac", stored as `start_with_windows`): +/// off, it is a viewer, and nothing of Latch outlives its window. +pub fn starts_with_window(cfg: &HostConfig) -> bool { + starts_with_window_on(cfg!(target_os = "macos"), cfg) +} + +fn starts_with_window_on(mac: bool, cfg: &HostConfig) -> bool { + !mac || cfg.start_with_windows +} + /// Start `--background` if nothing answers on loopback. pub fn ensure_service_running() { if service_alive() { @@ -952,6 +1011,20 @@ fn push_log(log: &Mutex>, msg: String) { mod tests { use super::*; + #[test] + fn only_a_mac_that_is_not_shared_opens_without_the_service() { + let shared = HostConfig::default(); + let viewer = HostConfig { + start_with_windows: false, + ..Default::default() + }; + assert!(starts_with_window_on(true, &shared)); + assert!(!starts_with_window_on(true, &viewer)); + // A PC nobody can reach in person keeps its service either way. + assert!(starts_with_window_on(false, &viewer)); + assert!(starts_with_window_on(false, &shared)); + } + fn whois(id: u64, login: &str) -> tailscale::WhoIs { tailscale::WhoIs { user_profile: tailscale::User { diff --git a/crates/host/src/setup.rs b/crates/host/src/setup.rs index 59863cd..59cd761 100644 --- a/crates/host/src/setup.rs +++ b/crates/host/src/setup.rs @@ -105,6 +105,46 @@ pub const ENGINE_CONF: &[(&str, &str)] = &[ ("vt_realtime", "enabled"), ]; +/// What Linux changes in [`ENGINE_CONF`]. Latch's Linux hosts are VPS +/// desktops that encode in software. +/// +/// Sunshine's X11 capture hands the encoder a picture every frame interval +/// whether or not anything moved, so a frame-rate floor above the stream's +/// rate can only re-encode the last picture: whenever a frame is done in +/// under 16.7 ms (a still desktop on an idle core), a 60 fps floor encodes +/// the same picture again before the next one arrives, up to doubling the +/// work under a 30 fps stream. 0 is the engine's own default, half the +/// stream's rate, which periodic capture never falls below. +/// +/// The viewer asks a small CPU for 30 fps (see `streamer::max_fps`): on a +/// two-core Ampere VPS Sunshine took 136% CPU for a still desktop at 60 fps +/// and 78% for a busier one at 30. That leaves room for superfast: on an Ampere core it took 17.1 ms a +/// 1080p frame of scrolling text against ultrafast's 14.4 ms, and at +/// 20 Mbps its keyframes came out at QP 21 instead of 38 (Y PSNR 35.1 dB +/// against 31.8): text stays legible after every keyframe the viewer asks +/// for, instead of smearing until the next frames repair it. +pub const LINUX_ENGINE_CONF: &[(&str, &str)] = + &[("minimum_fps_target", "0"), ("sw_preset", "superfast")]; + +/// [`ENGINE_CONF`], with [`LINUX_ENGINE_CONF`] in place when `linux`. +pub fn engine_conf_for(linux: bool) -> Vec<(&'static str, &'static str)> { + ENGINE_CONF + .iter() + .map(|&(k, v)| { + let linux_value = LINUX_ENGINE_CONF + .iter() + .find(|(n, _)| linux && *n == k) + .map(|&(_, v)| v); + (k, linux_value.unwrap_or(v)) + }) + .collect() +} + +/// The engine keys for this machine's platform. +pub fn engine_conf() -> Vec<(&'static str, &'static str)> { + engine_conf_for(cfg!(target_os = "linux")) +} + /// The only streamable app Latch launches. Upstream ships extra entries /// (Steam, a low-res desktop) that would show as a second product. pub const DESKTOP_APPS_JSON: &str = r#"{ @@ -127,21 +167,26 @@ fn conf_key(line: &str) -> Option<&str> { (!k.is_empty()).then_some(k) } -/// Upsert `ENGINE_CONF` into an existing sunshine.conf. Known keys are -/// replaced in place (first occurrence wins, later copies of those keys -/// dropped); everything else is kept. Sunshine's parser uses `emplace`, so -/// a naive append would leave an old `system_tray = enabled` in force. +/// Upsert this platform's [`engine_conf`] into an existing sunshine.conf. +/// Known keys are replaced in place (first occurrence wins, later copies of +/// those keys dropped); everything else is kept. Sunshine's parser uses +/// `emplace`, so a naive append would leave an old `system_tray = enabled` +/// in force. pub fn conceal_conf(existing: &str) -> String { + conceal_conf_with(existing, &engine_conf()) +} + +fn conceal_conf_with(existing: &str, conf: &[(&str, &str)]) -> String { let existing = existing.strip_prefix('\u{feff}').unwrap_or(existing); - let mut seen = [false; ENGINE_CONF.len()]; + let mut seen = vec![false; conf.len()]; let mut out = String::new(); if !existing.trim().is_empty() { for line in existing.lines() { - match conf_key(line).and_then(|k| ENGINE_CONF.iter().position(|(n, _)| *n == k)) { + match conf_key(line).and_then(|k| conf.iter().position(|(n, _)| *n == k)) { Some(i) if seen[i] => continue, Some(i) => { seen[i] = true; - let (n, v) = ENGINE_CONF[i]; + let (n, v) = conf[i]; out.push_str(n); out.push_str(" = "); out.push_str(v); @@ -154,7 +199,7 @@ pub fn conceal_conf(existing: &str) -> String { } } } - for (i, (n, v)) in ENGINE_CONF.iter().enumerate() { + for (i, (n, v)) in conf.iter().enumerate() { if !seen[i] { out.push_str(n); out.push_str(" = "); @@ -1110,6 +1155,7 @@ mod tests { "after the engine, before the power steps" ); assert!(s.contains("@(3024,1964)"), "{s}"); + assert!(s.contains("@(1280,720)"), "{s}"); assert!(s.contains("pnputil /restart-device"), "{s}"); let dry = script(&Plan { dry_run: true, @@ -1283,6 +1329,29 @@ system_tray = enabled assert_eq!(conceal_conf(&got), got); } + #[test] + fn linux_engines_get_no_frame_rate_floor_and_others_keep_theirs() { + fn get(conf: &[(&'static str, &'static str)], key: &str) -> Option<&'static str> { + conf.iter().find(|(k, _)| *k == key).map(|&(_, v)| v) + } + for (k, _) in LINUX_ENGINE_CONF { + assert!(get(ENGINE_CONF, k).is_some(), "{k} overrides nothing"); + } + let linux = engine_conf_for(true); + let other = engine_conf_for(false); + assert_eq!(linux.len(), ENGINE_CONF.len()); + assert_eq!(other, ENGINE_CONF); + assert_eq!(get(&linux, "minimum_fps_target"), Some("0")); + assert_eq!(get(&other, "minimum_fps_target"), Some("60")); + assert_eq!(get(&linux, "sw_preset"), Some("superfast")); + assert_eq!(get(&other, "sw_preset"), Some("ultrafast")); + assert_eq!(get(&linux, "sw_tune"), Some("zerolatency")); + let conf = conceal_conf_with("minimum_fps_target = 60\ncapture = x11\n", &linux); + assert_eq!(key_hits(&conf, "minimum_fps_target"), 1, "{conf}"); + assert!(conf.contains("minimum_fps_target = 0"), "{conf}"); + assert!(conf.contains("capture = x11"), "{conf}"); + } + #[test] fn the_engine_conf_is_written_before_the_service_starts() { let exe = PathBuf::from(r"C:\x\latch-host.exe"); diff --git a/crates/host/src/streamer.rs b/crates/host/src/streamer.rs index d90ef5c..1b893bb 100644 --- a/crates/host/src/streamer.rs +++ b/crates/host/src/streamer.rs @@ -170,6 +170,43 @@ fn read_tail(path: &std::path::Path, max: u64) -> Option { }) } +/// The size of the X screen the engine captures on Linux. Sunshine records +/// the whole root window of `$DISPLAY` and scales it to whatever size a +/// viewer asked for, so this is the one size that arrives unscaled. `None` +/// elsewhere, when no X server answers, and when the desktop resizes itself +/// to each viewer (`LATCH_DISPLAY_FOLLOWS_VIEWER=1`: a prep command switches +/// the X screen to the size asked for), so viewers ask for their own size. +#[cfg(target_os = "linux")] +pub fn capture_size() -> Option<[u32; 2]> { + use x11rb::connection::Connection; + if std::env::var_os("LATCH_DISPLAY_FOLLOWS_VIEWER").is_some_and(|v| v == "1") { + return None; + } + let (conn, screen) = x11rb::connect(None).ok()?; + let root = conn.setup().roots.get(screen)?; + Some([root.width_in_pixels.into(), root.height_in_pixels.into()]) +} + +#[cfg(not(target_os = "linux"))] +pub fn capture_size() -> Option<[u32; 2]> { + None +} + +/// The frame rate the engine keeps up with here at 1080p, 0 for no known +/// limit: 30 when it encodes in software on fewer than four cores (a viewer +/// asking for a smaller picture raises it in proportion). On a two-core +/// Ampere VPS, desktop sessions asked for at 60 fps averaged 33 to 47 +/// encoded frames a second by Sunshine's own per-session counts; Sunshine +/// took 136% CPU streaming a still desktop at 60 fps and 78% streaming a +/// busier one at 30, leaving the desktop itself the rest of two cores. +pub fn max_fps(encoder: &str, cores: usize) -> u32 { + if encoder == "software" && cores < 4 { + 30 + } else { + 0 + } +} + pub fn find() -> Option { #[cfg(windows)] { @@ -351,19 +388,27 @@ impl Api<'_> { } pub fn session_active(&self) -> bool { - self.call("GET", "/api/apps", None) - .ok() - .is_some_and(|v| session_listed(&v)) + // /api/apps only lists configured apps; it carries no running state. + latch_core::http::request( + ("127.0.0.1", SUNSHINE_PORT), + "GET", + "/serverinfo", + None, + Duration::from_secs(2), + ) + .ok() + .filter(|r| r.status == 200) + .and_then(|r| session_in_serverinfo(&r.body)) + .unwrap_or(true) } /// Migrate managed installations once, without interrupting a running app. pub fn upgrade_stream_profile(&self) -> Result { - let apps = self.call("GET", "/api/apps", None)?; - if session_listed(&apps) { + if self.session_active() { return Ok(false); } let current = self.call("GET", "/api/config", None)?; - let desired = stream_profile(¤t)?; + let desired = stream_profile(¤t, &crate::setup::engine_conf())?; if desired != current { self.call("POST", "/api/config", Some(&desired.to_string()))?; // Sunshine closes this HTTP connection as it restarts. @@ -386,18 +431,35 @@ impl Api<'_> { /// Accept the PIN the Mac is pairing with. Sunshine says no until the /// Mac has actually started pairing, so callers retry. /// - /// Releases up to 2026.5 take `{pin, name}`; later ones list the waiting - /// requests on `GET /api/pin` and want the request's `pairing_id` too. - pub fn submit_pin(&self, pin: &str, name: &str) -> Result<()> { + /// Releases up to 2026.5 take `{pin, name}` and answer at once; later + /// ones list the waiting requests on `GET /api/pin`, want the request's + /// `pairing_id` too, and from October 2026 keep the post open until the + /// Mac has finished the handshake. The Mac only goes on with it once + /// this has answered, so a post still open after [`PIN_TAKEN_AFTER`] + /// is left to finish on its own and reported as [`Pin::Taken`]. + pub fn submit_pin(&self, pin: &str, name: &str) -> Result { let pending = pending_pairings(self.call("GET", "/api/pin", None).ok()); for id in pending { let mut body = serde_json::json!({ "pin": pin, "name": name }); if let Some(id) = id { body["pairing_id"] = id.into(); } - let v = self.call("POST", "/api/pin", Some(&body.to_string()))?; + let (tx, rx) = std::sync::mpsc::channel(); + let (user, pass) = (self.user.to_string(), self.pass.to_string()); + std::thread::spawn(move || { + let api = Api { + user: &user, + pass: &pass, + }; + let _ = tx.send(api.call("POST", "/api/pin", Some(&body.to_string()))); + }); + let v = match rx.recv_timeout(PIN_TAKEN_AFTER) { + Ok(reply) => reply?, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(Pin::Taken), + Err(e) => bail!("PIN post: {e}"), + }; if v["status"].as_bool() == Some(true) || v["status"].as_str() == Some("true") { - return Ok(()); + return Ok(Pin::Paired); } } bail!("the streaming engine did not accept the PIN (is the Mac pairing right now?)") @@ -434,6 +496,21 @@ impl Api<'_> { } } +/// What became of a PIN handed to the engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pin { + /// The engine says the Mac is paired. + Paired, + /// The engine took the PIN and waits for the Mac to finish the + /// handshake, which it does once it hears the PIN was entered. + Taken, +} + +/// How long a PIN post may stay open before it counts as taken. An engine +/// that turns a PIN down (or one that never holds the post) answers well +/// within this; one that took it waits for the Mac. +pub const PIN_TAKEN_AFTER: Duration = Duration::from_millis(600); + /// The pairing ids `GET /api/pin` lists on a Sunshine master build. Every /// other reply (a release Sunshine answers 200 `{"error":"Not Found"}`, an /// old one 404, curl failing) means "no ids": still send the PIN once @@ -455,13 +532,15 @@ fn pending_pairings(reply: Option) -> Vec> { } } -/// Preserve unrelated engine settings; metadata from GET is never written. -fn stream_profile(current: &serde_json::Value) -> Result { +/// `current` with the keys of `conf` (this platform's +/// [`crate::setup::engine_conf`]) in force. Unrelated engine settings are +/// kept; metadata from GET is never written. +fn stream_profile(current: &serde_json::Value, conf: &[(&str, &str)]) -> Result { let mut config = current .as_object() .context("engine config is not an object")? .clone(); - let changed = crate::setup::ENGINE_CONF + let changed = conf .iter() .any(|(k, v)| config.get(*k).and_then(|v| v.as_str()) != Some(*v)); if !changed { @@ -470,7 +549,7 @@ fn stream_profile(current: &serde_json::Value) -> Result { for k in ["status", "platform", "version"] { config.remove(k); } - for (k, v) in crate::setup::ENGINE_CONF { + for (k, v) in conf { config.insert((*k).into(), (*v).into()); } Ok(config.into()) @@ -512,20 +591,18 @@ fn parse_reply(text: &str) -> Result { Ok(v) } -pub fn session_listed(v: &serde_json::Value) -> bool { - match v.get("current_app") { - Some(serde_json::Value::String(s)) if !s.is_empty() => return true, - Some(serde_json::Value::Number(n)) if n.as_u64().is_some_and(|n| n > 0) => return true, - _ => {} - } - v.get("apps") - .and_then(|a| a.as_array()) - .is_some_and(|apps| { - apps.iter().any(|a| { - a.get("running").and_then(|x| x.as_bool()) == Some(true) - || a.get("current").and_then(|x| x.as_bool()) == Some(true) - }) - }) +/// Whether the GameStream `/serverinfo` XML says an app is running: its +/// `currentgame` is the running app's id, 0 when nothing streams. `None` +/// when the reply does not say. +pub(crate) fn session_in_serverinfo(xml: &str) -> Option { + xml.split_once("")? + .1 + .split_once("")? + .0 + .trim() + .parse::() + .ok() + .map(|id| id != 0) } fn parse_clients(v: &serde_json::Value) -> Vec<(String, String)> { @@ -552,7 +629,7 @@ mod tests { fn streaming_profile_replaces_caps_and_fixed_modes_without_losing_other_settings() { let original = serde_json::json!({"status":true,"version":"x", "platform":"windows", "encoder":"amdvce", "max_bitrate":"1000", "dd_resolution_option":"manual", "output_name":"virtual"}); - let profile = stream_profile(&original).unwrap(); + let profile = stream_profile(&original, crate::setup::ENGINE_CONF).unwrap(); assert_eq!(profile["max_bitrate"], "0"); assert_eq!(profile["amd_rc"], "cbr"); assert_eq!(profile["amd_quality"], "speed"); @@ -565,8 +642,29 @@ mod tests { assert_eq!(profile["encoder"], "amdvce"); assert_eq!(profile["output_name"], "virtual"); assert!(profile.get("platform").is_none()); - assert_eq!(stream_profile(&profile).unwrap(), profile); - assert!(stream_profile(&serde_json::Value::Null).is_err()); + assert_eq!( + stream_profile(&profile, crate::setup::ENGINE_CONF).unwrap(), + profile + ); + assert!(stream_profile(&serde_json::Value::Null, crate::setup::ENGINE_CONF).is_err()); + } + + #[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. + let vps = serde_json::json!({"status":true, "capture":"x11", "encoder":"software", + "minimum_fps_target":"60", "sw_preset":"ultrafast", "min_threads":"2"}); + let linux = crate::setup::engine_conf_for(true); + let profile = stream_profile(&vps, &linux).unwrap(); + assert_eq!(profile["minimum_fps_target"], "0"); + assert_eq!(profile["sw_preset"], "superfast"); + assert_eq!(profile["capture"], "x11", "the deployment's own keys stay"); + assert_eq!(profile["encoder"], "software"); + assert_eq!(stream_profile(&profile, &linux).unwrap(), profile); + let other = crate::setup::engine_conf_for(false); + let elsewhere = stream_profile(&profile, &other).unwrap(); + assert_eq!(elsewhere["minimum_fps_target"], "60"); + assert_eq!(elsewhere["sw_preset"], "ultrafast"); } #[test] @@ -602,19 +700,29 @@ mod tests { } #[test] - fn session_listed_reads_current_app_and_running_flags() { - assert!(!session_listed(&serde_json::json!({"apps": []}))); - assert!(session_listed( - &serde_json::json!({"current_app": "Desktop"}) - )); - assert!(!session_listed(&serde_json::json!({"current_app": ""}))); - assert!(session_listed(&serde_json::json!({"current_app": 1}))); - assert!(session_listed( - &serde_json::json!({"apps": [{"name": "Desktop", "running": true}]}) - )); - assert!(!session_listed( - &serde_json::json!({"apps": [{"name": "Desktop", "running": false}]}) - )); + fn only_software_encoding_on_a_small_cpu_caps_the_frame_rate() { + assert_eq!(max_fps("software", 2), 30); + assert_eq!(max_fps("software", 1), 30); + assert_eq!(max_fps("software", 4), 0); + assert_eq!(max_fps("nvenc", 2), 0); + assert_eq!(max_fps("", 2), 0, "unknown encoder: no limit yet"); + } + + #[test] + fn serverinfo_reports_a_running_session_and_unknown_state_stays_unknown() { + assert_eq!( + session_in_serverinfo("0"), + Some(false) + ); + assert_eq!( + session_in_serverinfo("881448767"), + Some(true) + ); + assert_eq!( + session_in_serverinfo("bad"), + None + ); + assert_eq!(session_in_serverinfo(""), None); } #[test] diff --git a/deploy/native/README.md b/deploy/native/README.md index 4ba9976..dc3a935 100644 --- a/deploy/native/README.md +++ b/deploy/native/README.md @@ -25,6 +25,14 @@ The `.service` files in this directory are installed in ordering without conflicting with Latch's automatic registration. `wait-engine.py` is installed as `/usr/local/libexec/latch-wait-engine.py`. +`latch-engine.service` and `latch-display.service` carry `CPUWeight=1000`: +the encoder and the X server it copies frames from get ten times the CPU +share of other user services, which matters on a small VPS that also runs +agents. Latch Host keeps the engine on the Linux profile +(`crates/host/src/setup.rs`, `LINUX_ENGINE_CONF`: no frame-rate floor, +`superfast`) and tells viewers the X screen's size and, on fewer than four +cores, a 30 fps ceiling. + `latch.service` pulls in `latch-engine.service`, `latch-desktop.service`, and `latch-display.service`. PulseAudio runs as its standard user service. Check them as `ubuntu`: @@ -45,6 +53,26 @@ 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. +## A desktop that takes the viewer's size + +Xvfb cannot change size, so the relay desktop is streamed at 1920x1080 (a +smaller size the Mac asks for is that desktop scaled down). An X server that +can resize (Xorg with the dummy driver) can instead switch to each stream's +size: give Sunshine a `global_prep_cmd` that runs `xrandr` with +`SUNSHINE_CLIENT_WIDTH` and `SUNSHINE_CLIENT_HEIGHT`, and set +`LATCH_DISPLAY_FOLLOWS_VIEWER=1` in `latch.service.d/desktop.conf` so Latch +Host stops reporting a fixed capture size. Use a mode at 60 Hz for every size +(`xrandr --newmode` with a pixel clock of 60 x htotal x vtotal): the +compositor and browsers draw at the mode's rate. + +That needs Sunshine 2026.1014 or later. Earlier ones send absolute pointer +positions through an XTest fallback that keeps the screen size it saw at +startup, so after a resize the pointer lands at that size's scale (twice as +far at 540p, two thirds as far at a 3024x1964 Match screen). Relative +movement, and a desktop that stays at its startup size, are fine with any +version. The Mac launches the Desktop again whenever it asks for another +size than it last did, since a prep command runs only at a launch. + ## Upgrading from BroLink (before 4.1.0) Latch was called BroLink until 4.1.0, and a node that was set up then has diff --git a/deploy/native/latch-display.service b/deploy/native/latch-display.service index dda40e9..bb2ed48 100644 --- a/deploy/native/latch-display.service +++ b/deploy/native/latch-display.service @@ -4,6 +4,8 @@ Description=Latch native headless display [Service] ExecStartPre=/bin/sh -c 'umask 077; touch "%t/latch.Xauthority"; /usr/bin/xauth -f "%t/latch.Xauthority" add :1 . "$(/usr/bin/mcookie)"' ExecStart=/usr/bin/Xvfb :1 -screen 0 1920x1080x24 -nolisten tcp -auth %t/latch.Xauthority +# Every captured frame is a copy the X server makes; see latch-engine.service. +CPUWeight=1000 Restart=on-failure RestartSec=3 diff --git a/deploy/native/latch-engine.service b/deploy/native/latch-engine.service index ef115cc..34f3062 100644 --- a/deploy/native/latch-engine.service +++ b/deploy/native/latch-engine.service @@ -10,6 +10,10 @@ Environment=XAUTHORITY=%t/latch.Xauthority WorkingDirectory=%h/.config/sunshine ExecStartPre=/bin/sh -c 'for i in $(seq 1 50); do xdpyinfo >/dev/null 2>&1 && exit 0; sleep 0.2; done; exit 1' ExecStart=/usr/bin/sunshine %h/.config/sunshine/sunshine.conf +# The encoder runs on the CPU. When other user services (an agent, a build) +# want every core too, frames that miss their interval are dropped: give the +# engine ten times their share. It cannot raise its own priority as a user. +CPUWeight=1000 Restart=on-failure RestartSec=3 diff --git a/deploy/node/entrypoint.sh b/deploy/node/entrypoint.sh index 554d46e..2b593a7 100755 --- a/deploy/node/entrypoint.sh +++ b/deploy/node/entrypoint.sh @@ -38,17 +38,22 @@ startxfce4 >/var/log/xfce.log 2>&1 & sleep 1 CONF=/root/.config/sunshine/sunshine.conf +# X11 capture delivers a picture every frame interval, so a frame-rate floor +# above the stream's rate can only re-encode the last picture: 0 is +# Sunshine's default (half the stream's rate). superfast keeps keyframe text legible at +# the 30 fps a small CPU is asked for. Latch Host keeps the same keys current +# (crates/host/src/setup.rs, ENGINE_CONF and LINUX_ENGINE_CONF). cat >"$CONF" <<'EOF' system_tray = disabled origin_web_ui_allowed = pc credentials_file = /root/.config/sunshine/latch-web.json max_bitrate = 0 -minimum_fps_target = 60 +minimum_fps_target = 0 fec_percentage = 20 packetsize = 1184 amd_rc = cbr vaapi_rc = cbr -sw_preset = ultrafast +sw_preset = superfast sw_tune = zerolatency min_threads = 4 capture = x11 diff --git a/deploy/node/selftest.sh b/deploy/node/selftest.sh index 3e0e61c..b26be91 100755 --- a/deploy/node/selftest.sh +++ b/deploy/node/selftest.sh @@ -16,7 +16,8 @@ need "$ROOT/Dockerfile" "v2026.906.222525" need "$ROOT/entrypoint.sh" "latch-host --background" need "$ROOT/entrypoint.sh" "userspace-networking" need "$ROOT/entrypoint.sh" "packetsize = 1184" -need "$ROOT/entrypoint.sh" "sw_preset = ultrafast" +need "$ROOT/entrypoint.sh" "sw_preset = superfast" +need "$ROOT/entrypoint.sh" "minimum_fps_target = 0" need "$ROOT/entrypoint.sh" "latch-engine" need "$ROOT/entrypoint.sh" '"name": "Desktop"' need "$ROOT/docker-compose.yml" "latch-node" diff --git a/docs/MACOS.md b/docs/MACOS.md index 3e263f9..2ebef03 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -48,7 +48,7 @@ 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/Latch.app`, and installs it to +needed for the C parts), bundles `dist.noindex/Latch.app`, and installs it to `/Applications`. A locally built app never carries the quarantine flag. ### Signing @@ -59,9 +59,9 @@ notarize: ```bash CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./scripts/bundle-macos.sh -ditto -c -k --keepParent dist/Latch.app dist/Latch.zip -xcrun notarytool submit dist/Latch.zip --keychain-profile "notary" --wait -xcrun stapler staple dist/Latch.app +ditto -c -k --keepParent dist.noindex/Latch.app dist.noindex/Latch.zip +xcrun notarytool submit dist.noindex/Latch.zip --keychain-profile "notary" --wait +xcrun stapler staple dist.noindex/Latch.app ``` ## First run @@ -137,6 +137,29 @@ then presses Ctrl+V there). Text only, up to 32 KB; an image or a file on either clipboard is left alone. Short notices under the toolbar say when something crossed. With an older host, ⌘V pastes what the PC last copied. +A Linux machine gets ⌘C, ⌘X and ⌘V as Ctrl+Insert, Shift+Delete and +Shift+Insert, which GTK and Qt apps, Firefox, Chromium and terminals all +take for copy, cut and paste; Ctrl+C in a terminal would interrupt the +running program. Latch Host there puts pasted text in the selection as +well as the clipboard, because terminals paste the selection on +Shift+Insert. To copy in a terminal, select the text and press ⌘C. + +A Linux VPS draws a desktop of one fixed size and encodes on the CPU, so +Latch never asks it for more than that size: a larger resolution setting +gets the desktop's own size, a smaller one (720p, say) is scaled down. On +a machine with fewer than four cores, Latch asks for at most 30 fps at +1080p, and more for a smaller picture in proportion to the pixels it +saves: up to 67 fps at 720p, 43 at 900p, 120 at 540p. To get 60 fps from +such a machine, pick 720p (or 540p) and 60 fps. + +## Only connecting from this Mac + +On a Mac, **Share this Mac** on the **Sharing** page decides whether Latch +runs in the background so other machines can connect here. Turn it off and +this Mac only connects to the others: the background service and the +streaming engine stop, opening Latch no longer starts them, and nothing of +Latch runs once its window closes. **Share this Mac** turns it back on. + ### Laggy? Look at the path Tailscale connects the Mac and the PC directly when it can punch through @@ -177,7 +200,7 @@ it from this network (see below). | Setting | What it changes | |---------|-----------------| | Quality | **Recommended** (the default) asks for this screen's own size at 50 Mbps, on every path. **Smooth** is 1080p · 60 fps · 20 Mbps, **Balanced** match screen · 60 · 50, **Sharp** match screen · 60 · 100. **Custom** uses the rows below; changing one of them switches to it | -| Resolution | **Match screen** is this display's own pixel size (3024×1964 on a 14" MacBook Pro), so the picture fills it exactly. **1080p**, **1440p** and **4K** are the standard 16:9 sizes, 1920×1080, 2560×1440 and 3840×2160; on a display of another shape they leave a bar above and below. The list shows the pixel size of each. The PC switches its display to the size asked for; a PC with no monitor needs a virtual display that lists that size, which setup on the PC arranges for the Virtual Display Driver | +| Resolution | **Match screen** is this display's own pixel size (3024×1964 on a 14" MacBook Pro), so the picture fills it exactly. **540p**, **720p**, **900p**, **1080p**, **1440p** and **4K** are the standard 16:9 sizes, 960×540, 1280×720, 1600×900, 1920×1080, 2560×1440 and 3840×2160; on a display of another shape they leave a bar above and below. The sizes below 1080p are for a PC that encodes in software or a slow network: fewer pixels leave more bitrate for each and a higher frame rate. The list shows the pixel size of each. The PC switches its display to the size asked for; a PC with no monitor needs a virtual display that lists that size, which setup on the PC arranges for the Virtual Display Driver | | Frame rate | 30 to 240; the PC's display is switched to match when it can | | Bitrate | 2 to 150 Mbps; arrow keys step by 1, Shift-arrow by 10. The encoder holds this rate. Large swings mean the path is dropping packets | | Codec | Auto (HEVC when both ends can), HEVC, H.264 | diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh index bae6b50..e1b4a6d 100755 --- a/scripts/bundle-macos.sh +++ b/scripts/bundle-macos.sh @@ -18,14 +18,12 @@ if [[ -z "$BIN" ]]; then echo "build the app first: cargo build --release -p latch-host" >&2 exit 1 fi -APP="$ROOT/dist/Latch.app" +# Spotlight excludes .noindex folders, so this build copy stays out of app search. +APP="$ROOT/dist.noindex/Latch.app" # The workspace version, so the bundle cannot drift from the binary inside it. VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -# dist/ is a staging copy. Keep Spotlight from listing it next to -# /Applications/Latch.app. -touch "$ROOT/dist/.metadata_never_index" cp "$BIN" "$APP/Contents/MacOS/Latch" chmod +x "$APP/Contents/MacOS/Latch" # The Finder/Dock icon: the ink tile with the logo knocked out, built by diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh index 33f5560..c22036b 100755 --- a/scripts/install-macos.sh +++ b/scripts/install-macos.sh @@ -7,7 +7,7 @@ cd "$ROOT" rustup target add aarch64-apple-darwin >/dev/null cargo build --release -p latch-host --target aarch64-apple-darwin bash "$ROOT/scripts/bundle-macos.sh" -APP="$ROOT/dist/Latch.app" +APP="$ROOT/dist.noindex/Latch.app" if [[ ! -d "$APP" ]]; then echo "bundle failed" >&2 exit 1 @@ -15,8 +15,7 @@ fi xattr -dr com.apple.quarantine "$APP" 2>/dev/null || true rm -rf /Applications/Latch.app cp -R "$APP" /Applications/Latch.app -# The staging bundle in dist/ would otherwise appear as a second Latch -# in Spotlight and Launchpad. Unregister it; the real app is in /Applications. +# Unregister the staging copy; the installed app is in /Applications. LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" "$LSREGISTER" -u "$APP" 2>/dev/null || true "$LSREGISTER" -f /Applications/Latch.app 2>/dev/null || true From 4eb6da7140171c583cdcec210edd4d4636260d4f Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:45:26 -0700 Subject: [PATCH 02/34] Engine: defer updates only while a viewer is connected, PIN to the requester's own pairing A viewer that disconnects leaves its Desktop launched for resuming, and Sunshine keeps reporting it as currentgame until the engine restarts. Since PR #26 read that as "a stream is running", a Windows host refused every pushed update after the first ordinary stream, and the Mac re-sent the whole exe every three minutes. Whether someone watches now comes from the engine log's last session line (CLIENT CONNECTED against CLIENT DISCONNECTED or a Ping Timeout, which is all a sleeping Mac leaves), checked against /serverinfo. An unknown never blocks the update, which does not touch the engine; the stream-profile upgrade, which restarts it, still waits on unknowns and closes a Desktop nobody watches before it restarts. The PIN went to whichever pending pairing Sunshine listed first, which can be another device's or an abandoned one, and an empty 2026.10 list made an id-less post that engine always refuses. The request is now picked by the caller's address (by name where everything is loopback), and an empty or foreign-only list answers "waiting" without posting. Only posts with a pairing id get the 600 ms "taken" rule. Refusals are logged once per reason instead of once per retry, and engine calls run curl with -S so a failure says why instead of "curl: ". Co-Authored-By: Claude Opus 5.5 --- crates/host/src/service.rs | 48 ++-- crates/host/src/streamer.rs | 470 +++++++++++++++++++++++++++++------- crates/host/src/update.rs | 7 +- 3 files changed, 425 insertions(+), 100 deletions(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 56f081a..21c8412 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -69,6 +69,8 @@ pub struct Service { /// retries for it are answered without asking the engine again, which /// is busy with that very handshake. pin_taken: Mutex>, + /// The last PIN refusal logged. + pin_refused: Mutex>, } impl Service { @@ -89,6 +91,7 @@ impl Service { nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), pin_taken: Mutex::new(None), + pin_refused: Mutex::new(None), } } @@ -96,14 +99,11 @@ impl Service { push_log(&self.log, msg.into()); } - fn session_active(&self) -> bool { - let cfg = self.cfg.lock().clone(); - cfg.has_creds() - && Api { - user: &cfg.sunshine_user, - pass: &cfg.sunshine_pass, - } - .session_active() + /// A viewer is connected right now. A Desktop left running for the + /// viewer to resume does not count: see [`streamer::Session`]. + fn someone_watching(&self) -> bool { + let install = self.install.lock().clone(); + streamer::session(install.as_ref()).watched() } pub fn run_arc(self, replacing: bool) -> Result<()> { @@ -261,7 +261,7 @@ impl Service { user: &cfg.sunshine_user, pass: &cfg.sunshine_pass, }; - match api.upgrade_stream_profile() { + match api.upgrade_stream_profile(install.as_ref()) { Ok(true) => { if let Err(e) = std::fs::write(&marker, "1") { self.log(format!("could not save stream profile version: {e}")); @@ -272,7 +272,7 @@ impl Service { "stream profile ready: match client display, constant bitrate, 60 fps" }); } - Ok(false) => {} // A running app owns the display until it ends. + Ok(false) => {} // Retried once nobody watches. Err(e) => self.log(format!("stream profile could not be applied: {e:#}")), } } @@ -617,7 +617,7 @@ impl Service { ("GET", "/v1/status") => Response::json(200, &self.status(local)), ("GET", "/v1/display") => Response::json(200, &self.display()), ("POST", "/v1/display") => self.set_display(peer.ip(), req), - ("POST", "/v1/pin") => self.pin(req), + ("POST", "/v1/pin") => self.pin(peer.ip(), req), ("POST", "/v1/power") => self.power(peer.ip(), req), ("POST", p) if p == UPDATE_PATH => self.update(peer.ip(), req), ("GET", p) if p == CLIPBOARD_PATH => match clipboard::read() { @@ -661,7 +661,7 @@ impl Service { return Response::json(409, &Ack::err("an update is already being installed")); } let engine_running = self.streamer.lock().running; - if update::refuse_self_update(engine_running, self.session_active()) { + if update::refuse_self_update(engine_running, self.someone_watching()) { self.update_running.store(false, Ordering::Release); self.log("update deferred: a stream is running"); return Response::json( @@ -706,7 +706,7 @@ impl Service { } } - fn pin(&self, req: &Request) -> Response { + fn pin(&self, from: IpAddr, req: &Request) -> Response { let Ok(p) = req.json::() else { return Response::json(400, &Ack::err("expected {\"pin\",\"name\"}")); }; @@ -734,7 +734,7 @@ impl Service { user: &cfg.sunshine_user, pass: &cfg.sunshine_pass, }; - match api.submit_pin(&p.pin, &p.name) { + match api.submit_pin(&p.pin, &p.name, from) { Ok(streamer::Pin::Paired) => { self.log(format!("paired \"{}\"", p.name)); Response::json(200, &Ack::ok()) @@ -747,8 +747,26 @@ impl Service { )); Response::json(200, &Ack::ok()) } + Ok(streamer::Pin::NotYet) => { + // Not an error: the Mac retries within a second. A 200 with + // the reason is what it shows while it waits; any other + // status reads to it as "Latch isn't answering". + Response::json( + 200, + &Ack::err( + "waiting for this machine's pairing request to reach the streaming engine", + ), + ) + } Err(e) => { - self.log(format!("PIN from \"{}\" refused: {e:#}", p.name)); + // The Mac retries every second for over a minute; one line + // per reason, not one per retry. + let line = format!("PIN from \"{}\" refused: {e:#}", p.name); + let mut last = self.pin_refused.lock(); + if last.as_deref() != Some(line.as_str()) { + self.log(line.clone()); + *last = Some(line); + } Response::json(502, &Ack::err(e.to_string())) } } diff --git a/crates/host/src/streamer.rs b/crates/host/src/streamer.rs index 1b893bb..2569e84 100644 --- a/crates/host/src/streamer.rs +++ b/crates/host/src/streamer.rs @@ -5,7 +5,7 @@ use anyhow::{bail, Context, Result}; use latch_core::{SUNSHINE_PORT, SUNSHINE_WEB_PORT}; -use std::net::{SocketAddr, TcpStream}; +use std::net::{IpAddr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -339,7 +339,9 @@ impl Api<'_> { fn request(&self, method: &str, path: &str, body: Option<&str>) -> Result { let curl = if cfg!(windows) { "curl.exe" } else { "curl" }; let mut c = Command::new(curl); - c.args(["-sk", "--max-time", "8", "--config", "-"]) + // -S keeps curl's own error message under -s: without it every + // failure read "curl: ", whatever went wrong. + c.args(["-sSk", "--max-time", "8", "--config", "-"]) .args(["-X", method, "-H", "Content-Type: application/json"]) .arg(format!("https://localhost:{SUNSHINE_WEB_PORT}{path}")); if let Some(b) = body { @@ -377,7 +379,7 @@ impl Api<'_> { let out = child.wait_with_output().context("wait for curl")?; written?; if !out.status.success() { - bail!("curl: {}", String::from_utf8_lossy(&out.stderr).trim()); + bail!("{}", curl_failure(out.status.code(), &out.stderr)); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } @@ -387,43 +389,37 @@ impl Api<'_> { self.call("GET", "/api/apps", None).is_ok() } - pub fn session_active(&self) -> bool { - // /api/apps only lists configured apps; it carries no running state. - latch_core::http::request( - ("127.0.0.1", SUNSHINE_PORT), - "GET", - "/serverinfo", - None, - Duration::from_secs(2), - ) - .ok() - .filter(|r| r.status == 200) - .and_then(|r| session_in_serverinfo(&r.body)) - .unwrap_or(true) - } - - /// Migrate managed installations once, without interrupting a running app. - pub fn upgrade_stream_profile(&self) -> Result { - if self.session_active() { - return Ok(false); - } + /// Migrate managed installations once, without interrupting a viewer. + /// Applying the profile restarts the engine, which ends whatever app is + /// launched; one nobody watches (the Desktop a viewer left to resume) is + /// closed first, so its undo commands run, and the next viewer starts + /// it again. + pub fn upgrade_stream_profile(&self, install: Option<&Install>) -> Result { let current = self.call("GET", "/api/config", None)?; let desired = stream_profile(¤t, &crate::setup::engine_conf())?; - if desired != current { - self.call("POST", "/api/config", Some(&desired.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; - } - if std::time::Instant::now() >= deadline { - bail!("streaming engine did not return after applying the display profile"); - } - std::thread::sleep(Duration::from_millis(500)); + if desired == current { + return Ok(true); + } + let s = session(install); + if s.maybe_watched() { + return Ok(false); + } + if s.app_running == Some(true) { + self.close_app(); + } + self.call("POST", "/api/config", Some(&desired.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; + } + 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) } @@ -437,30 +433,45 @@ impl Api<'_> { /// Mac has finished the handshake. The Mac only goes on with it once /// this has answered, so a post still open after [`PIN_TAKEN_AFTER`] /// is left to finish on its own and reported as [`Pin::Taken`]. - pub fn submit_pin(&self, pin: &str, name: &str) -> Result { - let pending = pending_pairings(self.call("GET", "/api/pin", None).ok()); - for id in pending { - let mut body = serde_json::json!({ "pin": pin, "name": name }); - if let Some(id) = id { - body["pairing_id"] = id.into(); - } - let (tx, rx) = std::sync::mpsc::channel(); - let (user, pass) = (self.user.to_string(), self.pass.to_string()); - std::thread::spawn(move || { - let api = Api { - user: &user, - pass: &pass, - }; - let _ = tx.send(api.call("POST", "/api/pin", Some(&body.to_string()))); - }); - let v = match rx.recv_timeout(PIN_TAKEN_AFTER) { - Ok(reply) => reply?, + /// + /// The PIN goes to the request that came from `peer` (or, where every + /// address looks local, from a machine called `name`), never simply to + /// the first one listed: that may be another device's, or an abandoned + /// one, and handing it this PIN fails both. + pub fn submit_pin(&self, pin: &str, name: &str, peer: IpAddr) -> Result { + let listed = self.call("GET", "/api/pin", None).ok(); + let id = match choose_pairing(listed.as_ref(), peer, name) { + Pairing::Unlisted => None, + Pairing::Id(id) => Some(id), + Pairing::NotYet => return Ok(Pin::NotYet), + }; + let mut body = serde_json::json!({ "pin": pin, "name": name }); + if let Some(id) = &id { + body["pairing_id"] = id.as_str().into(); + } + let (tx, rx) = std::sync::mpsc::channel(); + let (user, pass) = (self.user.to_string(), self.pass.to_string()); + std::thread::spawn(move || { + let api = Api { + user: &user, + pass: &pass, + }; + let _ = tx.send(api.call("POST", "/api/pin", Some(&body.to_string()))); + }); + // Only an engine with pairing ids holds the post open; one without + // answers, however slowly, and curl's own time limit bounds that. + let reply = if id.is_some() { + match rx.recv_timeout(PIN_TAKEN_AFTER) { + Ok(reply) => reply, Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Ok(Pin::Taken), Err(e) => bail!("PIN post: {e}"), - }; - if v["status"].as_bool() == Some(true) || v["status"].as_str() == Some("true") { - return Ok(Pin::Paired); } + } else { + rx.recv().context("PIN post")? + }; + let v = reply?; + if v["status"].as_bool() == Some(true) || v["status"].as_str() == Some("true") { + return Ok(Pin::Paired); } bail!("the streaming engine did not accept the PIN (is the Mac pairing right now?)") } @@ -504,6 +515,10 @@ pub enum Pin { /// The engine took the PIN and waits for the Mac to finish the /// handshake, which it does once it hears the PIN was entered. Taken, + /// The engine lists the requests waiting for a PIN, and none is this + /// Mac's yet: it asks a moment after it starts pairing, and a slow path + /// can bring the PIN first. Nothing was sent; the Mac tries again. + NotYet, } /// How long a PIN post may stay open before it counts as taken. An engine @@ -511,24 +526,76 @@ pub enum Pin { /// within this; one that took it waits for the Mac. pub const PIN_TAKEN_AFTER: Duration = Duration::from_millis(600); -/// The pairing ids `GET /api/pin` lists on a Sunshine master build. Every -/// other reply (a release Sunshine answers 200 `{"error":"Not Found"}`, an -/// old one 404, curl failing) means "no ids": still send the PIN once -/// without one, which is what those versions want. -fn pending_pairings(reply: Option) -> Vec> { - let ids: Vec> = reply - .as_ref() - .and_then(|v| v["pairings"].as_array()) - .map(|a| { - a.iter() - .filter_map(|p| p["id"].as_str().map(|s| Some(s.to_string()))) - .collect() +/// Which of the engine's waiting pairing requests a PIN is for. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Pairing { + /// The engine keeps no list (a release Sunshine answers 200 + /// `{"error":"Not Found"}`, an old one 404, or curl failed): send the + /// PIN once without an id, which is what those versions want. + Unlisted, + /// The request with this id. + Id(String), + /// The engine lists requests, but none is this machine's: an empty + /// list included, since 2026.10 turns down a PIN without an id. + NotYet, +} + +/// Pick the request `GET /api/pin` lists for the machine at `peer` called +/// `name`. Sunshine lists each as `{id, name, address}`, oldest first, and +/// keeps one for five minutes after its device gave up. The address decides +/// when there is one to go by; a local peer (the window, or every peer of a +/// Docker node, which reaches the engine through loopback too) falls back +/// to the name. A request from another address or under another name is +/// never picked: this machine's own may simply not have arrived yet. +fn choose_pairing(reply: Option<&serde_json::Value>, peer: IpAddr, name: &str) -> Pairing { + let Some(list) = reply.and_then(|v| v["pairings"].as_array()) else { + return Pairing::Unlisted; + }; + struct Entry<'a> { + id: &'a str, + name: Option<&'a str>, + address: Option, + } + let entries: Vec = list + .iter() + .filter_map(|p| { + Some(Entry { + id: p["id"].as_str().filter(|id| !id.is_empty())?, + name: p["name"].as_str().filter(|n| !n.is_empty()), + address: p["address"].as_str().and_then(|a| { + a.trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .ok() + .map(|ip| ip.to_canonical()) + }), + }) }) - .unwrap_or_default(); - if ids.is_empty() { - vec![None] - } else { - ids + .collect(); + let peer = peer.to_canonical(); + let one = |found: Vec<&Entry>| match found.as_slice() { + [only] => Some(Pairing::Id(only.id.to_string())), + _ => None, + }; + let named = |e: &&Entry| e.name == Some(name); + if !peer.is_loopback() { + let from_peer: Vec<&Entry> = entries.iter().filter(|e| e.address == Some(peer)).collect(); + if from_peer.len() > 1 { + return one(from_peer.into_iter().filter(named).collect()).unwrap_or(Pairing::NotYet); + } + if let Some(found) = one(from_peer) { + return found; + } + } + // By name, among the requests that do not plainly come from elsewhere. + let local = |e: &&Entry| e.address.is_none_or(|a| a.is_loopback() || a == peer); + if let Some(found) = one(entries.iter().filter(local).filter(named).collect()) { + return found; + } + // A build that lists bare ids: the only one there is. + match entries.as_slice() { + [only] if only.name.is_none() && only.address.is_none() => Pairing::Id(only.id.to_string()), + _ => Pairing::NotYet, } } @@ -555,6 +622,19 @@ fn stream_profile(current: &serde_json::Value, conf: &[(&str, &str)]) -> Result< Ok(config.into()) } +/// What a failed curl run says: its own message, which names its exit code +/// ("curl: (7) Failed to connect…", "curl: (28) Operation timed out…"), or +/// the code alone when it printed nothing. +fn curl_failure(code: Option, stderr: &[u8]) -> String { + let msg = String::from_utf8_lossy(stderr); + let msg = msg.trim(); + match code { + _ if !msg.is_empty() => msg.to_string(), + Some(code) => format!("curl exited with code {code}"), + None => "curl was stopped".into(), + } +} + /// Sunshine's config carries its own web login and the pairing secrets. /// Display diagnostics travel to the Mac, so those keys never leave the PC. fn is_secret(key: &str) -> bool { @@ -591,6 +671,79 @@ fn parse_reply(text: &str) -> Result { Ok(v) } +/// What the engine says about a stream right now. An app being launched is +/// not someone watching: a viewer that disconnects leaves its Desktop +/// running, to be resumed, until the engine restarts or something closes +/// it (Sunshine treats a Desktop with no command as a placebo that never +/// ends by itself). Only the engine's log says whether a viewer is there. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Session { + /// An app is launched (`currentgame` in `/serverinfo`); `None` when the + /// engine did not answer. + pub app_running: Option, + /// The last line of the engine's log that starts or ends a session is + /// a viewer connecting; `None` when the log has neither. + pub viewer: Option, +} + +impl Session { + /// Someone may be watching, counting what is unknown as yes: what + /// restarting the engine (which ends any stream) has to wait for. + pub fn maybe_watched(&self) -> bool { + self.app_running.unwrap_or(true) && self.viewer.unwrap_or(true) + } + + /// The log says a viewer is connected and the engine does not say + /// nothing runs. Swapping Latch's own executable never touches the + /// engine, so only this defers an update; an unknown never blocks one + /// forever. + pub fn watched(&self) -> bool { + self.viewer == Some(true) && self.app_running != Some(false) + } +} + +/// Ask the engine whether an app runs, and read its log for a viewer. +pub fn session(install: Option<&Install>) -> Session { + let app_running = latch_core::http::request( + ("127.0.0.1", SUNSHINE_PORT), + "GET", + "/serverinfo", + None, + Duration::from_secs(2), + ) + .ok() + .filter(|r| r.status == 200) + .and_then(|r| session_in_serverinfo(&r.body)); + let viewer = install + .and_then(log_text) + .as_deref() + .and_then(viewer_connected_in); + Session { + app_running, + viewer, + } +} + +/// Whether a viewer is connected, from the engine's log: the last line that +/// starts a session (`CLIENT CONNECTED`) or ends one decides. A session ends +/// with `CLIENT DISCONNECTED` when the viewer quits, but only with `: +/// Ping Timeout` when it vanished (a Mac that slept, Wi-Fi that dropped). +/// `None` when the log holds neither, as after the engine restarted (it +/// rewrites its log at every start). +pub(crate) fn viewer_connected_in(log: &str) -> Option { + // "Initial Ping Timeout" (a viewer that never got going) ends one too. + const ENDED: [&str; 2] = ["CLIENT DISCONNECTED", "Ping Timeout"]; + log.lines().rev().find_map(|line| { + if ENDED.iter().any(|k| line.contains(k)) { + Some(false) + } else if line.contains("CLIENT CONNECTED") { + Some(true) + } else { + None + } + }) +} + /// Whether the GameStream `/serverinfo` XML says an app is running: its /// `currentgame` is the running app's id, 0 when nothing streams. `None` /// when the reply does not say. @@ -725,6 +878,76 @@ mod tests { assert_eq!(session_in_serverinfo(""), None); } + /// stuzha's own log, 2026-10-17: two sessions that ended with a ping + /// timeout (the Mac slept) and none with a disconnect line, while + /// `/serverinfo` still said Desktop was running. + const STUZHA_LOG: &str = "\ +[2026-10-17 16:32:42.610]: Info: Executing Undo Cmd: [/usr/local/bin/stuzha-display 1920 1080] +[2026-10-17 16:32:44.410]: Info: Found H.264 encoder: libx264 [software] +[2026-10-17 16:32:44.916]: Info: Executing [Desktop] +[2026-10-17 16:32:45.085]: Info: CLIENT CONNECTED +[2026-10-17 16:33:08.397]: Info: 100.111.100.57: Ping Timeout +[2026-10-17 16:36:36.882]: Info: Found H.264 encoder: libx264 [software] +[2026-10-17 16:36:37.224]: Info: CLIENT CONNECTED +[2026-10-17 16:37:28.162]: Info: 100.111.100.57: Ping Timeout +"; + + #[test] + fn a_viewer_is_connected_only_until_its_session_ends() { + let connected = "[2026-10-14 19:00:00.000]: Info: CLIENT CONNECTED\n"; + let quit = format!("{connected}[2026-10-14 19:21:54.306]: Info: CLIENT DISCONNECTED\n"); + assert_eq!(viewer_connected_in(connected), Some(true)); + assert_eq!(viewer_connected_in(&quit), Some(false)); + assert_eq!( + viewer_connected_in(&format!("{quit}{connected}")), + Some(true), + "a later session counts" + ); + assert_eq!(viewer_connected_in(STUZHA_LOG), Some(false)); + assert_eq!( + viewer_connected_in("[x]: Info: 100.64.0.5:51234: Initial Ping Timeout\n"), + Some(false) + ); + // A fresh log (the engine restarted) says nothing either way. + assert_eq!( + viewer_connected_in("[x]: Info: Executing [Desktop]\n"), + None + ); + assert_eq!(viewer_connected_in(""), None); + } + + #[test] + fn a_desktop_left_to_resume_is_nobody_watching() { + let s = |app_running, viewer| Session { + app_running, + viewer, + }; + // stuzha after the Mac slept: Desktop still launched, nobody there. + let resumable = s(Some(true), viewer_connected_in(STUZHA_LOG)); + assert!(!resumable.watched()); + assert!(!resumable.maybe_watched()); + assert!(!crate::update::refuse_self_update( + true, + resumable.watched() + )); + + let live = s(Some(true), Some(true)); + assert!(live.watched() && live.maybe_watched()); + assert!(crate::update::refuse_self_update(true, live.watched())); + // The engine did not answer, but its log has a viewer: watched. + assert!(s(None, Some(true)).watched()); + // Nothing launched: whatever the log says, nobody is watching. + assert!(!s(Some(false), Some(true)).watched()); + assert!(!s(Some(false), None).maybe_watched()); + // Unknowns never block an update, which leaves the engine alone... + assert!(!s(None, None).watched()); + assert!(!s(Some(true), None).watched()); + // ...but do hold back an engine restart, which would end a stream. + assert!(s(None, None).maybe_watched()); + assert!(s(Some(true), None).maybe_watched()); + assert!(!s(None, Some(false)).maybe_watched()); + } + #[test] fn the_encoder_family_is_read_from_the_log() { let log = "[2026-09-07 10:00:00.001]: Info: // Testing for available encoders //\n\ @@ -787,6 +1010,19 @@ mod tests { ); } + #[test] + fn a_failed_engine_call_says_why() { + assert_eq!( + curl_failure( + Some(7), + b"curl: (7) Failed to connect to localhost port 47990: Connection refused\n" + ), + "curl: (7) Failed to connect to localhost port 47990: Connection refused" + ); + assert_eq!(curl_failure(Some(28), b""), "curl exited with code 28"); + assert_eq!(curl_failure(None, b" "), "curl was stopped"); + } + #[test] fn replies_are_parsed_and_errors_surfaced() { assert!(parse_reply(r#"{"status":true}"#).is_ok()); @@ -798,19 +1034,87 @@ mod tests { } #[test] - fn pin_is_sent_once_without_an_id_unless_sunshine_lists_some() { + fn the_pin_goes_to_the_requesters_own_pairing() { + let mac: IpAddr = "100.100.1.2".parse().unwrap(); + let name = "Daniel\u{2019}s MacBook Pro"; + let pick = + |reply: serde_json::Value, peer: IpAddr| choose_pairing(Some(&reply), peer, name); // Release Sunshine: 200 with a "Not Found" body, no pairings key. let not_found = serde_json::json!({"error": "Not Found", "status_code": 404}); - assert_eq!(pending_pairings(Some(not_found)), vec![None]); - assert_eq!(pending_pairings(None), vec![None]); + assert_eq!(pick(not_found, mac), Pairing::Unlisted); + assert_eq!(choose_pairing(None, mac, name), Pairing::Unlisted); + // 2026.10 lists none yet: posting without an id would be refused. + assert_eq!( + pick(serde_json::json!({"pairings": []}), mac), + Pairing::NotYet + ); + + let ipad = serde_json::json!({"id": "a", "name": "iPad", "address": "100.64.0.5"}); + let ours = serde_json::json!({"id": "b", "name": name, "address": "100.100.1.2"}); + // Another device's request is listed first (oldest first). + assert_eq!( + pick(serde_json::json!({"pairings": [ipad, ours]}), mac), + Pairing::Id("b".into()) + ); + // Only someone else's is there: ours has not arrived yet. + assert_eq!( + pick(serde_json::json!({"pairings": [ipad]}), mac), + Pairing::NotYet + ); + // An IPv4-mapped address is the same machine. + let mapped = serde_json::json!({"id": "c", "name": "x", "address": "::ffff:100.100.1.2"}); + assert_eq!( + pick(serde_json::json!({"pairings": [ipad, mapped]}), mac), + Pairing::Id("c".into()) + ); + // Two from one address: the name tells them apart, or nothing does. + let moonlight = + serde_json::json!({"id": "d", "name": "Moonlight", "address": "100.100.1.2"}); + assert_eq!( + pick(serde_json::json!({"pairings": [moonlight, ours]}), mac), + Pairing::Id("b".into()) + ); + assert_eq!( + pick(serde_json::json!({"pairings": [moonlight, moonlight]}), mac), + Pairing::NotYet + ); + + // A Docker node: every peer, and every request, is loopback. + let local: IpAddr = "127.0.0.1".parse().unwrap(); + let node = + |id: &str, n: &str| serde_json::json!({"id": id, "name": n, "address": "127.0.0.1"}); + assert_eq!( + pick( + serde_json::json!({"pairings": [node("a", "iPad"), node("b", name)]}), + local + ), + Pairing::Id("b".into()) + ); + assert_eq!( + pick( + serde_json::json!({"pairings": [node("a", name), node("b", name)]}), + local + ), + Pairing::NotYet + ); + // The same name from another address is someone else. + let twin = serde_json::json!({"id": "e", "name": name, "address": "100.64.0.9"}); + assert_eq!( + pick(serde_json::json!({"pairings": [twin]}), mac), + Pairing::NotYet + ); + + // Builds that list bare ids: the only one, never a guess among two. assert_eq!( - pending_pairings(Some(serde_json::json!({"pairings": []}))), - vec![None] + pick(serde_json::json!({"pairings": [{"id": "f"}]}), mac), + Pairing::Id("f".into()) ); - let master = serde_json::json!({"pairings": [{"id": "a", "name": "mac"}, {"id": "b"}]}); assert_eq!( - pending_pairings(Some(master)), - vec![Some("a".to_string()), Some("b".to_string())] + pick( + serde_json::json!({"pairings": [{"id": "f"}, {"id": "g"}]}), + mac + ), + Pairing::NotYet ); } diff --git a/crates/host/src/update.rs b/crates/host/src/update.rs index 3de9057..1f9de01 100644 --- a/crates/host/src/update.rs +++ b/crates/host/src/update.rs @@ -67,8 +67,11 @@ fn with_suffix(exe: &Path, suffix: &str) -> PathBuf { exe.with_file_name(name) } -pub fn refuse_self_update(engine_running: bool, session_active: bool) -> bool { - engine_running && session_active +/// Whether to put an update off: only while a viewer is actually connected. +/// A Desktop left launched for a viewer to resume is no reason; refusing +/// then kept a PC from updating until it next restarted. +pub fn refuse_self_update(engine_running: bool, someone_watching: bool) -> bool { + engine_running && someone_watching } /// Check the upload and stage it beside `exe`. Returns the version it From b1ea9859d35f8fe207d4c4f12bf18fa52b530f16 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:48:17 -0700 Subject: [PATCH 03/34] Control API: under userspace Tailscale, ask who is behind a loopback connection The Docker node runs tailscaled with --tun=userspace-networking, which relays every tailnet peer's TCP connection from 127.0.0.1. The service took any loopback connection for its own window, so any machine the ACL let reach port 47850 skipped the owner check: it could approve its own pairing PIN, read the clipboard, power the node off, read the service log and quit it. Status now reads Tailscale's TUN flag (kept once seen, and read before the first request). With TUN false, a loopback request is judged after it has been read, when tailscaled has recorded the relayed peer: `tailscale whois --proto=tcp 127.0.0.1:` names it and Owner::admits decides, never as local (no log, no /v1/quit). Only a port Tailscale knows nothing about is a local process; a failed lookup admits nobody. Loopback answers are never cached, since every relayed peer shares 127.0.0.1. Kernel-TUN machines (the Mac, Windows, stuzha) keep today's behaviour with no extra whois. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/tailscale.rs | 44 +++++++++ crates/host/src/service.rs | 181 ++++++++++++++++++++++++++++++++++- 2 files changed, 220 insertions(+), 5 deletions(-) diff --git a/crates/core/src/tailscale.rs b/crates/core/src/tailscale.rs index 02350f8..cd3c216 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -70,6 +70,11 @@ pub struct Status { pub user: BTreeMap, /// The tailnet this machine is in. Absent before Tailscale 1.26. pub current_tailnet: Option, + /// Whether tailscaled runs a kernel TUN device. `false` is userspace + /// networking (a container's tailscaled), where every tailnet peer + /// reaches a local service from 127.0.0.1; `None` when it does not say. + #[serde(rename = "TUN")] + pub tun: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -275,6 +280,32 @@ pub fn whois(ip: std::net::IpAddr) -> Result { serde_json::from_str(&out).context("parse tailscale whois") } +/// `tailscale whois` for a connection that reached this machine from +/// `addr`, a loopback `ip:port`. Under userspace networking tailscaled +/// relays each tailnet peer's TCP connection from loopback and remembers +/// which peer every local port belongs to. `Ok(None)` when it knows of +/// none: a process on this machine made the connection. +pub fn whois_addr(addr: std::net::SocketAddr) -> Result> { + let cli = cli().ok_or_else(|| anyhow::anyhow!("Tailscale is not installed"))?; + let out = run_limited( + Command::new(cli).args(["whois", "--json", "--proto=tcp", &addr.to_string()]), + Duration::from_secs(2), + ); + match out { + Ok(out) => serde_json::from_str(&out) + .map(Some) + .context("parse tailscale whois"), + Err(e) if no_such_peer(&e.to_string()) => Ok(None), + Err(e) => Err(e), + } +} + +/// The CLI's answer for an address no peer is behind: it logs +/// "2026/10/17 16:46:33 peer not found" and exits 1. +fn no_such_peer(err: &str) -> bool { + err.contains("peer not found") || err.contains("no match for") +} + /// `tailscale netcheck --format=json`, the parts that explain why a peer /// is relayed: whether UDP works at all, what kind of NAT this machine is /// behind, and whether the router will map a port for it. @@ -740,6 +771,19 @@ mod tests { assert!(parse_status(SAMPLE).unwrap().current_tailnet.is_none()); } + #[test] + fn userspace_networking_is_told_from_a_kernel_tun() { + let st = parse_status(r#"{"BackendState":"Running","TUN":false}"#).unwrap(); + assert_eq!(st.tun, Some(false)); + let st = parse_status(r#"{"BackendState":"Running","TUN":true}"#).unwrap(); + assert_eq!(st.tun, Some(true)); + assert_eq!(parse_status(SAMPLE).unwrap().tun, None, "older versions"); + assert!(no_such_peer("2026/10/17 16:46:33 peer not found")); + assert!(no_such_peer("no match for IP:port 127.0.0.1:5")); + assert!(!no_such_peer("tailscale timed out")); + assert!(!no_such_peer("failed to connect to local Tailscale daemon")); + } + #[test] fn whois_is_parsed() { let w: WhoIs = serde_json::from_str( diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 21c8412..26f9abe 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -8,6 +8,10 @@ //! more: a machine shared in from someone else's tailnet can reach the //! port, but may not pair, sleep the PC, read its clipboard or push it an //! executable. +//! +//! Under userspace Tailscale (a container's tailscaled) every tailnet peer +//! arrives from loopback too, so there loopback alone proves nothing: +//! `tailscale whois` of the connection's own port says who is behind it. use crate::clipboard; use crate::config::HostConfig; @@ -61,6 +65,12 @@ pub struct Service { auth: Mutex>, /// The last `whois` failure logged. whois_failed: Mutex>, + /// Whether Tailscale here has a kernel TUN device, as last reported. + /// Kept when a later `status` fails: `Some(false)` (userspace) is what + /// makes loopback a peer's address too, and must not lapse. + tun: Mutex>, + /// The last line logged about a peer forwarded through loopback. + forwarded_logged: Mutex>, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -87,6 +97,8 @@ impl Service { log: Arc::new(Mutex::new(VecDeque::new())), auth: Mutex::new(HashMap::new()), whois_failed: Mutex::new(None), + tun: Mutex::new(None), + forwarded_logged: Mutex::new(None), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), @@ -162,6 +174,11 @@ impl Service { } self.ensure_autostart(&exe); } + // Before the first request: under userspace Tailscale every tailnet + // peer arrives from loopback, so that has to be known first. + if let Ok(st) = tailscale::status() { + self.note_tun(&st); + } let refresher = self.clone(); std::thread::spawn(move || refresher.refresh_loop()); // (refresh_loop takes the Arc so netcheck can run on its own thread.) @@ -213,6 +230,9 @@ impl Service { } let ts = tailscale::status().map_err(|e| e.to_string()); + if let Ok(st) = &ts { + self.note_tun(st); + } let came_up; { let mut cur = self.tailscale.lock(); @@ -512,9 +532,59 @@ impl Service { } } - /// Loopback is the control panel; a tailnet peer must pass - /// [`Owner::admits`]. A `whois` answer is remembered for [`AUTH_TTL`], - /// a refusal included; a failed `whois` is not remembered. + fn note_tun(&self, st: &tailscale::Status) { + if let Some(tun) = st.tun { + *self.tun.lock() = Some(tun); + } + } + + /// Who sent a request, once it has been read. A loopback connection is + /// a process on this machine, unless Tailscale runs in userspace mode: + /// then it may be a tailnet peer that tailscaled relayed, which must + /// pass [`Owner::admits`] like any other and never counts as local. + /// Asked after the request is read, when tailscaled has certainly + /// recorded which peer the connection is from (it does so only after + /// it has connected to this port). Never cached: every relayed peer + /// shares 127.0.0.1. + fn caller(&self, peer: SocketAddr) -> Caller { + if !peer.ip().is_loopback() { + return Caller::Peer { + allowed: self.authorized(peer.ip()), + }; + } + let tun = *self.tun.lock(); + let owner = self.tailscale.lock().as_ref().ok().map(Owner::of); + loopback_caller(tun, owner.as_ref(), || { + let found = tailscale::whois_addr(peer); + let line = match &found { + Ok(Some(w)) => Some(format!( + "{} ({}) asked through userspace Tailscale{}", + w.node.computed_name, + w.user_profile.login_name, + if owner.as_ref().is_some_and(|o| o.admits(w)) { + "" + } else { + ": not this account, refused" + } + )), + Ok(None) => None, + Err(e) => Some(format!("whois {peer}: {e}; refused")), + }; + if let Some(line) = line { + let mut last = self.forwarded_logged.lock(); + if last.as_deref() != Some(line.as_str()) { + self.log(line.clone()); + *last = Some(line); + } + } + found + }) + } + + /// Loopback may connect (see [`Service::caller`] for what it may do); + /// a tailnet peer must pass [`Owner::admits`]. A `whois` answer is + /// remembered for [`AUTH_TTL`], a refusal included; a failed `whois` is + /// not remembered. fn authorized(&self, ip: IpAddr) -> bool { if ip.is_loopback() { return true; @@ -593,7 +663,8 @@ impl Service { &Ack::err("browser access to the control API is disabled"), ); } - if !self.authorized(peer.ip()) { + let caller = self.caller(peer); + if !caller.allowed() { return Response::json( 403, &Ack::err("not a machine on this PC's Tailscale account"), @@ -612,7 +683,8 @@ impl Service { &Ack::err("a Latch request carries JSON or an executable"), ); } - let local = peer.ip().is_loopback(); + // The service log and /v1/quit are for this machine's own window. + let local = caller == Caller::Local; match (req.method.as_str(), req.path.as_str()) { ("GET", "/v1/status") => Response::json(200, &self.status(local)), ("GET", "/v1/display") => Response::json(200, &self.display()), @@ -873,6 +945,45 @@ pub fn describe_nat(n: &NatReport) -> String { } } +/// Who sent a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Caller { + /// A process on this machine: its own window, or a script. + Local, + /// A tailnet machine, and whether this machine's owner admits it. + Peer { allowed: bool }, +} + +impl Caller { + fn allowed(self) -> bool { + match self { + Caller::Local => true, + Caller::Peer { allowed } => allowed, + } + } +} + +/// Who is behind a loopback connection. Only with userspace networking +/// (`TUN` false) can it be a tailnet peer; then `whois` of the connection's +/// address says which, and an address Tailscale knows nothing about is a +/// process on this machine. A lookup that fails admits nobody. +fn loopback_caller( + tun: Option, + owner: Option<&Owner>, + whois: impl FnOnce() -> Result>, +) -> Caller { + if tun != Some(false) { + return Caller::Local; + } + match whois() { + Ok(None) => Caller::Local, + Ok(Some(w)) => Caller::Peer { + allowed: owner.is_some_and(|o| o.admits(&w)), + }, + Err(_) => Caller::Peer { allowed: false }, + } +} + /// Whose machine this is, for deciding who may use the control API. #[derive(Debug, Clone, PartialEq)] struct Owner { @@ -1110,6 +1221,66 @@ mod tests { assert!(!unnamed.admits(&relay)); } + /// A Docker node's tailscaled in userspace mode relays every tailnet + /// peer from 127.0.0.1: only `whois` of the port tells them apart from + /// the node's own processes. + #[test] + fn a_peer_relayed_through_loopback_is_judged_like_any_other() { + let owner = Owner { + user: 21, + tagged: false, + tailnet: "ada@example.com".into(), + }; + let never = || -> Result> { panic!("no whois needed") }; + // A kernel TUN, or a Tailscale that does not say: loopback is local. + assert_eq!( + loopback_caller(Some(true), Some(&owner), never), + Caller::Local + ); + assert_eq!(loopback_caller(None, Some(&owner), never), Caller::Local); + // Userspace: whoever whois finds behind the port. + let found = |id, login: &str| { + let w = whois(id, login); + move || Ok(Some(w)) + }; + assert_eq!( + loopback_caller(Some(false), Some(&owner), found(21, "ada@example.com")), + Caller::Peer { allowed: true }, + "the owner's Mac may ask, but is not the local window" + ); + assert_eq!( + loopback_caller(Some(false), Some(&owner), found(77, "bob@example.net")), + Caller::Peer { allowed: false } + ); + assert_eq!( + loopback_caller(Some(false), None, found(21, "ada@example.com")), + Caller::Peer { allowed: false }, + "nobody to compare with while Tailscale is down" + ); + assert_eq!( + loopback_caller(Some(false), Some(&owner), || Ok(None)), + Caller::Local, + "no peer behind the port: a process on this machine" + ); + assert_eq!( + loopback_caller(Some(false), Some(&owner), || anyhow::bail!("timed out")), + Caller::Peer { allowed: false }, + "a failed lookup admits nobody" + ); + assert!(!Caller::Peer { allowed: false }.allowed()); + assert!(Caller::Local.allowed()); + + // The window keeps working, and loopback never enters the cache. + let svc = Service::new(); + let req = Request { + method: "GET".into(), + path: "/v1/status".into(), + ..Default::default() + }; + assert_eq!(svc.handle("127.0.0.1:5".parse().unwrap(), &req).status, 200); + assert!(svc.auth.lock().is_empty()); + } + #[test] fn browsers_and_rebound_hostnames_cannot_read_or_write_the_api() { let svc = Service::new(); From 5ccf2a22e154a42640d171293ed3004344c694d8 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:51:12 -0700 Subject: [PATCH 04/34] Tailscale: read null maps, keep LAN addresses out of public_ip, idle paths unknown, fix DERP ids - `tailscale status --json` writes empty maps and lists as null ("Peer": null on a machine alone in its tailnet; Peer, User and Self.TailscaleIPs when signed out). The parse failed, so a fresh host showed "parse tailscale status" instead of working, and a signed-out Mac never said so. Nulls now read as empty, and the state reads "signed out", not "needslogin". - public_ipv4() took any CurAddr, so two machines on one LAN overwrote the stored wake address (the router that forwards UDP 9) with a LAN address. Only internet addresses count now. - direct() called every idle peer relayed, because Relay (the home DERP) is set for all of them; a direct PC flashed "Relayed via Los Angeles" at every launch. Without traffic the path is unknown, as documented. - machine_peers() listed Mullvad's exit nodes (hundreds with the add-on) and machines this one is shared out to, which `tailscale status` hides. - The DERP id table was off by one from 14 up (Amsterdam was missing), so a Mac in Los Angeles logged "nearest relay Paris". The table now matches 1.102's derp-map, and the host prefers the home region Tailscale names in its own status. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/tailscale.rs | 280 ++++++++++++++++++++++++++++++----- crates/host/src/service.rs | 6 +- 2 files changed, 250 insertions(+), 36 deletions(-) diff --git a/crates/core/src/tailscale.rs b/crates/core/src/tailscale.rs index cd3c216..10bede3 100644 --- a/crates/core/src/tailscale.rs +++ b/crates/core/src/tailscale.rs @@ -59,14 +59,19 @@ fn find_cli() -> Option { }) } -/// `tailscale status --json`, the parts Latch reads. +/// `tailscale status --json`, the parts Latch reads. Tailscale writes an +/// empty map or list as `null` (`"Peer": null` on a machine alone in its +/// tailnet; `Peer`, `User` and `Self.TailscaleIPs` signed out), which reads +/// as empty here rather than failing the whole status. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "PascalCase")] pub struct Status { pub backend_state: String, - #[serde(rename = "Self")] + #[serde(rename = "Self", deserialize_with = "null_as_default")] pub self_node: Node, + #[serde(deserialize_with = "null_as_default")] pub peer: BTreeMap, + #[serde(deserialize_with = "null_as_default")] pub user: BTreeMap, /// The tailnet this machine is in. Absent before Tailscale 1.26. pub current_tailnet: Option, @@ -98,14 +103,16 @@ pub struct Node { pub os: String, #[serde(rename = "UserID")] pub user_id: u64, - #[serde(rename = "TailscaleIPs")] + #[serde(rename = "TailscaleIPs", deserialize_with = "null_as_default")] pub tailscale_ips: Vec, pub online: bool, - /// The public `ip:port` of the peer's last direct path, if any. + /// The `ip:port` of the peer's last direct path, if any: its public + /// address, or its LAN address when both are on one network. #[serde(rename = "CurAddr")] pub cur_addr: String, /// The peer's home DERP region code ("tok", "lax"), which is where its - /// packets go when there is no direct path. + /// packets go when there is no direct path. Every peer has one, whether + /// or not its packets go that way. pub relay: String, /// The peer relay carrying this path, as `ip:port:vni:id`, when /// Tailscale found one. Empty for a direct or DERP path, and always @@ -114,14 +121,18 @@ pub struct Node { #[serde(rename = "PeerRelay")] pub peer_relay: String, /// ACL tags ("tag:relay"), present only on tagged nodes. + #[serde(deserialize_with = "null_as_default")] pub tags: Vec, - /// Traffic has flowed recently, so `cur_addr` and `relay` describe the - /// path in use rather than a guess. + /// Traffic has flowed recently, so an empty `cur_addr` means the + /// packets go through a relay rather than that nobody knows yet. pub active: bool, /// When this machine's node key stops working (RFC 3339), unless key /// expiry is disabled for it in the admin console; a zero time then. pub key_expiry: String, pub expired: bool, + /// A node `tailscale status` hides: it is only here because this + /// machine is shared out to its owner. + pub sharee_node: bool, } impl Node { @@ -162,16 +173,29 @@ impl Node { pub fn is_relay(&self) -> bool { self.tags.iter().any(|t| t == RELAY_TAG) } - /// The peer's public IPv4 address, when Tailscale reached it directly. + /// An exit node of the Mullvad add-on (hundreds of them, one per + /// location), which is never a machine of the user's. Told apart by its + /// name, as the CLI does: an ordinary exit node can carry a location too. + pub fn is_mullvad(&self) -> bool { + self.dns_name + .trim_end_matches('.') + .to_ascii_lowercase() + .ends_with(".mullvad.ts.net") + } + /// The peer's public IPv4 address, when Tailscale reached it directly + /// over the internet. A direct path inside one network is a LAN address, + /// which says nothing about the router a wake packet must go to. pub fn public_ipv4(&self) -> Option { - self.cur_addr.rsplit_once(':')?.0.parse().ok() + let ip: Ipv4Addr = self.cur_addr.rsplit_once(':')?.0.parse().ok()?; + is_public_v4(ip).then_some(ip) } /// Packets reach this peer directly rather than through a DERP relay. - /// Unknown until traffic has flowed; `None` then. + /// Unknown until traffic has flowed; `None` then, even though `relay` + /// names a home region for every peer. pub fn direct(&self) -> Option { if !self.cur_addr.is_empty() { Some(true) - } else if self.active || !self.relay.is_empty() { + } else if self.active { Some(false) } else { None @@ -179,6 +203,28 @@ impl Node { } } +/// An IPv4 address on the internet: not private, loopback, link-local, +/// broadcast or multicast, and not in 100.64.0.0/10 (Tailscale's own, and +/// carrier-grade NAT). +pub fn is_public_v4(ip: Ipv4Addr) -> bool { + !(ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_multicast() + || is_tailnet(ip)) +} + +/// A JSON `null` read as the type's empty value. +fn null_as_default<'de, D, T>(d: D) -> std::result::Result +where + D: serde::Deserializer<'de>, + T: Default + Deserialize<'de>, +{ + Ok(Option::::deserialize(d)?.unwrap_or_default()) +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default, rename_all = "PascalCase")] pub struct User { @@ -198,18 +244,37 @@ impl Status { .map(|u| u.login_name.as_str()) } /// Every tailnet peer that can share a desktop: any OS, minus `tag:relay` - /// nodes that exist only to carry packets. Sorted by hostname. + /// nodes that exist only to carry packets, and the nodes `tailscale + /// status` itself hides (machines this one is shared out to, Mullvad's + /// exit nodes). Sorted by hostname. pub fn machine_peers(&self) -> Vec<&Node> { - let mut v: Vec<&Node> = self.peer.values().filter(|n| !n.is_relay()).collect(); + let mut v: Vec<&Node> = self.listed_peers().filter(|n| !n.is_relay()).collect(); v.sort_by(|a, b| a.host_name.cmp(&b.host_name)); v } /// Nodes tagged `tag:relay`, any OS. Sorted by hostname like the PC list. pub fn relay_peers(&self) -> Vec<&Node> { - let mut v: Vec<&Node> = self.peer.values().filter(|n| n.is_relay()).collect(); + let mut v: Vec<&Node> = self.listed_peers().filter(|n| n.is_relay()).collect(); v.sort_by(|a, b| a.host_name.cmp(&b.host_name)); v } + /// The peers `tailscale status` lists. + fn listed_peers(&self) -> impl Iterator { + self.peer + .values() + .filter(|n| !n.sharee_node && !n.is_mullvad()) + } +} + +/// What a backend state other than "Running" means, for people. +pub fn state_words(backend_state: &str) -> String { + match backend_state { + "" | "NoState" => "not running".into(), + "NeedsLogin" => "signed out".into(), + "NeedsMachineAuth" => "waiting for an admin to approve this machine".into(), + "InUseOtherUser" => "in use by another user of this computer".into(), + other => other.to_lowercase(), + } } pub fn parse_status(json: &str) -> Result { @@ -243,14 +308,7 @@ pub fn status() -> Result { let out = run(Command::new(cli).args(["status", "--json"]))?; let st = parse_status(&out)?; if !st.running() { - bail!( - "Tailscale is {}", - if st.backend_state.is_empty() { - "not running".into() - } else { - st.backend_state.to_lowercase() - } - ); + bail!("Tailscale is {}", state_words(&st.backend_state)); } Ok(st) } @@ -334,6 +392,17 @@ pub struct NetCheck { } impl NetCheck { + /// [`NetCheck::report`] with the home relay region Tailscale itself + /// names (`Self.Relay` in its status), when there is one: its own name + /// for a region cannot fall out of date like Latch's table of ids. + pub fn report_near(&self, home: &str) -> crate::api::NatReport { + let mut r = self.report(); + if !home.is_empty() { + r.derp = home.to_string(); + } + r + } + /// The summary the host puts in its status and the client shows. pub fn report(&self) -> crate::api::NatReport { crate::api::NatReport { @@ -374,6 +443,8 @@ pub fn is_tailnet_ip(ip: IpAddr) -> bool { } /// The three-letter code of a DERP region, as `tailscale status` prints it. +/// From `tailscale debug derp-map` (Tailscale 1.102, October 2026); a +/// region added later has no code here, and its id says nothing. pub fn derp_code(region: i32) -> &'static str { match region { 1 => "nyc", @@ -389,20 +460,21 @@ pub fn derp_code(region: i32) -> &'static str { 11 => "sao", 12 => "ord", 13 => "den", - 14 => "jnb", - 15 => "mia", - 16 => "lax", - 17 => "par", - 18 => "mad", - 19 => "ams", + 14 => "ams", + 15 => "jnb", + 16 => "mia", + 17 => "lax", + 18 => "par", + 19 => "mad", 20 => "hkg", 21 => "tor", 22 => "waw", - 23 => "nai", + 23 => "dbi", 24 => "hnl", - 25 => "nue", - 26 => "hel", - 27 => "dbi", + 25 => "nai", + 26 => "nue", + 27 => "iad", + 28 => "hel", _ => "", } } @@ -437,6 +509,7 @@ pub fn derp_city(code: &str) -> &str { "nue" => "Nuremberg", "hel" => "Helsinki", "dbi" => "Dubai", + "iad" => "Ashburn", other => other, } } @@ -540,7 +613,7 @@ mod tests { "UserID": 42, "TailscaleIPs": ["fd7a::2", "100.64.0.30"], "Online": true, "CurAddr": "203.0.113.5:41641", "Relay": "lax", "Active": true, "KeyExpiry": "2999-03-02T23:03:00Z"}, "nodekey:c": {"ID": "nPC1", "HostName": "Den", "DNSName": "den.example.ts.net.", "OS": "windows", - "UserID": 43, "TailscaleIPs": ["100.64.0.31"], "Online": false}, + "UserID": 43, "TailscaleIPs": ["100.64.0.31"], "Online": false, "Relay": "sea"}, "nodekey:d": {"ID": "nPC3", "HostName": "Gaming-PC-2", "OS": "windows", "UserID": 42, "TailscaleIPs": ["100.64.0.11"], "Online": true, "CurAddr": "", "Relay": "tok", "Active": true} }, @@ -581,7 +654,7 @@ mod tests { assert!(pcs[2].key_expiry_days().unwrap() > 300_000); assert_eq!(pcs[0].key_expiry_days(), None); // Office is reached directly; Gaming-PC-2 only through the Tokyo relay; - // Den has not been talked to, so nobody knows. + // Den has not been talked to, so nobody knows, home relay or not. assert_eq!(pcs[2].direct(), Some(true)); assert_eq!(pcs[1].direct(), Some(false)); assert_eq!(pcs[1].relay, "tok"); @@ -602,13 +675,16 @@ mod tests { assert!(!r.portmap); assert_eq!(r.derp, "tok"); let hard = parse_netcheck( - r#"{"UDP":true,"MappingVariesByDestIP":true,"UPnP":true,"PreferredDERP":16}"#, + r#"{"UDP":true,"MappingVariesByDestIP":true,"UPnP":true,"PreferredDERP":17}"#, ) .unwrap(); let r = hard.report(); assert_eq!(r.hard, Some(true)); assert!(r.portmap, "UPnP counts as a port mapping"); assert_eq!(r.derp, "lax"); + // Tailscale's own name for the home region wins over the table. + assert_eq!(hard.report_near("sfo").derp, "sfo"); + assert_eq!(hard.report_near("").derp, "lax"); assert_eq!(derp_city("lax"), "Los Angeles"); assert_eq!(derp_city("tok"), "Tokyo"); assert_eq!(derp_city("zzz"), "zzz"); @@ -618,6 +694,140 @@ mod tests { assert_eq!(unknown.report().derp, ""); } + /// `tailscale debug derp-map` on 1.102: the ids from 14 up moved when + /// Amsterdam came in, which put a Mac in Los Angeles near "Paris". + #[test] + fn derp_ids_match_tailscales_map() { + for (id, code) in [ + (13, "den"), + (14, "ams"), + (15, "jnb"), + (16, "mia"), + (17, "lax"), + (18, "par"), + (19, "mad"), + (23, "dbi"), + (25, "nai"), + (26, "nue"), + (27, "iad"), + (28, "hel"), + ] { + assert_eq!(derp_code(id), code, "region {id}"); + assert_ne!(derp_city(code), code, "{code} has a city"); + } + } + + /// Real 1.102 output keeps `null` for empty maps and lists. + #[test] + fn null_maps_and_lists_read_as_empty() { + // Signed in, and alone in the tailnet. + let alone = parse_status( + r#"{"BackendState":"Running","TUN":true, + "Self":{"ID":"n1","HostName":"pc","OS":"windows","UserID":5, + "TailscaleIPs":["100.64.0.5"],"Tags":null}, + "CurrentTailnet":{"Name":"a@b.c"},"CertDomains":null,"Peer":null, + "User":{"5":{"ID":5,"LoginName":"a@b.c"}}}"#, + ) + .unwrap(); + assert!(alone.running()); + assert!(alone.machine_peers().is_empty()); + assert_eq!(alone.self_login(), Some("a@b.c")); + assert_eq!(alone.self_node.ipv4(), Some("100.64.0.5".parse().unwrap())); + // Signed out. + let out = parse_status( + r#"{"BackendState":"NeedsLogin","TailscaleIPs":null, + "Self":{"ID":"","HostName":"pc","OS":"windows","UserID":0, + "TailscaleIPs":null,"Addrs":[]}, + "CurrentTailnet":null,"CertDomains":null,"Peer":null,"User":null}"#, + ) + .unwrap(); + assert!(!out.running()); + assert_eq!(out.self_node.ipv4(), None); + assert_eq!(state_words(&out.backend_state), "signed out"); + assert!(parse_status(r#"{"Self":null}"#).is_ok()); + assert_eq!(state_words(""), "not running"); + assert_eq!(state_words("Stopped"), "stopped"); + assert_eq!(state_words("Starting"), "starting"); + } + + #[test] + fn only_an_internet_address_is_a_public_one() { + let at = |cur: &str| Node { + cur_addr: cur.into(), + ..Default::default() + }; + assert_eq!( + at("203.0.113.5:41641").public_ipv4(), + Some("203.0.113.5".parse().unwrap()) + ); + assert_eq!( + at("116.125.113.36:41641").public_ipv4(), + Some("116.125.113.36".parse().unwrap()) + ); + // Two machines on one network go direct over the LAN. + for lan in [ + "192.168.55.57:41641", + "10.0.0.5:41641", + "172.16.3.4:41641", + "169.254.1.1:41641", + "100.100.1.1:41641", + "127.0.0.1:41641", + "[2001:db8::1]:41641", + "", + ] { + assert_eq!(at(lan).public_ipv4(), None, "{lan}"); + } + } + + #[test] + fn an_idle_peer_has_an_unknown_path_whatever_its_home_relay() { + let st = parse_status( + r#"{"BackendState":"Running","Peer":{ + "a": {"HostName":"stuzha","Online":true,"Active":false,"CurAddr":"","Relay":"sfo"}, + "b": {"HostName":"hermes","Online":true,"Active":true,"CurAddr":"","Relay":"tok"}, + "c": {"HostName":"office","Online":true,"Active":false,"CurAddr":"203.0.113.9:41641","Relay":"lax"}, + "d": {"HostName":"pc","Online":true,"Active":false,"CurAddr":"","Relay":"lax", + "PeerRelay":"100.64.0.40:40000:vni:17"} + }}"#, + ) + .unwrap(); + assert_eq!(st.peer["a"].direct(), None, "nothing sent yet"); + assert_eq!(st.peer["b"].direct(), Some(false), "traffic, but relayed"); + assert_eq!( + st.peer["c"].direct(), + Some(true), + "a direct path still trusted" + ); + assert_eq!(st.peer["d"].direct(), None); + } + + #[test] + fn mullvad_and_sharee_nodes_are_no_machines() { + let st = parse_status( + r#"{"BackendState":"Running","Peer":{ + "a": {"HostName":"us-lax-wg-001","DNSName":"us-lax-wg-001.mullvad.ts.net.","OS":"linux", + "Online":true,"ExitNodeOption":true,"Location":{"Country":"USA","City":"Los Angeles"}}, + "b": {"HostName":"se-sto-wg-002","DNSName":"se-sto-wg-002.mullvad.ts.net.","OS":"linux", + "Online":true,"ExitNodeOption":true,"ExitNode":true}, + "c": {"HostName":"friends-laptop","DNSName":"friends-laptop.other.ts.net.","OS":"macOS", + "Online":true,"ShareeNode":true}, + "d": {"HostName":"vps","DNSName":"vps.example.ts.net.","OS":"linux","Online":true, + "ExitNodeOption":true,"Location":{"Country":"USA","City":"San Jose"}}, + "e": {"HostName":"Gaming-PC","DNSName":"gaming-pc.example.ts.net.","OS":"windows","Online":true} + }}"#, + ) + .unwrap(); + assert_eq!( + st.machine_peers() + .iter() + .map(|n| n.host_name.as_str()) + .collect::>(), + ["Gaming-PC", "vps"], + "an exit node of the user's own stays" + ); + assert!(st.relay_peers().is_empty()); + } + #[test] fn a_stopped_daemon_is_not_running() { let st = parse_status(r#"{"BackendState":"Stopped"}"#).unwrap(); diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 26f9abe..05b08b2 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -422,7 +422,11 @@ impl Service { std::thread::spawn(move || { match tailscale::netcheck() { Ok(n) => { - let report = n.report(); + let home = match &*svc.tailscale.lock() { + Ok(st) => st.self_node.relay.clone(), + Err(_) => String::new(), + }; + let report = n.report_near(&home); let mut cur = svc.nat.lock(); if cur.as_ref().is_none_or(|c| nat_changed(c, &report)) { svc.log(describe_nat(&report)); From 898fab57a4040f07ef94739cef9bbbdd6f2f5306 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:57:29 -0700 Subject: [PATCH 05/34] UI: dark style in Light mode, menus that close cleanly, screen-reader fixes apply() only replaced egui's dark style. egui picks the style by the system theme every frame, so a Mac or Windows PC in Light mode drew with egui's stock light style, which has no caption or title text styles, and the first caption panicked: Latch quit at launch, or mid-stream when Auto appearance flipped at sunrise. The theme is now pinned to dark and both slots get Latch's style. Menus: an Escape that closes a menu or a select list is used up, so the stream no longer forwards it to the PC and Stream settings no longer closes behind a list. A menu is known by its label instead of its button's position, so a row added above no longer hands an open "More for X" menu to another machine, and a menu whose button is no longer drawn (the toolbar folded into More, the stream ended) is closed at the end of the pass instead of blocking stream input forever. Accessibility: a select is named after its setting row and reports its choice; the chosen option in a list is drawn in full-strength text (the accent on the accent wash was 3.7:1); the bitrate slider takes a screen reader's increment, decrement and set-value, and reports its range. A spinner in a window that is not in front slows to four frames a second, and the system's Korean/CJK font is the last fallback of every family, so a Korean PC's error messages are no longer empty boxes. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/ui/Cargo.toml | 7 +- crates/ui/src/theme.rs | 126 +++++++++++++- crates/ui/src/widgets.rs | 362 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 478 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 251adfc..fe1fd93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2206,6 +2206,7 @@ dependencies = [ name = "latch-ui" version = "4.1.1" dependencies = [ + "ab_glyph", "egui", "latch-core", ] diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index b17f9d0..4e1c035 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -8,4 +8,9 @@ description = "Shared egui theme and widgets for the Latch host and client" [dependencies] latch-core.workspace = true -egui.workspace = true +# accesskit: the custom slider answers a screen reader's increment and +# decrement, as egui's own does. eframe turns the feature on anyway. +egui = { workspace = true, features = ["accesskit"] } +# The parser egui's fonts use, to check a system font before handing it +# over: egui panics on one it cannot read. +ab_glyph = "0.2" diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 933beed..14e0ac0 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -307,7 +307,20 @@ pub fn apply(ctx: &egui::Context) { // Popups, menus and tooltips appear and go at once: a fade would ask // for repaints the stream view does not want. style.animation_time = 0.1; - ctx.set_style(style); + // Latch has one look, dark, whatever the system's appearance. egui + // keeps a style per theme and picks one from the system's every frame, + // so on a Mac or PC in Light mode it drew with its stock light style, + // which has no caption or title text styles: the first caption panicked. + ctx.set_theme(egui::ThemePreference::Dark); + ctx.set_style_of(egui::Theme::Light, style.clone()); + ctx.set_style_of(egui::Theme::Dark, style); + // A menu whose button is no longer drawn (the toolbar folded into More, + // the stream ended, the page changed) would stay open in egui's memory + // with nothing on screen, blocking the stream's input and Escape. + ctx.on_end_pass( + "latch.orphan_menu", + Arc::new(crate::widgets::close_orphan_menu), + ); } fn font(bytes: &'static [u8]) -> Arc { @@ -377,10 +390,68 @@ fn fonts() -> FontDefinitions { for (family, list) in families { fonts.families.insert(family, list); } + // Last of all, the system's Korean and CJK font: none of the above has + // Hangul, so a Korean PC's error messages or an app named in Korean + // came out as empty boxes. + if let Some(bytes) = cjk_fallback() { + fonts + .font_data + .insert(CJK.into(), Arc::new(FontData::from_static(bytes))); + for list in fonts.families.values_mut() { + list.push(CJK.into()); + } + } fonts } -fn visuals() -> Visuals { +const CJK: &str = "SystemCJK"; + +/// The system font with Hangul and the CJK ideographs, read once per +/// process (it is 15-20 MB) and kept; None when the machine has none of +/// the usual ones, or the file does not parse (egui would panic on it). +fn cjk_fallback() -> Option<&'static [u8]> { + static FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); + *FONT.get_or_init(|| { + let bytes = cjk_candidates().iter().find_map(|p| usable_font(p))?; + Some(&*Box::leak(bytes.into_boxed_slice())) + }) +} + +/// The font file at `path`, if egui will be able to read it. +fn usable_font(path: &std::path::Path) -> Option> { + let bytes = std::fs::read(path).ok()?; + ab_glyph::FontRef::try_from_slice_and_index(&bytes, 0).ok()?; + Some(bytes) +} + +/// Where each OS keeps a font that covers Korean, first choice first. On a +/// Mac the smaller AppleGothic comes before Apple SD Gothic Neo, whose +/// collection of nine weights is 55 MB. +fn cjk_candidates() -> Vec { + use std::path::PathBuf; + if cfg!(target_os = "macos") { + vec![ + "/System/Library/Fonts/Supplemental/AppleGothic.ttf".into(), + "/System/Library/Fonts/AppleSDGothicNeo.ttc".into(), + ] + } else if cfg!(windows) { + let windir = std::env::var_os("WINDIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + vec![windir.join("Fonts").join("malgun.ttf")] + } else { + [ + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + ] + .map(PathBuf::from) + .to_vec() + } +} + +pub(crate) fn visuals() -> Visuals { let p = PALETTE; let mut v = Visuals::dark(); v.override_text_color = None; @@ -514,6 +585,57 @@ mod tests { // it, so it is always seen against the background. } + #[test] + fn a_light_mode_system_still_gets_the_dark_style() { + let ctx = egui::Context::default(); + apply(&ctx); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + // Captions use a text style only Latch's style has; egui's stock + // light style panicked on the first one. + let _ = ctx.run( + egui::RawInput { + system_theme: Some(theme), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + crate::caption(ui, "caption"); + crate::small_print(ui, "small print"); + }); + }, + ); + assert_eq!(ctx.theme(), egui::Theme::Dark); + assert_eq!(ctx.style().visuals.panel_fill, PALETTE.bg); + } + } + + #[test] + fn korean_text_is_drawn_when_the_system_has_a_font_for_it() { + let ctx = egui::Context::default(); + apply(&ctx); + let _ = ctx.run(Default::default(), |_| {}); + let has = ctx.fonts(|f| f.has_glyphs(&FontId::proportional(14.0), "연결이 거부되었습니다")); + assert_eq!(has, cjk_fallback().is_some()); + if cfg!(target_os = "macos") { + assert!(has, "every Mac has AppleGothic"); + } + } + + #[test] + fn a_font_file_that_does_not_parse_is_skipped() { + let dir = std::env::temp_dir().join(format!("latch-font-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let bad = dir.join("bad.ttf"); + std::fs::write(&bad, b"not a font at all").unwrap(); + assert!(usable_font(&bad).is_none()); + assert!(usable_font(&dir.join("missing.ttf")).is_none()); + let geist = include_bytes!("../assets/Geist-Regular.ttf"); + let good = dir.join("good.ttf"); + std::fs::write(&good, geist).unwrap(); + assert_eq!(usable_font(&good).as_deref(), Some(&geist[..])); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn the_scales_step_on_the_grid() { for s in [ diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index fcdb000..0fb761d 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -1131,7 +1131,7 @@ pub fn keep_in_view(response: &Response) { /// Where the menu open now sits on screen, or the visible part of an open /// [`select`]'s list, for layout checks. pub fn open_menu_rect(ctx: &egui::Context) -> Option { - let (id, rect): (Id, Rect) = ctx.data(|d| d.get_temp(open_menu_key()))?; + let (id, rect, _): (Id, Rect, u64) = ctx.data(|d| d.get_temp(open_menu_key()))?; ctx.memory(|m| m.is_popup_open(id)).then_some(rect) } @@ -1139,6 +1139,27 @@ fn open_menu_key() -> Id { Id::new("latch.open_menu") } +/// Note that the menu or list `id` was drawn at `rect` in this pass. +fn drawn_menu(ctx: &egui::Context, id: Id, rect: Rect) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(open_menu_key(), (id, rect, pass))); +} + +/// Run at the end of every pass (see [`crate::apply`]): close a menu or +/// list that egui still holds open but nothing drew this pass. Its button +/// went away (the toolbar folded into More, the stream ended, the page +/// changed), so nothing on screen could close it, and an open popup keeps +/// the stream's toolbar up and its keys and mouse from the PC. Popups that +/// are not Latch's own are left alone. +pub(crate) fn close_orphan_menu(ctx: &egui::Context) { + let Some((id, _, pass)) = ctx.data(|d| d.get_temp::<(Id, Rect, u64)>(open_menu_key())) else { + return; + }; + if pass != ctx.cumulative_pass_nr() && ctx.memory(|m| m.is_popup_open(id)) { + ctx.memory_mut(|m| m.close_popup()); + } +} + /// Open a popup menu under `trigger` while it is toggled on. A real egui /// popup: `any_popup_open` is true while it shows, Escape and a click /// outside close it. It hangs from the trigger's nearer edge, so a menu at @@ -1146,8 +1167,18 @@ fn open_menu_key() -> Id { /// it never runs past the window's top or bottom: it opens upwards when /// there is more room above, and scrolls when neither side has room for /// all of it (see [`place_popup`]). -fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R) -> Option { - let id = trigger.id.with("menu"); +/// +/// The menu is known by its `label`, not by the button's place: egui names +/// a widget by its position among its siblings, so a toolbar that gained a +/// tag, or a machine list that gained a row above, handed an open menu to +/// another button (another machine's Sleep). +fn popup_menu( + ui: &mut Ui, + trigger: &Response, + label: &str, + add: impl FnOnce(&mut Ui) -> R, +) -> Option { + let id = Id::new("latch.menu").with(label); if trigger.clicked() { ui.memory_mut(|m| m.toggle_popup(id)); } @@ -1191,9 +1222,13 @@ fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R ctx.request_discard("menu moved to fit the window"); } } - ctx.data_mut(|d| d.insert_temp(open_menu_key(), (id, shown.response.rect))); + drawn_menu(&ctx, id, shown.response.rect); let outside = trigger.clicked_elsewhere() && shown.response.clicked_elsewhere(); - if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Escape that closes the menu is used up here: otherwise the stream + // forwards it to the PC once the menu has gone, and a panel behind the + // menu closes on it too. + let escape = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if outside || escape { ui.memory_mut(|m| m.close_popup()); } Some(shown.inner.inner) @@ -1202,7 +1237,7 @@ fn popup_menu(ui: &mut Ui, trigger: &Response, add: impl FnOnce(&mut Ui) -> R /// A quiet button with a chevron that opens a menu. pub fn menu_button(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui) -> R) -> Option { let trigger = labelled_button(ui, Kind::Quiet, label, Some(Icon::ChevronDown), label); - popup_menu(ui, &trigger, add) + popup_menu(ui, &trigger, label, add) } /// An icon button that opens a menu; `label` is its accessible name. @@ -1213,7 +1248,7 @@ pub fn icon_menu( add: impl FnOnce(&mut Ui) -> R, ) -> Option { let trigger = icon_button(ui, icon, label); - popup_menu(ui, &trigger, add) + popup_menu(ui, &trigger, label, add) } fn menu_row(ui: &mut Ui, label: &str, checked: Option, enabled: bool) -> Response { @@ -1540,6 +1575,8 @@ pub fn select( let above = y - h / 2.0 - space::XS - (screen.top() + space::SM); let chrome = ui.spacing().menu_margin.sum().y + 2.0 * ui.visuals().window_stroke.width; let height = (below.max(above) - chrome).clamp(size::CONTROL, 320.0); + let selected: WidgetText = selected.into(); + let shown = selected.text().to_owned(); let mut list = None; let out = egui::ComboBox::from_id_salt(id) .selected_text(selected) @@ -1555,6 +1592,9 @@ pub fn select( ); }) .show_ui(ui, |ui| { + // The chosen row is marked by its wash and full-strength text: + // accent text on the accent wash read at 3.7:1, under AA. + ui.visuals_mut().selection.stroke = stroke(1.0, P.text); let out = clipped_scroll(ui, "latch.select", height, |ui| { ui.spacing_mut().item_spacing.y = 0.0; ui.spacing_mut().button_padding = Vec2::new(space::SM, 6.0); @@ -1564,15 +1604,33 @@ pub fn select( out.inner }); keep_in_view(&out.response); + // egui names the control with an empty string; a screen reader said + // only "pop up button". Name it after its setting row, and give the + // choice as its value. + let name: Option = ui.ctx().data(|d| d.get_temp(row_label_key())); + let enabled = ui.is_enabled(); + out.response.widget_info(|| WidgetInfo { + label: Some(name.clone().unwrap_or_else(|| shown.clone())), + current_text_value: Some(shown.clone()), + enabled, + ..WidgetInfo::new(WidgetType::ComboBox) + }); if let Some(rect) = list { // How egui's ComboBox names its popup. let popup = out.response.id.with("popup"); - ui.ctx() - .data_mut(|d| d.insert_temp(open_menu_key(), (popup, rect))); + drawn_menu(ui.ctx(), popup, rect); + // egui has just closed the list on Escape without using the key + // up: the Stream settings panel behind it would close on it too. + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); } out.inner } +/// Where [`setting_row`] leaves its label for the control it holds. +fn row_label_key() -> Id { + Id::new("latch.row_label") +} + /// A row in a [`select`]'s list: chosen when clicked, marked while chosen, /// and scrolled into view when Tab reaches it. pub fn select_option( @@ -1640,11 +1698,46 @@ pub fn slider( *value = value.saturating_sub(step); } } + // A screen reader's increment and decrement (VoiceOver's VO-Up and + // VO-Down) and its "set to", as egui's own slider takes them. + { + use egui::accesskit::{Action, ActionData}; + let (inc, dec, set) = ui.input(|i| { + ( + i.num_accesskit_action_requests(response.id, Action::Increment), + i.num_accesskit_action_requests(response.id, Action::Decrement), + i.accesskit_action_requests(response.id, Action::SetValue) + .filter_map(|r| match &r.data { + Some(ActionData::NumericValue(v)) => Some(*v), + _ => None, + }) + .last(), + ) + }); + *value = value.saturating_add(inc as u32).saturating_sub(dec as u32); + if let Some(v) = set { + *value = v.round().clamp(f64::from(lo), f64::from(hi)) as u32; + } + } *value = (*value).clamp(lo, hi); if *value != before { response.mark_changed(); } response.widget_info(|| WidgetInfo::slider(ui.is_enabled(), f64::from(*value), label)); + let now = *value; + ui.ctx().accesskit_node_builder(response.id, |b| { + use egui::accesskit::Action; + b.set_min_numeric_value(f64::from(lo)); + b.set_max_numeric_value(f64::from(hi)); + b.set_numeric_value_step(1.0); + b.add_action(Action::SetValue); + if now < hi { + b.add_action(Action::Increment); + } + if now > lo { + b.add_action(Action::Decrement); + } + }); if ui.is_rect_visible(rect) { let t = (*value - lo) as f32 / (hi - lo).max(1) as f32; let x = egui::lerp(rail.left()..=rail.right(), t); @@ -1826,12 +1919,18 @@ pub fn setting_row( } else { (size::ROW, 13.0) }; - split_row(ui, h, top, control, |ui| { + // The label is drawn after the control (see `split_row`); a select in + // the row takes its accessible name from here. + let key = row_label_key(); + ui.ctx().data_mut(|d| d.insert_temp(key, label.to_owned())); + let r = split_row(ui, h, top, control, |ui| { ui.label(RichText::new(label).color(P.text)); if let Some(h) = hint { caption(ui, h); } - }) + }); + ui.ctx().data_mut(|d| d.remove::(key)); + r } /// A settings row whose control is too wide to sit beside its label: the @@ -2118,8 +2217,11 @@ fn paint_mark(ui: &Ui, rect: egui::Rect, ink: Color32, accent: Color32) { } /// A paced activity indicator: unlike egui's immediate-repaint spinner this -/// asks for at most 30 frames a second, and only while it is on screen, so -/// it stays bounded when the low-latency stream renderer has vsync off. +/// asks for at most 30 frames a second, and only while it is inside the +/// visible part of the window, so it stays bounded when the low-latency +/// stream renderer has vsync off. While the window is not the one in front +/// (behind another, minimised) it turns four times a second: a page left +/// on a spinner for hours redrew the whole window 30 times a second. pub fn spinner(size: f32, color: egui::Color32) -> impl egui::Widget { move |ui: &mut egui::Ui| { let (rect, response) = @@ -2134,13 +2236,19 @@ pub fn spinner(size: f32, color: egui::Color32) -> impl egui::Widget { .collect(); ui.painter() .add(egui::Shape::line(points, egui::Stroke::new(1.5_f32, color))); - ui.ctx() - .request_repaint_after(std::time::Duration::from_millis(33)); + ui.ctx().request_repaint_after(spinner_pace( + ui.input(|i| i.viewport().focused.unwrap_or(true)), + )); } response } } +/// How soon a [`spinner`] asks to be drawn again. +fn spinner_pace(focused: bool) -> std::time::Duration { + std::time::Duration::from_millis(if focused { 33 } else { 250 }) +} + #[cfg(test)] mod tests { use super::*; @@ -2210,6 +2318,230 @@ mod tests { assert!(!place_popup(screen, high, 900.0, false).above); } + /// A context with Latch's style, after one empty pass. + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::apply(&ctx); + let _ = ctx.run(Default::default(), |_| {}); + ctx + } + + fn escape() -> egui::Event { + egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + } + } + + fn input(events: Vec) -> egui::RawInput { + egui::RawInput { + events, + ..Default::default() + } + } + + fn menu_id(label: &str) -> Id { + Id::new("latch.menu").with(label) + } + + #[test] + fn escape_that_closes_a_menu_is_used_up() { + let ctx = context(); + ctx.memory_mut(|m| m.open_popup(menu_id("Keys"))); + let mut later = None; + let _ = ctx.run(input(vec![escape()]), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + menu_button(ui, "Keys", |ui| menu_item(ui, "Escape")); + later = Some(ui.input(|i| i.key_pressed(egui::Key::Escape))); + }); + }); + assert!( + !ctx.memory(|m| m.any_popup_open()), + "Escape closes the menu" + ); + assert_eq!(later, Some(false), "and nothing after it sees the key"); + } + + #[test] + fn a_menu_stays_with_its_button_when_the_widgets_before_it_change() { + let ctx = context(); + ctx.memory_mut(|m| m.open_popup(menu_id("More for Gaming-PC"))); + for extra in [0, 1, 3] { + let _ = ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + // A row added above shifts every position-based id. + for _ in 0..extra { + let _ = ui.button("Connect"); + } + icon_menu(ui, Icon::More, "More for Gaming-PC", |ui| { + menu_item(ui, "Sleep") + }); + }); + }); + assert!( + open_menu_rect(&ctx).is_some(), + "still open with {extra} widgets before it" + ); + } + } + + #[test] + fn a_menu_whose_button_is_gone_is_closed() { + let ctx = context(); + ctx.memory_mut(|m| m.open_popup(menu_id("Keys"))); + let _ = ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + menu_button(ui, "Keys", |ui| menu_item(ui, "Escape")); + }); + }); + assert!(ctx.memory(|m| m.any_popup_open())); + // The toolbar folds into More: Keys is not drawn any more. + let _ = ctx.run(Default::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let _ = ui.button("More"); + }); + }); + assert!(!ctx.memory(|m| m.any_popup_open())); + // A popup that is not one of Latch's is left alone. + let theirs = Id::new("someone else's popup"); + ctx.memory_mut(|m| m.open_popup(theirs)); + let _ = ctx.run(Default::default(), |_| {}); + assert!(ctx.memory(|m| m.is_popup_open(theirs))); + } + + /// One pass of a "Resolution" setting row holding a select: what the + /// screen reader is told about the select, whether anything after it + /// still sees an Escape, and the id of the select's list. + fn resolution_row( + ctx: &egui::Context, + events: Vec, + ) -> (Option, bool, Id) { + let mut later = false; + let mut list = Id::NULL; + let out = ctx.run(input(events), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + setting_row(ui, "Resolution", None, |ui| { + // How egui's ComboBox names its list. + list = ui.make_persistent_id(Id::new("res")).with("popup"); + let mut pick = 1080; + select(ui, "res", "1080p", 160.0, |ui| { + for r in [720, 1080] { + select_option(ui, &mut pick, r, format!("{r}p")); + } + }); + }); + later = ui.input(|i| i.key_pressed(egui::Key::Escape)); + }); + }); + let node = out.platform_output.accesskit_update.and_then(|u| { + u.nodes + .into_iter() + .map(|(_, n)| n) + .find(|n| n.role() == egui::accesskit::Role::ComboBox) + }); + (node, later, list) + } + + #[test] + fn a_select_is_named_after_its_row_and_reads_its_choice() { + let ctx = context(); + ctx.enable_accesskit(); + let (node, _, _) = resolution_row(&ctx, vec![]); + let node = node.expect("the select is in the tree"); + assert_eq!(node.label(), Some("Resolution")); + assert_eq!(node.value(), Some("1080p")); + } + + #[test] + fn escape_that_closes_a_select_list_is_used_up() { + let ctx = context(); + let (_, _, list) = resolution_row(&ctx, vec![]); + ctx.memory_mut(|m| m.open_popup(list)); + let (_, later, _) = resolution_row(&ctx, vec![]); + assert!(!later); + assert!(ctx.memory(|m| m.is_popup_open(list)), "the list is open"); + let (_, later, _) = resolution_row(&ctx, vec![escape()]); + assert!( + !ctx.memory(|m| m.any_popup_open()), + "Escape closes the list" + ); + assert!(!later, "and the panel behind it does not see the key"); + // With the list closed, Escape is left for whoever wants it. + let (_, later, _) = resolution_row(&ctx, vec![escape()]); + assert!(later); + } + + #[test] + fn the_chosen_option_in_a_list_reads_at_aa() { + let v = crate::theme::visuals(); + let wash = v.selection.bg_fill; + for surface in [P.surface, P.raised] { + let a = f32::from(wash.a()) / 255.0; + let behind = lerp_color(surface, wash.to_opaque(), a); + assert!(contrast(P.text, behind) >= 4.5); + } + } + + #[test] + fn a_screen_reader_can_step_and_set_the_slider() { + use egui::accesskit::{Action, ActionData, ActionRequest}; + let ctx = context(); + ctx.enable_accesskit(); + let mut value = 50u32; + let pass = |ctx: &egui::Context, events, value: &mut u32| { + let mut id = Id::NULL; + let out = ctx.run(input(events), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + id = slider(ui, value, 2..=150, 180.0, "Bitrate").id; + }); + }); + (out, id) + }; + let (out, id) = pass(&ctx, vec![], &mut value); + let node = out + .platform_output + .accesskit_update + .unwrap() + .nodes + .into_iter() + .find(|(n, _)| *n == id.value().into()) + .map(|(_, n)| n) + .expect("the slider is in the tree"); + assert_eq!(node.min_numeric_value(), Some(2.0)); + assert_eq!(node.max_numeric_value(), Some(150.0)); + assert!(node.supports_action(Action::Increment)); + let request = |action, data| { + egui::Event::AccessKitActionRequest(ActionRequest { + action, + target: id.value().into(), + data, + }) + }; + pass(&ctx, vec![request(Action::Increment, None)], &mut value); + assert_eq!(value, 51); + pass(&ctx, vec![request(Action::Decrement, None)], &mut value); + pass(&ctx, vec![request(Action::Decrement, None)], &mut value); + assert_eq!(value, 49); + pass( + &ctx, + vec![request( + Action::SetValue, + Some(ActionData::NumericValue(400.0)), + )], + &mut value, + ); + assert_eq!(value, 150, "a value past the end is held to the range"); + } + + #[test] + fn a_spinner_slows_down_when_its_window_is_not_in_front() { + assert_eq!(spinner_pace(true).as_millis(), 33); + assert!(spinner_pace(false) >= std::time::Duration::from_millis(250)); + } + #[test] fn tones_have_distinct_marks_except_the_two_plain_ones() { assert_eq!(Tone::Neutral.color(), Tone::Info.color()); From 6d021702d9d3393b6db946885b3f48b43e6c449b Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:58:00 -0700 Subject: [PATCH 06/34] Clipboard: cut text to what fits a request body once JSON-escaped Clipboard::fit capped raw bytes at 32 KiB, but a quote or newline takes two bytes in JSON and other control characters six, so 32 KiB of newlines or control-heavy text became a body over the 64 KiB limit both sides enforce: the PC's copy never reached the Mac (the reply was refused as too large, silently, until the PC's clipboard changed) and a paste failed with "body too large". fit now counts the escaped length in the same pass and stops at 60 KiB of it, leaving room for the other fields; a test pins the per-character lengths to serde_json's. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/api.rs | 70 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index 7339370..51702f3 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -42,9 +42,14 @@ pub const UPDATE_MAX_BYTES: usize = 128 * 1024 * 1024; pub const FIRST_UPDATE_VERSION: &str = "3.1.0"; pub const CLIPBOARD_PATH: &str = "/v1/clipboard"; -/// Clipboard text longer than this is cut. It has to fit a 64 KiB request -/// body once JSON-escaped, and nobody pastes a novel through a stream. +/// Clipboard text longer than this is cut: nobody pastes a novel through a +/// stream. pub const CLIPBOARD_MAX_BYTES: usize = 32 * 1024; +/// ... and so is text that would be longer than this once JSON-escaped, so +/// it fits the 64 KiB body either side accepts with room for the other +/// fields. A quote or a newline takes two bytes there and other control +/// characters six, so 32 KiB of them alone would not fit. +pub const CLIPBOARD_MAX_JSON: usize = 60 * 1024; /// The PC's clipboard, as far as it is text. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -62,11 +67,18 @@ pub struct Clipboard { } impl Clipboard { - /// `text` cut to the size limit on a character boundary. + /// `text` cut on a character boundary to [`CLIPBOARD_MAX_BYTES`], and + /// to [`CLIPBOARD_MAX_JSON`] once escaped. pub fn fit(text: &str, seq: u64) -> Self { - let mut end = text.len().min(CLIPBOARD_MAX_BYTES); - while end > 0 && !text.is_char_boundary(end) { - end -= 1; + let (mut raw, mut escaped, mut end) = (0, 0, text.len()); + for (i, c) in text.char_indices() { + let (r, e) = (c.len_utf8(), json_len(c)); + if raw + r > CLIPBOARD_MAX_BYTES || escaped + e > CLIPBOARD_MAX_JSON { + end = i; + break; + } + raw += r; + escaped += e; } Self { text: text[..end].to_string(), @@ -77,6 +89,15 @@ impl Clipboard { } } +/// How many bytes `c` takes inside a JSON string as serde_json writes it. +fn json_len(c: char) -> usize { + match c { + '"' | '\\' | '\u{08}' | '\u{0C}' | '\n' | '\r' | '\t' => 2, + c if (c as u32) < 0x20 => 6, + c => c.len_utf8(), + } +} + /// What `tailscale netcheck` says about one machine's network, in the /// terms that decide whether two machines can connect directly. Both apps /// report their own; the Mac combines the two to explain a relayed path. @@ -305,6 +326,43 @@ mod tests { assert_eq!(s.streamer.audio_problem, ""); } + #[test] + fn clipboard_text_fits_a_request_body_however_it_escapes() { + let body = |c: &Clipboard| serde_json::to_string(c).unwrap().len(); + for s in [ + "\u{1b}".repeat(CLIPBOARD_MAX_BYTES), + "\"".repeat(CLIPBOARD_MAX_BYTES), + "\n".repeat(CLIPBOARD_MAX_BYTES), + "a\u{1}\"é\\".repeat(20_000), + ] { + let c = Clipboard::fit(&s, u64::MAX); + assert!(c.truncated); + assert!(s.starts_with(&c.text)); + assert!(body(&c) <= 64 * 1024, "{} bytes", body(&c)); + } + // Plain text still runs to the byte limit. + let plain = "x".repeat(CLIPBOARD_MAX_BYTES + 10); + let c = Clipboard::fit(&plain, 1); + assert_eq!(c.text.len(), CLIPBOARD_MAX_BYTES); + assert!(c.truncated); + let exact = "x".repeat(CLIPBOARD_MAX_BYTES); + assert!(!Clipboard::fit(&exact, 1).truncated); + } + + #[test] + fn the_escaped_length_matches_serde_json() { + let mut chars: Vec = (0u8..128).map(char::from).collect(); + chars.extend(['é', '€', '한', '😀', '\u{7f}', '\u{2028}']); + for c in chars { + let s = c.to_string(); + assert_eq!( + json_len(c), + serde_json::to_string(&s).unwrap().len() - 2, + "{c:?}" + ); + } + } + #[test] fn first_update_version_is_3_1() { assert_eq!(FIRST_UPDATE_VERSION, "3.1.0"); From 646f02b1b6edc29fcaeb16a5870444913f7a137a Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:00:00 -0700 Subject: [PATCH 07/34] Settings: one unreadable value costs that value, not the file; the service keeps its last good host.toml load() set the whole file aside and returned defaults on any parse error. A client.toml with resolution = "P720" read by a build without 720p (4.1.1, or a branch cut before PR #26) lost every pairing certificate, Hermes's wake MAC and the GitHub token. Well-formed TOML that does not fit is now salvaged key by key (a whole table when it fits, else its parts), the original kept as .bad-* beside it and the readable part written back, so only stream.resolution falls back. Text that is not TOML at all goes aside as before, and so does a file that is not UTF-8. A read error other than "not found" (permissions, a sharing violation) counted as a missing file, and update() then wrote defaults over it. Such a file is now an error for update(), which leaves it alone. The service re-read host.toml every 5 s with that loader, so a typo in a hand edit on a headless node moved the file away and ran with remote power allowed and no engine login. It now uses a strict try_load that never moves anything, treats an empty file (an editor mid-save) as unreadable, keeps the last good settings, and logs once per problem and once when it is fixed. Co-Authored-By: Claude Opus 5.5 --- crates/client/src/config.rs | 30 ++++ crates/core/src/config.rs | 324 +++++++++++++++++++++++++++++++++--- crates/host/src/config.rs | 12 ++ crates/host/src/service.rs | 70 +++++++- 4 files changed, 412 insertions(+), 24 deletions(-) diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 5e9f7a6..87c7e38 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -385,6 +385,36 @@ mod tests { assert_eq!(back, c); } + /// A build without 720p (4.1.1, or a branch cut before it) reading a + /// file this one wrote: the size goes back to its default, and the + /// machines, their certificates and wake addresses all stay. + #[test] + fn a_size_this_build_does_not_know_keeps_the_machines() { + let text = "github_token = \"t\"\n\ + [stream]\nquality = \"Custom\"\nresolution = \"P9999\"\nfps = 90\ncodec = \"Hevc\"\n\ + [pcs.n1]\nname = \"Hermes\"\nmac = \"02:00:00:00:00:01\"\n\ + public_ip = \"116.125.113.36\"\nserver_cert = \"3082\"\n\ + [pcs.n2]\nname = \"stuzha\"\nlast_seen_unix = \"bad\"\n"; + let (c, dropped): (ClientConfig, _) = latch_core::config::parse_lenient(text).unwrap(); + assert_eq!( + dropped, + ["pcs.n2.last_seen_unix", "stream.resolution"], + "only what cannot be read" + ); + assert_eq!(c.stream.resolution, Resolution::Native); + assert_eq!(c.stream.quality, Quality::Custom); + assert_eq!(c.stream.fps, 90); + assert_eq!(c.stream.codec, Codec::Hevc); + assert_eq!(c.github_token.as_deref(), Some("t")); + assert_eq!(c.pcs["n1"].server_cert.as_deref(), Some("3082")); + assert_eq!(c.pcs["n1"].mac.as_deref(), Some("02:00:00:00:00:01")); + assert_eq!(c.pcs["n1"].public_ip.as_deref(), Some("116.125.113.36")); + assert_eq!(c.pcs["n2"].name, "stuzha"); + assert_eq!(c.pcs["n2"].last_seen_unix, None); + // Text that is not TOML at all is still an error (and set aside). + assert!(latch_core::config::parse_lenient::("[stream").is_err()); + } + #[test] fn every_preset_survives_a_reload() { for p in Preset::ALL { diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index d636eba..0b72a55 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -116,44 +116,193 @@ fn copy_tree(from: &Path, to: &Path) -> Result<()> { } /// Read `name` from the data directory; defaults when the file is missing -/// or damaged, because refusing to start over a setting is worse than -/// re-picking it. -pub fn load(name: &str) -> T { +/// or cannot be read, because refusing to start over a setting is worse +/// than re-picking it. One value this build does not understand (a choice +/// a newer build added, a typo) costs only that value: see [`salvage`]. +pub fn load(name: &str) -> T { let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - load_unlocked(name) + read_unlocked(name).unwrap_or_else(|e| { + tracing::warn!("{e:#}; using defaults"); + T::default() + }) } -fn load_unlocked(name: &str) -> T { - let Ok(path) = data_dir().map(|d| d.join(name)) else { - return T::default(); +/// Read `name` exactly as it is: the default only when there is no file, +/// an error for anything else, and the file left as it was. For a reader +/// that already holds good settings and should keep them rather than take +/// defaults over a file someone is still editing. +pub fn try_load(name: &str) -> Result { + let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let path = data_dir()?.join(name); + let Some(bytes) = read_bytes(&path)? else { + return Ok(T::default()); + }; + let text = + String::from_utf8(bytes).with_context(|| format!("{} is not text", path.display()))?; + toml::from_str(&text).with_context(|| path.display().to_string()) +} + +/// The file's bytes; `None` when there is no file. Any other failure (no +/// permission, a sharing violation, a disk error) is an error: that file +/// may hold every setting, and treating it as missing would overwrite it. +fn read_bytes(path: &Path) -> Result>> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("could not read {}", path.display())), + } +} + +fn read_unlocked(name: &str) -> Result { + let path = data_dir()?.join(name); + let Some(bytes) = read_bytes(&path)? else { + return Ok(T::default()); + }; + let Ok(text) = String::from_utf8(bytes) else { + tracing::warn!("{} is not text; using defaults", path.display()); + set_aside(&path, true); + return Ok(T::default()); + }; + let (value, dropped) = match parse_lenient::(&text) { + Ok((value, dropped)) if dropped.is_empty() => return Ok(value), + Ok(salvaged) => salvaged, + Err(e) => { + tracing::warn!("{} is unreadable ({e:#}); using defaults", path.display()); + set_aside(&path, true); + return Ok(T::default()); + } + }; + // Well-formed, but some value does not fit (one a newer build wrote, or + // a typo). Keep everything else, and the original beside it. + tracing::warn!( + "{}: could not read {}; using the default for {}", + path.display(), + dropped.join(", "), + if dropped.len() == 1 { "it" } else { "them" } + ); + set_aside(&path, false); + if let Err(e) = save_unlocked(name, &value) { + tracing::warn!("could not rewrite {}: {e:#}", path.display()); + } + Ok(value) +} + +/// Read settings text: as a whole when it fits `T`, else what [`salvage`] +/// keeps of it, with the keys it left out (none when it all fit). An error +/// only when the text is not TOML at all. +pub fn parse_lenient( + text: &str, +) -> Result<(T, Vec)> { + match toml::from_str(text) { + Ok(value) => Ok((value, Vec::new())), + Err(e) => { + let disk = text.parse::().map_err(|_| e)?; + Ok(salvage(&disk)) + } + } +} + +/// Keep a damaged settings file as `.bad--`: moved when it is +/// to be replaced by defaults, copied when what could be read of it is +/// written back in its place (so there is never a moment without it). +fn set_aside(path: &Path, moved: bool) { + let backup = path.with_extension(format!( + "bad-{}-{}", + std::process::id(), + TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + let kept = if moved { + std::fs::rename(path, &backup) + } else { + std::fs::copy(path, &backup).map(|_| ()) }; - match std::fs::read_to_string(&path) { - Ok(s) => toml::from_str(&s).unwrap_or_else(|e| { - tracing::warn!("{} is unreadable ({e}); using defaults", path.display()); - let backup = path.with_extension(format!( - "bad-{}-{}", - std::process::id(), - TEMP_ID.fetch_add(1, Ordering::Relaxed) - )); - if let Err(error) = std::fs::rename(&path, &backup) { - tracing::warn!("could not preserve damaged config: {error}"); - } else { - tracing::warn!("damaged config preserved at {}", backup.display()); + match kept { + Ok(()) => tracing::warn!("damaged config preserved at {}", backup.display()), + Err(e) => tracing::warn!("could not preserve damaged config: {e}"), + } +} + +/// What `T` can take from a well-formed file that, as a whole, it cannot: +/// starting from the defaults, each key goes in whole if `T` still reads, +/// else a table key by key, else not at all. Returns the value and the +/// keys left out (`stream.resolution`). Every settings struct here is +/// `#[serde(default)]`, so any part of one is still one. +fn salvage(disk: &toml::Table) -> (T, Vec) { + let reads = |t: &toml::Table| T::deserialize(toml::Value::Table(t.clone())).is_ok(); + let mut good = toml::Table::try_from(T::default()).unwrap_or_default(); + let mut dropped = Vec::new(); + graft(&mut good, &[], disk, &reads, &mut dropped); + let value = T::deserialize(toml::Value::Table(good)).unwrap_or_default(); + (value, dropped) +} + +fn graft( + root: &mut toml::Table, + at: &[String], + disk: &toml::Table, + reads: &dyn Fn(&toml::Table) -> bool, + dropped: &mut Vec, +) { + for (key, value) in disk { + let mut path = at.to_vec(); + path.push(key.clone()); + let mut trial = root.clone(); + put(&mut trial, &path, value.clone()); + if reads(&trial) { + *root = trial; + continue; + } + if let toml::Value::Table(inner) = value { + // Its parts, then, into the table the default has there (or an + // empty one, if that much reads). + let mut trial = root.clone(); + if !matches!(get(&trial, &path), Some(toml::Value::Table(_))) { + put(&mut trial, &path, toml::Value::Table(toml::Table::new())); + } + if reads(&trial) { + *root = trial; + graft(root, &path, inner, reads, dropped); + continue; } - T::default() - }), - Err(_) => T::default(), + } + dropped.push(path.join(".")); + } +} + +fn get<'a>(root: &'a toml::Table, path: &[String]) -> Option<&'a toml::Value> { + let (last, parents) = path.split_last()?; + let mut table = root; + for key in parents { + table = table.get(key)?.as_table()?; } + table.get(last) +} + +/// Set `path` in `root`; its parents are tables already (see `graft`). +fn put(root: &mut toml::Table, path: &[String], value: toml::Value) { + let Some((last, parents)) = path.split_last() else { + return; + }; + let mut table = root; + for key in parents { + match table.get_mut(key).and_then(|v| v.as_table_mut()) { + Some(t) => table = t, + None => return, + } + } + table.insert(last.clone(), value); } /// Apply a read-modify-write transaction without allowing another in-process /// settings, discovery, or pairing writer to replace the snapshot underneath it. +/// A file that exists but cannot be read is left alone: the edit fails +/// rather than replace it with defaults. pub fn update( name: &str, edit: impl FnOnce(&mut T), ) -> Result<()> { let _guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let mut value: T = load_unlocked(name); + let mut value: T = read_unlocked(name)?; let before = toml::to_string(&value)?; edit(&mut value); if toml::to_string(&value)? != before { @@ -237,6 +386,135 @@ mod tests { n: u32, } + #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)] + enum Size { + Small, + #[default] + Native, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + #[serde(default)] + struct Stream { + size: Size, + fps: u32, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + #[serde(default)] + struct Machine { + name: String, + mac: Option, + seen: Option, + } + + /// The shape of client.toml: settings with an enum, and machines. + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + #[serde(default)] + struct Settings { + stream: Stream, + prompt: bool, + pcs: std::collections::BTreeMap, + } + + fn backups(stem: &str) -> Vec { + let prefix = format!("{stem}.bad-"); + std::fs::read_dir(data_dir().unwrap()) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with(&prefix)) + }) + .collect() + } + + /// A newer build's choice ("P720" read by a build without it) used to + /// cost the whole file: every pairing certificate and wake address. + #[test] + fn one_value_this_build_cannot_read_costs_only_that_value() { + let stem = format!("salvage-{}", std::process::id()); + let name = format!("{stem}.toml"); + let path = data_dir().unwrap().join(&name); + let text = "prompt = true\n[stream]\nsize = \"P9999\"\nfps = 45\n\ + [pcs.n1]\nname = \"PC\"\nmac = \"aa:bb:cc:dd:ee:ff\"\n\ + [pcs.n2]\nname = \"Mac\"\nseen = \"yesterday\"\n"; + std::fs::write(&path, text).unwrap(); + let got: Settings = load(&name); + assert_eq!(got.stream.size, Size::Native); + assert_eq!(got.stream.fps, 45); + assert!(got.prompt); + assert_eq!(got.pcs["n1"].mac.as_deref(), Some("aa:bb:cc:dd:ee:ff")); + assert_eq!(got.pcs["n2"].name, "Mac"); + assert_eq!(got.pcs["n2"].seen, None, "only the bad field goes"); + // The file now reads cleanly, and the original is kept once. + assert_eq!(try_load::(&name).unwrap(), got); + let kept = backups(&stem); + assert_eq!(kept.len(), 1); + assert_eq!(std::fs::read_to_string(&kept[0]).unwrap(), text); + assert_eq!(load::(&name), got); + assert_eq!(backups(&stem).len(), 1, "no new copy on a clean read"); + for p in kept { + std::fs::remove_file(p).unwrap(); + } + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn a_strict_read_never_moves_or_defaults_a_damaged_file() { + let stem = format!("strict-{}", std::process::id()); + let name = format!("{stem}.toml"); + let path = data_dir().unwrap().join(&name); + assert_eq!(try_load::(&name).unwrap(), Cfg::default(), "missing"); + std::fs::write(&path, "n = [oops").unwrap(); + assert!(try_load::(&name).is_err()); + std::fs::write(&path, "n = \"seven\"").unwrap(); + assert!(try_load::(&name).is_err()); + std::fs::write(&path, b"n = 1\n\xff").unwrap(); + assert!(try_load::(&name).is_err()); + assert!(path.exists(), "left where it was"); + assert!(backups(&stem).is_empty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn a_file_that_is_not_text_is_set_aside_like_junk() { + let stem = format!("binary-{}", std::process::id()); + let name = format!("{stem}.toml"); + let path = data_dir().unwrap().join(&name); + std::fs::write(&path, b"n = 1\n\xff").unwrap(); + assert_eq!(load::(&name), Cfg::default()); + let kept = backups(&stem); + assert_eq!(kept.len(), 1); + assert!(!path.exists()); + for p in kept { + std::fs::remove_file(p).unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn an_unreadable_file_is_not_overwritten() { + use std::os::unix::fs::PermissionsExt; + let name = format!("locked-{}.toml", std::process::id()); + save(&name, &Cfg { n: 7 }).unwrap(); + let path = data_dir().unwrap().join(&name); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read(&path).is_ok() { + // Root reads anything; nothing to show here. + std::fs::remove_file(path).unwrap(); + return; + } + assert!(update::(&name, |c| c.n = 9).is_err()); + assert!(try_load::(&name).is_err()); + assert_eq!(load::(&name), Cfg::default()); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(load::(&name), Cfg { n: 7 }); + std::fs::remove_file(path).unwrap(); + } + #[test] fn save_then_load_round_trips_and_junk_falls_back() { let stem = format!("test-{}", std::process::id()); diff --git a/crates/host/src/config.rs b/crates/host/src/config.rs index 897b5c3..bfa4b11 100644 --- a/crates/host/src/config.rs +++ b/crates/host/src/config.rs @@ -39,6 +39,18 @@ impl HostConfig { pub fn load() -> Self { latch_core::config::load(FILE) } + + /// Read host.toml for a service that already runs on settings: an error + /// rather than defaults for a file that cannot be read, does not parse, + /// or is empty (an editor saving in place truncates it first), and the + /// file left alone. Defaults mean remote power on and no engine login. + pub fn try_load() -> anyhow::Result { + let path = latch_core::config::data_dir()?.join(FILE); + if std::fs::metadata(&path).is_ok_and(|m| m.len() == 0) { + anyhow::bail!("{} is empty", path.display()); + } + latch_core::config::try_load(FILE) + } /// Overlay this copy's settings on the file rather than replacing it: /// `--setup` or a container's bootstrap may have written the engine /// login since this copy was loaded, and an empty login here never diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 05b08b2..58f60dc 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -71,6 +71,8 @@ pub struct Service { tun: Mutex>, /// The last line logged about a peer forwarded through loopback. forwarded_logged: Mutex>, + /// Why host.toml could not be re-read, while it cannot. + cfg_problem: Mutex>, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -99,6 +101,7 @@ impl Service { whois_failed: Mutex::new(None), tun: Mutex::new(None), forwarded_logged: Mutex::new(None), + cfg_problem: Mutex::new(None), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), @@ -222,7 +225,12 @@ impl Service { /// Cheap checks every tick; the slower probes on a longer cadence. fn refresh(self: &Arc, tick: u64) { - *self.cfg.lock() = HostConfig::load(); + let prev = self.cfg.lock().clone(); + let (cfg, line) = next_config(prev, HostConfig::try_load(), &mut self.cfg_problem.lock()); + *self.cfg.lock() = cfg; + if let Some(line) = line { + self.log(line); + } let stay_awake = self.cfg.lock().stay_awake; crate::power::keep_awake(stay_awake); if tick == 0 && stay_awake { @@ -949,6 +957,34 @@ pub fn describe_nat(n: &NatReport) -> String { } } +/// The settings to run on after re-reading host.toml: the file's when it +/// reads, else the last good ones, never defaults. A hand edit with a typo +/// (or a read mid-save) used to move the file aside and run with remote +/// power allowed and no engine login until someone noticed. Returns the +/// line to log: once per problem, and once when it is fixed. +fn next_config( + prev: HostConfig, + loaded: Result, + problem: &mut Option, +) -> (HostConfig, Option) { + match loaded { + Ok(cfg) => { + let fixed = problem + .take() + .map(|_| "host.toml reads again; using its settings".to_string()); + (cfg, fixed) + } + Err(e) => { + let why = format!("{e:#}"); + let line = (problem.as_deref() != Some(why.as_str())).then(|| { + format!("host.toml could not be read ({why}); keeping the settings from before") + }); + *problem = Some(why); + (prev, line) + } + } +} + /// Who sent a request. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Caller { @@ -1285,6 +1321,38 @@ mod tests { assert!(svc.auth.lock().is_empty()); } + #[test] + fn a_damaged_host_toml_keeps_the_last_good_settings() { + let good = HostConfig { + power_allowed: false, + sunshine_user: "latch".into(), + sunshine_pass: "secret".into(), + ..Default::default() + }; + let mut problem = None; + let (cfg, line) = next_config( + good.clone(), + Err(anyhow::anyhow!("expected `=`")), + &mut problem, + ); + assert_eq!(cfg, good, "power stays off and the engine login stays"); + assert!(line.unwrap().contains("expected `=`")); + // The same problem every 5 s is said once. + let (cfg, line) = next_config(cfg, Err(anyhow::anyhow!("expected `=`")), &mut problem); + assert_eq!(cfg, good); + assert_eq!(line, None); + // Fixed: the file's settings, and one line to say so. + let edited = HostConfig { + power_allowed: true, + ..good.clone() + }; + let (cfg, line) = next_config(cfg, Ok(edited.clone()), &mut problem); + assert_eq!(cfg, edited); + assert!(line.is_some()); + let (_, line) = next_config(edited.clone(), Ok(edited), &mut problem); + assert_eq!(line, None); + } + #[test] fn browsers_and_rebound_hostnames_cannot_read_or_write_the_api() { let svc = Service::new(); From 2e103bb9a746d5c3f230b3f6929ffe91c8df86b6 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:08:56 -0700 Subject: [PATCH 08/34] Pairing: hand the PIN over beside the first request, stop on a refusal The PIN went to Latch Host on the PC in front of the first pairing request: the handshake could only go on once that loop returned, up to 75 s later, whether or not the PC had long answered (the stuzha stall with Sunshine 2026.10). Now the request is polled every 100 ms and the hand-off runs on its own thread beside it: the handshake goes on the moment the PC answers, however the PIN got in, a first request that fails shows its error at once, and Cancel works throughout. A wait that runs out reads "nobody entered the PIN" instead of a bare "timed out". The host's reply is read whatever its status, so its refusal reason is shown ("isn't answering" is kept for silence). A refusal for good (400, 403, 422, twice in a row) ends the attempt with the host's reason; a host that cannot log in to its engine gets 15 s, then the card says to type the PIN there; passing refusals are asked again less and less often. Tests run the handshake against a fake host on loopback ports. Connecting: a cancel no longer quits an app it rejoined, nor one that was running before anything was touched; failures are logged with the step and the whole cause chain, and the banner shows the chain, with each step naming itself ("Asking stuzha to start the stream: ..."). The Desktop is relaunched for its size only where the desktop follows the viewer (a Linux host without a fixed size), and the size it was launched at is kept in client.toml so an app restart does not relaunch it. Saved machines that have left the tailnet for 30 days are forgotten, guarded by the tailnet they were seen on. Co-Authored-By: Claude Opus 5.5 --- crates/client/src/config.rs | 38 ++ crates/client/src/session.rs | 959 +++++++++++++++++++++++++++++++---- crates/stream/src/nvhttp.rs | 304 ++++++++++- 3 files changed, 1168 insertions(+), 133 deletions(-) diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs index 5e9f7a6..3b58dc7 100644 --- a/crates/client/src/config.rs +++ b/crates/client/src/config.rs @@ -195,6 +195,12 @@ pub struct KnownPc { pub tailscale_ip: Option, /// When the PC was last seen online (Unix seconds). pub last_seen_unix: Option, + /// The size its Desktop was last launched at from this machine, so the + /// first connect after Latch reopens can rejoin it at that size. + pub desktop_size: Option<[u32; 2]>, + /// The tailnet it was seen on, so a machine is only ever forgotten for + /// leaving the tailnet this machine is on now. + pub tailnet: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -279,6 +285,13 @@ impl ClientConfig { latch_core::config::update(FILE, edit) } + /// Forget a machine: its addresses, how to wake it, and its pairing. + /// One still on the tailnet is listed again, unpaired and unwakeable + /// until it has been seen online. + pub fn forget(&mut self, node_id: &str) -> bool { + self.pcs.remove(node_id).is_some() + } + pub fn forget_pin_on_mismatch(&mut self, node_id: &str, err: &anyhow::Error) -> bool { if !latch_stream::nvhttp::is_pin_mismatch(err) { return false; @@ -429,4 +442,29 @@ mod tests { assert!(!c.forget_pin_on_mismatch("n", &generic)); assert_eq!(c.pcs["n"].server_cert.as_deref(), Some("3082")); } + + #[test] + fn a_forgotten_machine_takes_its_pairing_and_nothing_else() { + let mut c = ClientConfig::default(); + for id in ["artemis", "stuzha"] { + c.pcs.insert( + id.into(), + KnownPc { + name: id.into(), + server_cert: Some("3082".into()), + desktop_size: Some([1280, 720]), + ..Default::default() + }, + ); + } + assert!(c.forget("artemis")); + assert!(!c.forget("artemis"), "gone already"); + assert_eq!(c.pcs.keys().collect::>(), ["stuzha"]); + assert_eq!(c.pcs["stuzha"].server_cert.as_deref(), Some("3082")); + // A file without the newer fields still reads. + let back: ClientConfig = + toml::from_str("[pcs.n]\nname = \"Gaming-PC\"\nserver_cert = \"3082\"\n").unwrap(); + assert_eq!(back.pcs["n"].desktop_size, None); + assert_eq!(back.pcs["n"].tailnet, None); + } } diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 17196af..048231c 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -4,15 +4,16 @@ use crate::clipboard; use crate::config::{ClientConfig, Codec, KnownPc, StreamSettings}; use crate::path::{self, Path}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use latch_core::api::{Ack, NatReport, PinRequest, PowerAction, PowerRequest, Status}; use latch_core::{http, tailscale, wake, CONTROL_PORT, SUNSHINE_PORT}; +use latch_stream::nvhttp::PinNotEntered; use latch_stream::session::Server; use latch_stream::{Client, Event, FrameSlot, Identity, Input, Session, Settings}; use parking_lot::Mutex; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::Receiver; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -63,6 +64,18 @@ impl Pc { pub fn power_allowed(&self) -> bool { self.host.as_ref().is_some_and(|h| h.power_allowed) } + /// Sleep is offered only for a Windows PC, which a wake packet brings + /// back. A Linux machine (a cloud VPS above all) or a Mac would stay + /// asleep with nothing here able to wake it. + pub fn can_sleep(&self) -> bool { + self.power_allowed() && sleep_offered(&self.os) + } +} + +/// Whether Latch offers to put a machine with Tailscale's OS string `os` +/// to sleep. +pub fn sleep_offered(os: &str) -> bool { + os.eq_ignore_ascii_case("windows") } /// A tailnet node tagged `tag:relay`: a candidate peer relay, never a PC. @@ -104,6 +117,11 @@ pub struct Discovery { pub relays: Vec, /// Relay servers this device is granted to use, from the debug command. pub peer_relay_servers: PeerRelayServers, + /// Every node Tailscale lists on this tailnet, asleep or not, relays and + /// phones included; `None` when Tailscale could not say. + pub listed: Option>, + /// The tailnet this machine is on, when Tailscale names it. + pub tailnet: Option, } /// Rescan the tailnet every few seconds and remember what each PC needs to @@ -182,47 +200,95 @@ fn spawn_peer_relay_probe(slot: Arc>) { }); } +/// A saved machine that has been off the tailnet this long is forgotten. +const FORGET_AFTER: u64 = 30 * 86_400; + /// Save what a scan taught about each PC: address, wake details, when it -/// was last seen. +/// was last seen. Forget the ones that have left the tailnet. fn learn(scan: &Discovery) { if let Err(e) = ClientConfig::update(|learned| { - for pc in &scan.pcs { - if pc.remembered { - continue; - } - let entry = learned.pcs.entry(pc.node_id.clone()).or_default(); - entry.name = pc.name.clone(); - if !pc.os.is_empty() { - entry.os = pc.os.clone(); - } - if let Some(ip) = pc.ip { - entry.tailscale_ip = Some(ip.to_string()); - } - if pc.online { - // Coarse on purpose: the file is rewritten only when this moves. - let now = latch_core::dates::now_unix(); - if entry - .last_seen_unix - .is_none_or(|t| now.saturating_sub(t) > 600) - { - entry.last_seen_unix = Some(now); - } + learn_into(learned, scan, latch_core::dates::now_unix()); + }) { + tracing::warn!("could not save what was learned: {e:#}"); + } +} + +fn learn_into(learned: &mut ClientConfig, scan: &Discovery, now: u64) { + for pc in &scan.pcs { + if pc.remembered { + continue; + } + let entry = learned.pcs.entry(pc.node_id.clone()).or_default(); + entry.name = pc.name.clone(); + if !pc.os.is_empty() { + entry.os = pc.os.clone(); + } + if let Some(ip) = pc.ip { + entry.tailscale_ip = Some(ip.to_string()); + } + if scan.tailnet.is_some() { + entry.tailnet = scan.tailnet.clone(); + } + if pc.online { + // Coarse on purpose: the file is rewritten only when this moves. + if entry + .last_seen_unix + .is_none_or(|t| now.saturating_sub(t) > 600) + { + entry.last_seen_unix = Some(now); } - if let Some(h) = &pc.host { - if h.mac.is_some() { - entry.mac = h.mac.clone(); - entry.lan_ip = h.lan_ip.clone(); - } + } + if let Some(h) = &pc.host { + if h.mac.is_some() { + entry.mac = h.mac.clone(); + entry.lan_ip = h.lan_ip.clone(); } - if let Some(p) = scan_public(pc) { - entry.public_ip = Some(p.to_string()); + } + if let Some(p) = scan_public(pc) { + entry.public_ip = Some(p.to_string()); + } + } + if let Some(listed) = &scan.listed { + for id in retired(&learned.pcs, listed, scan.tailnet.as_deref(), now) { + if let Some(k) = learned.pcs.remove(&id) { + tracing::info!("forgot {} ({id}): it has left the tailnet", k.name); } } - }) { - tracing::warn!("could not save what was learned: {e:#}"); } } +/// Saved machines that have left the tailnet this machine is on: Tailscale +/// no longer lists them (it lists every machine there, asleep or not) and +/// nothing has seen them for [`FORGET_AFTER`]. A machine saved on another +/// tailnet is kept for when this one goes back; one saved before tailnets +/// were recorded counts as this tailnet's only when another saved machine +/// is listed here. An empty listing proves nothing and forgets nothing. +fn retired( + pcs: &BTreeMap, + listed: &BTreeSet, + tailnet: Option<&str>, + now: u64, +) -> Vec { + if listed.is_empty() { + return Vec::new(); + } + let here = pcs.keys().any(|id| listed.contains(id)); + pcs.iter() + .filter(|(id, k)| { + let ours = match (k.tailnet.as_deref(), tailnet) { + (Some(theirs), Some(current)) => theirs == current, + (Some(_), None) => false, + (None, _) => here, + }; + !listed.contains(*id) + && ours + && k.last_seen_unix + .is_none_or(|t| now.saturating_sub(t) >= FORGET_AFTER) + }) + .map(|(id, _)| id.clone()) + .collect() +} + fn scan_public(pc: &Pc) -> Option { pc.known.as_ref()?.public_ip.as_deref()?.parse().ok() } @@ -262,6 +328,10 @@ pub fn scan(cfg: &ClientConfig) -> Discovery { } } pcs.sort_by(|a, b| a.name.cmp(&b.name)); + let mut listed: BTreeSet = st.peer.values().map(|n| n.id.clone()).collect(); + if std::env::var_os("LATCH_DEV_LOCAL").is_some() { + listed.insert("local".into()); + } let relays = st .relay_peers() .into_iter() @@ -281,6 +351,12 @@ pub fn scan(cfg: &ClientConfig) -> Discovery { self_ip: st.self_node.ipv4(), relays, peer_relay_servers: PeerRelayServers::default(), + listed: Some(listed), + tailnet: st + .current_tailnet + .as_ref() + .map(|t| t.name.clone()) + .filter(|n| !n.is_empty()), } } @@ -398,9 +474,29 @@ pub enum Step { }, } +impl Step { + /// For the log: the step, without the PIN a pairing carries. + pub fn name(&self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Waking => "waking", + Self::Waiting => "waiting", + Self::Pairing { .. } => "pairing", + Self::Launching => "launching", + Self::Connecting => "connecting", + Self::Streaming => "streaming", + Self::Ended { .. } => "ended", + } + } +} + #[derive(Debug, Clone)] pub struct Progress { pub pc: String, + /// The machine's Tailscale node id; names are not unique. + pub node_id: String, + /// Its Tailscale address, which power actions are sent to. + pub ip: Option, pub step: Step, pub detail: String, pub since: Instant, @@ -417,6 +513,8 @@ impl Default for Progress { fn default() -> Self { Self { pc: String::new(), + node_id: String::new(), + ip: None, step: Step::Idle, detail: String::new(), since: Instant::now(), @@ -493,6 +591,8 @@ pub struct Target { /// status; see [`fit_to_host`]. pub capture_size: Option<[u32; 2]>, pub max_fps: u32, + /// The size its Desktop was last launched at from this Mac, as saved. + pub desktop_size: Option<(u32, u32)>, } impl Target { @@ -516,6 +616,7 @@ impl Target { os: pc.os.clone(), capture_size: pc.host.as_ref().and_then(|h| h.streamer.capture_size), max_fps: pc.host.as_ref().map_or(0, |h| h.streamer.max_fps), + desktop_size: k.and_then(|k| k.desktop_size).map(|[w, h]| (w, h)), }) } } @@ -559,27 +660,77 @@ fn fixed_desktop(capture_size: Option<[u32; 2]>) -> Option<(u32, u32)> { } } -/// The size each PC's Desktop was last launched at from here, by node. +/// What a stream with `settings` asks the PC for on a display of `native` +/// pixels, after Auto has had its say and the PC's limits theirs: width, +/// height and frame rate. A connect asks for exactly this. +pub fn request_for( + settings: &StreamSettings, + native: (u32, u32), + capture_size: Option<[u32; 2]>, + max_fps: u32, +) -> (u32, u32, u32) { + let s = path::effective(settings); + fit_to_host(s.resolution.pixels(native), s.fps, capture_size, max_fps) +} + +/// The size each PC's Desktop was last launched at from here, by node. The +/// same is kept in client.toml ([`KnownPc::desktop_size`]) for the next +/// time this app opens. static LAUNCHED: Mutex> = Mutex::new(BTreeMap::new()); +/// Whether the PC's desktop takes the size of whoever launches it: a Linux +/// desktop whose host reports no fixed size, where a prep command resizes +/// the X screen at each launch. A Windows PC switches its display on a +/// resume too, a Mac never does, and a fixed X screen cannot. +fn follows_viewer(os: &str, capture_size: Option<[u32; 2]>) -> bool { + os.eq_ignore_ascii_case("linux") && fixed_desktop(capture_size).is_none() +} + /// Whether the running app has to be ended before this launch. Another app -/// always does. The Desktop does when asked to (a broken capture) and when -/// it may be running at another size: resuming keeps the PC's display as -/// the launch left it, since a host's prep command (a Linux desktop -/// resizing itself to the viewer) runs only at a launch, so a stream at a -/// new size would be the old desktop scaled, with the mouse moving over -/// the old one. A Desktop this Mac did not launch, or launched before it -/// was last opened, is of unknown size. +/// always does. The Desktop does when asked to (a broken capture), and on +/// a desktop that follows the viewer when it may be running at another +/// size: resuming keeps the PC's display as the launch left it, since the +/// prep command runs only at a launch, so a stream at a new size would be +/// the old desktop scaled, with the mouse moving over the old one. A +/// Desktop this Mac never launched is of unknown size. fn must_relaunch( current_game: u32, app_id: u32, desktop: bool, restart_capture: bool, + follows_viewer: bool, launched_at: Option<(u32, u32)>, size: (u32, u32), ) -> bool { current_game != 0 - && (current_game != app_id || (desktop && (restart_capture || launched_at != Some(size)))) + && (current_game != app_id + || (desktop && (restart_capture || (follows_viewer && launched_at != Some(size))))) +} + +/// A cancelled attempt ends the app on the PC only when it started it; one +/// it rejoined (a game left running, a desktop someone else watches) is +/// left as it was found, as a disconnect leaves it. +fn ends_app_on_cancel(resumed: bool) -> bool { + !resumed +} + +fn abandon(client: &Client<'_>, resumed: bool) { + if ends_app_on_cancel(resumed) { + let _ = client.quit(); + } +} + +/// Keep the size the PC's Desktop was launched at across restarts of this +/// app, so the first connect afterwards resumes it rather than ending and +/// launching it again at the same size. +fn remember_desktop_size(node_id: &str, (w, h): (u32, u32)) { + if let Err(e) = ClientConfig::update(|cfg| { + if let Some(pc) = cfg.pcs.get_mut(node_id) { + pc.desktop_size = Some([w, h]); + } + }) { + tracing::warn!("could not save the desktop's size: {e:#}"); + } } pub struct Connect { @@ -604,6 +755,8 @@ pub fn connect(c: Connect) { } *p = Progress { pc: c.target.name.clone(), + node_id: c.target.node_id.clone(), + ip: Some(c.target.ip), generation, ..Default::default() }; @@ -621,12 +774,25 @@ pub fn connect(c: Connect) { tracing::info!("cancelled: {e:#}"); p.set(Step::Ended { error: None }, ""); } - Err(e) => p.set( - Step::Ended { - error: Some(format!("{e}")), - }, - "", - ), + Err(e) => { + // The step it got to, and every cause: the banner alone + // leaves nothing to look at afterwards. + tracing::warn!( + "couldn't connect to {} ({}): {e:#}", + c.target.name, + if p.detail.is_empty() { + p.step.name() + } else { + p.detail.as_str() + } + ); + p.set( + Step::Ended { + error: Some(format!("{e:#}")), + }, + "", + ) + } } c.ctx.request_repaint(); }); @@ -702,6 +868,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { report(Step::Launching, "Checking pairing…".into()); let identity = Identity::load_or_create(&latch_core::config::data_dir()?.join("identity"))?; let mut client = Client::new(&identity, IpAddr::V4(t.ip), t.server_cert.clone())?; + let asking_paired = || format!("Asking {} whether this Mac is paired", t.name); let mut info = match client.server_info() { Ok(i) => i, Err(e) if latch_stream::nvhttp::is_pin_mismatch(&e) => { @@ -715,19 +882,21 @@ fn run(c: &Connect, generation: u64) -> Result<()> { tracing::warn!("could not forget the PC's old certificate: {save_error:#}"); } client = Client::new(&identity, IpAddr::V4(t.ip), None)?; - retry(8, &c.progress, generation, || client.server_info())? + retry(8, &c.progress, generation, || client.server_info()) + .with_context(asking_paired)? + } + Err(e) => { + tracing::info!("{}: {e:#}; asking again", t.name); + retry(8, &c.progress, generation, || client.server_info()) + .with_context(asking_paired)? } - Err(_) => retry(8, &c.progress, generation, || client.server_info())?, }; if !info.paired || client.server_cert().is_none() { let pin = format!("{:04}", rand::random::() % 10_000); report(Step::Pairing { pin: pin.clone() }, String::new()); - let der = client.pair_cancellable(&pin, &latch_core::config::machine_name(), || { - submit_pin(t.ip, &pin, &c.progress, &c.ctx); - stale(&c.progress, generation) - })?; + let der = pair(&mut client, &pin, c, generation)?; remember_cert(&t.node_id, &t.name, &der); - info = client.server_info()?; + info = client.server_info().with_context(asking_paired)?; if !info.paired { bail!("{} did not accept the pairing", t.name); } @@ -742,7 +911,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { let path = measure_path(&t.node_id, t.ip, &t.path); let mut settings = path::effective(&c.settings); let wanted = settings.resolution.pixels(c.native); - let (w, h, fps) = fit_to_host(wanted, settings.fps, t.capture_size, t.max_fps); + let (w, h, fps) = request_for(&c.settings, c.native, t.capture_size, t.max_fps); settings.fps = fps; tracing::info!( "path to {}: {} · {}{}", @@ -756,7 +925,9 @@ fn run(c: &Connect, generation: u64) -> Result<()> { } ); report(Step::Launching, "Starting the stream…".into()); - let apps = client.app_list()?; + let apps = client + .app_list() + .with_context(|| format!("Asking {} what it can stream", t.name))?; c.progress.lock().apps = apps.iter().map(|a| a.title.clone()).collect(); let app = apps .iter() @@ -765,22 +936,32 @@ fn run(c: &Connect, generation: u64) -> Result<()> { .or_else(|| apps.first()) .ok_or_else(|| anyhow!("{} offers nothing to stream", t.name))?; let desktop = app.title.eq_ignore_ascii_case("Desktop"); - let launched_at = LAUNCHED.lock().get(&t.node_id).copied(); + // Nothing on the PC has been touched yet: a cancel while the path was + // measured must not end what runs there. + if stale(&c.progress, generation) { + bail!("cancelled"); + } + let launched_at = LAUNCHED.lock().get(&t.node_id).copied().or(t.desktop_size); if must_relaunch( info.current_game, app.id, desktop, c.restart_capture, + follows_viewer(&t.os, t.capture_size), launched_at, (w, h), ) { - client.quit()?; + client + .quit() + .with_context(|| format!("Stopping {}'s previous stream", t.name))?; let stopped = Instant::now(); loop { if stale(&c.progress, generation) { bail!("cancelled"); } - info = client.server_info()?; + info = client + .server_info() + .with_context(|| format!("Asking {} whether its stream stopped", t.name))?; if info.current_game == 0 { break; } @@ -793,27 +974,37 @@ fn run(c: &Connect, generation: u64) -> Result<()> { std::thread::sleep(Duration::from_millis(250)); } } + if stale(&c.progress, generation) { + bail!("cancelled"); + } + // Rejoining what already runs there: a cancel from here on leaves it + // running, as a disconnect would. Only an app this attempt started is + // ended again. + let resumed = info.current_game != 0; let ri_key: [u8; 16] = rand::random(); let ri_id: u32 = rand::random(); let mut ri_iv = [0u8; 16]; ri_iv[..4].copy_from_slice(&ri_id.to_be_bytes()); - let rtsp = client.launch( - app.id, - w, - h, - fps, - settings.bitrate_kbps, - &ri_key, - ri_id, - info.current_game != 0, - )?; + let rtsp = client + .launch( + app.id, + w, + h, + fps, + settings.bitrate_kbps, + &ri_key, + ri_id, + resumed, + ) + .with_context(|| format!("Asking {} to start the stream", t.name))?; if desktop { LAUNCHED.lock().insert(t.node_id.clone(), (w, h)); + remember_desktop_size(&t.node_id, (w, h)); } // 5. Connect. if stale(&c.progress, generation) { - let _ = client.quit(); + abandon(&client, resumed); bail!("cancelled"); } let hevc = wants_hevc(settings.codec, info.codec_mode_support); @@ -843,7 +1034,7 @@ fn run(c: &Connect, generation: u64) -> Result<()> { ); if stale(&c.progress, generation) { drop(session); - let _ = client.quit(); + abandon(&client, resumed); bail!("cancelled"); } let input = session.input(); @@ -900,43 +1091,257 @@ fn retry( Err(last) } -/// Hand the PIN to Latch Host on the PC, which types it into Sunshine. If -/// there is no Latch Host, the PIN stays on screen for someone at the PC. -fn submit_pin(ip: Ipv4Addr, pin: &str, progress: &Mutex, ctx: &egui::Context) { - let req = PinRequest { - pin: pin.into(), - name: latch_core::config::machine_name(), +/// Pair with the PC: the handshake on this thread, and the PIN handed to +/// Latch Host on the PC beside its first request, on another. The +/// handshake goes on the moment the PC answers, however the PIN got in; +/// Cancel works throughout. +fn pair(client: &mut Client<'_>, pin: &str, c: &Connect, generation: u64) -> Result> { + let t = &c.target; + let hand_off = Arc::new(PinHandOff::new( + &t.name, + pin, + c.progress.clone(), + generation, + c.ctx.clone(), + )); + let mut offered = false; + let result = client.pair_cancellable(pin, &latch_core::config::machine_name(), |answered| { + if answered { + hand_off.finish(); + return false; + } + if !offered { + offered = true; + let (h, host) = (hand_off.clone(), SocketAddr::from((t.ip, CONTROL_PORT))); + std::thread::spawn(move || h.run(host)); + } + stale(&c.progress, generation) || hand_off.refused.lock().is_some() + }); + // However the handshake ended, the PIN is not offered again. + hand_off.stop.store(true, Ordering::Relaxed); + result.map_err(|e| { + if let Some(reason) = hand_off.refused.lock().take() { + return anyhow!("Latch on {} refused the PIN: {reason}.", t.name); + } + if e.downcast_ref::().is_some() { + return match hand_off.why_not.lock().take() { + Some(why) => anyhow!("Pairing with {} timed out: {why}.", t.name), + None => anyhow!( + "Pairing with {} timed out: nobody entered the PIN there.", + t.name + ), + }; + } + e.context(format!("Pairing with {}", t.name)) + }) +} + +/// How long the PIN is offered to Latch Host on the PC, and how long a host +/// that cannot log in to its engine is given to manage it (it checks again +/// every few seconds, and lags an engine that has just started). +const PIN_OFFER_FOR: Duration = Duration::from_secs(75); +const ENGINE_LOGIN_GRACE: Duration = Duration::from_secs(15); + +/// What Latch Host on the PC made of one PIN hand-off. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PinReply { + /// The engine has the PIN. + Taken, + /// Not taken, for a reason that can pass: the engine is not ready for + /// it yet, or refused it this time. + Retry(String), + /// The host cannot log in to its engine, so it cannot enter any PIN + /// until that is fixed (or the engine has finished starting). + NoEngine(String), + /// The host has no way to take a PIN: it is older than `/v1/pin`. + NoRoute, + /// Refused for good: this Mac may not ask, or the request is wrong. + Refused(String), + /// Nothing answered. + Unreachable(String), +} + +/// Sort Latch Host's reply to `POST /v1/pin`. It answers every refusal with +/// an error status and its reason in the body, so the body is read +/// whatever the status says. +fn pin_reply(reply: Result) -> PinReply { + let r = match reply { + Ok(r) => r, + Err(e) => return PinReply::Unreachable(format!("{e:#}")), }; - // Pairing draws no spinner, so a new reason has to ask for its frame. - let show = |detail: String| { - let mut p = progress.lock(); - if p.detail != detail { + let ack = r.parse::().ok(); + if r.status == 200 && ack.as_ref().is_some_and(|a| a.ok) { + return PinReply::Taken; + } + let reason = ack + .and_then(|a| a.error) + .filter(|e| !e.trim().is_empty()) + .unwrap_or_else(|| format!("HTTP {}", r.status)); + match r.status { + 404 => PinReply::NoRoute, + 400 | 403 | 422 => PinReply::Refused(reason), + _ if reason.starts_with("Latch cannot log in to the streaming engine") => { + PinReply::NoEngine(reason) + } + _ => PinReply::Retry(reason), + } +} + +/// Handing the PIN to Latch Host on the PC, which enters it into the +/// engine there. It runs on its own thread beside the first pairing +/// request and stops when that request is answered (the PIN went in, +/// however it got there), when the attempt is cancelled, when the host +/// refuses for good, or after [`PIN_OFFER_FOR`]. Whatever stops it, the +/// PIN stays on screen for someone at the PC until the request gives up. +struct PinHandOff { + pc: String, + request: PinRequest, + progress: Arc>, + generation: u64, + ctx: egui::Context, + /// The first pairing request is over: stop offering the PIN. + stop: AtomicBool, + /// Latch Host refused the PIN for good, and why. + refused: Mutex>, + /// Why the PIN has not gone in through Latch Host, for the message if + /// nobody enters it at all. + why_not: Mutex>, +} + +impl PinHandOff { + fn new( + pc: &str, + pin: &str, + progress: Arc>, + generation: u64, + ctx: egui::Context, + ) -> Self { + Self { + pc: pc.to_string(), + request: PinRequest { + pin: pin.into(), + name: latch_core::config::machine_name(), + }, + progress, + generation, + ctx, + stop: AtomicBool::new(false), + refused: Mutex::new(None), + why_not: Mutex::new(None), + } + } + + fn done(&self) -> bool { + self.stop.load(Ordering::Relaxed) || stale(&self.progress, self.generation) + } + + /// The first request is answered: stop, and take down any warning, + /// which is no longer true. + fn finish(&self) { + self.stop.store(true, Ordering::Relaxed); + self.show(String::new()); + } + + /// Pairing draws no spinner, so a new line has to ask for its frame. + fn show(&self, detail: String) { + let mut p = self.progress.lock(); + if p.generation == self.generation && p.detail != detail { p.detail = detail; drop(p); - ctx.request_repaint(); + self.ctx.request_repaint(); } - }; - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(75) { - if progress.lock().cancel { - return; + } + + /// Sleep `d` in short steps; false once it is time to stop. + fn wait(&self, d: Duration) -> bool { + let until = Instant::now() + d; + while Instant::now() < until { + if self.done() { + return false; + } + std::thread::sleep(Duration::from_millis(50)); } - // Latch Host answers once the engine has the PIN: up to a little - // over half a second with an engine that holds the post open. - match http::post_json::<_, Ack>((ip, CONTROL_PORT), "/v1/pin", &req, Duration::from_secs(3)) - { - Ok(a) if a.ok => return, - Ok(a) => { - let reason = a.error.unwrap_or_else(|| "PIN not accepted yet".into()); - tracing::info!("PIN not accepted yet: {reason}"); - show(reason); + !self.done() + } + + fn run(&self, host: SocketAddr) { + let pc = &self.pc; + let body = serde_json::to_string(&self.request).unwrap_or_default(); + let start = Instant::now(); + let mut pause = Duration::from_millis(800); + let mut no_engine_since: Option = None; + let mut refusals = 0; + while start.elapsed() < PIN_OFFER_FOR && !self.done() { + // Latch Host answers once the engine has the PIN: up to a + // little over half a second with an engine that holds the post. + let reply = pin_reply(http::request( + host, + "POST", + "/v1/pin", + Some(&body), + Duration::from_secs(3), + )); + if self.done() { + return; } - Err(e) => { - tracing::info!("Latch Host did not take the PIN: {e}"); - show("Latch on that machine isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, then try again.".into()); + if !matches!(reply, PinReply::Refused(_)) { + refusals = 0; + } + let why = |s: String| *self.why_not.lock() = Some(s); + match reply { + PinReply::Taken => { + *self.why_not.lock() = None; + self.show(String::new()); + return; + } + PinReply::Retry(reason) => { + tracing::info!("{pc} did not take the PIN yet: {reason}"); + self.show(format!( + "Latch on {pc} couldn't enter the PIN yet: {reason}" + )); + why(format!("Latch there couldn't enter the PIN ({reason})")); + } + PinReply::NoEngine(reason) => { + let since = *no_engine_since.get_or_insert_with(Instant::now); + tracing::info!("{pc} did not take the PIN: {reason}"); + why("Latch there can't log in to its streaming engine; run setup there".into()); + if since.elapsed() >= ENGINE_LOGIN_GRACE { + self.show(format!("Latch on {pc} can't log in to its streaming engine, so it can't enter the PIN. Run setup there, or type this PIN into the streaming engine's web page on that machine.")); + return; + } + self.show(format!( + "Latch on {pc} can't log in to its streaming engine yet." + )); + } + PinReply::NoRoute => { + tracing::info!("{pc}: Latch Host there has no PIN route"); + why("Latch there is too old to enter the PIN; update it there".into()); + self.show(format!("Latch on {pc} is too old to enter the PIN. Type it into the streaming engine's web page on that machine, or update Latch there.")); + return; + } + PinReply::Refused(reason) => { + // Twice in a row: a refusal can also be a moment when + // the host could not ask Tailscale who this Mac is. + refusals += 1; + tracing::info!("{pc} refused the PIN: {reason}"); + if refusals >= 2 { + *self.refused.lock() = Some(reason); + return; + } + } + PinReply::Unreachable(e) => { + tracing::info!("Latch Host on {pc} did not take the PIN: {e}"); + self.show(format!("Latch on {pc} isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, or type this PIN into the streaming engine's web page on that machine.")); + why("Latch there isn't answering, so it couldn't enter the PIN".into()); + } + } + // A refusal that keeps coming costs the host two engine calls + // and a log line each time: ask less often as it goes on. + if !self.wait(pause) { + return; } + pause = (pause * 2).min(Duration::from_secs(4)); } - std::thread::sleep(Duration::from_millis(800)); } } @@ -996,14 +1401,16 @@ mod tests { fn a_desktop_running_at_another_size_is_launched_again() { const DESKTOP: u32 = 7; let p720 = (1280, 720); + let follows = true; // Nothing running: a plain launch. - assert!(!must_relaunch(0, DESKTOP, true, true, None, p720)); + assert!(!must_relaunch(0, DESKTOP, true, true, follows, None, p720)); // Launched from here at this size: resumed. assert!(!must_relaunch( DESKTOP, DESKTOP, true, false, + follows, Some(p720), p720 )); @@ -1014,23 +1421,361 @@ mod tests { DESKTOP, true, false, + follows, Some((3024, 1964)), p720 )); - assert!(must_relaunch(DESKTOP, DESKTOP, true, false, None, p720)); + assert!(must_relaunch( + DESKTOP, DESKTOP, true, false, follows, None, p720 + )); // A broken capture, or another app, always. assert!(must_relaunch( DESKTOP, DESKTOP, true, true, + follows, + Some(p720), + p720 + )); + assert!(must_relaunch( + 9, + DESKTOP, + true, + false, + follows, Some(p720), p720 )); - assert!(must_relaunch(9, DESKTOP, true, false, Some(p720), p720)); // A game is never ended for a size change. - assert!(!must_relaunch(9, 9, false, false, None, p720)); - assert!(!must_relaunch(9, 9, false, true, None, p720)); + assert!(!must_relaunch(9, 9, false, false, follows, None, p720)); + assert!(!must_relaunch(9, 9, false, true, follows, None, p720)); + // A desktop that does not follow the viewer (Windows switches its + // display on a resume, a Mac and a fixed X screen never do) is not + // ended for its size, known or not; still for a broken capture. + for launched_at in [None, Some((3024, 1964))] { + assert!(!must_relaunch( + DESKTOP, + DESKTOP, + true, + false, + false, + launched_at, + p720 + )); + } + assert!(must_relaunch( + DESKTOP, DESKTOP, true, true, false, None, p720 + )); + } + + #[test] + fn only_a_linux_desktop_without_a_fixed_size_follows_the_viewer() { + assert!(follows_viewer("linux", None)); + assert!(follows_viewer("Linux", Some([0, 0])), "nonsense is no size"); + assert!(!follows_viewer("linux", Some([1920, 1080]))); + assert!(!follows_viewer("windows", None)); + assert!(!follows_viewer("macOS", None)); + assert!(!follows_viewer("", None), "an old host says no OS"); + } + + #[test] + fn a_cancel_ends_only_an_app_this_attempt_started() { + assert!(ends_app_on_cancel(false), "launched fresh, or relaunched"); + assert!(!ends_app_on_cancel(true), "rejoined: left as it was found"); + } + + #[test] + fn the_request_depends_on_the_display_only_where_the_setting_does() { + use crate::config::{Quality, Resolution}; + let (laptop, monitor) = ((3024, 1964), (2560, 1440)); + let custom = |resolution| StreamSettings { + quality: Quality::Custom, + resolution, + fps: 60, + ..Default::default() + }; + // A fixed size asks for the same whichever display the window is on. + let p1080 = custom(Resolution::P1080); + assert_eq!( + request_for(&p1080, laptop, None, 0), + request_for(&p1080, monitor, None, 0) + ); + // Match screen follows the display... + let native = custom(Resolution::Native); + assert_ne!( + request_for(&native, laptop, None, 0), + request_for(&native, monitor, None, 0) + ); + // ...unless the PC's fixed desktop clamps both to its own size. + let vps = Some([1920, 1080]); + assert_eq!( + request_for(&native, laptop, vps, 30), + request_for(&native, monitor, vps, 30) + ); + // Auto is Match screen. + let auto = StreamSettings::default(); + assert_eq!(auto.quality, Quality::Auto); + assert_eq!(request_for(&auto, laptop, None, 0).0, 3024); + assert_eq!(request_for(&auto, monitor, None, 0).0, 2560); + } + + fn known(tailnet: Option<&str>, seen_days_ago: Option) -> KnownPc { + KnownPc { + name: "pc".into(), + server_cert: Some("3082".into()), + tailnet: tailnet.map(str::to_string), + last_seen_unix: seen_days_ago.map(|d| NOW - d * 86_400), + ..Default::default() + } + } + + const NOW: u64 = 1_792_000_000; + + #[test] + fn a_machine_that_left_the_tailnet_a_month_ago_is_forgotten() { + let here = Some("daniel@example.com"); + let mut pcs = BTreeMap::new(); + pcs.insert("hermes".to_string(), known(here, Some(90))); + pcs.insert("artemis".to_string(), known(None, Some(31))); + pcs.insert("recent".to_string(), known(here, Some(2))); + pcs.insert("never-seen".to_string(), known(here, None)); + pcs.insert("work".to_string(), known(Some("work.example"), Some(400))); + // Hermes is listed (asleep for months, still on the tailnet); a + // relay listed by Tailscale but not among the PCs is listed too. + let listed: BTreeSet = ["hermes", "relay"].map(String::from).into(); + let gone = retired(&pcs, &listed, here, NOW); + assert_eq!(gone, ["artemis", "never-seen"], "{gone:?}"); + // Nothing listed, or nothing to say it is this tailnet: nothing goes. + assert!(retired(&pcs, &BTreeSet::new(), here, NOW).is_empty()); + let elsewhere: BTreeSet = ["someone-else"].map(String::from).into(); + assert_eq!( + retired(&pcs, &elsewhere, Some("other.example"), NOW), + Vec::::new(), + "on another tailnet, this one's machines are kept" + ); + // A Tailscale that names no tailnet forgets only unmarked entries, + // and only when a saved machine is listed. + assert_eq!(retired(&pcs, &listed, None, NOW), ["artemis"]); + } + + #[test] + fn learning_marks_the_tailnet_and_a_failed_scan_forgets_nothing() { + let mut cfg = ClientConfig::default(); + cfg.pcs.insert("artemis".into(), known(None, Some(40))); + cfg.pcs.insert("hermes".into(), known(None, Some(1))); + let hermes = Pc { + node_id: "hermes".into(), + name: "Hermes".into(), + ..Default::default() + }; + // Tailscale down: the list is what was saved, and nothing changes. + let down = Discovery { + error: Some("Tailscale is stopped".into()), + pcs: remembered(&cfg), + ..Default::default() + }; + let before = cfg.clone(); + learn_into(&mut cfg, &down, NOW); + assert_eq!(cfg, before); + // Tailscale up: Hermes is listed and marked; artemis is not. + let up = Discovery { + pcs: vec![hermes], + listed: Some(["hermes".to_string()].into()), + tailnet: Some("daniel@example.com".into()), + ..Default::default() + }; + learn_into(&mut cfg, &up, NOW); + assert_eq!(cfg.pcs.keys().collect::>(), ["hermes"]); + assert_eq!( + cfg.pcs["hermes"].tailnet.as_deref(), + Some("daniel@example.com") + ); + assert_eq!(cfg.pcs["hermes"].server_cert.as_deref(), Some("3082")); + } + + #[test] + fn the_saved_desktop_size_reaches_the_target() { + let pc = Pc { + ip: Some("100.64.0.9".parse().unwrap()), + known: Some(KnownPc { + desktop_size: Some([1280, 720]), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + Target::from_pc(&pc).unwrap().desktop_size, + Some((1280, 720)) + ); + } + + #[test] + fn a_pin_reply_is_read_whatever_its_status() { + let reply = |status: u16, body: &str| { + pin_reply(Ok(http::Response { + status, + body: body.into(), + })) + }; + let ack = |e: &str| serde_json::to_string(&Ack::err(e)).unwrap(); + assert_eq!( + reply(200, &serde_json::to_string(&Ack::ok()).unwrap()), + PinReply::Taken + ); + assert_eq!( + reply( + 502, + &ack("pairing_id must contain exactly 32 hexadecimal characters") + ), + PinReply::Retry("pairing_id must contain exactly 32 hexadecimal characters".into()) + ); + assert_eq!( + reply( + 502, + &ack("Latch cannot log in to the streaming engine on this PC; run setup there") + ), + PinReply::NoEngine( + "Latch cannot log in to the streaming engine on this PC; run setup there".into() + ) + ); + assert_eq!( + reply(403, &ack("not a machine on this PC's Tailscale account")), + PinReply::Refused("not a machine on this PC's Tailscale account".into()) + ); + assert_eq!( + reply(400, &ack("the PIN is four digits")), + PinReply::Refused("the PIN is four digits".into()) + ); + assert_eq!(reply(404, "not found"), PinReply::NoRoute); + assert_eq!(reply(502, ""), PinReply::Retry("HTTP 502".into())); + assert_eq!( + reply(200, &ack("not yet")), + PinReply::Retry("not yet".into()) + ); + assert!(matches!( + pin_reply(Err(anyhow!("timed out"))), + PinReply::Unreachable(e) if e == "timed out" + )); + } + + /// A Latch Host stand-in that answers every request the same way, and + /// counts them. + fn pin_host(status: u16, body: String) -> (SocketAddr, Arc) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let n = count.clone(); + std::thread::spawn(move || { + http::serve_with_peer_check( + listener, + |_| true, + move |_, _| { + n.fetch_add(1, Ordering::SeqCst); + http::Response { + status, + body: body.clone(), + } + }, + ) + }); + (addr, count) + } + + fn hand_off() -> Arc { + let progress = Arc::new(Mutex::new(Progress { + generation: 7, + ..Default::default() + })); + Arc::new(PinHandOff::new( + "stuzha", + "1234", + progress, + 7, + egui::Context::default(), + )) + } + + fn start(h: &Arc, host: SocketAddr) -> std::thread::JoinHandle<()> { + let h = h.clone(); + std::thread::spawn(move || h.run(host)) + } + + /// Wait for the hand-off thread, no longer than `within`. + fn ends_within(t: std::thread::JoinHandle<()>, within: Duration) -> bool { + let deadline = Instant::now() + within; + while !t.is_finished() { + if Instant::now() > deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } + true + } + + #[test] + fn a_hosts_reason_is_shown_and_the_hand_off_stops_when_told() { + let (host, _) = pin_host( + 502, + serde_json::to_string(&Ack::err("engine says no")).unwrap(), + ); + let h = hand_off(); + let t = start(&h, host); + std::thread::sleep(Duration::from_millis(300)); + let detail = h.progress.lock().detail.clone(); + assert_eq!( + detail, + "Latch on stuzha couldn't enter the PIN yet: engine says no" + ); + assert!(!t.is_finished(), "a passing refusal is offered again"); + h.finish(); + assert!(ends_within(t, Duration::from_millis(300))); + assert_eq!(h.progress.lock().detail, "", "the warning goes with it"); + assert!(h.refused.lock().is_none()); + } + + #[test] + fn a_refusal_for_good_ends_the_hand_off_with_its_reason() { + let (host, asked) = pin_host( + 403, + serde_json::to_string(&Ack::err("not a machine on this PC's Tailscale account")) + .unwrap(), + ); + let h = hand_off(); + let t = start(&h, host); + assert!(ends_within(t, Duration::from_secs(3))); + assert_eq!(asked.load(Ordering::SeqCst), 2, "asked twice, then no more"); + assert_eq!( + h.refused.lock().as_deref(), + Some("not a machine on this PC's Tailscale account") + ); + } + + #[test] + fn a_taken_pin_ends_the_hand_off_at_once() { + let (host, asked) = pin_host(200, serde_json::to_string(&Ack::ok()).unwrap()); + let h = hand_off(); + assert!(ends_within(start(&h, host), Duration::from_millis(500))); + assert_eq!(asked.load(Ordering::SeqCst), 1); + assert_eq!(h.progress.lock().detail, ""); + } + + #[test] + fn only_silence_reads_as_isnt_answering_and_cancel_stops_it() { + let closed = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + let h = hand_off(); + let t = start(&h, closed); + std::thread::sleep(Duration::from_millis(300)); + assert!( + h.progress.lock().detail.contains("isn't answering"), + "{}", + h.progress.lock().detail + ); + h.progress.lock().cancel = true; + assert!(ends_within(t, Duration::from_millis(300))); } #[test] diff --git a/crates/stream/src/nvhttp.rs b/crates/stream/src/nvhttp.rs index c2603b6..870deb0 100644 --- a/crates/stream/src/nvhttp.rs +++ b/crates/stream/src/nvhttp.rs @@ -19,6 +19,23 @@ pub const HTTPS_PORT: u16 = 47984; const TIMEOUT: Duration = Duration::from_secs(6); /// The host holds the first pairing reply until a PIN is entered. const PIN_WAIT: Duration = Duration::from_secs(90); +/// How long the host is given to register the first pairing request before +/// anyone is asked to enter the PIN, and how often the wait looks up. +const PIN_ASK_AFTER: Duration = Duration::from_millis(400); +const PIN_POLL: Duration = Duration::from_millis(100); + +/// Nobody entered the PIN on the PC before it stopped holding the first +/// pairing request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PinNotEntered; + +impl std::fmt::Display for PinNotEntered { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("nobody entered the PIN on the PC in time") + } +} + +impl std::error::Error for PinNotEntered {} #[derive(Debug, Clone, Default, PartialEq)] pub struct ServerInfo { @@ -93,6 +110,9 @@ pub struct Client<'a> { /// Host certificate (DER) learned at pairing; `None` before. server_cert: Option>, tls: Option>, + /// [`HTTP_PORT`] and [`HTTPS_PORT`], except against a test's fake host. + http_port: u16, + https_port: u16, } impl<'a> Client<'a> { @@ -106,9 +126,20 @@ impl<'a> Client<'a> { ip, server_cert, tls, + http_port: HTTP_PORT, + https_port: HTTPS_PORT, }) } + /// The same client against a fake host on other ports. Tests never bind + /// the real ones: a Mac sharing itself has Sunshine there. + #[cfg(test)] + fn with_ports(mut self, http: u16, https: u16) -> Self { + self.http_port = http; + self.https_port = https; + self + } + pub fn server_cert(&self) -> Option<&[u8]> { self.server_cert.as_deref() } @@ -127,7 +158,7 @@ impl<'a> Client<'a> { } fn get_http(&self, path: &str, query: &str, read_timeout: Duration) -> Result { - let addr = SocketAddr::new(self.ip, HTTP_PORT); + let addr = SocketAddr::new(self.ip, self.http_port); let mut s = TcpStream::connect_timeout(&addr, TIMEOUT)?; s.set_read_timeout(Some(read_timeout))?; s.set_write_timeout(Some(TIMEOUT))?; @@ -164,7 +195,7 @@ impl<'a> Client<'a> { } fn https_once(&self, tls: Arc, target: &str) -> Result { - let addr = SocketAddr::new(self.ip, HTTPS_PORT); + let addr = SocketAddr::new(self.ip, self.https_port); let mut tcp = TcpStream::connect_timeout(&addr, TIMEOUT)?; tcp.set_read_timeout(Some(TIMEOUT))?; tcp.set_write_timeout(Some(TIMEOUT))?; @@ -208,34 +239,44 @@ impl<'a> Client<'a> { }) } - /// [`Client::pair_cancellable`] without the cancel, for the live tests. + /// [`Client::pair_cancellable`] without the cancel, for the live tests: + /// `pending` runs once, beside the first request, when the host is + /// waiting for the PIN. #[cfg(test)] pub fn pair( &mut self, pin: &str, device_name: &str, - pending: impl FnOnce(), + pending: impl FnOnce() + Send, ) -> Result> { - self.pair_cancellable(pin, device_name, || { - pending(); - false + let mut pending = Some(pending); + std::thread::scope(|s| { + self.pair_cancellable(pin, device_name, |_| { + if let Some(p) = pending.take() { + s.spawn(p); + } + false + }) }) } - /// The Moonlight pairing handshake. `pending` is called once the host is - /// holding our first request and waiting for someone to enter `pin` on - /// its side; it returns `true` to cancel, which aborts that request. On - /// success the host certificate is pinned for the later HTTPS calls and - /// returned. + /// The Moonlight pairing handshake. The host holds our first request + /// until someone enters `pin` on its side. Meanwhile `poll` is called + /// about every 100 ms, from a moment after that request went out, with + /// whether the host has answered it; the last call says `true`, and the + /// handshake goes on at once. Entering the PIN is the caller's business, + /// beside this wait, never in front of it. `poll` returns `true` to + /// cancel, which aborts the request while it is held. On success the + /// host certificate is pinned for the later HTTPS calls and returned. pub fn pair_cancellable( &mut self, pin: &str, device_name: &str, - pending: impl FnOnce() -> bool, + poll: impl FnMut(bool) -> bool, ) -> Result> { let previous_tls = self.tls.clone(); let previous_cert = self.server_cert.clone(); - let result = self.pair_inner(pin, device_name, pending); + let result = self.pair_inner(pin, device_name, poll); if result.is_err() { self.tls = previous_tls; self.server_cert = previous_cert; @@ -248,7 +289,7 @@ impl<'a> Client<'a> { &mut self, pin: &str, device_name: &str, - pending: impl FnOnce() -> bool, + poll: impl FnMut(bool) -> bool, ) -> Result> { let salt: [u8; 16] = rand::random(); let mut salted = salt.to_vec(); @@ -266,24 +307,17 @@ impl<'a> Client<'a> { ); let ip = self.ip; let target = self.target("pair", &query); - let mut s = TcpStream::connect_timeout(&SocketAddr::new(ip, HTTP_PORT), TIMEOUT)?; + let mut s = TcpStream::connect_timeout(&SocketAddr::new(ip, self.http_port), TIMEOUT)?; let abort = s.try_clone()?; let handle = std::thread::spawn(move || -> Result { s.set_read_timeout(Some(PIN_WAIT))?; s.set_write_timeout(Some(TIMEOUT))?; - let r = http::exchange(&mut s, "GET", &target, &ip.to_string(), "")?; + let r = http::exchange(&mut s, "GET", &target, &ip.to_string(), "") + .map_err(held_request_error)?; check(&r.body)?; Ok(r.body) }); - std::thread::sleep(Duration::from_millis(400)); - if pending() { - let _ = abort.shutdown(std::net::Shutdown::Both); - let _ = handle.join(); - bail!("pairing cancelled"); - } - handle - .join() - .map_err(|_| anyhow!("pairing thread panicked"))?? + wait_for_first_reply(handle, &abort, poll)? }; if tag(&first, "paired") != Some("1") { bail!("the PC refused to start pairing"); @@ -413,6 +447,47 @@ impl<'a> Client<'a> { } } +/// The first pairing request's read gives up after [`PIN_WAIT`]: that is +/// nobody entering the PIN, not a network fault. +fn held_request_error(e: anyhow::Error) -> anyhow::Error { + if e.chain().any(|c| c.to_string() == "timed out") { + anyhow!(PinNotEntered) + } else { + e + } +} + +/// Wait for the host's answer to the first pairing request, which it holds +/// until the PIN is entered. From [`PIN_ASK_AFTER`] on, `poll` hears every +/// [`PIN_POLL`] whether the answer is in (the last call says so) and may +/// cancel the wait, which closes the connection under the held request. +/// The request's own read timeout bounds the whole wait. +fn wait_for_first_reply( + reply: std::thread::JoinHandle>, + connection: &TcpStream, + mut poll: impl FnMut(bool) -> bool, +) -> Result { + let started = std::time::Instant::now(); + loop { + let answered = reply.is_finished(); + if answered || started.elapsed() >= PIN_ASK_AFTER { + let cancel = poll(answered); + if answered { + break; + } + if cancel { + let _ = connection.shutdown(std::net::Shutdown::Both); + let _ = reply.join(); + bail!("pairing cancelled"); + } + } + std::thread::sleep(PIN_POLL); + } + reply + .join() + .map_err(|_| anyhow!("pairing thread panicked"))? +} + /// Every reply carries `status_code`; anything but 200 is an error whose /// text is in `status_message`. fn check(xml: &str) -> Result<()> { @@ -676,6 +751,183 @@ mod tests { assert_eq!(ecb(&cipher, &ct, false), vec![1u8; 32]); assert_eq!(urlencode("Example Mac"), "Example%20Mac"); } + + use parking_lot::Mutex; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Instant; + + /// One identity for every fake pairing; it plays the PC's certificate + /// too, which is all the first reply has to carry. + fn identity() -> &'static Identity { + static ID: std::sync::OnceLock = std::sync::OnceLock::new(); + ID.get_or_init(|| { + let dir = std::env::temp_dir().join(format!( + "latch-fake-pair-{}-{:08x}", + std::process::id(), + rand::random::() + )); + let id = Identity::load_or_create(&dir).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + id + }) + } + + type Seen = Arc>>; + + /// A stand-in for Sunshine's pairing port on 127.0.0.1. Each request's + /// target is recorded with when it arrived; `reply` answers it, taking + /// its time if it likes (a held first request), or says `None` to hold + /// the connection until the client closes it. + fn fake_host(reply: impl Fn(&str) -> Option + Send + Sync + 'static) -> (u16, Seen) { + use std::io::Read; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen: Seen = Arc::default(); + let reply = Arc::new(reply); + let log = seen.clone(); + std::thread::spawn(move || { + for mut s in listener.incoming().flatten() { + let (reply, log) = (reply.clone(), log.clone()); + std::thread::spawn(move || { + let Ok(Some(req)) = http::read_request(&mut s) else { + return; + }; + log.lock().push((req.path.clone(), Instant::now())); + match reply(&req.path) { + Some(xml) => { + let _ = http::write_bytes(&mut s, 200, "text/xml", xml.as_bytes()); + } + None => { + let _ = s.read(&mut [0u8; 1]); + } + } + }); + } + }); + (port, seen) + } + + fn arrived(seen: &Seen, phrase: &str) -> Option { + seen.lock() + .iter() + .find(|(path, _)| path.contains(phrase)) + .map(|(_, at)| *at) + } + + const NOT_PAIRED: &str = r#"0"#; + + fn client(port: u16) -> Client<'static> { + Client::new(identity(), IpAddr::from([127, 0, 0, 1]), None) + .unwrap() + .with_ports(port, port) + } + + /// The stuzha deadlock: the PIN is entered on the PC while whatever + /// hands it over keeps failing. The handshake must go on as soon as + /// the PC answers the first request, not when the hand-off gives up. + #[test] + fn the_handshake_goes_on_as_soon_as_the_pc_answers() { + let entered = Arc::new(AtomicBool::new(false)); + let cert = hex(identity().cert_pem.as_bytes()); + let (port, seen) = fake_host({ + let entered = entered.clone(); + move |path| { + if path.contains("phrase=getservercert") { + let t = Instant::now(); + while !entered.load(Ordering::SeqCst) && t.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(5)); + } + Some(format!( + r#"1{cert}"# + )) + } else if path.contains("clientchallenge=") { + Some(NOT_PAIRED.into()) + } else { + Some(r#""#.into()) + } + } + }); + let mut polls = Vec::new(); + let mut entered_at = None; + let err = client(port) + .pair_cancellable("1234", "test", |answered| { + polls.push(answered); + // Someone types the PIN on the PC; the hand-off never + // reports success and never cancels. + if entered_at.is_none() { + entered_at = Some(Instant::now()); + entered.store(true, Ordering::SeqCst); + } + false + }) + .unwrap_err(); + assert_eq!(err.to_string(), "pairing failed at the challenge step"); + let challenged = arrived(&seen, "clientchallenge=").expect("phase 2 was attempted"); + let waited = challenged.duration_since(entered_at.unwrap()); + assert!(waited < Duration::from_secs(1), "phase 2 waited {waited:?}"); + assert_eq!(polls.last(), Some(&true), "the last poll hears the answer"); + assert!(polls[..polls.len() - 1].iter().all(|a| !a), "{polls:?}"); + assert!( + arrived(&seen, "/unpair").is_some(), + "a failed pairing unpairs" + ); + } + + #[test] + fn cancel_ends_a_held_first_request_at_once() { + let (port, seen) = fake_host(|path| { + if path.contains("phrase=getservercert") { + None + } else { + Some(r#""#.into()) + } + }); + let start = Instant::now(); + let err = client(port) + .pair_cancellable("1234", "test", |_| { + start.elapsed() >= Duration::from_millis(300) + }) + .unwrap_err(); + assert_eq!(err.to_string(), "pairing cancelled"); + assert!( + start.elapsed() < Duration::from_millis(1500), + "{:?}", + start.elapsed() + ); + assert!( + arrived(&seen, "/unpair").is_some(), + "a cancelled pairing unpairs" + ); + } + + #[test] + fn a_refused_first_request_surfaces_at_once() { + let (port, _) = fake_host(|_| Some(NOT_PAIRED.into())); + let start = Instant::now(); + let mut polls = Vec::new(); + let err = client(port) + .pair_cancellable("1234", "test", |answered| { + polls.push(answered); + false + }) + .unwrap_err(); + assert_eq!(err.to_string(), "the PC refused to start pairing"); + assert!( + start.elapsed() < Duration::from_millis(600), + "{:?}", + start.elapsed() + ); + assert_eq!(polls, [true], "the caller hears that the wait is over"); + } + + #[test] + fn a_held_request_that_times_out_means_nobody_entered_the_pin() { + let e = held_request_error(anyhow!("timed out")); + assert!(e.downcast_ref::().is_some(), "{e:#}"); + assert_eq!(e.to_string(), "nobody entered the PIN on the PC in time"); + let e = held_request_error(anyhow!("connection closed")); + assert_eq!(e.to_string(), "connection closed"); + } } /// Against the Sunshine on this machine, with Latch Host entering the PIN: From 02e007251441943bd82d2da21e3aca6f644ffb31 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:09:36 -0700 Subject: [PATCH 09/34] Updates: a failing self-update no longer stops the PCs' updates A self-update that failed set the release aside and skipped the host pushes until the next check, then downloaded the same 5 MB again every 15 minutes: a release whose Mac build cannot install (a version that does not match its tag, say) kept every PC on the old host. The failure is now remembered on its own: the pushes go on, a failed download is tried again after 15 minutes, and a download that cannot work is not fetched again until the release or its asset changes. The app that is installed is the one just checked: the tarball is re-verified against GitHub's digest at install time and unpacked straight into the staging folder beside the running app, where its signature and version are checked, instead of reusing whatever an earlier run left unpacked under the same tag. manual_update_message says what to do about a Linux host or container behind the release, which this Mac does not update (the lobby shows it from the next commit). The stream handover finds a pre-3.1 host under its BroLink process name and path and replaces it in place, so its firewall rules still apply, and the GitHub token is looked up on the handover's thread, not the window's. Co-Authored-By: Claude Opus 5.5 --- crates/client/src/handover.rs | 42 +++- crates/client/src/update.rs | 412 ++++++++++++++++++++++++++++------ crates/core/src/legacy.rs | 4 + 3 files changed, 378 insertions(+), 80 deletions(-) diff --git a/crates/client/src/handover.rs b/crates/client/src/handover.rs index a18e430..91189b9 100644 --- a/crates/client/src/handover.rs +++ b/crates/client/src/handover.rs @@ -1,5 +1,6 @@ //! Getting a new Latch Host onto a PC whose host is too old to take -//! `/v1/update`, with nobody at the PC: the stream itself is the way in. +//! `/v1/update` (before 3.1, so one still named BroLink), with nobody at +//! the PC: the stream itself is the way in. //! //! The Mac serves the new `latch-host.exe` and a small PowerShell script //! on its own Tailscale address, to that one PC only, for a few minutes. @@ -15,7 +16,7 @@ use crate::input::{self, press_chord}; use anyhow::{anyhow, Context, Result}; use latch_core::http; use latch_core::update::{self, Release}; -use latch_core::HANDOVER_PORT; +use latch_core::{legacy, HANDOVER_PORT}; use latch_stream::Input; use parking_lot::Mutex; use semver::Version; @@ -58,10 +59,12 @@ pub struct Handover { impl Handover { /// Serve `release`'s host (fetching it first) and type the install /// command on the PC at `pc_ip` through `input`. `mac_ip` is this Mac's - /// Tailscale address, the only one the PC can reach. + /// Tailscale address, the only one the PC can reach. `configured_token` + /// is the one in Settings; without it, git's is asked for here, off + /// the window's thread. pub fn start( release: Option, - token: Option, + configured_token: Option, mac_ip: Ipv4Addr, pc_ip: Ipv4Addr, input: Input, @@ -79,6 +82,7 @@ impl Handover { std::thread::Builder::new() .name("handover".into()) .spawn(move || { + let token = update::token(configured_token.as_deref()); let outcome = run( release, token.as_deref(), @@ -150,8 +154,10 @@ pub fn command(mac_ip: Ipv4Addr) -> String { format!("powershell -ep bypass -c \"irm http://{mac_ip}:{HANDOVER_PORT}/u.ps1|iex\"") } -/// The script the PC runs. It finds the running host by its process, or -/// the usual install folder, and replaces that file. +/// The script the PC runs. It finds the running host by its process, under +/// either name, or the file where an install put it, and replaces that file +/// where it is: a host this old is a BroLink one, and its firewall rules +/// name that path. A fresh folder is made only when there is none. pub fn script(mac_ip: Ipv4Addr, version: &Version, sha256_hex: &str) -> String { format!( r#"# Latch Host {version}: installed from your Mac through the stream. @@ -159,7 +165,8 @@ $ErrorActionPreference = 'Stop' $src = 'http://{mac_ip}:{port}' $host.UI.RawUI.WindowTitle = 'Latch Host update' Write-Host "Installing Latch Host {version} from your Mac..." -$p = (Get-Process latch-host -ErrorAction SilentlyContinue | Select-Object -First 1).Path +$p = (Get-Process latch-host, {old_process} -ErrorAction SilentlyContinue | Where-Object Path | Select-Object -First 1).Path +if (-not $p) {{ foreach ($c in (Join-Path $env:LOCALAPPDATA 'Latch\latch-host.exe'), (Join-Path $env:LOCALAPPDATA '{old_exe}')) {{ if (Test-Path $c) {{ $p = $c; break }} }} }} if (-not $p) {{ $p = Join-Path $env:LOCALAPPDATA 'Latch\latch-host.exe' }} $dir = Split-Path -Parent $p New-Item -ItemType Directory -Force -Path $dir | Out-Null @@ -170,7 +177,7 @@ if ($h -ne '{sha}') {{ Remove-Item "$p.new" -Force; throw "the download did not Write-Host "Stopping the old service" try {{ Invoke-RestMethod -Method Post -Uri http://127.0.0.1:47850/v1/quit -ContentType 'application/json' -Body '{{}}' -TimeoutSec 3 | Out-Null }} catch {{}} Start-Sleep -Milliseconds 800 -Get-Process latch-host -ErrorAction SilentlyContinue | Stop-Process -Force +Get-Process latch-host, {old_process} -ErrorAction SilentlyContinue | Stop-Process -Force Start-Sleep -Milliseconds 400 if (Test-Path $p) {{ Move-Item $p "$p.old" -Force }} Move-Item "$p.new" $p -Force @@ -184,6 +191,8 @@ Start-Sleep 5 mac_ip = mac_ip, port = HANDOVER_PORT, sha = sha256_hex.to_uppercase(), + old_process = legacy::WINDOWS_HOST_PROCESS, + old_exe = legacy::WINDOWS_HOST_EXE, ) } @@ -332,8 +341,23 @@ mod tests { let s = script(ip, &Version::new(3, 1, 0), "abcdef"); assert!(s.contains("$src = 'http://100.64.0.20:47851'"), "{s}"); assert!(s.contains("-ne 'ABCDEF'"), "the hash is compared uppercase"); - assert!(s.contains("Get-Process latch-host"), "{s}"); + // A host this old runs as BroLink: found under either name, and + // replaced where it is, so its firewall rules still apply. + assert!(s.contains("Get-Process latch-host, brolink-host"), "{s}"); + assert!( + s.contains( + "Get-Process latch-host, brolink-host -ErrorAction SilentlyContinue | Stop-Process" + ), + "both are stopped: {s}" + ); + assert!(s.contains("'BroLink\\brolink-host.exe'"), "{s}"); assert!(s.contains("Latch\\latch-host.exe"), "{s}"); + let found = s.find("Test-Path $c").unwrap(); + let fresh = s.rfind("if (-not $p) { $p = Join-Path").unwrap(); + assert!( + found < fresh, + "a new folder only when no install was found: {s}" + ); assert!(s.contains("/v1/quit"), "{s}"); assert!(s.contains("--background"), "{s}"); assert!(!s.contains("ghp_"), "no token in the script"); diff --git a/crates/client/src/update.rs b/crates/client/src/update.rs index 7dccc64..cc0d88a 100644 --- a/crates/client/src/update.rs +++ b/crates/client/src/update.rs @@ -24,7 +24,7 @@ use parking_lot::Mutex; use semver::Version; use std::collections::{BTreeMap, BTreeSet}; use std::net::{Ipv4Addr, SocketAddr}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -87,6 +87,10 @@ pub fn spawn( let mut next = Instant::now() + FIRST_CHECK; let mut release: Option = None; let mut token = None; + // The last self-update that failed. It is not tried again on every + // tick, and one that can only fail the same way not until the + // release changes; the PCs get theirs meanwhile either way. + let mut self_failed: Option = None; loop { std::thread::sleep(TICK); let cfg = ClientConfig::load(); @@ -130,9 +134,13 @@ pub fn spawn( let rel = &rel; // This app first. - if rel.is_newer_than(&update::current()) && state.lock().ready.is_none() { + if rel.is_newer_than(&update::current()) + && state.lock().ready.is_none() + && self_update_due(self_failed.as_ref(), rel, Instant::now()) + { match prepare_self(rel, token.as_deref()) { Ok(Some(v)) => { + self_failed = None; let mut st = state.lock(); st.ready = Some(v.clone()); st.notice = Some(( @@ -150,13 +158,9 @@ pub fn spawn( } Err(e) => { state.lock().message = - format!("Could not download Latch {}: {e}.", rel.version); + format!("Could not update this Mac to Latch {}: {e:#}.", rel.version); tracing::warn!("self-update: {e:#}"); - // Try again at the next check rather than every tick. - release = None; - next = Instant::now() + RETRY_AFTER; - ctx.request_repaint(); - continue; + self_failed = Some(SelfFailure::new(rel, &e, Instant::now())); } } ctx.request_repaint(); @@ -177,17 +181,16 @@ pub fn spawn( } }; if idle { - match install_self(rel) { + match install_self(rel, token.as_deref()) { Ok(()) => relaunch(), Err(e) => { let mut st = state.lock(); st.ready = None; st.notice = None; - st.message = format!("Could not install Latch {v}: {e}."); + st.message = format!("Could not install Latch {v}: {e:#}."); tracing::error!("self-update: {e:#}"); progress.lock().updating = false; - release = None; - next = Instant::now() + RETRY_AFTER; + self_failed = Some(SelfFailure::new(rel, &e, Instant::now())); } } ctx.request_repaint(); @@ -205,7 +208,10 @@ pub fn spawn( continue; }; if !update::takes_pushed_host(&h.os) { - continue; // a Mac or a container updates itself + // A Mac updates with its own app; a Linux host or a + // container is updated by hand, and the lobby says so + // (`manual_update_message`). + continue; } if !update::host_can_receive_update(&v) { // The lobby shows this PC what to do; here, just note it. @@ -365,86 +371,158 @@ fn run(cmd: &str, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } +/// A download that arrived whole and still cannot update this Mac. The +/// same asset fails the same way every time, so it is not fetched again. +#[derive(Debug)] +struct Unusable; + +impl std::fmt::Display for Unusable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("this release cannot update this Mac") + } +} + +/// A self-update that failed: for which release and asset, and when it may +/// be tried again (`None`: not until the release or its asset changes). +#[derive(Debug, Clone, PartialEq)] +struct SelfFailure { + tag: String, + digest: Option, + retry_at: Option, +} + +impl SelfFailure { + fn new(rel: &Release, e: &anyhow::Error, now: Instant) -> Self { + Self { + tag: rel.tag.clone(), + digest: mac_digest(rel), + retry_at: e + .downcast_ref::() + .is_none() + .then_some(now + RETRY_AFTER), + } + } +} + +fn mac_digest(rel: &Release) -> Option { + rel.asset(MAC_ASSET).and_then(|a| a.sha256.clone()) +} + +/// Whether to try updating this Mac to `rel` now: always after nothing has +/// failed, or for another release or a re-published asset; for the one +/// that failed, once its wait is over, and never if it cannot work. +fn self_update_due(failed: Option<&SelfFailure>, rel: &Release, now: Instant) -> bool { + match failed { + None => true, + Some(f) if f.tag != rel.tag || f.digest != mac_digest(rel) => true, + Some(f) => f.retry_at.is_some_and(|at| now >= at), + } +} + /// Download and verify the new app. `Ok(None)` when this copy is not in a -/// bundle (a development build), which is left alone. +/// bundle (a development build), which is left alone. A download that +/// fails its checks is [`Unusable`]; a failed download is not. fn prepare_self(rel: &Release, token: Option<&str>) -> Result> { if bundle_path().is_none() { return Ok(None); } - let tarball = fetch_asset(rel, MAC_ASSET, token)?; - let unpacked = updates_dir(rel)?.join("unpacked"); - let app = unpacked.join("Latch.app"); - if !app.exists() { - let _ = std::fs::remove_dir_all(&unpacked); - std::fs::create_dir_all(&unpacked)?; - run( - "/usr/bin/tar", - &[ - "xzf", - &tarball.display().to_string(), - "-C", - &unpacked.display().to_string(), - ], - )?; + if rel.asset(MAC_ASSET).is_none() { + return Err(anyhow!("release {} has no {MAC_ASSET}", rel.tag).context(Unusable)); } - let verified = (|| -> Result { - anyhow::ensure!(app.exists(), "the download holds no Latch.app"); - run( - "/usr/bin/codesign", - &["--verify", "--deep", "--strict", &app.display().to_string()], - ) - .context("the new app's signature does not verify")?; - let version = run( - "/usr/bin/plutil", - &[ - "-extract", - "CFBundleShortVersionString", - "raw", - &app.join("Contents/Info.plist").display().to_string(), - ], - )?; - let version = - Version::parse(&version).with_context(|| format!("bundle version {version:?}"))?; - anyhow::ensure!( - version == rel.version, - "the download says {version}, the release {}", - rel.version - ); - Ok(version) - })(); - match verified { + let tarball = fetch_asset(rel, MAC_ASSET, token)?; + match prepare_in(&updates_dir(rel)?.join("unpacked"), &tarball, &rel.version) { Ok(v) => Ok(Some(v)), Err(e) => { - // Nothing from a failed check may be found and trusted at the - // next attempt: fetch and unpack again from scratch. - let _ = std::fs::remove_dir_all(&unpacked); + // It matched GitHub's digest, so fetching it again would bring + // the same bytes. let _ = std::fs::remove_file(&tarball); - Err(e) + Err(e.context(Unusable)) } } } -/// Move the verified app over this one. macOS keeps the running executable -/// alive across the rename, so the swap is safe while we are still up. -fn install_self(rel: &Release) -> Result<()> { +/// Unpack `tarball` into an emptied `unpacked` and check the app in it, +/// then empty it again: an unpacked copy is never kept to be trusted later +/// (the install unpacks the tarball afresh). +fn prepare_in(unpacked: &Path, tarball: &Path, want: &Version) -> Result { + let _ = std::fs::remove_dir_all(unpacked); + std::fs::create_dir_all(unpacked)?; + let checked = unpack_app(tarball, unpacked).and_then(|app| verify_app(&app, want)); + let _ = std::fs::remove_dir_all(unpacked); + checked +} + +/// Extract `Latch.app`, and nothing else in the archive, into `dir`. +fn unpack_app(tarball: &Path, dir: &Path) -> Result { + run( + "/usr/bin/tar", + &[ + "xzf", + &tarball.display().to_string(), + "-C", + &dir.display().to_string(), + "Latch.app", + ], + )?; + Ok(dir.join("Latch.app")) +} + +/// The app is there, its signature verifies, and it is the version wanted. +fn verify_app(app: &Path, want: &Version) -> Result { + anyhow::ensure!(app.exists(), "the download holds no Latch.app"); + run( + "/usr/bin/codesign", + &["--verify", "--deep", "--strict", &app.display().to_string()], + ) + .context("the new app's signature does not verify")?; + let version = run( + "/usr/bin/plutil", + &[ + "-extract", + "CFBundleShortVersionString", + "raw", + &app.join("Contents/Info.plist").display().to_string(), + ], + )?; + let version = + Version::parse(&version).with_context(|| format!("bundle version {version:?}"))?; + anyhow::ensure!( + version == *want, + "the download says {version}, the release {want}" + ); + Ok(version) +} + +/// Move the new app over this one. The release's tarball is checked +/// against GitHub's digest again (and fetched again if the asset changed +/// since it was prepared), unpacked straight into the staging folder beside +/// this app and checked there: what was checked is exactly what goes in. +/// macOS keeps the running executable alive across the rename, so the swap +/// is safe while we are still up. +fn install_self(rel: &Release, token: Option<&str>) -> Result<()> { let bundle = bundle_path().ok_or_else(|| anyhow!("not running from an app bundle"))?; - let new = updates_dir(rel)?.join("unpacked").join("Latch.app"); - anyhow::ensure!(new.exists(), "nothing is downloaded"); - replace_bundle(&bundle, &new, |new, staged| { - run( - "/usr/bin/ditto", - &[&new.display().to_string(), &staged.display().to_string()], - ) - .map(|_| ()) + let tarball = fetch_asset(rel, MAC_ASSET, token)?; + replace_bundle(&bundle, &tarball, |tarball, staged| { + stage_from(tarball, staged, &rel.version) })?; let _ = run( "/usr/bin/xattr", &["-dr", "com.apple.quarantine", &bundle.display().to_string()], ); - let _ = std::fs::remove_dir_all(updates_dir(rel)?.join("unpacked")); Ok(()) } +/// Unpack the app from `tarball` as `staged` (`Latch.app` in a fresh +/// staging folder) and check it where it lies. +fn stage_from(tarball: &Path, staged: &Path, want: &Version) -> Result<()> { + let dir = staged + .parent() + .ok_or_else(|| anyhow!("the staging folder has no parent"))?; + let app = unpack_app(tarball, dir)?; + anyhow::ensure!(app == staged, "unpacked to {}", app.display()); + verify_app(&app, want).map(|_| ()) +} + /// Stage on the destination volume before moving the running app. A failed /// cross-volume copy leaves the working app untouched, and the final swaps /// are both same-volume renames. @@ -529,6 +607,27 @@ pub fn old_host_message(name: &str, version: &Version) -> String { ) } +/// What to say about a host this Mac cannot update when a newer release is +/// out: a Linux machine or a container, whose Latch Host is installed by +/// hand. `None` for a Windows PC (it is sent the update), a Mac (its own +/// app updates it) and a host already on the release. +pub fn manual_update_message( + name: &str, + os: &str, + running: &Version, + rel: &Release, +) -> Option { + if update::takes_pushed_host(os) || os.eq_ignore_ascii_case("macos") { + return None; + } + rel.is_newer_than(running).then(|| { + format!( + "{name} runs Latch Host {running}, and {} is out. This Mac sends updates to Windows PCs only: install the new Latch Host on {name} by hand, the way it was set up.", + rel.version + ) + }) +} + fn is_transient(e: &anyhow::Error) -> bool { let s = format!("{e:#}"); s.contains("timed out") @@ -695,6 +794,117 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A minimal app bundle at `dir/Latch.app` that says `version` and + /// carries `marker`, ad-hoc signed as a release build is. + #[cfg(target_os = "macos")] + fn fake_app(dir: &Path, version: &str, marker: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let app = dir.join("Latch.app"); + let contents = app.join("Contents"); + std::fs::create_dir_all(contents.join("MacOS")).unwrap(); + std::fs::create_dir_all(contents.join("Resources")).unwrap(); + let exe = contents.join("MacOS/Latch"); + std::fs::write(&exe, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(contents.join("Resources/marker"), marker).unwrap(); + std::fs::write( + contents.join("Info.plist"), + format!( + r#" + + +CFBundleExecutableLatch +CFBundleIdentifiercom.example.latch-update-test +CFBundlePackageTypeAPPL +CFBundleShortVersionString{version} +"# + ), + ) + .unwrap(); + let path = app.display().to_string(); + run( + "/usr/bin/codesign", + &["-s", "-", "--force", "--deep", &path], + ) + .unwrap(); + app + } + + /// A release tarball laid out as release.yml makes it. + #[cfg(target_os = "macos")] + fn tarball_of(dir: &Path, version: &str, marker: &str) -> PathBuf { + let src = dir.join(format!("build-{marker}")); + fake_app(&src, version, marker); + std::fs::write(src.join("README-MACOS.md"), "read me").unwrap(); + let tarball = dir.join(format!("{marker}.tar.gz")); + run( + "/usr/bin/tar", + &[ + "czf", + &tarball.display().to_string(), + "-C", + &src.display().to_string(), + "Latch.app", + "README-MACOS.md", + ], + ) + .unwrap(); + tarball + } + + #[cfg(target_os = "macos")] + fn scratch() -> PathBuf { + let dir = std::env::temp_dir().join(format!("latch-selfup-{:016x}", rand::random::())); + std::fs::create_dir(&dir).unwrap(); + dir + } + + #[cfg(target_os = "macos")] + #[test] + fn an_app_left_unpacked_earlier_is_never_what_gets_checked() { + let dir = scratch(); + let unpacked = dir.join("unpacked"); + // An earlier build of the same tag, valid in itself, left behind. + fake_app(&unpacked, "9.9.9", "stale"); + let tarball = tarball_of(&dir, "9.9.8", "fresh"); + let want = Version::new(9, 9, 9); + let e = prepare_in(&unpacked, &tarball, &want).unwrap_err(); + assert!(e.to_string().contains("says 9.9.8"), "{e:#}"); + assert_eq!( + prepare_in(&unpacked, &tarball, &Version::new(9, 9, 8)).unwrap(), + Version::new(9, 9, 8) + ); + assert!(!unpacked.exists(), "nothing unpacked is kept"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn the_install_checks_the_app_it_unpacks_into_the_stage() { + let dir = scratch(); + let apps = dir.join("Applications"); + let bundle = fake_app(&apps, "9.9.8", "running"); + let tarball = tarball_of(&dir, "9.9.9", "fresh"); + let marker = + |app: &Path| std::fs::read_to_string(app.join("Contents/Resources/marker")).unwrap(); + // The wrong version: refused, and the running app is untouched. + let e = replace_bundle(&bundle, &tarball, |t, staged| { + stage_from(t, staged, &Version::new(9, 9, 7)) + }) + .unwrap_err(); + assert!(format!("{e:#}").contains("says 9.9.9"), "{e:#}"); + assert_eq!(marker(&bundle), "running"); + assert_eq!(std::fs::read_dir(&apps).unwrap().count(), 1); + // The right one goes in, and only the app comes out of the archive. + replace_bundle(&bundle, &tarball, |t, staged| { + stage_from(t, staged, &Version::new(9, 9, 9)) + }) + .unwrap(); + assert_eq!(marker(&bundle), "fresh"); + assert_eq!(std::fs::read_dir(&apps).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn the_bundle_is_found_only_inside_an_app() { // The test binary lives in target/, not in a .app. @@ -791,6 +1001,66 @@ mod tests { assert!(!runs_at_least(closed, &want, Duration::ZERO)); } + #[test] + fn a_linux_host_behind_the_release_is_named_and_others_are_not() { + let r = rel("4.1.2"); + let old = Version::new(4, 1, 1); + let msg = manual_update_message("stuzha", "linux", &old, &r).expect("a line"); + assert!(msg.contains("stuzha") && msg.contains("4.1.1") && msg.contains("4.1.2")); + assert_eq!( + manual_update_message("stuzha", "linux", &Version::new(4, 1, 2), &r), + None + ); + for os in ["windows", "", "macOS"] { + assert_eq!(manual_update_message("pc", os, &old, &r), None, "{os:?}"); + } + } + + fn rel_with_mac_asset(v: &str, digest: &str) -> Release { + Release { + assets: vec![latch_core::update::Asset { + name: MAC_ASSET.into(), + url: String::new(), + size: 1, + sha256: Some(digest.into()), + }], + ..rel(v) + } + } + + #[test] + fn a_failed_self_update_waits_and_one_that_cannot_work_waits_for_a_new_asset() { + let now = Instant::now(); + let r = rel_with_mac_asset("4.1.2", "aa"); + assert!(self_update_due(None, &r, now)); + // A download that failed: again after a while, not every tick. + let failed = SelfFailure::new(&r, &anyhow!("timed out"), now); + assert!(!self_update_due(Some(&failed), &r, now)); + assert!(self_update_due(Some(&failed), &r, now + RETRY_AFTER)); + // A download that can never work (it says 4.1.1 inside): not again + // for these bytes, at once for new ones or a new release. + let unusable = SelfFailure::new( + &r, + &anyhow!("the download says 4.1.1, the release 4.1.2").context(Unusable), + now, + ); + assert!(!self_update_due( + Some(&unusable), + &r, + now + 100 * RETRY_AFTER + )); + assert!(self_update_due( + Some(&unusable), + &rel_with_mac_asset("4.1.2", "bb"), + now + )); + assert!(self_update_due( + Some(&unusable), + &rel_with_mac_asset("4.1.3", "aa"), + now + )); + } + #[test] fn pipe_and_eagain_are_retried() { assert!(is_transient(&anyhow!("connection closed"))); diff --git a/crates/core/src/legacy.rs b/crates/core/src/legacy.rs index 811336d..4a3a0ca 100644 --- a/crates/core/src/legacy.rs +++ b/crates/core/src/legacy.rs @@ -15,6 +15,10 @@ pub const STREAMER_KIND: &str = "BroLink"; /// The update push's headers, which a 4.0 host requires and a 4.0 Mac sends. pub const UPDATE_VERSION_HEADER: &str = "x-brolink-version"; pub const UPDATE_SHA256_HEADER: &str = "x-brolink-sha256"; +/// A Windows host from 4.0 or older: its process name, and where the +/// install script put it under `%LOCALAPPDATA%`. +pub const WINDOWS_HOST_PROCESS: &str = "brolink-host"; +pub const WINDOWS_HOST_EXE: &str = r"BroLink\brolink-host.exe"; /// Whether `app` is what a Latch node (this version or an older one) puts in /// `Status::app`. From 431ae0a778218ec6fffe143444f65e5546f3052c Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:10:23 -0700 Subject: [PATCH 10/34] Lobby and stream: power by node id, ask before sleep, forget a machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Power actions found their machine by display name, which a tailnet does not keep unique: Restart, Shut down and the sleep offer now go by node id, and a machine that has gone away gets a notice, not a request to its namesake. A power action on one machine no longer cancels a connection to another, and the stream from its own machine ends only once the host has agreed, so a refusal leaves it up and is shown over the picture. The stream's machine menu follows the owner's power setting (no menu when it is off), asks before Sleep as before Restart, and offers Sleep only for a Windows PC: a Linux VPS or a Mac would stay asleep with nothing here able to wake it. The lobby row menu and the sleep offer after a session follow the same rule. A machine that is away can be forgotten from its row menu, after a question that says what goes with it (its pairing, how to wake it). A Mac with Share this Mac off reads "only connects to your other machines", not "the background service is stopped". Hosts this Mac cannot update are named once a newer release is out. The PC is asked why only about a black picture, never about video that stopped or cannot be decoded, and a Linux or Mac host, a failed ask or an empty answer all leave an answer, so "Asking the PC why…" no longer stays up. A display change reconnects only when it changes what the stream asks for. Full screen follows what the system reports (the green button), survives a reconnect without leaving and coming back, and the window returns to what it was before the stream. Stream failures and abnormal ends are logged. Co-Authored-By: Claude Opus 5.5 --- crates/client/src/app.rs | 718 +++++++++++++++++++++++++++++++---- crates/client/src/display.rs | 53 +++ crates/client/src/share.rs | 3 + crates/client/src/stream.rs | 134 +++++-- crates/host/src/product.rs | 1 + 5 files changed, 805 insertions(+), 104 deletions(-) diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index 19df712..bee3a4e 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -43,6 +43,25 @@ const NOTICE_FOR: Duration = Duration::from_secs(12); /// A one-line result, filled in by a worker thread. type Notice = Arc>>; +/// A PC's fixed desktop size and frame-rate ceiling, from its last status. +type HostLimits = (Option<[u32; 2]>, u32); + +/// A question about one machine, waiting for a second click. The machine +/// is its node id: names are not unique on a tailnet. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Confirm { + node_id: String, + name: String, + action: Confirmable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Confirmable { + Power(PowerAction), + /// Forget what Latch saved about it, its pairing included. + Forget, +} + /// The window's pages, one per tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { @@ -95,12 +114,20 @@ pub struct ClientApp { tailscale_ok: Arc, brand: ui::Brand, page: Page, + /// Whether the window is full screen, as the system last reported it. fullscreen: bool, - /// A restart or shutdown waits for a second click. - confirm: Option<(String, PowerAction)>, + /// Full screen or not once streaming is over: what the window was when + /// the stream first connected. Kept across a reconnect. + fullscreen_after: Option, + /// A restart, a shutdown or forgetting a machine waits for a second + /// click. + confirm: Option, notice: Option<(Tone, String, Instant)>, /// After a session ends, offer to sleep this PC. offer_sleep: Option, + /// A power action went to the machine of the current attempt: its end + /// is no failure, and no reason to offer sleep. Cleared if refused. + power_sent: Arc, ended_seen: bool, pending_notice: Option, updates: Arc>, @@ -165,9 +192,11 @@ impl ClientApp { brand: ui::Brand::new(&cc.egui_ctx), page: Page::Machines, fullscreen: false, + fullscreen_after: None, confirm: None, notice: None, offer_sleep: None, + power_sent: Arc::default(), ended_seen: true, pending_notice: None, updates: Arc::default(), @@ -253,9 +282,12 @@ impl ClientApp { self.streamed = false; self.stream_failed = false; self.confirm = None; + self.power_sent.store(false, Ordering::Relaxed); self.last_pc = Some(pc.clone()); self.asked_about_video = false; - *self.video_help.lock() = None; + // A new slot, so an answer still on its way about the last stream + // lands in the old one. + self.video_help = Arc::default(); self.display_at_connect = Self::native_pixels(ctx); self.display_change = None; session::connect(Connect { @@ -284,30 +316,43 @@ impl ClientApp { self.pending_notice = Some(notice); } - /// The first time a connected stream diagnoses its own picture as - /// broken, ask the PC what it can see. One question per stream: the - /// answer does not change while the same capture keeps failing. - fn ask_why_black(&mut self, ctx: &egui::Context, live: &Live) { + /// The first time a connected stream finds its picture black, ask the + /// PC what it can see. Only then: a picture that stopped arriving or + /// cannot be decoded is the network's or this Mac's doing, which the PC + /// cannot explain. One question per stream: the answer does not change + /// while the same capture keeps failing. Every way it ends leaves an + /// answer, so "Asking the PC why…" never stays up. + fn ask_why_black(&mut self, ctx: &egui::Context, live: &Live, os: &str) { + let problem = live.session.stats().video_problem; if self.asked_about_video - || live.session.stats().video_problem.is_none() + || !problem + .as_deref() + .is_some_and(crate::display::is_black_picture) || !live.session.connected() { return; } self.asked_about_video = true; - let (ip, help, ctx) = (live.ip, self.video_help.clone(), ctx.clone()); + let help = self.video_help.clone(); + if !crate::display::can_explain(os) { + *help.lock() = Some(crate::display::no_answer(os)); + return; + } + let (ip, ctx, os) = (live.ip, ctx.clone(), os.to_string()); std::thread::spawn(move || { let answer = match crate::display::ask(ip) { Ok(report) => crate::display::verdict(&report), Err(e) => { tracing::warn!("display report: {e:#}"); - return; + Default::default() } }; - if !answer.message.is_empty() { - *help.lock() = Some(answer); - ctx.request_repaint(); - } + *help.lock() = Some(if answer.message.is_empty() { + crate::display::no_answer(&os) + } else { + answer + }); + ctx.request_repaint(); }); } @@ -347,22 +392,52 @@ impl ClientApp { self.disconnect(); } + /// Ask the machine at `ip` to sleep, restart or shut down. A stream from + /// it, or a connection to it, ends once it has agreed (its host ends + /// the stream itself first), so a refusal leaves the stream up. A + /// connection to any other machine is none of this action's business. fn power(&mut self, ip: std::net::Ipv4Addr, name: &str, action: PowerAction) { let name = name.to_string(); - // End the stream first so Sunshine sees a clean disconnect. - self.disconnect(); - self.notify_later(move || { - std::thread::sleep(Duration::from_millis(600)); - match session::power(ip, action) { - Ok(()) => ( + let ours = self.is_current_target(ip); + if ours { + self.power_sent.store(true, Ordering::Relaxed); + if self.live.lock().is_some() { + self.view.toast( + Tone::Neutral, + format!("Asking {name} to {}…", action.label().to_lowercase()), + ); + } + } + let (live, progress, sent) = ( + self.live.clone(), + self.progress.clone(), + self.power_sent.clone(), + ); + self.notify_later(move || match session::power(ip, action) { + Ok(()) => { + if ours { + end_connection_to(&live, &progress, ip); + } + ( Tone::Success, format!("{name}: {} requested.", action.label().to_lowercase()), - ), - Err(e) => (Tone::Danger, format!("{name}: {e}")), + ) + } + Err(e) => { + if ours { + sent.store(false, Ordering::Relaxed); + } + (Tone::Danger, format!("{name}: {e}")) } }); } + /// The machine this window is streaming from or connecting to. + fn is_current_target(&self, ip: std::net::Ipv4Addr) -> bool { + let live_ip = self.live.lock().as_ref().map(|l| l.ip); + targets(live_ip, &self.progress.lock(), ip) + } + fn test_wake(&mut self, pc: &Pc) { let pc = pc.clone(); self.notify_later(move || match session::wake_test(&pc) { @@ -411,6 +486,7 @@ impl ClientApp { prog.step = Step::Streaming; prog.since = Instant::now(); self.streamed = true; + self.fullscreen_after.get_or_insert(self.fullscreen); if self.cfg.stream.fullscreen { fullscreen = Some(true); } else { @@ -428,7 +504,12 @@ impl ClientApp { } } Event::Failed { stage, code } => { - let cancelled = prog.cancel; + let cancelled = prog.cancel || self.power_sent.load(Ordering::Relaxed); + tracing::warn!( + "{}: the stream failed at the {stage} step (code {code}){}", + live.pc, + if cancelled { " after a stop" } else { "" } + ); self.stream_failed = !cancelled; prog.step = Step::Ended { error: (!cancelled).then_some(format!( @@ -437,8 +518,17 @@ impl ClientApp { }; } Event::Terminated { code, message } => { - let cancelled = prog.cancel; + let cancelled = prog.cancel || self.power_sent.load(Ordering::Relaxed); let error = code != 0 && !cancelled; + if code != 0 { + tracing::warn!( + "{}: the stream ended, code {code}: {message}{}", + live.pc, + if cancelled { " (after a stop)" } else { "" } + ); + } else { + tracing::info!("{}: the stream ended: {message}", live.pc); + } self.stream_failed = error; prog.step = Step::Ended { error: error.then_some(message), @@ -458,7 +548,8 @@ impl ClientApp { } self.view.reset(ctx, Some(live)); *guard = None; - fullscreen = Some(false); + fullscreen = + fullscreen_when_finished(self.reconnect.is_some(), &mut self.fullscreen_after); } else if ended { live.session.stop(); } @@ -468,9 +559,53 @@ impl ClientApp { } } + /// Reconnect when the window has moved to a display of another size + /// (held for a second), if that changes what the stream asks for: it + /// does for Match screen, and not for a fixed size or for a PC whose + /// fixed desktop is smaller than both displays. `asked` is what the + /// stream asked for; `limits` the PC's fixed desktop and frame-rate + /// ceiling, when discovery knows them (without them, it reconnects). + fn follow_display( + &mut self, + ctx: &egui::Context, + (asked, limits): ((u32, u32, u32), Option), + ) { + let size = Self::native_pixels(ctx); + if size == self.display_at_connect || self.reconnect.is_some() { + self.display_change = None; + return; + } + match self.display_change { + Some((candidate, at)) + if candidate == size && at.elapsed() >= Duration::from_secs(1) => + { + let changes = limits.is_none_or(|(capture_size, max_fps)| { + session::request_for(&self.cfg.stream, size, capture_size, max_fps) != asked + }); + if changes { + self.restart_capture = true; + self.reconnect = self.last_pc.clone(); + self.disconnect(); + } else { + // Nothing to ask for anew: this display is the one the + // stream is for now. + self.display_at_connect = size; + self.display_change = None; + } + } + Some((candidate, _)) if candidate == size => {} + _ => self.display_change = Some((size, Instant::now())), + } + } + fn poll_notice(&mut self) { let ready = self.pending_notice.as_ref().and_then(|n| n.lock().take()); if let Some((tone, text)) = ready { + // The machine list is out of sight while a stream runs: a + // refused power action, say, has to be read over the picture. + if self.live.lock().is_some() { + self.view.toast(tone, text.clone()); + } self.notice = Some((tone, text, Instant::now())); self.pending_notice = None; } @@ -514,6 +649,70 @@ impl ClientApp { } } +/// Full screen or not once a stream has finished. A restart, new settings +/// or a new display reconnect at once, so the window stays as it is rather +/// than leaving full screen and coming back; otherwise it goes back to what +/// it was before the stream (`after`), if the stream ever showed. +fn fullscreen_when_finished(reconnecting: bool, after: &mut Option) -> Option { + if reconnecting { + None + } else { + after.take() + } +} + +/// Where to send a power action meant for the machine `node_id`: the +/// listed machine with that id, if it answers and allows it. Never another +/// machine of the same name. +fn power_target(disc: &Discovery, node_id: &str) -> Option { + disc.pcs + .iter() + .find(|p| p.node_id == node_id && !p.remembered && p.online && p.power_allowed()) + .and_then(|p| p.ip) +} + +/// What forgetting a machine does, for the question before it. +fn forget_question(disc: &Discovery, node_id: &str, name: &str) -> String { + let listed = disc + .pcs + .iter() + .any(|p| p.node_id == node_id && !p.remembered); + if listed { + format!("Latch forgets how to wake {name} and its pairing. It stays in this list while it is on your Tailscale network; remove it in Tailscale's admin console to retire it.") + } else { + format!("Latch forgets {name}'s address, how to wake it and its pairing. If it is still on your Tailscale network, it is listed again once Tailscale is back, and pairs again on the next connect.") + } +} + +/// Whether `ip` is the machine of the stream (`live_ip`) or of the +/// connection under way. +fn targets( + live_ip: Option, + progress: &Progress, + ip: std::net::Ipv4Addr, +) -> bool { + live_ip == Some(ip) || (progress.active() && progress.ip == Some(ip)) +} + +/// End the stream from `ip`, or the connection to it, from a worker: what +/// a disconnect does, for that machine only. One lock at a time, as the +/// window takes them. +fn end_connection_to( + live: &Mutex>, + progress: &Mutex, + ip: std::net::Ipv4Addr, +) { + { + let mut p = progress.lock(); + if p.active() && p.ip == Some(ip) { + p.cancel = true; + } + } + if let Some(l) = live.lock().as_ref().filter(|l| l.ip == ip) { + l.session.stop(); + } +} + /// Look for Tailscale's CLI every few seconds on a thread of its own, and /// repaint when the answer changes. fn spawn_tailscale_probe(ok: Arc, ctx: egui::Context) { @@ -528,6 +727,11 @@ fn spawn_tailscale_probe(ok: Arc, ctx: egui::Context) { impl eframe::App for ClientApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // The window's green button (or Globe-F) changes full screen behind + // the app's back: what the system reports is what is true. + if let Some(on) = ctx.input(|i| i.viewport().fullscreen) { + self.fullscreen = on; + } self.poll_live(ctx); self.poll_notice(); @@ -549,20 +753,40 @@ impl eframe::App for ClientApp { .and_then(|p| p.host.as_ref()) .and_then(|h| stream::old_host(&h.version, latest.as_ref())); self.tend_handover(pc_now, &l.pc); - self.ask_why_black(ctx, l); - let help = self.video_help.lock().clone(); + let os = pc_now.map(|p| p.os.clone()).unwrap_or_default(); + self.ask_why_black(ctx, l, &os); + // The PC's answer is about a black picture: it goes under + // that headline and no other. + let black = l + .session + .stats() + .video_problem + .as_deref() + .is_some_and(crate::display::is_black_picture); + let help = self.video_help.lock().clone().filter(|_| black); let env = Env { live: l, cfg: &self.cfg, fullscreen: self.fullscreen, path: pc_now.map(|p| p.path.clone()), - os: pc_now.map(|p| p.os.clone()).unwrap_or_default(), + os, old_host, handover: self.handover.as_ref().map(|h| h.status(&l.pc)), video_help: help, + // Discovery's fresh word on it; without that, nothing. + power_allowed: pc_now.is_some_and(|p| p.power_allowed()), + can_sleep: pc_now.is_some_and(|p| p.can_sleep()), }; let actions = self.view.show(ctx, &env); let (ip, name, input) = (l.ip, l.pc.clone(), l.input.clone()); + // What a reconnect for a new display would ask for, beside + // what this stream asked for. + let display_request = ( + l.requested, + pc_now + .and_then(|p| p.host.as_ref()) + .map(|h| (h.streamer.capture_size, h.streamer.max_fps)), + ); drop(guard); for a in actions { match a { @@ -599,22 +823,7 @@ impl eframe::App for ClientApp { } } } - } - let size = Self::native_pixels(ctx); - if size != self.display_at_connect && self.reconnect.is_none() { - match self.display_change { - Some((candidate, at)) - if candidate == size && at.elapsed() >= Duration::from_secs(1) => - { - self.restart_capture = true; - self.reconnect = self.last_pc.clone(); - self.disconnect(); - } - Some((candidate, _)) if candidate == size => {} - _ => self.display_change = Some((size, Instant::now())), - } - } else { - self.display_change = None; + self.follow_display(ctx, display_request); } self.commit(); return; @@ -645,11 +854,16 @@ impl eframe::App for ClientApp { if let Step::Ended { .. } = &prog.step { if !self.ended_seen { self.ended_seen = true; - if self.cfg.sleep_prompt { + // A reconnect that failed before its stream came back: the + // window leaves full screen as the stream's end would have. + if let Some(on) = self.fullscreen_after.take() { + self.set_fullscreen(ctx, on); + } + if self.cfg.sleep_prompt && !self.power_sent.load(Ordering::Relaxed) { self.offer_sleep = disc .pcs .iter() - .find(|p| p.name == prog.pc && p.power_allowed()) + .find(|p| p.node_id == prog.node_id && p.can_sleep()) .cloned(); } } @@ -812,27 +1026,68 @@ impl ClientApp { } fn confirm_banner(&mut self, ui: &mut egui::Ui, disc: &Discovery) { - let Some((name, action)) = self.confirm.clone() else { + let Some(q) = self.confirm.clone() else { return; }; - let pc = disc.pcs.iter().find(|p| p.name == name).cloned(); - ui::banner( - ui, - Tone::Danger, - &format!("{} {name}?", action.label()), - Some("Programs there close without asking, and anything unsaved is lost."), - |ui| { - if ui::destructive_button(ui, action.label()).clicked() { - if let Some(ip) = pc.as_ref().and_then(|p| p.ip) { - self.power(ip, &name, action); - } - self.confirm = None; - } - if ui::ghost_button(ui, "Cancel").clicked() { - self.confirm = None; + let name = &q.name; + let (title, body, button) = match q.action { + Confirmable::Power(action) => ( + format!("{} {name}?", action.label()), + match action { + PowerAction::Sleep => "Asleep, it can only be woken from its own network, or through a router that forwards wake packets to it.".to_string(), + _ => "Programs there close without asking, and anything unsaved is lost.".to_string(), + }, + action.label(), + ), + Confirmable::Forget => ( + format!("Forget {name}?"), + forget_question(disc, &q.node_id, name), + "Forget", + ), + }; + ui::banner(ui, Tone::Danger, &title, Some(&body), |ui| { + if ui::destructive_button(ui, button).clicked() { + match q.action { + Confirmable::Power(action) => match power_target(disc, &q.node_id) { + Some(ip) => self.power(ip, name, action), + None => { + self.notice = Some(( + Tone::Danger, + format!("{name} is no longer reachable, so nothing was sent."), + Instant::now(), + )) + } + }, + Confirmable::Forget => self.forget(&q.node_id, name), } - }, - ); + self.confirm = None; + } + if ui::ghost_button(ui, "Cancel").clicked() { + self.confirm = None; + } + }); + } + + /// Remove what Latch saved about a machine, its pairing included. A + /// remembered row goes at once; a machine still on the tailnet stays + /// listed, unpaired. + fn forget(&mut self, node_id: &str, name: &str) { + let outcome = ClientConfig::update(|c| { + c.forget(node_id); + }); + self.cfg.forget(node_id); + self.discovery + .lock() + .pcs + .retain(|p| !(p.remembered && p.node_id == node_id)); + self.notice = Some(match outcome { + Ok(()) => (Tone::Neutral, format!("Forgot {name}."), Instant::now()), + Err(e) => ( + Tone::Danger, + format!("Couldn't forget {name}: {e}"), + Instant::now(), + ), + }); } fn machine_list( @@ -912,6 +1167,7 @@ impl ClientApp { let g = slot.lock(); let running = g.setup_running; let stopped = g.service_stopped; + let viewer_only = g.viewer_only; let status = g.status.clone(); drop(g); let noun = this_noun(status.as_ref().map(|s| s.os.as_str()).unwrap_or_default()); @@ -924,6 +1180,11 @@ impl ClientApp { .as_ref() .is_some_and(|s| s.streamer.running && s.streamer.api_ok); let (tone, detail) = match &status { + // Off on purpose, not a fault: Share this Mac is off. + None if viewer_only => ( + Tone::Neutral, + "Not shared · it only connects to your other machines".to_string(), + ), None if stopped => ( Tone::Neutral, "Not shared · the background service is stopped".to_string(), @@ -957,22 +1218,37 @@ impl ClientApp { ), }; ui::list_row(ui, Some(tone), &name, &detail, |ui| { - let label = if shared { "Manage" } else { "Set up sharing" }; + // Only ever the Sharing page: nothing starts from the list. + let label = if shared { + "Manage" + } else if viewer_only && status.is_none() { + "Share this Mac…" + } else { + "Set up sharing" + }; if ui::secondary_button(ui, label).clicked() { self.page = Page::Sharing; } }); } - /// The row's "…" menu: wake, power, copy the address. + /// The row's "…" menu: wake, power, copy the address, forget. fn row_menu(&mut self, ui: &mut egui::Ui, pc: &Pc) { let live = !pc.remembered; let wake = live && !pc.online && pc.can_wake(); let test_wake = live && pc.online && pc.host.is_some() && pc.can_wake(); let power = live && pc.online && pc.power_allowed(); - if !(wake || test_wake || power || pc.ip.is_some()) { + // Only a machine that is away: one that answers is re-learned + // within seconds, and forgetting it would only unpair it. + let forget = !pc.online && pc.known.is_some(); + if !(wake || test_wake || power || forget || pc.ip.is_some()) { return; } + let ask = |action| Confirm { + node_id: pc.node_id.clone(), + name: pc.name.clone(), + action, + }; ui::icon_menu(ui, Icon::More, &format!("More for {}", pc.name), |ui| { if wake && ui::menu_item(ui, "Wake").clicked() { self.notice = Some(match session::wake_only(pc) { @@ -994,16 +1270,18 @@ impl ClientApp { if wake || test_wake { ui::menu_separator(ui); } - if ui::menu_item(ui, "Sleep").clicked() { + // Only where something can wake it again: never a Linux + // VPS or a Mac, which would stay asleep. + if pc.can_sleep() && ui::menu_item(ui, "Sleep").clicked() { if let Some(ip) = pc.ip { self.power(ip, &pc.name, PowerAction::Sleep); } } if ui::menu_item(ui, "Restart…").clicked() { - self.confirm = Some((pc.name.clone(), PowerAction::Restart)); + self.confirm = Some(ask(Confirmable::Power(PowerAction::Restart))); } if ui::menu_item(ui, "Shut down…").clicked() { - self.confirm = Some((pc.name.clone(), PowerAction::Shutdown)); + self.confirm = Some(ask(Confirmable::Power(PowerAction::Shutdown))); } } if let Some(ip) = pc.ip { @@ -1019,6 +1297,14 @@ impl ClientApp { )); } } + if forget { + if wake || test_wake || power || pc.ip.is_some() { + ui::menu_separator(ui); + } + if ui::menu_item(ui, "Forget this machine…").clicked() { + self.confirm = Some(ask(Confirmable::Forget)); + } + } }); } @@ -1043,7 +1329,8 @@ impl ClientApp { /// Everything worth knowing about the paths and keys that is not /// urgent, folded away. Urgent key expiry is at the top of the page. fn details(&self, ui: &mut egui::Ui, disc: &Discovery) { - let mut items: Vec<(Tone, String)> = path_warnings(disc) + let release = self.updates.lock().release.clone(); + let mut items: Vec<(Tone, String)> = path_warnings(disc, release.as_ref()) .into_iter() .map(|t| (Tone::Warning, t)) .collect(); @@ -1096,10 +1383,12 @@ impl ClientApp { return; } } - let token = latch_core::update::token(self.cfg.github_token.as_deref()); + // The token is looked up on the handover's own thread: git's + // credential helper may wait on a Keychain prompt, and this thread + // draws the stream. self.handover = Some(Handover::start( release, - token, + self.cfg.github_token.clone(), mac_ip, ip, input, @@ -1372,7 +1661,7 @@ impl ClientApp { ui, &mut self.cfg.auto_update, "Keep Latch up to date", - Some("Checks GitHub every few hours, installs new versions of this app, and sends updates to your other machines over Tailscale."), + Some("Checks GitHub every few hours, installs new versions of this app, and sends updates to your Windows PCs over Tailscale."), ) { self.dirty = true; } @@ -1727,8 +2016,9 @@ fn host_audio_problem(discovery: &Mutex, node_id: &str) -> Option Vec { +/// send, runs a host too old to update itself, or runs one this Mac cannot +/// update that is behind `release`. +fn path_warnings(disc: &Discovery, release: Option<&latch_core::update::Release>) -> Vec { let mut out = Vec::new(); for pc in disc.pcs.iter().filter(|p| !p.remembered && p.online) { let nat = pc.host.as_ref().and_then(|h| h.nat.as_ref()); @@ -1752,6 +2042,11 @@ fn path_warnings(disc: &Discovery) -> Vec { if !latch_core::update::host_can_receive_update(&v) { out.push(update::old_host_message(&pc.name, &v)); } + if let Some(t) = + release.and_then(|rel| update::manual_update_message(&pc.name, &h.os, &v, rel)) + { + out.push(t); + } } } } @@ -1887,7 +2182,7 @@ mod tests { pcs: vec![gaming_pc.clone()], ..Default::default() }; - let w = path_warnings(&disc); + let w = path_warnings(&disc, None); assert_eq!(w.len(), 4, "{w:?}"); assert!(w[0].contains("Tokyo relay"), "{}", w[0]); assert!(w[1].contains("software"), "{}", w[1]); @@ -1911,7 +2206,7 @@ mod tests { pcs: vec![gaming_pc.clone()], ..Default::default() }; - assert!(path_warnings(&disc).is_empty()); + assert!(path_warnings(&disc, None).is_empty()); // Nothing is said about a PC that is off. gaming_pc.online = false; gaming_pc.path.direct = Some(false); @@ -1919,7 +2214,115 @@ mod tests { pcs: vec![gaming_pc], ..Default::default() }; - assert!(path_warnings(&disc).is_empty()); + assert!(path_warnings(&disc, None).is_empty()); + } + + fn power_pc(id: &str, os: &str, ip: &str, online: bool) -> Pc { + Pc { + node_id: id.into(), + name: "latch-node".into(), + os: os.into(), + ip: Some(ip.parse().unwrap()), + online, + host: online.then(|| latch_core::api::Status { + power_allowed: true, + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn a_power_action_goes_to_the_machine_asked_for_never_its_namesake() { + let disc = Discovery { + pcs: vec![ + power_pc("old", "windows", "100.64.0.3", false), + power_pc("a", "windows", "100.64.0.1", true), + power_pc("b", "linux", "100.64.0.2", true), + ], + ..Default::default() + }; + let ip = |s: &str| Some(s.parse().unwrap()); + assert_eq!(power_target(&disc, "a"), ip("100.64.0.1")); + assert_eq!(power_target(&disc, "b"), ip("100.64.0.2")); + assert_eq!( + power_target(&disc, "old"), + None, + "asleep, so nothing to send to" + ); + assert_eq!(power_target(&disc, "gone"), None); + let mut refused = disc.clone(); + refused.pcs[1].host.as_mut().unwrap().power_allowed = false; + assert_eq!(power_target(&refused, "a"), None, "its owner said no"); + } + + #[test] + fn sleep_is_offered_only_where_a_wake_packet_brings_it_back() { + assert!(power_pc("a", "windows", "100.64.0.1", true).can_sleep()); + for os in ["linux", "macOS", ""] { + assert!(!power_pc("a", os, "100.64.0.1", true).can_sleep(), "{os:?}"); + } + let mut pc = power_pc("a", "windows", "100.64.0.1", true); + pc.host.as_mut().unwrap().power_allowed = false; + assert!(!pc.can_sleep()); + } + + #[test] + fn a_power_action_ends_only_the_connection_to_its_own_machine() { + let (gaming, den) = ( + "100.64.0.10".parse().unwrap(), + "100.64.0.31".parse().unwrap(), + ); + let mut p = Progress { + step: Step::Waking, + ip: Some(gaming), + ..Default::default() + }; + assert!(targets(None, &p, gaming), "the machine being woken"); + assert!( + !targets(None, &p, den), + "another machine's sleep leaves it be" + ); + assert!(targets(Some(den), &p, den), "the machine streaming"); + p.step = Step::Ended { error: None }; + assert!(!targets(None, &p, gaming), "nothing under way any more"); + } + + #[test] + fn a_reconnect_keeps_full_screen_and_the_end_restores_the_window() { + let mut after = Some(false); + assert_eq!(fullscreen_when_finished(true, &mut after), None); + assert_eq!(after, Some(false), "kept for the stream that follows"); + assert_eq!(fullscreen_when_finished(false, &mut after), Some(false)); + assert_eq!(after, None); + assert_eq!( + fullscreen_when_finished(false, &mut after), + None, + "a stream that never showed changes nothing" + ); + } + + #[test] + fn a_host_this_mac_cannot_update_is_named_once_a_release_is_out() { + let mut stuzha = power_pc("s", "linux", "100.64.0.5", true); + stuzha.name = "stuzha".into(); + let h = stuzha.host.as_mut().unwrap(); + h.version = "4.1.1".into(); + h.os = "linux".into(); + let disc = Discovery { + pcs: vec![stuzha], + ..Default::default() + }; + let rel = latch_core::update::Release { + version: Version::new(4, 1, 2), + tag: "v4.1.2".into(), + prerelease: false, + assets: vec![], + }; + assert!(path_warnings(&disc, None).is_empty()); + let w = path_warnings(&disc, Some(&rel)); + assert_eq!(w.len(), 1, "{w:?}"); + assert!(w[0].contains("stuzha runs Latch Host 4.1.1"), "{}", w[0]); } fn a_relay(online: bool) -> Relay { @@ -2398,6 +2801,11 @@ pub(crate) mod snapshots { }); self } + + fn local_share(mut self, share: share::LocalShare) -> Self { + self.local = Some(share); + self + } } pub fn build( @@ -2535,9 +2943,39 @@ pub(crate) mod snapshots { Setup::new(pcs()).step( "Gaming-PC", Step::Pairing { pin: "4821".into() }, - "Latch on that machine isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, then try again.", + "Latch on Gaming-PC isn't answering, so it can't enter the PIN. Open Latch there and set up sharing, or type this PIN into the streaming engine's web page on that machine.", ), ), + ( + "pairing-refused-for-now", + Setup::new(pcs()).step( + "Gaming-PC", + Step::Pairing { pin: "4821".into() }, + "Latch on Gaming-PC couldn't enter the PIN yet: the streaming engine did not accept the PIN (is the Mac pairing right now?)", + ), + ), + ( + "pairing-refused", + failed("Gaming-PC", "Latch on Gaming-PC refused the PIN: not a machine on this PC's Tailscale account."), + ), + ( + "confirm-forget", + Setup::new(remembered()).tweak(|app| { + app.confirm = Some(Confirm { + node_id: "n2".into(), + name: "Office".into(), + action: Confirmable::Forget, + }) + }), + ), + ( + "lobby-mac-not-shared", + Setup::new(pcs()).local_share(share::LocalShare { + service_stopped: true, + viewer_only: true, + ..Default::default() + }), + ), ( "failed", failed("Office", "Office didn't wake up. A wake packet reaches it only from its own network, or through a router that forwards UDP port 9 to it. Its Sharing tab in Latch shows whether Wake-on-LAN is ready."), @@ -2559,7 +2997,11 @@ pub(crate) mod snapshots { ( "confirm-restart", Setup::new(pcs()).tweak(|app| { - app.confirm = Some(("Gaming-PC".into(), PowerAction::Restart)) + app.confirm = Some(Confirm { + node_id: "n1".into(), + name: "Gaming-PC".into(), + action: Confirmable::Power(PowerAction::Restart), + }) }), ), ( @@ -2659,7 +3101,11 @@ pub(crate) mod snapshots { ); assert_eq!( h.state().confirm, - Some(("Gaming-PC".into(), PowerAction::Restart)) + Some(Confirm { + node_id: "n1".into(), + name: "Gaming-PC".into(), + action: Confirmable::Power(PowerAction::Restart), + }) ); assert!( h.query_by_label("Restart").is_some(), @@ -2667,6 +3113,114 @@ pub(crate) mod snapshots { ); } + #[test] + fn a_linux_machine_is_never_offered_sleep() { + let mut h = build(Setup::new(many()), TYPICAL, 1.0, false); + h.get_by_label("More for vps-sanjose").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Restart…").is_some()); + assert!(h.query_by_label("Sleep").is_none(), "nothing could wake it"); + } + + #[test] + fn a_machine_that_is_away_can_be_forgotten_after_a_question() { + // Asleep but still on the tailnet: offered, with what it costs. + let mut h = build(Setup::new(pcs()), TYPICAL, 1.0, false); + h.get_by_label("More for Office").simulate_click(); + h.run_steps(2); + h.get_by_label("Forget this machine…").simulate_click(); + h.run_steps(2); + assert_eq!( + h.state().confirm, + Some(Confirm { + node_id: "n2".into(), + name: "Office".into(), + action: Confirmable::Forget, + }) + ); + assert!(h.query_by_label("Forget Office?").is_some()); + assert!(h + .query_by_label_contains("stays in this list while it is on your Tailscale network") + .is_some()); + // A machine that answers is not offered it: it would only unpair. + let mut h = build(Setup::new(pcs()), TYPICAL, 1.0, false); + h.get_by_label("More for Gaming-PC").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Forget this machine…").is_none()); + // Remembered while Tailscale is off: offered too. + let mut h = build(Setup::new(remembered()), TYPICAL, 1.0, false); + h.get_by_label("More for Office").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Forget this machine…").is_some()); + } + + #[test] + fn a_mac_with_sharing_off_is_not_reported_as_a_stopped_service() { + let h = build( + Setup::new(pcs()).local_share(share::LocalShare { + service_stopped: true, + viewer_only: true, + ..Default::default() + }), + MIN, + 1.0, + false, + ); + assert!(h.query_by_label_contains("service is stopped").is_none()); + assert!(h + .query_by_label_contains("only connects to your other machines") + .is_some()); + assert!(h.query_by_label("Share this Mac…").is_some()); + // Someone pressed Stop: that is what it says. + let h = build( + Setup::new(pcs()).local_share(share::LocalShare { + service_stopped: true, + ..Default::default() + }), + MIN, + 1.0, + false, + ); + assert!(h.query_by_label_contains("service is stopped").is_some()); + } + + #[test] + fn full_screen_is_what_the_system_says_it_is() { + let mut h = build(Setup::new(pcs()), MIN, 1.0, false); + assert!(!h.state().fullscreen); + let root = |h: &mut egui_kittest::Harness<'_, ClientApp>, on| { + h.input_mut() + .viewports + .entry(egui::ViewportId::ROOT) + .or_default() + .fullscreen = Some(on); + h.run_steps(1); + }; + root(&mut h, true); + assert!(h.state().fullscreen, "the green button entered it"); + root(&mut h, false); + assert!(!h.state().fullscreen, "and left it"); + } + + #[test] + fn a_reconnect_that_fails_before_its_stream_leaves_full_screen() { + let setup = Setup::new(pcs()) + .step( + "Gaming-PC", + Step::Ended { + error: Some("timed out".into()), + }, + "", + ) + .tweak(|app| { + app.ended_seen = false; + app.fullscreen = true; + app.fullscreen_after = Some(false); + }); + let h = build(setup, MIN, 1.0, false); + assert_eq!(h.state().fullscreen_after, None, "the window went back"); + } + #[test] fn a_notices_quiet_action_lines_up_with_its_text() { for state in ["failed", "confirm-restart"] { diff --git a/crates/client/src/display.rs b/crates/client/src/display.rs index a7396fb..9571c69 100644 --- a/crates/client/src/display.rs +++ b/crates/client/src/display.rs @@ -33,6 +33,33 @@ pub struct Help { pub busy: bool, } +/// Whether a stream's video problem is the black picture, the one problem +/// the PC can be asked about. Video that never came, stopped coming or +/// cannot be decoded is the network's doing or this Mac's. +pub fn is_black_picture(problem: &str) -> bool { + problem.contains("black picture") +} + +/// Whether the PC can say why its picture is black: only a Windows host +/// reports its displays. An OS not known yet is asked anyway. +pub fn can_explain(os: &str) -> bool { + os.is_empty() || os.eq_ignore_ascii_case("windows") +} + +/// The answer when there is none to be had, so the notice stops saying it +/// is asking. +pub fn no_answer(os: &str) -> Help { + Help { + message: if can_explain(os) { + "The PC couldn't say why. Restarting the stream, or the PC, is the next thing to try." + } else { + "Only a Windows PC can be asked why its picture is black. Restarting the stream is the next thing to try." + } + .into(), + ..Default::default() + } +} + pub fn ask(ip: Ipv4Addr) -> Result { http::get_json::((ip, CONTROL_PORT), "/v1/display", REPORT_TIMEOUT) } @@ -279,6 +306,32 @@ mod tests { assert!(!h.hdr_is_on); } + #[test] + fn only_a_black_picture_is_asked_about_and_every_ask_ends_in_an_answer() { + assert!(is_black_picture("The PC is sending a black picture: the connection and the video are healthy, but every frame is blank. Asking the PC why…")); + for other in [ + "Video cannot be decoded: decode failed (-12909). Try restarting the stream or selecting H.264 in Settings.", + "Video stopped arriving from the PC. Try restarting the stream or lowering Quality.", + "Connected, but no video has arrived from the PC. Check its display, or restart the stream.", + ] { + assert!(!is_black_picture(other), "{other}"); + } + assert!(can_explain("windows") && can_explain("")); + assert!(!can_explain("linux") && !can_explain("macOS")); + // What a Linux or Mac host answers: nothing to go on. + let elsewhere = serde_json::json!({ + "windows": {"supported": false}, + "advanced_color": {"supported": false}, + "sunshine": {}, + }); + assert_eq!(verdict(&elsewhere).message, ""); + for os in ["windows", "linux"] { + let h = no_answer(os); + assert!(!h.message.is_empty() && !h.hdr_is_on, "{os}"); + assert!(!h.message.contains("Asking"), "{}", h.message); + } + } + #[test] fn a_report_that_says_nothing_yields_no_verdict() { assert_eq!(verdict(&serde_json::json!({})), Help::default()); diff --git a/crates/client/src/share.rs b/crates/client/src/share.rs index 17b4cd2..8ee5b34 100644 --- a/crates/client/src/share.rs +++ b/crates/client/src/share.rs @@ -14,6 +14,9 @@ pub struct LocalShare { pub setup_result: Option>, /// Someone stopped the background service from the Sharing page. pub service_stopped: bool, + /// The owner turned Share this Mac off: this Mac only connects to the + /// others, and its service is off on purpose. + pub viewer_only: bool, } pub type Slot = Arc>; diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 9614ac4..4becb42 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -68,6 +68,20 @@ fn machine_noun(os: &str) -> &'static str { } } +/// Whether the machine's menu has anything in it: power actions its owner +/// allows, or an update for an old host. +fn has_power_items(env: &Env<'_>) -> bool { + env.power_allowed || env.old_host.is_some() +} + +/// What a power action costs, said before it is sent to `pc`. +fn power_question(action: PowerAction, pc: &str) -> String { + match action { + PowerAction::Sleep => format!("The stream ends once {pc} agrees. Asleep, it can only be woken from its own network, or through a router that forwards wake packets to it."), + PowerAction::Restart | PowerAction::Shutdown => format!("The stream ends once {pc} agrees. Programs there close without asking, and anything unsaved is lost."), + } +} + #[derive(Debug, Clone, PartialEq)] pub enum Action { Disconnect, @@ -100,6 +114,10 @@ pub struct Env<'a> { pub handover: Option, /// What the PC said about a black picture, once it has answered. pub video_help: Option, + /// Latch Host there takes power actions from this Mac. + pub power_allowed: bool, + /// Sleep is offered: something can wake the machine again. + pub can_sleep: bool, } struct Toast { @@ -566,7 +584,7 @@ impl View { self.keys_menu(ui, live, cfg, actions); self.stats_button(ui); self.fullscreen_button(ui, env, actions); - self.power_menu(ui, env, actions); + self.power_menu(ui, env); } if ui::ghost_button(ui, "Stream settings").clicked() { self.held.release_all(&live.input); @@ -596,10 +614,7 @@ impl View { .font(theme::semibold(theme::text::TITLE)) .color(P.text), ); - ui::muted( - ui, - "The stream ends first. Programs there close without asking, and anything unsaved is lost.", - ); + ui::muted(ui, power_question(action, &live.pc)); ui.add_space(space::XS); ui.horizontal(|ui| { if ui::destructive_button(ui, action.label()).clicked() { @@ -675,8 +690,10 @@ impl View { self.mouse_items(ui, ctx, live, cfg, actions); ui::menu_label(ui, "Send keys"); self.key_items(ui, live, cfg, actions); - ui::menu_label(ui, machine_noun(&env.os)); - self.power_items(ui, env, actions); + if has_power_items(env) { + ui::menu_label(ui, machine_noun(&env.os)); + self.power_items(ui, env); + } }); } @@ -704,24 +721,33 @@ impl View { } } - fn power_menu(&mut self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { - ui::menu_button(ui, machine_noun(&env.os), |ui| { - self.power_items(ui, env, actions) - }); + fn power_menu(&mut self, ui: &mut egui::Ui, env: &Env<'_>) { + if !has_power_items(env) { + return; + } + ui::menu_button(ui, machine_noun(&env.os), |ui| self.power_items(ui, env)); } - fn power_items(&mut self, ui: &mut egui::Ui, env: &Env<'_>, actions: &mut Vec) { - if ui::menu_item(ui, "Sleep").clicked() { - actions.push(Action::Power(PowerAction::Sleep)); - } - if ui::menu_item(ui, "Restart…").clicked() { - self.confirm = Some(PowerAction::Restart); - } - if ui::menu_item(ui, "Shut down…").clicked() { - self.confirm = Some(PowerAction::Shutdown); + /// Sleep, Restart and Shut down where the machine's owner allows them, + /// each asked about first: a slip in this menu must not end the stream + /// or put to sleep a machine that nothing here can wake. The stream + /// stays up until the machine has agreed. + fn power_items(&mut self, ui: &mut egui::Ui, env: &Env<'_>) { + if env.power_allowed { + if env.can_sleep && ui::menu_item(ui, "Sleep…").clicked() { + self.confirm = Some(PowerAction::Sleep); + } + if ui::menu_item(ui, "Restart…").clicked() { + self.confirm = Some(PowerAction::Restart); + } + if ui::menu_item(ui, "Shut down…").clicked() { + self.confirm = Some(PowerAction::Shutdown); + } } if let Some((running, _)) = &env.old_host { - ui::menu_separator(ui); + if env.power_allowed { + ui::menu_separator(ui); + } let label = format!("Update Latch Host… (runs {running})"); if ui::menu_item_enabled(ui, env.handover.is_none(), &label) .on_hover_text("Installs the newest Latch Host through this stream, so later updates arrive by themselves.") @@ -1455,6 +1481,8 @@ pub(crate) mod tests { pub old_host: Option<(Version, Option)>, pub handover: Option, pub video_help: Option, + pub power_allowed: bool, + pub can_sleep: bool, } pub(crate) struct Fixture { @@ -1554,6 +1582,8 @@ pub(crate) mod tests { old_host: extra.old_host.clone(), handover: extra.handover.clone(), video_help: extra.video_help.clone(), + power_allowed: extra.power_allowed, + can_sleep: extra.can_sleep, }; view.show(ctx, &env); } @@ -1604,6 +1634,8 @@ pub(crate) mod tests { view_with_bar(), Extra { os: "windows".into(), + power_allowed: true, + can_sleep: true, ..Default::default() }, ), @@ -1694,7 +1726,7 @@ pub(crate) mod tests { h.ctx.memory(|m| m.any_popup_open()), "More must use egui::popup so any_popup_open is true; a Background Area would fail this" ); - for item in ["Ctrl+Alt+Delete", "Stream performance", "Sleep"] { + for item in ["Ctrl+Alt+Delete", "Stream performance", "Sleep…"] { assert!(h.query_by_label(item).is_some(), "More lists {item}"); } let popup_open = h.ctx.memory(|m| m.any_popup_open()); @@ -1707,6 +1739,60 @@ pub(crate) mod tests { ); } + #[test] + fn power_items_follow_the_owner_and_sleep_only_where_it_can_wake() { + let wide = Vec2::new(1400.0, 860.0); + let with = |power_allowed, can_sleep, os: &str| { + harness_with( + wide, + 1.0, + false, + fixture( + view_with_bar(), + Extra { + os: os.into(), + power_allowed, + can_sleep, + ..Default::default() + }, + ), + ) + }; + // The owner turned power actions off: no menu at all. + let h = with(false, false, "windows"); + assert!(h.query_by_label("PC").is_none(), "an empty menu is no menu"); + // A Linux VPS: restart and shut down, never sleep. + let mut h = with(true, false, "linux"); + h.get_by_label("Machine").simulate_click(); + h.run_steps(2); + assert!(h.query_by_label("Restart…").is_some()); + assert!(h.query_by_label("Sleep…").is_none()); + // A Windows PC: sleep too, and it asks first. + let mut h = with(true, true, "windows"); + h.get_by_label("PC").simulate_click(); + h.run_steps(2); + h.get_by_label("Sleep…").simulate_click(); + h.run_steps(2); + assert_eq!(h.state().view.confirm, Some(PowerAction::Sleep)); + assert!( + h.query_by_label_contains("woken from its own network") + .is_some(), + "the question says what sleep costs" + ); + // The narrow toolbar's More menu follows the same rule. + let mut h = harness_with( + Vec2::new(640.0, 420.0), + 1.0, + false, + fixture(view_with_bar(), Extra::default()), + ); + h.get_by_label("More").simulate_click(); + h.run_steps(2); + for item in ["Sleep…", "Restart…", "Shut down…"] { + assert!(h.query_by_label(item).is_none(), "{item}"); + } + } + #[test] fn toolbar_at_normal_width_keeps_controls_on_the_bar() { let h = harness(Vec2::new(1400.0, 860.0)); @@ -1825,6 +1911,8 @@ pub(crate) mod tests { Extra { os: "windows".into(), old_host: Some((Version::new(3, 0, 0), Some(Version::new(4, 0, 2)))), + power_allowed: true, + can_sleep: true, ..Default::default() } } @@ -2083,6 +2171,8 @@ pub(crate) mod tests { use crate::app::snapshots::{file_name, save, MATRIX, PAIR}; let windows = || Extra { os: "windows".into(), + power_allowed: true, + can_sleep: true, ..Default::default() }; for &(size, ppp) in &MATRIX { diff --git a/crates/host/src/product.rs b/crates/host/src/product.rs index 1308382..8bf20bb 100644 --- a/crates/host/src/product.rs +++ b/crates/host/src/product.rs @@ -86,6 +86,7 @@ impl NodeApp { g.setup_running = shared.setup_running; g.setup_result = shared.setup_result.clone(); g.service_stopped = shared.stopped_by_user(); + g.viewer_only = shared.viewer_only; } } From e508d3d1551b818589ee437a08b95a58aa96a773 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:11:06 -0700 Subject: [PATCH 11/34] Service lifecycle: start through launchd/systemd, stop respawn storms, survive bad updates Starting and restarting: - The window started the service as its own child, outside launchd, so a crash was never restarted, and Share on started a second copy beside launchd's (whichever lost exited 0, and launchd then left its job stopped). ensure_service_running now asks the login item first: launchctl bootstrap/kickstart (no -k) on a Mac, systemctl --user start --no-block on Linux, a bare copy only where there is none. After an update the window quits the old service, waits for the port, and starts it the same way. - Turning Share on used legacy `launchctl load`, which silently skips a label macOS marked disabled (this Mac's state: not loaded, disabled, the service running unsupervised). It now enables the label, bootstraps it, and reports when macOS still does not load it. - A Mac or Linux install replaced under a running service (same version, so the window saw nothing) kept serving old code indefinitely. The service notices its program file changed and, once nobody watches, restarts into it: by exiting non-zero under a supervisor, or by handing over to the new file. Respawn storms: - The window restarted a service that exits by design every 4 s for hours (6,438 spawns overnight on 2026-10-12) because it never re-read host.toml. ensure_service_running and the poller now check that the Mac is shared (the window becomes a viewer if not), retries back off from 2 s to once a minute, a copy that stops as it starts is reported with its exit status, and the --background error reaches service.log. - A window opened from an older copy relaunched the same old file every second, forever. It relaunches only when its own file was replaced, and otherwise says to open Latch from Applications/the Start menu and leaves the service alone. Updates: - The rollback only caught a new exe that died within 2 s while it was still waiting for the port, and the new service deleted the last good exe as soon as it bound. The old service now starts a watcher from the retired exe (--probation) that puts it back unless the new version answers /v1/status and keeps answering; the retired file is removed only after ten minutes. - host.toml accept_updates (default on, a toggle on Windows) refuses pushed updates; accepted and refused pushes are logged with sender and SHA-256. A panic hook appends the panic and its place to service.log/panel.log before panic=abort ends the process. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/dates.rs | 18 +++ crates/host/src/app.rs | 170 ++++++++++++++++++--- crates/host/src/config.rs | 11 +- crates/host/src/main.rs | 91 ++++++++++- crates/host/src/service.rs | 222 +++++++++++++++++++++++++-- crates/host/src/unix_setup.rs | 279 +++++++++++++++++++++++++++++++--- crates/host/src/update.rs | 260 +++++++++++++++++++++++++++---- 7 files changed, 968 insertions(+), 83 deletions(-) diff --git a/crates/core/src/dates.rs b/crates/core/src/dates.rs index 31d83e9..0dffb0c 100644 --- a/crates/core/src/dates.rs +++ b/crates/core/src/dates.rs @@ -42,6 +42,18 @@ pub fn ymd(unix: u64) -> String { format!("{y:04}-{m:02}-{d:02}") } +/// `YYYY-MM-DDTHH:MM:SSZ` (UTC) for a Unix time. +pub fn utc_timestamp(unix: u64) -> String { + let secs = unix % 86_400; + format!( + "{}T{:02}:{:02}:{:02}Z", + ymd(unix), + secs / 3600, + secs / 60 % 60, + secs % 60 + ) +} + /// The date part of an RFC 3339 string such as `2027-03-02T23:03:00Z`. pub fn parse_ymd(s: &str) -> Option<(i64, u32, u32)> { let s = s.get(..10)?; @@ -90,4 +102,10 @@ mod tests { assert!(days_until("2999-01-01T00:00:00Z").unwrap() > 300_000); assert!(days_until("2001-01-01T00:00:00Z").unwrap() < 0); } + + #[test] + fn a_unix_time_reads_as_utc() { + assert_eq!(utc_timestamp(0), "1970-01-01T00:00:00Z"); + assert_eq!(utc_timestamp(1_792_258_000), "2026-10-17T17:26:40Z"); + } } diff --git a/crates/host/src/app.rs b/crates/host/src/app.rs index e765069..3a52f9f 100644 --- a/crates/host/src/app.rs +++ b/crates/host/src/app.rs @@ -33,6 +33,11 @@ pub struct Shared { /// A Mac whose owner turned sharing off: it only connects to the /// others, so the window neither starts nor restarts the service. pub viewer_only: bool, + /// The service runs a newer Latch than this window, and this window's + /// own file has not been replaced: it is an older copy (a download, a + /// disk image) opened by hand. Relaunching it would only start the same + /// old file again, so it says so instead, and leaves the service alone. + pub stale_panel: Option, /// Moves whenever anything above changes, so a reader can skip copying /// what it already has. pub rev: u64, @@ -295,6 +300,13 @@ impl HostApp { } Err(e) => { tracing::warn!("autostart: {e:#}"); + if self.os == Os::Mac && on { + // Not shared after all: take back what was saved above, + // or the next window to open would start the service. + if let Err(e) = self.cfg.save() { + tracing::warn!("could not save host config: {e:#}"); + } + } self.note = Some(( Tone::Danger, format!("Couldn't change whether Latch starts at login: {e}."), @@ -393,7 +405,20 @@ impl HostApp { let setup_running = shared.setup_running; let setup_result = shared.setup_result.clone(); let setup_log = shared.setup_log.clone(); + let viewer_only = shared.viewer_only; + let stale_panel = shared.stale_panel.clone(); drop(shared); + if self.live + && self.os == Os::Mac + && viewer_only + && self.cfg.start_with_windows + && !self.dirty + { + // Another window (or a hand edit) turned sharing off. Saving any + // other setting from here must not turn it back on. + self.cfg.start_with_windows = false; + self.autostart = false; + } if setup_running { self.busy_since.get_or_insert_with(Instant::now); // The seconds counter under the spinner. @@ -413,7 +438,22 @@ impl HostApp { if let Some((tone, text, _)) = self.note.clone() { ui::notice(ui, tone, &text); } - if s.version != env!("CARGO_PKG_VERSION") { + if let Some(path) = &stale_panel { + ui::notice( + ui, + Tone::Warning, + &format!( + "This window is an older copy of Latch ({}, at {path}); the background service runs {}. Close it and open Latch from {}.", + env!("CARGO_PKG_VERSION"), + s.version, + if self.os == Os::Windows { + "the Start menu" + } else { + "Applications" + } + ), + ); + } else if s.version != env!("CARGO_PKG_VERSION") { ui::notice( ui, Tone::Neutral, @@ -889,6 +929,15 @@ impl HostApp { ) { self.dirty = true; } + ui::row_separator(ui); + if ui::toggle_row( + ui, + &mut self.cfg.accept_updates, + "Install updates your other machines send", + Some("A Mac on your Tailscale account sends this PC each new Latch and it restarts by itself. Off, update Latch here."), + ) { + self.dirty = true; + } } ui::row_separator(ui); let mut auto = self.autostart; @@ -1071,11 +1120,15 @@ fn update_action(service: &str, panel: &str) -> UpdateAction { } } -fn relaunch_this_exe() -> bool { - let Ok(exe) = std::env::current_exe() else { +/// Start the window again from the path this one started from, if an +/// update has put a new file there. A window opened from an older copy (a +/// download, a disk image) whose file nobody replaced would only start +/// itself again, and that one again, once a second, forever. +fn relaunch_if_replaced() -> bool { + let Some(started) = crate::service::started_from().filter(|s| s.replaced()) else { return false; }; - std::process::Command::new(exe) + std::process::Command::new(started.path()) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -1083,6 +1136,38 @@ fn relaunch_this_exe() -> bool { .is_ok() } +/// When the window restarts a service that does not answer: after two +/// failed polls (about two seconds), then after waits that double up to a +/// minute. A service that stops as it starts (port 47850 taken by another +/// program, a crash at start) was started again every four seconds for as +/// long as the window stayed open. Starts over once the service answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Respawn { + next_at: u32, + wait: u32, +} + +impl Respawn { + const LONGEST: u32 = 60; + + fn new() -> Self { + Self { + next_at: 2, + wait: 4, + } + } + + /// Whether to start the service at this count of failed polls. + fn due(&mut self, failures: u32) -> bool { + if failures < self.next_at { + return false; + } + self.next_at = failures + self.wait; + self.wait = (self.wait * 2).min(Self::LONGEST); + true + } +} + /// Poll the service every second; refresh Sunshine's client list now and /// then; restart the service after an update. The window repaints only /// when an answer differs from the last. @@ -1101,6 +1186,8 @@ fn only_aged(old: &Status, new: &Status) -> bool { fn spawn_poller(shared: Arc>, ctx: egui::Context) { std::thread::spawn(move || { let mut failures = 0u32; + let mut respawn = Respawn::new(); + let mut quick_exit: Option = None; let mut tick = 0u64; let mut restarted_service = false; loop { @@ -1115,25 +1202,36 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { match r { Ok(st) => { failures = 0; + respawn = Respawn::new(); + quick_exit = None; match update_action(&st.version, env!("CARGO_PKG_VERSION")) { UpdateAction::RestartService if !restarted_service => { // This panel is newer than the service (an // update replaced the exe; current_exe still // names it). Restart once; looping on != used // to kill a *newer* service every second. + // Through launchd or systemd, which would not + // restart a service that quit by itself. restarted_service = true; - let _ = http::request( - ("127.0.0.1", CONTROL_PORT), - "POST", - "/v1/quit", - None, - Duration::from_secs(2), - ); - std::thread::sleep(Duration::from_secs(1)); - crate::service::ensure_service_running(); + crate::service::restart_service(); } - UpdateAction::RelaunchPanel if relaunch_this_exe() => { - std::process::exit(0); + UpdateAction::RelaunchPanel => { + if relaunch_if_replaced() { + std::process::exit(0); + } + let mut s = shared.lock(); + if s.stale_panel.is_none() { + let path = crate::service::started_from() + .map(|e| e.path().display().to_string()) + .unwrap_or_default(); + tracing::warn!( + "this window is Latch {} from {path}, older than the service ({}); not relaunching it", + env!("CARGO_PKG_VERSION"), + st.version + ); + s.stale_panel = Some(path); + changed = true; + } } _ => {} } @@ -1160,19 +1258,35 @@ fn spawn_poller(shared: Arc>, ctx: egui::Context) { } Err(e) => { failures += 1; + if let Some(why) = crate::service::take_quick_exit() { + quick_exit = Some(why); + } let mut s = shared.lock(); - let stopped_by_user = - s.service_error.as_deref() == Some(STOPPED) || s.viewer_only; + // An older copy of the window must not become the + // service: it would point the login item at itself. + let stopped_by_user = s.service_error.as_deref() == Some(STOPPED) + || s.viewer_only + || s.stale_panel.is_some(); changed |= s.status.is_some(); s.status = None; if !stopped_by_user { - let e = format!("{e}"); + let e = quick_exit.clone().unwrap_or_else(|| format!("{e}")); changed |= s.service_error.as_deref() != Some(e.as_str()); s.service_error = Some(e); } drop(s); - if failures % 4 == 2 && !stopped_by_user { - crate::service::ensure_service_running(); + if !stopped_by_user && respawn.due(failures) { + // Another window, or a hand edit, may have turned + // sharing off since this one opened: the service + // would only exit again. + if crate::service::starts_with_window(&HostConfig::load()) { + crate::service::ensure_service_running(); + } else { + let mut s = shared.lock(); + s.viewer_only = true; + s.service_error = Some(STOPPED.into()); + changed = true; + } } } } @@ -1207,6 +1321,22 @@ mod tests { assert_eq!(decode_log(&utf16), "hi\n"); } + #[test] + fn a_service_that_will_not_start_is_retried_less_and_less_often() { + let mut r = Respawn::new(); + let fired: Vec = (1..=400).filter(|&f| r.due(f)).collect(); + assert_eq!(&fired[..6], [2, 6, 14, 30, 62, 122]); + // Then once a minute, never less often: a PC must come back. + assert!( + fired.windows(2).skip(5).all(|w| w[1] - w[0] == 60), + "{fired:?}" + ); + // Once it answers, the next outage starts quickly again. + let mut r = Respawn::new(); + assert!(!r.due(1)); + assert!(r.due(2)); + } + #[test] fn a_newer_service_does_not_get_killed_by_the_old_panel() { assert_eq!(update_action("3.1.0", "3.1.0"), UpdateAction::None); diff --git a/crates/host/src/config.rs b/crates/host/src/config.rs index bfa4b11..f240731 100644 --- a/crates/host/src/config.rs +++ b/crates/host/src/config.rs @@ -17,6 +17,10 @@ pub struct HostConfig { /// on another network cannot wake it. On by default. Explicit Sleep /// from the Mac or the Start menu still works. pub stay_awake: bool, + /// Take a newer `latch-host.exe` that a machine on the owner's account + /// sends (`/v1/update`), which is how a Mac keeps its PCs current. Off, + /// the PC refuses every push and is updated only at the PC itself. + pub accept_updates: bool, /// Sunshine web-UI login the host uses to accept pairing PINs. Written /// by the setup step, which sets the same values on Sunshine. pub sunshine_user: String, @@ -29,6 +33,7 @@ impl Default for HostConfig { power_allowed: true, start_with_windows: true, stay_awake: true, + accept_updates: true, sunshine_user: String::new(), sunshine_pass: String::new(), } @@ -60,6 +65,7 @@ impl HostConfig { disk.power_allowed = self.power_allowed; disk.start_with_windows = self.start_with_windows; disk.stay_awake = self.stay_awake; + disk.accept_updates = self.accept_updates; if self.has_creds() { disk.sunshine_user = self.sunshine_user.clone(); disk.sunshine_pass = self.sunshine_pass.clone(); @@ -101,9 +107,12 @@ mod tests { assert!(c.power_allowed); assert!(c.start_with_windows); assert!(c.stay_awake); + assert!(c.accept_updates, "pushed updates stay on unless turned off"); assert!(!c.has_creds()); // An old host.toml without the field gets the default too. let c: HostConfig = toml::from_str("power_allowed = false").unwrap(); - assert!(!c.power_allowed && c.start_with_windows && c.stay_awake); + assert!(!c.power_allowed && c.start_with_windows && c.stay_awake && c.accept_updates); + let c: HostConfig = toml::from_str("accept_updates = false").unwrap(); + assert!(!c.accept_updates); } } diff --git a/crates/host/src/main.rs b/crates/host/src/main.rs index e24f02b..808b15e 100644 --- a/crates/host/src/main.rs +++ b/crates/host/src/main.rs @@ -29,6 +29,20 @@ struct Args { /// Started by an update: wait for this process to give up the port. #[arg(long, requires = "background")] replaces: Option, + /// Started by an update from the executable it retired: watch the new + /// service (this process id) and put the retired one back if it never + /// answers. See `update::probation`. + #[arg( + long, + value_name = "PID", + hide = true, + requires_all = ["background", "probation_version", "probation_exe"] + )] + probation: Option, + #[arg(long, hide = true)] + probation_version: Option, + #[arg(long, hide = true)] + probation_exe: Option, /// Give the streaming engine in DIR Latch's name and icon. Run by /// setup, elevated, with the engine stopped; exits non-zero with the /// reason on stderr. @@ -51,6 +65,8 @@ struct Args { } fn main() -> Result<()> { + // Before anything can replace it: which file this process is. + let _ = service::started_from(); let args = Args::parse(); if let Some(dir) = &args.local_app_data { // Before any thread or data_dir() call; edition 2021, so set_var is safe. @@ -58,11 +74,13 @@ fn main() -> Result<()> { } // The elevated setup helper logs with the panel; `setup.log` is the // script's own output, which the setup card shows. - init_logging(if args.background { + let log = if args.background { "service.log" } else { "panel.log" - }); + }; + init_logging(log); + install_panic_hook(data_dir().ok().map(|d| d.join(log))); // init_logging has just called data_dir(), which takes over an older // install's folder; the engine's config inside still names the old one. #[cfg(not(windows))] @@ -73,8 +91,19 @@ fn main() -> Result<()> { // either way this copy runs from where it is. tracing::warn!("could not move into Latch.app: {e:#}"); } + if let (Some(pid), Some(version), Some(exe)) = + (args.probation, &args.probation_version, &args.probation_exe) + { + return latch_host::update::probation(exe, pid, version).inspect_err(|e| { + tracing::error!("the update watcher failed: {e:#}"); + }); + } if args.background { - return Service::new().run_arc(args.replaces.is_some()); + // Started by a window or a supervisor, nobody sees stderr: the + // reason it could not start belongs in its log. + return Service::new() + .run_arc(args.replaces.is_some()) + .inspect_err(|e| tracing::error!("the service could not start: {e:#}")); } if args.setup { return run_setup(); @@ -129,6 +158,44 @@ fn run_setup() -> Result<()> { /// and that one target is heard from only when it is an error. const DEFAULT_LOG: &str = "info,wgpu_hal=error"; +/// Release builds abort on a panic, in any thread. Without this the reason +/// went to stderr, which nothing keeps (a launchd agent, a Windows Run-key +/// process), and the log simply stopped. The line is appended to `log` +/// directly rather than through tracing, whose writer may be the very lock +/// the panic happened under; the default hook still runs after it. +fn install_panic_hook(log: Option) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let line = panic_line( + latch_core::dates::now_unix(), + std::thread::current().name(), + &info.to_string(), + ); + if let Some(path) = &log { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = f.write_all(line.as_bytes()); + } + } + previous(info); + })); +} + +/// `2026-10-17T16:46:33Z ERROR panic in thread 'main': `, +/// in the log's own format. +fn panic_line(unix: u64, thread: Option<&str>, info: &str) -> String { + format!( + "{} ERROR panic in thread '{}': {}\n", + latch_core::dates::utc_timestamp(unix), + thread.unwrap_or("unnamed"), + info.replace('\n', " ") + ) +} + /// Logs go to a file in the data directory: neither mode has a console. fn init_logging(file: &str) { use tracing_subscriber::EnvFilter; @@ -148,9 +215,25 @@ fn init_logging(file: &str) { #[cfg(test)] mod tests { - use super::DEFAULT_LOG; + use super::{panic_line, DEFAULT_LOG}; use tracing_subscriber::EnvFilter; + #[test] + fn a_panic_leaves_a_line_in_the_log() { + let line = panic_line( + 1_792_258_000, + Some("refresh"), + "panicked at crates/host/src/service.rs:212:9:\nindex out of bounds", + ); + assert!(line.starts_with("2026-10-17T"), "{line}"); + assert!( + line.contains("panic in thread 'refresh': panicked at crates/host/src/service.rs:212:9: index out of bounds"), + "{line}" + ); + assert!(line.ends_with('\n') && line.matches('\n').count() == 1); + assert!(panic_line(0, None, "x").contains("thread 'unnamed'")); + } + /// A typo here would be silent: `EnvFilter` drops directives it cannot /// parse and carries on, so a broken default would quietly restore the /// per-frame flood rather than fail the build. diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 58f60dc..8015277 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -45,6 +45,9 @@ const PIN_TAKEN_FOR: Duration = Duration::from_secs(30); /// Ticks (of 5 s) between runs of `tailscale netcheck`: it takes seconds /// and the network does not move often. const NETCHECK_TICKS: u64 = 120; +/// The tick (of 5 s) at which a service lets go of what an update retired: +/// ten minutes in, long after the update's watcher has finished. +const TIDY_TICK: u64 = 120; pub struct Service { cfg: Mutex, @@ -73,6 +76,8 @@ pub struct Service { forwarded_logged: Mutex>, /// Why host.toml could not be re-read, while it cannot. cfg_problem: Mutex>, + /// A reinstall was noticed and handing over to it failed. + reinstall_tried: AtomicBool, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -102,6 +107,7 @@ impl Service { tun: Mutex::new(None), forwarded_logged: Mutex::new(None), cfg_problem: Mutex::new(None), + reinstall_tried: AtomicBool::new(false), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), @@ -173,7 +179,6 @@ impl Service { if let Ok(exe) = std::env::current_exe() { if replacing { self.log("updated: the previous version has been replaced"); - update::tidy(&exe); } self.ensure_autostart(&exe); } @@ -419,6 +424,49 @@ impl Service { *cur = v; } } + + if !cfg!(windows) && tick % 12 == 11 { + self.follow_reinstall(); + } + // What an update retired is kept until the new version has served + // for a while: the update's watcher puts it back if it does not. + if tick == TIDY_TICK { + if let Ok(exe) = std::env::current_exe() { + update::tidy(&exe); + } + } + } + + /// A Mac or Linux install replaced under a running service (a new + /// Latch.app copied over, a package upgrade) keeps serving the old code + /// until something restarts it, and the window cannot tell when the + /// version did not change. Restart into the new program once nobody + /// watches. (Windows swaps its executable through `update`.) + fn follow_reinstall(self: &Arc) { + let Some(stamp) = started_from().filter(|s| s.replaced()) else { + return; + }; + if self.reinstall_tried.load(Ordering::Acquire) || self.someone_watching() { + return; + } + #[cfg(not(windows))] + if crate::unix_setup::supervised() { + // launchd and systemd restart a service that failed, from the + // path the new program is at. + self.log("Latch was reinstalled; the service restarts into the new copy"); + std::process::exit(75); + } + // Once: a new copy that cannot start is not retried every minute. + self.reinstall_tried.store(true, Ordering::Release); + match update::hand_over(stamp.path(), update::STARTUP_GRACE) { + Ok(_) => { + self.log("Latch was reinstalled; the new copy takes over the service"); + std::process::exit(0); + } + Err(e) => self.log(format!( + "Latch was reinstalled, but the new copy did not start: {e:#}" + )), + } } /// `tailscale netcheck` on its own thread, once at a time. @@ -741,6 +789,17 @@ impl Service { ), ); } + if !self.cfg.lock().accept_updates { + self.log(format!( + "update from {from} refused: this PC takes no pushed updates (accept_updates = false in host.toml)" + )); + return Response::json( + 403, + &Ack::err( + "the owner of this PC has turned pushed updates off; update it at the PC", + ), + ); + } if self.update_running.swap(true, Ordering::AcqRel) { return Response::json(409, &Ack::err("an update is already being installed")); } @@ -762,15 +821,25 @@ impl Service { }; match update::stage(req, &exe) { Ok(version) => { + let sha = req + .header(latch_core::api::UPDATE_SHA256_HEADER) + .or_else(|| req.header(latch_core::legacy::UPDATE_SHA256_HEADER)) + .unwrap_or("") + .trim() + .to_ascii_lowercase(); self.log(format!( - "updating to {version}: {from} sent the new Latch Host" + "updating to {version}: {from} sent the new Latch Host (SHA-256 {sha})" )); let running = self.update_running.clone(); let log = self.log.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(500)); match update::apply(&exe) { - Ok(()) => { + Ok(h) => { + if let Err(e) = update::start_probation(&exe, &h, &version.to_string()) + { + tracing::warn!("{e:#}"); + } tracing::info!("handing over to {version}"); std::process::exit(0); } @@ -784,7 +853,7 @@ impl Service { } Err(r) => { self.update_running.store(false, Ordering::Release); - self.log(format!("update refused: {}", r.message())); + self.log(format!("update from {from} refused: {}", r.message())); Response::json(r.status(), &Ack::err(r.message())) } } @@ -1111,13 +1180,73 @@ fn starts_with_window_on(mac: bool, cfg: &HostConfig) -> bool { !mac || cfg.start_with_windows } -/// Start `--background` if nothing answers on loopback. -pub fn ensure_service_running() { +/// Start the background service if nothing answers on loopback. Through +/// launchd or systemd when there is a login item, so it is supervised and +/// there is only ever one; a copy of its own only where there is none (a +/// Windows PC, or a Mac whose owner turned the login item off). A Mac that +/// is not shared starts nothing: the service would only exit again, and a +/// window that kept asking spawned one every four seconds for hours. +/// Returns whether it asked for a start. +pub fn ensure_service_running() -> bool { + if !starts_with_window(&HostConfig::load()) { + tracing::info!("not starting the background service: this Mac is not shared"); + return false; + } if service_alive() { - return; + return false; + } + #[cfg(not(windows))] + match crate::unix_setup::start_supervised() { + Some(Ok(by)) => { + tracing::info!("asked {by} to start the background service"); + return true; + } + Some(Err(e)) => { + tracing::warn!("could not start the background service through its login item ({e:#}); starting it directly"); + } + None => {} + } + spawn_service() +} + +/// After an update: stop the service that answers (launchd's, or a copy an +/// older window started) and start it again from the file now on disk. +/// The supervisor would not restart it by itself, since /v1/quit exits 0. +pub fn restart_service() { + let _ = http::request( + ("127.0.0.1", CONTROL_PORT), + "POST", + "/v1/quit", + None, + Duration::from_secs(2), + ); + // Until it has let go of the port: a copy started while it still holds + // it would exit at once, and launchd would leave it at that. + let deadline = Instant::now() + Duration::from_secs(5); + while service_alive() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(200)); } + std::thread::sleep(Duration::from_millis(300)); + ensure_service_running(); +} + +/// How soon after it started a copy that stops counts as one that could not +/// start at all. +const QUICK_EXIT: Duration = Duration::from_secs(5); + +/// Why the last copy this window started stopped at once, for the page. +static QUICK_EXIT_NOTE: Mutex> = Mutex::new(None); + +/// The reason the last copy this window started stopped as soon as it +/// started, once. +pub fn take_quick_exit() -> Option { + QUICK_EXIT_NOTE.lock().take() +} + +/// Start a copy of `--background` as a child of this process. +fn spawn_service() -> bool { let Ok(exe) = std::env::current_exe() else { - return; + return false; }; let mut c = std::process::Command::new(exe); c.arg("--background") @@ -1129,17 +1258,74 @@ pub fn ensure_service_running() { use std::os::windows::process::CommandExt; c.creation_flags(0x0000_0008 | 0x0800_0000); // DETACHED_PROCESS | CREATE_NO_WINDOW } + let started = Instant::now(); match c.spawn() { Ok(mut child) => { + let pid = child.id(); std::thread::spawn(move || { - let _ = child.wait(); + // Exit 0 at once is no failure: another copy already + // answered, or a Mac turned out not to be shared. + if let Ok(status) = child.wait() { + if !status.success() && started.elapsed() < QUICK_EXIT { + let why = format!( + "the background service stopped as it started ({status}); its log says why" + ); + tracing::warn!("{why}"); + *QUICK_EXIT_NOTE.lock() = Some(why); + } + } }); - tracing::info!("started the background service"); + tracing::info!("launched the background service (pid {pid})"); + true + } + Err(e) => { + tracing::error!("could not start the background service: {e}"); + false } - Err(e) => tracing::error!("could not start the background service: {e}"), } } +/// The program file this process started from, as it was then: to tell +/// when an update or a reinstall has put another one at that path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExeStamp { + path: std::path::PathBuf, + len: u64, + modified: Option, +} + +impl ExeStamp { + fn of(path: std::path::PathBuf) -> Option { + let m = std::fs::metadata(&path).ok()?; + Some(Self { + len: m.len(), + modified: m.modified().ok(), + path, + }) + } + + pub fn path(&self) -> &std::path::Path { + &self.path + } + + /// Another file sits at the path now: another size or time. `false` + /// while the path cannot be read (gone, or held by a virus scanner). + pub fn replaced(&self) -> bool { + Self::of(self.path.clone()) + .is_some_and(|now| (now.len, now.modified) != (self.len, self.modified)) + } +} + +/// This process's [`ExeStamp`], taken the first time it is asked for; +/// `main` asks first thing. The path is kept from then on: on Linux +/// `current_exe` follows the file an update renames away. +pub fn started_from() -> Option<&'static ExeStamp> { + static STARTED: std::sync::OnceLock> = std::sync::OnceLock::new(); + STARTED + .get_or_init(|| std::env::current_exe().ok().and_then(ExeStamp::of)) + .as_ref() +} + /// The `Content-Type` Latch's own callers send: JSON, or the executable /// on the update route. A browser cannot send either cross-origin without /// a CORS preflight, so this keeps web pages from driving the service. @@ -1408,6 +1594,20 @@ mod tests { assert!(!svc.update_running.load(Ordering::Acquire)); } + #[test] + fn an_owner_can_turn_pushed_updates_off() { + let svc = Service::new(); + svc.cfg.lock().accept_updates = false; + let r = svc.update(LOOPBACK, &Request::default()); + if cfg!(windows) { + assert_eq!(r.status, 403, "{}", r.body); + assert!(r.body.contains("turned pushed updates off"), "{}", r.body); + } else { + assert_eq!(r.status, 400, "only a Windows PC takes pushes at all"); + } + assert!(!svc.update_running.load(Ordering::Acquire)); + } + #[test] fn wake_evidence_requires_a_known_matching_adapter() { let svc = Service::new(); diff --git a/crates/host/src/unix_setup.rs b/crates/host/src/unix_setup.rs index e034815..5cec79a 100644 --- a/crates/host/src/unix_setup.rs +++ b/crates/host/src/unix_setup.rs @@ -463,9 +463,196 @@ pub fn autostart_enabled() -> bool { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] const LAUNCH_AGENT_LABEL: &str = "com.bardbro.latch.node"; +/// How to start the background service when nothing answers. A copy the +/// window starts itself has no supervisor: launchd and systemd restart +/// only what they started, and a second copy started beside theirs loses +/// the port race and leaves their job stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartPlan { + /// macOS: load the login item; its RunAtLoad starts the service. + Bootstrap, + /// macOS: start the loaded login item (nothing if it already runs). + Kickstart, + /// Linux: start the user unit, with its drop-ins' environment. + SystemdStart, + /// No login item to go through: start a copy directly. + Spawn, +} + +/// What login item this machine has for the service. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginItem { + None, + /// The launch agent's file exists; whether launchd has it loaded, and + /// whether it is marked disabled (`launchctl print-disabled`), which + /// System Settings' Login Items does and which loads nothing. + MacAgent { + loaded: bool, + disabled: bool, + }, + /// The systemd user unit's file exists. + LinuxUnit, +} + +pub fn start_plan(item: LoginItem) -> StartPlan { + match item { + LoginItem::None | LoginItem::MacAgent { disabled: true, .. } => StartPlan::Spawn, + LoginItem::MacAgent { loaded: true, .. } => StartPlan::Kickstart, + LoginItem::MacAgent { loaded: false, .. } => StartPlan::Bootstrap, + LoginItem::LinuxUnit => StartPlan::SystemdStart, + } +} + +fn login_item() -> LoginItem { + #[cfg(target_os = "macos")] + { + if launch_agent_path().is_ok_and(|p| p.exists()) { + return LoginItem::MacAgent { + loaded: launch_agent_loaded(), + disabled: launch_agent_disabled(), + }; + } + } + #[cfg(target_os = "linux")] + { + if user_unit_path().is_ok_and(|p| p.exists()) { + return LoginItem::LinuxUnit; + } + } + LoginItem::None +} + +/// Start the service through its login item. `None` when there is none to +/// go through (or macOS has it disabled), so the caller starts a copy +/// itself; otherwise how it went, naming what was asked. +pub fn start_supervised() -> Option> { + let item = login_item(); + let plan = start_plan(item); + if let LoginItem::MacAgent { disabled: true, .. } = item { + tracing::warn!( + "macOS has Latch's login item turned off (System Settings > General > Login Items), so the service starts without it and does not come back after a restart" + ); + } + match plan { + StartPlan::Spawn => None, + #[cfg(target_os = "macos")] + StartPlan::Bootstrap | StartPlan::Kickstart => Some((|| -> Result<&'static str> { + let uid = uid()?; + let args = if plan == StartPlan::Bootstrap { + let plist = launch_agent_path()?; + bootstrap_args(&uid, &plist.display().to_string()) + } else { + kickstart_args(&uid) + }; + anyhow::ensure!(launchctl(&args)?, "launchctl {} failed", args[0]); + Ok("launchd") + })()), + #[cfg(target_os = "linux")] + StartPlan::SystemdStart => Some((|| -> Result<&'static str> { + // --no-block: the window calls this before it opens, and a unit + // may wait for the engine before it starts. + anyhow::ensure!( + systemctl_user(&["start", "--no-block", "latch.service"])?, + "systemctl --user start latch.service failed" + ); + Ok("systemd") + })()), + _ => None, + } +} + +/// Whether launchd or systemd started this process, and so restarts it +/// after a failure. +pub fn supervised() -> bool { + #[cfg(target_os = "macos")] + { + std::env::var("XPC_SERVICE_NAME").is_ok_and(|v| v == LAUNCH_AGENT_LABEL) + } + #[cfg(target_os = "linux")] + { + fs::read_to_string("/proc/self/cgroup").is_ok_and(|c| in_latch_unit(&c)) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + false + } +} + +/// `/proc/self/cgroup` of a process systemd runs as `latch.service`. +#[cfg(any(target_os = "linux", test))] +fn in_latch_unit(cgroup: &str) -> bool { + cgroup + .lines() + .any(|l| l.trim_end().ends_with("/latch.service")) +} + +/// `launchctl kickstart` without `-k`: it starts a job that is not +/// running and leaves one that is alone. +#[cfg(any(target_os = "macos", test))] +fn kickstart_args(uid: &str) -> Vec { + vec![ + "kickstart".into(), + format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"), + ] +} + +#[cfg(any(target_os = "macos", test))] +fn bootstrap_args(uid: &str, plist: &str) -> Vec { + vec!["bootstrap".into(), format!("gui/{uid}"), plist.into()] +} + +#[cfg(any(target_os = "macos", test))] +fn enable_args(uid: &str) -> Vec { + vec!["enable".into(), format!("gui/{uid}/{LAUNCH_AGENT_LABEL}")] +} + +/// Whether `launchctl print-disabled gui/` lists the launch agent as +/// disabled ("=> disabled", or "=> true" before macOS 13). +#[cfg(any(target_os = "macos", test))] +fn listed_disabled(print_disabled: &str) -> bool { + let label = format!("\"{LAUNCH_AGENT_LABEL}\""); + print_disabled.lines().any(|l| { + l.trim().strip_prefix(&label).is_some_and(|rest| { + let v = rest.trim().trim_start_matches("=>").trim(); + v == "disabled" || v == "true" + }) + }) +} + +#[cfg(target_os = "macos")] +fn launch_agent_disabled() -> bool { + let Ok(uid) = uid() else { return false }; + Command::new("launchctl") + .args(["print-disabled", &format!("gui/{uid}")]) + .output() + .is_ok_and(|o| listed_disabled(&String::from_utf8_lossy(&o.stdout))) +} + +#[cfg(target_os = "macos")] +fn uid() -> Result { + let out = Command::new("id").arg("-u").output().context("id -u")?; + let uid = String::from_utf8_lossy(&out.stdout).trim().to_string(); + anyhow::ensure!( + out.status.success() && !uid.is_empty() && uid.bytes().all(|b| b.is_ascii_digit()), + "id -u said {uid:?}" + ); + Ok(uid) +} + +#[cfg(target_os = "macos")] +fn launchctl(args: &[String]) -> Result { + Ok(Command::new("launchctl") + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("run launchctl")? + .success()) +} + #[cfg(target_os = "macos")] fn launch_agent_path() -> Result { let home = dirs_home()?; @@ -536,12 +723,7 @@ pub fn adopt_legacy_bundle(background: bool) -> Result<()> { } retire_legacy_launch_agent(); if !background { - let uid = Command::new("id") - .arg("-u") - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); + let uid = uid().unwrap_or_default(); // The old job, which launchd runs from the path that no longer exists. let _ = Command::new("launchctl") .args([ @@ -657,16 +839,19 @@ fn macos_launch_agent(enable: bool, exe: &Path) -> Result<()> { if launch_agent_loaded() { return Ok(()); } - // RunAtLoad starts the service now. If one already answers on the port, - // the new copy exits 0 and launchd does not try again. - let loaded = Command::new("launchctl") - .args(["load"]) - .arg(&path) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("load Latch launch agent")?; - anyhow::ensure!(loaded.success(), "could not load Latch launch agent"); + // Turning sharing on is the owner asking for the login item, so a label + // macOS has marked disabled is enabled first: the legacy `launchctl + // load` skipped such a job without a word, and the service then ran + // with nothing to restart it. RunAtLoad starts the service now; if one + // already answers on the port, the new copy exits 0 and launchd leaves + // it. + let uid = uid()?; + let _ = launchctl(&enable_args(&uid)); + let _ = launchctl(&bootstrap_args(&uid, &path.display().to_string())); + anyhow::ensure!( + launch_agent_loaded(), + "macOS did not load Latch's login item; allow Latch in System Settings > General > Login Items" + ); Ok(()) } @@ -782,6 +967,66 @@ mod tests { assert!(plist.contains("RunAtLoad")); } + #[test] + fn the_service_starts_through_its_login_item_when_there_is_one() { + use LoginItem::*; + let mac = |loaded, disabled| MacAgent { loaded, disabled }; + assert_eq!(start_plan(mac(false, false)), StartPlan::Bootstrap); + // Stopped by the window (Share off then on, or Stop then Start): + // the job is still loaded but nothing runs. + assert_eq!(start_plan(mac(true, false)), StartPlan::Kickstart); + // Turned off in Login Items: nothing launchd would start. + assert_eq!(start_plan(mac(false, true)), StartPlan::Spawn); + assert_eq!(start_plan(mac(true, true)), StartPlan::Spawn); + assert_eq!(start_plan(LinuxUnit), StartPlan::SystemdStart); + assert_eq!(start_plan(None), StartPlan::Spawn); + + let kick = kickstart_args("501"); + assert_eq!(kick, ["kickstart", "gui/501/com.bardbro.latch.node"]); + assert!( + !kick.iter().any(|a| a == "-k"), + "-k would kill a service that runs" + ); + assert_eq!( + bootstrap_args("501", "/Users/a/Library/LaunchAgents/x.plist"), + [ + "bootstrap", + "gui/501", + "/Users/a/Library/LaunchAgents/x.plist" + ] + ); + assert_eq!( + enable_args("501"), + ["enable", "gui/501/com.bardbro.latch.node"] + ); + } + + #[test] + fn a_disabled_login_item_and_a_systemd_service_are_recognised() { + // `launchctl print-disabled gui/501` on the Mac, 2026-10-17. + let listing = "disabled services = {\n\t\t\"com.apple.x\" => enabled\n\t\t\"com.bardbro.latch.node\" => disabled\n\t}\n"; + assert!(listed_disabled(listing)); + assert!(listed_disabled("\t\"com.bardbro.latch.node\" => true\n")); + assert!(!listed_disabled( + "\t\"com.bardbro.latch.node\" => enabled\n" + )); + assert!(!listed_disabled( + "\t\"com.bardbro.latch.node.extra\" => disabled\n" + )); + assert!(in_latch_unit( + "0::/user.slice/user-1004.slice/user@1004.service/app.slice/latch.service\n" + )); + assert!(in_latch_unit( + "1:name=systemd:/user.slice/user@1000.service/latch.service\n" + )); + assert!(!in_latch_unit( + "0::/user.slice/user-1004.slice/user@1004.service/app.slice/latch-engine.service\n" + )); + assert!(!in_latch_unit( + "0::/user.slice/user-1004.slice/session-3.scope\n" + )); + } + #[test] fn an_older_installs_engine_login_is_found_under_its_new_name() { let dir = std::env::temp_dir().join(format!("latch-repair-{}", std::process::id())); diff --git a/crates/host/src/update.rs b/crates/host/src/update.rs index 1f9de01..34db660 100644 --- a/crates/host/src/update.rs +++ b/crates/host/src/update.rs @@ -10,6 +10,13 @@ //! service to let go of the port, and the old one exits after answering. //! A new exe that cannot start, or exits at once, is moved aside and the //! old one put back, still serving. +//! +//! One that starts but then never serves (it panics on its first tick, or +//! hangs) is caught by the old executable itself: the old service starts it +//! from its retired file with `--probation` before it exits, and that +//! watcher puts the old version back unless the new one answers with its +//! version within [`PROBATION`]. The retired file stays until the new +//! service has run for a while (see [`tidy`]). use anyhow::{Context, Result}; use latch_core::api::{UPDATE_MAX_BYTES, UPDATE_SHA256_HEADER, UPDATE_VERSION_HEADER}; @@ -19,14 +26,20 @@ use latch_core::update; use semver::Version; use std::io::ErrorKind; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::process::Child; +use std::time::{Duration, Instant}; /// Anything smaller is not the host. const MIN_SIZE: usize = 1024 * 1024; /// How long a new copy must stay up before the old one lets go. It waits /// for the old one's port, so it is still running after this unless it /// could not start at all. -const STARTUP_GRACE: Duration = Duration::from_secs(2); +pub const STARTUP_GRACE: Duration = Duration::from_secs(2); +/// How long a new service has, once the old one has let go, to answer on +/// the control port with its version and keep answering for +/// [`HEALTHY_FOR`]. Otherwise the previous executable is put back. +pub const PROBATION: Duration = Duration::from_secs(60); +const HEALTHY_FOR: Duration = Duration::from_secs(15); /// Why an upload was not staged, with the HTTP status it earns. #[derive(Debug, PartialEq, Eq)] @@ -143,13 +156,52 @@ fn is_host_executable(bytes: &[u8]) -> bool { && bytes.get(offset..).is_some_and(|b| b.len() >= 24 + optional_size + sections * 40) } +/// A new service started, and where the one it replaces now is. +#[derive(Debug)] +pub struct Handover { + /// The new service's process. + pub pid: u32, + /// The executable that was running, renamed out of the way. + pub retired: PathBuf, +} + /// Swap the staged executable in and start it. The caller exits afterwards; /// the new process waits for that before it takes the port. -pub fn apply(exe: &Path) -> Result<()> { +pub fn apply(exe: &Path) -> Result { apply_within(exe, STARTUP_GRACE) } -fn apply_within(exe: &Path, grace: Duration) -> Result<()> { +/// Start `exe` as the service that takes over from this one: `--replaces` +/// makes it wait for this process to let go of the port. Fails if it has +/// already exited after `grace` (a crash, a missing library). Returns its +/// process id. +pub fn hand_over(exe: &Path, grace: Duration) -> Result { + let mut child = start_service(exe, &["--replaces", &std::process::id().to_string()]) + .context("start the new service")?; + std::thread::sleep(grace); + if let Ok(Some(status)) = child.try_wait() { + anyhow::bail!("the new service exited at once ({status})"); + } + Ok(child.id()) +} + +/// `exe --background `, detached from this process. +fn start_service(exe: &Path, args: &[&str]) -> std::io::Result { + let mut c = std::process::Command::new(exe); + c.arg("--background") + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0000_0008 | 0x0800_0000); // DETACHED_PROCESS | CREATE_NO_WINDOW + } + c.spawn() +} + +fn apply_within(exe: &Path, grace: Duration) -> Result { let new = staged(exe); anyhow::ensure!(new.exists(), "nothing is staged at {}", new.display()); let old = { @@ -171,37 +223,151 @@ fn apply_within(exe: &Path, grace: Duration) -> Result<()> { // Keep the old service installed if the replacement cannot run: this // process is still serving requests, and a PC nobody can reach in // person must not be left with a service that never starts. - let restore = |why: anyhow::Error| -> Result<()> { - rename_retry(exe, &new).context("move the failed replacement aside")?; - rename_retry(&old, exe).context("restore the previous executable")?; - Err(why.context("the previous executable is back in place")) - }; - let mut c = std::process::Command::new(exe); - c.arg("--background") - .arg("--replaces") - .arg(std::process::id().to_string()) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - c.creation_flags(0x0000_0008 | 0x0800_0000); // DETACHED_PROCESS | CREATE_NO_WINDOW - } - let mut child = match c.spawn() { - Ok(child) => child, - Err(e) => return restore(anyhow::Error::new(e).context("start the new service")), - }; // One that has already exited could not start (a crash, a missing // library): it would leave the PC with no service after this one quits. - std::thread::sleep(grace); - if let Ok(Some(status)) = child.try_wait() { - return restore(anyhow::anyhow!("the new service exited at once ({status})")); + match hand_over(exe, grace) { + Ok(pid) => Ok(Handover { pid, retired: old }), + Err(why) => { + rename_retry(exe, &new).context("move the failed replacement aside")?; + rename_retry(&old, exe).context("restore the previous executable")?; + Err(why.context("the previous executable is back in place")) + } } +} + +/// Start the watcher for the new service from the executable it retired, +/// which is what it puts back if it has to. Call before exiting. +pub fn start_probation(exe: &Path, h: &Handover, version: &str) -> Result<()> { + let exe = exe.display().to_string(); + start_service( + &h.retired, + &[ + "--probation", + &h.pid.to_string(), + "--probation-version", + version, + "--probation-exe", + &exe, + ], + ) + .map(drop) + .context("start the update watcher") +} + +/// The watcher (`--background --probation `), run from the retired +/// executable: wait for the new service at `exe` to answer with `version` +/// and keep answering; if it does not, stop it, put this executable back at +/// `exe` and start it again. A PC nobody can reach in person must not be +/// left with a service that started and then died. +pub fn probation(exe: &Path, new_pid: u32, version: &str) -> Result<()> { + let retired = std::env::current_exe().context("own path")?; + let verdict = watch( + answering_version, + version, + PROBATION, + HEALTHY_FOR, + Duration::from_millis(500), + ); + let Err(why) = verdict else { + tracing::info!("Latch Host {version} is up and answering; the update is done"); + return Ok(()); + }; + tracing::error!("Latch Host {version} {why}; putting the previous version back"); + // A hung one still holds the port; a dead one's id may already belong + // to something else, so only one that still listens is stopped. + if port_held() { + kill(new_pid); + } + restore_previous(exe, &retired)?; + start_service(exe, &["--replaces", &new_pid.to_string()]) + .context("start the previous version again")?; + tracing::info!("the previous version is back in place and starting"); Ok(()) } -/// Remove what the last update retired, once its process has gone. +/// Wait until `probe` has said `want` for `healthy_for` without a gap, or +/// give up after `limit`, saying what went wrong. +fn watch( + probe: impl Fn() -> Option, + want: &str, + limit: Duration, + healthy_for: Duration, + every: Duration, +) -> std::result::Result<(), String> { + let start = Instant::now(); + let mut up_since: Option = None; + let mut ever_up = false; + loop { + match probe() { + Some(v) if v == want => { + ever_up = true; + if up_since.get_or_insert_with(Instant::now).elapsed() >= healthy_for { + return Ok(()); + } + } + _ => up_since = None, + } + if start.elapsed() >= limit { + return Err(if ever_up { + "started, then stopped answering".into() + } else { + format!("did not answer within {} s", limit.as_secs()) + }); + } + std::thread::sleep(every); + } +} + +/// The version the service on this machine reports, if one answers. +fn answering_version() -> Option { + let st: latch_core::api::Status = latch_core::http::get_json( + ("127.0.0.1", latch_core::CONTROL_PORT), + "/v1/status", + Duration::from_secs(2), + ) + .ok()?; + Some(st.version) +} + +fn port_held() -> bool { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], latch_core::CONTROL_PORT)); + std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() +} + +fn kill(pid: u32) { + #[cfg(windows)] + let mut c = { + use std::os::windows::process::CommandExt; + let mut c = std::process::Command::new("taskkill"); + c.args(["/F", "/PID", &pid.to_string()]) + .creation_flags(0x0800_0000); + c + }; + #[cfg(not(windows))] + let mut c = { + let mut c = std::process::Command::new("kill"); + c.args(["-9", &pid.to_string()]); + c + }; + let _ = c + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + std::thread::sleep(Duration::from_millis(500)); +} + +/// Put the retired executable back at `exe`, and the one that failed where +/// an upload waits (`.new`), so the logs can still be matched to it. +fn restore_previous(exe: &Path, retired: &Path) -> Result<()> { + let failed = staged(exe); + let _ = std::fs::remove_file(&failed); + rename_retry(exe, &failed).context("move the failed version aside")?; + rename_retry(retired, exe).context("put the previous version back") +} + +/// Remove what the last update retired. The service calls this once it +/// has run for a while, not when it starts: until the new version has +/// proven itself, the retired one is what [`probation`] puts back. pub fn tidy(exe: &Path) { let _ = std::fs::remove_file(retired(exe)); let Some(dir) = exe.parent() else { return }; @@ -298,6 +464,31 @@ mod tests { assert!(!is_host_executable(b"MZ")); } + #[test] + fn a_new_service_must_answer_with_its_version_and_keep_answering() { + use std::cell::Cell; + let ms = Duration::from_millis; + let want = "4.2.0"; + // Up at once and staying up. + assert_eq!( + watch(|| Some(want.into()), want, ms(400), ms(60), ms(10)), + Ok(()) + ); + // Never answers: the panic on the first tick, or a hang. + let e = watch(|| None, want, ms(100), ms(30), ms(10)).unwrap_err(); + assert!(e.contains("did not answer"), "{e}"); + // The old version still answers: the new one never took over. + assert!(watch(|| Some("4.1.1".into()), want, ms(100), ms(30), ms(10)).is_err()); + // Answers, then dies before it has proven itself. + let calls = Cell::new(0); + let flaky = || { + calls.set(calls.get() + 1); + (calls.get() < 3).then(|| want.to_string()) + }; + let e = watch(flaky, want, ms(150), ms(100), ms(10)).unwrap_err(); + assert!(e.contains("stopped answering"), "{e}"); + } + #[test] fn an_active_session_blocks_self_update() { assert!(refuse_self_update(true, true)); @@ -430,9 +621,18 @@ mod tests { assert!(staged(&exe).exists()); // One that keeps running is the new service. script("#!/bin/sh\nexec sleep 2\n"); - apply_within(&exe, Duration::from_millis(300)).unwrap(); + let h = apply_within(&exe, Duration::from_millis(300)).unwrap(); assert!(std::fs::read(&exe).unwrap().starts_with(b"#!/bin/sh")); assert_eq!(std::fs::read(retired(&exe)).unwrap(), b"old"); + assert_eq!(h.retired, retired(&exe)); + assert!(h.pid > 0); + // It then never served: the watcher puts the old one back. + restore_previous(&exe, &h.retired).unwrap(); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert!(std::fs::read(staged(&exe)) + .unwrap() + .starts_with(b"#!/bin/sh")); + assert!(!retired(&exe).exists()); } std::fs::write(retired(&exe), b"retired").unwrap(); std::fs::write(dir.join("latch-host.exe.old-notes"), b"keep").unwrap(); From 180b213b192d4ff5555ca2a896d53db47acd59fc Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:12:00 -0700 Subject: [PATCH 12/34] Pairing: name what went wrong when the PIN never finished When Latch Host entered the PIN but the engine never answered the first request, the timeout said nobody entered it; it now says the engine never finished. The wording of a failed pairing (a refusal in the host's words, a timeout with its reason, else the handshake's error under "Pairing with ") is one function with a test, and a power confirmation that finds its machine gone says it may also have stopped taking power actions. Co-Authored-By: Claude Opus 5.5 --- crates/client/src/app.rs | 2 +- crates/client/src/session.rs | 83 ++++++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/crates/client/src/app.rs b/crates/client/src/app.rs index bee3a4e..4003810 100644 --- a/crates/client/src/app.rs +++ b/crates/client/src/app.rs @@ -1053,7 +1053,7 @@ impl ClientApp { None => { self.notice = Some(( Tone::Danger, - format!("{name} is no longer reachable, so nothing was sent."), + format!("Nothing was sent: {name} is offline, or no longer takes power actions from here."), Instant::now(), )) } diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index 048231c..c2b3a93 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -1120,22 +1120,36 @@ fn pair(client: &mut Client<'_>, pin: &str, c: &Connect, generation: u64) -> Res // However the handshake ended, the PIN is not offered again. hand_off.stop.store(true, Ordering::Relaxed); result.map_err(|e| { - if let Some(reason) = hand_off.refused.lock().take() { - return anyhow!("Latch on {} refused the PIN: {reason}.", t.name); - } - if e.downcast_ref::().is_some() { - return match hand_off.why_not.lock().take() { - Some(why) => anyhow!("Pairing with {} timed out: {why}.", t.name), - None => anyhow!( - "Pairing with {} timed out: nobody entered the PIN there.", - t.name - ), - }; - } - e.context(format!("Pairing with {}", t.name)) + pairing_error( + e, + &t.name, + hand_off.refused.lock().take(), + hand_off.why_not.lock().take(), + ) }) } +/// What to say when pairing with `pc` failed: Latch Host's refusal when it +/// refused for good, why the PIN never went in when nobody entered it in +/// time, else the handshake's own error. +fn pairing_error( + e: anyhow::Error, + pc: &str, + refused: Option, + why_not: Option, +) -> anyhow::Error { + if let Some(reason) = refused { + return anyhow!("Latch on {pc} refused the PIN: {reason}."); + } + if e.downcast_ref::().is_some() { + return match why_not { + Some(why) => anyhow!("Pairing with {pc} timed out: {why}."), + None => anyhow!("Pairing with {pc} timed out: nobody entered the PIN there."), + }; + } + e.context(format!("Pairing with {pc}")) +} + /// How long the PIN is offered to Latch Host on the PC, and how long a host /// that cannot log in to its engine is given to manage it (it checks again /// every few seconds, and lags an engine that has just started). @@ -1290,7 +1304,8 @@ impl PinHandOff { let why = |s: String| *self.why_not.lock() = Some(s); match reply { PinReply::Taken => { - *self.why_not.lock() = None; + // Should the engine still not answer, this is why. + why("Latch there entered the PIN, but the streaming engine never finished pairing".into()); self.show(String::new()); return; } @@ -1659,6 +1674,46 @@ mod tests { )); } + #[test] + fn a_failed_pairing_says_why_in_the_hosts_words() { + let say = |e: anyhow::Error, refused: Option<&str>, why: Option<&str>| { + format!( + "{:#}", + pairing_error(e, "stuzha", refused.map(Into::into), why.map(Into::into)) + ) + }; + assert_eq!( + say( + anyhow!("pairing cancelled"), + Some("not a machine on this PC's Tailscale account"), + None + ), + "Latch on stuzha refused the PIN: not a machine on this PC's Tailscale account." + ); + assert_eq!( + say( + anyhow!(PinNotEntered), + None, + Some("Latch there isn't answering, so it couldn't enter the PIN") + ), + "Pairing with stuzha timed out: Latch there isn't answering, so it couldn't enter the PIN." + ); + assert_eq!( + say(anyhow!(PinNotEntered), None, None), + "Pairing with stuzha timed out: nobody entered the PIN there." + ); + assert_eq!( + say(anyhow!("pairing failed at the challenge step"), None, None), + "Pairing with stuzha: pairing failed at the challenge step" + ); + // The step and the cause, not just the cause. + let e = anyhow!("timed out").context("Asking stuzha to start the stream"); + assert_eq!( + format!("{e:#}"), + "Asking stuzha to start the stream: timed out" + ); + } + /// A Latch Host stand-in that answers every request the same way, and /// counts them. fn pin_host(status: u16, body: String) -> (SocketAddr, Arc) { From e5727aa608037a94ac3c9034af66815625356358 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:13:29 -0700 Subject: [PATCH 13/34] Stream input: send keys, clicks and pastes in the order they were made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream view translated a frame's events with the frame-end state, so order inside a frame was lost: modifiers were synced once before the keys (Shift let go with T in one frame typed "t", ⌘Z with ⌘ let go sent a bare Z), and a click went out before the frame's pointer position or captured movement. Each key and click now goes with its own modifiers and position, in order; the frame-end state follows for modifier-only changes. Horizontal scroll had the wrong sign (egui's +x scrolls left, Moonlight's right). Pastes: the chord was pressed by a worker after the POST, so Return typed after ⌘V reached a PC 150 ms away before the paste. The worker now only sends the text; the window presses the chord through Held and holds everything typed or clicked after it until the text has arrived (at most 2 s, then the PC pastes what it has). ⌘V with an image or nothing on the Mac clipboard was swallowed whole by egui-winit; its V release now sends the paste. A copy made on the PC (files, an image, or text not yet read after a ⌘C) is no longer overwritten by the Mac's older text while the Mac clipboard's change count is unchanged. A paste over 32 KB is sent cut, says so, and its cut copy no longer comes back over the Mac's clipboard; a Windows PC's CRLF read-back is no echo either. Host key and capture: the host key lets go of everything held before toggling (a key let go during the chord stuck down on the PC), closes a panel, question or menu before capturing, waits for both keys to be up (no lone Alt), and can free a capture before the stream connects. A click on the picture no longer captures the mouse while connecting. In free mode egui gives up focus while the toolbar is hidden, so Tab reaches the PC instead of the Stats panel. Mac: while the stream has the keys, the app menu's Quit, Hide and Hide Others lose their shortcuts (⌘Q quit Latch mid-stream with Command as Ctrl) and egui's ⌘+/⌘-/⌘0 zoom is off. A Mac host gets ⌘ as Command whatever "Command acts as Ctrl" says. The keypad's + sends VK_ADD instead of the = key. The translation sends through a Sink trait, so tests record what the PC would see: order, masks and signs for each case above. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/client/Cargo.toml | 8 + crates/client/src/clipboard.rs | 450 ++++++++++--- crates/client/src/input.rs | 198 ++++-- crates/client/src/lib.rs | 1 + crates/client/src/macos.rs | 122 ++++ crates/client/src/stream.rs | 1078 +++++++++++++++++++++++++++----- 7 files changed, 1559 insertions(+), 301 deletions(-) create mode 100644 crates/client/src/macos.rs diff --git a/Cargo.lock b/Cargo.lock index fe1fd93..4888ab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2125,6 +2125,9 @@ dependencies = [ "latch-core", "latch-stream", "latch-ui", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "parking_lot", "rand", "semver", diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7cf0a4b..a496a36 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -30,6 +30,14 @@ egui.workspace = true egui-wgpu.workspace = true semver = "1" +# The versions winit already builds on: nothing new is compiled. Used to +# take ⌘Q and ⌘H back from the app menu during a stream, and to read the +# 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-foundation = { version = "0.2", features = ["NSString", "NSThread"] } + [dev-dependencies] toml.workspace = true egui_kittest = { version = "0.31", features = ["wgpu", "eframe"] } diff --git a/crates/client/src/clipboard.rs b/crates/client/src/clipboard.rs index fb7592f..bd45b40 100644 --- a/crates/client/src/clipboard.rs +++ b/crates/client/src/clipboard.rs @@ -6,8 +6,10 @@ //! the PC never reached the Mac. Now a worker polls the PC's clipboard //! every second or so and hands new text to the window, which puts it in //! the Mac's clipboard; and a paste first sends the Mac's text to the PC, -//! then presses Ctrl+V there. Hosts before 3.1 have no such route; the -//! worker notices the 404 and says so once. +//! unless the PC has copied something newer since, then the window presses +//! Ctrl+V there, with anything typed meanwhile held back behind it. Hosts +//! before 3.1 have no such route; the worker notices the 404 and says so +//! once. //! //! A Linux desktop is sent the X11 shortcuts instead (see [`Shortcuts`]): //! in its terminals Ctrl+C interrupts the running program and Ctrl+V types @@ -18,7 +20,7 @@ use latch_core::api::{Ack, Clipboard, CLIPBOARD_PATH}; use latch_core::{http, CONTROL_PORT}; use parking_lot::Mutex; use std::net::Ipv4Addr; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -26,6 +28,9 @@ const POLL: Duration = Duration::from_millis(1500); /// After a ⌘C on the PC, Windows needs a moment before the clipboard has /// the new text. const POKE_DELAY: Duration = Duration::from_millis(400); +/// How long a paste right after a ⌘C waits for the PC's new clipboard to +/// be read, before sending the Mac's text anyway. +const POKE_WAIT: Duration = Duration::from_millis(1200); const TIMEOUT: Duration = Duration::from_secs(5); /// A clipboard shortcut pressed on the Mac and replayed on the PC. @@ -85,7 +90,8 @@ impl Shortcuts { struct Shared { /// Text the PC copied that the window has not taken yet. incoming: Option, - /// The last text either side saw, so nothing echoes back and forth. + /// The last text both sides held, so nothing echoes back and forth. + /// Empty once the PC has copied something that is not text. last: String, last_seq: Option, /// The host answered 404: older than 3.1. @@ -94,16 +100,40 @@ struct Shared { selection: bool, /// Something the window can show about the last exchange. note: Option<(String, Instant)>, - pokes: u32, + /// ⌘C and ⌘X sent to the PC, and how many of them the poll had seen + /// when its last read finished: while these differ, a copy may be on + /// its way that a paste must not overwrite. + pokes: u64, + polled: u64, + /// The PC has copied something (text, an image, files) that the + /// window has not yet weighed against the Mac's clipboard. + pc_copied: bool, + /// The Mac clipboard's change count when the PC last copied something. + /// While the Mac's count still reads this, the PC's clipboard is the + /// newer of the two and a paste leaves it alone. + pc_newer_than: Option, + /// The newest paste whose text has reached the PC, or failed to. + ready: u64, + /// The newest paste the window stopped waiting for. + gave_up: u64, +} + +/// What a paste needs before the window presses its chord. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Paste { + /// Nothing: the PC already has the right clipboard. + Now, + /// The Mac's text is on its way; [`Sync::paste_ready`] says when. + Wait(u64), } pub struct Sync { ip: Ipv4Addr, shared: Arc>, stop: Arc, - /// Pastes whose chord has been sent, so the window can put a modifier - /// the user is still holding back down afterwards. - done: Arc, + /// Repainted when a paste's text has arrived. + ctx: egui::Context, + pastes: AtomicU64, } impl Sync { @@ -114,6 +144,7 @@ impl Sync { { let shared = shared.clone(); let stop = stop.clone(); + let ctx = ctx.clone(); std::thread::Builder::new() .name("clipboard".into()) .spawn(move || poll_loop(ip, shared, stop, ctx)) @@ -123,17 +154,11 @@ impl Sync { ip, shared, stop, - done: Arc::default(), + ctx, + pastes: AtomicU64::new(0), } } - /// How many pastes have had their chord sent. The chord releases the - /// modifier it pressed; if ⌘ is still physically down when this moves, - /// the window presses the modifier again. - pub fn pastes_done(&self) -> u32 { - self.done.load(Ordering::Acquire) - } - /// Text copied on the PC since the last call, once. pub fn take_incoming(&self) -> Option { self.shared.lock().incoming.take() @@ -164,44 +189,195 @@ impl Sync { self.shared.lock().pokes += 1; } - /// Put `text` in the PC's clipboard, then run `then` (the paste chord) - /// on the worker thread. If the PC already has this text, `then` runs - /// at once; if the host cannot take it, `then` still runs, so the PC - /// pastes whatever it has rather than nothing. - pub fn push(&self, text: String, then: impl FnOnce() + Send + 'static) { - let (same, unsupported) = { - let sh = self.shared.lock(); - (sh.last == text, sh.unsupported) - }; - let done = self.done.clone(); - if same || unsupported || text.is_empty() { - then(); - done.fetch_add(1, Ordering::AcqRel); - return; + /// Called every frame with a way to read the Mac clipboard's change + /// count: when the PC has copied something since, that count is what + /// the PC's copy is newer than. Read on the window's thread, after + /// text from the PC has been handed over but before the Mac has + /// written it, so that write counts as a change too (harmless: the + /// Mac then holds the text the PC has). + pub fn note_mac_clipboard(&self, count: impl FnOnce() -> Option) { + let mut sh = self.shared.lock(); + if std::mem::take(&mut sh.pc_copied) { + sh.pc_newer_than = count(); + } + } + + /// Start a paste of `text`, the Mac's clipboard, whose change count is + /// `mac_count`. The PC is sent the text first unless it already has it, + /// or has copied something itself since the Mac last changed (an image + /// or files there must not be replaced by older text from here), or the + /// host cannot take it; then the PC pastes what it has. An empty + /// `text` (an image or nothing on the Mac) is never sent. + pub fn push(&self, text: String, mac_count: Option) -> Paste { + let fitted = Clipboard::fit(&text, 0); + let sh = self.shared.lock(); + if !sh.needs(&fitted, mac_count) { + return Paste::Now; } - let shared = self.shared.clone(); - let ip = self.ip; + let id = self.pastes.fetch_add(1, Ordering::Relaxed) + 1; + // A ⌘C just sent may not have been read yet: wait for that read + // before deciding, or the Mac's older text would replace it. + let poke = (sh.polled != sh.pokes).then_some((sh.pokes, sh.last_seq)); + drop(sh); + let (shared, stop, ctx, ip) = ( + self.shared.clone(), + self.stop.clone(), + self.ctx.clone(), + self.ip, + ); std::thread::spawn(move || { - match send(ip, &text) { - Ok(()) => { - let mut sh = shared.lock(); - sh.last = text; - sh.note = Some(("Clipboard sent to the PC".into(), Instant::now())); + if let Some((pokes, seq)) = poke { + let until = Instant::now() + POKE_WAIT; + while shared.lock().polled < pokes + && Instant::now() < until + && !stop.load(Ordering::Relaxed) + { + std::thread::sleep(Duration::from_millis(50)); } - Err(Fail::Unsupported) => shared.lock().unsupported = true, - Err(Fail::Other(e)) => { - tracing::warn!("clipboard to PC: {e}"); - shared.lock().note = Some((format!("Clipboard not sent: {e}"), Instant::now())); + if shared.lock().last_seq != seq { + // The PC copied something after all: it pastes that. + shared.lock().finish(id); + ctx.request_repaint(); + return; } } - then(); - done.fetch_add(1, Ordering::AcqRel); + let before = shared.lock().sending(&fitted); + let result = send(ip, &fitted); + let mut sh = shared.lock(); + match result { + Ok(()) => sh.sent(&fitted, id), + Err(e) => { + sh.not_sent(&fitted, before); + match e { + Fail::Unsupported => sh.unsupported = true, + Fail::Other(e) => { + tracing::warn!("clipboard to PC: {e}"); + sh.note = Some((format!("Clipboard not sent: {e}"), Instant::now())); + } + } + } + } + sh.finish(id); + drop(sh); + ctx.request_repaint(); }); + Paste::Wait(id) + } + + /// Whether paste `id` is ready for its chord: its text has reached the + /// PC, or failed to. + pub fn paste_ready(&self, id: u64) -> bool { + self.shared.lock().ready >= id + } + + /// The window has stopped waiting for paste `id` and pressed its chord + /// anyway (or dropped it). + pub fn give_up(&self, id: u64) { + let mut sh = self.shared.lock(); + sh.gave_up = sh.gave_up.max(id); } pub fn stop(&self) { self.stop.store(true, Ordering::Relaxed); } + + /// Mark paste `id` as arrived, as its worker would. + #[cfg(test)] + pub(crate) fn finish_for_test(&self, id: u64) { + self.shared.lock().finish(id); + } +} + +impl Shared { + /// Whether a paste of `fitted` has to send it to the PC first. + fn needs(&self, fitted: &Clipboard, mac_count: Option) -> bool { + let pc_newer = mac_count.is_some() && self.pc_newer_than == mac_count; + !(fitted.text.is_empty() + || self.unsupported + || pc_newer + || same_text(&self.last, &fitted.text)) + } + + /// `fitted` is about to be written on the PC: count it as the last text + /// now, so the poll does not take the PC reading it back for a copy + /// made there. Returns what `last` was, for [`Shared::not_sent`]. + fn sending(&mut self, fitted: &Clipboard) -> String { + std::mem::replace(&mut self.last, fitted.text.clone()) + } + + fn sent(&mut self, fitted: &Clipboard, id: u64) { + self.pc_newer_than = None; + let text = if self.gave_up >= id { + "The clipboard reached the PC after the paste: paste again for it" + } else if fitted.truncated { + "Sent the first 32 KB of the clipboard to the PC" + } else { + "Clipboard sent to the PC" + }; + self.note = Some((text.into(), Instant::now())); + } + + fn not_sent(&mut self, fitted: &Clipboard, before: String) { + if self.last == fitted.text { + self.last = before; + } + } + + fn finish(&mut self, id: u64) { + self.ready = self.ready.max(id); + } + + /// Take in one read of the PC's clipboard. True when there is + /// something new for the window: text to put in the Mac's clipboard, + /// or a copy of something else to weigh against it. + fn observe(&mut self, c: Clipboard, primed: &mut bool) -> bool { + self.selection = c.selection; + if !*primed { + // The PC's existing clipboard is not a copy that happened + // during this stream. Seed last/seq so we do not overwrite + // whatever the Mac was holding. + *primed = true; + self.last_seq = Some(c.seq); + if !c.text.is_empty() { + self.last = c.text; + } + return false; + } + if self.last_seq == Some(c.seq) { + return false; + } + self.last_seq = Some(c.seq); + if !c.text.is_empty() && same_text(&c.text, &self.last) { + // Our own paste, read back. + return false; + } + self.pc_copied = true; + if c.text.is_empty() { + // An image or files: nothing to bring over, but what the PC + // holds is no longer the last text either side had. + self.last.clear(); + return true; + } + self.last = c.text.clone(); + self.incoming = Some(c.text); + self.note = Some(( + if c.truncated { + "Copied from the PC (cut at 32 KB)".to_string() + } else { + "Copied from the PC".to_string() + }, + Instant::now(), + )); + true + } +} + +/// The same text, whatever its line endings: a Windows PC reads back +/// what it was sent with CRLF turned into LF. +fn same_text(a: &str, b: &str) -> bool { + a == b + || ((a.contains('\r') || b.contains('\r')) + && a.replace("\r\n", "\n") == b.replace("\r\n", "\n")) } enum Fail { @@ -209,9 +385,8 @@ enum Fail { Other(String), } -fn send(ip: Ipv4Addr, text: &str) -> Result<(), Fail> { - let body = - serde_json::to_string(&Clipboard::fit(text, 0)).map_err(|e| Fail::Other(e.to_string()))?; +fn send(ip: Ipv4Addr, fitted: &Clipboard) -> Result<(), Fail> { + let body = serde_json::to_string(fitted).map_err(|e| Fail::Other(e.to_string()))?; let r = http::request( (ip, CONTROL_PORT), "POST", @@ -246,7 +421,7 @@ fn fetch(ip: Ipv4Addr) -> Result { fn poll_loop(ip: Ipv4Addr, shared: Arc>, stop: Arc, ctx: egui::Context) { let mut failures = 0u32; - let mut pokes_seen = 0u32; + let mut pokes_seen = 0u64; let mut primed = false; while !stop.load(Ordering::Relaxed) { // Sleep in small steps so a poke or a stop is noticed quickly. @@ -263,38 +438,13 @@ fn poll_loop(ip: Ipv4Addr, shared: Arc>, stop: Arc, ct break; } pokes_seen = shared.lock().pokes; - match fetch(ip) { + let result = fetch(ip); + shared.lock().polled = pokes_seen; + match result { Ok(c) => { failures = 0; - let mut sh = shared.lock(); - sh.selection = c.selection; - if !primed { - // The PC's existing clipboard is not a copy that happened - // during this stream. Seed last/seq so we do not overwrite - // whatever the Mac was holding. - primed = true; - sh.last_seq = Some(c.seq); - if !c.text.is_empty() { - sh.last = c.text; - } - continue; - } - if sh.last_seq != Some(c.seq) { - sh.last_seq = Some(c.seq); - if !c.text.is_empty() && c.text != sh.last { - sh.last = c.text.clone(); - sh.incoming = Some(c.text); - sh.note = Some(( - if c.truncated { - "Copied from the PC (cut at 32 KB)".to_string() - } else { - "Copied from the PC".to_string() - }, - Instant::now(), - )); - drop(sh); - ctx.request_repaint(); - } + if shared.lock().observe(c, &mut primed) { + ctx.request_repaint(); } } Err(Fail::Unsupported) => { @@ -314,8 +464,11 @@ fn poll_loop(ip: Ipv4Addr, shared: Arc>, stop: Arc, ct } } -/// Whether the window should offer a paste at all: `Event::Paste` carries -/// text only when the Mac's clipboard holds some. +/// Whether a paste's text is worth sending. `Event::Paste` carries text +/// only when the Mac's clipboard holds some; a ⌘V with an image or +/// nothing there is seen by its V key's release instead (see the stream +/// view) and pastes with empty text, which is never sent: the PC pastes +/// what it has. pub fn worth_sending(text: &str) -> bool { !text.is_empty() } @@ -366,21 +519,34 @@ mod tests { ); } - #[test] - fn a_push_of_the_same_text_runs_the_paste_at_once() { - let s = Sync { + fn sync() -> Sync { + Sync { ip: Ipv4Addr::LOCALHOST, shared: Arc::default(), stop: Arc::new(AtomicBool::new(true)), - done: Arc::default(), - }; + ctx: egui::Context::default(), + pastes: AtomicU64::new(0), + } + } + + fn pc(text: &str, seq: u64) -> Clipboard { + Clipboard { + text: text.into(), + seq, + ..Default::default() + } + } + + #[test] + fn a_paste_the_pc_already_has_is_pressed_at_once() { + let s = sync(); s.shared.lock().last = "hello".into(); - let ran = Arc::new(AtomicBool::new(false)); - let r = ran.clone(); - assert_eq!(s.pastes_done(), 0); - s.push("hello".into(), move || r.store(true, Ordering::Relaxed)); - assert!(ran.load(Ordering::Relaxed)); - assert_eq!(s.pastes_done(), 1); + assert_eq!(s.push("hello".into(), None), Paste::Now); + assert_eq!( + s.push(String::new(), None), + Paste::Now, + "an image on the Mac" + ); assert!(worth_sending("x")); assert!(worth_sending(" \n"), "whitespace is valid clipboard text"); assert!(!worth_sending("")); @@ -388,5 +554,109 @@ mod tests { s.shared.lock().incoming = Some("from pc".into()); assert_eq!(s.take_incoming().as_deref(), Some("from pc")); assert!(s.take_incoming().is_none()); + // Paste ids count up; a paste is ready once its id is reached. + s.shared.lock().finish(2); + assert!(s.paste_ready(1) && s.paste_ready(2) && !s.paste_ready(3)); + } + + #[test] + fn files_or_an_image_copied_on_the_pc_are_pasted_there_while_the_mac_is_unchanged() { + let mut sh = Shared::default(); + let mut primed = false; + assert!(!sh.observe(pc("old pc text", 1), &mut primed)); + // The PC copies files: no text, a new sequence number. + assert!(sh.observe(pc("", 2), &mut primed)); + assert!(sh.incoming.is_none()); + assert!(sh.pc_copied); + // The window weighs it against the Mac clipboard's change count. + let s = sync(); + *s.shared.lock() = sh; + s.note_mac_clipboard(|| Some(41)); + assert!(!s.shared.lock().pc_copied); + let mac = Clipboard::fit("mac text", 0); + let sh = s.shared.lock(); + assert!( + !sh.needs(&mac, Some(41)), + "the Mac has not changed: paste the files" + ); + assert!( + sh.needs(&mac, Some(42)), + "the Mac copied since: send its text" + ); + assert!(sh.needs(&mac, None), "no change count: as before"); + drop(sh); + // Copying the old text again after the files is a copy too. + let mut sh = s.shared.lock(); + assert!(sh.observe(pc("old pc text", 3), &mut primed)); + assert_eq!(sh.incoming.as_deref(), Some("old pc text")); + } + + #[test] + fn text_copied_on_the_pc_comes_over_once() { + let mut sh = Shared::default(); + let mut primed = false; + sh.observe(pc("", 1), &mut primed); + assert!(sh.observe(pc("copied there", 2), &mut primed)); + assert_eq!(sh.incoming.take().as_deref(), Some("copied there")); + assert_eq!(sh.last, "copied there"); + assert!(!sh.observe(pc("copied there", 2), &mut primed), "same seq"); + // The Mac now holds that text: a paste of it sends nothing. + assert!(!sh.needs(&Clipboard::fit("copied there", 0), None)); + } + + #[test] + fn the_pc_reading_back_a_paste_is_not_a_copy() { + let mut sh = Shared::default(); + let mut primed = false; + sh.observe(pc("before", 1), &mut primed); + let fitted = Clipboard::fit("from the mac", 0); + let before = sh.sending(&fitted); + assert_eq!(before, "before"); + sh.sent(&fitted, 1); + assert!(!sh.observe(pc("from the mac", 2), &mut primed)); + assert!(sh.incoming.is_none() && !sh.pc_copied); + // A Windows PC reads CRLF back as LF. + let crlf = Clipboard::fit("a\r\nb", 0); + sh.sending(&crlf); + sh.sent(&crlf, 2); + assert!(!sh.observe(pc("a\nb", 3), &mut primed)); + assert!(sh.incoming.is_none()); + assert!(!sh.needs(&Clipboard::fit("a\nb", 0), None)); + // A send that failed puts the old text back, so the next paste of + // the same text tries again. + let fitted = Clipboard::fit("new", 0); + let before = sh.sending(&fitted); + sh.not_sent(&fitted, before); + assert_eq!(sh.last, "a\r\nb"); + assert!(sh.needs(&fitted, None)); + } + + #[test] + fn a_long_paste_is_cut_and_the_cut_copy_never_comes_back() { + let long = "x".repeat(latch_core::api::CLIPBOARD_MAX_BYTES + 100); + let fitted = Clipboard::fit(&long, 0); + assert!(fitted.truncated); + let mut sh = Shared::default(); + let mut primed = false; + sh.observe(pc("", 1), &mut primed); + sh.sending(&fitted); + sh.sent(&fitted, 1); + assert!(sh.note.as_ref().unwrap().0.contains("first 32 KB")); + // The PC reads back the cut text under a new number. + assert!(!sh.observe(pc(&fitted.text, 2), &mut primed)); + assert!(sh.incoming.is_none(), "the Mac keeps its whole text"); + // Pasting the same long text again sends nothing new. + assert!(!sh.needs(&Clipboard::fit(&long, 0), None)); + } + + #[test] + fn a_paste_the_window_gave_up_on_says_so_when_it_lands() { + let s = sync(); + s.give_up(3); + let mut sh = s.shared.lock(); + sh.sent(&Clipboard::fit("x", 0), 3); + assert!(sh.note.as_ref().unwrap().0.contains("paste again")); + sh.sent(&Clipboard::fit("x", 0), 4); + assert_eq!(sh.note.as_ref().unwrap().0, "Clipboard sent to the PC"); } } diff --git a/crates/client/src/input.rs b/crates/client/src/input.rs index 16af58d..fe5614d 100644 --- a/crates/client/src/input.rs +++ b/crates/client/src/input.rs @@ -53,7 +53,10 @@ pub fn vk(key: Key) -> Option { Backtick => 0xC0, Minus => 0xBD, Period => 0xBE, - Plus | Equals => 0xBB, + Equals => 0xBB, + // egui's Plus comes from the keypad's + key alone (the main row's + // key is Equals): VK_ADD types "+" there, VK_OEM_PLUS typed "=". + Plus => 0x6B, Quote => 0xDE, Num0 => 0x30, Num1 => 0x31, @@ -129,6 +132,103 @@ pub fn button(b: PointerButton) -> Option { }) } +/// Where keyboard and mouse events go: the PC, through Moonlight, or in +/// tests a recording, so the order and masks the PC would see can be +/// checked. +pub trait Sink { + /// `vk` is a Windows virtual-key code; `mask` the `MODIFIER_*` bits. + fn key(&self, vk: i16, down: bool, mask: c_char); + /// `b` is one of the `BUTTON_*` codes. + fn button(&self, b: i32, down: bool); + /// An absolute position in a `w` by `h` picture. + fn position(&self, x: i16, y: i16, w: i16, h: i16); + /// Relative movement, while the mouse is captured. + fn motion(&self, dx: i16, dy: i16); + /// In 1/120ths of a wheel click; positive is up and right. + fn scroll(&self, vertical: i16, horizontal: i16); +} + +impl Sink for Input { + fn key(&self, vk: i16, down: bool, mask: c_char) { + Input::key(self, vk, down, mask); + } + fn button(&self, b: i32, down: bool) { + self.mouse_button(b, down); + } + fn position(&self, x: i16, y: i16, w: i16, h: i16) { + self.mouse_position(x, y, w, h); + } + fn motion(&self, dx: i16, dy: i16) { + self.mouse_move(dx, dy); + } + fn scroll(&self, vertical: i16, horizontal: i16) { + Input::scroll(self, vertical, horizontal); + } +} + +/// What a [`Recorder`] saw, in order. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Sent { + Key(i16, bool, c_char), + Button(i32, bool), + Position(i16, i16, i16, i16), + Motion(i16, i16), + Scroll(i16, i16), +} + +/// A [`Sink`] that keeps what it is sent, and says whether the stream is +/// connected. Like [`Input`], it drops everything while it is not. +#[cfg(test)] +#[derive(Debug, Default)] +pub struct Recorder { + pub sent: std::cell::RefCell>, + pub connected: std::cell::Cell, +} + +#[cfg(test)] +impl Recorder { + fn record(&self, s: Sent) { + if self.connected.get() { + self.sent.borrow_mut().push(s); + } + } + + pub fn take(&self) -> Vec { + std::mem::take(&mut self.sent.borrow_mut()) + } + + /// Only the key events, as (key, down). + pub fn keys(&self) -> Vec<(i16, bool)> { + self.take() + .into_iter() + .filter_map(|s| match s { + Sent::Key(vk, down, _) => Some((vk, down)), + _ => None, + }) + .collect() + } +} + +#[cfg(test)] +impl Sink for Recorder { + fn key(&self, vk: i16, down: bool, mask: c_char) { + self.record(Sent::Key(vk, down, mask)); + } + fn button(&self, b: i32, down: bool) { + self.record(Sent::Button(b, down)); + } + fn position(&self, x: i16, y: i16, w: i16, h: i16) { + self.record(Sent::Position(x, y, w, h)); + } + fn motion(&self, dx: i16, dy: i16) { + self.record(Sent::Motion(dx, dy)); + } + fn scroll(&self, vertical: i16, horizontal: i16) { + self.record(Sent::Scroll(vertical, horizontal)); + } +} + /// One key event as Moonlight takes it: the key, down or up, and the /// modifier mask sent with it. type KeyEvent = (i16, bool, c_char); @@ -136,17 +236,17 @@ type KeyEvent = (i16, bool, c_char); /// The modifier keys, in the order their mask bits are listed. const MODIFIERS: [i16; 4] = [VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN]; -fn send(input: &Input, events: &[KeyEvent]) { +fn send(out: &(impl Sink + ?Sized), events: &[KeyEvent]) { for &(vk, down, mask) in events { - input.key(vk, down, mask); + out.key(vk, down, mask); } } /// Press `keys` in order and release them in reverse, with `mask` for the -/// modifiers already held. Usable from any thread, which is what a paste -/// that first has to reach the PC's clipboard needs. -pub fn press_chord(input: &Input, keys: &[i16], mask: c_char) { - send(input, &chord_events(keys, mask)); +/// modifiers already held. Usable from any thread with an [`Input`], which +/// is what typing on the PC from a worker needs. +pub fn press_chord(out: &(impl Sink + ?Sized), keys: &[i16], mask: c_char) { + send(out, &chord_events(keys, mask)); } fn chord_events(keys: &[i16], mask: c_char) -> Vec { @@ -163,20 +263,11 @@ fn chord_events(keys: &[i16], mask: c_char) -> Vec { events } -/// [`press_chord`] for a chord that must arrive with no other modifier: -/// those in `mask` that `keys` does not use are let go first, so ⌘ held as -/// Ctrl does not turn Shift+Insert into Ctrl+Shift+Insert. They stay up; -/// the window presses a modifier the user still holds back down once the -/// paste is done (see `clipboard::Sync::pastes_done`). -pub fn press_chord_alone(input: &Input, keys: &[i16], mask: c_char) { - send(input, &alone_events(keys, keys, mask, false)); -} - -/// The events that send `keys` with no other modifier down. Modifiers in -/// `mask` outside `keys` are released first and, with `restore`, pressed -/// again after; `press` is the part of `keys` to press, all of it when -/// `mask` may be stale (a worker thread's copy). -fn alone_events(keys: &[i16], press: &[i16], mask: c_char, restore: bool) -> Vec { +/// The events that send `keys` with no other modifier down: modifiers in +/// `mask` outside `keys` are released first, so ⌘ held as Ctrl does not +/// turn Shift+Insert into Ctrl+Shift+Insert, and pressed again after. +/// `press` is the part of `keys` to press: those not already held. +fn alone_events(keys: &[i16], press: &[i16], mask: c_char) -> Vec { let others: Vec = MODIFIERS .into_iter() .filter(|&vk| mask & mask_of(&[vk]) != 0 && !keys.contains(&vk)) @@ -188,11 +279,9 @@ fn alone_events(keys: &[i16], press: &[i16], mask: c_char, restore: bool) -> Vec events.push((vk, false, mask)); } events.extend(chord_events(press, mask)); - if restore { - for &vk in &others { - mask |= mask_of(&[vk]); - events.push((vk, true, mask)); - } + for &vk in &others { + mask |= mask_of(&[vk]); + events.push((vk, true, mask)); } events } @@ -223,29 +312,29 @@ pub struct Held { } impl Held { - pub fn key(&mut self, input: &Input, vk: i16, down: bool) { + pub fn key(&mut self, out: &(impl Sink + ?Sized), vk: i16, down: bool) { if down { self.keys.insert(vk); } else { self.keys.remove(&vk); } - input.key(vk, down, self.mask()); + out.key(vk, down, self.mask()); } - pub fn button(&mut self, input: &Input, b: i32, down: bool) { + pub fn button(&mut self, out: &(impl Sink + ?Sized), b: i32, down: bool) { if down { self.buttons.insert(b); } else { self.buttons.remove(&b); } - input.mouse_button(b, down); + out.button(b, down); } /// Turn modifier changes into key presses. Cmd maps to Ctrl or the /// Windows key. - pub fn modifiers(&mut self, input: &Input, now: Modifiers, cmd_is_ctrl: bool) { + pub fn modifiers(&mut self, out: &(impl Sink + ?Sized), now: Modifiers, cmd_is_ctrl: bool) { for (vk, down) in self.modifier_changes(now, cmd_is_ctrl) { - self.key(input, vk, down); + self.key(out, vk, down); } } @@ -276,12 +365,12 @@ impl Held { .fold(0, |mask, (_, bit)| mask | bit) } - pub fn release_all(&mut self, input: &Input) { + pub fn release_all(&mut self, out: &(impl Sink + ?Sized)) { for k in std::mem::take(&mut self.keys) { - input.key(k, false, 0); + out.key(k, false, 0); } for b in std::mem::take(&mut self.buttons) { - input.mouse_button(b, false); + out.button(b, false); } } @@ -289,20 +378,20 @@ impl Held { /// already down (⌘ held while ⌘C, ⌘V follow each other) is left down: /// releasing it here would make the next shortcut in the same hold /// arrive without its modifier. - pub fn chord(&mut self, input: &Input, keys: &[i16]) { + pub fn chord(&mut self, out: &(impl Sink + ?Sized), keys: &[i16]) { let fresh = self.chord_plan(keys); - press_chord(input, &fresh, self.mask()); + press_chord(out, &fresh, self.mask()); } /// [`Held::chord`] for a chord that must arrive with no other modifier: /// held modifiers it does not use are let go for it and pressed again /// after, so the PC ends up holding what the user holds. - pub fn chord_alone(&mut self, input: &Input, keys: &[i16]) { - send(input, &self.chord_alone_events(keys)); + pub fn chord_alone(&mut self, out: &(impl Sink + ?Sized), keys: &[i16]) { + send(out, &self.chord_alone_events(keys)); } fn chord_alone_events(&self, keys: &[i16]) -> Vec { - alone_events(keys, &self.chord_plan(keys), self.mask(), true) + alone_events(keys, &self.chord_plan(keys), self.mask()) } /// The keys of `keys` a chord would have to press: those not held. @@ -437,32 +526,6 @@ mod tests { ); } - #[test] - fn a_paste_alone_from_the_worker_presses_the_whole_chord_and_restores_nothing() { - // The mask is the worker's copy from when ⌘V arrived; the window - // puts Ctrl back once the paste is counted. - assert_eq!( - alone_events( - &[VK_SHIFT, VK_INSERT], - &[VK_SHIFT, VK_INSERT], - MODIFIER_CTRL, - false - ), - vec![ - (VK_CONTROL, false, 0), - (VK_SHIFT, true, MODIFIER_SHIFT), - (VK_INSERT, true, MODIFIER_SHIFT), - (VK_INSERT, false, MODIFIER_SHIFT), - (VK_SHIFT, false, 0), - ] - ); - assert_eq!( - alone_events(&[VK_CONTROL, VK_V], &[VK_CONTROL, VK_V], 0, false), - chord_events(&[VK_CONTROL, VK_V], 0), - "nothing held: the same as a plain chord" - ); - } - #[test] fn letters_digits_and_symbols_map_to_us_layout_codes() { assert_eq!(vk(Key::A), Some(0x41)); @@ -471,5 +534,8 @@ mod tests { assert_eq!(vk(Key::F12), Some(0x7B)); assert_eq!(vk(Key::Colon), vk(Key::Semicolon)); assert_eq!(vk(Key::Copy), None, "clipboard keys are handled as events"); + // The keypad's + is VK_ADD; the main row's =/+ key is VK_OEM_PLUS. + assert_eq!(vk(Key::Plus), Some(0x6B)); + assert_eq!(vk(Key::Equals), Some(0xBB)); } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index aeb3c0c..ee47aaa 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod display; pub mod handover; pub mod input; +pub mod macos; pub mod path; pub mod session; pub mod settings; diff --git a/crates/client/src/macos.rs b/crates/client/src/macos.rs new file mode 100644 index 0000000..38861e6 --- /dev/null +++ b/crates/client/src/macos.rs @@ -0,0 +1,122 @@ +//! The two things the stream needs from AppKit itself. +//! +//! winit gives every Mac app a standard menu whose Hide (⌘H), Hide Others +//! (⌥⌘H) and Quit (⌘Q) items take those keys before the window sees them: +//! mid-stream, ⌘Q meant for the PC (Ctrl+Q, quit an app there) quit Latch +//! and ⌘H hid it. While the stream has the keyboard those items keep their +//! 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. +//! +//! Elsewhere both do nothing. + +/// The app menu items whose shortcuts the stream takes over. +#[cfg(any(target_os = "macos", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Item { + Hide, + HideOthers, + Quit, +} + +/// The key an item answers to: none while the stream owns the keys. +#[cfg(any(target_os = "macos", test))] +fn equivalent(item: Item, stream_owns_keys: bool) -> &'static str { + match (item, stream_owns_keys) { + (_, true) => "", + (Item::Hide | Item::HideOthers, false) => "h", + (Item::Quit, false) => "q", + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{equivalent, Item}; + use objc2::sel; + use objc2_app_kit::{NSApplication, NSPasteboard}; + use objc2_foundation::{MainThreadMarker, NSString}; + use std::cell::Cell; + + thread_local! { + /// What the menu was last set to, so AppKit is only asked on a change. + static OWNED: Cell> = const { Cell::new(None) }; + } + + pub fn stream_owns_keys(owned: bool) { + // Menus belong to the main thread; tests run elsewhere. + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + if OWNED.get() == Some(owned) { + return; + } + OWNED.set(Some(owned)); + let app = NSApplication::sharedApplication(mtm); + // SAFETY: plain AppKit getters and setters on the main thread. + unsafe { + let Some(bar) = app.mainMenu() else { return }; + let Some(menu) = bar.itemAtIndex(0).and_then(|i| i.submenu()) else { + return; + }; + for i in 0..menu.numberOfItems() { + let Some(entry) = menu.itemAtIndex(i) else { + continue; + }; + let item = match entry.action() { + Some(a) if a == sel!(hide:) => Item::Hide, + Some(a) if a == sel!(hideOtherApplications:) => Item::HideOthers, + Some(a) if a == sel!(terminate:) => Item::Quit, + _ => continue, + }; + entry.setKeyEquivalent(&NSString::from_str(equivalent(item, owned))); + } + } + } + + pub fn pasteboard_changes() -> Option { + // SAFETY: NSPasteboard is usable from any thread; this only reads + // its change count. + Some(unsafe { NSPasteboard::generalPasteboard().changeCount() }) + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + pub fn stream_owns_keys(_owned: bool) {} + + pub fn pasteboard_changes() -> Option { + None + } +} + +/// While `owned`, ⌘Q, ⌘H and ⌥⌘H go to the stream (and so to the PC) +/// instead of quitting or hiding Latch. Cheap to call every frame. +pub fn stream_owns_keys(owned: bool) { + imp::stream_owns_keys(owned); +} + +/// The Mac clipboard's change count: it moves whenever anything is copied. +/// None where there is no such count. +pub fn pasteboard_changes() -> Option { + imp::pasteboard_changes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_stream_takes_quit_and_hide_and_gives_them_back() { + for item in [Item::Hide, Item::HideOthers, Item::Quit] { + assert_eq!(equivalent(item, true), ""); + } + assert_eq!(equivalent(Item::Hide, false), "h"); + // Hide Others keeps its Option bit in the item's modifier mask. + assert_eq!(equivalent(Item::HideOthers, false), "h"); + assert_eq!(equivalent(Item::Quit, false), "q"); + // Off the main thread (as here) it does nothing, and says so safely. + stream_owns_keys(true); + stream_owns_keys(false); + } +} diff --git a/crates/client/src/stream.rs b/crates/client/src/stream.rs index 9614ac4..366fc57 100644 --- a/crates/client/src/stream.rs +++ b/crates/client/src/stream.rs @@ -12,12 +12,12 @@ use crate::clipboard::{self, Edit}; use crate::config::{ClientConfig, StreamSettings}; -use crate::input::{self, press_chord, Held}; +use crate::input::{self, Held, Sink}; use crate::path::Path; use crate::session::Live; use crate::video; use egui::{ - Align2, Color32, CursorIcon, Event, Frame, Id, Margin, Pos2, Rect, RichText, Vec2, + Align2, Color32, CursorIcon, Event, Frame, Id, Margin, Modifiers, Pos2, Rect, RichText, Vec2, ViewportCommand, }; use latch_core::api::PowerAction; @@ -36,6 +36,9 @@ const STATUS: f32 = 24.0; const TOAST_FOR: Duration = Duration::from_secs(5); /// How long the host-key hint stays up when a stream starts. const HINT_FOR: Duration = Duration::from_secs(6); +/// How long a paste's chord, and what is typed after it, wait for its text +/// to reach the PC before they go anyway. +const PASTE_WAIT: Duration = Duration::from_secs(2); /// Each side of an overlay frame (its inner margin) and the picture it /// leaves visible beyond that. const OVERLAY_PAD: f32 = 12.0; @@ -58,16 +61,21 @@ pub fn host_key() -> (&'static [&'static str], &'static str) { /// What to call the other machine in the toolbar: its power menu reads /// "PC" for Windows, as the lobby's messages expect. fn machine_noun(os: &str) -> &'static str { - let os = os.to_ascii_lowercase(); - if os.starts_with("windows") { + if os.to_ascii_lowercase().starts_with("windows") { "PC" - } else if os.starts_with("macos") || os.starts_with("darwin") { + } else if is_mac_os(os) { "Mac" } else { "Machine" } } +/// Whether Tailscale's OS string names a Mac. +fn is_mac_os(os: &str) -> bool { + let os = os.to_ascii_lowercase(); + os.starts_with("macos") || os.starts_with("darwin") +} + #[derive(Debug, Clone, PartialEq)] pub enum Action { Disconnect, @@ -128,16 +136,67 @@ pub struct View { toasts: Vec, /// When the stream started, for the host-key hint. hint_since: Option, - /// Pastes seen through `clipboard.pastes_done()`. - pastes_seen: u32, /// Ctrl+Alt was down when capture last toggled; ignore further edges /// until both keys are up. `release_all` clears `Held.modifiers`, so /// deriving "was down" from that retriggered the toggle every frame. capture_chord_held: bool, + /// A paste whose text is still on its way to the PC. + pending: Option, + /// Where the pointer went in free mode, not yet sent. + moved_to: Option, + /// Since V was last let go: a paste was made, and ⌘ was down. A ⌘V + /// egui swallowed (the Mac's clipboard holds no text) shows only as a V + /// let go that the PC never saw pressed, with ⌘ seen and no paste. + pasted_since_v_up: bool, + cmd_since_v_up: bool, + /// The other machine is a Mac: ⌘ is Command there, whatever the + /// "Command acts as Ctrl" setting says for Windows and Linux. + host_mac: bool, /// A video problem to show in place of the session's, for rendering /// the recovery overlay without a broken capture. #[cfg(test)] problem: Option, + /// Where input goes in tests instead of the PC, and whether the + /// stream counts as connected. + #[cfg(test)] + tap: Option>, +} + +/// A paste whose text is on its way to the PC's clipboard. Its chord, and +/// everything typed or clicked after it, wait for the text; a key let go +/// before the chord would otherwise run a command on the old line (⌘V +/// then Return, 300 ms away from a PC in Korea). +struct PendingPaste { + id: u64, + until: Instant, + queued: Vec, +} + +/// Input held back behind a paste. +enum Queued { + /// An event, with where it was going when it arrived. + Event(Event, At), + /// The modifiers held at the end of a frame: egui has no events for + /// Shift, Ctrl, Option or ⌘ on their own. + Modifiers(Modifiers), +} + +/// Where an event was going when it arrived. +#[derive(Debug, Clone, Copy)] +struct At { + keys_to_pc: bool, + over_video: bool, +} + +/// What this frame's input is sent through, and what it needs to know. +struct Link<'a, S: Sink + ?Sized> { + ctx: &'a egui::Context, + live: &'a Live, + cfg: &'a ClientConfig, + out: &'a S, + connected: bool, + video: Rect, + ppp: f32, } impl Default for View { @@ -158,10 +217,16 @@ impl Default for View { poor_hinted: false, toasts: Vec::new(), hint_since: None, - pastes_seen: 0, capture_chord_held: false, + pending: None, + moved_to: None, + pasted_since_v_up: false, + cmd_since_v_up: false, + host_mac: false, #[cfg(test)] problem: None, + #[cfg(test)] + tap: None, } } } @@ -195,9 +260,31 @@ impl View { /// Release everything and let the cursor go; called when the stream /// ends or the window loses focus. pub fn reset(&mut self, ctx: &egui::Context, live: Option<&Live>) { - if let Some(l) = live { - self.held.release_all(&l.input); + self.reset_with(ctx, live, live.map(|l| &l.input)); + } + + fn reset_with( + &mut self, + ctx: &egui::Context, + live: Option<&Live>, + out: Option<&S>, + ) { + // A paste still waiting goes, with what was held back behind it: + // none of it has reached the PC, and pasting the PC's old clipboard + // after the user has gone would be worse than nothing. + if let Some(p) = self.pending.take() { + if let Some(l) = live { + l.clipboard.give_up(p.id); + } } + self.moved_to = None; + self.pasted_since_v_up = false; + self.cmd_since_v_up = false; + if let Some(out) = out { + self.held.release_all(out); + } + crate::macos::stream_owns_keys(false); + ctx.options_mut(|o| o.zoom_with_keyboard = true); self.set_captured(ctx, false); self.bar_shown = false; self.confirm = None; @@ -236,6 +323,7 @@ impl View { pub fn show(&mut self, ctx: &egui::Context, env: &Env<'_>) -> Vec { let live = env.live; let mut actions = Vec::new(); + self.host_mac = is_mac_os(&env.os); let screen = ctx.screen_rect(); let stats = live.session.stats(); let (vw, vh) = if stats.width > 0 { @@ -785,8 +873,10 @@ impl View { } if cfg!(target_os = "macos") { ui::menu_separator(ui); - if ui::menu_choice(ui, "Command acts as Ctrl", cfg.cmd_is_ctrl) - .on_hover_text("On: Command-C, V and Z copy, paste and undo there. Off: Command is the Windows key.") + if self.host_mac { + ui::menu_note(ui, "Command is Command on another Mac."); + } else if ui::menu_choice(ui, "Command acts as Ctrl", cfg.cmd_is_ctrl) + .on_hover_text("On: Command-C, V and Z copy, paste and undo there. Off: Command is the Windows key. A Mac there always gets Command.") .clicked() { actions.push(Action::ToggleCmd); @@ -836,7 +926,7 @@ impl View { && cfg.capture_mouse { actions.push(Action::MouseCapture(false)); - self.release(ctx, live); + self.release(ctx, &live.input); } } @@ -989,31 +1079,43 @@ impl View { } /// Capture the mouse and take the toolbar away: the picture is all. - fn grab(&mut self, ctx: &egui::Context, live: &Live) { - self.held.release_all(&live.input); + fn grab(&mut self, ctx: &egui::Context, out: &(impl Sink + ?Sized)) { + self.held.release_all(out); self.motion = (0.0, 0.0); + // A captured mouse sends every click to the PC, while egui still + // sees them at the frozen pointer: a panel, question or menu left + // open would take them too ("Apply and reconnect", "Restart"). + // Closing them is Cancel. + self.settings = None; + self.confirm = None; + self.confirm_install = false; + ctx.memory_mut(|m| m.close_popup()); self.set_captured(ctx, true); self.bar_shown = false; } /// Give the mouse back. - fn release(&mut self, ctx: &egui::Context, live: &Live) { - self.held.release_all(&live.input); + fn release(&mut self, ctx: &egui::Context, out: &(impl Sink + ?Sized)) { + self.held.release_all(out); self.set_captured(ctx, false); } /// Ctrl+Alt, the host key. From the picture it frees the mouse and /// drops the toolbar; from the toolbar it hides it and, when capture /// is on, takes the mouse back. - fn host_key(&mut self, ctx: &egui::Context, live: &Live, cfg: &ClientConfig) { + fn host_key(&mut self, ctx: &egui::Context, cfg: &ClientConfig, out: &(impl Sink + ?Sized)) { self.hint_since = None; + // Everything goes up first, whichever way this goes: while the + // chord is down every event is swallowed, a key let go meanwhile + // included, and it would stay down on the PC. + self.held.release_all(out); if self.captured || !self.bar_shown { - self.release(ctx, live); + self.release(ctx, out); self.bar_shown = true; } else { self.bar_shown = false; if cfg.capture_mouse { - self.grab(ctx, live); + self.grab(ctx, out); } } } @@ -1030,7 +1132,7 @@ impl View { // it. The click-to-capture path stays for re-capturing after a free. let focused = ctx.input(|i| i.focused); if capture && focused { - self.grab(ctx, live); + self.grab(ctx, &live.input); } self.hint_since = Some(Instant::now()); } @@ -1044,7 +1146,34 @@ impl View { video: Rect, bar: Option, ) { - let input = &live.input; + #[cfg(test)] + if let Some(tap) = self.tap.clone() { + let connected = tap.connected.get(); + return self.forward(ctx, live, cfg, video, bar, &*tap, connected); + } + self.forward( + ctx, + live, + cfg, + video, + bar, + &live.input, + live.input.connected(), + ); + } + + /// [`View::input`], sending through `out`. + #[allow(clippy::too_many_arguments)] + fn forward( + &mut self, + ctx: &egui::Context, + live: &Live, + cfg: &ClientConfig, + video: Rect, + bar: Option, + out: &S, + connected: bool, + ) { let (events, modifiers, pointer, focused) = ctx.input(|i| { ( i.events.clone(), @@ -1067,15 +1196,27 @@ impl View { overlay, over_overlay, ); - if self.captured && !popup && !overlay { + // While the keys belong to the PC, egui must not hold a focus: Tab + // would move it to the Stats panel or a notice's button, and every + // key after would stay in Latch. With the toolbar up, Tab walks + // the toolbar and its panels as it should; before the first + // picture it reaches Cancel. + let keys_belong_to_pc = self.captured || (bar.is_none() && live.frames.seq() > 0); + if keys_belong_to_pc && !popup && !overlay { ctx.memory_mut(|m| { if let Some(id) = m.focused() { m.surrender_focus(id); } }); } - let keys_to_pc = - focused && !ctx.wants_keyboard_input() && !popup && !overlay && input.connected(); + let keys_to_pc = focused && !ctx.wants_keyboard_input() && !popup && !overlay && connected; + // ⌘Q and ⌘H belong to the PC too while it has the keys, and so do + // ⌘+, ⌘- and ⌘0: egui would zoom Latch's own window on them while + // they zoomed the browser on the PC. + crate::macos::stream_owns_keys(keys_to_pc); + if ctx.options(|o| o.zoom_with_keyboard) == keys_to_pc { + ctx.options_mut(|o| o.zoom_with_keyboard = !keys_to_pc); + } let ppp = ctx.pixels_per_point(); let motion_scale = { let stats = live.session.stats(); @@ -1090,99 +1231,62 @@ impl View { ppp } }; + let link = Link { + ctx, + live, + cfg, + out, + connected, + video, + ppp, + }; + live.clipboard + .note_mac_clipboard(crate::macos::pasteboard_changes); if !focused { - if self.held.any_down() || self.captured { - self.reset(ctx, Some(live)); + if self.held.any_down() || self.captured || self.pending.is_some() { + self.reset_with(ctx, Some(live), Some(out)); } return; } + // A paste whose text has arrived goes before anything this frame. + self.settle_paste(&link, false); // 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 - // the toolbar opens (games and the Start menu react to one). + // the toolbar opens (games and the Start menu react to one), nor a + // lone Alt when Control is let go first (Windows opens the menu bar). + // Before the stream connects it can still free a captured mouse. let ctrl_alt = modifiers.ctrl && modifiers.alt; - if ctrl_alt { - if !self.capture_chord_held && input.connected() { + if ctrl_alt || (self.capture_chord_held && (modifiers.ctrl || modifiers.alt)) { + if ctrl_alt && !self.capture_chord_held && (connected || self.captured) { self.capture_chord_held = true; - self.host_key(ctx, live, cfg); + self.settle_paste(&link, true); + if connected { + self.host_key(ctx, cfg, out); + } else { + self.release(ctx, out); + } } return; } self.capture_chord_held = false; - if keys_to_pc { - self.held.modifiers(input, modifiers, cfg.cmd_is_ctrl); - // A paste's chord releases the modifier it pressed once the text - // has reached the PC. If ⌘ is still held here, press it again - // on the PC, so ⌘V ⌘V in one hold pastes twice. - let done = live.clipboard.pastes_done(); - if done != self.pastes_seen { - self.pastes_seen = done; - for vk in [input::VK_CONTROL, input::VK_LWIN] { - if self.held.is_down(vk) { - input.key(vk, true, self.held.mask()); - } - } - } - } else if self.held.any_down() { - self.held.release_all(input); + if !keys_to_pc && self.held.any_down() { + self.held.release_all(out); } - if (over_video || self.captured) && input.connected() { + if (over_video || self.captured) && connected { ctx.set_cursor_icon(CursorIcon::None); } - let mut position: Option = None; + let at = At { + keys_to_pc, + over_video, + }; for ev in events { match ev { - Event::Key { - physical_key, - key, - pressed, - repeat, - .. - } if keys_to_pc && (pressed || !repeat) => { - // OS autorepeat arrives as extra Downs with `repeat`. - // Sunshine does not generate them, so holding a key - // would otherwise type once. Releases never repeat. - if let Some(vk) = physical_key.and_then(input::vk).or_else(|| input::vk(key)) { - self.held.key(input, vk, pressed); - } - } - Event::Copy if keys_to_pc => { - self.clipboard_chord(live, Edit::Copy, cfg); - live.clipboard.poke(); - } - Event::Cut if keys_to_pc => { - self.clipboard_chord(live, Edit::Cut, cfg); - live.clipboard.poke(); - } - Event::Paste(text) if keys_to_pc => { - // The Mac's text first, then the paste: the PC pastes - // what was copied here, not what it had. The chord is - // sent whole: by the time the text has crossed the - // network a quick tap has let go of ⌘. - let (keys, alone) = live.shortcuts.keys( - Edit::Paste, - self.clipboard_modifier(cfg), - live.clipboard.pastes_to_selection(), - ); - let mask = self.held.mask(); - let handle = input.clone(); - let text = if clipboard::worth_sending(&text) { - text - } else { - String::new() - }; - live.clipboard.push(text, move || { - if alone { - input::press_chord_alone(&handle, &keys, mask); - } else { - press_chord(&handle, &keys, mask); - } - }); - } - Event::PointerMoved(p) if !self.captured && over_video => position = Some(p), + // The pointer is never held back behind a paste. + Event::PointerMoved(p) if !self.captured && over_video => 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 @@ -1191,67 +1295,222 @@ impl View { self.motion.0 += d.x * motion_scale; self.motion.1 += d.y * motion_scale; } - Event::PointerButton { - button, pressed, .. - } if self.captured || over_video || (!pressed && self.held.any_down()) => { - // A click on the picture puts the toolbar away and, when - // capture is on, takes the mouse. The click itself still - // goes to the PC: what was clicked on is what was meant. - if pressed && over_video && !self.captured { - self.bar_shown = false; - if cfg.capture_mouse { - self.grab(ctx, live); - } - } - if let Some(b) = input::button(button) { - self.held.button(input, b, pressed); - } - } Event::MouseWheel { unit, delta, .. } if self.captured || over_video => { - let scale = match unit { - egui::MouseWheelUnit::Point => 3.0, - egui::MouseWheelUnit::Line => 120.0, - egui::MouseWheelUnit::Page => 1200.0, - }; - self.scroll.0 += delta.x * scale; - self.scroll.1 += delta.y * scale; + let (h, v) = wheel_to_host(unit, delta); + self.scroll.0 += h; + self.scroll.1 += v; } - Event::WindowFocused(false) => self.reset(ctx, Some(live)), - _ => {} + Event::WindowFocused(false) => self.reset_with(ctx, Some(live), Some(out)), + ev => self.dispatch(&link, Queued::Event(ev, at)), } } - if let Some(p) = position { - let x = ((p.x - video.left()) * ppp).round() as i16; - let y = ((p.y - video.top()) * ppp).round() as i16; - input.mouse_position( - x, - y, - (video.width() * ppp).round() as i16, - (video.height() * ppp).round() as i16, - ); + // egui reports a modifier pressed or let go on its own only in its + // state at the end of the frame. + if keys_to_pc { + self.dispatch(&link, Queued::Modifiers(modifiers)); } - if self.captured && (self.motion.0.abs() >= 1.0 || self.motion.1.abs() >= 1.0) { - let dx = self.motion.0.trunc(); - let dy = self.motion.1.trunc(); - self.motion.0 -= dx; - self.motion.1 -= dy; - input.mouse_move(dx as i16, dy as i16); + if let Some(p) = self.moved_to.take() { + self.send_position(&link, p); } + self.flush_motion(out); if self.scroll.0.abs() >= 1.0 || self.scroll.1.abs() >= 1.0 { let h = self.scroll.0.trunc(); let v = self.scroll.1.trunc(); self.scroll.0 -= h; self.scroll.1 -= v; - input.scroll( + out.scroll( v.clamp(-32000.0, 32000.0) as i16, h.clamp(-32000.0, 32000.0) as i16, ); } + if let Some(p) = &self.pending { + ctx.request_repaint_after(p.until.saturating_duration_since(Instant::now())); + } + } + + /// Send `item` now, or hold it behind a paste still on its way. + fn dispatch(&mut self, link: &Link<'_, S>, item: Queued) { + match &mut self.pending { + Some(p) => p.queued.push(item), + None => self.handle(link, item), + } + } + + /// Send one event to the PC, as it stood when it arrived. + fn handle(&mut self, link: &Link<'_, S>, item: Queued) { + let out = link.out; + let cmd_is_ctrl = self.cmd_is_ctrl(link.cfg); + let (ev, at) = match item { + Queued::Modifiers(m) => { + self.cmd_since_v_up |= m.command; + self.held.modifiers(out, m, cmd_is_ctrl); + return; + } + Queued::Event(ev, at) => (ev, at), + }; + match ev { + Event::Key { + physical_key, + key, + pressed, + repeat, + modifiers: m, + } if at.keys_to_pc && (pressed || !repeat) => { + // The host key never reaches the PC, even when it was + // pressed and let go inside one frame. + if m.ctrl && m.alt { + return; + } + // Each key goes with the modifiers held when it was pressed: + // a Shift or ⌘ let go in the same frame came after it. + self.cmd_since_v_up |= m.command; + self.held.modifiers(out, m, cmd_is_ctrl); + // OS autorepeat arrives as extra Downs with `repeat`. + // Sunshine does not generate them, so holding a key + // would otherwise type once. Releases never repeat. + let Some(vk) = physical_key.and_then(input::vk).or_else(|| input::vk(key)) else { + return; + }; + let seen = self.held.is_down(vk); + if key == egui::Key::V && !pressed { + let pasted = std::mem::take(&mut self.pasted_since_v_up); + let cmd = std::mem::take(&mut self.cmd_since_v_up); + if swallowed_paste(seen, pasted, cmd) { + // A paste from an image or nothing on the Mac: the + // PC pastes what it has. It goes on the key's + // release, the first sign of it. + self.paste(link, String::new()); + } + } + if !pressed && !seen { + // The PC never saw it go down: egui took the press + // for ⌘C, ⌘X or ⌘V, or it was let go already. + return; + } + self.held.key(out, vk, pressed); + } + Event::Copy if at.keys_to_pc => { + self.clipboard_chord(link, Edit::Copy); + link.live.clipboard.poke(); + } + Event::Cut if at.keys_to_pc => { + self.clipboard_chord(link, Edit::Cut); + link.live.clipboard.poke(); + } + Event::Paste(text) if at.keys_to_pc => self.paste(link, text), + Event::PointerButton { + pos, + button, + pressed, + modifiers: m, + } if self.captured || at.over_video || (!pressed && self.held.any_down()) => { + if at.keys_to_pc && !(m.ctrl && m.alt) { + self.held.modifiers(out, m, cmd_is_ctrl); + } + // The click lands where it was made: the pointer goes + // there first, before a capture would zero the motion. + if self.captured { + self.flush_motion(out); + } else { + self.moved_to = None; + self.send_position(link, pos); + } + // A click on the picture puts the toolbar away and, when + // capture is on, takes the mouse. The click itself still + // 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 { + self.bar_shown = false; + if link.cfg.capture_mouse { + self.grab(link.ctx, out); + } + } + if let Some(b) = input::button(button) { + self.held.button(out, b, pressed); + } + } + _ => {} + } + } + + /// The Mac's clipboard first, then the paste: the PC pastes what was + /// copied here, not what it had. While the text travels, the chord and + /// everything after it wait (see [`PendingPaste`]). + fn paste(&mut self, link: &Link<'_, S>, text: String) { + self.pasted_since_v_up = true; + let text = if clipboard::worth_sending(&text) { + text + } else { + String::new() + }; + match link + .live + .clipboard + .push(text, crate::macos::pasteboard_changes()) + { + clipboard::Paste::Now => self.clipboard_chord(link, Edit::Paste), + clipboard::Paste::Wait(id) => { + self.pending = Some(PendingPaste { + id, + until: Instant::now() + PASTE_WAIT, + queued: Vec::new(), + }); + } + } + } + + /// Press a waiting paste's chord once its text is on the PC, or `now`, + /// or once it has waited long enough (the PC then pastes what it has), + /// and send what was held back behind it, in order. + fn settle_paste(&mut self, link: &Link<'_, S>, now: bool) { + let Some(p) = &self.pending else { return }; + let ready = link.live.clipboard.paste_ready(p.id); + if !(ready || now || Instant::now() >= p.until) { + return; + } + let Some(p) = self.pending.take() else { return }; + if !ready { + link.live.clipboard.give_up(p.id); + } + self.clipboard_chord(link, Edit::Paste); + for item in p.queued { + self.dispatch(link, item); + } + } + + /// Send a pointer position in the picture, held to its edges. + 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, + ); + } + + /// Send the whole pixels of captured movement, keeping the fraction. + fn flush_motion(&mut self, out: &(impl Sink + ?Sized)) { + if self.captured && (self.motion.0.abs() >= 1.0 || self.motion.1.abs() >= 1.0) { + let dx = self.motion.0.trunc(); + let dy = self.motion.1.trunc(); + self.motion.0 -= dx; + self.motion.1 -= dy; + out.motion(dx as i16, dy as i16); + } + } + + /// 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 { + cfg.cmd_is_ctrl && !self.host_mac } /// What ⌘ stands for in a clipboard shortcut. fn clipboard_modifier(&self, cfg: &ClientConfig) -> i16 { - if cfg!(target_os = "macos") && !cfg.cmd_is_ctrl { + if self.host_mac || (cfg!(target_os = "macos") && !cfg.cmd_is_ctrl) { input::VK_LWIN } else { input::VK_CONTROL @@ -1260,20 +1519,39 @@ impl View { /// egui swallows Cmd/Ctrl+C/X/V into clipboard events; replay them as /// the key chord the PC expects. - fn clipboard_chord(&mut self, live: &Live, edit: Edit, cfg: &ClientConfig) { - let (keys, alone) = live.shortcuts.keys( + fn clipboard_chord(&mut self, link: &Link<'_, S>, edit: Edit) { + let (keys, alone) = link.live.shortcuts.keys( edit, - self.clipboard_modifier(cfg), - live.clipboard.pastes_to_selection(), + self.clipboard_modifier(link.cfg), + link.live.clipboard.pastes_to_selection(), ); if alone { - self.held.chord_alone(&live.input, &keys); + self.held.chord_alone(link.out, &keys); } else { - self.held.chord(&live.input, &keys); + self.held.chord(link.out, &keys); } } } +/// Whether a V let go was a ⌘V egui swallowed: the PC never saw V go +/// down, no paste came from it, and ⌘ was down since the last V. +fn swallowed_paste(v_seen_down: bool, pasted: bool, cmd: bool) -> bool { + !v_seen_down && !pasted && cmd +} + +/// 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 +/// Linux's, is positive to the right. Vertical agrees: +y is up. +fn wheel_to_host(unit: egui::MouseWheelUnit, delta: Vec2) -> (f32, f32) { + let scale = match unit { + egui::MouseWheelUnit::Point => 3.0, + egui::MouseWheelUnit::Line => 120.0, + egui::MouseWheelUnit::Page => 1200.0, + }; + (-delta.x * scale, delta.y * scale) +} + /// Whether a pointer in the picture 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. @@ -2228,4 +2506,514 @@ 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, + }; + use egui::{Key, PointerButton}; + use latch_stream::ffi::{BUTTON_LEFT, MODIFIER_CTRL, MODIFIER_SHIFT}; + use std::rc::Rc; + + const VK_T: i16 = 0x54; + const VK_W: i16 = 0x57; + const VK_Y: i16 = 0x59; + const VK_Z: i16 = 0x5A; + const CMD: Modifiers = Modifiers { + mac_cmd: true, + command: true, + ..Modifiers::NONE + }; + const CTRL_ALT: Modifiers = Modifiers { + ctrl: true, + alt: true, + ..Modifiers::NONE + }; + /// On the picture, clear of the card shown before the first picture. + const ON_PICTURE: Pos2 = Pos2::new(20.0, 200.0); + + /// A stream view at the smallest window whose input goes to a + /// recording instead of the PC, with the other machine running `os`. + fn recording_on( + view: View, + connected: bool, + os: &str, + ) -> (egui_kittest::Harness<'static, Fixture>, Rc) { + let tap = Rc::new(Recorder::default()); + tap.connected.set(connected); + let view = View { + tap: Some(tap.clone()), + ..view + }; + let extra = Extra { + os: os.into(), + ..Default::default() + }; + let h = harness_with(Vec2::new(640.0, 420.0), 1.0, false, fixture(view, extra)); + tap.take(); + (h, tap) + } + + fn recording( + view: View, + connected: bool, + ) -> (egui_kittest::Harness<'static, Fixture>, Rc) { + recording_on(view, connected, "windows") + } + + fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { + key, + physical_key: Some(key), + pressed, + repeat: false, + modifiers, + } + } + + fn click(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + } + } + + /// One frame with these events, ending with `end` held. + fn frame(h: &mut egui_kittest::Harness<'_, Fixture>, events: Vec, end: Modifiers) { + h.input_mut().events.extend(events); + h.input_mut().modifiers = end; + h.run_steps(1); + } + + #[test] + fn a_shift_let_go_in_the_same_frame_still_capitalises() { + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![ + key(Key::T, true, Modifiers::SHIFT), + key(Key::T, false, Modifiers::SHIFT), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Key(VK_SHIFT, true, MODIFIER_SHIFT), + Sent::Key(VK_T, true, MODIFIER_SHIFT), + Sent::Key(VK_T, false, MODIFIER_SHIFT), + Sent::Key(VK_SHIFT, false, 0), + ] + ); + // A key pressed before Shift in the same frame stays lowercase. + frame( + &mut h, + vec![key(Key::Y, true, Modifiers::NONE)], + Modifiers::SHIFT, + ); + assert_eq!(tap.keys(), [(VK_Y, true), (VK_SHIFT, true)]); + } + + #[test] + fn command_z_with_command_let_go_in_the_same_frame_is_still_undo() { + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![key(Key::Z, true, CMD)], Modifiers::NONE); + assert_eq!( + tap.take(), + [ + Sent::Key(VK_CONTROL, true, MODIFIER_CTRL), + Sent::Key(VK_Z, true, MODIFIER_CTRL), + Sent::Key(VK_CONTROL, false, 0), + ] + ); + } + + #[test] + fn a_mac_there_gets_command_as_command() { + let (mut h, tap) = recording_on(View::default(), true, "macOS"); + assert!(h.state().cfg.cmd_is_ctrl, "the default, for Windows"); + frame(&mut h, vec![key(Key::Z, true, CMD)], CMD); + assert_eq!(tap.keys(), [(VK_LWIN, true), (VK_Z, true)]); + // Copy is the Command chord there too. + frame(&mut h, vec![Event::Copy], CMD); + assert_eq!(tap.keys(), [(input::VK_C, true), (input::VK_C, false)]); + assert!(is_mac_os("macOS") && is_mac_os("darwin") && is_mac_os("MacOS")); + assert!(!is_mac_os("windows") && !is_mac_os("linux") && !is_mac_os("")); + } + + #[test] + fn command_plus_zooms_the_pc_not_latch() { + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![key(Key::Equals, true, CMD)], CMD); + // A new zoom takes effect on the next frame. + h.run_steps(1); + assert_eq!(h.ctx.zoom_factor(), 1.0); + assert!(tap.keys().contains(&(0xBB, true))); + // Without the stream's keys, egui's zoom is back. + h.state_mut().view.settings = Some(ClientConfig::default().stream); + h.run_steps(1); + frame(&mut h, vec![key(Key::Equals, true, CMD)], CMD); + h.run_steps(1); + assert!(h.ctx.zoom_factor() > 1.0); + } + + #[test] + fn the_host_key_never_reaches_the_pc_even_inside_one_frame() { + let (mut h, tap) = recording(View::default(), true); + // Ctrl+Alt+T pressed and Alt let go before the frame was drawn. + frame(&mut h, vec![key(Key::T, true, CTRL_ALT)], Modifiers::CTRL); + let keys = tap.keys(); + assert!(!keys.contains(&(VK_T, true)), "{keys:?}"); + assert!(!keys.iter().any(|&(k, _)| k == VK_MENU), "{keys:?}"); + } + + #[test] + fn scrolling_sideways_scrolls_the_pc_the_same_way() { + use egui::MouseWheelUnit::{Line, Page, Point}; + assert_eq!(wheel_to_host(Line, Vec2::new(1.0, 0.0)), (-120.0, 0.0)); + assert_eq!(wheel_to_host(Line, Vec2::new(0.0, 1.0)), (0.0, 120.0)); + assert_eq!(wheel_to_host(Point, Vec2::new(-2.0, -2.0)), (6.0, -6.0)); + assert_eq!(wheel_to_host(Page, Vec2::new(0.5, 0.0)), (-600.0, 0.0)); + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![ + Event::PointerMoved(ON_PICTURE), + Event::MouseWheel { + unit: Line, + delta: Vec2::new(1.0, 0.0), + modifiers: Modifiers::NONE, + }, + ], + Modifiers::NONE, + ); + let sent = tap.take(); + assert!(sent.contains(&Sent::Scroll(0, -120)), "{sent:?}"); + } + + #[test] + fn a_click_goes_where_the_pointer_was_when_it_was_made() { + let (mut h, tap) = recording(View::default(), true); + h.state_mut().cfg.capture_mouse = false; + // 1920×1080 in 640×420: the picture is 640×360, 30 points down. + let elsewhere = Pos2::new(300.0, 100.0); + frame( + &mut h, + vec![ + Event::PointerMoved(ON_PICTURE), + click(ON_PICTURE, true), + Event::PointerMoved(elsewhere), + click(elsewhere, false), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Position(20, 170, 640, 360), + Sent::Button(BUTTON_LEFT, true), + Sent::Position(300, 70, 640, 360), + Sent::Button(BUTTON_LEFT, false), + ] + ); + assert!(!h.state().view.captured, "free mode"); + } + + #[test] + fn a_captured_click_comes_after_the_movement_before_it() { + let view = View { + captured: true, + grabbed: true, + ..View::default() + }; + let (mut h, tap) = recording(view, true); + // 1920 stream pixels across 640 points: three per point. + frame( + &mut h, + vec![ + Event::MouseMoved(Vec2::new(3.5, 0.0)), + click(ON_PICTURE, true), + Event::MouseMoved(Vec2::new(1.0, 1.0)), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Motion(10, 0), + Sent::Button(BUTTON_LEFT, true), + Sent::Motion(3, 3), + ] + ); + } + + #[test] + fn a_click_that_captures_the_mouse_sends_its_position_first() { + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![Event::PointerMoved(ON_PICTURE), click(ON_PICTURE, true)], + Modifiers::NONE, + ); + assert_eq!( + tap.take(), + [ + Sent::Position(20, 170, 640, 360), + Sent::Button(BUTTON_LEFT, true), + ] + ); + assert!(h.state().view.captured); + } + + #[test] + fn a_click_before_the_stream_connects_does_not_take_the_mouse() { + let (mut h, tap) = recording(View::default(), false); + frame( + &mut h, + vec![ + Event::PointerMoved(ON_PICTURE), + click(ON_PICTURE, true), + click(ON_PICTURE, false), + ], + Modifiers::NONE, + ); + h.run_steps(1); + assert!(!h.state().view.captured && !h.state().view.grabbed); + assert!(h.query_by_label("Cancel").is_some()); + // A capture left over from a dropped connection: the host key + // still lets the mouse go. + h.state_mut().view.captured = true; + frame(&mut h, vec![], CTRL_ALT); + assert!(!h.state().view.captured); + let sent = tap.take(); + assert!( + !sent.iter().any(|s| matches!(s, Sent::Button(..))), + "{sent:?}" + ); + } + + #[test] + fn the_host_key_lets_go_of_what_was_held() { + let (mut h, tap) = recording(view_with_bar(), true); + h.state_mut().cfg.capture_mouse = false; + frame( + &mut h, + vec![key(Key::W, true, Modifiers::NONE)], + Modifiers::NONE, + ); + assert_eq!(tap.keys(), [(VK_W, true)]); + // The bar hides; W is let go while the chord is still down. + frame(&mut h, vec![key(Key::W, false, CTRL_ALT)], CTRL_ALT); + assert_eq!(tap.keys(), [(VK_W, false)]); + assert!(!h.state().view.held.any_down()); + assert!(!h.state().view.bar_shown); + // Control let go before Option: no lone Option reaches the PC. + frame(&mut h, vec![], Modifiers::ALT); + frame(&mut h, vec![], Modifiers::NONE); + assert!(tap.keys().is_empty()); + } + + #[test] + fn the_host_key_never_captures_the_mouse_under_an_open_panel() { + let view = View { + settings: Some(ClientConfig::default().stream), + ..view_with_bar() + }; + let (mut h, _tap) = recording(view, true); + frame(&mut h, vec![], CTRL_ALT); + let v = &h.state().view; + assert!(v.captured && !v.bar_shown); + assert!(v.settings.is_none(), "closed, as Cancel"); + let view = View { + confirm: Some(PowerAction::Restart), + ..view_with_bar() + }; + let (mut h, _tap) = recording(view, true); + frame(&mut h, vec![], CTRL_ALT); + assert!(h.state().view.confirm.is_none() && h.state().view.captured); + } + + #[test] + fn a_paste_goes_before_what_was_typed_after_it() { + // Nothing to send first (an image on the Mac): at once, in order. + let (mut h, tap) = recording(View::default(), true); + frame( + &mut h, + vec![ + Event::Paste(String::new()), + key(Key::Enter, true, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert_eq!( + tap.keys(), + [ + (VK_CONTROL, true), + (VK_V, true), + (VK_V, false), + (VK_CONTROL, false), + (VK_RETURN, true), + ] + ); + // Text on its way: Return waits for it, then follows the paste. + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![], CMD); + assert_eq!(tap.keys(), [(VK_CONTROL, true)]); + h.state_mut().view.pending = Some(PendingPaste { + id: 1, + until: Instant::now() + Duration::from_secs(60), + queued: Vec::new(), + }); + // ⌘ let go, then Return, while the text travels. + frame(&mut h, vec![], Modifiers::NONE); + frame( + &mut h, + vec![ + key(Key::Enter, true, Modifiers::NONE), + key(Key::Enter, false, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert!(tap.take().is_empty(), "nothing overtakes the paste"); + h.state() + .live + .as_ref() + .unwrap() + .clipboard + .finish_for_test(1); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!( + tap.keys(), + [ + (VK_V, true), + (VK_V, false), + (VK_CONTROL, false), + (VK_RETURN, true), + (VK_RETURN, false), + ] + ); + assert!(h.state().view.pending.is_none()); + } + + #[test] + fn a_paste_that_waits_too_long_goes_once_and_losing_focus_drops_it() { + let (mut h, tap) = recording(View::default(), true); + h.state_mut().view.pending = Some(PendingPaste { + id: 7, + until: Instant::now(), + queued: vec![Queued::Event( + key(Key::Enter, true, Modifiers::NONE), + At { + keys_to_pc: true, + over_video: false, + }, + )], + }); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!( + tap.keys(), + [ + (VK_CONTROL, true), + (VK_V, true), + (VK_V, false), + (VK_CONTROL, false), + (VK_RETURN, true), + ] + ); + frame(&mut h, vec![], Modifiers::NONE); + assert_eq!(tap.keys(), [], "and only once"); + // Focus goes while a paste waits: it goes, unsent, with what was + // held behind it; what the PC holds is let go. + h.state_mut().view.pending = Some(PendingPaste { + id: 8, + until: Instant::now() + Duration::from_secs(60), + queued: Vec::new(), + }); + frame( + &mut h, + vec![key(Key::A, true, Modifiers::NONE)], + Modifiers::NONE, + ); + frame(&mut h, vec![Event::WindowFocused(false)], Modifiers::NONE); + assert!(h.state().view.pending.is_none()); + assert_eq!(tap.keys(), [(VK_RETURN, false)]); + } + + #[test] + fn command_v_pastes_even_with_no_text_on_the_mac() { + // egui swallows ⌘V's press when the Mac's clipboard has no text; + // only the V let go shows. + let (mut h, tap) = recording(View::default(), true); + frame(&mut h, vec![], CMD); + assert_eq!(tap.keys(), [(VK_CONTROL, true)]); + frame(&mut h, vec![key(Key::V, false, CMD)], CMD); + assert_eq!(tap.keys(), [(VK_V, true), (VK_V, false)]); + // With text, the paste event comes and the V let go adds nothing. + frame( + &mut h, + vec![Event::Paste(String::new()), key(Key::V, false, CMD)], + CMD, + ); + assert_eq!(tap.keys(), [(VK_V, true), (VK_V, false)]); + // A plain V is typed as ever. + frame(&mut h, vec![], Modifiers::NONE); + tap.take(); + frame( + &mut h, + vec![ + key(Key::V, true, Modifiers::NONE), + key(Key::V, false, Modifiers::NONE), + ], + Modifiers::NONE, + ); + assert_eq!(tap.keys(), [(VK_V, true), (VK_V, false)]); + for (seen, pasted, cmd) in [ + (true, false, true), + (false, true, true), + (false, false, false), + ] { + assert!(!swallowed_paste(seen, pasted, cmd)); + } + assert!(swallowed_paste(false, false, true)); + } + + #[test] + fn tab_in_free_mode_goes_to_the_pc_with_the_stats_panel_open() { + let view = View { + stats: true, + ..View::default() + }; + let (mut h, tap) = recording(view, true); + h.state_mut().cfg.capture_mouse = false; + h.state() + .live + .as_ref() + .unwrap() + .frames + .publish(latch_stream::video::Frame::default()); + h.run_steps(2); + tap.take(); + for _ in 0..3 { + h.press_key(Key::Tab); + h.run_steps(1); + assert!(h.ctx.memory(|m| m.focused()).is_none()); + } + let tabs = tap + .keys() + .into_iter() + .filter(|&k| k == (VK_TAB, true)) + .count(); + assert_eq!(tabs, 3); + // Focus taken in the Stats panel while the toolbar showed goes + // when the toolbar does. + h.state_mut().view.bar_shown = true; + h.run_steps(2); + tab_to(&mut h, "Copy diagnostics"); + h.state_mut().view.bar_shown = false; + h.run_steps(2); + assert!(h.ctx.memory(|m| m.focused()).is_none()); + } } From f094e315a8e95a25ed48ef6fa0900f0a20aaad81 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:14:39 -0700 Subject: [PATCH 14/34] Power: refuse Sleep where nothing can wake the machine, say why Mac restarts fail, keep a shared Mac awake - One click on Sleep for stuzha (an OCI KVM guest, s2idle, no wake source, polkit allowing it) would have taken Tailscale, SSH, Latch and the Hermes agent down until someone reset the VM from the cloud console. The host now refuses Sleep on a Linux VM and on a Mac (Latch has no wake path to either), refuses every action in a container (no systemd), and asks logind before ending anyone's stream. Restart and Shut down stay, behind power_allowed as before. A refusal comes back as an Ack with the reason, which every Mac client shows as it is. - On a Mac, Restart and Shut down go through System Events, which needs an Automation permission macOS asks for on the Mac's own screen; the Mac that asked was told it worked. osascript now runs before the reply, at most 3 s, and its failure is reported in words (not allowed, nobody answered the prompt, an app cancelled). Failures of the actions that run after the reply reach the service log instead of only stderr. - keep_awake did nothing off Windows while the log said "keeping this PC awake" at every start, on stuzha and on a shared MacBook that went on to idle-sleep off the tailnet. A shared Mac now holds a PreventUserIdleSystemSleep assertion while plugged in (not PreventSystemSleep, which turns a requested sleep into dark wake), the switch shows on the Mac's Sharing page, and the log speaks only when it starts or stops being true. - The wake-packet listener ran everywhere but can only match a MAC on Windows: it held UDP 9 on every interface of the Mac and logged a permission error at every start on Linux. It is Windows-only now, and the Sharing page no longer shows "LAN address None" where none is known. Co-Authored-By: Claude Opus 5.5 --- crates/host/src/app.rs | 82 +++++++-- crates/host/src/power.rs | 351 +++++++++++++++++++++++++++++++++++-- crates/host/src/service.rs | 83 +++++++-- 3 files changed, 467 insertions(+), 49 deletions(-) diff --git a/crates/host/src/app.rs b/crates/host/src/app.rs index 3a52f9f..e9e0fa8 100644 --- a/crates/host/src/app.rs +++ b/crates/host/src/app.rs @@ -797,10 +797,14 @@ impl HostApp { } rows.push(Kv::new("Wake-on-LAN", wake).tone(tone)); } - rows.push(Kv::new( - "LAN address", - s.lan_ip.clone().unwrap_or_else(|| "None".into()), - )); + // Only Windows reports one (with its wake adapter), and there "None" + // is worth seeing; elsewhere it only looked like something missing. + if self.os == Os::Windows || s.lan_ip.is_some() { + rows.push(Kv::new( + "LAN address", + s.lan_ip.clone().unwrap_or_else(|| "None".into()), + )); + } if self.os == Os::Windows { let (tone, text) = match gamepad { Some(true) => (Tone::Success, "Virtual controller driver installed"), @@ -919,16 +923,25 @@ impl HostApp { ) { self.dirty = true; } - if self.os == Os::Windows { + if self.os != Os::Linux { + // A Linux server does not idle-sleep; there it would do nothing. + let (title, detail) = if self.os == Os::Mac { + ( + "Keep this Mac awake while plugged in", + "Asleep, Tailscale is off too, and no other machine can wake it. Closing the lid or choosing Sleep still sleeps it.", + ) + } else { + ( + "Keep this PC awake while plugged in", + "Asleep, Tailscale is off too, so machines on other networks can't wake it. Sleep from the Start menu or another machine still works.", + ) + }; ui::row_separator(ui); - if ui::toggle_row( - ui, - &mut self.cfg.stay_awake, - "Keep this PC awake while plugged in", - Some("Asleep, Tailscale is off too, so machines on other networks can't wake it. Sleep from the Start menu or another machine still works."), - ) { + if ui::toggle_row(ui, &mut self.cfg.stay_awake, title, Some(detail)) { self.dirty = true; } + } + if self.os == Os::Windows { ui::row_separator(ui); if ui::toggle_row( ui, @@ -1408,6 +1421,7 @@ mod snapshots { } fn ready_status(os: &str) -> Status { + let windows = os == "windows"; Status { app: latch_core::APP_ID.into(), version: env!("CARGO_PKG_VERSION").into(), @@ -1419,13 +1433,22 @@ mod snapshots { os: os.into(), tailscale_ip: Some("100.64.0.10".into()), tailscale_login: Some("user@example.com".into()), - lan_ip: Some("192.168.1.10".into()), - mac: Some("02:00:00:00:00:01".into()), - wake_ready: Some(true), - wake_adapter: "Ethernet".into(), - wake_adapter_description: "Example NIC".into(), - wake_packet_age_secs: Some(42), - fast_startup: Some(false), + // Only a Windows PC reports its wake adapter. + lan_ip: windows.then(|| "192.168.1.10".into()), + mac: windows.then(|| "02:00:00:00:00:01".into()), + wake_ready: windows.then_some(true), + wake_adapter: if windows { + "Ethernet".into() + } else { + String::new() + }, + wake_adapter_description: if windows { + "Example NIC".into() + } else { + String::new() + }, + wake_packet_age_secs: windows.then_some(42), + fast_startup: windows.then_some(false), streamer: Streamer { kind: latch_core::STREAMER_KIND.into(), installed: true, @@ -1682,9 +1705,30 @@ mod snapshots { ); assert!(h.query_by_label("Wake-on-LAN").is_none()); assert!(h.query_by_label("Game controllers").is_none()); + assert!( + h.query_by_label("LAN address").is_none(), + "a Mac has none to show" + ); + assert!(h.query_by_label_contains("Install updates").is_none()); + assert!( + h.query_all_by_label("Keep this Mac awake while plugged in") + .next() + .is_some(), + "a shared Mac holds a power assertion while plugged in" + ); + let h = build( + Shared { + status: Some(ready_status("linux")), + ..Default::default() + }, + Os::Linux, + tall, + 1.0, + false, + ); assert!( h.query_by_label_contains("Keep this").is_none(), - "keeping awake is Windows-only; the switch would do nothing on a Mac" + "a Linux server does not idle-sleep; the switch would do nothing" ); } diff --git a/crates/host/src/power.rs b/crates/host/src/power.rs index f2d830a..aa9b444 100644 --- a/crates/host/src/power.rs +++ b/crates/host/src/power.rs @@ -1,13 +1,20 @@ -//! Remote power actions, and keeping the PC from idle-sleeping. +//! Remote power actions, and keeping the machine from idle-sleeping. use anyhow::Result; use latch_core::api::PowerAction; +#[cfg(target_os = "macos")] +use std::time::{Duration, Instant}; -/// Prevent Windows from idle-sleeping while Latch Host is running, so -/// Tailscale stays up. User-initiated Sleep (Start menu or the Mac) still -/// works. Call this from the background service, not the control panel: -/// the execution state is per-thread, and `refresh_loop` is the one caller. -pub fn keep_awake(on: bool) { +/// Keep the machine from idle-sleeping while the service runs, so Tailscale +/// stays up, but only while it is plugged in. Sleep the owner asks for +/// (the Start menu, the Apple menu, the lid) still works. Returns whether +/// the machine is now kept awake, so the log says so only when it is. +/// +/// Windows: an execution-state request, which belongs to the calling thread; +/// call it from the background service's `refresh_loop`, its one caller. +/// macOS: a PreventUserIdleSystemSleep assertion, which belongs to the +/// process and goes with it. Elsewhere nothing: a server does not idle-sleep. +pub fn keep_awake(on: bool) -> bool { #[cfg(windows)] { use windows::Win32::System::Power::{ @@ -27,17 +34,230 @@ pub fn keep_awake(on: bool) { } else { SetThreadExecutionState(ES_CONTINUOUS); } + on && plugged_in } } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + { + mac::keep_awake(on && mac::on_ac_power()) + } + #[cfg(not(any(windows, target_os = "macos")))] { let _ = on; + false + } +} + +/// IOKit's power assertions and power source, without a crate for them. +#[cfg(target_os = "macos")] +mod mac { + use std::ffi::{c_char, c_void, CStr, CString}; + use std::sync::Mutex; + + type CFTypeRef = *const c_void; + type CFStringRef = *const c_void; + const UTF8: u32 = 0x0800_0100; // kCFStringEncodingUTF8 + const ASSERTION_ON: u32 = 255; // kIOPMAssertionLevelOn + + #[link(name = "IOKit", kind = "framework")] + extern "C" { + fn IOPSCopyPowerSourcesInfo() -> CFTypeRef; + fn IOPSGetProvidingPowerSourceType(snapshot: CFTypeRef) -> CFStringRef; + fn IOPMAssertionCreateWithName( + kind: CFStringRef, + level: u32, + name: CFStringRef, + id: *mut u32, + ) -> i32; + fn IOPMAssertionRelease(id: u32) -> i32; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFRelease(cf: CFTypeRef); + fn CFStringCreateWithCString( + alloc: *const c_void, + s: *const c_char, + encoding: u32, + ) -> CFStringRef; + fn CFStringGetCString(s: CFStringRef, buf: *mut c_char, size: isize, encoding: u32) -> u8; + } + + /// The assertion this process holds, if any. + static HELD: Mutex> = Mutex::new(None); + + /// Hold the assertion when `want`, release it otherwise. Returns + /// whether it is held. + pub fn keep_awake(want: bool) -> bool { + let mut held = HELD.lock().unwrap_or_else(|e| e.into_inner()); + match (*held, want) { + (Some(_), true) => true, + (None, false) => false, + (Some(id), false) => { + unsafe { IOPMAssertionRelease(id) }; + *held = None; + false + } + (None, true) => { + // Not PreventSystemSleep (`caffeinate -s`): that one keeps + // the Mac in dark wake when someone asks it to sleep. + let (Some(kind), Some(name)) = ( + cf_string("PreventUserIdleSystemSleep"), + cf_string("Latch: keep this Mac reachable while it is shared"), + ) else { + return false; + }; + let mut id = 0u32; + let ok = unsafe { IOPMAssertionCreateWithName(kind, ASSERTION_ON, name, &mut id) }; + unsafe { + CFRelease(kind); + CFRelease(name); + } + if ok == 0 { + *held = Some(id); + } + held.is_some() + } + } + } + + /// The Mac draws from a power adapter (or a UPS), not its battery. + pub fn on_ac_power() -> bool { + unsafe { + let info = IOPSCopyPowerSourcesInfo(); + if info.is_null() { + // A Mac with no power-source information is a desktop. + return true; + } + let source = IOPSGetProvidingPowerSourceType(info); + let mut buf = [0 as c_char; 64]; + let read = !source.is_null() + && CFStringGetCString(source, buf.as_mut_ptr(), buf.len() as isize, UTF8) != 0; + CFRelease(info); + !read || CStr::from_ptr(buf.as_ptr()).to_bytes() != b"Battery Power" + } } + + fn cf_string(s: &str) -> Option { + let c = CString::new(s).ok()?; + let r = unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) }; + (!r.is_null()).then_some(r) + } +} + +/// What kind of machine this is, as far as remote power goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// A Windows PC: Wake-on-LAN brings it back. + Pc, + /// A Mac: nothing in Latch can wake it once it sleeps. + Mac, + /// A Linux machine on its own hardware: someone at it can wake it. + Linux, + /// A Linux virtual machine (a cloud VPS): asleep, it has no wake + /// source but its provider's console. + LinuxVm, + /// A container (the Docker node): there is no systemd to sleep, + /// restart or power off anything. + Container, +} + +/// This machine's [`Kind`], looked at once. +pub fn kind() -> Kind { + static KIND: std::sync::OnceLock = std::sync::OnceLock::new(); + *KIND.get_or_init(|| { + if cfg!(windows) { + Kind::Pc + } else if cfg!(target_os = "macos") { + Kind::Mac + } else { + let exists = |p: &str| std::path::Path::new(p).exists(); + let vm = std::process::Command::new("systemd-detect-virt") + .args(["--vm", "--quiet"]) + .status() + .is_ok_and(|s| s.success()); + linux_kind( + exists("/run/systemd/system"), + exists("/.dockerenv") || exists("/run/.containerenv"), + vm, + ) + } + }) +} + +fn linux_kind(systemd: bool, container: bool, vm: bool) -> Kind { + if container || !systemd { + Kind::Container + } else if vm { + Kind::LinuxVm + } else { + Kind::Linux + } +} + +/// Why `name`, a machine of `kind`, will not do `action` for another +/// machine; `None` when it will. Sleep is refused where nothing can wake +/// the machine again: it would drop off the tailnet until someone reached +/// it by hand (stuzha, an OCI VM in s2idle, would need the cloud console). +/// Shut down stays: the owner allowed it, and the Mac asks first. +pub fn refusal(kind: Kind, action: PowerAction, name: &str) -> Option { + match (kind, action) { + (Kind::Container, _) => Some(format!( + "{name} runs in a container, which cannot sleep, restart or shut down the machine it runs on" + )), + (Kind::LinuxVm, PowerAction::Sleep) => Some(format!( + "{name} is a virtual machine: asleep, only its provider's console could wake it. Restart it instead" + )), + (Kind::Mac, PowerAction::Sleep) => Some(format!( + "Latch cannot wake {name} once it sleeps, so it does not put it to sleep from here. Sleep it at the Mac" + )), + _ => None, + } +} + +/// On Linux, whether logind lets this service do `action`: `Some(answer)` +/// when it says anything but "yes" ("challenge" needs a password nobody +/// will type, "no" is never). `None` when it agrees or cannot be asked. +pub fn logind_refuses(action: PowerAction) -> Option { + if !cfg!(target_os = "linux") { + return None; + } + let method = match action { + PowerAction::Sleep => "CanSuspend", + PowerAction::Restart => "CanReboot", + PowerAction::Shutdown => "CanPowerOff", + }; + let out = std::process::Command::new("busctl") + .args([ + "--system", + "call", + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + method, + ]) + .output() + .ok() + .filter(|o| o.status.success())?; + let reply = String::from_utf8_lossy(&out.stdout); + let answer = logind_answer(&reply)?; + (answer != "yes").then(|| answer.to_string()) +} + +/// `s "yes"`, busctl's way of printing a string reply, as `yes`. +fn logind_answer(reply: &str) -> Option<&str> { + reply + .trim() + .strip_prefix("s ")? + .trim() + .strip_prefix('"')? + .strip_suffix('"') } /// Carry out `action`. Sleep returns once the request is accepted (the OS /// suspends a moment later); restart and shutdown schedule themselves a few -/// seconds out so the reply has left the machine. +/// seconds out so the reply has left the machine. On a Mac, restart and +/// shutdown go through System Events and say why when macOS will not. pub fn perform(action: PowerAction) -> Result<()> { #[cfg(windows)] { @@ -108,14 +328,7 @@ fn unix_perform(action: PowerAction) -> Result<()> { } else { "shut down" }; - let status = std::process::Command::new("osascript") - .args([ - "-e", - &format!("tell application \"System Events\" to {apple}"), - ]) - .status()?; - anyhow::ensure!(status.success(), "osascript {apple} failed"); - Ok(()) + system_events(apple, Duration::from_secs(3)) } #[cfg(target_os = "linux")] { @@ -133,3 +346,109 @@ fn unix_perform(action: PowerAction) -> Result<()> { } } } + +/// Ask System Events to `verb` the Mac, waiting at most `limit`. Sending it +/// Apple events needs the Automation permission, which macOS asks for on +/// the Mac's own screen, where nobody may be; and an app with unsaved work +/// can cancel the restart. Either way the Mac stays on, so say so. +#[cfg(target_os = "macos")] +fn system_events(verb: &str, limit: Duration) -> Result<()> { + use std::process::{Command, Stdio}; + let mut child = Command::new("osascript") + .args([ + "-e", + &format!("tell application \"System Events\" to {verb}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn()?; + let start = Instant::now(); + loop { + if child.try_wait()?.is_some() { + break; + } + if start.elapsed() >= limit { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("{}", osascript_failure("-1712")); + } + std::thread::sleep(Duration::from_millis(100)); + } + let out = child.wait_with_output()?; + anyhow::ensure!( + out.status.success(), + "{}", + osascript_failure(&String::from_utf8_lossy(&out.stderr)) + ); + Ok(()) +} + +/// What an osascript error means for the person who asked. +#[cfg(any(target_os = "macos", test))] +fn osascript_failure(stderr: &str) -> String { + const SETTINGS: &str = "System Settings > Privacy & Security > Automation"; + if stderr.contains("-1743") { + format!("Latch is not allowed to control System Events on this Mac; allow it in {SETTINGS}") + } else if stderr.contains("-1712") { + format!("macOS is asking on the Mac's screen whether Latch may control System Events, and nobody answered; allow it in {SETTINGS}") + } else if stderr.contains("-128") { + "an app on the Mac cancelled it, likely over unsaved work".into() + } else { + format!( + "osascript: {}", + stderr.trim().lines().next().unwrap_or("failed") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sleep_is_refused_where_nothing_could_wake_the_machine() { + use PowerAction::*; + assert!(refusal(Kind::Pc, Sleep, "PC").is_none()); + assert!(refusal(Kind::Linux, Sleep, "box").is_none()); + let vm = refusal(Kind::LinuxVm, Sleep, "stuzha").unwrap(); + assert!(vm.contains("stuzha is a virtual machine"), "{vm}"); + assert!(refusal(Kind::LinuxVm, Restart, "stuzha").is_none()); + assert!(refusal(Kind::LinuxVm, Shutdown, "stuzha").is_none()); + assert!(refusal(Kind::Mac, Sleep, "MacBook").is_some()); + assert!(refusal(Kind::Mac, Restart, "MacBook").is_none()); + for a in [Sleep, Restart, Shutdown] { + assert!(refusal(Kind::Container, a, "node").is_some()); + } + assert_eq!(linux_kind(true, false, true), Kind::LinuxVm); + assert_eq!(linux_kind(true, false, false), Kind::Linux); + assert_eq!(linux_kind(false, false, false), Kind::Container); + assert_eq!(linux_kind(true, true, false), Kind::Container); + } + + /// IOKit is reached without a crate; this is the check that the + /// declarations match it. The assertion is held for a moment only. + #[cfg(target_os = "macos")] + #[test] + #[ignore = "takes a real power assertion; run on demand on a Mac"] + fn a_mac_holds_and_lets_go_of_its_assertion() { + let _ = mac::on_ac_power(); + assert!(mac::keep_awake(true)); + assert!(mac::keep_awake(true), "held once, not again"); + assert!(!mac::keep_awake(false)); + } + + #[test] + fn logind_and_osascript_answers_are_read() { + assert_eq!(logind_answer("s \"yes\"\n"), Some("yes")); + assert_eq!(logind_answer("s \"challenge\""), Some("challenge")); + assert_eq!(logind_answer("garbage"), None); + assert!(osascript_failure( + "0:46: execution error: Not authorized to send Apple events to System Events. (-1743)" + ) + .contains("not allowed")); + assert!(osascript_failure("execution error: User canceled. (-128)").contains("cancelled")); + assert!(osascript_failure("-1712").contains("nobody answered")); + assert_eq!(osascript_failure("boom\nmore"), "osascript: boom"); + } +} diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 8015277..995ba4e 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -20,8 +20,8 @@ use crate::update; use crate::wake::{self, WakeInfo}; use anyhow::{Context, Result}; use latch_core::api::{ - Ack, Clipboard, DisplayRequest, NatReport, PinRequest, PowerRequest, Status, Streamer, - CLIPBOARD_PATH, UPDATE_PATH, + Ack, Clipboard, DisplayRequest, NatReport, PinRequest, PowerAction, PowerRequest, Status, + Streamer, CLIPBOARD_PATH, UPDATE_PATH, }; use latch_core::http::{self, Request, Response}; use latch_core::tailscale::{self, is_tailnet_ip}; @@ -78,6 +78,8 @@ pub struct Service { cfg_problem: Mutex>, /// A reinstall was noticed and handing over to it failed. reinstall_tried: AtomicBool, + /// The machine is being kept from idle-sleeping (see `power::keep_awake`). + kept_awake: AtomicBool, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -108,6 +110,7 @@ impl Service { forwarded_logged: Mutex::new(None), cfg_problem: Mutex::new(None), reinstall_tried: AtomicBool::new(false), + kept_awake: AtomicBool::new(false), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), @@ -190,9 +193,15 @@ impl Service { let refresher = self.clone(); std::thread::spawn(move || refresher.refresh_loop()); // (refresh_loop takes the Arc so netcheck can run on its own thread.) - let svc = self.clone(); - if let Err(e) = wake::listen(move |mac, from| svc.wake_packet(mac, from)) { - self.log(format!("not listening for wake packets: {e:#}")); + // Only a Windows PC learns its wake MAC (see `wake::probe`), and + // without one every packet is ignored: elsewhere the listener only + // held UDP 9 on every interface (a Mac) or failed to bind it and + // said so at every start (a Linux user service). + if wants_wake_listener() { + let svc = self.clone(); + if let Err(e) = wake::listen(move |mac, from| svc.wake_packet(mac, from)) { + self.log(format!("not listening for wake packets: {e:#}")); + } } let handler = self.clone(); let authorizer = self.clone(); @@ -237,9 +246,22 @@ impl Service { self.log(line); } let stay_awake = self.cfg.lock().stay_awake; - crate::power::keep_awake(stay_awake); - if tick == 0 && stay_awake { - self.log("keeping this PC awake so Tailscale stays reachable"); + let awake = crate::power::keep_awake(stay_awake); + // Said when it starts and stops being true, and only where it is: + // a Linux server, or a laptop on battery, is not being kept awake. + if awake != self.kept_awake.swap(awake, Ordering::AcqRel) { + self.log(if awake { + format!( + "keeping this {} awake while it is plugged in, so Tailscale stays reachable", + if cfg!(target_os = "macos") { + "Mac" + } else { + "PC" + } + ) + } else { + "no longer keeping this machine awake (on battery, or the setting is off)".into() + }); } let ts = tailscale::status().map_err(|e| e.to_string()); @@ -978,6 +1000,20 @@ impl Service { &Ack::err("the owner of this PC has turned remote power actions off"), ); } + let action = p.action; + let what = action.label().to_lowercase(); + // Refused before the stream is ended for it, and with the reason: + // the Mac shows an answer that is not ok as it is. (Every Mac + // client reads a 200's `ok`; another status only as "HTTP 409".) + let name = self.name.lock().clone(); + let refused = crate::power::refusal(crate::power::kind(), action, &name).or_else(|| { + crate::power::logind_refuses(action) + .map(|answer| format!("this machine's login manager says {answer} to {what}")) + }); + if let Some(why) = refused { + self.log(format!("{what} requested by {from}, refused: {why}")); + return Response::json(200, &Ack::err(why)); + } if cfg.has_creds() { Api { user: &cfg.sunshine_user, @@ -985,16 +1021,24 @@ impl Service { } .close_app(); } - self.log(format!( - "{} requested by {from}", - p.action.label().to_lowercase() - )); + self.log(format!("{what} requested by {from}")); + if cfg!(target_os = "macos") && action != PowerAction::Sleep { + // System Events answers at once, or not without someone at the + // Mac; either way the one who asked hears which. + return match crate::power::perform(action) { + Ok(()) => Response::json(200, &Ack::ok()), + Err(e) => { + self.log(format!("{what} failed: {e:#}")); + Response::json(200, &Ack::err(format!("{e:#}"))) + } + }; + } // Reply first: sleep can suspend the machine before the bytes leave. - let action = p.action; + let log = self.log.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(400)); if let Err(e) = crate::power::perform(action) { - tracing::error!("{}: {e:#}", action.label()); + push_log(&log, format!("{what} failed: {e:#}")); } }); Response::json(200, &Ack::ok()) @@ -1155,6 +1199,12 @@ fn nat_changed(old: &NatReport, new: &NatReport) -> bool { != (new.udp, new.ipv4, new.ipv6, new.hard, new.portmap) } +/// Whether to listen for wake packets: only where `wake::probe` can find +/// this machine's MAC to match them against, which is Windows. +fn wants_wake_listener() -> bool { + cfg!(windows) +} + /// True when a service answers on loopback. pub fn service_alive() -> bool { http::request( @@ -1608,6 +1658,11 @@ mod tests { assert!(!svc.update_running.load(Ordering::Acquire)); } + #[test] + fn only_a_pc_listens_for_wake_packets() { + assert_eq!(wants_wake_listener(), cfg!(windows)); + } + #[test] fn wake_evidence_requires_a_known_matching_adapter() { let svc = Service::new(); From 8653911331be1f858a5dc689eea4d9a9438fa5b0 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:16:36 -0700 Subject: [PATCH 15/34] Setup: leave a systemd-run engine to systemd, open the macOS firewall for Latch - On a native Linux node the engine is latch-engine.service, with the desktop's environment and its CPU priority. Setup pkilled it (a clean exit, which Restart=on-failure does not restart) and started its own copy; run over SSH that copy had no DISPLAY, bound its ports and said "started" while every stream failed. With the unit present, setup now writes the config and restarts the unit (latch.service follows through Requires=), and the service's keep-alive starts the unit rather than a copy beside it. - The macOS firewall blocked incoming connections to the ad-hoc signed Latch.app on this Mac, so other machines could not reach the control service at all (no PIN entry, no status) while its window said "Shared". The service now notices and lists it as a setup item, and setup asks for an administrator to allow Latch through, verifying after. Co-Authored-By: Claude Opus 5.5 --- crates/host/src/service.rs | 16 ++++ crates/host/src/streamer.rs | 11 +++ crates/host/src/unix_setup.rs | 176 ++++++++++++++++++++++++++++++++-- 3 files changed, 197 insertions(+), 6 deletions(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 995ba4e..87eccfc 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -80,6 +80,8 @@ pub struct Service { reinstall_tried: AtomicBool, /// The machine is being kept from idle-sleeping (see `power::keep_awake`). kept_awake: AtomicBool, + /// The macOS firewall turns away connections to this program. + firewall_blocked: AtomicBool, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -111,6 +113,7 @@ impl Service { cfg_problem: Mutex::new(None), reinstall_tried: AtomicBool::new(false), kept_awake: AtomicBool::new(false), + firewall_blocked: AtomicBool::new(false), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), @@ -450,6 +453,16 @@ impl Service { if !cfg!(windows) && tick % 12 == 11 { self.follow_reinstall(); } + #[cfg(target_os = "macos")] + if tick.is_multiple_of(12) { + if let Ok(exe) = std::env::current_exe() { + let blocked = crate::unix_setup::firewall_blocks(&exe); + if blocked && !self.firewall_blocked.swap(blocked, Ordering::AcqRel) { + self.log("the macOS firewall blocks incoming connections to Latch, so other machines cannot reach this Mac; run setup here"); + } + self.firewall_blocked.store(blocked, Ordering::Release); + } + } // What an update retired is kept until the new version has served // for a while: the update's watcher puts it back if it does not. if tick == TIDY_TICK { @@ -561,6 +574,9 @@ impl Service { if wake.fast_startup == Some(true) { setup.push("Fast Startup is on, so the PC cannot be woken after a shutdown.".into()); } + if self.firewall_blocked.load(Ordering::Acquire) { + setup.push("The macOS firewall blocks other machines from reaching Latch.".into()); + } if *self.virtual_display.lock() == Some(false) { setup.push( "The virtual display does not list every screen size a viewer can ask for.".into(), diff --git a/crates/host/src/streamer.rs b/crates/host/src/streamer.rs index 2569e84..d02dd5e 100644 --- a/crates/host/src/streamer.rs +++ b/crates/host/src/streamer.rs @@ -252,6 +252,17 @@ pub fn start(install: &Install) -> Result<()> { if running() { return Ok(()); } + if let Some(unit) = crate::unix_setup::engine_unit() { + // systemd's engine (it stopped, or someone stopped it): start + // the unit, with its environment, rather than a copy beside it. + // --no-block: the unit may wait for the desktop first. + let started = Command::new("systemctl") + .args(["--user", "start", "--no-block", unit]) + .status() + .context("systemctl --user start")?; + anyhow::ensure!(started.success(), "systemctl --user start {unit} failed"); + return Ok(()); + } let conf = install.conf(); let mut c = Command::new(install.exe()); if conf.exists() { diff --git a/crates/host/src/unix_setup.rs b/crates/host/src/unix_setup.rs index 5cec79a..f86e105 100644 --- a/crates/host/src/unix_setup.rs +++ b/crates/host/src/unix_setup.rs @@ -191,7 +191,16 @@ fn run_inner(p: &Plan<'_>, log: &mut Vec) -> Result<()> { install_engine(&dir, log)?; } - stop_engine(); + // A native Linux node (deploy/native, stuzha) runs the engine as a + // systemd user unit, with the desktop's environment and its priority. + // Killing it there stopped the unit for good (a clean exit is no + // failure to restart from), and the copy started here instead lacked + // DISPLAY when setup ran over SSH: every stream failed while the port + // still answered. + let unit = engine_unit(); + if unit.is_none() { + stop_engine(); + } let conf = conf_path()?; let existing = fs::read_to_string(&conf).unwrap_or_default(); let credentials = conf.with_file_name(LOGIN_FILE); @@ -226,18 +235,102 @@ fn run_inner(p: &Plan<'_>, log: &mut Vec) -> Result<()> { step(log, "engine login set"); } - stop_engine(); - std::thread::sleep(Duration::from_millis(400)); - streamer::start(&install).context("start streaming engine")?; - step(log, "streaming engine started"); + if let Some(unit) = unit { + // Through `Requires=`, Latch's own unit restarts with it. + anyhow::ensure!( + systemctl_user(&["restart", unit])?, + "systemctl --user restart {unit} failed" + ); + // Its start may wait for the X screen first. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while !streamer::running() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(250)); + } + anyhow::ensure!(streamer::running(), "{unit} did not start listening"); + step( + log, + format!("restarted the streaming engine's unit, {unit}"), + ); + } else { + stop_engine(); + std::thread::sleep(Duration::from_millis(400)); + streamer::start(&install).context("start streaming engine")?; + step(log, "streaming engine started"); + } if let Ok(exe) = std::env::current_exe() { set_autostart(true, &exe)?; step(log, "Latch starts at login so others can connect"); + #[cfg(target_os = "macos")] + if firewall_blocks(&exe) { + allow_through_firewall(&exe)?; + step(log, "the macOS firewall lets other machines reach Latch"); + } } Ok(()) } +#[cfg(any(target_os = "macos", test))] +const FIREWALL: &str = "/usr/libexec/ApplicationFirewall/socketfilterfw"; + +/// Whether the macOS application firewall turns away connections to +/// `exe`: the control service then looks absent to every other machine +/// (no PIN entry, no status), while the Mac's own window says "Shared". A +/// rule for an ad-hoc signed app can lapse with each new build. +#[cfg(target_os = "macos")] +pub fn firewall_blocks(exe: &Path) -> bool { + let run = |args: &[&str]| { + Command::new(FIREWALL) + .args(args) + .stderr(Stdio::null()) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() + }; + firewall_on(&run(&["--getglobalstate"])) + && app_blocked(&run(&["--getappblocked", &exe.display().to_string()])) +} + +/// "Firewall is enabled. (State = 1)"; 2 is "block all but essentials". +#[cfg(any(target_os = "macos", test))] +fn firewall_on(global: &str) -> bool { + global.contains("State = 1") || global.contains("State = 2") +} + +/// "Incoming connection to is blocked." (or "is permitted."). +#[cfg(any(target_os = "macos", test))] +fn app_blocked(answer: &str) -> bool { + answer.contains("is blocked") +} + +/// Add `exe` to the firewall's list as allowed. That takes an +/// administrator, so macOS asks for the password; setup is something the +/// owner started at the Mac. +#[cfg(target_os = "macos")] +fn allow_through_firewall(exe: &Path) -> Result<()> { + let out = Command::new("osascript") + .args(["-e", &firewall_allow_script(&exe.display().to_string())]) + .stdin(Stdio::null()) + .output() + .context("run osascript")?; + anyhow::ensure!( + out.status.success() && !firewall_blocks(exe), + "the macOS firewall blocks Latch, so other machines cannot reach it ({}). Allow Latch in System Settings > Network > Firewall > Options", + String::from_utf8_lossy(&out.stderr).trim() + ); + Ok(()) +} + +/// The AppleScript that runs `socketfilterfw --add` and `--unblockapp` for +/// `exe` as an administrator. +#[cfg(any(target_os = "macos", test))] +fn firewall_allow_script(exe: &str) -> String { + let exe = exe.replace('\\', "\\\\").replace('"', "\\\""); + format!( + "set exe to \"{exe}\"\ndo shell script \"{FIREWALL} --add \" & quoted form of exe & \" && {FIREWALL} --unblockapp \" & quoted form of exe with administrator privileges" + ) +} + fn install_engine(dir: &Path, log: &mut Vec) -> Result<()> { if exe_in(dir).exists() { log.push("engine already unpacked".into()); @@ -387,6 +480,41 @@ fn which(name: &str) -> Option { .map(PathBuf::from) } +/// The systemd user unit that runs the engine on this machine, when there is +/// one: then the engine is systemd's to stop and start, never Latch's. +pub fn engine_unit() -> Option<&'static str> { + #[cfg(target_os = "linux")] + { + const UNIT: &str = "latch-engine.service"; + let out = Command::new("systemctl") + .args([ + "--user", + "show", + "-p", + "LoadState", + "-p", + "UnitFileState", + UNIT, + ]) + .stderr(Stdio::null()) + .output() + .ok() + .filter(|o| o.status.success())?; + unit_installed(&String::from_utf8_lossy(&out.stdout)).then_some(UNIT) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +/// `systemctl show -p LoadState -p UnitFileState` of a unit that exists +/// and is not masked. ("not-found" when nobody installed one.) +#[cfg(any(target_os = "linux", test))] +fn unit_installed(show: &str) -> bool { + show.lines().any(|l| l.trim() == "LoadState=loaded") +} + pub fn stop_engine() { for name in engine_exe_names() { let _ = Command::new("pkill") @@ -885,7 +1013,6 @@ fn retire_legacy_user_unit() { } } -#[cfg(target_os = "linux")] fn systemctl_user(args: &[&str]) -> Result { Ok(Command::new("systemctl") .arg("--user") @@ -1027,6 +1154,43 @@ mod tests { )); } + #[test] + fn a_firewall_that_blocks_latch_is_recognised_and_opened_by_path() { + assert!(firewall_on("Firewall is enabled. (State = 1)\n")); + assert!(!firewall_on("Firewall is disabled. (State = 0)\n")); + assert!(app_blocked( + "Incoming connection to /Applications/Latch.app/Contents/MacOS/Latch is blocked.\n" + )); + assert!(!app_blocked( + "Incoming connection to /Applications/Latch.app/Contents/MacOS/Latch is permitted.\n" + )); + let script = firewall_allow_script("/Applications/My \"Latch\".app/Contents/MacOS/Latch"); + assert!( + script.starts_with( + "set exe to \"/Applications/My \\\"Latch\\\".app/Contents/MacOS/Latch\"\n" + ), + "{script}" + ); + assert!(script.contains("--add \" & quoted form of exe"), "{script}"); + assert!( + script.contains("--unblockapp \" & quoted form of exe"), + "{script}" + ); + assert!( + script.ends_with("with administrator privileges"), + "{script}" + ); + } + + #[test] + fn an_engine_systemd_runs_is_left_to_systemd() { + assert!(unit_installed("LoadState=loaded\nUnitFileState=static\n")); + assert!(unit_installed("LoadState=loaded\nUnitFileState=enabled\n")); + assert!(!unit_installed("LoadState=not-found\nUnitFileState=\n")); + assert!(!unit_installed("LoadState=masked\nUnitFileState=masked\n")); + assert!(!unit_installed("")); + } + #[test] fn an_older_installs_engine_login_is_found_under_its_new_name() { let dir = std::env::temp_dir().join(format!("latch-repair-{}", std::process::id())); From 751f745bee9d9071496d6104bbb0a99071286819 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:17:29 -0700 Subject: [PATCH 16/34] Mac login item: say when macOS has it off, and keep scratch copies from taking it over This Mac's launch agent was marked disabled in launchd while its plist existed and host.toml said shared: the toggle showed on, the service ran with nothing to restart it, and it would not have come back after a restart. The service now lists that as a setup item, and setup (or turning Share on) enables the label even when the job is loaded. The service rewrote the login item to whatever copy it ran from, so a build started from dist.noindex, a disk image or App Translocation took it over. It now points it only at an app installed in /Applications or ~/Applications; turning Share on from any copy still does. Co-Authored-By: Claude Opus 5.5 --- crates/host/src/service.rs | 14 +++++++ crates/host/src/unix_setup.rs | 69 +++++++++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/crates/host/src/service.rs b/crates/host/src/service.rs index 87eccfc..fc36b65 100644 --- a/crates/host/src/service.rs +++ b/crates/host/src/service.rs @@ -82,6 +82,8 @@ pub struct Service { kept_awake: AtomicBool, /// The macOS firewall turns away connections to this program. firewall_blocked: AtomicBool, + /// macOS has the login item turned off (see `unix_setup`). + login_item_off: AtomicBool, /// This PC's side of the NAT story, from `tailscale netcheck`. nat: Mutex>, nat_running: AtomicBool, @@ -114,6 +116,7 @@ impl Service { reinstall_tried: AtomicBool::new(false), kept_awake: AtomicBool::new(false), firewall_blocked: AtomicBool::new(false), + login_item_off: AtomicBool::new(false), nat: Mutex::new(None), nat_running: AtomicBool::new(false), update_running: Arc::new(AtomicBool::new(false)), @@ -462,6 +465,11 @@ impl Service { } self.firewall_blocked.store(blocked, Ordering::Release); } + let off = crate::unix_setup::login_item_disabled(); + if off && !self.login_item_off.swap(off, Ordering::AcqRel) { + self.log("macOS has Latch's login item turned off, so this Mac is not shared after a restart; run setup here"); + } + self.login_item_off.store(off, Ordering::Release); } // What an update retired is kept until the new version has served // for a while: the update's watcher puts it back if it does not. @@ -577,6 +585,12 @@ impl Service { if self.firewall_blocked.load(Ordering::Acquire) { setup.push("The macOS firewall blocks other machines from reaching Latch.".into()); } + if self.login_item_off.load(Ordering::Acquire) { + setup.push( + "macOS has Latch's login item turned off, so sharing stops at the next restart." + .into(), + ); + } if *self.virtual_display.lock() == Some(false) { setup.push( "The virtual display does not list every screen size a viewer can ask for.".into(), diff --git a/crates/host/src/unix_setup.rs b/crates/host/src/unix_setup.rs index f86e105..03061f8 100644 --- a/crates/host/src/unix_setup.rs +++ b/crates/host/src/unix_setup.rs @@ -552,6 +552,16 @@ pub fn set_autostart(enable: bool, exe: &Path) -> Result<()> { pub fn register_autostart(exe: &Path) -> Result<()> { #[cfg(target_os = "macos")] { + if !installed_app(exe) { + // A build run from a scratch folder, a disk image or App + // Translocation must not take the login item over: at the next + // login it would start that copy, or nothing once it is gone. + tracing::info!( + "not pointing the login item at {}: not an installed Latch.app", + exe.display() + ); + return Ok(()); + } write_launch_agent(exe)?; retire_legacy_launch_agent(); Ok(()) @@ -737,6 +747,31 @@ fn enable_args(uid: &str) -> Vec { vec!["enable".into(), format!("gui/{uid}/{LAUNCH_AGENT_LABEL}")] } +/// Whether `exe` is the program of an app installed in /Applications or +/// ~/Applications, the only copies the service may point the login item at +/// by itself. +#[cfg(any(target_os = "macos", test))] +fn installed_app(exe: &Path) -> bool { + let p = exe.to_string_lossy(); + p.contains("/Applications/") + && p.contains(".app/Contents/MacOS/") + && !p.contains("/AppTranslocation/") +} + +/// The login item exists but macOS has it turned off (Login Items in +/// System Settings, or `launchctl disable`): the Mac looks shared, and is +/// not after the next restart. Setup turns it back on. +pub fn login_item_disabled() -> bool { + #[cfg(target_os = "macos")] + { + launch_agent_path().is_ok_and(|p| p.exists()) && launch_agent_disabled() + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + /// Whether `launchctl print-disabled gui/` lists the launch agent as /// disabled ("=> disabled", or "=> true" before macOS 13). #[cfg(any(target_os = "macos", test))] @@ -964,17 +999,17 @@ fn macos_launch_agent(enable: bool, exe: &Path) -> Result<()> { } write_launch_agent(exe)?; retire_legacy_launch_agent(); - if launch_agent_loaded() { - return Ok(()); - } // Turning sharing on is the owner asking for the login item, so a label - // macOS has marked disabled is enabled first: the legacy `launchctl - // load` skipped such a job without a word, and the service then ran - // with nothing to restart it. RunAtLoad starts the service now; if one - // already answers on the port, the new copy exits 0 and launchd leaves - // it. + // macOS has marked disabled is enabled: the legacy `launchctl load` + // skipped such a job without a word, and the service then ran with + // nothing to restart it, and not at all after the next login. let uid = uid()?; let _ = launchctl(&enable_args(&uid)); + if launch_agent_loaded() { + return Ok(()); + } + // RunAtLoad starts the service now; if one already answers on the + // port, the new copy exits 0 and launchd leaves it. let _ = launchctl(&bootstrap_args(&uid, &path.display().to_string())); anyhow::ensure!( launch_agent_loaded(), @@ -1128,6 +1163,24 @@ mod tests { ); } + #[test] + fn only_an_installed_app_takes_the_login_item_by_itself() { + for ok in [ + "/Applications/Latch.app/Contents/MacOS/Latch", + "/Users/ada/Applications/Latch.app/Contents/MacOS/Latch", + ] { + assert!(installed_app(Path::new(ok)), "{ok}"); + } + for scratch in [ + "/Users/ada/dev/latch/dist.noindex/Latch-prev-2026-10-12.app/Contents/MacOS/Latch", + "/Volumes/Latch/Latch.app/Contents/MacOS/Latch", + "/private/var/folders/x/T/AppTranslocation/1234/d/Latch.app/Contents/MacOS/Latch", + "/Users/ada/dev/latch/target/release/latch-host", + ] { + assert!(!installed_app(Path::new(scratch)), "{scratch}"); + } + } + #[test] fn a_disabled_login_item_and_a_systemd_service_are_recognised() { // `launchctl print-disabled gui/501` on the Mac, 2026-10-17. From 4101fdb0146dec202c759587fc6825b6b11d2331 Mon Sep 17 00:00:00 2001 From: MrBeldum <157205701+MrBeldum@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:20:06 -0700 Subject: [PATCH 17/34] Retire step: retry the engine move, never nest it, keep the pairing state A 4.0 PC's engine folder, with the pairing state in its config, was moved to C:\Program Files\Latch\engine with one Move-Item. A held handle (an antivirus scan, an Explorer window, an engine restarted in the gap) failed the move, setup then installed a fresh engine with a new identity and no paired devices, and the old state stayed stranded where nothing read it again. A part-filled Latch\engine left by an earlier failed run made the move nest the old folder at engine\engine, with the same result. The move is now tried five times, stopping any engine that runs from the old folder before each try. A folder in the way is removed when empty and kept aside as engine.partial-