- Rust 97.9%
- Shell 2.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| crates | ||
| scripts/macos | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| KNOWN_ISSUES.md | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| README.md | ||
| rust-toolchain.toml | ||
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/uinputon both machines, and read access to/dev/input/event*on the server. Thecrosspc setuprule and theinputgroup provide both. - For clipboard sharing, a Wayland session whose compositor implements
ext-data-controlorwlr-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-versionof 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.
-
On each machine, print its fingerprint:
crosspc pairThis prints 64 lowercase hex characters and creates
identity.pemon first run. -
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.
-
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 toknown_peers.tomlbesidecrosspc.toml. -
Confirm both sides with
crosspc status. It liststrusted peersand 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+CorSIGTERMto 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/plaintext/htmlimage/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/uinputorcannot open any device from /dev/input/event*: runcrosspc setupas root, reload udev, and log in again after adding yourself to theinputgroup.cannot parse ...: the configuration contains an unknown or misspelled key. The parse error names the line.layout: invalid:crosspc statusreports this without failing. Runcrosspc layoutto see the screens, then fix overlaps, gaps, or a missinglocal_screenentry.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
- the Apache License, Version 2.0 (LICENSE-APACHE), or
- the MIT license (LICENSE-MIT),
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.