Keyboard, pointer and clipboard sharing between Linux machines over an encrypted LAN link
  • Rust 97.9%
  • Shell 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-23 08:37:51 +02:00
crates chore: release 0.2.9 2026-10-23 08:37:51 +02:00
scripts/macos chore: release 0.2.9 2026-10-23 08:37:51 +02:00
.gitignore docs: stop tracking docs/superpowers 2026-10-22 15:18:10 +02:00
Cargo.lock chore: release 0.2.9 2026-10-23 08:37:51 +02:00
Cargo.toml chore: release 0.2.9 2026-10-23 08:37:51 +02:00
CHANGELOG.md chore: release 0.2.9 2026-10-23 08:37:51 +02:00
KNOWN_ISSUES.md chore: release 0.2.9 2026-10-23 08:37:51 +02:00
LICENSE-APACHE docs: license crosspc under MIT OR Apache-2.0 2026-10-22 15:12:19 +02:00
LICENSE-MIT docs: license crosspc under MIT OR Apache-2.0 2026-10-22 15:12:19 +02:00
README.md chore: release 0.2.9 2026-10-23 08:37:51 +02:00
rust-toolchain.toml feat(proto): workspace scaffold and length-prefixed wire framing 2026-10-20 10:16:02 +02:00

crosspc

crosspc shares one keyboard and pointer between computers over an encrypted LAN link. The server machine captures its own input, routes it to the screen under the pointer, and injects the events a client would have typed locally.

Version 0.2.0 adds macOS: one end can be a Mac and the other a Linux machine. Linux on both ends keeps working. The Linux input backend is evdev and uinput: it grabs /dev/input/event* and injects through /dev/uinput, in both Wayland and X11 sessions. The X11-specific backend from the design, XGrabKeyboard/XTEST, is not implemented. The macOS backend is a CGEventTap capture and a CGEventPost injector, driven by a menu-bar app, CrossPC. Clipboard sharing is Wayland-only.

Version 0.2.9. See CHANGELOG.md for what changed, and KNOWN_ISSUES.md for what does not work yet.

Requirements

  • Two machines on the same network. One of them runs macOS 14 or later, the other Linux, or both run Linux.
  • Rust stable to build the binary.
  • On Linux: write access to /dev/uinput on both machines, and read access to /dev/input/event* on the server. The crosspc setup rule and the input group provide both.
  • For clipboard sharing, a Wayland session whose compositor implements ext-data-control or wlr-data-control. X11 is not supported.

The daemon never runs as root. Grant access through the udev rule and group membership instead.

Install

Build and install the binary from the workspace root:

cargo install --path crates/daemon --locked

The installed command is crosspc. Verify it:

crosspc --help

To build without installing, run cargo build --release -p crosspc-daemon and copy target/release/crosspc onto your PATH.

Grant input access

Run this on both machines:

sudo crosspc setup

The command writes /etc/udev/rules.d/60-crosspc.rules with two rules:

KERNEL=="uinput", GROUP="input", MODE="0660"
SUBSYSTEM=="input", GROUP="input"

It then prints the commands that make the rule take effect:

sudo udevadm control --reload-rules
sudo udevadm trigger
sudo usermod -aG input "$USER"

The group membership applies only to a new login session, so log out and back in. Without the rule, the daemon reports that it cannot open /dev/uinput or any /dev/input/event* device.

macOS

The macOS support ships in 0.2.0. It is a menu-bar app, CrossPC, that drives the daemon, and a macOS backend with an event tap capture, a CGEventPost injector, the NSPasteboard clipboard, and the Secure Input check.

Requirements

  • macOS 14 or later.
  • Rust 1.99 or later, which is the rust-version of the workspace.
  • Two grants for the daemon, both in System Settings under Privacy & Security: Accessibility and Input Monitoring. The daemon never runs as root.

Build and test

Run the suite on the Mac:

cargo test --workspace

The Linux-only crate compiles empty on macOS. The macOS backend keeps its portable modules on Linux, and only its event tap, its injector, and its permission checks are macOS-only. The menu-bar app has macOS-only code as well. The suite is not the same on both platforms: 16 tests never run on Linux, 14 of them in the macOS backend and 2 in the menu-bar app. Another 62 tests never run on macOS: 61 in the Linux backend and 1 in the menu-bar app, in the branch that refuses to run without a menu bar.

Sign the bundle

Signing needs an identity. The setup script creates a self-signed one in a keychain of its own, crosspc-signing, and prints the fingerprint to export:

eval "$(scripts/macos/setup-signing.sh)"

The identity lives in its own keychain and not in the login keychain: the login keychain asks for a password and a confirmation, so a keychain of its own lets the script run without a person at the keyboard and leaves your own keychain untouched.

The identity is local. It says the bundle is the same one that was signed before, and nothing more. The script names the identity it creates, so it never picks up an Apple Development identity by accident.

The passphrase of that keychain is generated the first time the script runs and is written to ~/.config/crosspc/signing-keychain.pass (or $XDG_CONFIG_HOME/crosspc/signing-keychain.pass) with mode 600. It is not in the repository, and the script never prints it. The bundle script reads it from the same file.

Keep that file: the passphrase is the way back into the identity, and the file is where the script keeps it so you do not have to type it every time. The identity is what the two permission grants are bound to. If the file is gone, the script says what it would cost to make a new identity — a new signature, so macOS sees a different app and both grants have to be given again — and stops. It never replaces the identity on its own. To replace it on purpose, run the script with --replace-identity.

If the file is gone and you still have the passphrase, run the script from a terminal: it asks for the passphrase of the existing keychain, and typing it keeps the identity and both permission grants. Leaving the answer empty stops the script.

Then build and sign the app:

scripts/macos/bundle.sh

The result is target/CrossPC.app. It holds the menu-bar app and the daemon that the app starts. Move the bundle to /Applications when it is good enough.

First run

Copy the configuration where the daemon looks for it:

mkdir -p ~/.config/crosspc
cp crosspc.toml ~/.config/crosspc/crosspc.toml

Set role and local_screen in it, and the screens of both machines. The file is the one described under Configure below: the client names the server in peer, the server names nothing.

If the file comes from the Linux machine, set accel.curve = "linear" in it before you start the app. The curve belongs to the Wayland server, where the pointer position has to be reconstructed from the raw deltas of the device. The Mac reads the real cursor position, and a factor scales every delta its estimate moves by, so the estimate runs ahead of the real cursor and the crossing to the peer fires before the pointer reaches the edge. See KNOWN_ISSUES.md.

Start CrossPC.app. The app starts the daemon from the bundle when nothing answers on the control socket, so there is nothing else to start by hand. The menu shows the role, the peers, and the two permissions.

Start the app, not the daemon. macOS attributes a permission request to the process that started the program making it. A daemon you start by hand from a terminal, over ssh, or from a script is attributed to that shell, and the shell holds neither grant, so the daemon reports both permissions as missing and cannot create its event tap. The daemon the app starts is attributed to the app, and the app's grants cover it.

The two permission panes

The menu has an item for each grant:

  • Grant Accessibility… opens Privacy & Security > Accessibility.
  • Grant Input Monitoring… opens Privacy & Security > Input Monitoring.

Give both to the crosspc-daemon binary inside CrossPC.app, and to no other copy of the daemon. The menu flips both items to granted when the daemon reports the grant. A rebuild of the bundle keeps the grants, as long as the identity is the same one.

A daemon that reports both permissions as missing while the binary stands in both panes was not started by the app. See First run for why that matters.

crosspc setup on macOS says that the udev rule does not apply, names the two grants, and succeeds: there is nothing to install.

Secure Input

macOS switches Secure Input on while a password field holds the focus. The daemon cannot read the input then, so the menu shows a banner and the input stays local. The sharing resumes when the field loses the focus.

Pairing

A Linux peer has no menu, so the approval on that side stays offline. The menu shows this Mac's fingerprint on a line of its own; take it from there and run this on the Linux machine:

crosspc pair --accept <fingerprint>

The Mac answers from its own menu. When the Mac is the server, the menu shows the pending client with its fingerprint and the code; compare the code with the crosspc pair output on the Linux machine, then choose Accept Pairing or Reject Pairing.

When the Mac is the client, Pair with server… starts the pairing. The menu shows the server's fingerprint and the code, next to this Mac's own fingerprint. Compare the code with the server's side, accept, and the client writes the trust store. The server has to trust the Mac first, or it answers PairRejected and the menu reports the reason.

The Pair with server… item sends the address the configuration names in peer, so the Mac dials the server without a command line. The item is disabled when the configuration names no address.

Launch at login

The menu has a Launch at login item. It registers the app as a login item through SMAppService, and macOS asks the user to approve the registration the first time. The item shows the state the platform reports.

Configure

The daemon reads ~/.config/crosspc/crosspc.toml. An absolute XDG_CONFIG_HOME replaces ~/.config. With --config <path>, the identity and trust store live beside that file instead.

The file rejects unknown keys, so a typo fails at startup instead of keeping a silent default. Both roles describe the whole screen layout and name their own screen with local_screen. Screen ids are assigned by position in the file.

Server configuration

role = "server"
listen = "0.0.0.0:24800"
local_screen = "desk"

[[screens]]
name = "desk"
x = 0
y = 0
width = 1920
height = 1080
scale = 1.0

[[screens]]
name = "laptop"
x = 1920
y = 0
width = 2560
height = 1440
scale = 2.0

[clipboard]
max_bytes = 10485760

[accel]
curve = "linear"

Client configuration

role = "client"
peer = "desk.lan:24800"
local_screen = "laptop"

[[screens]]
name = "desk"
x = 0
y = 0
width = 1920
height = 1080
scale = 1.0

[[screens]]
name = "laptop"
x = 1920
y = 0
width = 2560
height = 1440
scale = 2.0

Use the same [[screens]] list on both machines. The client sends its own screen geometry to the server; the server keeps the full layout and decides every crossing.

Configuration keys

Key Type Default Meaning
role "server" or "client" required Which runtime this machine runs
listen string none Address a server binds, for example 0.0.0.0:24800
peer string none Address a client dials, for example desk.lan:24800
local_screen string required The screen this machine owns; must name a [[screens]] entry
screens array of tables empty Every screen in the logical layout
screens[].name string required Unique screen name
screens[].x, screens[].y integer required Position in logical points
screens[].width, screens[].height integer required Size in logical points, at most 65536 each
screens[].scale float required Scale factor, finite and greater than zero
clipboard.max_bytes integer 10485760 (10 MiB) Largest clipboard value carried in either direction
modifiers.enabled boolean true Whether Command and Control are translated between platforms
accel.curve string "linear" linear or flat:<factor> with a finite factor greater than zero
heartbeat_interval_secs integer 1 Heartbeat interval; three missed heartbeats drop the peer
reconnect_backoff_secs array of integers [1, 2, 4, 8] Delays before reconnect attempts; each entry is greater than zero and capped at 30 seconds

A layout must have at least one screen, at most 32, no overlaps, unique names, and every screen must connect to the others through shared edges. crosspc layout prints the screens and reports whether the layout validates. crosspc status adds the role, addresses, identity fingerprint, and trusted peers.

Pair the two machines

Both machines pair offline. Each machine records the other machine's certificate fingerprint. On macOS the menu bar can approve a peer online instead; see Pairing in the macOS section.

  1. On each machine, print its fingerprint:

    crosspc pair
    

    This prints 64 lowercase hex characters and creates identity.pem on first run.

  2. Compare the fingerprints out of band. To derive the 12-character pairing code, run this on either machine with the other machine's fingerprint:

    crosspc pair --peer <other-fingerprint>
    

    Each machine passes the other machine's fingerprint and both produce the same code.

  3. On each machine, record the other machine:

    crosspc pair --accept <other-fingerprint> --hostname <name>
    

    The command prints trusted <fingerprint> as <name> and writes the entry to known_peers.toml beside crosspc.toml.

  4. Confirm both sides with crosspc status. It lists trusted peers and one line per peer.

Both sides must accept the other. A connection from an unrecorded fingerprint is refused during the TLS handshake.

Run the daemon

Start the server on the machine with the keyboard and pointer:

crosspc daemon --role server

Start the client on the other machine:

crosspc daemon --role client

The role, listen, and peer come from the configuration file. The flags --role, --listen, and --peer override them for one run. --deadman <secs> applies to the server only: it releases the input grab and exits when the session makes no progress for that long.

The client reconnects with the configured backoff after a lost connection. The server grabs the input devices only after a connection has been stable for two seconds, so a flapping peer never takes them. It releases the grab when the last peer leaves and accepts the next connection. While ungrabbed, the server discards the events it captures: your desktop already receives them, and injecting them would type everything twice. Stop either daemon with Ctrl+C or SIGTERM; the server releases every held key and button, then releases its grab on the input devices.

The daemon runs the role under a supervisor on both platforms. The supervisor serves the control socket, control.sock beside the configuration file, with mode 0600. If the role ends with an error, such as a server whose last input device was unplugged, the supervisor starts it again after 5 seconds. Each failure in a row doubles the wait, up to 60 seconds, and a role that ran for 60 seconds resets it. The daemon itself stays up, so systemd does not restart it for a failed role.

Hotkeys

crosspc has no hotkey bindings. The protocol reserves Hotkey messages for lock, release, and recentre, and their configuration comes later.

Until then:

  • Cross back to the local screen's edge to type locally again.
  • Stop the server with Ctrl+C or SIGTERM to take full local control back. The daemon releases held input and the grab on the way out.
  • Use --deadman <secs> on the server to bound a session that stops making progress.

Clipboard support

crosspc carries three MIME types, exactly:

  • text/plain
  • text/html
  • image/png

Other types are rejected on both sides. The default cap is 10 MiB; raise clipboard.max_bytes to carry larger values. Content hashes suppress echo loops, so a value copied on one machine is written once on the other.

The backend uses the Wayland data-control protocol through wl-clipboard-rs. It requires ext-data-control or wlr-data-control. X11 is unsupported, and there is no X11 fallback. When the clipboard is unavailable, the daemon logs clipboard disabled: ... once and continues sharing input without the clipboard.

Run crosspc clipboard-probe to check the local clipboard. It prints the offered MIME type, length, and SHA-256, and exits non-zero when the clipboard offers nothing.

Pointer estimate

The Linux server cannot read the absolute cursor position. The evdev backend behaves the same in Wayland and X11 sessions: it seeds the position at the centre of the local screen and accumulates relative deltas from the grabbed device. That position is an estimate. The macOS server has no such limit: it reads the absolute cursor position from the window server.

Two effects follow on Linux:

  • The compositor applies its own pointer acceleration to the injected device, so the estimate can drift from what you see.
  • An edge crossing can misjudge by a few pixels. Under Wayland the compositor's acceleration makes drift likely; under X11 crosspc still uses the estimate because it has no X11 pointer query. This is a limitation of the evdev backend, not a defect to hide.

The estimate clamps at the screen bounds, so a trip to a corner re-anchors it. It also recentres when the peer that owns the pointer disconnects. If your desktop uses acceleration, set accel.curve = "flat:<factor>" to match it.

That curve is for the Wayland server, where the position has to be reconstructed from the raw deltas of the device. A macOS server reads the real cursor position, so it keeps accel.curve = "linear": a factor scales every delta the estimate moves by, and the estimate then runs ahead of the real cursor, so the crossing to the peer fires before the pointer reaches the edge. See KNOWN_ISSUES.md.

On a macOS server the pointer on the peer can travel further than the local one. The position macOS reports already carries macOS's own pointer acceleration, and the peer accelerates the injected motion again with the profile of its own compositor, so the two pointers separate while you move. The design accepts that drift. It is measured on the hardware, and it is not fixed.

Input during the grab delay

The server grabs the input devices only after a connection has been stable for two seconds. Input during that window stays local: crosspc does not forward it, and the remote machine does not see it. A key or button held across the grab is not recorded, so the peer never receives its press. A release reaches the peer only when the pointer has crossed to that peer; a release while the pointer is local stays local. Pointer motion before the grab is not tracked, so the remote cursor starts from the estimated position at the moment of the grab.

Key mapping and mixed layouts

Keys travel as USB HID usage IDs, which are positional, not character codes. Two machines with the same physical and logical layout agree on every key.

Mixed layouts do not. An ISO keyboard next to an ANSI keyboard, or a Dutch layout next to a US layout, produces different characters for some keys, because the key in a given position does not carry the same legend or the same character. For example, the key next to the Enter key and the key left of Z differ between ISO and ANSI. crosspc does not translate characters.

The [modifiers] table enables Command and Control translation for mixed platforms. On Linux to Linux it changes nothing. Set modifiers.enabled = false to keep the native modifiers.

Touchpads and other absolute devices

The capture excludes devices with absolute axes by default. This covers touchpads, touchscreens, and drawing tablets, so moving the pointer on a touchpad does not steer the shared session. It also excludes any device whose name contains ouch, case-insensitive. Relative pointing devices and keyboards are captured.

crosspc has no configuration key to include an excluded device.

Troubleshooting

  • cannot open /dev/uinput or cannot open any device from /dev/input/event*: run crosspc setup as root, reload udev, and log in again after adding yourself to the input group.
  • cannot parse ...: the configuration contains an unknown or misspelled key. The parse error names the line.
  • layout: invalid: crosspc status reports this without failing. Run crosspc layout to see the screens, then fix overlaps, gaps, or a missing local_screen entry.
  • client: cannot connect to ...: check that the server is running, that the address and port match, and that each side accepted the other's fingerprint.
  • clipboard disabled: ...: the session is not Wayland, or the compositor lacks data-control. Input sharing continues.
  • The peer pointer moves on its own: see KNOWN_ISSUES.md.
  • The daemon reports both permissions as missing on macOS while the binary stands in both permission panes: the daemon was not started by the app. See First run.

Logs go to standard error with a crosspc: prefix.

Security

The link uses TLS 1.3 with mutual authentication. Each install generates a self-signed certificate and key in identity.pem; peers are pinned by the SHA-256 fingerprint in known_peers.toml. Protocol version 1 is refused by any peer that speaks another version. The examples use port 24800.

What 0.2.0 does not include

  • Hotkey configuration, file drag and drop, and any internet relay.
  • The Windows backend.
  • The X11-specific input backend (XGrabKeyboard/XTEST); crosspc uses evdev and uinput in X11 sessions too.
  • X11 clipboard support.
  • An absolute pointer position on a Wayland server. The position there is an estimate, and Pointer estimate says what follows from that.

The open defects of this release are in KNOWN_ISSUES.md.

Verification status

Measured on 0.2.9. The headless suite passes on both platforms. On Linux, cargo test --workspace runs 606 tests and all of them pass. On macOS it runs 567 and all of them pass. The 20 ignored tests of the tree are outside those counts: 10 in the Linux backend, which open real input devices and compile empty on macOS, and 10 in the loopback suite. The loopback run, two daemons over real TLS on one machine, passes 10 on both platforms:

cargo test -p crosspc-daemon --test loopback -- --ignored --test-threads=1

cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --check are clean on both platforms.

On the hardware, a Mac and a Linux machine have been used in both roles. With the Mac as server: the two permission grants, pairing from the menu, and the pointer crossing to the Linux machine and back. With Linux as server: the crossing to the Mac and back, and typing, dragging, selecting, and double-clicking on the Mac. The bundle has been rebuilt many times with the grants kept. The keyboard, the right mouse button, and the scroll wheel from a Mac server, Command+C on the Linux machine, the clipboard in both directions, the Secure Input banner, and quitting the menu app have not run. The state of each check is in KNOWN_ISSUES.md.

The live two-machine matrix of two Linux machines has not run.

Tests

Run the headless suite:

cargo test --workspace

The loopback tests bind a port and run TLS handshakes in one process. They are ignored by default; run them with:

cargo test -p crosspc-daemon --test loopback -- --ignored --test-threads=1

License

crosspc is licensed under either of

at your option. Unless you state otherwise, any contribution you submit for inclusion in crosspc is licensed as above, without any additional terms or conditions.