Profiles: machines, roles and shared layers

The repo was one machine's capture. It is now a set of profiles built from
layers under profiles/:

  machines/  hp-laptop, macbook-air-m2, coder-desktop
  roles/     developer, devops, ai-agents
  features/  handoff (lan-mouse)
  shared     base, personal, personal-desktop

A profile resolves to its includes (depth first), any --roles, then itself;
for a path several layers provide, the last wins. install.sh --show prints
the layers and which one owns each file.

Hyprland settings no longer share a file: base's hyprland.lua loads
~/.config/hypr/layers/*.lua with Omarchy's own require_all, and bindings.lua
stays stock so nothing binds twice. Verified with Hyprland 0.56.2
--verify-config for all three profiles, plus a broken probe layer to prove
the directory is loaded.

packages.txt gains per-line arch (Arch Linux ARM lacks opencode, codex, mise,
shellcheck) and cmd= (skip when dotfiles' ~/.local/bin already provides the
command), and an [ignore] section that replaces capture.sh's hard-coded list.
Tab-separated records would have collapsed empty fields under read, so
package records use the unit separator.

hp-laptop: re-captured. monitors.lua catch-all scale is now 1, shell.json
has four new plugins, fix-stuck-displays has its connector-name fix,
navbar-cat is gone, plugin-manager and omarchy-phone are pinned, the
herdr-hud working tree is re-diffed and bin/herdr-web rides along. Its
handoff peer is now the Air (192.168.1.49); the old default list was the
HP's own addresses, written from the Air's side.

macbook-air-m2: captured read-only over SSH as it is (no themes, plugins
or bar changes). Its LAN_MOUSE_POINTER_SCALE=0.7 moves from the shared unit
into a lan-mouse.service.d drop-in in its own layer.

coder-desktop: the Omarchy settings covert-cloud's image used to patch in
by hand (Virtual-1 at 1920x1080, no animations, stay-awake, Retro 82 once),
for the image to apply at a pinned commit. Roles reach it per workspace via
install.sh --roles ... --no-clobber.

capture.sh writes each file back into the layer that owns it, and refuses
to run before the first layered install unless --force, since the old file
shapes would land in shared layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153egowV1Xw2RNjZnSyZicc
This commit is contained in:
2026-10-16 16:01:54 -04:00
co-authored by Claude Opus 5.5
parent b3f3f46b98
commit 87a31858fd
98 changed files with 2010 additions and 532 deletions
+513
View File
@@ -0,0 +1,513 @@
#!/bin/bash
# omarchy:summary=Share keyboard and mouse with a nearby Omarchy machine
# omarchy:group=handoff
# omarchy:args=[status|toggle|on|off|position <edge>|peer <host> <ip>|fingerprint|pair <desc> <sha256>|restart|log [-f|N]|fixcase|doctor]
# omarchy:examples=omarchy-handoff position right
# omarchy:examples=omarchy-handoff toggle
# NOTE: the omarchy: metadata above is cosmetic. Omarchy's dispatcher only scans
# its own bin dir, so `omarchy handoff ...` does NOT work -- invoke this as the
# bare `omarchy-handoff`.
# HARD-WON FACTS -- kept here because lan-mouse rewrites config.toml on every
# CLI activate/deactivate (i.e. every SUPER+SHIFT+K) and strips its comments.
# Startup activation does NOT rewrite it; only the CLI path does.
# * capture_backend MUST be "layer-shell". On Hyprland 0.56.2 the
# "input-capture-portal" backend is a SILENT NO-OP: it initializes cleanly,
# org.freedesktop.portal.InputCapture answers on D-Bus (v1, caps=3), and it
# creates ZERO barriers. No edge ever fires and nothing is logged.
# Verify with: lan-mouse --capture-backend <b> test-capture
# and look for "adding capture for position X - using outputs: [NON-EMPTY]".
# Note layer-shell logs an EMPTY pass for all positions first, then a
# populated pass after enumerating outputs -- empty alone is not a failure.
# * layer-shell arms barriers PER OUTPUT, not just the desktop's outer rim,
# so an interior monitor edge is a valid crossing point.
# * [authorized_fingerprints] is keyed by FINGERPRINT: "<sha256>" = "<desc>".
# Reversed is a valid non-empty table, so paired() below CANNOT detect it;
# auth then fails silently and looks exactly like a dropped packet.
# * capture_backend/position/activate_on_startup/fingerprints all survive the
# rewrite; only comments are lost.
# * The daemon runs as the lan-mouse.service USER UNIT. Do NOT start it from
# autostart.lua's o.launch_on_start(): that sends stdout AND stderr to
# /dev/null, so every pairing failure is invisible. Cost: hours of
# diagnosing from a stale pre-reboot log file while the live daemon logged
# nowhere. `omarchy-handoff log` reads the journal for this reason.
# * The log level env var is LAN_MOUSE_LOG_LEVEL, NOT RUST_LOG. RUST_LOG is
# silently ignored, so it looks like debug logging "doesn't work".
# * Transport is DTLS over UDP (webrtc-dtls), not QUIC. The error
# "Alert is Fatal or Close Notify" (= ErrAlertFatalOrClose) means the REMOTE
# sent a fatal alert, i.e. the PEER REJECTED OUR CERT. Something answered,
# so it is a TRUST failure, never a firewall. Contrast "Connection timed
# out" = nothing answered at all (firewall, or the source-address trap
# below).
# * The fingerprint lan-mouse compares is sha256 of the DER certificate --
# exactly `openssl x509 -noout -fingerprint -sha256`. Not the PEM file hash,
# not the pubkey hash.
# * SOURCE-ADDRESS TRAP (two NICs on one subnet): the daemon listens on
# 0.0.0.0 but does not set IP_PKTINFO, so replies take the kernel's
# route-chosen source. With the dock up, `ip route get <peer>` prefers the
# dock NIC, so a peer connecting to our Wi-Fi address gets replies from the
# dock address and drops them as an unknown 4-tuple -> "Connection timed
# out" on that address only, while ICMP to it works fine. Undocked it
# inverts and works. Listing both peer-side IPs is correct; the unusable
# one just costs a 5s timeout per attempt.
# Thin wrapper over lan-mouse. lan-mouse's own config.toml is the single source
# of truth -- this script only drives its CLI so the edge and pairing are
# changeable without hand-editing TOML, and so a keybind has something to call.
set -euo pipefail
CONFIG="${CONFIG:-$HOME/.config/lan-mouse/config.toml}"
CERT="$HOME/.config/lan-mouse/lan-mouse.pem"
SOCKET="${XDG_RUNTIME_DIR:-/run/user/$UID}/lan-mouse-socket.sock"
LOG="${XDG_STATE_HOME:-$HOME/.local/state}/lan-mouse.log"
notify() { omarchy-notification-send -g "󰌌" "Handoff" "$1" 2>/dev/null || echo "$1"; }
die() { echo "omarchy-handoff: $1" >&2; exit 1; }
# The daemon owns the socket; every cli call is a no-op without it.
require_daemon() {
[[ -S $SOCKET ]] || die "lan-mouse daemon is not running (try: omarchy-handoff restart)"
}
# Resolve a client's numeric id from its hostname, since lan-mouse addresses
# clients by an index that shifts as entries are added or removed.
client_id() {
local host="${1:-}"
require_daemon
if [[ -n $host ]]; then
lan-mouse cli list | sed -n "s/^id \([0-9]\+\): ${host}:.*/\1/p" | head -n1
else
lan-mouse cli list | sed -n 's/^id \([0-9]\+\):.*/\1/p' | head -n1
fi
}
first_host() {
require_daemon
lan-mouse cli list | sed -n 's/^id [0-9]\+: \([^:]*\):.*/\1/p' | head -n1
}
cmd_status() {
if [[ -S $SOCKET ]]; then
echo "daemon: running"
else
echo "daemon: stopped"
return 0
fi
echo "peers:"
lan-mouse cli list | sed 's/^/ /'
echo "fingerprint (this machine):"
echo " $(cmd_fingerprint)"
}
cmd_fingerprint() {
[[ -f $CERT ]] || die "no certificate yet at $CERT (start the daemon once)"
# MUST be lowercase. lan-mouse compares with a plain HashMap key lookup
# (src/listen.rs: authorized.contains_key(&fingerprints[0])) against
# crypto::generate_fingerprint(), which formats "{x:02x}" and .to_lowercase().
# openssl prints UPPERCASE, so passing it through unchanged silently never
# matches -- that was the real cause of the dead crossing on 2026-10-10.
openssl x509 -in "$CERT" -noout -fingerprint -sha256 | cut -d= -f2 | tr "A-Z" "a-z"
}
# Activation is what actually gates input crossing the edge, so on/off/toggle
# map to activate/deactivate rather than stopping the daemon.
is_active() {
local id="$1"
lan-mouse cli list | grep -q "^id ${id}:.*active: true"
}
# Arming the edge with no authorized peer key is the one way this locks up a
# machine: input gets captured, the peer rejects it unauthenticated, and the
# keyboard looks dead. Refuse unless pairing is mutual, or --force is given.
# Parse the actual TOML table rather than slicing text: a line-range slice from
# the header to EOF also counts keys belonging to any table emitted AFTER
# [authorized_fingerprints], and `lan-mouse cli save-config` -- not us -- chooses
# that order. An empty fingerprint table followed by any populated table made the
# text version return true and arm an unpaired machine, i.e. it failed OPEN into
# exactly the lockup this guard exists to prevent.
# A malformed or unreadable config counts as 0, so it fails CLOSED.
paired() {
[[ -f $CONFIG ]] || return 1
local n
n=$(python3 -I -c '
import sys, tomllib
try:
with open(sys.argv[1], "rb") as f:
print(len(tomllib.load(f).get("authorized_fingerprints", {})))
except (OSError, tomllib.TOMLDecodeError):
print(0)
' "$CONFIG")
(( n > 0 ))
}
cmd_on() {
local force=0
[[ ${1:-} == --force ]] && { force=1; shift; }
local id; id=$(client_id "${1:-}")
[[ -n $id ]] || die "no peer configured"
if (( ! force )) && ! paired; then
die "no peer fingerprint authorized -- refusing to capture input.
Crossing the edge now would forward this keyboard and mouse to a peer that
cannot accept it, and the machine would look frozen (release: Ctrl+Shift+Super+Alt).
Pair first: omarchy-handoff pair <name> <sha256>
Override: omarchy-handoff on --force"
fi
lan-mouse cli activate "$id"
notify "Handoff enabled"
}
cmd_off() { local id; id=$(client_id "${1:-}"); [[ -n $id ]] || die "no peer configured"; lan-mouse cli deactivate "$id"; notify "Handoff disabled"; }
cmd_toggle() {
local id; id=$(client_id "${1:-}")
[[ -n $id ]] || die "no peer configured (try: omarchy-handoff peer <host> <ip>)"
if is_active "$id"; then cmd_off "${1:-}"; else cmd_on "${1:-}"; fi
}
# The whole point of the wrapper: the physical side the other laptop sits on is
# one value, not a code change.
cmd_position() {
local edge="${1:-}" id
case "$edge" in
left|right|top|bottom) ;;
*) die "position must be one of: left right top bottom" ;;
esac
id=$(client_id "${2:-}")
[[ -n $id ]] || die "no peer configured"
lan-mouse cli set-position "$id" "$edge"
lan-mouse cli save-config
notify "Peer is now on the $edge"
}
cmd_peer() {
local host="${1:-}" ip="${2:-}" edge="${3:-left}"
[[ -n $host && -n $ip ]] || die "usage: omarchy-handoff peer <hostname> <ip>[,<ip>...] [edge]"
require_daemon
local existing; existing=$(client_id "$host")
[[ -n $existing ]] && lan-mouse cli remove-client "$existing"
lan-mouse cli add-client --hostname "$host" --ips "$ip" --port 4242
cmd_position "$edge" "$host"
}
cmd_pair() {
local desc="${1:-}" fp="${2:-}"
# Normalise case: a fingerprint copied from openssl or a chat message is
# usually uppercase, and lan-mouse's comparison is case-SENSITIVE.
fp=$(tr "A-Z" "a-z" <<<"$fp")
[[ -n $desc && -n $fp ]] || die "usage: omarchy-handoff pair <description> <sha256-fingerprint>"
require_daemon
lan-mouse cli authorize-key "$desc" "$fp"
lan-mouse cli save-config
notify "Authorized $desc"
}
cmd_fixcase() {
[[ -f $CONFIG ]] || die "no config at $CONFIG"
python3 -I -c "
import re, sys
path = sys.argv[1]
with open(path) as f:
s = f.read()
pat = re.compile(\"\\\"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){31})\\\"\\\\s*=\")
n = sum(1 for m in pat.finditer(s) if any(c.isupper() for c in m.group(1)))
s2 = pat.sub(lambda m: chr(34) + m.group(1).lower() + chr(34) + \" =\", s)
if n:
with open(path, \"w\") as f:
f.write(s2)
print(n)
" "$CONFIG" | {
read -r n
if (( n > 0 )); then
echo "lowercased $n fingerprint(s); restart to load: omarchy-handoff restart"
else
echo "nothing to fix -- all fingerprints already lowercase"
fi
}
}
cmd_restart() {
# 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
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 (omarchy-handoff log)"
notify "Handoff daemon restarted"
}
# Show what the capture layer is actually doing -- the only way to tell a real
# barrier from a backend that selected cleanly and armed nothing.
cmd_log() {
# The daemon runs as the lan-mouse.service user unit, so its output is in the
# journal. It USED to be started by o.launch_on_start() in autostart.lua,
# which sent stdout AND stderr to /dev/null -- so for hours this printed a
# stale pre-reboot file while the live daemon logged nowhere at all. Read the
# journal first; fall back to $LOG only for a hand-started daemon.
if systemctl --user --quiet is-enabled lan-mouse.service 2>/dev/null \
|| systemctl --user --quiet is-active lan-mouse.service 2>/dev/null; then
if [[ ${1:-} == -f ]]; then
journalctl --user -u lan-mouse.service -o cat -f
else
journalctl --user -u lan-mouse.service -o cat --no-pager -n "${1:-40}"
fi
return
fi
[[ -f $LOG ]] || die "no journal unit and no log at $LOG (try: omarchy-handoff restart)"
echo "# NOTE: reading fallback file $LOG (lan-mouse.service is not active)" >&2
if [[ ${1:-} == -f ]]; then tail -f "$LOG"; else tail -n "${1:-40}" "$LOG"; fi
}
# Health check. Deliberately exits non-zero on failure: a check that can only
# report success is decoration. Scope is capture + trust-entry SHAPE -- it
# cannot prove the peer trusts our key, only that our own table isn't malformed.
# Pass a saved log as $1 to parse it instead of probing (used to test failures).
cmd_doctor() {
local src="${1:-}" rc=0 probe cb last
echo "== configured backend =="
cb=$(python3 -I -c '
import sys, tomllib
try:
with open(sys.argv[1], "rb") as f:
print(tomllib.load(f).get("capture_backend") or "")
except Exception:
print("")
' "$CONFIG")
if [[ -z $cb ]]; then
echo " (unset -> defaults to input-capture-portal)"
echo " WARNING: that default is a SILENT NO-OP on Hyprland 0.56.2 -- it"
echo " initializes cleanly and creates no barriers. Set capture_backend = \"layer-shell\"."
rc=1
elif [[ $cb != layer-shell ]]; then
echo " $cb"
echo " WARNING: only layer-shell is known to create real barriers here."
rc=1
else
echo " layer-shell (correct)"
fi
echo
echo "== trust entry shape =="
# Checks CASE as well as ordering. An uppercase key is a valid TOML table
# entry and the right digest, so nothing anywhere complains -- the daemon
# just never matches it and both ends send DTLS bad_certificate.
local badcase
badcase=$(python3 -I -c "
import sys, tomllib
try:
with open(sys.argv[1], \"rb\") as f:
fps = tomllib.load(f).get(\"authorized_fingerprints\", {})
except Exception:
fps = {}
print(sum(1 for k in fps if any(c.isupper() for c in k)))
" "$CONFIG" 2>/dev/null || echo 0)
if [[ ${badcase:-0} -gt 0 ]]; then
echo " FAILING -- ${badcase} fingerprint(s) contain UPPERCASE hex."
echo " lan-mouse compares the string verbatim and generates lowercase, so"
echo " an uppercase key never matches: the digest is right, the case is not,"
echo " nothing logs a complaint, and both ends send DTLS bad_certificate."
echo " Fix: omarchy-handoff fixcase"
rc=1
fi
# Catches a REVERSED entry, which paired() cannot: a backwards
# "description" = "<sha256>" line is a valid non-empty table, and auth then
# fails silently and looks exactly like a dropped packet.
python3 -I -c '
import sys, tomllib
try:
with open(sys.argv[1], "rb") as f:
fps = tomllib.load(f).get("authorized_fingerprints", {})
except Exception:
print(" could not parse config"); sys.exit(1)
if not fps:
print(" NO fingerprints authorized -- the peer cannot be trusted yet."); sys.exit(1)
bad = [k for k in fps if k.count(":") < 16]
for k, v in fps.items():
label = "order OK " if k.count(":") >= 16 else "BACKWARDS"
print(f" {label} {k[:24]}... = {v!r}")
if bad:
print(" ERROR: key must be the FINGERPRINT, value the description.")
print(" Fix: lan-mouse cli remove-authorized-key <desc>, then")
print(" lan-mouse cli authorize-key <desc> <sha256>")
sys.exit(1)
' "$CONFIG" || rc=1
echo
echo "== peer reachability =="
# Added after both machines spent hours green on doctor while a firewall ate
# every inbound packet. Capture and trust can both be perfect and nothing
# crosses. This reads the DAEMON'S OWN connect outcomes -- ground truth from
# the real protocol, not a synthetic probe that proves nothing for UDP.
local pip pport ufwline verdict
pip=$(python3 -I -c "
import sys, tomllib
try:
with open(sys.argv[1], \"rb\") as f:
cs = tomllib.load(f).get(\"clients\", [])
ips = cs[0].get(\"ips\", []) if cs else []
print(ips[0] if ips else \"\")
except Exception:
print(\"\")
" "$CONFIG" 2>/dev/null || true)
pport=$(python3 -I -c "
import sys, tomllib
try:
with open(sys.argv[1], \"rb\") as f:
d = tomllib.load(f)
print(d.get(\"clients\", [{}])[0].get(\"port\") or d.get(\"port\") or 4242)
except Exception:
print(4242)
" "$CONFIG" 2>/dev/null || true)
if [[ -z $pip ]]; then
echo " no peer ip configured -- nothing to reach."
rc=1
else
echo " peer: ${pip}:${pport}"
if ping -c1 -W2 -q "$pip" >/dev/null 2>&1; then
echo " ICMP: host is up"
else
echo " ICMP: no reply -- peer is off, asleep, or on another network."
echo " (Everything below will fail for that reason alone.)"
rc=1
fi
# Our own inbound half. This is the check that was missing entirely.
if command -v ufw >/dev/null && sudo -n ufw status >/dev/null 2>&1; then
ufwline=$(sudo -n ufw status 2>/dev/null | grep -E "^${pport}/udp" || true)
if [[ -n $ufwline ]]; then
echo " our ufw: ALLOWS ${pport}/udp -- ${ufwline}"
else
echo " our ufw: NO rule for ${pport}/udp. If the default is deny incoming,"
echo " the peer's handshake never reaches our daemon and it looks like a"
echo " silent drop. Check: sudo ufw status verbose"
rc=1
fi
else
echo " our ufw: not checked (needs passwordless sudo); verify by hand."
fi
# What did the real protocol actually do most recently?
verdict=$(cmd_log 400 2>/dev/null | grep -aE "connect|Alert|connection" | tail -n1 || true)
if [[ -z $verdict ]]; then
echo " daemon log: no connection attempt recorded yet."
echo " (Cross the edge once, then re-run -- the attempt is what tells us"
echo " whether this is a firewall, a trust, or a routing problem.)"
else
echo " last connect line: ${verdict#*] }"
case $verdict in
*"Alert is Fatal or Close Notify"*)
echo " => the PEER ACTIVELY REJECTED our certificate (DTLS fatal alert)."
echo " Not a firewall: something answered. The peer does not trust"
echo " our fingerprint. Ours is:"
echo " $(cmd_fingerprint 2>/dev/null || echo "<no cert>")"
echo " The peer must authorize exactly that, keyed BY FINGERPRINT."
rc=1 ;;
*"Connection timed out"*)
echo " => NOTHING ANSWERED. Either the peer's firewall is dropping"
echo " ${pport}/udp, or our reply left via a different source address"
echo " than the peer sent to (two NICs on one subnet does this --"
echo " check: ip route get ${pip})."
rc=1 ;;
*"connection established"*|*"connected to"*)
echo " => transport is UP." ;;
esac
fi
fi
echo
echo "== barrier probe =="
if [[ -n $src ]]; then
probe=$(cat "$src")
else
# test-capture runs until killed, so timeout's 124 is expected, not a failure.
probe=$(timeout 8 lan-mouse --capture-backend "${cb:-layer-shell}" test-capture 2>&1 || true)
fi
if ! grep -q "adding capture" <<<"$probe"; then
echo " NO BARRIERS CREATED -- capture is dead."
echo " Full probe output:"
sed 's/^/ /' <<<"$probe"
rc=1
else
# test-capture probes ALL FOUR positions and the registration order is NOT
# stable, so a bare `| tail -n1` returns whichever edge happened to land
# last -- not ours. Observed this report "left" and "bottom" on consecutive
# runs from an unchanged config. Validate only OUR configured edge.
# Within that edge, the populated list grows as each output registers, so
# the LAST line for it is the authoritative one.
local pos
pos=$(python3 -I -c '
import sys, tomllib
try:
with open(sys.argv[1], "rb") as f:
cs = tomllib.load(f).get("clients", [])
print(cs[0].get("position", "") if cs else "")
except Exception:
print("")
' "$CONFIG")
if [[ -z $pos ]]; then
echo " cannot determine our configured edge from $CONFIG"
rc=1
else
echo " our configured edge: $pos"
last=$(grep "adding capture for position ${pos} " <<<"$probe" | tail -n1 || true)
if [[ -z $last ]]; then
echo " FAILING -- no barrier was ever created for \"$pos\"."
echo " (Other edges may have armed; that does not help us.)"
rc=1
else
echo " last line for $pos: ${last#*] }"
if [[ $last == *"using outputs: []"* ]]; then
echo " FAILING -- the final pass for $pos lists no outputs."
echo " (An empty pass BEFORE populated ones is normal and transient.)"
rc=1
else
echo " OK -- barrier is real on the output(s) above, so $pos can fire."
fi
fi
fi
fi
echo
if (( rc == 0 )); then
echo "RESULT: OK"
else
echo "RESULT: PROBLEMS FOUND (see above)"
fi
echo " Scope: capture barriers, reachability, and the shape of OUR OWN"
echo " trust entry. It cannot prove the peer trusts our key unless the"
echo " daemon has actually attempted a connection -- cross the edge once."
return $rc
}
case "${1:-status}" in
status) cmd_status ;;
toggle) shift; cmd_toggle "$@" ;;
on|enable) shift; cmd_on "$@" ;;
off|disable) shift; cmd_off "$@" ;;
position) shift; cmd_position "$@" ;;
peer) shift; cmd_peer "$@" ;;
fingerprint) cmd_fingerprint ;;
pair) shift; cmd_pair "$@" ;;
restart) cmd_restart ;;
log) shift; cmd_log "$@" ;;
fixcase) shift; cmd_fixcase "$@" ;;
doctor) shift; cmd_doctor "$@" ;;
*) die "unknown command '${1}'" ;;
esac
@@ -0,0 +1,4 @@
-- handoff feature: share this keyboard/mouse with the other Omarchy laptop.
-- SUPER+K is already Omarchy's "Keybindings", so this takes SUPER+SHIFT+K.
-- Same chord on both laptops so muscle memory carries across.
o.bind("SUPER + SHIFT + K", "Toggle Handoff (keyboard/mouse sharing)", "omarchy-handoff toggle")
@@ -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"
@@ -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).
@@ -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.
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
# See machines/hp-laptop/packages.txt for the format.
[repo]
lan-mouse # keyboard+mouse sharing driven by bin/omarchy-handoff; also the unpatched fallback
rust # only to compile handoff/patch/; ~250MiB installed, removable after
+4
View File
@@ -0,0 +1,4 @@
# omarchy-handoff: lan-mouse keyboard/mouse sharing between the two laptops.
# Machines that include this set handoff_peer_ips to the OTHER laptop's
# addresses; install.sh's handoff step opens 4242/udp in ufw for exactly those.
description="Keyboard/mouse sharing between the laptops (lan-mouse, patched)"
@@ -0,0 +1,33 @@
[Unit]
Description=lan-mouse input sharing daemon (omarchy-handoff)
PartOf=graphical-session.target
After=graphical-session.target xdg-desktop-portal-hyprland.service
Requisite=graphical-session.target
[Service]
Type=simple
# 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
# LAN_MOUSE_POINTER_SCALE (default 1.0) is per-machine, so it is not set here:
# a machine that needs it ships lan-mouse.service.d/pointer-scale.conf
# (see machines/macbook-air-m2).
Restart=on-failure
RestartSec=2
# Output goes to the journal on purpose. The previous autostart.lua path sent
# both streams to /dev/null, which hid every pairing failure for hours.
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=graphical-session.target