#!/bin/bash # omarchy:summary=Share keyboard and mouse with a nearby Omarchy machine # omarchy:group=handoff # omarchy:args=[status|toggle|on|off|position |peer |fingerprint|pair |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 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: "" = "". # 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 ` 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 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 )" 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 [,...] [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 " 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() { 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 sleep 2 [[ -S $SOCKET ]] || die "daemon failed to start (see $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" = "" 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 , then") print(" lan-mouse cli authorize-key ") 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 "")" 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