diff --git a/README.md b/README.md index 933f835..993e134 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ into the repo so this stays the source of truth. | `plugins.txt` | The 10 Omarchy shell plugins, each pinned to a commit. | | `plugin-patches/` | Local plugin work that exists on no remote (see below). | | `bin/` | `omarchy-handoff` and `fix-stuck-displays`. | +| `handoff/` | The lan-mouse source patch that fixes pointer feel, plus example `config.toml`s. Not a `~/.config` mirror. | | `systemd/user/` | `lan-mouse.service`, `voxtype.service`. | | `system/` | `/etc` drop-ins: the ARP sysctl and `hid_apple` fnmode. | | `packages.txt` | Packages beyond the Omarchy baseline, each with a reason. | @@ -62,6 +63,33 @@ reversed form fails silently, and the log variable is `LAN_MOUSE_LOG_LEVEL`, not `o.launch_on_start` sends both streams to `/dev/null`, hiding every pairing failure. +Three further facts were only established after the above was first written, and +they are what actually made it work: + +- **Fingerprints are case-sensitive.** lan-mouse generates them lowercase and + compares with a plain map lookup — no normalisation — while `openssl` prints + uppercase and `cli authorize-key` stores whatever it is handed. An uppercase + digest is the *correct* digest, valid TOML, non-empty, passes every guard, and + never matches: the DTLS handshake dies `bad_certificate`, indistinguishable + from a wrong key or a dropped packet. This was the root cause of a full day of + dead crossings. `omarchy-handoff fixcase` repairs it; `doctor` now fails on it. +- **Authorization is receiver-side only, not mutual.** `connect.rs` sets + `insecure_skip_verify`, so the *connecting* side never checks the peer's + certificate — only the listener consults `authorized_fingerprints`. Each + direction therefore depends solely on the receiver's list, two-way control + needs both lists, and a sender cannot tell whether it is talking to the real + peer. Input goes to whatever answers on that IP:port. +- **`ufw` is default-deny inbound**, and a dropped inbound 4242 presents + *identically* to a bad certificate. `install.sh --handoff` opens it for the + peer's addresses. Check it first: `journalctl -k -g 'UFW BLOCK'`. + +`handoff/patch/` carries a two-line source patch so the daemon forwards the +accelerated deltas, which is the only way to make the sender's `sensitivity` and +`accel_profile` affect remote pointer feel at all; it also adds +`LAN_MOUSE_POINTER_SCALE` for the residual difference in logical pixel density +between the two screens. `install.sh` builds it per-machine — the two laptops are +different architectures — and `systemd/user/lan-mouse.service` runs the result. + `system/sysctl.d/99-omarchy-handoff-arp.conf` goes with it: Wi-Fi and the dock's ethernet both sit on `192.168.1.0/24`, and with the default ARP settings the subnet's mapping collapses onto whichever NIC replied last. @@ -76,13 +104,23 @@ stock ChatGPT webapp to Claude, and adds four Herdr HUD bindings plus Handoff. **Input** (`config/hypr/input.lua`) enables the three-finger horizontal workspace gesture that ships commented out, and flattens pointer acceleration for -`unknown-device*`. That second rule belongs to Handoff: lan-mouse forwards -relative deltas the *sending* compositor has already accelerated, and since -Omarchy leaves `accel_profile` adaptive the receiver amplifies them a second time, -so a forwarded pointer arrives hypersensitive while the local trackpad feels -normal. `wlr-virtual-pointer` gives Hyprland no device name — virtual keyboards -get named, pointers don't — hence matching on `unknown-device`; no real pointer on -this machine uses that name, so the rule cannot touch the trackpad. +`unknown-device*`. + +That second rule is **a no-op, and the reasoning originally given for it was +wrong.** It was added believing lan-mouse forwards deltas the sending compositor +had already accelerated, which the receiver then amplified a second time. Upstream +does the opposite: it forwards the *unaccelerated* deltas and discards the +accelerated pair in the same event (`handoff/patch/REBUILD.md`). And the rule +cannot act either way — `hl.device({name="unknown-device"})` does match the +virtual pointer (provable by toggling `scroll_factor`), but `sensitivity` and +`accel_profile` are libinput pointer-acceleration settings and a +`wlr-virtual-pointer` is not a libinput device, so they have nothing to act on. +Measured 2026-10-10 on both machines with `lan-mouse test-emulation` + `hyprctl +cursorpos`: travel was 398x198px at `sensitivity = 0` and identical at `-0.9`, +exactly 1:1 with the raw deltas. The lines are kept because they are harmless and +the `unknown-device` naming note is worth preserving; the real fix is +`handoff/patch/`. Do not add sensitivity or accel_profile rules here expecting +them to change remote pointer feel. **Terminals** are all dropped to a smaller font (9 -> 8) except ghostty, which went the other way to 12. `shell.toml` sets the bar font base size to 10. @@ -109,8 +147,13 @@ back into the repo. - **Secrets.** `~/.config/lan-mouse/` holds a private key and per-machine fingerprints; `~/.config/novnc/auth.env`, SSH keys and the git credential - helpers are likewise machine-local. Pair a new machine with - `omarchy-handoff peer ` and `omarchy-handoff doctor`. + helpers are likewise machine-local. Start from + `handoff/config/config.toml.example-{air,laptop}` (which carry the + `capture_backend`, edge and lowercase-fingerprint rules), then pair with + `omarchy-handoff pair ` and `omarchy-handoff doctor`. + A fresh install regenerates the certificate, so this machine's fingerprint + changes and the *peer* has to re-pair. Back up `lan-mouse.pem` beforehand if + you would rather keep it stable — somewhere private, not here. - **Shell, git and dev tooling.** That is already its own repo, `dotfiles` (`git.nic-oconnor.com/public/dotfiles`), which handles zsh, mise, 1Password and the `~/.local/bin` tool shims. The only overlap avoided here is diff --git a/bin/omarchy-handoff b/bin/omarchy-handoff index c936c02..11c946c 100755 --- a/bin/omarchy-handoff +++ b/bin/omarchy-handoff @@ -228,17 +228,25 @@ print(n) } cmd_restart() { - pkill -x lan-mouse 2>/dev/null || true - mkdir -p "$(dirname "$LOG")" - # Keep the log. Discarding it cost real debugging time once: the portal - # backend's silent no-op is invisible without these lines. - if command -v uwsm-app >/dev/null; then - uwsm-app -- lan-mouse daemon >>"$LOG" 2>&1 & + # Prefer the user unit, matching cmd_log's assumption. pkill + relaunch races + # the unit's Restart=on-failure and can leave TWO daemons contending for UDP + # 4242 and the socket, so only fall back to a hand-start when no unit exists. + if systemctl --user --quiet is-enabled lan-mouse.service 2>/dev/null \ + || systemctl --user --quiet is-active lan-mouse.service 2>/dev/null; then + systemctl --user restart lan-mouse.service else - setsid lan-mouse daemon >>"$LOG" 2>&1 & + pkill -x lan-mouse 2>/dev/null || true + mkdir -p "$(dirname "$LOG")" + # Keep the log. Discarding it cost real debugging time once: the portal + # backend's silent no-op is invisible without these lines. + if command -v uwsm-app >/dev/null; then + uwsm-app -- lan-mouse daemon >>"$LOG" 2>&1 & + else + setsid lan-mouse daemon >>"$LOG" 2>&1 & + fi fi sleep 2 - [[ -S $SOCKET ]] || die "daemon failed to start (see $LOG)" + [[ -S $SOCKET ]] || die "daemon failed to start (omarchy-handoff log)" notify "Handoff daemon restarted" } diff --git a/handoff/config/config.toml.example-air b/handoff/config/config.toml.example-air new file mode 100644 index 0000000..03645f0 --- /dev/null +++ b/handoff/config/config.toml.example-air @@ -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" diff --git a/handoff/config/config.toml.example-laptop b/handoff/config/config.toml.example-laptop new file mode 100644 index 0000000..bc2e519 --- /dev/null +++ b/handoff/config/config.toml.example-laptop @@ -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 fingerprint CHANGES whenever air's cert is regenerated (e.g. a fresh install). diff --git a/handoff/patch/REBUILD.md b/handoff/patch/REBUILD.md new file mode 100644 index 0000000..178b401 --- /dev/null +++ b/handoff/patch/REBUILD.md @@ -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. diff --git a/handoff/patch/accelerated-deltas.patch b/handoff/patch/accelerated-deltas.patch new file mode 100644 index 0000000..9ff7532 --- /dev/null +++ b/handoff/patch/accelerated-deltas.patch @@ -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 = std::sync::OnceLock::new(); ++ *SCALE.get_or_init(|| { ++ env::var("LAN_MOUSE_POINTER_SCALE") ++ .ok() ++ .and_then(|v| v.parse::().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 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 })), diff --git a/handoff/patch/build.sh b/handoff/patch/build.sh new file mode 100755 index 0000000..fd32a21 --- /dev/null +++ b/handoff/patch/build.sh @@ -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" diff --git a/install.sh b/install.sh index 0b73dd0..dc09efe 100755 --- a/install.sh +++ b/install.sh @@ -16,7 +16,13 @@ set -uo pipefail REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" STAMP="$(date +%s)" DRY=0 -STEPS=(packages config themes plugins bin services system) +STEPS=(packages config themes plugins bin handoff services system) + +# omarchy-handoff peer addresses to open the firewall for. The peer is dual-homed +# (Wi-Fi + dock NIC) and the dock address disappears on undock, so list both. +if [[ -z ${HANDOFF_PEER_IPS+x} ]]; then + HANDOFF_PEER_IPS=(192.168.1.24 192.168.1.86) +fi c_ok=$'\033[32m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_err=$'\033[31m'; c_hd=$'\033[1m'; c_off=$'\033[0m' [[ -t 1 ]] || { c_ok=; c_skip=; c_warn=; c_err=; c_hd=; c_off=; } @@ -264,6 +270,60 @@ step_services() { say " rather than now. 'systemctl --user start ' to bring one up early." } +# ---------------------------------------------------------------- handoff +step_handoff() { + head_ "omarchy-handoff (lan-mouse)" + + # 1. The patched daemon. systemd/user/lan-mouse.service points at this path, so + # it has to exist before the services step enables the unit. Per-architecture: + # this laptop is x86_64 and omarchy-air is aarch64, so each builds its own. + if [[ -x $HOME/.local/bin/lan-mouse-patched ]]; then + skip "~/.local/bin/lan-mouse-patched (delete it to force a rebuild)" + elif ! command -v cargo >/dev/null; then + warn "cargo missing - run the packages step first, or: omarchy pkg add rust" + elif (( DRY )); then + printf ' %s? would build ~/.local/bin/lan-mouse-patched from handoff/patch/%s\n' "$c_skip" "$c_off" + else + say " building patched daemon from handoff/patch/ (~1 min; see handoff/patch/REBUILD.md for why)" + if "$REPO/handoff/patch/build.sh"; then ok "~/.local/bin/lan-mouse-patched" + else err "build failed - the unit will not start until this succeeds"; fi + fi + + # 2. The firewall. THE bug that cost a full day: ufw is default-deny inbound, so + # the peer's DTLS handshake dies at the wire and presents EXACTLY like a bad + # certificate. Authorization is receiver-side only (connect.rs sets + # insecure_skip_verify), so the RECEIVER's firewall must allow 4242/udp or + # that direction is silently dead. + if ! command -v ufw >/dev/null; then + skip "ufw not installed - nothing to open" + else + local ip rules="" + # Reading the ruleset needs root, so don't probe it on a dry run. + (( DRY )) || rules=$(sudo ufw status 2>/dev/null || true) + for ip in "${HANDOFF_PEER_IPS[@]}"; do + if [[ $rules == *"4242"*"$ip"* ]]; then + skip "ufw allows 4242/udp from $ip" + elif (( DRY )); then + printf ' %s? would allow 4242/udp from %s%s\n' "$c_skip" "$ip" "$c_off" + else + sudo ufw allow from "$ip" to any port 4242 proto udp \ + comment "lan-mouse peer $ip" >/dev/null && ok "ufw: 4242/udp from $ip" + fi + done + say "" + say " Verify a blocked handshake with: journalctl -k -g 'UFW BLOCK'" + fi + + say "" + say " ~/.config/lan-mouse/ is NOT in this repo (private key + per-machine" + say " fingerprints). Start from handoff/config/config.toml.example-* and:" + say " omarchy-handoff fingerprint # give it to the peer, LOWERCASE" + say " omarchy-handoff pair " + say " omarchy-handoff doctor # must pass before arming" + say " omarchy-handoff on" + say " A fresh install regenerates the cert, so the PEER must re-pair." +} + # ---------------------------------------------------------------- system step_system() { head_ "System drop-ins (needs sudo)" diff --git a/packages.txt b/packages.txt index 5bb232f..d5ee060 100644 --- a/packages.txt +++ b/packages.txt @@ -14,6 +14,9 @@ # pipewire-debug) which is out of this repo's scope. [repo] +# --- omarchy-handoff: builds the patched lan-mouse daemon (handoff/patch/) --- +rust # only to compile handoff/patch/; ~250MiB installed, removable after + # --- hardware: this laptop is Intel + an Anker USB-C dock --- linux-omarchy # kernel this install actually boots linux-omarchy-headers # needed to build out-of-tree modules against it diff --git a/systemd/user/lan-mouse.service b/systemd/user/lan-mouse.service index 1492e48..2b06693 100644 --- a/systemd/user/lan-mouse.service +++ b/systemd/user/lan-mouse.service @@ -6,10 +6,24 @@ Requisite=graphical-session.target [Service] Type=simple -# LAN_MOUSE_LOG_LEVEL (NOT RUST_LOG -- lan-mouse ignores that) is what makes auth rejections visible at all; lan-mouse logs the -# offending fingerprint only at debug level. Drop to "info" once paired. -Environment=LAN_MOUSE_LOG_LEVEL=debug -ExecStart=/usr/bin/lan-mouse daemon +# LAN_MOUSE_LOG_LEVEL, NOT RUST_LOG -- lan-mouse silently ignores RUST_LOG. +# Note there is NO auth-rejection message at any level: rejection surfaces only +# as a frontend event, so debug gives connect attempts and DTLS alert text only. +# To see WHICH side rejected a handshake you need the wire, not the log. +Environment=LAN_MOUSE_LOG_LEVEL=info +# Patched daemon: upstream 0.11.0 forwards UNACCELERATED pointer deltas +# (input-capture/src/layer_shell.rs discards the accelerated dx/dy in the same +# event), and the wlr-virtual-pointer receiver applies no curve of its own, so +# the remote pointer is far too fast and sensitivity/accel_profile are inert on +# BOTH ends. Built by install.sh from handoff/patch/ -- see its REBUILD.md. +# Falls back to nothing: if the build is missing the unit fails loudly rather +# than silently running the unpatched binary with the wrong pointer feel. +ExecStart=%h/.local/bin/lan-mouse-patched daemon +# Multiplier for the residual logical-ppi difference between the two screens +# (default 1.0 = upstream). omarchy-air is 140.1 logical ppi at scale 1.6 vs +# ~95.8 for the 23" LG, so 0.7 makes motion cover the same PHYSICAL distance. +# Per-machine: adjust or drop this line on the other host. +Environment=LAN_MOUSE_POINTER_SCALE=0.7 Restart=on-failure RestartSec=2 # Output goes to the journal on purpose. The previous autostart.lua path sent