From 9ac198da269e44e74acb1493f48e9c0e745a5ca7 Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Sat, 10 Oct 2026 12:07:15 -0400 Subject: [PATCH] Capture this laptop's Omarchy customizations Everything on top of a stock Omarchy 4.0.4-1 install, gathered so a second machine can be brought to the same state: the ~/.config files that actually differ from the shipped defaults, the three hand-built themes, the ten shell plugins pinned to commits, the two custom scripts, two user units, and the /etc drop-ins. install.sh applies it all idempotently, backing up anything it overwrites. capture.sh runs the other way so the repo stays the source of truth rather than a snapshot that rots. indie.herdr-hud's ten local-only commits ship as a git bundle plus a working-tree patch, since they stay off the upstream PR deliberately. Secrets, the dotfiles repo's territory (shell/git/mise), the remote-access stack and aether-managed themes are all scoped out; README says why for each. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + README.md | 146 +++ bin/fix-stuck-displays | 53 ++ bin/omarchy-handoff | 505 ++++++++++ capture.sh | 189 ++++ config/alacritty/alacritty.toml | 28 + config/foot/foot.ini | 26 + config/ghostty/config | 41 + config/herdr/config.toml | 107 +++ config/hypr/autostart.lua | 10 + config/hypr/bindings.lua | 43 + config/hypr/hyprland.lua | 32 + config/hypr/input.lua | 72 ++ config/hypr/monitors.lua | 48 + config/kitty/kitty.conf | 23 + config/omarchy/branding/about.txt | 26 + config/omarchy/branding/screensaver.txt | 10 + config/omarchy/defaults/agent | 1 + config/omarchy/ilyazar.btop/settings.toml | 12 + config/omarchy/shell.json | 94 ++ config/omarchy/shell.toml | 2 + config/opencode/opencode.json | 4 + install.sh | 321 +++++++ packages.txt | 56 ++ .../indie.herdr-hud--bin--herdr-agents | 192 ++++ plugin-patches/indie.herdr-hud-worktree.patch | 861 ++++++++++++++++++ plugin-patches/indie.herdr-hud.bundle | Bin 0 -> 23408 bytes ...o.github.tallsam.navbar-cat-worktree.patch | 48 + plugins.txt | 12 + system/modprobe.d/hid_apple.conf | 1 + system/sysctl.d/99-omarchy-handoff-arp.conf | 9 + systemd/user/lan-mouse.service | 21 + systemd/user/voxtype.service | 17 + themes/ferrari/CREDITS.md | 5 + .../ferrari/backgrounds/1-giallo-studio.jpg | Bin 0 -> 1183695 bytes .../ferrari/backgrounds/2-london-nights.jpg | Bin 0 -> 1986021 bytes themes/ferrari/backgrounds/3-light-trails.jpg | Bin 0 -> 2393341 bytes themes/ferrari/backgrounds/4-at-speed.jpg | Bin 0 -> 1291104 bytes themes/ferrari/colors.toml | 35 + themes/ferrari/icons.theme | 1 + themes/porsche/CREDITS.md | 6 + themes/porsche/backgrounds/0-718-cayman-s.jpg | Bin 0 -> 1185521 bytes .../porsche/backgrounds/1-rainy-evening.jpg | Bin 0 -> 1402510 bytes themes/porsche/backgrounds/2-gulf-917.jpg | Bin 0 -> 2539886 bytes themes/porsche/backgrounds/3-night.jpg | Bin 0 -> 3138184 bytes themes/porsche/backgrounds/4-carrera-rs.jpg | Bin 0 -> 2028144 bytes themes/porsche/colors.toml | 35 + themes/porsche/icons.theme | 1 + themes/simpsons/CREDITS.md | 14 + .../backgrounds/1-springfield-wasteland.jpg | Bin 0 -> 333682 bytes themes/simpsons/backgrounds/2-bart-sunset.jpg | Bin 0 -> 332101 bytes themes/simpsons/backgrounds/3-duff-wall.jpg | Bin 0 -> 1814966 bytes themes/simpsons/btop.theme | 93 ++ themes/simpsons/chromium.theme | 1 + themes/simpsons/colors.toml | 37 + themes/simpsons/hyprland.lua | 17 + themes/simpsons/icons.theme | 1 + themes/simpsons/keyboard.rgb | 1 + themes/simpsons/neovim.lua | 13 + themes/simpsons/preview.png | Bin 0 -> 461140 bytes themes/simpsons/shell.lock.toml | 6 + 61 files changed, 3279 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/fix-stuck-displays create mode 100755 bin/omarchy-handoff create mode 100755 capture.sh create mode 100644 config/alacritty/alacritty.toml create mode 100644 config/foot/foot.ini create mode 100644 config/ghostty/config create mode 100644 config/herdr/config.toml create mode 100644 config/hypr/autostart.lua create mode 100644 config/hypr/bindings.lua create mode 100644 config/hypr/hyprland.lua create mode 100644 config/hypr/input.lua create mode 100644 config/hypr/monitors.lua create mode 100644 config/kitty/kitty.conf create mode 100644 config/omarchy/branding/about.txt create mode 100644 config/omarchy/branding/screensaver.txt create mode 100644 config/omarchy/defaults/agent create mode 100644 config/omarchy/ilyazar.btop/settings.toml create mode 100644 config/omarchy/shell.json create mode 100644 config/omarchy/shell.toml create mode 100644 config/opencode/opencode.json create mode 100755 install.sh create mode 100644 packages.txt create mode 100755 plugin-patches/indie.herdr-hud--bin--herdr-agents create mode 100644 plugin-patches/indie.herdr-hud-worktree.patch create mode 100644 plugin-patches/indie.herdr-hud.bundle create mode 100644 plugin-patches/io.github.tallsam.navbar-cat-worktree.patch create mode 100644 plugins.txt create mode 100644 system/modprobe.d/hid_apple.conf create mode 100644 system/sysctl.d/99-omarchy-handoff-arp.conf create mode 100644 systemd/user/lan-mouse.service create mode 100644 systemd/user/voxtype.service create mode 100644 themes/ferrari/CREDITS.md create mode 100644 themes/ferrari/backgrounds/1-giallo-studio.jpg create mode 100644 themes/ferrari/backgrounds/2-london-nights.jpg create mode 100644 themes/ferrari/backgrounds/3-light-trails.jpg create mode 100644 themes/ferrari/backgrounds/4-at-speed.jpg create mode 100644 themes/ferrari/colors.toml create mode 100644 themes/ferrari/icons.theme create mode 100644 themes/porsche/CREDITS.md create mode 100644 themes/porsche/backgrounds/0-718-cayman-s.jpg create mode 100644 themes/porsche/backgrounds/1-rainy-evening.jpg create mode 100644 themes/porsche/backgrounds/2-gulf-917.jpg create mode 100644 themes/porsche/backgrounds/3-night.jpg create mode 100644 themes/porsche/backgrounds/4-carrera-rs.jpg create mode 100644 themes/porsche/colors.toml create mode 100644 themes/porsche/icons.theme create mode 100644 themes/simpsons/CREDITS.md create mode 100644 themes/simpsons/backgrounds/1-springfield-wasteland.jpg create mode 100644 themes/simpsons/backgrounds/2-bart-sunset.jpg create mode 100644 themes/simpsons/backgrounds/3-duff-wall.jpg create mode 100644 themes/simpsons/btop.theme create mode 100644 themes/simpsons/chromium.theme create mode 100644 themes/simpsons/colors.toml create mode 100644 themes/simpsons/hyprland.lua create mode 100644 themes/simpsons/icons.theme create mode 100644 themes/simpsons/keyboard.rgb create mode 100644 themes/simpsons/neovim.lua create mode 100644 themes/simpsons/preview.png create mode 100644 themes/simpsons/shell.lock.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d720a6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.bak.* +*.prefix.* +*.new diff --git a/README.md b/README.md new file mode 100644 index 0000000..933f835 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# omarchy-setup + +Every customization this laptop has on top of a stock Omarchy install, in one +place, so a second machine can be brought to the same state. + +Captured from Omarchy **4.0.4-1** on **2026-10-10**. + +```bash +git clone ~/omarchy-setup +cd ~/omarchy-setup +./install.sh --dry-run # see what it would change +./install.sh # do it +``` + +`install.sh` is idempotent and backs up anything it overwrites as +`.bak.`. `capture.sh` runs the other way, pulling live changes back +into the repo so this stays the source of truth. + +## What is in here + +| Path | What | +|---|---| +| `config/` | Mirrors `~/.config`. Only files that differ from Omarchy's shipped defaults. | +| `themes/` | The three hand-built themes, wallpapers included. | +| `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`. | +| `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. | + +## The customizations, and why + +**Displays** (`config/hypr/monitors.lua`) are the fiddliest part. The file is the +single source of truth for monitor rules on purpose — overlapping rules spread +across several files is what previously made external displays unrecoverable. +Three outputs are named by EDID description rather than connector, so they match +whichever port they land on: the built-in Chimei panel at 1.25x, the LG IPS236 on +HDMI at 1x, and the LG TV through the Anker dock. The TV is deliberately left on +`preferred` (3840x2160@30) — the dock negotiates DP alt-mode with only two lanes, +so forcing 4K60 produced a "monitor has no mode" loop. `GDK_SCALE=1` with a 1.25 +monitor scale; the stock pairing is 2 and `auto`. + +`hyprland.lua` sources `hyprmoncfg-monitors.lua` last so the `hyprmoncfg` daemon's +generated layout wins over anything above it. + +`bin/fix-stuck-displays` recovers the related kernel-side failure: after +suspend/resume i915 can leave an unplugged connector "disconnected" but still +"enabled" on a pipe Hyprland has forgotten, which blocks every other output from +modesetting. It forces the connector on, disables it through Hyprland to release +the pipe, then returns it to auto-detect. It needs passwordless sudo for +`tee /sys/class/drm/*/status`. + +**Keyboard/mouse sharing** (`bin/omarchy-handoff`, `systemd/user/lan-mouse.service`) +drives `lan-mouse` to share input with the other Omarchy laptop, bound to +`SUPER+SHIFT+K` (`SUPER+K` is already Keybindings). The script's header comment +is the real documentation — it records the non-obvious facts that cost hours: +`capture_backend` must be `layer-shell` (the portal backend is a silent no-op on +Hyprland 0.56.2), `[authorized_fingerprints]` is keyed by fingerprint and the +reversed form fails silently, and the log variable is `LAN_MOUSE_LOG_LEVEL`, not +`RUST_LOG`. It runs as a user unit rather than from `autostart.lua` because +`o.launch_on_start` sends both streams to `/dev/null`, hiding every pairing +failure. + +`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. + +**Bar and shell** (`config/omarchy/shell.json`) adds `omarchy.active-window` to +the left, five plugin widgets, and `indie.herdr-hud` as a non-bar plugin. Idle is +stretched a long way past stock — screensaver at 20 minutes, lock at 30, against +2.5 and 5. + +**Keybindings** (`config/hypr/bindings.lua`) rebinds `SUPER+SHIFT+A` from the +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. + +**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. + +**Branding** (`config/omarchy/branding/`) replaces the about and screensaver +ASCII art. + +**`hid_apple` fnmode=2** makes the F-keys act as F-keys on an Apple keyboard. +It only takes effect after `sudo mkinitcpio -P` or a module reload. + +## Local plugin work + +`indie.herdr-hud` is the one piece that exists on no remote. Ten commits live on +its `local` branch — touchpad scroll feel, agent ordering, context-size display — +and they stay off the upstream PR deliberately. `plugin-patches/` carries them as +a git bundle plus a patch for the uncommitted working tree, and `install.sh` +replays both after cloning. `io.github.tallsam.navbar-cat` has a one-file working +tree change captured the same way. + +`capture.sh plugins` re-bundles and re-diffs these, so local work keeps flowing +back into the repo. + +## Not in here, on purpose + +- **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`. +- **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 + `~/.config/git/config`, whose customizations are identity and credential + helpers — `dotfiles` territory. +- **The remote-access stack.** `wayvnc`, `novnc`, `sunshine`, `freerdp`, `remmina` + and `omarchy-remote-desktop` are installed on this laptop but scoped out. + `kokd.remote-desktop` is still in `plugins.txt` since it is a bar plugin, but + it is not placed in `shell.json`. +- **Work tooling.** The keycloak helpers (`kcadm`, `kclogin`, `wren-keycloak`) and + `paperclipai.service`. +- **aether-managed themes.** `pink-gt3rs`, `porsche-cayman` and `porsche-rain` + carry a `.aether-managed` marker and are reinstallable with `aether`, so their + ~18MB of wallpapers and previews stay out. +- **`btop.conf`.** Its only diff against stock is v1.4.7 default drift, not a + real customization. +- **Backup files.** Fifteen `.bak.*` and `.prefix.*` files are sitting in + `~/.config/hypr` and `~/.config/omarchy` from earlier editing sessions. They are + not tracked and are safe to delete. + +## Adding a file + +`capture.sh` only refreshes what the repo already tracks, so start it by hand: + +```bash +cp ~/.config/foo/bar.conf config/foo/bar.conf # path mirrors ~/.config +git add config/foo/bar.conf +``` + +`install.sh config` and `capture.sh config` both walk `config/` recursively, so +nothing else needs changing. Check first that the file actually differs from +stock — `diff /usr/share/omarchy/config/foo/bar.conf ~/.config/foo/bar.conf` — +or you will be tracking a file Omarchy already ships. diff --git a/bin/fix-stuck-displays b/bin/fix-stuck-displays new file mode 100755 index 0000000..bfd3dde --- /dev/null +++ b/bin/fix-stuck-displays @@ -0,0 +1,53 @@ +#!/bin/bash +# Release display pipes the kernel left enabled on unplugged connectors. +# +# After suspend/resume (or unplugging a USB-C display) i915 can leave a +# connector "disconnected" but still "enabled" on a pipe that Hyprland has +# forgotten. That pipe then blocks every other external output from +# modesetting (black screens, or a ~5 s re-modeset loop). +# +# Fix: force the connector on so Hyprland sees it, disable it from Hyprland +# (which releases the pipe), force it off, then return it to auto-detect. +# Never `hyprctl reload` here: a reload re-enables the connector while it is +# forced on and re-sticks the pipe. + +log() { echo "fix-stuck-displays: $*"; logger -t fix-stuck-displays -- "$*"; } + +wait_for() { # wait_for + local n=$(( $1 * 4 )); shift + for _ in $(seq "$n"); do "$@" && return 0; sleep 0.25; done + return 1 +} + +stuck=() +for d in /sys/class/drm/card*-*; do + [[ -f $d/status && -f $d/enabled ]] || continue + [[ $d == *eDP* ]] && continue + [[ $(<"$d/status") == disconnected && $(<"$d/enabled") == enabled ]] && stuck+=("$d") +done + +if (( ${#stuck[@]} == 0 )); then + log "no stuck connectors" + exit 0 +fi + +systemctl --user stop hyprmoncfgd + +for d in "${stuck[@]}"; do + out=${d##*/card*-} + log "releasing $out" + echo on | sudo -n tee "$d/status" >/dev/null + wait_for 5 sh -c "hyprctl monitors all | grep -q '^Monitor $out '" || log "$out never appeared in Hyprland" + hyprctl eval "hl.monitor({ output = \"$out\", disabled = true })" >/dev/null + wait_for 5 grep -qx disabled "$d/enabled" || log "$out still enabled after disable" + echo off | sudo -n tee "$d/status" >/dev/null + sleep 1 + echo detect | sudo -n tee "$d/status" >/dev/null + log "$out now $(<"$d/status") $(<"$d/enabled")" +done + +# Drop any low-refresh fallback hyprmoncfgd wrote while outputs were failing. +rm -f ~/.config/hyprmoncfg/display-fallbacks.json + +# hyprmoncfgd re-applies the matching profile (modes, positions) on startup. +systemctl --user start hyprmoncfgd diff --git a/bin/omarchy-handoff b/bin/omarchy-handoff new file mode 100755 index 0000000..c936c02 --- /dev/null +++ b/bin/omarchy-handoff @@ -0,0 +1,505 @@ +#!/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 diff --git a/capture.sh b/capture.sh new file mode 100755 index 0000000..86e8b70 --- /dev/null +++ b/capture.sh @@ -0,0 +1,189 @@ +#!/bin/bash +# Pull the live machine's state back into this repo - the reverse of install.sh. +# +# Run it after customizing something by hand, then review `git diff` and commit. +# It only refreshes paths the repo already tracks; it never invents new ones, so +# adding a newly customized file is a deliberate act (see README, "Adding a file"). +# +# ./capture.sh # refresh everything +# ./capture.sh config # only the named steps +# ./capture.sh --list +# +# Steps: config themes plugins bin services system packages + +set -uo pipefail + +REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +STEPS=(config themes plugins bin services system packages) + +c_ch=$'\033[33m'; c_skip=$'\033[2m'; c_warn=$'\033[33m'; c_hd=$'\033[1m'; c_off=$'\033[0m' +[[ -t 1 ]] || { c_ch=; c_skip=; c_warn=; c_hd=; c_off=; } +say() { printf '%s\n' "$*"; } +head_() { printf '\n%s==> %s%s\n' "$c_hd" "$*" "$c_off"; } +chg() { printf ' %sM %s%s\n' "$c_ch" "$*" "$c_off"; } +skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; } +warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; } +gone() { printf ' %s? %s%s\n' "$c_warn" "$*" "$c_off"; } + +# pull_back