Sync omarchy-air's handoff work: pointer patch, firewall, case fix

Everything from the air side of lan-mouse that was living only on that
machine, plus corrections to what this repo already said.

New: handoff/patch/ is a two-line source patch making the daemon forward
the ACCELERATED pointer deltas. Upstream 0.11.0 forwards dx_unaccel and
discards the accelerated pair in the same event, and the wlr receiver
applies no curve either, so remote motion arrives with the sender's
acceleration stripped and nothing replacing it -- and sensitivity /
accel_profile are inert on both ends, so no config can fix it. The patch
also adds LAN_MOUSE_POINTER_SCALE for the residual logical-ppi gap
(0.7 for air's 140.1ppi against the LG's ~95.8). install.sh builds it
per-machine; the unit now runs the result. This laptop is still unpatched,
so its forwarded motion is still wrong until the handoff step runs here.

New: install.sh --handoff opens 4242/udp for the peer in ufw. ufw is
default-deny inbound and a dropped inbound packet presents IDENTICALLY to
a bad certificate; this cost a day. Nothing in the repo covered it.

Fixed: cmd_restart bypassed the systemd unit with pkill + uwsm-app, which
races Restart=on-failure and can leave two daemons on 4242. It now prefers
the unit, matching what cmd_log already assumed, and keeps the hand-start
path only for when no unit exists.

Documented, and this was the actual root cause of a day of dead crossings:
fingerprints are CASE-SENSITIVE. lan-mouse generates them lowercase and
compares with a plain map lookup, while openssl prints uppercase and
cli authorize-key stores what it is handed. An uppercase digest is the
right digest, valid TOML, non-empty, passes every guard, and never
matches -- the handshake dies bad_certificate. Also documented:
authorization is receiver-side only (connect.rs sets insecure_skip_verify),
so each direction depends solely on the receiver's list and a sender cannot
tell whether it is talking to the real peer.

Corrected: README claimed the unknown-device* accel rule was needed because
the receiver double-accelerates already-accelerated deltas. That is backwards
and the rule is a measured no-op -- the device matches, but sensitivity and
accel_profile are libinput settings and a wlr-virtual-pointer is not a
libinput device. Measured on both machines: 398x198px travel at
sensitivity 0 and identical at -0.9, exactly 1:1. Lines kept (harmless, and
the naming note is worth having) with the real mechanism written down.

~/.config/lan-mouse/ stays out of the repo as before; handoff/config/ holds
example configs carrying the backend, edge and lowercase rules instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrgrMhPG8CTo9XZF326rjZ
This commit is contained in:
2026-10-10 17:06:34 -04:00
co-authored by Claude Opus 5
parent 9ac198da26
commit 50176b78ee
10 changed files with 339 additions and 22 deletions
+32
View File
@@ -0,0 +1,32 @@
# omarchy-air side. Install to ~/.config/lan-mouse/config.toml
#
# NOTE: the daemon OWNS this file. It rewrites it on cli activate/deactivate and
# strips every comment, so treat this copy as the documented source and expect the
# live file to lose the comments on first use. `lan-mouse cli list` is the only
# trustworthy view of runtime state.
# input-capture-portal is a SILENT no-op on Hyprland 0.56.2: it initializes and
# advertises on D-Bus, then creates ZERO barriers. Never leave this unset -- unset
# defaults to the portal. See README.md.
capture_backend = "layer-shell"
port = 4242
[[clients]]
hostname = "omarchy-laptop"
# Both of the peer's paths. .24 is Wi-Fi, .86 is the dock NIC. Keep BOTH: .86
# disappears on undock. Expect a ~5s stall on .24 while docked -- the laptop's dock
# route wins (metric 100 vs 600) so its replies leave as .86 and we drop the
# unknown 4-tuple. Structural, not a fault.
ips = ["192.168.1.24", "192.168.1.86"]
# air sits UNDERNEATH the LG IPS236, so the peer is off our TOP edge.
position = "top"
# Ships DISARMED. Arm only after the PEER authorizes this machine's fingerprint
# (see README "Pairing"), then: omarchy-handoff on
activate_on_startup = false
[authorized_fingerprints]
# Keyed BY FINGERPRINT, value is the description -- the reverse of the intuitive
# order. MUST be lowercase: lan-mouse generates lowercase and compares with a plain
# map lookup, so an uppercase digest is correct, valid, and never matches.
# This is the LAPTOP's cert digest; it stays valid unless the laptop is rebuilt.
"7c:ea:b6:0d:5d:12:6b:c8:b3:2f:9b:21:f2:20:41:fc:b5:8a:b0:67:06:d8:f9:89:0b:43:b0:ce:10:f9:b6:1e" = "omarchy-laptop"
+17
View File
@@ -0,0 +1,17 @@
# omarchy-laptop side. Mirror of config.toml.air.
# input-capture-portal is a SILENT no-op on Hyprland 0.56.2 -- see ../../README.md
capture_backend = "layer-shell"
port = 4242
[[clients]]
hostname = "omarchy-air"
ips = ["192.168.1.49"]
# air sits UNDERNEATH the LG IPS236, so from the laptop it is off the BOTTOM edge.
position = "bottom"
# Leave FALSE until this machine holds the peer's fingerprint (see README).
activate_on_startup = false
[authorized_fingerprints]
# MUST be lowercase. Fill in with:
# omarchy-handoff pair omarchy-air <air's lowercase sha256>
# Air's fingerprint CHANGES whenever air's cert is regenerated (e.g. a fresh install).
+59
View File
@@ -0,0 +1,59 @@
# Patched lan-mouse — forward accelerated pointer deltas
## Why
Upstream lan-mouse 0.11.0 (`input-capture/src/layer_shell.rs`) captures pointer motion from
`zwp_relative_pointer_v1` and forwards the **unaccelerated** deltas:
```rust
dx_unaccel: dx,
dy_unaccel: dy,
..
```
The `..` discards the accelerated `dx`/`dy` carried in the same event. The receiving end emulates
through `wlr-virtual-pointer`, which is not a libinput device and therefore applies no
acceleration curve of its own. Net effect: the sender's acceleration curve is stripped in transit
and nothing replaces it, so the pointer is far too fast on the remote machine.
Critically, this also means `sensitivity` / `accel_profile` are inert for forwarded motion on
**both** ends — the receiver because a virtual pointer isn't a libinput device, the sender because
unaccelerated deltas bypass the curve by definition. There is no configuration fix; that is why
this patch exists.
## What the patch does
1. Forwards the accelerated `dx`/`dy` instead. This restores the sending machine's curve, and
makes the sender's `sensitivity`/`accel_profile` actually control how the remote pointer feels.
2. Adds `LAN_MOUSE_POINTER_SCALE` (float, default `1.0`) as a multiplier, for the residual
difference in logical pixel density between the two screens. Unset = upstream scaling.
## Rebuild
```sh
git clone https://github.com/feschber/lan-mouse.git
cd lan-mouse
git checkout 0d2190e787db9e482e17d4c16fb7c00ef4841bcb # == Arch extra 0.11.0-1
git apply ~/.local/share/lan-mouse-patched/accelerated-deltas.patch
CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
cargo build --release --locked --no-default-features \
--features layer_shell_capture,wlroots_emulation
install -m755 target/release/lan-mouse ~/.local/bin/lan-mouse-patched
systemctl --user restart lan-mouse
```
Build deps: `rust` (installed from Arch `extra`). GTK is deliberately excluded — the daemon does
not need the GUI, and the signed package at `/usr/bin/lan-mouse` still provides it.
## Install layout
- Patched daemon: `~/.local/bin/lan-mouse-patched` (named so it cannot shadow `lan-mouse` on PATH)
- Unit: `~/.config/systemd/user/lan-mouse.service` -> `ExecStart` points at the patched binary
- The signed Arch package stays installed and untouched; no pacman divergence, no `IgnorePkg`.
- Cert/config path is unchanged (`~/.config/lan-mouse/`), so the DTLS fingerprint is unchanged and
the peer's `[authorized_fingerprints]` still matches.
## Upstreaming
Worth reporting to https://github.com/feschber/lan-mouse — the unaccel choice is defensible when
the receiver applies its own acceleration, but no Wayland emulation backend in lan-mouse does.
+48
View File
@@ -0,0 +1,48 @@
diff --git i/input-capture/src/layer_shell.rs w/input-capture/src/layer_shell.rs
index 698c584..7d81d37 100644
--- i/input-capture/src/layer_shell.rs
+++ w/input-capture/src/layer_shell.rs
@@ -70,6 +70,21 @@ use super::{
error::{LayerShellCaptureCreationError, WaylandBindError},
};
+/// Multiplier applied to forwarded pointer motion, from `LAN_MOUSE_POINTER_SCALE`.
+///
+/// Defaults to 1.0. Useful when the two machines differ in logical pixel density, so the
+/// same logical delta covers a different physical distance on each screen.
+fn pointer_scale() -> f64 {
+ static SCALE: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
+ *SCALE.get_or_init(|| {
+ env::var("LAN_MOUSE_POINTER_SCALE")
+ .ok()
+ .and_then(|v| v.parse::<f64>().ok())
+ .filter(|v| v.is_finite() && *v > 0.0)
+ .unwrap_or(1.0)
+ })
+}
+
struct Globals {
compositor: wl_compositor::WlCompositor,
pointer_constraints: ZwpPointerConstraintsV1,
@@ -887,13 +902,19 @@ impl Dispatch<ZwpRelativePointerV1, ()> for State {
if let zwp_relative_pointer_v1::Event::RelativeMotion {
utime_hi,
utime_lo,
- dx_unaccel: dx,
- dy_unaccel: dy,
+ dx,
+ dy,
..
} = event
{
if let Some(window) = &app.focused {
let time = ((((utime_hi as u64) << 32) | utime_lo as u64) / 1000) as u32;
+ // Forward the ACCELERATED deltas. The unaccelerated ones bypass the sending
+ // compositor's pointer-acceleration curve, and the receiving end emulates via
+ // wlr-virtual-pointer, which is not a libinput device and so applies no curve
+ // of its own -- the acceleration would be lost entirely in transit.
+ let scale = pointer_scale();
+ let (dx, dy) = (dx * scale, dy * scale);
app.pending_events.push_back((
window.pos,
CaptureEvent::Input(Event::Pointer(PointerEvent::Motion { time, dx, dy })),
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Build the patched lan-mouse daemon into ~/.local/bin/lan-mouse-patched.
# Rationale and the upstream bug are in REBUILD.md. Called by ../install.sh.
set -euo pipefail
# Arch extra 0.11.0-1. Pinned because the patch is against these exact line numbers;
# a newer upstream may have fixed this, in which case check before repinning.
commit=0d2190e787db9e482e17d4c16fb7c00ef4841bcb
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
command -v cargo >/dev/null || {
echo "cargo not found -- install the 'rust' package first" >&2
exit 1
}
# Build in a throwaway dir: the source tree is a build input, not something to keep.
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
git clone --quiet https://github.com/feschber/lan-mouse.git "$work/lan-mouse"
cd "$work/lan-mouse"
git checkout --quiet "$commit"
git apply "$here/accelerated-deltas.patch"
# GTK deliberately excluded: the daemon needs no GUI, and the signed Arch package
# still provides it at /usr/bin/lan-mouse. LTO off keeps this at ~1 min.
CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
cargo build --release --locked --no-default-features \
--features layer_shell_capture,wlroots_emulation
install -Dm755 target/release/lan-mouse ~/.local/bin/lan-mouse-patched
echo "installed ~/.local/bin/lan-mouse-patched"