Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added — Phase 2: multi-monitor support ([#8])

- **Machines can expose multiple monitors.** New `DisplayLayout { monitors: Vec<ScreenGeometry> }` type describes a machine's full monitor arrangement, configured via a `[[monitors]]` list (each with `width`, `height`, and top-left `x`/`y` offset). When no monitors are listed, the daemon falls back to a single monitor sized by `daemon.screen_width`/`screen_height`, so existing configs are unchanged.
- **Edge detection spans the combined desktop.** The daemon derives the union bounding box of all monitors and runs all cursor clamping and barrier crossing against it — so on a dual-monitor machine the cursor now traverses the whole desktop before crossing to another machine, instead of stopping at the first monitor's inner edge. All crossing logic is unchanged; only the screen bounds it operates on now come from the layout.
- **Layout travels on the wire.** `Hello`/`Welcome`/`ScreenUpdate` now carry the `DisplayLayout` instead of a single `ScreenGeometry`, so peers see each other's real monitor arrangement. Config validation rejects a monitor with a zero dimension.

### Changed

- **Protocol version → 0.2.** The handshake's screen field changed from `ScreenGeometry` to `DisplayLayout`; major version stays 0, so this is a breaking wire change within the pre-1.0 alpha (rebuild both ends).

[#8]: https://github.com/Adjoint-uk/cross-control/issues/8

### Added — Phase 2: clipboard HTML and images ([#6], [#7])

- **HTML and PNG image clipboard sync.** The clipboard path is no longer text-only. `ClipboardProvider` gained `get_format(format)`, and the `arboard` backend now reads/writes HTML (`get().html()` / `set_html`) and images, converting between the wire's PNG bytes and the raw RGBA the platform clipboard uses (via the `image` crate, PNG feature only). `available_formats()` probes all three formats.
- **Richest-format selection on hand-off.** When the controller offers its clipboard, the controlled side now requests the richest format it can apply — image, else HTML, else plain text — instead of always taking plain text.
- **Size cap enforced ([#7]).** Clipboard payloads over `clipboard.max_size` (default 10 MiB) are dropped with a warning on both the send and receive side, rather than put on the control stream. Chunked streaming of large images over a dedicated QUIC stream remains a follow-up.

[#6]: https://github.com/Adjoint-uk/cross-control/issues/6
[#7]: https://github.com/Adjoint-uk/cross-control/issues/7

### Added — Phase 2: live status readout ([#13]) and TOML layout validation ([#9])

- **`cross-control status` now shows live peers, latency, and focus.** The daemon writes a `StatusSnapshot` (peers with name/state/latency, plus which peer holds focus) to `cross-control.status.json` in the runtime dir every couple of seconds; the `status` command reads and renders it as a table. This is the CLI↔daemon channel the previous `status` lacked — it mirrors the PID-file pattern rather than standing up a full IPC socket. A richer query/subscribe channel can replace the file later without changing the rendered output.
- **Real latency, not a stub.** The daemon pings each peer on a 2-second cadence and records the round-trip time when the `Pong` returns (a new `LatencyTracker` per session). `status` shows `—` until the first ping completes, then `N ms`.
- **`Config::validate()` for screen layouts.** Loading a config now rejects layouts that would silently misroute the cursor: empty or duplicate screen names, a screen sharing this machine's `identity.name`, two screens on the same local edge, self-loop adjacency edges, one screen given two neighbors on the same edge, and `[[screen_adjacency]]` blocks that never connect back to this machine (a typo or dead island). Multi-hop screens introduced only via `[[screen_adjacency]]` are correctly accepted — reachability is checked to a fixpoint, not against `[[screens]]` alone.
- **Documented layout format.** `examples/config.toml` now explains `[[screens]]` vs `[[screen_adjacency]]`, the optional `address`/`fingerprint`, and gives a worked multi-hop example.

### Changed

- `DaemonEvent::SessionReady` now boxes its `PeerSession` payload (the session grew a latency tracker; boxing keeps the enum variants balanced).

### Test coverage

- 84 tests pass workspace-wide (was 70). New: `Config::validate` cases, `StatusSnapshot` JSON round-trip, and `LatencyTracker` ping/pong bookkeeping. Still 2 ignored (`mdns_loopback`, `arboard_backend` — both need host facilities unavailable in CI).

[#9]: https://github.com/Adjoint-uk/cross-control/issues/9
[#13]: https://github.com/Adjoint-uk/cross-control/issues/13

### Added — loopback demo and systemd user service ([#11])

- **`loopback` example.** `cargo run -p cross-control-daemon --example loopback` runs two daemons in one process on `127.0.0.1` and drives a full session — QUIC handshake, device announce, edge-based cursor crossing, and input forwarding — with no second machine, no root, and no display server. It asserts the forwarded keypress lands on the receiving daemon and narrates each step. Built with `--features linux -- --real`, the receiver injects into a real uinput device so the cursor visibly moves. This makes everything *between* the two physical ends reproducible on one box; only real evdev capture and a live compositor remain for hardware bring-up.
- **systemd user service, fixed up ([#11]).** `systemd/cross-control.service` is now a correct, documented user unit: dropped the `network-online.target` ordering (unavailable in the user manager; the daemon reconnects on its own), added install/prerequisite comments, and reconciled the copy `install.sh` generates so the two no longer diverge. README gains a "Run as a service" section covering enable, logs, lingering, and the `input`/`uinput` prerequisites.

[#11]: https://github.com/Adjoint-uk/cross-control/issues/11

### Added — Phase 2 opener: clipboard text sync

- **`cross-control-clipboard` backends.** `ArboardClipboard` (default feature `arboard`) for the real system clipboard on X11, macOS, Windows, and wlroots-based Wayland; `MockClipboard` (feature `mock`) for tests and headless daemon runs. The trait now requires `Send + Sync + 'static` so the daemon can hold `&self.clipboard` across `.await` on a multi-thread runtime.
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,6 +60,9 @@ rcgen = "0.13"
# Clipboard
arboard = "3"

# Image codec for clipboard PNG <-> raw RGBA conversion
image = { version = "0.25", default-features = false, features = ["png"] }

# mDNS discovery
mdns-sd = "0.13"

Expand Down
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
1 change: 1 addition & 0 deletions crates/cross-control-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
51 changes: 51 additions & 0 deletions crates/cross-control-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion crates/cross-control-clipboard/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []

Expand All @@ -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"] }
Expand Down
Loading
Loading