From d501c5e5ba74251490bb252c3faac0cf146e08f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 23:24:18 +0000 Subject: [PATCH 1/5] feat: live status readout (#13) and TOML layout validation (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 work exposing daemon internals that already existed. status (#13): the daemon now writes a StatusSnapshot (peers with name/state/latency + which peer holds focus) to a JSON file in the runtime dir on a 2s timer, and `cross-control status` renders it as a table. Latency is measured, not stubbed: each peer is pinged on the same cadence and the round-trip time recorded when the Pong returns, via a new per-session LatencyTracker. Mirrors the PID-file pattern rather than a full IPC socket; the rendered output is stable if that's swapped later. layouts (#9): Config::validate() runs at load and rejects layouts that would silently misroute the cursor — empty/duplicate screen names, a screen named like this machine, two screens on one local edge, self-loop adjacency, one screen with two neighbors on an edge, and adjacency blocks that never connect back to this machine. Multi-hop screens introduced only via [[screen_adjacency]] are accepted (reachability to a fixpoint). examples/config.toml now documents the format with a worked multi-hop example. DaemonEvent::SessionReady boxes its PeerSession payload to keep the enum variants balanced after the session grew a latency tracker. 84 tests pass (was 70); clippy clean under -D warnings. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01By4dqQfiNPtKJMCG16UkDu --- CHANGELOG.md | 18 ++ Cargo.lock | 2 + Cargo.toml | 1 + crates/cross-control-cli/Cargo.toml | 1 + crates/cross-control-cli/src/main.rs | 51 +++++ crates/cross-control-daemon/Cargo.toml | 1 + crates/cross-control-daemon/src/config.rs | 238 +++++++++++++++++++++ crates/cross-control-daemon/src/daemon.rs | 150 ++++++++++++- crates/cross-control-daemon/src/lib.rs | 2 + crates/cross-control-daemon/src/session.rs | 88 ++++++++ crates/cross-control-daemon/src/setup.rs | 3 + crates/cross-control-daemon/src/status.rs | 102 +++++++++ examples/config.toml | 38 +++- 13 files changed, 679 insertions(+), 16 deletions(-) create mode 100644 crates/cross-control-daemon/src/status.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9204717..c1e90b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 — 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..b28e98b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ dependencies = [ "cross-control-protocol", "cross-control-types", "hostname", + "serde_json", "tokio", "toml", "tracing", @@ -389,6 +390,7 @@ dependencies = [ "hostname", "rustls", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index cddc023..3db7d7d 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 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-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/src/config.rs b/crates/cross-control-daemon/src/config.rs index 3e18e87..0f0cd25 100644 --- a/crates/cross-control-daemon/src/config.rs +++ b/crates/cross-control-daemon/src/config.rs @@ -1,5 +1,7 @@ //! Daemon configuration loaded from TOML. +use std::collections::{HashMap, HashSet}; + use cross_control_types::screen::Position; use serde::{Deserialize, Serialize}; @@ -20,6 +22,110 @@ pub struct Config { pub screen_adjacency: Vec, } +impl Config { + /// 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(); + + // 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. /// /// Used by the server to know where to route the cursor when it leaves @@ -209,4 +315,136 @@ 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 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..0157196 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::{ @@ -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, } @@ -109,6 +116,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 { @@ -170,6 +185,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, } } @@ -254,8 +272,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!( @@ -371,17 +392,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 +506,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"); @@ -422,7 +530,11 @@ impl Daemon { .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 +544,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 +949,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; @@ -1026,6 +1145,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 { @@ -1104,7 +1230,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() { 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..53105f1 100644 --- a/crates/cross-control-daemon/src/session.rs +++ b/crates/cross-control-daemon/src/session.rs @@ -1,6 +1,7 @@ //! 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::{ @@ -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 { @@ -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 { @@ -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/examples/config.toml b/examples/config.toml index bcca015..48f1c53 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,35 @@ 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. +# ── 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" From 547798a2b74858ca8f159de012852580cb321543 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 23:35:35 +0000 Subject: [PATCH 2/5] feat: loopback demo + fix systemd user service (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — handshake, device announce, edge crossing, input forwarding — with no second machine, no root, and no display. It asserts the forwarded keypress lands on the receiver and narrates each step. Built with `--features linux -- --real`, the receiver injects into a real uinput device so the cursor visibly moves. Everything between the two physical ends is now reproducible on one box; only real evdev capture and a live compositor remain for hardware bring-up. systemd (#11): systemd/cross-control.service is now a correct, documented user unit — dropped network-online.target ordering (not available in the user manager; the daemon reconnects on its own), added install and prerequisite comments. Reconciled the divergent unit install.sh generated so the two stay in sync, and pinned its ExecStart to the resolved install path. README gains a "Run as a service" section (enable, logs, lingering, input/uinput prerequisites) and a "Try it on one machine" pointer to the loopback example. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01By4dqQfiNPtKJMCG16UkDu --- CHANGELOG.md | 7 + README.md | 36 ++ .../cross-control-daemon/examples/loopback.rs | 315 ++++++++++++++++++ install.sh | 8 +- systemd/cross-control.service | 21 +- 5 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 crates/cross-control-daemon/examples/loopback.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e90b8..c68b30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#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/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-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/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" < Date: Fri, 3 Jul 2026 23:46:51 +0000 Subject: [PATCH 3/5] feat(clipboard): HTML and PNG image sync with size cap (#6, #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clipboard path is no longer text-only. ClipboardProvider gains get_format(format). The arboard backend reads and 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 text, HTML, and image. On hand-off the controlled side now requests the richest format the controller offers that it can apply — image, else HTML, else plain text — instead of always taking plain text. Payloads over clipboard.max_size (default 10 MiB) are dropped with a warning on both send and receive rather than put on the control stream; chunked streaming of large images over a dedicated QUIC stream remains a follow-up. 92 tests pass (was 84): PNG encode/decode round-trip, mock get_format for html/png, and daemon integration tests for HTML and image hand-off. Clippy clean under -D warnings; clipboard crate still builds mock-only (image is gated behind the arboard feature). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01By4dqQfiNPtKJMCG16UkDu --- CHANGELOG.md | 9 ++ Cargo.lock | 1 + Cargo.toml | 3 + DESIGN.md | 2 +- crates/cross-control-clipboard/Cargo.toml | 4 +- .../src/arboard_backend.rs | 132 +++++++++++++++--- crates/cross-control-clipboard/src/lib.rs | 15 +- crates/cross-control-clipboard/src/mock.rs | 36 +++++ crates/cross-control-daemon/src/daemon.rs | 54 +++++-- .../tests/daemon_integration.rs | 92 +++++++++++- crates/cross-control-types/src/clipboard.rs | 44 ++++++ 11 files changed, 356 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c68b30b..505435f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. diff --git a/Cargo.lock b/Cargo.lock index b28e98b..0e89173 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,7 @@ dependencies = [ "arboard", "async-trait", "cross-control-types", + "image", "thiserror 2.0.18", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 3db7d7d..03f01b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,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/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/src/daemon.rs b/crates/cross-control-daemon/src/daemon.rs index 0157196..b0f0be2 100644 --- a/crates/cross-control-daemon/src/daemon.rs +++ b/crates/cross-control-daemon/src/daemon.rs @@ -994,11 +994,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 }); @@ -1012,17 +1011,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 { @@ -1031,6 +1040,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; }; @@ -1177,6 +1196,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 { 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-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); + } } From 6e1b066a859064e6ae65ed1d2a06cb3f630a4f22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 20:15:09 +0000 Subject: [PATCH 4/5] feat: multi-monitor support per machine (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce DisplayLayout { monitors: Vec } describing 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. The daemon derives the union bounding box of all monitors and runs all cursor clamping and edge/barrier detection against it — so on a dual-monitor machine the cursor now traverses the whole combined desktop before crossing to another machine, instead of stopping at the first monitor's inner edge. The crossing logic itself is untouched; only the screen bounds it operates on now come from the layout. Hello/Welcome/ScreenUpdate carry the DisplayLayout instead of a single ScreenGeometry so peers see each other's real monitor arrangement, and sessions store the remote layout. Config validation rejects a monitor with a zero dimension. Protocol version bumped to 0.2: the handshake screen field changed type, a breaking wire change within the pre-1.0 alpha (major stays 0). 100 tests pass (was 92): DisplayLayout bounding-box/edge cases and roundtrip, config display_layout fallback + monitor validation. Clippy clean under -D warnings; loopback example still green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01By4dqQfiNPtKJMCG16UkDu --- CHANGELOG.md | 12 ++ crates/cross-control-daemon/src/config.rs | 101 ++++++++++++- crates/cross-control-daemon/src/daemon.rs | 38 +++-- crates/cross-control-daemon/src/session.rs | 22 +-- crates/cross-control-protocol/src/wire.rs | 4 +- .../tests/quic_roundtrip.rs | 10 +- crates/cross-control-types/src/lib.rs | 2 +- crates/cross-control-types/src/message.rs | 21 +-- crates/cross-control-types/src/screen.rs | 141 ++++++++++++++++++ docs/protocol.md | 10 +- examples/config.toml | 22 +++ 11 files changed, 334 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 505435f..adfffd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ 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. diff --git a/crates/cross-control-daemon/src/config.rs b/crates/cross-control-daemon/src/config.rs index 0f0cd25..93d926d 100644 --- a/crates/cross-control-daemon/src/config.rs +++ b/crates/cross-control-daemon/src/config.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use cross_control_types::screen::Position; +use cross_control_types::screen::{DisplayLayout, Position, ScreenGeometry}; use serde::{Deserialize, Serialize}; /// Top-level configuration. @@ -20,9 +20,35 @@ 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 @@ -32,6 +58,17 @@ impl Config { 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(); @@ -218,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 { @@ -426,6 +476,55 @@ position = "Right" 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. diff --git a/crates/cross-control-daemon/src/daemon.rs b/crates/cross-control-daemon/src/daemon.rs index b0f0be2..2d385a2 100644 --- a/crates/cross-control-daemon/src/daemon.rs +++ b/crates/cross-control-daemon/src/daemon.rs @@ -12,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}; @@ -78,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, @@ -135,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); @@ -168,6 +173,7 @@ impl Daemon { cursor_y, config, machine_id, + layout, screen, transport, capture, @@ -249,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 { @@ -257,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 { @@ -354,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 { @@ -373,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), @@ -517,14 +523,14 @@ 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 @@ -1237,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>>, @@ -1250,7 +1256,7 @@ fn spawn_outbound_handshake( conn, our_id, &our_name, - &our_screen, + &our_layout, &local_devices, ) .await @@ -1300,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) @@ -1318,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/session.rs b/crates/cross-control-daemon/src/session.rs index 53105f1..4c95683 100644 --- a/crates/cross-control-daemon/src/session.rs +++ b/crates/cross-control-daemon/src/session.rs @@ -5,7 +5,7 @@ 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}; @@ -17,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, @@ -72,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), @@ -121,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; @@ -146,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(()) @@ -171,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 @@ -186,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; 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/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 48f1c53..a132da9 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -22,6 +22,28 @@ release_hotkey = ["LeftCtrl", "LeftShift", "Escape"] enabled = true max_size = 10485760 # 10 MiB +# ── 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 From 4f4c0f9e557dc969463fdec525e66c6a49758997 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 09:43:37 +0000 Subject: [PATCH 5/5] fix(input): satisfy clippy 1.96 map_unwrap_or in device probe CI's stable toolchain moved to Rust 1.96, whose clippy flags `.map(..).unwrap_or(false)` on a Result. Use `is_ok_and` instead. Behaviour is identical; this is pre-existing code surfaced by the newer lint. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01By4dqQfiNPtKJMCG16UkDu --- crates/cross-control-input/src/linux/capture.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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() });