diff --git a/CHANGELOG.md b/CHANGELOG.md index 9204717..adfffd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Phase 2: multi-monitor support ([#8]) + +- **Machines can expose multiple monitors.** New `DisplayLayout { monitors: Vec }` type describes a machine's full monitor arrangement, configured via a `[[monitors]]` list (each with `width`, `height`, and top-left `x`/`y` offset). When no monitors are listed, the daemon falls back to a single monitor sized by `daemon.screen_width`/`screen_height`, so existing configs are unchanged. +- **Edge detection spans the combined desktop.** The daemon derives the union bounding box of all monitors and runs all cursor clamping and barrier crossing against it — so on a dual-monitor machine the cursor now traverses the whole desktop before crossing to another machine, instead of stopping at the first monitor's inner edge. All crossing logic is unchanged; only the screen bounds it operates on now come from the layout. +- **Layout travels on the wire.** `Hello`/`Welcome`/`ScreenUpdate` now carry the `DisplayLayout` instead of a single `ScreenGeometry`, so peers see each other's real monitor arrangement. Config validation rejects a monitor with a zero dimension. + +### Changed + +- **Protocol version → 0.2.** The handshake's screen field changed from `ScreenGeometry` to `DisplayLayout`; major version stays 0, so this is a breaking wire change within the pre-1.0 alpha (rebuild both ends). + +[#8]: https://github.com/Adjoint-uk/cross-control/issues/8 + +### Added — Phase 2: clipboard HTML and images ([#6], [#7]) + +- **HTML and PNG image clipboard sync.** The clipboard path is no longer text-only. `ClipboardProvider` gained `get_format(format)`, and the `arboard` backend now reads/writes HTML (`get().html()` / `set_html`) and images, converting between the wire's PNG bytes and the raw RGBA the platform clipboard uses (via the `image` crate, PNG feature only). `available_formats()` probes all three formats. +- **Richest-format selection on hand-off.** When the controller offers its clipboard, the controlled side now requests the richest format it can apply — image, else HTML, else plain text — instead of always taking plain text. +- **Size cap enforced ([#7]).** Clipboard payloads over `clipboard.max_size` (default 10 MiB) are dropped with a warning on both the send and receive side, rather than put on the control stream. Chunked streaming of large images over a dedicated QUIC stream remains a follow-up. + +[#6]: https://github.com/Adjoint-uk/cross-control/issues/6 +[#7]: https://github.com/Adjoint-uk/cross-control/issues/7 + +### Added — Phase 2: live status readout ([#13]) and TOML layout validation ([#9]) + +- **`cross-control status` now shows live peers, latency, and focus.** The daemon writes a `StatusSnapshot` (peers with name/state/latency, plus which peer holds focus) to `cross-control.status.json` in the runtime dir every couple of seconds; the `status` command reads and renders it as a table. This is the CLI↔daemon channel the previous `status` lacked — it mirrors the PID-file pattern rather than standing up a full IPC socket. A richer query/subscribe channel can replace the file later without changing the rendered output. +- **Real latency, not a stub.** The daemon pings each peer on a 2-second cadence and records the round-trip time when the `Pong` returns (a new `LatencyTracker` per session). `status` shows `—` until the first ping completes, then `N ms`. +- **`Config::validate()` for screen layouts.** Loading a config now rejects layouts that would silently misroute the cursor: empty or duplicate screen names, a screen sharing this machine's `identity.name`, two screens on the same local edge, self-loop adjacency edges, one screen given two neighbors on the same edge, and `[[screen_adjacency]]` blocks that never connect back to this machine (a typo or dead island). Multi-hop screens introduced only via `[[screen_adjacency]]` are correctly accepted — reachability is checked to a fixpoint, not against `[[screens]]` alone. +- **Documented layout format.** `examples/config.toml` now explains `[[screens]]` vs `[[screen_adjacency]]`, the optional `address`/`fingerprint`, and gives a worked multi-hop example. + +### Changed + +- `DaemonEvent::SessionReady` now boxes its `PeerSession` payload (the session grew a latency tracker; boxing keeps the enum variants balanced). + +### Test coverage + +- 84 tests pass workspace-wide (was 70). New: `Config::validate` cases, `StatusSnapshot` JSON round-trip, and `LatencyTracker` ping/pong bookkeeping. Still 2 ignored (`mdns_loopback`, `arboard_backend` — both need host facilities unavailable in CI). + +[#9]: https://github.com/Adjoint-uk/cross-control/issues/9 +[#13]: https://github.com/Adjoint-uk/cross-control/issues/13 + +### Added — loopback demo and systemd user service ([#11]) + +- **`loopback` example.** `cargo run -p cross-control-daemon --example loopback` runs two daemons in one process on `127.0.0.1` and drives a full session — QUIC handshake, device announce, edge-based cursor crossing, and input forwarding — with no second machine, no root, and no display server. It asserts the forwarded keypress lands on the receiving daemon and narrates each step. Built with `--features linux -- --real`, the receiver injects into a real uinput device so the cursor visibly moves. This makes everything *between* the two physical ends reproducible on one box; only real evdev capture and a live compositor remain for hardware bring-up. +- **systemd user service, fixed up ([#11]).** `systemd/cross-control.service` is now a correct, documented user unit: dropped the `network-online.target` ordering (unavailable in the user manager; the daemon reconnects on its own), added install/prerequisite comments, and reconciled the copy `install.sh` generates so the two no longer diverge. README gains a "Run as a service" section covering enable, logs, lingering, and the `input`/`uinput` prerequisites. + +[#11]: https://github.com/Adjoint-uk/cross-control/issues/11 + ### Added — Phase 2 opener: clipboard text sync - **`cross-control-clipboard` backends.** `ArboardClipboard` (default feature `arboard`) for the real system clipboard on X11, macOS, Windows, and wlroots-based Wayland; `MockClipboard` (feature `mock`) for tests and headless daemon runs. The trait now requires `Send + Sync + 'static` so the daemon can hold `&self.clipboard` across `.await` on a multi-thread runtime. diff --git a/Cargo.lock b/Cargo.lock index d2a0fe8..0e89173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "cross-control-protocol", "cross-control-types", "hostname", + "serde_json", "tokio", "toml", "tracing", @@ -369,6 +370,7 @@ dependencies = [ "arboard", "async-trait", "cross-control-types", + "image", "thiserror 2.0.18", "tokio", "tracing", @@ -389,6 +391,7 @@ dependencies = [ "hostname", "rustls", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index cddc023..03f01b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Serialization serde = { version = "1", features = ["derive"] } +serde_json = "1" bincode = { version = "2", features = ["serde"] } # CLI @@ -59,6 +60,9 @@ rcgen = "0.13" # Clipboard arboard = "3" +# Image codec for clipboard PNG <-> raw RGBA conversion +image = { version = "0.25", default-features = false, features = ["png"] } + # mDNS discovery mdns-sd = "0.13" diff --git a/DESIGN.md b/DESIGN.md index f99144c..590a48c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -17,7 +17,7 @@ The codebase is past the v0.2.0-alpha *code* milestone — only hardware bring-u - **`cross-control-discovery`** (~500 LOC) — `Discovery` trait + `MdnsDiscovery` (mdns-sd, fingerprint TXT records) + `DiscoveryAggregator` (multi-backend fan-in with `MachineId` dedupe) + `StaticDiscovery` (wraps config peers). - **`cross-control-cli`** (269 LOC) — `start`, `stop`, `status`, `generate-cert`, `pair`. - **`cross-control-certgen`** (125 LOC) — TLS cert generation + SHA-256 fingerprinting. -- **`cross-control-clipboard`** — text backend shipped (Phase 2 opener); HTML/image backends remain. +- **`cross-control-clipboard`** — text, HTML, and PNG image backends shipped over `arboard`, with a per-hand-off size cap. Chunked streaming of large images remains. - **`cross-control-tui-test`** (884 LOC) — TUI harness for visual testing. --- diff --git a/README.md b/README.md index 545ba38..78d7f6d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,42 @@ cross-control status See [docs/setup-guide.md](docs/setup-guide.md) for detailed setup instructions and troubleshooting. +### Run as a service + +To keep the daemon running across logins, install the systemd **user** unit: + +```bash +mkdir -p ~/.config/systemd/user +cp systemd/cross-control.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now cross-control +systemctl --user status cross-control # check it's running +journalctl --user -u cross-control -f # follow logs +``` + +The install script offers to do this for you. Two prerequisites, since input +capture/emulation needs device access: your user must be in the `input` group, +and `/dev/uinput` must be group-readable/writable (see **Linux permissions +setup** above). To have the service start before you log in, enable lingering: +`sudo loginctl enable-linger $USER`. The unit assumes the binary is on +`~/.cargo/bin`; if you installed elsewhere, adjust `ExecStart` with +`systemctl --user edit cross-control`. + +## Try it on one machine + +No second computer? The `loopback` example runs two daemons in one process and +drives a full session — handshake, cursor crossing, input forwarding — end to +end on `127.0.0.1`: + +```bash +cargo run -p cross-control-daemon --example loopback +``` + +On a Linux desktop with `/dev/uinput` access, add `--features linux -- --real` +to have the receiving daemon inject into a real virtual device so you can watch +the cursor move. This covers everything except the physical input ends +(real evdev capture and a live compositor). + ## Architecture ``` diff --git a/crates/cross-control-cli/Cargo.toml b/crates/cross-control-cli/Cargo.toml index af68037..fbe5d0b 100644 --- a/crates/cross-control-cli/Cargo.toml +++ b/crates/cross-control-cli/Cargo.toml @@ -29,6 +29,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } anyhow = { workspace = true } toml = { workspace = true } +serde_json = { workspace = true } hostname = "0.4" [lints] diff --git a/crates/cross-control-cli/src/main.rs b/crates/cross-control-cli/src/main.rs index 8cff803..f162ca5 100644 --- a/crates/cross-control-cli/src/main.rs +++ b/crates/cross-control-cli/src/main.rs @@ -251,9 +251,60 @@ fn show_status() -> anyhow::Result<()> { } } + // Show live peers, latency, and focus from the daemon's status snapshot. + // The daemon writes this file every couple of seconds; a running daemon + // that hasn't written it yet (just started) simply shows no peers. + if alive { + print_live_status(); + } + Ok(()) } +/// Read and render the daemon's status snapshot: which peer holds focus and +/// the table of connected peers with latency. Silent if the file is missing +/// or unreadable — the daemon may not have written it yet. +fn print_live_status() { + use cross_control_daemon::status::{status_file_path, StatusSnapshot}; + + let Ok(content) = std::fs::read_to_string(status_file_path()) else { + return; + }; + let Ok(snapshot) = StatusSnapshot::from_json(&content) else { + return; + }; + + // Focus line: who is driving whom right now. + let focus = match (&snapshot.controlling, &snapshot.controlled_by) { + (Some(c), _) => format!("controlling {c}"), + (None, Some(b)) => format!("controlled by {b}"), + (None, None) => "local".to_string(), + }; + println!("Focus: {focus}"); + + if snapshot.peers.is_empty() { + println!("Peers: none connected"); + return; + } + + println!("Peers: {} connected", snapshot.peers.len()); + println!(); + print_peer_row("NAME", "STATE", "LATENCY", "ADDRESS"); + for peer in &snapshot.peers { + let latency = peer + .latency_ms + .map_or_else(|| "—".to_string(), |ms| format!("{ms} ms")); + print_peer_row(&peer.name, &peer.state, &latency, &peer.address); + } +} + +/// Print one aligned row of the peer table. Inlining the captured identifiers +/// (rather than passing string literals as args) keeps the header call free of +/// the `print_literal` lint. +fn print_peer_row(name: &str, state: &str, latency: &str, address: &str) { + println!(" {name:<20} {state:<10} {latency:<10} {address}"); +} + fn stop_daemon() -> anyhow::Result<()> { use cross_control_daemon::setup; diff --git a/crates/cross-control-clipboard/Cargo.toml b/crates/cross-control-clipboard/Cargo.toml index 324f827..170f801 100644 --- a/crates/cross-control-clipboard/Cargo.toml +++ b/crates/cross-control-clipboard/Cargo.toml @@ -11,7 +11,8 @@ authors.workspace = true [features] default = ["arboard"] # Real backend over arboard (X11, macOS, Windows, wlroots-based Wayland). -arboard = ["dep:arboard"] +# Pulls in `image` for PNG <-> raw RGBA conversion on the image path. +arboard = ["dep:arboard", "dep:image"] # In-memory backend for tests and headless daemon runs. mock = [] @@ -23,6 +24,7 @@ thiserror = { workspace = true } tracing = { workspace = true } anyhow = { workspace = true } arboard = { workspace = true, optional = true } +image = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } diff --git a/crates/cross-control-clipboard/src/arboard_backend.rs b/crates/cross-control-clipboard/src/arboard_backend.rs index f9fbcde..b7733f3 100644 --- a/crates/cross-control-clipboard/src/arboard_backend.rs +++ b/crates/cross-control-clipboard/src/arboard_backend.rs @@ -24,7 +24,9 @@ use crate::{ClipboardError, ClipboardProvider}; /// Polling interval for `watch`. Tunable via [`ArboardClipboard::with_poll_interval`]. const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500); -/// `arboard`-backed [`ClipboardProvider`]. Text-only for now. +/// `arboard`-backed [`ClipboardProvider`]. Supports text, HTML, and PNG +/// images. Image payloads are converted between the wire's PNG encoding and +/// the raw RGBA the platform clipboard exposes. pub struct ArboardClipboard { /// Shared so `spawn_blocking` tasks can each grab a fresh lock. inner: Arc>, @@ -67,35 +69,63 @@ impl ClipboardProvider for ArboardClipboard { .map_err(|e| ClipboardError::Other(e.into()))? } + async fn get_format( + &self, + format: ClipboardFormat, + ) -> Result { + let inner = Arc::clone(&self.inner); + spawn_blocking(move || { + let mut guard = inner.lock().map_err(|_| ClipboardError::AccessDenied)?; + match format { + ClipboardFormat::PlainText => guard.get_text().map(|t| ClipboardContent::text(&t)), + ClipboardFormat::Html => guard.get().html().map(|h| ClipboardContent::html(&h)), + ClipboardFormat::Png => { + let img = guard.get_image().map_err(map_arboard_error)?; + return rgba_to_png(img.width, img.height, &img.bytes) + .map(ClipboardContent::png); + } + } + .map_err(map_arboard_error) + }) + .await + .map_err(|e| ClipboardError::Other(e.into()))? + } + async fn set(&mut self, content: ClipboardContent) -> Result<(), ClipboardError> { - if content.format != ClipboardFormat::PlainText { - return Err(ClipboardError::FormatUnavailable); - } - let text = content - .as_text() - .ok_or(ClipboardError::FormatUnavailable)? - .to_string(); let inner = Arc::clone(&self.inner); spawn_blocking(move || { let mut guard = inner.lock().map_err(|_| ClipboardError::AccessDenied)?; - guard.set_text(text).map_err(map_arboard_error) + match content.format { + ClipboardFormat::PlainText => { + let text = content.as_text().ok_or(ClipboardError::FormatUnavailable)?; + guard.set_text(text).map_err(map_arboard_error) + } + ClipboardFormat::Html => { + let html = content.as_html().ok_or(ClipboardError::FormatUnavailable)?; + guard.set_html(html, None).map_err(map_arboard_error) + } + ClipboardFormat::Png => { + let image = png_to_rgba(&content.data)?; + guard.set_image(image).map_err(map_arboard_error) + } + } }) .await .map_err(|e| ClipboardError::Other(e.into()))? } async fn available_formats(&self) -> Result, ClipboardError> { - // arboard doesn't expose a list-formats API. We probe `get_text` and - // report PlainText if it works. HTML / image probing would add round - // trips for formats we don't yet sync — skip until those land. + // arboard has no list-formats API, so probe each format we support. + // A probe that comes back `ContentNotAvailable` just means that + // format isn't on the clipboard; only a harder error propagates. let inner = Arc::clone(&self.inner); spawn_blocking(move || { let mut guard = inner.lock().map_err(|_| ClipboardError::AccessDenied)?; - match guard.get_text() { - Ok(_) => Ok(vec![ClipboardFormat::PlainText]), - Err(arboard::Error::ContentNotAvailable) => Ok(Vec::new()), - Err(e) => Err(map_arboard_error(e)), - } + let mut formats = Vec::new(); + probe(guard.get_text(), ClipboardFormat::PlainText, &mut formats)?; + probe(guard.get().html(), ClipboardFormat::Html, &mut formats)?; + probe(guard.get_image(), ClipboardFormat::Png, &mut formats)?; + Ok(formats) }) .await .map_err(|e| ClipboardError::Other(e.into()))? @@ -156,6 +186,49 @@ impl Drop for ArboardClipboard { } } +/// Record `format` as available when the probe succeeded. A missing format +/// (`ContentNotAvailable`) is skipped; any other error propagates. +fn probe( + result: Result, + format: ClipboardFormat, + into: &mut Vec, +) -> Result<(), ClipboardError> { + match result { + Ok(_) => { + into.push(format); + Ok(()) + } + Err(arboard::Error::ContentNotAvailable | arboard::Error::ConversionFailure) => Ok(()), + Err(e) => Err(map_arboard_error(e)), + } +} + +/// Encode raw RGBA8 pixels (row-major, 4 bytes/pixel) as PNG. +fn rgba_to_png(width: usize, height: usize, rgba: &[u8]) -> Result, ClipboardError> { + let w = u32::try_from(width).map_err(|_| ClipboardError::FormatUnavailable)?; + let h = u32::try_from(height).map_err(|_| ClipboardError::FormatUnavailable)?; + let img = image::RgbaImage::from_raw(w, h, rgba.to_vec()) + .ok_or_else(|| ClipboardError::Other(anyhow::anyhow!("RGBA buffer size mismatch")))?; + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut out, image::ImageFormat::Png) + .map_err(|e| ClipboardError::Other(e.into()))?; + Ok(out.into_inner()) +} + +/// Decode PNG bytes into the raw RGBA [`arboard::ImageData`] the clipboard wants. +fn png_to_rgba(png: &[u8]) -> Result, ClipboardError> { + let img = image::load_from_memory_with_format(png, image::ImageFormat::Png) + .map_err(|e| ClipboardError::Other(e.into()))? + .to_rgba8(); + let (width, height) = img.dimensions(); + Ok(arboard::ImageData { + width: width as usize, + height: height as usize, + bytes: std::borrow::Cow::Owned(img.into_raw()), + }) +} + /// Translate an [`arboard::Error`] into our [`ClipboardError`] variants. fn map_arboard_error(e: arboard::Error) -> ClipboardError { match e { @@ -173,6 +246,31 @@ fn map_arboard_error(e: arboard::Error) -> ClipboardError { mod tests { use super::*; + #[test] + fn png_rgba_round_trip() { + // A 2x2 image with four distinct RGBA pixels survives encode → decode. + let width = 2usize; + let height = 2usize; + let rgba: Vec = vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 255, 255, 0, 128, // semi-transparent yellow + ]; + let png = rgba_to_png(width, height, &rgba).expect("encode"); + // PNG magic number. + assert_eq!(&png[..4], &[0x89, 0x50, 0x4E, 0x47]); + let decoded = png_to_rgba(&png).expect("decode"); + assert_eq!(decoded.width, width); + assert_eq!(decoded.height, height); + assert_eq!(decoded.bytes.as_ref(), rgba.as_slice()); + } + + #[test] + fn png_decode_rejects_garbage() { + assert!(png_to_rgba(&[0, 1, 2, 3, 4]).is_err()); + } + /// Constructing the backend requires a display server; ignore by default. #[tokio::test] #[ignore = "requires a display server"] diff --git a/crates/cross-control-clipboard/src/lib.rs b/crates/cross-control-clipboard/src/lib.rs index 6d462f1..82dcce2 100644 --- a/crates/cross-control-clipboard/src/lib.rs +++ b/crates/cross-control-clipboard/src/lib.rs @@ -8,9 +8,9 @@ //! - [`mock::MockClipboard`] (feature `mock`) — in-memory, for tests and //! headless runs. //! -//! The text path (`ClipboardFormat::PlainText`) is wired end-to-end. HTML -//! and image formats are defined in the wire protocol and accepted by the -//! trait but are not yet implemented by either backend. +//! Text, HTML, and PNG images are wired end-to-end through the `arboard` +//! backend. Image payloads convert between the wire's PNG bytes and the raw +//! RGBA the platform clipboard uses. use async_trait::async_trait; use cross_control_types::{ClipboardContent, ClipboardFormat}; @@ -34,9 +34,16 @@ pub use mock::MockClipboard; /// multi-thread runtime. #[async_trait] pub trait ClipboardProvider: Send + Sync + 'static { - /// Get the current clipboard content in the preferred format. + /// Get the current clipboard content in the preferred format (text if + /// available). Used for size hints; format-specific reads go through + /// [`ClipboardProvider::get_format`]. async fn get(&self) -> Result; + /// Get the current clipboard content in a specific format, or + /// [`ClipboardError::FormatUnavailable`] if that format isn't present. + async fn get_format(&self, format: ClipboardFormat) + -> Result; + /// Set the clipboard content. async fn set(&mut self, content: ClipboardContent) -> Result<(), ClipboardError>; diff --git a/crates/cross-control-clipboard/src/mock.rs b/crates/cross-control-clipboard/src/mock.rs index 0071562..23c28d7 100644 --- a/crates/cross-control-clipboard/src/mock.rs +++ b/crates/cross-control-clipboard/src/mock.rs @@ -47,6 +47,20 @@ impl ClipboardProvider for MockClipboard { guard.clone().ok_or(ClipboardError::FormatUnavailable) } + async fn get_format( + &self, + format: ClipboardFormat, + ) -> Result { + let guard = self + .state + .lock() + .map_err(|_| ClipboardError::AccessDenied)?; + match guard.as_ref() { + Some(content) if content.format == format => Ok(content.clone()), + _ => Err(ClipboardError::FormatUnavailable), + } + } + async fn set(&mut self, content: ClipboardContent) -> Result<(), ClipboardError> { { let mut guard = self @@ -135,4 +149,26 @@ mod tests { vec![ClipboardFormat::PlainText] ); } + + #[tokio::test] + async fn get_format_matches_stored_format() { + let mut cb = MockClipboard::new(); + cb.set(ClipboardContent::html("hi")).await.unwrap(); + let got = cb.get_format(ClipboardFormat::Html).await.unwrap(); + assert_eq!(got.as_html(), Some("hi")); + // A different format isn't available. + assert!(matches!( + cb.get_format(ClipboardFormat::Png).await, + Err(ClipboardError::FormatUnavailable) + )); + } + + #[tokio::test] + async fn get_format_returns_png() { + let mut cb = MockClipboard::new(); + cb.set(ClipboardContent::png(vec![1, 2, 3])).await.unwrap(); + let got = cb.get_format(ClipboardFormat::Png).await.unwrap(); + assert_eq!(got.format, ClipboardFormat::Png); + assert_eq!(got.data, vec![1, 2, 3]); + } } diff --git a/crates/cross-control-daemon/Cargo.toml b/crates/cross-control-daemon/Cargo.toml index 026019c..b958d91 100644 --- a/crates/cross-control-daemon/Cargo.toml +++ b/crates/cross-control-daemon/Cargo.toml @@ -21,6 +21,7 @@ cross-control-discovery = { workspace = true } cross-control-certgen = { workspace = true } tokio = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/cross-control-daemon/examples/loopback.rs b/crates/cross-control-daemon/examples/loopback.rs new file mode 100644 index 0000000..b1b0406 --- /dev/null +++ b/crates/cross-control-daemon/examples/loopback.rs @@ -0,0 +1,315 @@ +//! Two daemons, one machine — the whole networked KVM on loopback. +//! +//! This runs a complete cross-control session between two daemons in a single +//! process, both bound to `127.0.0.1`, with **no second machine, no root, and +//! no display server required**. It exercises everything between the two +//! physical ends: the QUIC handshake, device announce, edge-based cursor +//! crossing, and live input forwarding. The only pieces it does *not* cover +//! are the two ends themselves — real `evdev` capture of a physical mouse and +//! real `uinput` injection into a live compositor — which is exactly the +//! remaining hardware bring-up (issues #1/#3). +//! +//! ## Run it +//! +//! ```console +//! # Headless, deterministic — machine-B uses a mock backend we can observe. +//! cargo run -p cross-control-daemon --example loopback +//! +//! # On a Linux desktop with /dev/uinput access, watch a REAL cursor move: +//! cargo run -p cross-control-daemon --example loopback --features linux -- --real +//! ``` +//! +//! In `--real` mode, machine-B injects the forwarded input into an actual +//! uinput virtual device, so after the cursor "crosses" you will see your +//! pointer drift as machine-A streams mouse motion across the link. + +use std::net::SocketAddr; +use std::time::Duration; + +use cross_control_daemon::config::{Config, DaemonConfig, IdentityConfig, ScreenConfig}; +use cross_control_daemon::{Daemon, DaemonEvent, DaemonStatus}; +use cross_control_input::mock::{MockCapture, MockEmulation, MockEmulationHandle}; +use cross_control_input::InputEmulation; +use cross_control_types::{ + ButtonState, CapturedEvent, DeviceCapability, DeviceId, DeviceInfo, InputEvent, KeyCode, + MachineId, Position, +}; +use tokio::sync::watch; + +/// The two devices each daemon advertises to its peer. +fn demo_devices() -> Vec { + vec![ + DeviceInfo { + id: DeviceId(1), + name: "loopback-keyboard".to_string(), + capabilities: vec![DeviceCapability::Keyboard], + }, + DeviceInfo { + id: DeviceId(2), + name: "loopback-mouse".to_string(), + capabilities: vec![DeviceCapability::RelativeMouse, DeviceCapability::Scroll], + }, + ] +} + +/// machine-B's emulation backend, plus a mock handle when we can observe it. +enum EmulationB { + /// Mock backend — deterministic, inspectable via the handle. + Mock(MockEmulationHandle), + /// Real uinput backend — visible on screen but not inspectable here. + /// Only constructed when built with `--features linux`. + #[cfg_attr(not(feature = "linux"), allow(dead_code))] + Real, +} + +/// Build machine-B's emulation. `--real` (with `--features linux`) wires a real +/// uinput device so you can watch the cursor move; otherwise a mock backend we +/// can assert against. +fn build_emulation_b(real: bool) -> (Box, EmulationB) { + if real { + #[cfg(feature = "linux")] + { + let emu = cross_control_input::linux::emulation::UinputEmulation::new(); + println!("machine-B: using REAL uinput emulation — watch your cursor."); + return (Box::new(emu), EmulationB::Real); + } + #[cfg(not(feature = "linux"))] + { + eprintln!("note: --real needs `--features linux`; falling back to the mock backend.\n"); + } + } + let backend = MockEmulation::new(); + let handle = backend.handle(); + (Box::new(backend), EmulationB::Mock(handle)) +} + +/// Wait until `pred` holds on a status watch channel, or time out. +async fn wait_for( + rx: &mut watch::Receiver, + timeout: Duration, + pred: impl Fn(&DaemonStatus) -> bool, +) -> Result<(), &'static str> { + tokio::time::timeout(timeout, async { + loop { + if pred(&rx.borrow_and_update().clone()) { + return Ok(()); + } + if rx.changed().await.is_err() { + return Err("status channel closed"); + } + } + }) + .await + .map_err(|_| "timed out")? +} + +#[tokio::main] +async fn main() { + let real = std::env::args().any(|a| a == "--real"); + if let Err(e) = run(real).await { + eprintln!("\n✗ loopback demo failed: {e}"); + std::process::exit(1); + } +} + +#[allow(clippy::too_many_lines)] +async fn run(real: bool) -> Result<(), Box> { + // QUIC needs a process-wide crypto provider installed once. + let _ = rustls::crypto::ring::default_provider().install_default(); + + println!("cross-control loopback demo — two daemons on 127.0.0.1\n"); + + // Each daemon gets its own self-signed cert and an ephemeral UDP port. + let cert_a = cross_control_certgen::generate_certificate("localhost")?; + let cert_b = cross_control_certgen::generate_certificate("localhost")?; + let bind: SocketAddr = "127.0.0.1:0".parse()?; + let transport_a = + cross_control_protocol::QuicTransport::bind(bind, &cert_a.cert_pem, &cert_a.key_pem)?; + let transport_b = + cross_control_protocol::QuicTransport::bind(bind, &cert_b.cert_pem, &cert_b.key_pem)?; + let addr_b = transport_b.local_addr()?; + + // machine-A sits left of machine-B and dials it directly (static address, + // so the demo doesn't depend on multicast/mDNS being available). + let config_a = Config { + daemon: DaemonConfig { + screen_width: 1920, + screen_height: 1080, + discovery: false, + ..DaemonConfig::default() + }, + identity: IdentityConfig { + name: "machine-a".to_string(), + }, + screens: vec![ScreenConfig { + name: "machine-b".to_string(), + address: Some(addr_b.to_string()), + position: Position::Right, + fingerprint: None, + }], + ..Config::default() + }; + let config_b = Config { + daemon: DaemonConfig { + screen_width: 1920, + screen_height: 1080, + discovery: false, + ..DaemonConfig::default() + }, + identity: IdentityConfig { + name: "machine-b".to_string(), + }, + screens: vec![ScreenConfig { + name: "machine-a".to_string(), + address: None, + position: Position::Left, + fingerprint: None, + }], + ..Config::default() + }; + + // machine-A: scripted mock capture (we feed it cursor motion) + mock + // emulation. machine-B: idle capture + the chosen emulation backend. + let (capture_a, feed_a) = MockCapture::new(); + let (capture_b, _feed_b) = MockCapture::new(); + let (emulation_b, emu_b) = build_emulation_b(real); + + let mut daemon_a = Daemon::new( + config_a, + MachineId::new(), + transport_a, + Box::new(capture_a), + Box::new(MockEmulation::new()), + ); + daemon_a.set_local_devices(demo_devices()); + let mut status_a = daemon_a.status_receiver(); + let shutdown_a = daemon_a.event_sender(); + + let mut daemon_b = Daemon::new( + config_b, + MachineId::new(), + transport_b, + Box::new(capture_b), + emulation_b, + ); + daemon_b.set_local_devices(demo_devices()); + let mut status_b = daemon_b.status_receiver(); + let shutdown_b = daemon_b.event_sender(); + + // Start B (the server) first, then A dials it. + let handle_b = tokio::spawn(async move { + if let Err(e) = daemon_b.run().await { + eprintln!("machine-B error: {e}"); + } + }); + tokio::time::sleep(Duration::from_millis(50)).await; + let handle_a = tokio::spawn(async move { + if let Err(e) = daemon_a.run().await { + eprintln!("machine-A error: {e}"); + } + }); + + // 1) Handshake + pairing. + wait_for(&mut status_a, Duration::from_secs(5), |s| { + s.session_count >= 1 + }) + .await?; + wait_for(&mut status_b, Duration::from_secs(5), |s| { + s.session_count >= 1 + }) + .await?; + println!("✓ paired: machine-a ⇄ machine-b over QUIC (TLS 1.3)"); + + // Give device-announce a moment so B has virtual devices mapped. + tokio::time::sleep(Duration::from_millis(200)).await; + + // 2) Push machine-A's cursor into machine-B's edge. + println!("→ moving machine-a's cursor right, into the machine-b edge…"); + for _ in 0..5 { + feed_a + .send(CapturedEvent { + device_id: DeviceId(2), + timestamp_us: 1000, + event: InputEvent::MouseMove { dx: 500, dy: 0 }, + }) + .await?; + tokio::time::sleep(Duration::from_millis(20)).await; + } + wait_for(&mut status_a, Duration::from_secs(5), |s| { + s.controlling.is_some() + }) + .await?; + wait_for(&mut status_b, Duration::from_secs(5), |s| { + s.controlled_by.is_some() + }) + .await?; + println!("✓ crossed: machine-a is now controlling machine-b"); + tokio::time::sleep(Duration::from_millis(100)).await; + + // 3) Forward input across the link and confirm it lands on machine-B. + match emu_b { + EmulationB::Mock(handle) => { + println!("→ typing 'A' on machine-a; expecting it on machine-b…"); + for i in 0..5 { + feed_a + .send(CapturedEvent { + device_id: DeviceId(1), + timestamp_us: 3000 + i, + event: InputEvent::Key { + code: KeyCode::KeyA, + state: ButtonState::Pressed, + }, + }) + .await?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + let landed = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if handle.injected_events().iter().any(|e| { + matches!( + e.event, + InputEvent::Key { + code: KeyCode::KeyA, + .. + } + ) + }) { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + if landed.is_err() { + return Err("machine-b never received the forwarded KeyA".into()); + } + println!("✓ forwarded: machine-b's virtual keyboard received KeyA"); + } + EmulationB::Real => { + println!("→ streaming mouse motion to machine-b — watch the cursor drift…"); + for _ in 0..120 { + feed_a + .send(CapturedEvent { + device_id: DeviceId(2), + timestamp_us: 4000, + event: InputEvent::MouseMove { dx: 3, dy: 3 }, + }) + .await?; + tokio::time::sleep(Duration::from_millis(16)).await; + } + println!("✓ streamed 120 motion events into machine-b's real uinput device"); + } + } + + // 4) Clean shutdown of both daemons. + let _ = shutdown_a.send(DaemonEvent::Shutdown).await; + let _ = shutdown_b.send(DaemonEvent::Shutdown).await; + let _ = tokio::time::timeout(Duration::from_secs(5), handle_a).await; + let _ = tokio::time::timeout(Duration::from_secs(5), handle_b).await; + + println!("\n✓ done — the full KVM path works on one machine."); + println!( + " Remaining for real two-box bring-up: physical evdev capture + live uinput/compositor." + ); + Ok(()) +} diff --git a/crates/cross-control-daemon/src/config.rs b/crates/cross-control-daemon/src/config.rs index 3e18e87..93d926d 100644 --- a/crates/cross-control-daemon/src/config.rs +++ b/crates/cross-control-daemon/src/config.rs @@ -1,6 +1,8 @@ //! Daemon configuration loaded from TOML. -use cross_control_types::screen::Position; +use std::collections::{HashMap, HashSet}; + +use cross_control_types::screen::{DisplayLayout, Position, ScreenGeometry}; use serde::{Deserialize, Serialize}; /// Top-level configuration. @@ -18,6 +20,147 @@ pub struct Config { pub screens: Vec, #[serde(default)] pub screen_adjacency: Vec, + /// This machine's monitors. When empty, the daemon falls back to a + /// single monitor sized by `daemon.screen_width`/`screen_height`. + #[serde(default)] + pub monitors: Vec, +} + +impl Config { + /// This machine's display layout. Uses the explicit `[[monitors]]` list + /// when present, otherwise a single monitor sized by the legacy + /// `daemon.screen_width`/`screen_height` fields. + #[must_use] + pub fn display_layout(&self) -> DisplayLayout { + if self.monitors.is_empty() { + DisplayLayout::single(self.daemon.screen_width, self.daemon.screen_height) + } else { + DisplayLayout::new( + self.monitors + .iter() + .map(|m| ScreenGeometry { + width: m.width, + height: m.height, + x: m.x, + y: m.y, + }) + .collect(), + ) + } + } + + /// Validate the screen layout before the daemon builds its adjacency map. + /// + /// The daemon derives cursor routing from `[[screens]]` and + /// `[[screen_adjacency]]`; a typo (a dangling neighbor name, two screens + /// on the same edge) would otherwise silently misroute the cursor. This + /// turns those into a clear startup error instead. + pub fn validate(&self) -> Result<(), String> { + let me = self.identity.name.as_str(); + + // Every declared monitor must have a non-zero size, or edge detection + // against the combined desktop breaks. + for (i, m) in self.monitors.iter().enumerate() { + if m.width == 0 || m.height == 0 { + return Err(format!( + "monitor #{i} has a zero dimension ({}x{}); width and height must be > 0", + m.width, m.height + )); + } + } + + // Direct neighbors: names must be present, unique, and distinct from + // this machine. + let mut names = HashSet::new(); + for sc in &self.screens { + if sc.name.trim().is_empty() { + return Err("a [[screens]] entry has an empty name".to_string()); + } + if sc.name == me { + return Err(format!( + "screen \"{}\" shares this machine's identity.name; remote screens need distinct names", + sc.name + )); + } + if !names.insert(sc.name.as_str()) { + return Err(format!("duplicate screen name \"{}\"", sc.name)); + } + } + + // Each local edge can hold at most one direct neighbor. + let mut local_edges: HashMap<_, &str> = HashMap::new(); + for sc in &self.screens { + let edge = sc.position.local_edge(); + if let Some(other) = local_edges.insert(edge, sc.name.as_str()) { + return Err(format!( + "screens \"{other}\" and \"{}\" are both on this machine's {edge:?} edge; \ + each edge holds at most one neighbor", + sc.name + )); + } + } + + // Adjacency edges must not be self-loops, and must not give one + // screen two different neighbors on the same edge. + // + // Names in `[[screen_adjacency]]` need NOT appear in `[[screens]]`: + // that block exists precisely to introduce screens more than one hop + // away, which are not this machine's direct neighbors. + let mut adjacency_edges = HashSet::new(); + for adj in &self.screen_adjacency { + if adj.screen == adj.neighbor { + return Err(format!( + "[[screen_adjacency]] links screen \"{}\" to itself", + adj.screen + )); + } + let edge = adj.position.local_edge(); + if !adjacency_edges.insert((adj.screen.as_str(), edge)) { + return Err(format!( + "[[screen_adjacency]] gives screen \"{}\" two neighbors on its {edge:?} edge", + adj.screen + )); + } + } + + // Every screen named in the adjacency graph must connect back to this + // machine through some chain of edges — otherwise the block is dead + // (a typo, or an island the cursor can never reach). Inverse edges are + // implicit, so reachability is undirected. Start from this machine and + // its direct neighbors, then expand to a fixpoint. + let mut reachable: HashSet<&str> = HashSet::new(); + reachable.insert(me); + for sc in &self.screens { + reachable.insert(sc.name.as_str()); + } + loop { + let mut changed = false; + for adj in &self.screen_adjacency { + let s = reachable.contains(adj.screen.as_str()); + let n = reachable.contains(adj.neighbor.as_str()); + if s ^ n { + reachable.insert(adj.screen.as_str()); + reachable.insert(adj.neighbor.as_str()); + changed = true; + } + } + if !changed { + break; + } + } + for adj in &self.screen_adjacency { + for name in [&adj.screen, &adj.neighbor] { + if !reachable.contains(name.as_str()) { + return Err(format!( + "[[screen_adjacency]] screen \"{name}\" is not connected to this \ + machine \"{me}\" through any chain of screens" + )); + } + } + } + + Ok(()) + } } /// An adjacency edge between two screens in the full screen graph. @@ -112,6 +255,19 @@ impl Default for ClipboardConfig { } } +/// One monitor attached to this machine. Position is given by the top-left +/// `x`/`y` offset within the machine's combined desktop; both default to 0 +/// for a single-monitor setup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitorConfig { + pub width: u32, + pub height: u32, + #[serde(default)] + pub x: i32, + #[serde(default)] + pub y: i32, +} + /// A remote screen definition. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScreenConfig { @@ -209,4 +365,185 @@ fingerprint = "SHA256:abc123" assert_eq!(config.screens[0].name, "laptop-right"); assert_eq!(config.screens[0].position, Position::Right); } + + /// Build a screen with a name and position, no address. + fn screen(name: &str, position: Position) -> ScreenConfig { + ScreenConfig { + name: name.to_string(), + address: None, + position, + fingerprint: None, + } + } + + #[test] + fn valid_multi_hop_layout_passes() { + // desk: [center] -- right --> [right] -- right --> [far-right] + let toml_str = r#" +[identity] +name = "center" + +[[screens]] +name = "right" +position = "Right" + +[[screen_adjacency]] +screen = "right" +neighbor = "far-right" +position = "Right" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.validate().is_ok()); + } + + #[test] + fn default_config_validates() { + assert!(Config::default().validate().is_ok()); + } + + #[test] + fn duplicate_screen_name_rejected() { + let mut config = Config::default(); + config.identity.name = "center".to_string(); + config.screens = vec![ + screen("laptop", Position::Left), + screen("laptop", Position::Right), + ]; + let err = config.validate().unwrap_err(); + assert!(err.contains("duplicate screen name"), "{err}"); + } + + #[test] + fn two_screens_on_same_edge_rejected() { + let mut config = Config::default(); + config.identity.name = "center".to_string(); + config.screens = vec![screen("a", Position::Right), screen("b", Position::Right)]; + let err = config.validate().unwrap_err(); + assert!(err.contains("Right edge"), "{err}"); + } + + #[test] + fn screen_named_like_self_rejected() { + let mut config = Config::default(); + config.identity.name = "center".to_string(); + config.screens = vec![screen("center", Position::Left)]; + let err = config.validate().unwrap_err(); + assert!(err.contains("identity.name"), "{err}"); + } + + #[test] + fn adjacency_reachable_through_chain_passes() { + // center -[right]- right -[right]- far (far is 2 hops away and is + // introduced only by the adjacency entry, not by [[screens]]). + let mut config = Config::default(); + config.identity.name = "center".to_string(); + config.screens = vec![screen("right", Position::Right)]; + config.screen_adjacency = vec![ScreenAdjacency { + screen: "right".to_string(), + neighbor: "far".to_string(), + position: Position::Right, + }]; + assert!(config.validate().is_ok(), "{:?}", config.validate()); + } + + #[test] + fn orphaned_adjacency_block_rejected() { + // An adjacency edge between two screens that never connect back to + // this machine — a dead block, usually a typo. + let mut config = Config::default(); + config.identity.name = "center".to_string(); + config.screens = vec![screen("right", Position::Right)]; + config.screen_adjacency = vec![ScreenAdjacency { + screen: "island-a".to_string(), + neighbor: "island-b".to_string(), + position: Position::Right, + }]; + let err = config.validate().unwrap_err(); + assert!(err.contains("not connected"), "{err}"); + } + + #[test] + fn self_loop_adjacency_rejected() { + let mut config = Config::default(); + config.identity.name = "center".to_string(); + config.screens = vec![screen("right", Position::Right)]; + config.screen_adjacency = vec![ScreenAdjacency { + screen: "right".to_string(), + neighbor: "right".to_string(), + position: Position::Right, + }]; + let err = config.validate().unwrap_err(); + assert!(err.contains("itself"), "{err}"); + } + + #[test] + fn display_layout_falls_back_to_screen_dimensions() { + let mut config = Config::default(); + config.daemon.screen_width = 2560; + config.daemon.screen_height = 1440; + let bb = config.display_layout().bounding_box(); + assert_eq!((bb.width, bb.height), (2560, 1440)); + } + + #[test] + fn display_layout_uses_monitors_when_present() { + let config = Config { + monitors: vec![ + MonitorConfig { + width: 1920, + height: 1080, + x: 0, + y: 0, + }, + MonitorConfig { + width: 1920, + height: 1080, + x: 1920, + y: 0, + }, + ], + ..Config::default() + }; + let layout = config.display_layout(); + assert_eq!(layout.monitors.len(), 2); + // Combined desktop is 3840 wide. + assert_eq!(layout.bounding_box().width, 3840); + } + + #[test] + fn zero_size_monitor_rejected() { + let config = Config { + monitors: vec![MonitorConfig { + width: 1920, + height: 0, + x: 0, + y: 0, + }], + ..Config::default() + }; + let err = config.validate().unwrap_err(); + assert!(err.contains("zero dimension"), "{err}"); + } + + #[test] + fn conflicting_adjacency_edges_rejected() { + // right's Right edge is given two different neighbors. + let mut config = Config::default(); + config.identity.name = "center".to_string(); + config.screens = vec![screen("right", Position::Right)]; + config.screen_adjacency = vec![ + ScreenAdjacency { + screen: "right".to_string(), + neighbor: "far-a".to_string(), + position: Position::Right, + }, + ScreenAdjacency { + screen: "right".to_string(), + neighbor: "far-b".to_string(), + position: Position::Right, + }, + ]; + let err = config.validate().unwrap_err(); + assert!(err.contains("two neighbors on its Right edge"), "{err}"); + } } diff --git a/crates/cross-control-daemon/src/daemon.rs b/crates/cross-control-daemon/src/daemon.rs index 329a4b9..2d385a2 100644 --- a/crates/cross-control-daemon/src/daemon.rs +++ b/crates/cross-control-daemon/src/daemon.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; +use std::time::Duration; use cross_control_clipboard::ClipboardProvider; use cross_control_discovery::{ @@ -11,8 +12,8 @@ use cross_control_discovery::{ use cross_control_input::{InputCapture, InputEmulation}; use cross_control_protocol::QuicTransport; use cross_control_types::{ - CapturedEvent, ClipboardFormat, ClipboardMessage, ControlMessage, DeviceInfo, InputEvent, - InputMessage, KeyCode, MachineId, ScreenEdge, ScreenGeometry, + CapturedEvent, ClipboardFormat, ClipboardMessage, ControlMessage, DeviceInfo, DisplayLayout, + InputEvent, InputMessage, KeyCode, MachineId, ScreenEdge, ScreenGeometry, }; use tokio::sync::{mpsc, watch}; use tracing::{debug, info, warn}; @@ -20,6 +21,11 @@ use tracing::{debug, info, warn}; use crate::config::Config; use crate::error::DaemonError; use crate::session::PeerSession; +use crate::status::{status_file_path, PeerStatus, StatusSnapshot}; + +/// How often the daemon pings each peer for latency and writes the status +/// snapshot file the CLI reads. +const STATUS_INTERVAL: Duration = Duration::from_secs(2); /// Events processed by the daemon's main loop. pub enum DaemonEvent { @@ -39,8 +45,9 @@ pub enum DaemonEvent { }, /// A peer disconnected. PeerDisconnected(MachineId), - /// A fully handshaked session is ready (from a background task). - SessionReady { session: PeerSession }, + /// A fully handshaked session is ready (from a background task). Boxed + /// because `PeerSession` is much larger than the other variants. + SessionReady { session: Box }, /// Shutdown signal. Shutdown, } @@ -71,6 +78,10 @@ impl Default for DaemonStatus { pub struct Daemon { config: Config, machine_id: MachineId, + /// This machine's full monitor layout, sent to peers in the handshake. + layout: DisplayLayout, + /// Bounding box of `layout` — the combined desktop used for all edge + /// detection and cursor clamping. screen: ScreenGeometry, transport: QuicTransport, capture: Box, @@ -109,6 +120,14 @@ pub struct Daemon { /// skip it. When `Some`, the daemon syncs the text clipboard on each /// control hand-off. clipboard: Option>, + /// Latest status snapshot, shared with the file-writer task that the CLI + /// reads. Updated on peer, focus, and latency changes. + status_snapshot: Arc>, + /// Monotonic sequence for liveness pings. + next_ping_seq: u64, + /// Handle to the background task that persists the status snapshot, so it + /// can be stopped cleanly on shutdown before the file is removed. + status_writer: Option>, } impl Daemon { @@ -120,7 +139,8 @@ impl Daemon { capture: Box, emulation: Box, ) -> Self { - let screen = ScreenGeometry::new(config.daemon.screen_width, config.daemon.screen_height); + let layout = config.display_layout(); + let screen = layout.bounding_box(); let (event_tx, event_rx) = mpsc::channel(1024); let cursor_x = i32::try_from(screen.width / 2).unwrap_or(960); let cursor_y = i32::try_from(screen.height / 2).unwrap_or(540); @@ -153,6 +173,7 @@ impl Daemon { cursor_y, config, machine_id, + layout, screen, transport, capture, @@ -170,6 +191,9 @@ impl Daemon { local_fingerprint: None, dialed_addrs: Arc::new(Mutex::new(HashSet::new())), clipboard: None, + status_snapshot: Arc::new(Mutex::new(StatusSnapshot::default())), + next_ping_seq: 0, + status_writer: None, } } @@ -231,7 +255,7 @@ impl Daemon { let event_tx = self.event_tx.clone(); let our_id = self.machine_id; let our_name = self.config.identity.name.clone(); - let our_screen = self.screen.clone(); + let our_layout = self.layout.clone(); let local_devices = self.local_devices.clone(); tokio::spawn(async move { loop { @@ -239,12 +263,12 @@ impl Daemon { Ok(conn) => { let tx = event_tx.clone(); let name = our_name.clone(); - let screen = our_screen.clone(); + let layout = our_layout.clone(); let devs = local_devices.clone(); tokio::spawn(async move { let remote = conn.remote_address(); match perform_handshake_responder( - conn, our_id, &name, &screen, &devs, + conn, our_id, &name, &layout, &devs, ) .await { @@ -254,8 +278,11 @@ impl Daemon { remote = %remote, "inbound handshake complete" ); - let _ = - tx.send(DaemonEvent::SessionReady { session }).await; + let _ = tx + .send(DaemonEvent::SessionReady { + session: Box::new(session), + }) + .await; } Err(e) => { warn!( @@ -333,7 +360,7 @@ impl Daemon { let event_tx = self.event_tx.clone(); let our_id = self.machine_id; let our_name = self.config.identity.name.clone(); - let our_screen = self.screen.clone(); + let our_layout = self.layout.clone(); let local_devices = self.local_devices.clone(); let dialed = Arc::clone(&self.dialed_addrs); tokio::spawn(async move { @@ -352,7 +379,7 @@ impl Daemon { peer, our_id, our_name.clone(), - our_screen.clone(), + our_layout.clone(), local_devices.clone(), event_tx.clone(), Arc::clone(&dialed), @@ -371,17 +398,104 @@ impl Daemon { info!("daemon running"); self.broadcast_status(); + self.update_snapshot(); + + // Persist the status snapshot to disk on a timer so a separate + // `cross-control status` process can read live peer/latency/focus + // state. Runs as its own task; stopped in `shutdown()`. + { + let snapshot = Arc::clone(&self.status_snapshot); + self.status_writer = Some(tokio::spawn(async move { + let path = status_file_path(); + let mut ticker = tokio::time::interval(STATUS_INTERVAL); + loop { + ticker.tick().await; + let json = snapshot.lock().ok().and_then(|s| s.to_json().ok()); + if let Some(json) = json { + let _ = std::fs::write(&path, json); + } + } + })); + } - // Main event loop — purely event-driven, never blocks on I/O. - while let Some(event) = self.event_rx.recv().await { - if self.handle_event(event).await { - break; + // Ping peers on the same cadence so latency stays fresh. Ticks are + // handled on the event loop itself, keeping all session mutation + // single-owner. The first tick fires immediately — swallow it. + let mut ping_interval = tokio::time::interval(STATUS_INTERVAL); + ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ping_interval.tick().await; + + // Main event loop — event-driven, with a periodic ping tick. Never + // blocks on I/O. + loop { + tokio::select! { + maybe_event = self.event_rx.recv() => { + match maybe_event { + Some(event) => { + if self.handle_event(event).await { + break; + } + } + None => break, + } + } + _ = ping_interval.tick() => { + self.send_pings().await; + } } } self.shutdown().await } + /// Send a liveness ping to every peer and record the send time so the + /// round-trip time can be computed when the `Pong` returns. Refreshes the + /// status snapshot afterward. + async fn send_pings(&mut self) { + let seq = self.next_ping_seq; + self.next_ping_seq = self.next_ping_seq.wrapping_add(1); + for session in self.sessions.values_mut() { + if session + .control_tx + .send(&ControlMessage::Ping { seq }) + .await + .is_ok() + { + session.record_ping_sent(seq); + } + } + self.update_snapshot(); + } + + /// Rebuild the shared status snapshot from current sessions and focus. + /// Cheap — a handful of peers — and called only on peer/focus/latency + /// transitions, never on the mouse-move hot path. + fn update_snapshot(&self) { + let peers = self + .sessions + .values() + .map(|s| PeerStatus { + name: s.name.clone(), + machine_id: s.machine_id.to_string(), + address: s.connection.remote_address().to_string(), + state: s.state.to_string(), + latency_ms: s + .last_rtt() + .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)), + }) + .collect(); + let name_of = |id: MachineId| self.sessions.get(&id).map(|s| s.name.clone()); + let snapshot = StatusSnapshot { + name: self.config.identity.name.clone(), + controlling: self.controlling.and_then(name_of), + controlled_by: self.controlled_by.and_then(name_of), + peers, + }; + if let Ok(mut snap) = self.status_snapshot.lock() { + *snap = snapshot; + } + } + /// Handle a single daemon event. Returns `true` if the daemon should shut down. async fn handle_event(&mut self, event: DaemonEvent) -> bool { match event { @@ -398,7 +512,7 @@ impl Daemon { self.handle_peer_disconnected(machine_id).await; } DaemonEvent::SessionReady { session } => { - self.handle_session_ready(session); + self.handle_session_ready(*session); } DaemonEvent::Shutdown => { info!("shutting down"); @@ -409,20 +523,24 @@ impl Daemon { let tx = self.event_tx.clone(); let our_id = self.machine_id; let our_name = self.config.identity.name.clone(); - let our_screen = self.screen.clone(); + let our_layout = self.layout.clone(); let local_devices = self.local_devices.clone(); tokio::spawn(async move { match perform_handshake_responder( conn, our_id, &our_name, - &our_screen, + &our_layout, &local_devices, ) .await { Ok(session) => { - let _ = tx.send(DaemonEvent::SessionReady { session }).await; + let _ = tx + .send(DaemonEvent::SessionReady { + session: Box::new(session), + }) + .await; } Err(e) => { warn!(error = %e, "incoming connection handshake failed"); @@ -432,6 +550,10 @@ impl Daemon { } } self.broadcast_status(); + // Refresh the CLI-facing snapshot after every transition. Cheap (a + // few peers) and keeps peers/latency/focus current without touching + // the file — the writer task persists it on its own timer. + self.update_snapshot(); false } @@ -833,6 +955,9 @@ impl Daemon { } ControlMessage::Pong { seq } => { debug!(peer = %machine_id, seq, "received pong"); + if let Some(session) = self.sessions.get_mut(&machine_id) { + session.record_pong(seq); + } } ControlMessage::Clipboard(cb_msg) => { self.handle_clipboard_message(machine_id, cb_msg).await; @@ -875,11 +1000,10 @@ impl Daemon { match msg { ClipboardMessage::Offer { formats, size_hint } => { debug!(peer = %peer_id, ?formats, size_hint, "received clipboard offer"); - // Prefer PlainText for now. HTML/PNG round-trip is defined in - // the wire protocol but no backend yet sets them. - let format = if formats.contains(&ClipboardFormat::PlainText) { - ClipboardFormat::PlainText - } else { + // Request the richest format the peer offers that we know how + // to apply: an image beats HTML, HTML beats plain text. + let Some(format) = richest_format(&formats) else { + debug!(peer = %peer_id, "offer had no format we support"); return; }; let request = ControlMessage::Clipboard(ClipboardMessage::Request { format }); @@ -893,17 +1017,27 @@ impl Daemon { let Some(clipboard) = self.clipboard.as_ref() else { return; }; - if format != ClipboardFormat::PlainText { - debug!(?format, "clipboard request for unsupported format"); - return; - } - let content = match clipboard.get().await { + let content = match clipboard.get_format(format).await { Ok(c) => c, Err(e) => { - debug!(error = %e, "clipboard get failed; ignoring request"); + debug!(?format, error = %e, "clipboard get failed; ignoring request"); return; } }; + // Enforce the configured size cap before putting a large + // payload on the wire. Streaming large images is a follow-up; + // for now oversized content is simply not synced. + let max = self.config.clipboard.max_size; + if content.size() > max { + warn!( + peer = %peer_id, + size = content.size(), + max, + ?format, + "clipboard content exceeds max_size; not sending" + ); + return; + } let data = ControlMessage::Clipboard(ClipboardMessage::Data(content)); if let Some(session) = self.sessions.get_mut(&peer_id) { if let Err(e) = session.control_tx.send(&data).await { @@ -912,6 +1046,16 @@ impl Daemon { } } ClipboardMessage::Data(content) => { + let max = self.config.clipboard.max_size; + if content.size() > max { + warn!( + peer = %peer_id, + size = content.size(), + max, + "received oversized clipboard content; rejecting" + ); + return; + } let Some(clipboard) = self.clipboard.as_mut() else { return; }; @@ -1026,6 +1170,13 @@ impl Daemon { async fn shutdown(&mut self) -> Result<(), DaemonError> { info!("daemon shutting down"); + // Stop the status writer before removing its file so it can't + // recreate it after cleanup. + if let Some(writer) = self.status_writer.take() { + writer.abort(); + } + let _ = std::fs::remove_file(status_file_path()); + // Disconnect all peers let peer_ids: Vec = self.sessions.keys().copied().collect(); for peer_id in peer_ids { @@ -1051,6 +1202,19 @@ impl Daemon { } } +/// Pick the richest clipboard format from an offer that we can apply locally: +/// image first, then HTML, then plain text. Returns `None` if the offer +/// contains no format we recognise. +fn richest_format(formats: &[ClipboardFormat]) -> Option { + [ + ClipboardFormat::Png, + ClipboardFormat::Html, + ClipboardFormat::PlainText, + ] + .into_iter() + .find(|f| formats.contains(f)) +} + /// Try to reserve a `SocketAddr` for an outbound dial. Returns `true` if this /// caller now owns the dial; `false` if another task got there first. fn claim_dial(dialed: &Arc>>, addr: SocketAddr) -> bool { @@ -1079,7 +1243,7 @@ fn spawn_outbound_handshake( peer: Peer, our_id: MachineId, our_name: String, - our_screen: ScreenGeometry, + our_layout: DisplayLayout, local_devices: Vec, event_tx: mpsc::Sender, dialed: Arc>>, @@ -1092,7 +1256,7 @@ fn spawn_outbound_handshake( conn, our_id, &our_name, - &our_screen, + &our_layout, &local_devices, ) .await @@ -1104,7 +1268,9 @@ fn spawn_outbound_handshake( "outbound handshake complete" ); if event_tx - .send(DaemonEvent::SessionReady { session }) + .send(DaemonEvent::SessionReady { + session: Box::new(session), + }) .await .is_err() { @@ -1140,13 +1306,13 @@ async fn perform_handshake_responder( conn: cross_control_protocol::PeerConnection, our_id: MachineId, our_name: &str, - our_screen: &ScreenGeometry, + our_layout: &DisplayLayout, local_devices: &[DeviceInfo], ) -> Result { let (control_tx, control_rx) = conn.accept_control_stream().await?; let mut session = PeerSession::new(conn, control_tx, control_rx); session - .handshake_responder(our_id, our_name, our_screen) + .handshake_responder(our_id, our_name, our_layout) .await?; session.announce_devices(local_devices).await?; Ok(session) @@ -1158,13 +1324,13 @@ async fn perform_handshake_initiator( conn: cross_control_protocol::PeerConnection, our_id: MachineId, our_name: &str, - our_screen: &ScreenGeometry, + our_layout: &DisplayLayout, local_devices: &[DeviceInfo], ) -> Result { let (control_tx, control_rx) = conn.open_control_stream().await?; let mut session = PeerSession::new(conn, control_tx, control_rx); session - .handshake_initiator(our_id, our_name, our_screen) + .handshake_initiator(our_id, our_name, our_layout) .await?; session.announce_devices(local_devices).await?; Ok(session) diff --git a/crates/cross-control-daemon/src/lib.rs b/crates/cross-control-daemon/src/lib.rs index 4fdc200..d2ac8d1 100644 --- a/crates/cross-control-daemon/src/lib.rs +++ b/crates/cross-control-daemon/src/lib.rs @@ -9,7 +9,9 @@ pub mod error; pub mod session; pub mod setup; pub mod state; +pub mod status; pub use config::Config; pub use daemon::{Daemon, DaemonEvent, DaemonStatus}; pub use error::DaemonError; +pub use status::{PeerStatus, StatusSnapshot}; diff --git a/crates/cross-control-daemon/src/session.rs b/crates/cross-control-daemon/src/session.rs index 6c24ebf..4c95683 100644 --- a/crates/cross-control-daemon/src/session.rs +++ b/crates/cross-control-daemon/src/session.rs @@ -1,10 +1,11 @@ //! Peer session management: handshake, enter/leave, device announce. use std::collections::HashMap; +use std::time::{Duration, Instant}; use cross_control_protocol::{MessageReceiver, MessageSender, PeerConnection}; use cross_control_types::{ - ControlMessage, DeviceId, DeviceInfo, InputMessage, MachineId, ProtocolVersion, ScreenGeometry, + ControlMessage, DeviceId, DeviceInfo, DisplayLayout, InputMessage, MachineId, ProtocolVersion, VirtualDeviceId, PROTOCOL_VERSION, }; use tracing::{debug, info, warn}; @@ -16,7 +17,7 @@ use crate::state::SessionState; pub struct PeerSession { pub machine_id: MachineId, pub name: String, - pub remote_screen: ScreenGeometry, + pub remote_layout: DisplayLayout, pub state: SessionState, pub control_tx: MessageSender, control_rx: Option, @@ -27,6 +28,38 @@ pub struct PeerSession { /// Devices announced by the remote peer. pub remote_devices: Vec, pub connection: PeerConnection, + /// Liveness-ping bookkeeping used to derive the round-trip time. + latency: LatencyTracker, +} + +/// Tracks in-flight liveness pings and the most recent round-trip time for a +/// single peer. Split out so the timing logic is unit-testable without a live +/// QUIC connection. +#[derive(Default)] +struct LatencyTracker { + /// Sequence number → send time for pings awaiting a pong. + pending: HashMap, + /// Most recent measured round-trip time, if any ping has completed. + last_rtt: Option, +} + +impl LatencyTracker { + /// Record that a ping with `seq` was just sent. Prunes pings older than a + /// few cycles that never got a reply so the map can't grow unbounded. + fn record_sent(&mut self, seq: u64) { + let now = Instant::now(); + self.pending + .retain(|_, sent| now.duration_since(*sent) < Duration::from_secs(30)); + self.pending.insert(seq, now); + } + + /// Match a pong to its ping and update the round-trip time. Unknown or + /// duplicate sequence numbers are ignored. + fn record_pong(&mut self, seq: u64) { + if let Some(sent) = self.pending.remove(&seq) { + self.last_rtt = Some(Instant::now().duration_since(sent)); + } + } } impl PeerSession { @@ -39,7 +72,7 @@ impl PeerSession { Self { machine_id: MachineId::default(), name: String::new(), - remote_screen: ScreenGeometry::new(1920, 1080), + remote_layout: DisplayLayout::default(), state: SessionState::Connected, control_tx, control_rx: Some(control_rx), @@ -48,9 +81,27 @@ impl PeerSession { device_map: HashMap::new(), remote_devices: Vec::new(), connection, + latency: LatencyTracker::default(), } } + /// Record that a liveness `Ping` with `seq` was just sent, so its `Pong` + /// can be timed. + pub fn record_ping_sent(&mut self, seq: u64) { + self.latency.record_sent(seq); + } + + /// Match an incoming `Pong` to its `Ping` and update the round-trip time. + /// Unknown or duplicate sequence numbers are ignored. + pub fn record_pong(&mut self, seq: u64) { + self.latency.record_pong(seq); + } + + /// The most recent round-trip time, if a ping has completed. + pub fn last_rtt(&self) -> Option { + self.latency.last_rtt + } + /// Take ownership of the control receiver for spawning a reader task. /// Returns `None` if already taken. pub fn take_control_rx(&mut self) -> Option { @@ -70,13 +121,13 @@ impl PeerSession { &mut self, our_id: MachineId, our_name: &str, - our_screen: &ScreenGeometry, + our_layout: &DisplayLayout, ) -> Result<(), DaemonError> { let hello = ControlMessage::Hello { version: PROTOCOL_VERSION, machine_id: our_id, name: our_name.to_string(), - screen: our_screen.clone(), + layout: our_layout.clone(), }; self.control_tx.send(&hello).await?; self.state = SessionState::HelloSent; @@ -95,12 +146,12 @@ impl PeerSession { version, machine_id, name, - screen, + layout, } => { verify_version(version)?; self.machine_id = machine_id; self.name.clone_from(&name); - self.remote_screen = screen; + self.remote_layout = layout; self.state = SessionState::Idle; info!(peer = %name, id = %machine_id, "handshake complete (initiator)"); Ok(()) @@ -120,7 +171,7 @@ impl PeerSession { &mut self, our_id: MachineId, our_name: &str, - our_screen: &ScreenGeometry, + our_layout: &DisplayLayout, ) -> Result<(), DaemonError> { let rx = self .control_rx @@ -135,18 +186,18 @@ impl PeerSession { version, machine_id, name, - screen, + layout, } => { verify_version(version)?; self.machine_id = machine_id; self.name.clone_from(&name); - self.remote_screen = screen; + self.remote_layout = layout; let welcome = ControlMessage::Welcome { version: PROTOCOL_VERSION, machine_id: our_id, name: our_name.to_string(), - screen: our_screen.clone(), + layout: our_layout.clone(), }; self.control_tx.send(&welcome).await?; self.state = SessionState::Idle; @@ -279,3 +330,40 @@ fn verify_version(remote: ProtocolVersion) -> Result<(), DaemonError> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latency_records_round_trip() { + let mut lt = LatencyTracker::default(); + assert!(lt.last_rtt.is_none()); + lt.record_sent(1); + lt.record_pong(1); + assert!(lt.last_rtt.is_some()); + // The ping is consumed once matched. + assert!(lt.pending.is_empty()); + } + + #[test] + fn pong_for_unknown_seq_is_ignored() { + let mut lt = LatencyTracker::default(); + lt.record_sent(1); + // A pong for a seq we never sent leaves state untouched. + lt.record_pong(99); + assert!(lt.last_rtt.is_none()); + assert_eq!(lt.pending.len(), 1); + } + + #[test] + fn duplicate_pong_is_ignored() { + let mut lt = LatencyTracker::default(); + lt.record_sent(1); + lt.record_pong(1); + let first = lt.last_rtt; + // A second pong for the same seq has no matching ping to consume. + lt.record_pong(1); + assert_eq!(lt.last_rtt, first); + } +} diff --git a/crates/cross-control-daemon/src/setup.rs b/crates/cross-control-daemon/src/setup.rs index 46272c3..f77fcbf 100644 --- a/crates/cross-control-daemon/src/setup.rs +++ b/crates/cross-control-daemon/src/setup.rs @@ -22,6 +22,9 @@ pub fn load_config(path: Option<&str>) -> Result { .map_err(|e| DaemonError::Config(format!("failed to read config: {e}")))?; let config: Config = toml::from_str(&content) .map_err(|e| DaemonError::Config(format!("failed to parse config: {e}")))?; + config + .validate() + .map_err(|e| DaemonError::Config(format!("invalid config: {e}")))?; info!(path = %config_path.display(), "loaded config"); Ok(config) } else { diff --git a/crates/cross-control-daemon/src/status.rs b/crates/cross-control-daemon/src/status.rs new file mode 100644 index 0000000..86c7cd9 --- /dev/null +++ b/crates/cross-control-daemon/src/status.rs @@ -0,0 +1,102 @@ +//! Live daemon status snapshot, shared with the CLI via a small JSON file. +//! +//! The daemon owns all peer state inside its single-threaded event loop, so +//! there is no shared-memory channel a separate `cross-control status` +//! process could read. Rather than stand up a full IPC socket server, the +//! daemon periodically serialises a [`StatusSnapshot`] to a file in the +//! runtime directory (next to the PID file) and the CLI deserialises it. +//! +//! This mirrors the existing PID-file pattern: cheap, crash-safe (a stale +//! file is simply ignored once the PID is gone), and good enough for a +//! human-facing status readout. A richer query/subscribe IPC channel can +//! replace it later without changing the wire types the CLI renders. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// A point-in-time view of the running daemon, written to disk for the CLI. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StatusSnapshot { + /// This machine's configured name. + pub name: String, + /// Peer we are currently controlling (sending input to), if any. + pub controlling: Option, + /// Peer currently controlling us (sending us input), if any. + pub controlled_by: Option, + /// Every peer we currently hold a session with. + pub peers: Vec, +} + +/// Per-peer status line. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeerStatus { + /// Peer's advertised name. + pub name: String, + /// Peer's machine id (UUID string). + pub machine_id: String, + /// Remote socket address of the QUIC connection. + pub address: String, + /// Session state (`Idle`, `Controlling`, `Controlled`, …). + pub state: String, + /// Last measured round-trip time in milliseconds, if a ping has + /// completed a full round trip yet. + pub latency_ms: Option, +} + +impl StatusSnapshot { + /// Serialise to pretty JSON. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Parse from JSON. + pub fn from_json(s: &str) -> Result { + serde_json::from_str(s) + } +} + +/// Path of the status snapshot file. Sits alongside the PID file so both +/// share the same lifetime and cleanup story. +pub fn status_file_path() -> PathBuf { + dirs::runtime_dir() + .or_else(dirs::state_dir) + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("cross-control.status.json") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_json_round_trip() { + let snap = StatusSnapshot { + name: "center".to_string(), + controlling: Some("laptop-right".to_string()), + controlled_by: None, + peers: vec![PeerStatus { + name: "laptop-right".to_string(), + machine_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + address: "192.168.1.42:24800".to_string(), + state: "Controlling".to_string(), + latency_ms: Some(3), + }], + }; + let json = snap.to_json().unwrap(); + let back = StatusSnapshot::from_json(&json).unwrap(); + assert_eq!(back.name, "center"); + assert_eq!(back.controlling.as_deref(), Some("laptop-right")); + assert_eq!(back.peers.len(), 1); + assert_eq!(back.peers[0].latency_ms, Some(3)); + } + + #[test] + fn empty_snapshot_round_trips() { + let snap = StatusSnapshot::default(); + let json = snap.to_json().unwrap(); + let back = StatusSnapshot::from_json(&json).unwrap(); + assert!(back.peers.is_empty()); + assert!(back.controlling.is_none()); + } +} diff --git a/crates/cross-control-daemon/tests/daemon_integration.rs b/crates/cross-control-daemon/tests/daemon_integration.rs index f0df93f..882abb9 100644 --- a/crates/cross-control-daemon/tests/daemon_integration.rs +++ b/crates/cross-control-daemon/tests/daemon_integration.rs @@ -12,8 +12,8 @@ use cross_control_daemon::config::{ use cross_control_daemon::{Daemon, DaemonEvent, DaemonStatus}; use cross_control_input::mock::{MockCapture, MockEmulation, MockEmulationHandle}; use cross_control_types::{ - ButtonState, CapturedEvent, ClipboardContent, DeviceCapability, DeviceId, DeviceInfo, - InputEvent, KeyCode, MachineId, Position, + ButtonState, CapturedEvent, ClipboardContent, ClipboardFormat, DeviceCapability, DeviceId, + DeviceInfo, InputEvent, KeyCode, MachineId, Position, }; use tokio::sync::{mpsc, watch}; use tracing_subscriber::EnvFilter; @@ -1054,3 +1054,91 @@ async fn test_clipboard_text_syncs_on_control_handoff() { pair.shutdown().await; } + +/// Drive the pair to the point where A is controlling B — the trigger for the +/// clipboard hand-off (Offer → Request → Data). +async fn drive_to_controlling(pair: &mut TestPair) { + wait_for_status(&mut pair.status_a, Duration::from_secs(5), |s| { + s.session_count >= 1 + }) + .await + .expect("handshake A"); + wait_for_status(&mut pair.status_b, Duration::from_secs(5), |s| { + s.session_count >= 1 + }) + .await + .expect("handshake B"); + tokio::time::sleep(Duration::from_millis(200)).await; + + for _ in 0..5 { + pair.feed_a + .send(CapturedEvent { + device_id: DeviceId(2), + timestamp_us: 1000, + event: InputEvent::MouseMove { dx: 500, dy: 0 }, + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + wait_for_status(&mut pair.status_a, Duration::from_secs(5), |s| { + s.controlling.is_some() + }) + .await + .expect("A should be controlling"); +} + +#[tokio::test] +async fn test_clipboard_html_syncs_on_control_handoff() { + let mut pair = setup_pair().await; + pair.clipboard_a + .set(ClipboardContent::html("bold from A")) + .await + .expect("set A clipboard"); + + drive_to_controlling(&mut pair).await; + + // B should request HTML (the richest format offered) and apply it. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let mut got: Option = None; + while tokio::time::Instant::now() < deadline { + if let Ok(content) = pair.clipboard_b.get_format(ClipboardFormat::Html).await { + got = content.as_html().map(str::to_string); + if got.is_some() { + break; + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert_eq!(got.as_deref(), Some("bold from A")); + + pair.shutdown().await; +} + +#[tokio::test] +async fn test_clipboard_image_syncs_on_control_handoff() { + let mut pair = setup_pair().await; + // The bytes are opaque to the mock backend (no PNG decode), so any + // payload exercises the Offer → Request(Png) → Data round-trip. + let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4]; + pair.clipboard_a + .set(ClipboardContent::png(png_bytes.clone())) + .await + .expect("set A clipboard"); + + drive_to_controlling(&mut pair).await; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let mut got: Option> = None; + while tokio::time::Instant::now() < deadline { + if let Ok(content) = pair.clipboard_b.get_format(ClipboardFormat::Png).await { + got = Some(content.data); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert_eq!(got.as_deref(), Some(png_bytes.as_slice())); + + pair.shutdown().await; +} diff --git a/crates/cross-control-input/src/linux/capture.rs b/crates/cross-control-input/src/linux/capture.rs index dd46c70..7d0f538 100644 --- a/crates/cross-control-input/src/linux/capture.rs +++ b/crates/cross-control-input/src/linux/capture.rs @@ -300,9 +300,8 @@ fn diagnose_no_devices() -> InputError { // Event files exist — check if we can read any let any_readable = event_files.iter().any(|f| { - fs::metadata(f.path()) - .map(|m| m.mode() & 0o004 != 0) // world-readable - .unwrap_or(false) + // world-readable, or openable by us + fs::metadata(f.path()).is_ok_and(|m| m.mode() & 0o004 != 0) || fs::File::open(f.path()).is_ok() }); diff --git a/crates/cross-control-protocol/src/wire.rs b/crates/cross-control-protocol/src/wire.rs index 5c7032b..cc037d6 100644 --- a/crates/cross-control-protocol/src/wire.rs +++ b/crates/cross-control-protocol/src/wire.rs @@ -37,7 +37,7 @@ pub fn decode_message>(payload: &[u8]) -> Result mod tests { use super::*; use cross_control_types::message::{ControlMessage, Message, PROTOCOL_VERSION}; - use cross_control_types::screen::ScreenGeometry; + use cross_control_types::screen::DisplayLayout; use cross_control_types::MachineId; #[test] @@ -46,7 +46,7 @@ mod tests { version: PROTOCOL_VERSION, machine_id: MachineId::new(), name: "test".to_string(), - screen: ScreenGeometry::new(1920, 1080), + layout: DisplayLayout::single(1920, 1080), }); let bytes = encode_message(&msg).unwrap(); diff --git a/crates/cross-control-protocol/tests/quic_roundtrip.rs b/crates/cross-control-protocol/tests/quic_roundtrip.rs index 06371e2..f0112a4 100644 --- a/crates/cross-control-protocol/tests/quic_roundtrip.rs +++ b/crates/cross-control-protocol/tests/quic_roundtrip.rs @@ -3,8 +3,8 @@ use std::net::SocketAddr; use cross_control_types::{ - ControlMessage, DeviceCapability, DeviceId, DeviceInfo, InputEvent, InputMessage, KeyCode, - MachineId, ScreenGeometry, PROTOCOL_VERSION, + ControlMessage, DeviceCapability, DeviceId, DeviceInfo, DisplayLayout, InputEvent, + InputMessage, KeyCode, MachineId, PROTOCOL_VERSION, }; use cross_control_types::ButtonState; @@ -35,7 +35,7 @@ async fn hello_welcome_handshake_on_loopback() { version, machine_id: _, name, - screen: _, + layout: _, } => { assert_eq!(version, PROTOCOL_VERSION); assert_eq!(name, "test-client"); @@ -48,7 +48,7 @@ async fn hello_welcome_handshake_on_loopback() { version: PROTOCOL_VERSION, machine_id: MachineId::new(), name: "test-server".to_string(), - screen: ScreenGeometry::new(2560, 1440), + layout: DisplayLayout::single(2560, 1440), }; tx.send(&welcome).await.unwrap(); @@ -96,7 +96,7 @@ async fn hello_welcome_handshake_on_loopback() { version: PROTOCOL_VERSION, machine_id: MachineId::new(), name: "test-client".to_string(), - screen: ScreenGeometry::new(1920, 1080), + layout: DisplayLayout::single(1920, 1080), }; tx.send(&hello).await.unwrap(); diff --git a/crates/cross-control-types/src/clipboard.rs b/crates/cross-control-types/src/clipboard.rs index 27f43b7..352a0c4 100644 --- a/crates/cross-control-types/src/clipboard.rs +++ b/crates/cross-control-types/src/clipboard.rs @@ -31,6 +31,24 @@ impl ClipboardContent { } } + /// Create HTML clipboard content. + #[must_use] + pub fn html(s: &str) -> Self { + Self { + format: ClipboardFormat::Html, + data: s.as_bytes().to_vec(), + } + } + + /// Create PNG image clipboard content from encoded PNG bytes. + #[must_use] + pub fn png(bytes: Vec) -> Self { + Self { + format: ClipboardFormat::Png, + data: bytes, + } + } + /// Try to interpret the data as UTF-8 text. #[must_use] pub fn as_text(&self) -> Option<&str> { @@ -41,6 +59,16 @@ impl ClipboardContent { } } + /// Try to interpret the data as a UTF-8 HTML fragment. + #[must_use] + pub fn as_html(&self) -> Option<&str> { + if self.format == ClipboardFormat::Html { + std::str::from_utf8(&self.data).ok() + } else { + None + } + } + /// Size of the content in bytes. #[must_use] pub fn size(&self) -> usize { @@ -82,4 +110,20 @@ mod tests { let content = ClipboardContent::text("abc"); assert_eq!(content.size(), 3); } + + #[test] + fn clipboard_html_roundtrip() { + let content = ClipboardContent::html("hi"); + assert_eq!(content.format, ClipboardFormat::Html); + assert_eq!(content.as_html(), Some("hi")); + assert_eq!(content.as_text(), None); + } + + #[test] + fn clipboard_png_helper_sets_format() { + let content = ClipboardContent::png(vec![0x89, 0x50, 0x4E, 0x47]); + assert_eq!(content.format, ClipboardFormat::Png); + assert_eq!(content.as_text(), None); + assert_eq!(content.as_html(), None); + } } diff --git a/crates/cross-control-types/src/lib.rs b/crates/cross-control-types/src/lib.rs index 7e01f19..0249e51 100644 --- a/crates/cross-control-types/src/lib.rs +++ b/crates/cross-control-types/src/lib.rs @@ -20,4 +20,4 @@ pub use machine::MachineId; pub use message::{ ClipboardMessage, ControlMessage, InputMessage, Message, ProtocolVersion, PROTOCOL_VERSION, }; -pub use screen::{Barrier, BarrierId, Position, ScreenEdge, ScreenGeometry}; +pub use screen::{Barrier, BarrierId, DisplayLayout, Position, ScreenEdge, ScreenGeometry}; diff --git a/crates/cross-control-types/src/message.rs b/crates/cross-control-types/src/message.rs index 34d1754..85e224e 100644 --- a/crates/cross-control-types/src/message.rs +++ b/crates/cross-control-types/src/message.rs @@ -9,10 +9,13 @@ use crate::clipboard::{ClipboardContent, ClipboardFormat}; use crate::device::{DeviceId, DeviceInfo}; use crate::event::InputEvent; use crate::machine::MachineId; -use crate::screen::{ScreenEdge, ScreenGeometry}; +use crate::screen::{DisplayLayout, ScreenEdge}; /// Current protocol version. -pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 0, minor: 1 }; +/// +/// Bumped to 0.2 when `Hello`/`Welcome`/`ScreenUpdate` began carrying a +/// [`DisplayLayout`] (multi-monitor) instead of a single `ScreenGeometry`. +pub const PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 0, minor: 2 }; /// Protocol version for compatibility negotiation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)] @@ -43,7 +46,7 @@ pub enum ControlMessage { version: ProtocolVersion, machine_id: MachineId, name: String, - screen: ScreenGeometry, + layout: DisplayLayout, }, /// Response to Hello. @@ -51,7 +54,7 @@ pub enum ControlMessage { version: ProtocolVersion, machine_id: MachineId, name: String, - screen: ScreenGeometry, + layout: DisplayLayout, }, /// Announce a new input device. @@ -60,8 +63,8 @@ pub enum ControlMessage { /// An input device was removed. DeviceGone { device_id: DeviceId }, - /// Screen geometry changed (e.g. monitor added/removed). - ScreenUpdate(ScreenGeometry), + /// Display layout changed (e.g. monitor added/removed). + ScreenUpdate(DisplayLayout), /// Cursor is crossing to the remote machine. Enter { @@ -148,7 +151,7 @@ mod tests { version: PROTOCOL_VERSION, machine_id: MachineId::new(), name: "test-machine".to_string(), - screen: ScreenGeometry::new(1920, 1080), + layout: DisplayLayout::single(1920, 1080), }); let _decoded = bincode_roundtrip(&msg); } @@ -159,7 +162,7 @@ mod tests { version: PROTOCOL_VERSION, machine_id: MachineId::new(), name: "remote".to_string(), - screen: ScreenGeometry::new(2560, 1440), + layout: DisplayLayout::single(2560, 1440), }); let _decoded = bincode_roundtrip(&msg); } @@ -244,6 +247,6 @@ mod tests { #[test] fn protocol_version_display() { - assert_eq!(PROTOCOL_VERSION.to_string(), "0.1"); + assert_eq!(PROTOCOL_VERSION.to_string(), "0.2"); } } diff --git a/crates/cross-control-types/src/screen.rs b/crates/cross-control-types/src/screen.rs index 27d45e7..d974f57 100644 --- a/crates/cross-control-types/src/screen.rs +++ b/crates/cross-control-types/src/screen.rs @@ -47,6 +47,79 @@ impl ScreenGeometry { } } +/// A machine's full display layout: one or more monitors, each a +/// [`ScreenGeometry`] rectangle positioned by its `x`/`y` offset. +/// +/// A single-monitor machine has one entry at the origin. A dual-monitor +/// setup with two 1920×1080 panels side by side has two entries at `x = 0` +/// and `x = 1920`. Edge detection and cursor clamping operate on the +/// [`bounding_box`](DisplayLayout::bounding_box) — the union rectangle of all +/// monitors — so the cursor traverses the whole combined desktop before it +/// crosses to another machine. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)] +pub struct DisplayLayout { + pub monitors: Vec, +} + +impl DisplayLayout { + /// A single monitor at the origin. + #[must_use] + pub fn single(width: u32, height: u32) -> Self { + Self { + monitors: vec![ScreenGeometry::new(width, height)], + } + } + + /// A layout from an explicit list of monitors. + #[must_use] + pub fn new(monitors: Vec) -> Self { + Self { monitors } + } + + /// The union rectangle covering every monitor. Falls back to a 1920×1080 + /// origin rectangle if the layout is somehow empty, so downstream edge + /// math never sees a zero-sized screen. + #[must_use] + pub fn bounding_box(&self) -> ScreenGeometry { + let mut iter = self.monitors.iter(); + let Some(first) = iter.next() else { + return ScreenGeometry::new(1920, 1080); + }; + let mut min_x = first.x; + let mut min_y = first.y; + let mut max_x = first + .x + .saturating_add(i32::try_from(first.width).unwrap_or(i32::MAX)); + let mut max_y = first + .y + .saturating_add(i32::try_from(first.height).unwrap_or(i32::MAX)); + for m in iter { + min_x = min_x.min(m.x); + min_y = min_y.min(m.y); + max_x = max_x.max(m.x.saturating_add(i32::try_from(m.width).unwrap_or(i32::MAX))); + max_y = max_y.max(m.y.saturating_add(i32::try_from(m.height).unwrap_or(i32::MAX))); + } + ScreenGeometry { + width: u32::try_from(max_x.saturating_sub(min_x)).unwrap_or(0), + height: u32::try_from(max_y.saturating_sub(min_y)).unwrap_or(0), + x: min_x, + y: min_y, + } + } + + /// Whether a coordinate is on the given outer edge of the combined desktop. + #[must_use] + pub fn is_at_edge(&self, px: i32, py: i32, edge: ScreenEdge) -> bool { + self.bounding_box().is_at_edge(px, py, edge) + } +} + +impl Default for DisplayLayout { + fn default() -> Self { + Self::single(1920, 1080) + } +} + /// Which edge of the screen a barrier is on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Encode, Decode)] pub enum ScreenEdge { @@ -178,6 +251,74 @@ mod tests { assert_eq!(barrier, decoded); } + #[test] + fn layout_single_bounding_box() { + let layout = DisplayLayout::single(1920, 1080); + let bb = layout.bounding_box(); + assert_eq!((bb.x, bb.y, bb.width, bb.height), (0, 0, 1920, 1080)); + } + + #[test] + fn layout_side_by_side_bounding_box() { + // Two 1920×1080 monitors side by side → a 3840×1080 desktop. + let layout = DisplayLayout::new(vec![ + ScreenGeometry::new(1920, 1080), + ScreenGeometry { + width: 1920, + height: 1080, + x: 1920, + y: 0, + }, + ]); + let bb = layout.bounding_box(); + assert_eq!((bb.x, bb.y, bb.width, bb.height), (0, 0, 3840, 1080)); + // The right edge is now at x = 3839, not 1919. + assert!(layout.is_at_edge(3839, 500, ScreenEdge::Right)); + assert!(!layout.is_at_edge(1919, 500, ScreenEdge::Right)); + assert!(layout.is_at_edge(0, 500, ScreenEdge::Left)); + } + + #[test] + fn layout_stacked_with_offset_origin() { + // A monitor above another, second at negative y. + let layout = DisplayLayout::new(vec![ + ScreenGeometry::new(1920, 1080), + ScreenGeometry { + width: 1920, + height: 1080, + x: 0, + y: -1080, + }, + ]); + let bb = layout.bounding_box(); + assert_eq!((bb.x, bb.y, bb.width, bb.height), (0, -1080, 1920, 2160)); + assert!(layout.is_at_edge(500, -1080, ScreenEdge::Top)); + assert!(layout.is_at_edge(500, 1079, ScreenEdge::Bottom)); + } + + #[test] + fn layout_roundtrip() { + let layout = DisplayLayout::new(vec![ + ScreenGeometry::new(1920, 1080), + ScreenGeometry { + width: 2560, + height: 1440, + x: 1920, + y: 0, + }, + ]); + let config = bincode::config::standard(); + let bytes = bincode::encode_to_vec(&layout, config).unwrap(); + let (decoded, _): (DisplayLayout, _) = bincode::decode_from_slice(&bytes, config).unwrap(); + assert_eq!(layout, decoded); + } + + #[test] + fn empty_layout_falls_back() { + let bb = DisplayLayout::new(vec![]).bounding_box(); + assert_eq!((bb.width, bb.height), (1920, 1080)); + } + #[test] fn position_edge_mapping() { assert_eq!(Position::Left.local_edge(), ScreenEdge::Left); diff --git a/docs/protocol.md b/docs/protocol.md index 1049c9e..73f17ee 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -39,11 +39,11 @@ Maximum message size: 1 MiB (1,048,576 bytes). ### Control Messages -- `Hello { version, machine_id, name, screen }` - Initial handshake -- `Welcome { version, machine_id, name, screen }` - Handshake response +- `Hello { version, machine_id, name, layout }` - Initial handshake +- `Welcome { version, machine_id, name, layout }` - Handshake response - `DeviceAnnounce(DeviceInfo)` - New input device available - `DeviceGone { device_id }` - Device removed -- `ScreenUpdate(ScreenGeometry)` - Display geometry changed +- `ScreenUpdate(DisplayLayout)` - Display layout changed (monitor added/removed) - `Enter { edge, position }` - Cursor crossing to remote - `EnterAck` - Remote ready to receive input - `Leave { edge, position }` - Cursor returning to local @@ -64,4 +64,6 @@ Maximum message size: 1 MiB (1,048,576 bytes). The `Hello`/`Welcome` exchange includes a `ProtocolVersion { major, minor }`. Peers must have matching major versions. Minor version differences are tolerated (newer features are silently ignored by older peers). -Current version: **0.1** +`layout` is a `DisplayLayout { monitors: Vec }` — a machine's full monitor arrangement. A single-monitor machine sends one entry; edge detection runs against the union bounding box of all monitors. + +Current version: **0.2** diff --git a/examples/config.toml b/examples/config.toml index bcca015..a132da9 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -4,10 +4,14 @@ [daemon] port = 24800 bind = "0.0.0.0" -discovery = true +discovery = true # mDNS auto-discovery; set false on networks that block multicast log_level = "info" +screen_width = 1920 # this machine's screen size, used for edge detection +screen_height = 1080 [identity] +# This machine's name. Must be unique across your setup and must differ from +# every [[screens]] name below. Defaults to the system hostname if omitted. name = "workstation-left" [input] @@ -18,11 +22,57 @@ release_hotkey = ["LeftCtrl", "LeftShift", "Escape"] enabled = true max_size = 10485760 # 10 MiB -# Define remote screens and their position relative to this machine. -# You can have multiple [[screens]] entries for multi-machine setups. +# ── Monitors (optional) ────────────────────────────────────────────────── +# +# By default this machine is treated as one screen sized by +# daemon.screen_width/screen_height. For a multi-monitor setup, list each +# monitor with its size and top-left offset (x, y) within the combined +# desktop. Edge detection runs against the union of all monitors, so the +# cursor traverses the whole desktop before crossing to another machine. +# +# Example: two 1920x1080 monitors side by side (a 3840x1080 desktop): +# +# [[monitors]] +# width = 1920 +# height = 1080 +# x = 0 +# y = 0 +# +# [[monitors]] +# width = 1920 +# height = 1080 +# x = 1920 +# y = 0 + +# ── Screen layout ──────────────────────────────────────────────────────── +# +# [[screens]] declares a machine placed directly next to THIS one, and which +# edge of this screen the cursor crosses to reach it. `position` is one of +# Left, Right, Above, Below. Each edge can hold at most one direct neighbor. +# +# `address` is optional: with mDNS discovery on, peers are found automatically +# and you only need `position`. Set `address` to pin a static host (or on +# networks without multicast). `fingerprint` pins the peer's TLS cert (TOFU) — +# fill it in after the first pairing; see `cross-control status` for your own. [[screens]] name = "laptop-right" -address = "192.168.1.42" position = "Right" -fingerprint = "SHA256:ab:cd:ef:..." # Set after first pairing +# address = "192.168.1.42" # optional with mDNS +# fingerprint = "SHA256:ab:cd:ef:..." # set after first pairing + +# ── Multi-hop layout (optional) ────────────────────────────────────────── +# +# [[screen_adjacency]] describes edges BETWEEN OTHER screens, so the cursor +# can hop across a chain of machines. Each entry reads: leaving `screen` by +# its `position` edge lands on `neighbor`. Inverse edges are generated +# automatically, so you only list each link once. +# +# Example three-machine row — [workstation-left] | [laptop-right] | [tablet]: +# the cursor goes left→right off this machine onto laptop-right (above), then +# right again off laptop-right onto tablet. +# +# [[screen_adjacency]] +# screen = "laptop-right" +# neighbor = "tablet" +# position = "Right" diff --git a/install.sh b/install.sh index 753be7f..f1b425f 100755 --- a/install.sh +++ b/install.sh @@ -101,16 +101,21 @@ setup_systemd() { fi mkdir -p "$service_dir" + # Keep this in sync with systemd/cross-control.service. No network-online + # ordering — that target isn't available in the user manager and the + # daemon reconnects on its own. ExecStart is pinned to the real install + # path so it works regardless of the user manager's PATH. cat > "${service_dir}/cross-control.service" <