#!/bin/bash # Apply this repo's Omarchy customizations to the machine it runs on. # # Safe to re-run: every step is a no-op when the target already matches, and any # file it would overwrite is backed up to .bak. first. # # ./install.sh # everything # ./install.sh config themes # only the named steps # ./install.sh --list # show the steps # ./install.sh --dry-run # print what would change, touch nothing # # Steps: packages config themes plugins bin services system set -uo pipefail REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" STAMP="$(date +%s)" DRY=0 STEPS=(packages config themes plugins bin handoff services system) # Per-host overlay: hosts//{config,system}/ mirror the shared trees, and # a file there replaces the shared copy of the same path on that host only. It is # how hardware-specific files (monitor scale, fnmode) stay off the wrong machine. HOST="${OMARCHY_SETUP_HOST:-$(hostname)}" HOST_DIR="$REPO/hosts/$HOST" # 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=; } say() { printf '%s\n' "$*"; } head_() { printf '\n%s==> %s%s\n' "$c_hd" "$*" "$c_off"; } ok() { printf ' %s+%s %s\n' "$c_ok" "$c_off" "$*"; } skip() { printf ' %s= %s%s\n' "$c_skip" "$*" "$c_off"; } warn() { printf ' %s! %s%s\n' "$c_warn" "$*" "$c_off"; } err() { printf ' %sx %s%s\n' "$c_err" "$*" "$c_off"; } run() { if (( DRY )); then printf ' %s? would: %s%s\n' "$c_skip" "$*" "$c_off"; else "$@"; fi; } # install_file [mode] -- copy only when content differs, backing up first install_file() { local src=$1 dest=$2 mode=${3:-} local rel=${dest/#"$HOME"/\~} if [[ -e $dest ]] && cmp -s "$src" "$dest"; then skip "$rel" return 0 fi if (( DRY )); then printf ' %s? would write %s%s\n' "$c_skip" "$rel" "$c_off" return 0 fi mkdir -p "$(dirname -- "$dest")" if [[ -e $dest ]]; then cp -a "$dest" "$dest.bak.$STAMP" cp "$src" "$dest" [[ -n $mode ]] && chmod "$mode" "$dest" ok "$rel (previous saved as $(basename -- "$dest").bak.$STAMP)" else cp "$src" "$dest" [[ -n $mode ]] && chmod "$mode" "$dest" ok "$rel" fi } # layered -- NUL-separated "\t" for every file in the shared tree # and the host overlay, the host copy winning where both have the same path. layered() { local tree=$1 f rel declare -A pick=() for base in "$REPO/$tree" "$HOST_DIR/$tree"; do [[ -d $base ]] || continue while IFS= read -r -d '' f; do rel=${f#"$base/"}; pick[$rel]=$f done < <(find "$base" -type f -print0) done for rel in "${!pick[@]}"; do printf '%s\t%s\0' "${pick[$rel]}" "$rel"; done | sort -z -t$'\t' -k2 } # ---------------------------------------------------------------- packages step_packages() { head_ "Packages" command -v omarchy >/dev/null || { err "omarchy not on PATH - is this an Omarchy system?"; return 1; } local section="" repo=() aur=() optional=() while IFS= read -r line; do line=${line%%#*}; line=${line//[[:space:]]/} [[ -z $line ]] && continue case $line in '[repo]') section=repo; continue ;; '[aur]') section=aur; continue ;; '[optional]') section=optional; continue ;; esac case $section in repo) repo+=("$line") ;; aur) aur+=("$line") ;; optional) optional+=("$line") ;; esac done < "$REPO/packages.txt" local missing=() for p in "${repo[@]}"; do pacman -Qq "$p" &>/dev/null || missing+=("$p"); done if (( ${#missing[@]} )); then say " installing from repos: ${missing[*]}" run omarchy pkg add "${missing[@]}" || err "some repo packages failed" else skip "all ${#repo[@]} repo packages present" fi missing=() for p in "${aur[@]}"; do pacman -Qq "$p" &>/dev/null || missing+=("$p"); done if (( ${#missing[@]} )); then say " installing from AUR: ${missing[*]}" run omarchy pkg aur add "${missing[@]}" || err "some AUR packages failed" else skip "all ${#aur[@]} AUR packages present" fi for p in "${optional[@]}"; do pacman -Qq "$p" &>/dev/null || warn "optional, not installed: $p (omarchy pkg add $p)" done } # ---------------------------------------------------------------- config step_config() { head_ "Config files -> ~/.config" [[ -d $HOST_DIR/config ]] && say " host overlay: hosts/$HOST/config" local src rel while IFS=$'\t' read -r -d '' src rel; do install_file "$src" "$HOME/.config/$rel" done < <(layered config) if (( ! DRY )) && command -v hyprctl >/dev/null && [[ -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then hyprctl reload >/dev/null 2>&1 local errs; errs=$(hyprctl configerrors 2>/dev/null) if [[ -n $errs && $errs != "no errors"* ]]; then err "Hyprland reported config errors:"; printf '%s\n' "$errs" | sed 's/^/ /' else ok "hyprctl reload - no config errors" fi fi } # ---------------------------------------------------------------- themes step_themes() { head_ "Themes -> ~/.config/omarchy/themes" local t name dest for t in "$REPO"/themes/*/; do [[ -d $t ]] || continue name=$(basename -- "$t") dest="$HOME/.config/omarchy/themes/$name" if [[ -d $dest ]] && diff -rq "$t" "$dest" >/dev/null 2>&1; then skip "$name" else run mkdir -p "$dest" run cp -r "$t." "$dest/" ok "$name" fi done say "" say " Themes managed by aether (not in this repo - reinstall with \`aether\`):" say " pink-gt3rs porsche-cayman porsche-rain" say " Apply one with: omarchy theme set " } # ---------------------------------------------------------------- plugins step_plugins() { head_ "Shell plugins -> ~/.config/omarchy/plugins" local dir="$HOME/.config/omarchy/plugins" [[ -d $dir ]] || run mkdir -p "$dir" local id url sha p while IFS=$'\t' read -r id url sha; do [[ -z ${id:-} || $id == \#* ]] && continue p="$dir/$id" if [[ -d $p/.git ]]; then if [[ $(git -C "$p" rev-parse HEAD 2>/dev/null) == "$sha" ]]; then skip "$id @ ${sha:0:7}" continue fi if [[ -n $(git -C "$p" status --porcelain 2>/dev/null) ]]; then warn "$id has local changes - leaving it alone (wanted ${sha:0:7})" continue fi run git -C "$p" fetch --quiet origin run git -C "$p" checkout --quiet "$sha" && ok "$id -> ${sha:0:7}" else if (( DRY )); then printf ' %s? would clone %s -> %s%s\n' "$c_skip" "$id" "${sha:0:7}" "$c_off" continue fi git clone --quiet "$url" "$p" || { err "clone failed: $id ($url)"; continue; } git -C "$p" checkout --quiet "$sha" || warn "$id: sha $sha not found, left on default branch" ok "$id @ ${sha:0:7}" fi done < "$REPO/plugins.txt" apply_herdr_hud apply_worktree_patch io.github.tallsam.navbar-cat say "" say " Bar placement lives in config/omarchy/shell.json, already installed by the" say " config step. The shell hot-reloads it; \`omarchy restart shell\` if not." } # indie.herdr-hud carries 10 commits that intentionally never went to the # upstream PR, plus an uncommitted working tree. Both are replayed here. apply_herdr_hud() { local p="$HOME/.config/omarchy/plugins/indie.herdr-hud" local bundle="$REPO/plugin-patches/indie.herdr-hud.bundle" [[ -d $p/.git && -f $bundle ]] || return 0 if git -C "$p" rev-parse --verify --quiet local >/dev/null; then skip "indie.herdr-hud: 'local' branch already present" elif (( DRY )); then printf ' %s? would replay the herdr-hud local branch%s\n' "$c_skip" "$c_off" return 0 else git -C "$p" fetch --quiet "$bundle" 'refs/heads/local:refs/heads/local' \ && { git -C "$p" checkout --quiet local && ok "indie.herdr-hud: replayed 'local' branch"; } \ || warn "indie.herdr-hud: could not replay the local branch from the bundle" fi apply_worktree_patch indie.herdr-hud install_file "$REPO/plugin-patches/indie.herdr-hud--bin--herdr-agents" "$p/bin/herdr-agents" 755 } apply_worktree_patch() { local id=$1 local p="$HOME/.config/omarchy/plugins/$id" local patch="$REPO/plugin-patches/$id-worktree.patch" [[ -d $p/.git && -s $patch ]] || return 0 if git -C "$p" apply --reverse --check "$patch" &>/dev/null; then skip "$id: working-tree patch already applied" elif (( DRY )); then printf ' %s? would apply %s working-tree patch%s\n' "$c_skip" "$id" "$c_off" elif git -C "$p" apply --3way "$patch" &>/dev/null; then ok "$id: applied working-tree patch" else warn "$id: working-tree patch did not apply cleanly - see $patch" fi } # ---------------------------------------------------------------- bin step_bin() { head_ "Scripts -> ~/.local/bin" local f for f in "$REPO"/bin/*; do [[ -f $f ]] || continue install_file "$f" "$HOME/.local/bin/$(basename -- "$f")" 755 done case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) warn "~/.local/bin is not on PATH" ;; esac say "" say " fix-stuck-displays needs passwordless sudo for 'tee /sys/class/drm/*/status'." say " omarchy-handoff needs a peer and a paired fingerprint before it does anything:" say " omarchy-handoff peer then omarchy-handoff doctor" say " Its config (~/.config/lan-mouse/) holds a private key and per-machine" say " fingerprints, so it is not in this repo by design." } # ---------------------------------------------------------------- services step_services() { head_ "systemd user units" local f name changed=0 for f in "$REPO"/systemd/user/*.service; do [[ -f $f ]] || continue name=$(basename -- "$f") local dest="$HOME/.config/systemd/user/$name" [[ -e $dest ]] && cmp -s "$f" "$dest" || changed=1 install_file "$f" "$dest" done (( changed )) && run systemctl --user daemon-reload for f in "$REPO"/systemd/user/*.service; do [[ -f $f ]] || continue name=$(basename -- "$f") if systemctl --user is-enabled --quiet "$name" 2>/dev/null; then skip "$name already enabled" else run systemctl --user enable "$name" && ok "enabled $name" fi done say "" say " Both are WantedBy=graphical-session.target, so they start with Hyprland" say " rather than now. 'systemctl --user start ' to bring one up early." } # ---------------------------------------------------------------- handoff step_handoff() { head_ "omarchy-handoff (lan-mouse)" # Self-sufficient on purpose: `install.sh handoff` alone brings input sharing up # on an otherwise stock machine, without the packages/config/themes steps that # would drag personalizations along. Everything here is idempotent, so running # the full install.sh afterwards does no extra work. # lan-mouse: the signed package, for the GUI/CLI and as the unpatched fallback. # rust: only to compile handoff/patch/, removable afterwards. # NOT python-evdev: packages.txt calls it a dependency, but omarchy-air runs # this whole setup without it installed, so it is not required for sharing. local need=() p for p in lan-mouse rust; do pacman -Qq "$p" &>/dev/null || need+=("$p") done if (( ${#need[@]} )); then say " installing: ${need[*]}" run omarchy pkg add "${need[@]}" || warn "package install failed: ${need[*]}" else skip "lan-mouse, rust" fi install_file "$REPO/bin/omarchy-handoff" "$HOME/.local/bin/omarchy-handoff" 0755 # 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 unit, which points at the binary built above. install_file "$REPO/systemd/user/lan-mouse.service" \ "$HOME/.config/systemd/user/lan-mouse.service" 0644 if (( ! DRY )); then systemctl --user daemon-reload systemctl --user is-enabled --quiet lan-mouse.service 2>/dev/null \ && skip "lan-mouse.service already enabled" \ || { systemctl --user enable lan-mouse.service >/dev/null && ok "enabled lan-mouse.service"; } fi # 3. 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="" # Both reading and changing the ruleset need root. If sudo can't run without # a prompt and there's no terminal to prompt on, say exactly what to run # instead of emitting raw sudo errors and half-applying. if (( ! DRY )) && ! sudo -n true 2>/dev/null && [[ ! -t 0 ]]; then warn "sudo needs a password and there is no terminal; run these yourself:" for ip in "${HANDOFF_PEER_IPS[@]}"; do say " sudo ufw allow from $ip to any port 4242 proto udp comment 'lan-mouse peer $ip'" done say "" say " Without them, anything the peer INITIATES toward this machine is" say " dropped at the wire and looks exactly like a bad certificate." return 0 fi (( 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)" [[ -d $HOST_DIR/system ]] && say " host overlay: hosts/$HOST/system" local src rel dest while IFS=$'\t' read -r -d '' src rel; do dest="/etc/$rel" if [[ -e $dest ]] && cmp -s "$src" "$dest"; then skip "$dest" continue fi if (( DRY )); then printf ' %s? would write %s%s\n' "$c_skip" "$dest" "$c_off" continue fi sudo install -D -m 0644 "$src" "$dest" && ok "$dest" done < <(layered system) if (( ! DRY )); then sudo sysctl --system >/dev/null 2>&1 && ok "sysctl reloaded" say "" say " hid_apple.conf (fnmode) only takes effect once the" say " initramfs is rebuilt or the module reloads:" say " sudo mkinitcpio -P # or: sudo modprobe -r hid_apple && sudo modprobe hid_apple" fi } # ---------------------------------------------------------------- main main() { local want=() for a in "$@"; do case $a in --dry-run|-n) DRY=1 ;; --list|-l) printf '%s\n' "${STEPS[@]}"; exit 0 ;; -h|--help) sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) err "unknown option: $a"; exit 2 ;; *) if [[ " ${STEPS[*]} " == *" $a "* ]]; then want+=("$a") else err "unknown step: $a (one of: ${STEPS[*]})"; exit 2; fi ;; esac done (( ${#want[@]} )) || want=("${STEPS[@]}") say "${c_hd}omarchy-setup${c_off} repo=$REPO host=$HOST" (( DRY )) && warn "dry run - nothing will be changed" command -v omarchy >/dev/null || warn "omarchy not found; this looks like a non-Omarchy host" for s in "${want[@]}"; do "step_$s"; done head_ "Done" say " Re-running is safe. Backups from this run are suffixed .bak.$STAMP" say " Pull live changes back into the repo with ./capture.sh" } main "$@"