From a9501c016ffe091b99fddf4d9a47f14f2fbf443a Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:13:56 -0400 Subject: [PATCH 1/9] fix: prevent duplicate wallpaper listeners Separate one-shot refresh from session-owned listening and bound daemon startup. Add isolated regression checks and CI. Co-authored-by: Codex --- .config/hypr/conf/keybinds.conf | 2 +- .config/hypr/scripts/wallpaper.sh | 87 +++++++++++++++++++------------ .github/workflows/checks.yml | 14 +++++ README.md | 9 ++++ tests/run.sh | 6 +++ tests/wallpaper.sh | 51 ++++++++++++++++++ 6 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 tests/run.sh create mode 100644 tests/wallpaper.sh diff --git a/.config/hypr/conf/keybinds.conf b/.config/hypr/conf/keybinds.conf index fb0ecba..7d3d147 100644 --- a/.config/hypr/conf/keybinds.conf +++ b/.config/hypr/conf/keybinds.conf @@ -29,7 +29,7 @@ bind = $mainMod, G, exec, kitty --class floating-terminal -e lazygit bind = $mainMod SHIFT, Escape, exec, kitty --class floating-terminal -e btop # Hyprland Scripts -bind = $mainMod, A, exec, ~/.config/hypr/scripts/wallpaper.sh +bind = $mainMod, A, exec, ~/.config/hypr/scripts/wallpaper.sh --once bind = $mainMod SHIFT, R, exec, ~/.config/hypr/scripts/reload.sh # Waybar Scripts diff --git a/.config/hypr/scripts/wallpaper.sh b/.config/hypr/scripts/wallpaper.sh index 8664d7a..431fcef 100755 --- a/.config/hypr/scripts/wallpaper.sh +++ b/.config/hypr/scripts/wallpaper.sh @@ -1,41 +1,60 @@ -#!/bin/bash +#!/usr/bin/env bash +# Apply workspace wallpapers once or listen with one owner per Hyprland session. +set -euo pipefail -# Transition settings: clean bezier fade matching monitor refresh rate (144Hz) -TRANSITION_OPTS="--transition-type fade --transition-bezier .23,1,.32,1 --transition-duration 0.8 --transition-fps 144" - -change_wallpaper(){ -workspace=$(hyprctl activeworkspace -j | jq -r '.id') - -# Set wallpapers for each workspace -case $workspace in - 1) - awww img /home/samhb/Pictures/Wallpapers/csm_wallpaper.jpg $TRANSITION_OPTS - ;; - 2) - awww img /home/samhb/Pictures/Wallpapers/reze_wallpaper.png $TRANSITION_OPTS - ;; - 3) - awww img /home/samhb/Pictures/Wallpapers/dandadan_wallpaper.png $TRANSITION_OPTS - ;; - 4) - awww img /home/samhb/Pictures/Wallpapers/goku_black_wallpaper.jpg $TRANSITION_OPTS - ;; - 5) - awww img /home/samhb/Pictures/Wallpapers/evangelion_wallpaper.png $TRANSITION_OPTS - ;; -esac +mode=${1:-listen} +if (( $# > 1 )) || [[ $mode != listen && $mode != --once ]]; then + echo 'Usage: wallpaper.sh [--once]' >&2 + exit 2 +fi +for command in hyprctl jq awww; do + command -v "$command" >/dev/null || { echo "Missing command: $command" >&2; exit 1; } +done +# Apply the current workspace image; unmapped workspaces retain the last image. +change_wallpaper() { + local workspace image + workspace=$(hyprctl activeworkspace -j | jq -er '.id') + case "$workspace" in + 1) image=csm_wallpaper.jpg ;; + 2) image=reze_wallpaper.png ;; + 3) image=dandadan_wallpaper.png ;; + 4) image=goku_black_wallpaper.jpg ;; + 5) image=evangelion_wallpaper.png ;; + *) return 0 ;; + esac + image="$HOME/Pictures/Wallpapers/$image" + if [[ ! -f $image ]]; then + echo "Wallpaper not found: $image" >&2 + return 1 + fi + awww img "$image" --transition-type fade --transition-bezier .23,1,.32,1 \ + --transition-duration 0.8 --transition-fps 144 } -# Set initial wallpaper -change_wallpaper +if [[ $mode == listen ]]; then + : "${XDG_RUNTIME_DIR:?Missing XDG_RUNTIME_DIR}" "${HYPRLAND_INSTANCE_SIGNATURE:?Missing Hyprland session}" + for command in socat flock; do + command -v "$command" >/dev/null || { echo "Missing command: $command" >&2; exit 1; } + done + socket_path="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" + [[ -S $socket_path ]] || { echo "Hyprland socket not found: $socket_path" >&2; exit 1; } + exec 9>"$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/wallpaper.lock" + flock -n 9 || { echo 'Wallpaper listener already running.'; exit 0; } +fi -# Socket Path: -SOCKET_PATH="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" +# Autostart order does not guarantee awww is ready; bound the wait to five seconds. +ready=false +for ((attempt=0; attempt<20; attempt++)); do + if awww query >/dev/null 2>&1; then ready=true; break; fi + sleep 0.25 +done +[[ $ready == true ]] || { echo 'Wallpaper daemon unavailable after five seconds.' >&2; exit 1; } +change_wallpaper +[[ $mode == --once ]] && exit 0 -# Listen for workspace events -socat -U - UNIX-CONNECT:${SOCKET_PATH} | while read -r line; do - if echo "$line" | grep -q "workspace>>"; then - change_wallpaper - fi +socat -U - "UNIX-CONNECT:$socket_path" | while IFS= read -r line; do + case "$line" in + workspace\>\>*) change_wallpaper || echo 'Could not update workspace wallpaper.' >&2 ;; + esac done diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..0a6e15c --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,14 @@ +# Validate shell syntax, regressions, and shell usage on pull requests. +name: Dotfiles checks +on: [push, pull_request] +permissions: + contents: read +jobs: + shell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y shellcheck jq + - run: bash tests/run.sh + - name: ShellCheck changed shell scripts + run: shellcheck .config/hypr/scripts/wallpaper.sh tests/*.sh diff --git a/README.md b/README.md index 0768617..1d036e4 100644 --- a/README.md +++ b/README.md @@ -179,3 +179,12 @@ To apply these tweaks: ```bash sudo ./scripts/apply-system-optimizations.sh ``` + +## Verification + +Run `bash tests/run.sh` for shell syntax and isolated regression checks. Tests use +mock commands and temporary homes; they do not alter the running desktop. + +The workspace wallpaper listener has one owner per Hyprland session. `SUPER+A` +runs `wallpaper.sh --once` to refresh without creating another listener. Workspaces +6–10 retain the last wallpaper. Missing images and daemon failures go to stderr. diff --git a/tests/run.sh b/tests/run.sh new file mode 100644 index 0000000..ac0b03d --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run syntax checks and isolated regressions without touching the live desktop. +set -euo pipefail +cd "$(dirname "$0")/.." +while IFS= read -r -d '' script; do bash -n "$script"; done < <(find scripts .config tests -type f -name '*.sh' -print0) +for test in tests/*.sh; do [[ $test == tests/run.sh ]] || bash "$test"; done diff --git a/tests/wallpaper.sh b/tests/wallpaper.sh new file mode 100644 index 0000000..8e75087 --- /dev/null +++ b/tests/wallpaper.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Regression checks for wallpaper refresh and listener ownership. +set -euo pipefail +repo=$(cd "$(dirname "$0")/.." && pwd) +task_tmp=$(mktemp -d) +trap 'rm -rf "$task_tmp"' EXIT +export HOME="$task_tmp/home" XDG_RUNTIME_DIR="$task_tmp/runtime" HYPRLAND_INSTANCE_SIGNATURE=test +export TEST_LOG="$task_tmp/images" +mkdir -p "$HOME/Pictures/Wallpapers" "$XDG_RUNTIME_DIR/hypr/test" "$task_tmp/bin" +touch "$HOME/Pictures/Wallpapers/csm_wallpaper.jpg" +python3 - "$XDG_RUNTIME_DIR/hypr/test/.socket2.sock" <<'PY' +import os, socket, sys +try: + s = socket.socket(socket.AF_UNIX) + s.bind(sys.argv[1]) +except PermissionError: + # Restricted runners forbid socket creation; socat is mocked regardless. + os.symlink('/run/dbus/system_bus_socket', sys.argv[1]) +PY +cat > "$task_tmp/bin/hyprctl" <<'MOCK' +#!/bin/bash +printf '{"id":%s}\n' "${TEST_WORKSPACE:-1}" +MOCK +cat > "$task_tmp/bin/awww" <<'MOCK' +#!/bin/bash +if [[ $1 == query ]]; then exit "${TEST_DAEMON_STATUS:-0}"; fi +printf '%s\n' "$2" >> "$TEST_LOG" +MOCK +cat > "$task_tmp/bin/socat" <<'MOCK' +#!/bin/bash +sleep 0.5 +printf 'activewindow>>ignored\nworkspace>>1\n' +MOCK +chmod +x "$task_tmp/bin/"* +export PATH="$task_tmp/bin:$PATH" +script="$repo/.config/hypr/scripts/wallpaper.sh" +bash "$script" --once +[[ $(wc -l < "$TEST_LOG") == 1 ]] +TEST_WORKSPACE=6 bash "$script" --once +[[ $(wc -l < "$TEST_LOG") == 1 ]] +rm "$TEST_LOG" +bash "$script" & +listener=$! +for ((i=0; i<100; i++)); do [[ -f $TEST_LOG ]] && break; sleep 0.01; done +bash "$script" +wait "$listener" +[[ $(wc -l < "$TEST_LOG") == 2 ]] +rm "$HOME/Pictures/Wallpapers/csm_wallpaper.jpg" +if bash "$script" --once >/dev/null 2>&1; then echo 'Missing image accepted' >&2; exit 1; fi +if bash "$script" --invalid >/dev/null 2>&1; then exit 1; fi +printf 'Wallpaper regression checks passed\n' From 11d94079021bcc77cde52e820a3e8e1746a02acb Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:14:41 -0400 Subject: [PATCH 2/9] fix: make rofi pickers handle selections correctly Preserve SSIDs and paths using indexed selections. Implement clipboard delete and confirmed clear, and keep cancellation side-effect free. Co-authored-by: Codex --- .config/rofi/scripts/clipboard_history.sh | 47 ++++++++++--- .config/rofi/scripts/wallpaper.sh | 59 ++++++++-------- .config/rofi/scripts/wifi.sh | 32 ++++++--- .github/workflows/checks.yml | 2 +- README.md | 7 ++ tests/rofi.sh | 82 +++++++++++++++++++++++ 6 files changed, 178 insertions(+), 51 deletions(-) create mode 100644 tests/rofi.sh diff --git a/.config/rofi/scripts/clipboard_history.sh b/.config/rofi/scripts/clipboard_history.sh index 90f5598..614007b 100755 --- a/.config/rofi/scripts/clipboard_history.sh +++ b/.config/rofi/scripts/clipboard_history.sh @@ -1,13 +1,40 @@ #!/usr/bin/env bash +# Copy, delete, or confirm clearing clipboard history without changing it on cancel. set -euo pipefail +trap 'notify-send "Clipboard" "Clipboard operation failed." >&2 || true' ERR +payload=$(mktemp) +trap 'rm -f "$payload"' EXIT -# Show clipboard history with custom message using dedicated theme -cliphist list | rofi -dmenu -i \ - -p " CLIPBOARD HISTORY" \ - -mesg " [ ENTER ] PASTE [ DEL ] DELETE ENTRY [ CTRL+DEL ] CLEAR ALL" \ - -theme ~/.config/rofi/themes/clipboard.rasi | cliphist decode | wl-copy - -# Notify if copied -if [ -n "$(wl-paste 2>/dev/null || true)" ]; then - notify-send "Clipboard" "Content copied!" -t 2000 -fi +while true; do + history=$(cliphist list) + [[ -n $history ]] || { notify-send 'Clipboard' 'History is empty.'; exit 0; } + mapfile -t entries <<< "$history" + status=0 + selected=$(printf '%s\n' "${entries[@]}" | rofi -dmenu -i -no-custom -format i \ + -kb-custom-1 Delete -kb-custom-2 Control+Delete \ + -p 'CLIPBOARD HISTORY' -mesg '[ ENTER ] COPY [ DEL ] DELETE ENTRY [ CTRL+DEL ] CLEAR ALL' \ + -theme "$HOME/.config/rofi/themes/clipboard.rasi") || status=$? + [[ $status != 1 ]] || exit 0 + case $status in + 0|10) + [[ $selected =~ ^(0|[1-9][0-9]*)$ ]] && (( selected < ${#entries[@]} )) || exit 1 + if [[ $status == 10 ]]; then + printf '%s\n' "${entries[selected]}" | cliphist delete + continue + fi + # Decode first: a failed lookup must not replace the current clipboard. + printf '%s\n' "${entries[selected]}" | cliphist decode > "$payload" + wl-copy < "$payload" + notify-send 'Clipboard' 'Content copied!' -t 2000 + exit 0 + ;; + 11) + confirm_status=0 + confirmation=$(printf 'Cancel\nClear all\n' | rofi -dmenu -no-custom -format i \ + -p 'Clear clipboard history?' -theme "$HOME/.config/rofi/themes/current.rasi") || confirm_status=$? + if [[ $confirm_status == 0 && $confirmation == 1 ]]; then cliphist wipe; fi + [[ $confirm_status == 0 || $confirm_status == 1 ]] || exit 1 + ;; + *) exit "$status" ;; + esac +done diff --git a/.config/rofi/scripts/wallpaper.sh b/.config/rofi/scripts/wallpaper.sh index f063482..bbad249 100755 --- a/.config/rofi/scripts/wallpaper.sh +++ b/.config/rofi/scripts/wallpaper.sh @@ -1,29 +1,30 @@ -#!/bin/bash - -WALLPAPER_DIR="$HOME/Pictures/Wallpapers" -[ -d "$WALLPAPER_DIR" ] || WALLPAPER_DIR="$HOME/Pictures/Wallpaper" - -# Get all wallpapers -WALLPAPERS=$(find "$WALLPAPER_DIR" -type f \( -iname "*.jpg" -o -iname "*.png" -o -iname "*.jpeg" \) | sort) - -# Check if directory has wallpapers -if [ -z "$WALLPAPERS" ]; then - notify-send "Wallpaper Selector" "No wallpapers found in $WALLPAPER_DIR" - exit 1 -fi - -# Create rofi menu with image names -SELECTED=$(echo "$WALLPAPERS" | while read -r wallpaper; do - basename "$wallpaper" -done | rofi -dmenu -i -theme ~/.config/rofi/themes/current.rasi -p "Select Wallpaper" \ - -theme-str 'window {width: 600px;} listview {lines: 10;}') - -# If something was selected -if [ -n "$SELECTED" ]; then - WALLPAPER_PATH=$(echo "$WALLPAPERS" | grep "$SELECTED$") - - if [ -f "$WALLPAPER_PATH" ]; then - awww img "$WALLPAPER_PATH" --transition-type fade --transition-bezier .23,1,.32,1 --transition-duration 0.8 --transition-fps 144 - notify-send "Wallpaper Changed" "$SELECTED" -i "$WALLPAPER_PATH" -t 3000 - fi -fi +#!/usr/bin/env bash +# Select wallpaper paths by index, preserving punctuation and duplicate basenames. +set -euo pipefail +trap 'notify-send "Wallpaper" "Could not select or apply wallpaper." >&2 || true' ERR +wallpaper_dir="$HOME/Pictures/Wallpapers" +[[ -d $wallpaper_dir ]] || wallpaper_dir="$HOME/Pictures/Wallpaper" +[[ -d $wallpaper_dir ]] || { notify-send 'Wallpaper' 'Wallpaper directory not found.'; exit 1; } +# A temporary null-delimited list preserves paths and lets discovery failures surface. +list=$(mktemp) +trap 'rm -f "$list"' EXIT +find "$wallpaper_dir" -type f \( -iname '*.jpg' -o -iname '*.png' -o -iname '*.jpeg' \) -print0 | sort -z > "$list" +mapfile -d '' -t wallpapers < "$list" +(( ${#wallpapers[@]} )) || { notify-send 'Wallpaper' 'No wallpapers found.'; exit 0; } +names=() +for wallpaper in "${wallpapers[@]}"; do + name=${wallpaper#"$wallpaper_dir/"} + # Display control characters safely; the index still resolves the original path. + printf -v name '%q' "$name" + names+=("$name") +done +status=0 +selected=$(printf '%s\n' "${names[@]}" | rofi -dmenu -i -no-custom -format i \ + -theme "$HOME/.config/rofi/themes/current.rasi" -p 'Select Wallpaper' \ + -theme-str 'window {width: 600px;} listview {lines: 10;}') || status=$? +[[ $status != 1 ]] || exit 0 +[[ $status == 0 ]] || exit "$status" +[[ $selected =~ ^(0|[1-9][0-9]*)$ ]] && (( selected < ${#wallpapers[@]} )) || exit 1 +awww img "${wallpapers[selected]}" --transition-type fade --transition-bezier .23,1,.32,1 \ + --transition-duration 0.8 --transition-fps 144 +notify-send 'Wallpaper Changed' "${names[selected]}" -i "${wallpapers[selected]}" -t 3000 diff --git a/.config/rofi/scripts/wifi.sh b/.config/rofi/scripts/wifi.sh index eaa484c..54e02a9 100755 --- a/.config/rofi/scripts/wifi.sh +++ b/.config/rofi/scripts/wifi.sh @@ -1,11 +1,21 @@ -#!/bin/bash -notify-send "WiFi" "Scanning networks..." -WIFI_LIST=$(nmcli -f SSID,SECURITY,SIGNAL device wifi list | tail -n +2) -CHOSEN=$(echo "$WIFI_LIST" | rofi -dmenu -i -theme ~/.config/rofi/themes/current.rasi -p "WiFi Networks" | awk '{print $1}') - -if [ -n "$CHOSEN" ]; then - PASSWORD=$(rofi -dmenu -theme ~/.config/rofi/themes/current.rasi -password -p "Password for $CHOSEN") - nmcli device wifi connect "$CHOSEN" password "$PASSWORD" && \ - notify-send "WiFi" "Connected to $CHOSEN" || \ - notify-send "WiFi" "Failed to connect" -fi +#!/usr/bin/env bash +# Select an intact SSID and delegate credentials to NetworkManager in Kitty. +set -euo pipefail +trap 'notify-send "Wi-Fi" "Could not open the network connection prompt." >&2 || true' ERR +networks=$(nmcli --escape no --get-values SSID device wifi list) +declare -A seen=() +ssids=() +# shortcut: SSIDs containing newlines are not representable in this menu; use nmcli directly for those. +while IFS= read -r ssid; do + [[ -n $ssid ]] || continue + if [[ ! ${seen["$ssid"]+present} ]]; then ssids+=("$ssid"); seen["$ssid"]=1; fi +done <<< "$networks" +(( ${#ssids[@]} )) || { notify-send 'Wi-Fi' 'No named networks found.'; exit 0; } +status=0 +selected=$(printf '%s\n' "${ssids[@]}" | rofi -dmenu -i -no-custom -format i \ + -theme "$HOME/.config/rofi/themes/current.rasi" -p 'Wi-Fi Networks') || status=$? +[[ $status != 1 ]] || exit 0 +[[ $status == 0 ]] || exit "$status" +[[ $selected =~ ^(0|[1-9][0-9]*)$ ]] && (( selected < ${#ssids[@]} )) || exit 1 +# No password is collected or placed in command arguments by this script. +kitty --hold -e nmcli --ask device wifi connect "${ssids[selected]}" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0a6e15c..05d6e14 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,4 +11,4 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y shellcheck jq - run: bash tests/run.sh - name: ShellCheck changed shell scripts - run: shellcheck .config/hypr/scripts/wallpaper.sh tests/*.sh + run: shellcheck .config/hypr/scripts/wallpaper.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh tests/*.sh diff --git a/README.md b/README.md index 1d036e4..5d2e11f 100644 --- a/README.md +++ b/README.md @@ -188,3 +188,10 @@ mock commands and temporary homes; they do not alter the running desktop. The workspace wallpaper listener has one owner per Hyprland session. `SUPER+A` runs `wallpaper.sh --once` to refresh without creating another listener. Workspaces 6–10 retain the last wallpaper. Missing images and daemon failures go to stderr. + +Rofi Wi-Fi selection opens Kitty with NetworkManager's `nmcli --ask` prompt; +saved credentials and authentication remain NetworkManager's responsibility. +Clipboard history supports Enter to copy, Delete to delete the selected entry, +and Ctrl+Delete to clear history after confirmation. Escape leaves the clipboard +and history alone. Wallpaper selection displays relative paths and handles +punctuation and duplicate filenames without regex matching. diff --git a/tests/rofi.sh b/tests/rofi.sh new file mode 100644 index 0000000..508b537 --- /dev/null +++ b/tests/rofi.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Exercise picker selection, cancellation, and destructive clipboard actions. +set -euo pipefail +repo=$(cd "$(dirname "$0")/.." && pwd) +task_tmp=$(mktemp -d) +trap 'rm -rf "$task_tmp"' EXIT +export HOME="$task_tmp/home" TEST_DIR="$task_tmp" +mkdir -p "$HOME/Pictures/Wallpapers/a" "$HOME/Pictures/Wallpapers/b" "$task_tmp/bin" +cat > "$task_tmp/bin/rofi" <<'MOCK' +#!/bin/bash +cat > "$TEST_DIR/menu" +count=0 +[[ ! -f $TEST_DIR/count ]] || read -r count < "$TEST_DIR/count" +printf '%s\n' "$((count+1))" > "$TEST_DIR/count" +case ${TEST_ACTION:-copy}:$count in + cancel:*|delete:1|wipe:2|reject:2) exit 1 ;; + delete:0) printf '0\n'; exit 10 ;; + wipe:0|reject:0) printf '0\n'; exit 11 ;; + wipe:1) printf '1\n' ;; + reject:1) printf '0\n' ;; + *) printf '%s\n' "${TEST_INDEX:-0}" ;; +esac +MOCK +cat > "$task_tmp/bin/cliphist" <<'MOCK' +#!/bin/bash +case $1 in + list) printf '1\tfirst\n2\tsecond\n' ;; + decode) cat > "$TEST_DIR/entry"; [[ ${TEST_DECODE_FAIL:-0} == 0 ]] || exit 1; printf '\211PNG\000\377' ;; + delete) cat > "$TEST_DIR/deleted" ;; + wipe) touch "$TEST_DIR/wiped" ;; +esac +MOCK +cat > "$task_tmp/bin/wl-copy" <<'MOCK' +#!/bin/bash +cat > "$TEST_DIR/copied" +MOCK +cat > "$task_tmp/bin/nmcli" <<'MOCK' +#!/bin/bash +printf 'My Home WiFi\nGuest: Network\\name\nMy Home WiFi\n' +MOCK +cat > "$task_tmp/bin/kitty" <<'MOCK' +#!/bin/bash +printf '%s\n' "$@" > "$TEST_DIR/kitty" +MOCK +cat > "$task_tmp/bin/awww" <<'MOCK' +#!/bin/bash +printf '%s\n' "$2" > "$TEST_DIR/wallpaper" +MOCK +printf '#!/bin/bash\nexit 0\n' > "$task_tmp/bin/notify-send" +chmod +x "$task_tmp/bin/"* +export PATH="$task_tmp/bin:$PATH" +clipboard="$repo/.config/rofi/scripts/clipboard_history.sh" +TEST_ACTION=cancel bash "$clipboard" +[[ ! -f $TEST_DIR/copied ]] +rm "$TEST_DIR/count" +bash "$clipboard" +printf '\211PNG\000\377' > "$task_tmp/expected" +cmp "$task_tmp/expected" "$TEST_DIR/copied" +rm "$TEST_DIR/count" "$TEST_DIR/copied" +TEST_ACTION=delete bash "$clipboard" +[[ $(cat "$TEST_DIR/deleted") == $'1\tfirst' && ! -f $TEST_DIR/copied ]] +rm "$TEST_DIR/count" +TEST_ACTION=reject bash "$clipboard" +[[ ! -f $TEST_DIR/wiped ]] +rm "$TEST_DIR/count" +TEST_ACTION=wipe bash "$clipboard" +[[ -f $TEST_DIR/wiped ]] +rm "$TEST_DIR/count" +if TEST_DECODE_FAIL=1 bash "$clipboard" >/dev/null 2>&1; then exit 1; fi +[[ ! -f $TEST_DIR/copied ]] +rm "$TEST_DIR/count" +TEST_INDEX=1 bash "$repo/.config/rofi/scripts/wifi.sh" +[[ $(tail -n 1 "$TEST_DIR/kitty") == 'Guest: Network\name' ]] +[[ $(wc -l < "$TEST_DIR/menu") == 2 ]] +rm "$TEST_DIR/count" "$TEST_DIR/kitty" +TEST_ACTION=cancel bash "$repo/.config/rofi/scripts/wifi.sh" +[[ ! -f $TEST_DIR/kitty ]] +touch "$HOME/Pictures/Wallpapers/a/wallpaper[1].png" "$HOME/Pictures/Wallpapers/b/wallpaper[1].png" +rm "$TEST_DIR/count" +TEST_INDEX=1 bash "$repo/.config/rofi/scripts/wallpaper.sh" +[[ $(cat "$TEST_DIR/wallpaper") == "$HOME/Pictures/Wallpapers/b/wallpaper[1].png" ]] +printf 'Rofi regression checks passed\n' From e09766d77d17596f140b8e7d3bc4f8a428771817 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:15:01 -0400 Subject: [PATCH 3/9] fix: make workstation tuning conservative and verifiable Limit automatic tuning to backed-up file-watch configuration. Preserve higher limits, verify effective values, and restore on failure. Document optional system changes and rollback separately. Co-authored-by: Codex --- .github/workflows/checks.yml | 2 +- README.md | 18 ++---- docs/system-tuning.md | 67 +++++++++++++++++++ scripts/apply-system-optimizations.sh | 64 ++---------------- scripts/inotify.py | 93 +++++++++++++++++++++++++++ tests/run.sh | 2 + tests/tuning.py | 68 ++++++++++++++++++++ 7 files changed, 243 insertions(+), 71 deletions(-) create mode 100644 docs/system-tuning.md create mode 100644 scripts/inotify.py create mode 100644 tests/tuning.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 05d6e14..4e1eb2e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,4 +11,4 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y shellcheck jq - run: bash tests/run.sh - name: ShellCheck changed shell scripts - run: shellcheck .config/hypr/scripts/wallpaper.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh tests/*.sh + run: shellcheck .config/hypr/scripts/wallpaper.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh scripts/apply-system-optimizations.sh tests/*.sh diff --git a/README.md b/README.md index 5d2e11f..7bdc4cd 100644 --- a/README.md +++ b/README.md @@ -164,22 +164,18 @@ Existing conflicting configuration files or directories will be safely backed up ## System optimizations -An automated tuning script is included in `scripts/apply-system-optimizations.sh`. It configures: - -- DNF parallel downloads (capped at 2), fastest mirror selection, and default yes prompts. -- Tuned power profile set to `desktop` for responsive CPU governor scaling. -- Disables `NetworkManager-wait-online.service` to trim cold boot time. -- Inotify user watch limit raised to 524,288 for large development projects. -- Hardware video decode via RPM Fusion `mesa-va-drivers-freeworld` on AMD Radeon GPUs. -- Disables idle background daemons (`ModemManager`, `thermald`, `rsyslog`, `abrtd`, and `lvm2-monitor`). -- Installs modern developer CLI tools (`fzf`, `zoxide`, `eza`, `git-delta`, `duf`). - -To apply these tweaks: +The default script changes only the developer file-watch limit: ```bash sudo ./scripts/apply-system-optimizations.sh ``` +It preserves unrelated sysctl settings and higher watch limits, backs up changed +files, verifies the kernel value, and restores the previous setting if application +fails. It does not install packages, alter power profiles, or disable services. +See [optional system steps](docs/system-tuning.md) for individually verified +changes and rollback. Previously applied system settings are left in place. + ## Verification Run `bash tests/run.sh` for shell syntax and isolated regression checks. Tests use diff --git a/docs/system-tuning.md b/docs/system-tuning.md new file mode 100644 index 0000000..c012738 --- /dev/null +++ b/docs/system-tuning.md @@ -0,0 +1,67 @@ +# Optional workstation changes + +## TL;DR + +The default script only raises the developer file-watch limit. Apply the steps +below separately after recording current settings. These commands are for the +Fedora workstation; they are not automatic optimizations or measured wins. + +## DNF preferences + +Inspect `/etc/dnf/dnf.conf` and back it up with `sudo cp -a --backup=numbered +/etc/dnf/dnf.conf /etc/dnf/dnf.conf.before-dotfiles` before editing. Keep +`max_parallel_downloads=2` as the existing preference until download measurements +justify another value. `fastestmirror=True` selects by connection latency, not +throughput. `defaultyes=True` changes the default confirmation choice, not speed. +For Fedora defaults remove those overrides only after reviewing the file. Verify +with `dnf --dump-main-config` (check the installed DNF version's help first). +Restore the saved file to roll back. + +Reference: [DNF5 configuration](https://dnf5.readthedocs.io/en/latest/dnf5.conf.5.html). + +## Power profile + +Run `tuned-adm active` and `tuned-adm verify` in the actual host session. Record the +active profile before testing another. Keep the current profile by default. +If testing desktop scaling, first confirm `desktop` appears in `tuned-adm list`, +then run `sudo tuned-adm profile desktop`; verify and compare responsiveness and +idle power. Roll back with `sudo tuned-adm profile PREVIOUS_PROFILE`. A file in +`/etc/tuned` is not proof of live activation; do not disable other power managers +without understanding the host's integration. + +## Services and boot + +Measure `systemd-analyze critical-chain` and `systemd-analyze blame` first. Inspect +`systemctl list-dependencies --reverse NetworkManager-wait-online.service` and +whether network-dependent mounts/services need it. If demonstrated unnecessary, +record its enabled state, then use `sudo systemctl disable +NetworkManager-wait-online.service`; compare subsequent boots. Restore its +previous enabled state to roll back. Never blindly disable ModemManager, thermald, +rsyslog, abrtd, or lvm2-monitor; identify actual use and dependencies first. +Previously disabled services require individual review, not blanket re-enabling. + +## AMD hardware video decoding + +Inspect `rpm -q mesa-va-drivers mesa-va-drivers-freeworld` and `vainfo`; confirm +which codecs and driver the running application uses. A package's presence alone +does not establish active browser decoding. If needed, follow the current +[RPM Fusion codec instructions](https://rpmfusion.org/Howto/Multimedia) for the +installed Fedora release. Review a proposed DNF swap interactively; do not add +repositories or import keys silently. Record the transaction ID and package +versions. Review `sudo dnf history undo TRANSACTION_ID` to roll back; availability +of old packages can limit rollback. Do not claim codec acceleration until verified +in the application. + +## Developer tools + +Review and run `sudo dnf install fzf zoxide eza git-delta duf` separately. Verify +with `command -v` for each executable (`delta` is the git-delta executable). +Record the DNF transaction for rollback; avoid removing pre-existing tools. + +## File-watch rollback + +On success, the watch-limit helper prints any backup path. Restore that exact +file and apply just its watch-limit value with `sudo sysctl -w +fs.inotify.max_user_watches=PREVIOUS_VALUE`. If no file previously existed, remove +only the newly created workstation file and restore the previously recorded +kernel value. Do not apply every sysctl file as a shortcut to rollback. diff --git a/scripts/apply-system-optimizations.sh b/scripts/apply-system-optimizations.sh index 85ef969..0d1e955 100755 --- a/scripts/apply-system-optimizations.sh +++ b/scripts/apply-system-optimizations.sh @@ -1,63 +1,9 @@ #!/usr/bin/env bash +# Apply only the backed-up, verified developer file-watch setting. set -euo pipefail - -if [ "$EUID" -ne 0 ]; then - echo "[-] Please run this script with sudo: sudo ./scripts/apply-system-optimizations.sh" +if (( EUID != 0 )); then + echo 'Run: sudo ./scripts/apply-system-optimizations.sh' >&2 exit 1 fi - -echo "[+] 1. Configuring DNF for fast mirrors and parallel downloads (max 2)..." -if ! grep -q "max_parallel_downloads" /etc/dnf/dnf.conf 2>/dev/null; then - cat << 'EOF' >> /etc/dnf/dnf.conf -max_parallel_downloads=2 -fastestmirror=True -defaultyes=True -EOF - echo " Done: Added max_parallel_downloads=2, fastestmirror=True, defaultyes=True." -else - sed -i 's/^max_parallel_downloads=.*/max_parallel_downloads=2/' /etc/dnf/dnf.conf - echo " Done: Updated max_parallel_downloads to 2." -fi - -echo "[+] 2. Setting tuned power profile to 'desktop' (optimized for Ryzen desktop workloads)..." -if command -v tuned-adm >/dev/null 2>&1; then - tuned-adm profile desktop - echo " Done: Active tuned profile set to desktop." -fi - -echo "[+] 3. Shaving ~5.6s off boot time (disabling NetworkManager-wait-online.service)..." -systemctl disable NetworkManager-wait-online.service 2>/dev/null || true -echo " Done: NetworkManager-wait-online disabled." - -echo "[+] 4. Raising inotify file watch limits for dev projects..." -cat << 'EOF' > /etc/sysctl.d/99-workstation.conf -# Raise file watch limits for IDEs, Node, Rust, and Python dev -fs.inotify.max_user_watches=524288 -EOF -sysctl -p /etc/sysctl.d/99-workstation.conf -echo " Done: inotify watches set to 524288." - -echo "[+] 5. Setting up RPM Fusion Free & hardware video acceleration (VA-API freeworld)..." -if ! dnf repolist | grep -q "rpmfusion-free"; then - echo " Enabling RPM Fusion Free repository..." - dnf install -y "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" || true -fi - -if dnf repolist | grep -q "rpmfusion-free"; then - echo " Swapping mesa-va-drivers for freeworld hardware acceleration..." - dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld || true - echo " Done: Hardware video acceleration installed." -fi - -echo "[+] 6. Disabling unnecessary background daemons (ModemManager, thermald, rsyslog, abrtd, lvm2-monitor)..." -systemctl disable --now thermald ModemManager rsyslog abrtd lvm2-monitor 2>/dev/null || true -echo " Done: Unnecessary background daemons disabled." - -echo "[+] 7. Installing modern developer CLI tools (fzf, zoxide, eza, git-delta, duf)..." -if dnf install -y fzf zoxide eza git-delta duf; then - echo " Done: Modern developer CLI tools installed." -else - echo " [!] Warning: Failed to install developer CLI tools. Install manually via 'sudo dnf install fzf zoxide eza git-delta duf'." -fi - -echo "[✓] All system-level performance optimizations applied successfully!" +python3 "$(dirname "${BASH_SOURCE[0]}")/inotify.py" +echo 'File-watch setting verified. Other system settings were not changed.' diff --git a/scripts/inotify.py b/scripts/inotify.py new file mode 100644 index 0000000..f9ef17d --- /dev/null +++ b/scripts/inotify.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Raise the workstation watch limit with preserved settings and rollback on failure.""" +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile + +KEY = 'fs.inotify.max_user_watches' +MINIMUM = 524288 + + +def apply(config: Path) -> int: + """Preserve higher limits, update only KEY, and verify the running kernel. + + Args: + config: Sysctl file to update; production uses the fixed workstation path. + Returns: + The verified effective watch limit. + Raises: + ValueError: Existing configuration is malformed. + OSError: Configuration cannot be safely written. + subprocess.CalledProcessError: A sysctl command fails. + RuntimeError: The kernel does not report the requested value. + """ + if config.is_symlink(): + raise ValueError('Refusing to replace a symlinked sysctl file') + current = int(subprocess.check_output(['sysctl', '-n', KEY], text=True).strip()) + original = config.read_text() if config.exists() else '' + lines = original.splitlines(keepends=True) + pattern = re.compile(r'^\s*-?fs\.inotify\.max_user_watches\s*(?:=\s*|\s+)([^#;]+)') + values = [int(match.group(1).strip()) for line in lines if (match := pattern.match(line))] + desired = max(MINIMUM, current, *values) + # Keep exact formatting when an existing single setting already suffices. + if values == [desired]: + updated = original + else: + updated = ''.join(line for line in lines if not pattern.match(line)) + if updated and not updated.endswith('\n'): + updated += '\n' + updated += f'{KEY}={desired}\n' + backup = None + changed = updated != original + if changed: + config.parent.mkdir(parents=True, exist_ok=True) + metadata = config.stat() if config.exists() else None + if metadata: + fd, name = tempfile.mkstemp(prefix=config.name + '.backup-', dir=config.parent) + os.close(fd) + backup = Path(name) + shutil.copy2(config, backup) + fd, name = tempfile.mkstemp(prefix='.' + config.name, dir=config.parent) + temporary = Path(name) + try: + with os.fdopen(fd, 'w') as output: + os.fchmod(output.fileno(), stat.S_IMODE(metadata.st_mode) if metadata else 0o644) + if metadata: + os.fchown(output.fileno(), metadata.st_uid, metadata.st_gid) + output.write(updated) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, config) + finally: + temporary.unlink(missing_ok=True) + if backup: + print(f'Previous configuration saved to {backup}') + try: + if current != desired: + subprocess.run(['sysctl', '-w', f'{KEY}={desired}'], check=True) + effective = int(subprocess.check_output(['sysctl', '-n', KEY], text=True).strip()) + if effective != desired: + raise RuntimeError(f'Expected {desired} watches, kernel reports {effective}') + except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError): + if changed: + if backup: + os.replace(backup, config) + else: + config.unlink(missing_ok=True) + try: + subprocess.run(['sysctl', '-w', f'{KEY}={current}'], check=True) + except (OSError, subprocess.CalledProcessError) as error: + print(f'Could not restore the previous kernel value: {error}', file=sys.stderr) + raise + return effective + + +if __name__ == '__main__': + if os.geteuid() != 0: + raise SystemExit('Run through sudo ./scripts/apply-system-optimizations.sh') + print(f'Verified watch limit: {apply(Path("/etc/sysctl.d/99-workstation.conf"))}') diff --git a/tests/run.sh b/tests/run.sh index ac0b03d..db5d4da 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -4,3 +4,5 @@ set -euo pipefail cd "$(dirname "$0")/.." while IFS= read -r -d '' script; do bash -n "$script"; done < <(find scripts .config tests -type f -name '*.sh' -print0) for test in tests/*.sh; do [[ $test == tests/run.sh ]] || bash "$test"; done + +for test in tests/*.py; do [[ ! -f $test ]] || python3 -B "$test"; done diff --git a/tests/tuning.py b/tests/tuning.py new file mode 100644 index 0000000..5e39414 --- /dev/null +++ b/tests/tuning.py @@ -0,0 +1,68 @@ +"""Check sysctl preservation, verification, and rollback using disposable files.""" +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('inotify', Path(__file__).resolve().parents[1] / 'scripts/inotify.py') +inotify = importlib.util.module_from_spec(spec) +spec.loader.exec_module(inotify) + +class TuningTests(unittest.TestCase): + def test_preserves_higher_limits_and_unrelated_settings(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + original = '# local comment\nvm.swappiness=80\nfs.inotify.max_user_watches=1048576\n' + config.write_text(original) + with patch.object(inotify.subprocess, 'check_output', side_effect=['524288', '1048576']), patch.object(inotify.subprocess, 'run') as run: + self.assertEqual(inotify.apply(config), 1048576) + self.assertEqual(config.read_text(), original) + run.assert_called_once_with(['sysctl', '-w', 'fs.inotify.max_user_watches=1048576'], check=True) + self.assertEqual(list(Path(directory).glob('*.backup-*')), []) + + def test_updates_once_and_backs_up(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + original = '# custom\nvm.swappiness=80\nfs.inotify.max_user_watches = 8192\n' + config.write_text(original) + with patch.object(inotify.subprocess, 'check_output', return_value='524288'), patch.object(inotify.subprocess, 'run'): + inotify.apply(config) + first = config.read_text() + inotify.apply(config) + self.assertIn('vm.swappiness=80', first) + backups = list(Path(directory).glob('*.backup-*')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), original) + + def test_failed_apply_restores_file(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + original = 'vm.swappiness=80\n' + config.write_text(original) + failure = subprocess.CalledProcessError(1, 'sysctl') + with patch.object(inotify.subprocess, 'check_output', return_value='8192'), patch.object(inotify.subprocess, 'run', side_effect=[failure, None]): + with self.assertRaises(subprocess.CalledProcessError): + inotify.apply(config) + self.assertEqual(config.read_text(), original) + + def test_failed_verification_removes_new_file(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + with patch.object(inotify.subprocess, 'check_output', side_effect=['8192', '8192']), patch.object(inotify.subprocess, 'run'): + with self.assertRaises(RuntimeError): + inotify.apply(config) + self.assertFalse(config.exists()) + + def test_malformed_existing_value_is_not_overwritten(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + config.write_text('fs.inotify.max_user_watches=unexpected\n') + with patch.object(inotify.subprocess, 'check_output', return_value='8192'), patch.object(inotify.subprocess, 'run') as run: + with self.assertRaises(ValueError): + inotify.apply(config) + run.assert_not_called() + +if __name__ == '__main__': + unittest.main() From 9197750b5a3abcd80252e289872330ba3af5145f Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:15:21 -0400 Subject: [PATCH 4/9] feat: track portable shell and git customizations Install guarded developer fragments and migrate only recognized local settings. Preserve identity and local Git overrides; back up file and symlink conflicts with unique names. Co-authored-by: Codex --- .github/workflows/checks.yml | 2 +- README.md | 15 +++++ git/developer.gitconfig | 11 ++++ scripts/install-symlinks.sh | 46 ++++++------- scripts/migrate-shell.py | 121 +++++++++++++++++++++++++++++++++++ shell/dotfiles.sh | 15 +++++ tests/install.sh | 37 +++++++++++ tests/migration.py | 40 ++++++++++++ tests/run.sh | 2 +- 9 files changed, 265 insertions(+), 24 deletions(-) create mode 100644 git/developer.gitconfig create mode 100644 scripts/migrate-shell.py create mode 100644 shell/dotfiles.sh create mode 100644 tests/install.sh create mode 100644 tests/migration.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4e1eb2e..8b043ab 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,4 +11,4 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y shellcheck jq - run: bash tests/run.sh - name: ShellCheck changed shell scripts - run: shellcheck .config/hypr/scripts/wallpaper.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh scripts/apply-system-optimizations.sh tests/*.sh + run: shellcheck .config/hypr/scripts/wallpaper.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh scripts/*.sh shell/*.sh tests/*.sh diff --git a/README.md b/README.md index 7bdc4cd..afc1a8d 100644 --- a/README.md +++ b/README.md @@ -191,3 +191,18 @@ Clipboard history supports Enter to copy, Delete to delete the selected entry, and Ctrl+Delete to clear history after confirmation. Escape leaves the clipboard and history alone. Wallpaper selection displays relative paths and handles punctuation and duplicate filenames without regex matching. + +### Portable Bash and Git settings + +The installer links `shell/dotfiles.sh` into `~/.bashrc.d/` and installs a Git +include for `git/developer.gitconfig`. Bash tooling is interactive-only and guarded +when optional programs are missing. Fastfetch remains enabled at shell startup. +Install the optional CLI tools from [system steps](docs/system-tuning.md) first if +needed; Git Delta requires the `delta` executable. + +Only the exact recognized legacy Bash block and matching Git presentation values +are migrated. Locally edited shell blocks are preserved and may require manual +deduplication. Git identity, credentials, signing, and differing local overrides +stay local and take precedence. Symlinked `.bashrc` or `.gitconfig` require manual +integration. Backups use unique `.backup-*` names; link conflicts are stored under +`BACKUP/original`. Restore backups to undo installation, then open a new shell. diff --git a/git/developer.gitconfig b/git/developer.gitconfig new file mode 100644 index 0000000..529e6d8 --- /dev/null +++ b/git/developer.gitconfig @@ -0,0 +1,11 @@ +# Portable diff presentation; identity, credentials, and signing remain local. +[core] + pager = delta +[interactive] + diffFilter = delta --color-only +[delta] + navigate = true + light = false + side-by-side = false + line-numbers = true + syntax-theme = "Catppuccin Mocha" diff --git a/scripts/install-symlinks.sh b/scripts/install-symlinks.sh index 96adf6d..a7190fe 100755 --- a/scripts/install-symlinks.sh +++ b/scripts/install-symlinks.sh @@ -1,30 +1,32 @@ #!/usr/bin/env bash +# Link tracked configs and portable fragments, preserving local settings in backups. set -euo pipefail - DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CONFIG_SOURCE="$DOTFILES_DIR/.config" -TARGET_DIR="$HOME/.config" - -echo "[+] Installing dotfiles symlinks into $TARGET_DIR..." -mkdir -p "$TARGET_DIR" - -for item in "$CONFIG_SOURCE"/*; do - base="$(basename "$item")" - target="$TARGET_DIR/$base" +command -v python3 >/dev/null +command -v git >/dev/null - if [ -L "$target" ] && [ "$(readlink -f "$target")" = "$item" ]; then - echo " [=] $base is already linked." - continue +# Preserve conflicts, including dangling links, before installing one source. +link_item() { + local source=$1 target=$2 backup + mkdir -p "$(dirname "$target")" + if [[ -L $target && $(readlink -f "$target") == "$source" ]]; then + echo "Already linked: $target" + return fi - - if [ -e "$target" ]; then - backup="${target}.backup-$(date +%s)" - echo " [!] Backing up existing $base to $backup" - mv "$target" "$backup" + if [[ -e $target || -L $target ]]; then + backup=$(mktemp -d "${target}.backup-XXXXXX") + mv -- "$target" "$backup/original" + echo "Backup: $backup/original" fi + ln -s -- "$source" "$target" + echo "Linked: $target" +} - ln -s "$item" "$target" - echo " [+] Linked $base -> $item" +# Migrate before linking so unsupported shell ownership fails without changing configs. +python3 "$DOTFILES_DIR/scripts/migrate-shell.py" +for item in "$DOTFILES_DIR/.config/"*; do + link_item "$item" "$HOME/.config/$(basename "$item")" done - -echo "[✓] All dotfiles successfully linked!" +link_item "$DOTFILES_DIR/shell/dotfiles.sh" "$HOME/.bashrc.d/dotfiles.sh" +link_item "$DOTFILES_DIR/git/developer.gitconfig" "$HOME/.config/git/developer.gitconfig" +echo 'Configuration installed. Open a new interactive shell to use the fragment.' diff --git a/scripts/migrate-shell.py b/scripts/migrate-shell.py new file mode 100644 index 0000000..f140435 --- /dev/null +++ b/scripts/migrate-shell.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Migrate only the recognized inline Bash block, preserving other local content.""" +from pathlib import Path +import shutil +import subprocess +import os +import tempfile + +LEGACY = '''alias syu="sudo dnf upgrade" + +# Modern developer aliases +if command -v eza >/dev/null 2>&1; then + alias ls="eza --icons --group-directories-first" + alias ll="eza -la --icons --group-directories-first" + alias tree="eza --tree --icons" +fi + +if command -v bat >/dev/null 2>&1; then + alias cat="bat --paging=never" +fi + +if command -v duf >/dev/null 2>&1; then + alias df="duf" +fi + +# Zoxide smart jumping (replaces cd) +command -v zoxide >/dev/null 2>&1 && eval "$(zoxide init bash)" + +# FZF keybindings & fuzzy completion +command -v fzf >/dev/null 2>&1 && eval "$(fzf --bash)" + +eval "$(starship init bash)" +fastfetch +''' +DIRECTORY_LOADER = '''if [ -d ~/.bashrc.d ]; then + for rc in ~/.bashrc.d/*; do + if [ -f "$rc" ]; then + . "$rc" + fi + done +fi''' +HOOK = '''# Dotfiles portable developer configuration +if [ -r "$HOME/.bashrc.d/dotfiles.sh" ]; then + . "$HOME/.bashrc.d/dotfiles.sh" +fi +''' + + +def migrate(path: Path) -> None: + """Back up and migrate an exact known block; never remove edited local code. + + Args: + path: Local Bash startup file. + Raises: + ValueError: The startup file is a symlink requiring manual integration. + OSError: Backup or update fails. + """ + if path.is_symlink(): + raise ValueError('Symlinked .bashrc: integrate shell/dotfiles.sh manually') + original = path.read_text() if path.exists() else '' + updated = original.replace(LEGACY, '') + if DIRECTORY_LOADER not in updated and HOOK not in updated: + updated = updated.rstrip('\n') + '\n\n' + HOOK + if updated == original: + return + if path.exists(): + with tempfile.NamedTemporaryFile(prefix=path.name + '.backup-', dir=path.parent, delete=False) as backup: + shutil.copy2(path, backup.name) + print(f'Bash backup: {backup.name}') + path.write_text(updated) + + +def migrate_git(path: Path, include: Path) -> None: + """Prepend a portable include and remove only matching legacy Git values. + + Local identity and differing overrides stay after the include and take priority. + Raises ValueError for symlinked files and OSError or CalledProcessError on failure. + """ + if path.is_symlink(): + raise ValueError('Symlinked .gitconfig: integrate git/developer.gitconfig manually') + original = path.read_text() if path.exists() else '' + settings = { + 'core.pager': 'delta', 'interactive.diffFilter': 'delta --color-only', + 'delta.navigate': 'true', 'delta.light': 'false', 'delta.side-by-side': 'false', + 'delta.line-numbers': 'true', 'delta.syntax-theme': 'Catppuccin Mocha', + } + with tempfile.TemporaryDirectory(dir=path.parent) as directory: + draft = Path(directory) / 'gitconfig' + draft.write_text(original) + for key, value in settings.items(): + result = subprocess.run(['git', 'config', '--file', str(draft), '--fixed-value', + '--unset-all', key, value], capture_output=True, text=True) + if result.returncode not in (0, 5): + raise RuntimeError(f'Cannot migrate Git setting {key}: {result.stderr}') + # Let Git quote the path instead of implementing its configuration grammar. + prefix = Path(directory) / 'include' + subprocess.run(['git', 'config', '--file', str(prefix), '--add', 'include.path', str(include)], check=True) + existing = subprocess.run(['git', 'config', '--file', str(draft), '--get-all', 'include.path'], + capture_output=True, text=True) + if existing.returncode not in (0, 1): + raise RuntimeError('Cannot read Git include settings') + updated = draft.read_text() + if str(include) not in existing.stdout.splitlines(): + updated = prefix.read_text() + updated + if updated == original: + return + if path.exists(): + with tempfile.NamedTemporaryFile(prefix=path.name + '.backup-', dir=path.parent, delete=False) as backup: + shutil.copy2(path, backup.name) + print(f'Git backup: {backup.name}') + draft.chmod(path.stat().st_mode & 0o777) + else: + draft.chmod(0o600) + draft.write_text(updated) + os.replace(draft, path) + + +if __name__ == '__main__': + home = Path.home() + migrate(home / '.bashrc') + migrate_git(home / '.gitconfig', home / '.config/git/developer.gitconfig') diff --git a/shell/dotfiles.sh b/shell/dotfiles.sh new file mode 100644 index 0000000..cdaabee --- /dev/null +++ b/shell/dotfiles.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Portable interactive developer aliases, navigation, completion, and prompt. +[[ $- == *i* ]] || return 0 +alias syu='sudo dnf upgrade' +if command -v eza >/dev/null 2>&1; then + alias ls='eza --icons --group-directories-first' + alias ll='eza -la --icons --group-directories-first' + alias tree='eza --tree --icons' +fi +command -v bat >/dev/null 2>&1 && alias cat='bat --paging=never' +command -v duf >/dev/null 2>&1 && alias df=duf +if command -v zoxide >/dev/null 2>&1; then eval "$(zoxide init bash)"; fi +if command -v fzf >/dev/null 2>&1; then eval "$(fzf --bash)"; fi +if command -v starship >/dev/null 2>&1; then eval "$(starship init bash)"; fi +if command -v fastfetch >/dev/null 2>&1; then fastfetch; fi diff --git a/tests/install.sh b/tests/install.sh new file mode 100644 index 0000000..955e904 --- /dev/null +++ b/tests/install.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Verify installation and migration preserve files, links, and local Git identity. +set -euo pipefail +repo=$(cd "$(dirname "$0")/.." && pwd) +task_tmp=$(mktemp -d) +trap 'rm -rf "$task_tmp"' EXIT +export HOME="$task_tmp/home with spaces" GIT_CONFIG_GLOBAL="$task_tmp/home with spaces/.gitconfig" GIT_CONFIG_SYSTEM=/dev/null +mkdir -p "$HOME/.config" "$HOME/.bashrc.d" +ln -s "$task_tmp/missing" "$HOME/.config/kitty" +printf 'local bar\n' > "$HOME/.config/waybar" +printf 'local shell\n' > "$HOME/.bashrc.d/local.sh" +printf '[user]\n name = Local User\n' > "$HOME/.gitconfig" +cat > "$HOME/.bashrc" <<'RC' +# local setup +export CUSTOM_LOCAL=preserved +# Modern developer aliases +if command -v eza >/dev/null 2>&1; then + alias ls="eza --icons --group-directories-first" +fi +# user code that must not be swallowed by partial migration +export KEEP_ME=yes +RC +# A partial or locally edited block must be preserved rather than guessed at. +bash "$repo/scripts/install-symlinks.sh" >/dev/null +[[ -L $HOME/.config/kitty && -L $HOME/.config/waybar ]] +[[ $(git config --global user.name) == 'Local User' ]] +[[ $(git config --global --get-all include.path | wc -l) == 1 ]] +[[ $(cat "$HOME/.bashrc.d/local.sh") == 'local shell' ]] +[[ $(cat "$HOME/.bashrc") == *'export KEEP_ME=yes'* ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'kitty.backup-*' | wc -l) == 1 ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'waybar.backup-*' | wc -l) == 1 ]] +bash "$repo/scripts/install-symlinks.sh" >/dev/null +[[ $(git config --global --get-all include.path | wc -l) == 1 ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'waybar.backup-*' | wc -l) == 1 ]] +# Portable fragments must not initialize interactive tools in a script. +bash -c 'source "$HOME/.bashrc.d/dotfiles.sh"; [[ ! ${CUSTOM_LOCAL+present} ]]' +printf 'Installer regression checks passed\n' diff --git a/tests/migration.py b/tests/migration.py new file mode 100644 index 0000000..f9220c7 --- /dev/null +++ b/tests/migration.py @@ -0,0 +1,40 @@ +"""Verify exact legacy migration and precedence of local Git overrides.""" +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('migration', Path(__file__).resolve().parents[1] / 'scripts/migrate-shell.py') +migration = importlib.util.module_from_spec(spec) +spec.loader.exec_module(migration) + +class MigrationTests(unittest.TestCase): + def test_exact_bash_block_migrates_once(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / '.bashrc' + path.write_text('export LOCAL=yes\n' + migration.LEGACY) + migration.migrate(path) + migration.migrate(path) + self.assertIn('export LOCAL=yes', path.read_text()) + self.assertNotIn('starship init', path.read_text()) + self.assertEqual(path.read_text().count(migration.HOOK), 1) + self.assertEqual(len(list(Path(directory).glob('.bashrc.backup-*'))), 1) + + def test_git_local_override_wins_and_identity_survives(self): + with tempfile.TemporaryDirectory(prefix='git home ') as directory: + path = Path(directory) / '.gitconfig' + fragment = Path(directory) / 'developer.gitconfig' + fragment.write_text('[core]\n pager=delta\n[delta]\n light=false\n') + path.write_text('[user]\n name=Local User\n[core]\n pager=less\n[delta]\n light=false\n') + migration.migrate_git(path, fragment) + migration.migrate_git(path, fragment) + def value(key): + return subprocess.check_output(['git', 'config', '--file', str(path), '--includes', '--get', key], text=True).strip() + self.assertEqual(value('core.pager'), 'less') + self.assertEqual(value('user.name'), 'Local User') + self.assertEqual(value('delta.light'), 'false') + self.assertEqual(len(list(Path(directory).glob('.gitconfig.backup-*'))), 1) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/run.sh b/tests/run.sh index db5d4da..4651fb4 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -2,7 +2,7 @@ # Run syntax checks and isolated regressions without touching the live desktop. set -euo pipefail cd "$(dirname "$0")/.." -while IFS= read -r -d '' script; do bash -n "$script"; done < <(find scripts .config tests -type f -name '*.sh' -print0) +while IFS= read -r -d '' script; do bash -n "$script"; done < <(find scripts .config tests shell -type f -name '*.sh' -print0) for test in tests/*.sh; do [[ $test == tests/run.sh ]] || bash "$test"; done for test in tests/*.py; do [[ ! -f $test ]] || python3 -B "$test"; done From bcee2beea71d8a8597ae366d75867ef8a6f9ebf7 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:19:18 -0400 Subject: [PATCH 5/9] fix: reserve clipboard deletion keys for picker actions Remove Delete from Rofi text editing before assigning the clipboard action. Add a regression check for conflicting bindings. Co-authored-by: Codex --- .config/rofi/scripts/clipboard_history.sh | 1 + tests/rofi.sh | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/.config/rofi/scripts/clipboard_history.sh b/.config/rofi/scripts/clipboard_history.sh index 614007b..7364ed0 100755 --- a/.config/rofi/scripts/clipboard_history.sh +++ b/.config/rofi/scripts/clipboard_history.sh @@ -11,6 +11,7 @@ while true; do mapfile -t entries <<< "$history" status=0 selected=$(printf '%s\n' "${entries[@]}" | rofi -dmenu -i -no-custom -format i \ + -kb-remove-char-forward Control+d -kb-remove-word-forward Control+Alt+d \ -kb-custom-1 Delete -kb-custom-2 Control+Delete \ -p 'CLIPBOARD HISTORY' -mesg '[ ENTER ] COPY [ DEL ] DELETE ENTRY [ CTRL+DEL ] CLEAR ALL' \ -theme "$HOME/.config/rofi/themes/clipboard.rasi") || status=$? diff --git a/tests/rofi.sh b/tests/rofi.sh index 508b537..a3774b6 100644 --- a/tests/rofi.sh +++ b/tests/rofi.sh @@ -9,6 +9,11 @@ mkdir -p "$HOME/Pictures/Wallpapers/a" "$HOME/Pictures/Wallpapers/b" "$task_tmp/ cat > "$task_tmp/bin/rofi" <<'MOCK' #!/bin/bash cat > "$TEST_DIR/menu" +# The deletion shortcut must not collide with Rofi's default text editing key. +if [[ " $* " == *' -kb-custom-1 Delete '* && " $* " != *' -kb-remove-char-forward Control+d '* ]]; then + echo 'Delete remains assigned to two actions' >&2 + exit 2 +fi count=0 [[ ! -f $TEST_DIR/count ]] || read -r count < "$TEST_DIR/count" printf '%s\n' "$((count+1))" > "$TEST_DIR/count" From d6583d2a9d54d602ebf171dd58dd2ec737690669 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:22:18 -0400 Subject: [PATCH 6/9] fix: report wallpaper workspace query failures Explicitly reject failed compositor queries while preserving event listening. Add a failed-query regression case. Co-authored-by: Codex --- .config/hypr/scripts/wallpaper.sh | 5 ++++- tests/wallpaper.sh | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.config/hypr/scripts/wallpaper.sh b/.config/hypr/scripts/wallpaper.sh index 431fcef..2d0f426 100755 --- a/.config/hypr/scripts/wallpaper.sh +++ b/.config/hypr/scripts/wallpaper.sh @@ -14,7 +14,10 @@ done # Apply the current workspace image; unmapped workspaces retain the last image. change_wallpaper() { local workspace image - workspace=$(hyprctl activeworkspace -j | jq -er '.id') + if ! workspace=$(hyprctl activeworkspace -j | jq -er '.id'); then + echo 'Could not read active workspace.' >&2 + return 1 + fi case "$workspace" in 1) image=csm_wallpaper.jpg ;; 2) image=reze_wallpaper.png ;; diff --git a/tests/wallpaper.sh b/tests/wallpaper.sh index 8e75087..96448de 100644 --- a/tests/wallpaper.sh +++ b/tests/wallpaper.sh @@ -19,6 +19,7 @@ except PermissionError: PY cat > "$task_tmp/bin/hyprctl" <<'MOCK' #!/bin/bash +[[ ! -f $TEST_LOG.fail ]] || exit 1 printf '{"id":%s}\n' "${TEST_WORKSPACE:-1}" MOCK cat > "$task_tmp/bin/awww" <<'MOCK' @@ -29,6 +30,7 @@ MOCK cat > "$task_tmp/bin/socat" <<'MOCK' #!/bin/bash sleep 0.5 +[[ ${TEST_EVENT_FAIL:-0} != 1 ]] || touch "$TEST_LOG.fail" printf 'activewindow>>ignored\nworkspace>>1\n' MOCK chmod +x "$task_tmp/bin/"* @@ -45,6 +47,11 @@ for ((i=0; i<100; i++)); do [[ -f $TEST_LOG ]] && break; sleep 0.01; done bash "$script" wait "$listener" [[ $(wc -l < "$TEST_LOG") == 2 ]] +rm "$TEST_LOG" +TEST_EVENT_FAIL=1 bash "$script" 2> "$task_tmp/errors" +[[ $(wc -l < "$TEST_LOG") == 1 ]] +grep -q 'Could not read active workspace' "$task_tmp/errors" +rm "$TEST_LOG.fail" rm "$HOME/Pictures/Wallpapers/csm_wallpaper.jpg" if bash "$script" --once >/dev/null 2>&1; then echo 'Missing image accepted' >&2; exit 1; fi if bash "$script" --invalid >/dev/null 2>&1; then exit 1; fi From e607f7aaca65a24e0800ec0e195e922302cd8652 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:25:41 -0400 Subject: [PATCH 7/9] docs: establish verified workstation performance baselines (#13) Record isolated shell startup samples with explicit limitations. Document reproducible host checks and preserve visual settings until measured evidence justifies changes. Co-authored-by: Codex --- README.md | 15 ++++++-- docs/performance-baseline.md | 75 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 docs/performance-baseline.md diff --git a/README.md b/README.md index afc1a8d..892bd7d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Hyprland desktop setup on Fedora Linux, styled with Catppuccin Mocha. Built for │ │ ├── reload.sh # Reload compositor, bar, and notifications │ │ └── wallpaper.sh # Per-workspace wallpaper listener │ ├── kitty/ -│ │ └── kitty.conf # GPU terminal with 144Hz input tuning +│ │ └── kitty.conf # GPU terminal with configured redraw delays │ ├── rofi/ │ │ ├── config.rasi # Application launcher │ │ ├── scripts/ @@ -109,11 +109,11 @@ https://github.com/user-attachments/assets/e41b7de6-2717-4371-a81f-c4a124757f12 |---|---| | [Hyprland](https://github.com/hyprwm/Hyprland) | Wayland tiling compositor | | [Waybar](https://github.com/Alexays/Waybar) | Floating status bar with workspace indicators | -| [awww](https://codeberg.org/LGFae/awww) | Wallpaper daemon running 144 FPS bezier transitions | +| [awww](https://codeberg.org/LGFae/awww) | Wallpaper daemon with transitions configured at 144 FPS | | [SwayNC](https://github.com/ErikReider/SwayNotificationCenter) | Notification center and quick settings | | [hyprlock](https://github.com/hyprwm/hyprlock) | Screen locker with screenshot blur | | [hypridle](https://github.com/hyprwm/hypridle) | Idle listener and sleep controller | -| [Kitty](https://github.com/kovidgoyal/kitty) | Terminal emulator running 6ms refresh and 1ms input latency | +| [Kitty](https://github.com/kovidgoyal/kitty) | Terminal emulator with 6ms repaint and 1ms input delay settings | | [Rofi](https://github.com/davatorium/rofi) | App launcher, power menu, and picker scripts | | [Fastfetch](https://github.com/fastfetch-cli/fastfetch) | System information summary | @@ -160,7 +160,7 @@ To link all configuration directories (`hypr`, `waybar`, `kitty`, `rofi`, `swayn ./scripts/install-symlinks.sh ``` -Existing conflicting configuration files or directories will be safely backed up automatically with a timestamp before creating the symlinks. +Existing conflicting files, directories, and symlinks are preserved in unique backup directories before replacement. ## System optimizations @@ -206,3 +206,10 @@ deduplication. Git identity, credentials, signing, and differing local overrides stay local and take precedence. Symlinked `.bashrc` or `.gitconfig` require manual integration. Backups use unique `.backup-*` names; link conflicts are stored under `BACKUP/original`. Restore backups to undo installation, then open a new shell. + + +### Performance evidence + +See [the performance baseline](docs/performance-baseline.md) for recorded shell +startup samples, their sandbox limitations, and the remaining host checks. Current +visual and redraw settings are preferences; they do not establish measured latency. diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..1e252a4 --- /dev/null +++ b/docs/performance-baseline.md @@ -0,0 +1,75 @@ +# Workstation performance baseline + +## TL;DR + +Preserve the current visuals and target balanced responsiveness. Configuration is +not a benchmark. The measurements below were captured in a restricted agent +sandbox, not a complete Hyprland session; no desktop performance gain is claimed. + +## Recorded evidence + +| Check | Observation | Limit | +|---|---|---| +| OS | Fedora 43 KDE edition | Read from host os-release | +| Memory/storage | About 30 GiB RAM, 8 GiB zram, Btrfs on NVMe | Snapshot, not a pressure test | +| Shell with current startup | 20 samples; median 39.32 ms; range 38.53–41.25 ms | Non-login interactive Bash, no PTY, output discarded | +| Shell without Fastfetch | 20 samples; median 33.95 ms; range 32.84–35.54 ms | Temporary rc file; original startup untouched | +| Fastfetch difference | 5.37 ms between medians | Sandbox observation only; retain current behavior | +| Codec package | mesa-va-drivers-freeworld installed; mesa-va-drivers absent | Package inventory does not prove application decoding | +| Power configuration | `/etc/tuned/active_profile`: balanced; EPP: balance_performance | Live TuneD activation could not be queried | +| Monitor/compositor | Unverified | Hyprland socket access denied | +| Boot/failed services | Unverified | System and user bus access denied | +| Desktop idle CPU/power | Unverified | Process visibility excludes the live desktop | + +## Repeat shell startup measurement + +Run from your normal host terminal. This measures startup without terminal +rendering, so also compare perceived opening time in Kitty. The temporary variant +intercepts the direct Fastfetch startup invocation; it never edits dotfiles. + +```python +import pathlib, statistics, subprocess, tempfile, time + +original = pathlib.Path.home().joinpath('.bashrc').read_text() +with tempfile.TemporaryDirectory() as directory: + for variant in ('current', 'without_fastfetch'): + rc = pathlib.Path(directory) / variant + # Intercept only direct fastfetch calls in this disposable child shell. + # The tracked fragment and existing rc both use direct calls. + rc.write_text(('fastfetch() { :; }\n' if variant == 'without_fastfetch' else '') + original) + samples = [] + for _ in range(20): + start = time.perf_counter() + subprocess.run(['bash', '--noprofile', '--rcfile', str(rc), '-ic', 'exit'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + samples.append((time.perf_counter() - start) * 1000) + assert len(samples) == 20 + print(variant, 'median/range ms:', statistics.median(samples), min(samples), max(samples)) +``` + +This temporary function does not intercept an explicit `command fastfetch` or +absolute executable path; inspect customized startup files before using it. +Retain Fastfetch unless repeated host measurements or your preference justify +moving it to an on-demand command. + +## Complete the host baseline + +- Capture `systemd-analyze`, `systemd-analyze critical-chain`, and failed system/user + units across three comparable cold boots. Report medians and ranges, not a single + longest service: services can start in parallel. +- Run `hyprctl monitors`, `hyprctl configerrors`, `tuned-adm active`, and + `tuned-adm verify`. Confirm actual resolution/refresh and power-profile activation. +- Count `wallpaper.sh` listener processes after login and after ten `SUPER+A` + refreshes. There should be one listener. +- With the same apps open and no builds/downloads, capture three sixty-second + idle samples using `pidstat -u -r 1 60` if installed. Record compositor, bar, + wallpaper, CPU activity, and available hardware power readings separately. +- Inspect `vainfo` and the browser's media diagnostics during the same video test. + Confirm the codec, rendering/decoding device, CPU use, and dropped frames. + +## Change policy + +Keep blur, transparency, animations, VRR, Kitty delays, and wallpaper transition +rate unchanged in this series. Test one proposed tuning change at a time with the +same workload. Adopt it only if improvement is repeatable beyond baseline variation, +and record any visual, latency, or idle-power tradeoff plus rollback. From 6657a70f4fd6630ec62d2e8626299ac8af872a6c Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:28:11 -0400 Subject: [PATCH 8/9] fix: make rofi pickers handle selections correctly (#10) * fix: make rofi pickers handle selections correctly Preserve SSIDs and paths using indexed selections. Implement clipboard delete and confirmed clear, and keep cancellation side-effect free. Co-authored-by: Codex * fix: reserve clipboard deletion keys for picker actions Remove Delete from Rofi text editing before assigning the clipboard action. Add a regression check for conflicting bindings. Co-authored-by: Codex * fix: make workstation tuning conservative and verifiable (#11) Limit automatic tuning to backed-up file-watch configuration. Preserve higher limits, verify effective values, and restore on failure. Document optional system changes and rollback separately. Co-authored-by: Codex --------- Co-authored-by: Codex --- .config/rofi/scripts/clipboard_history.sh | 48 +++++++++--- .config/rofi/scripts/wallpaper.sh | 59 +++++++------- .config/rofi/scripts/wifi.sh | 32 +++++--- .github/workflows/checks.yml | 2 +- README.md | 25 +++--- docs/system-tuning.md | 67 ++++++++++++++++ scripts/apply-system-optimizations.sh | 64 ++-------------- scripts/inotify.py | 93 +++++++++++++++++++++++ tests/rofi.sh | 87 +++++++++++++++++++++ tests/run.sh | 2 + tests/tuning.py | 68 +++++++++++++++++ 11 files changed, 426 insertions(+), 121 deletions(-) create mode 100644 docs/system-tuning.md create mode 100644 scripts/inotify.py create mode 100644 tests/rofi.sh create mode 100644 tests/tuning.py diff --git a/.config/rofi/scripts/clipboard_history.sh b/.config/rofi/scripts/clipboard_history.sh index 90f5598..7364ed0 100755 --- a/.config/rofi/scripts/clipboard_history.sh +++ b/.config/rofi/scripts/clipboard_history.sh @@ -1,13 +1,41 @@ #!/usr/bin/env bash +# Copy, delete, or confirm clearing clipboard history without changing it on cancel. set -euo pipefail +trap 'notify-send "Clipboard" "Clipboard operation failed." >&2 || true' ERR +payload=$(mktemp) +trap 'rm -f "$payload"' EXIT -# Show clipboard history with custom message using dedicated theme -cliphist list | rofi -dmenu -i \ - -p " CLIPBOARD HISTORY" \ - -mesg " [ ENTER ] PASTE [ DEL ] DELETE ENTRY [ CTRL+DEL ] CLEAR ALL" \ - -theme ~/.config/rofi/themes/clipboard.rasi | cliphist decode | wl-copy - -# Notify if copied -if [ -n "$(wl-paste 2>/dev/null || true)" ]; then - notify-send "Clipboard" "Content copied!" -t 2000 -fi +while true; do + history=$(cliphist list) + [[ -n $history ]] || { notify-send 'Clipboard' 'History is empty.'; exit 0; } + mapfile -t entries <<< "$history" + status=0 + selected=$(printf '%s\n' "${entries[@]}" | rofi -dmenu -i -no-custom -format i \ + -kb-remove-char-forward Control+d -kb-remove-word-forward Control+Alt+d \ + -kb-custom-1 Delete -kb-custom-2 Control+Delete \ + -p 'CLIPBOARD HISTORY' -mesg '[ ENTER ] COPY [ DEL ] DELETE ENTRY [ CTRL+DEL ] CLEAR ALL' \ + -theme "$HOME/.config/rofi/themes/clipboard.rasi") || status=$? + [[ $status != 1 ]] || exit 0 + case $status in + 0|10) + [[ $selected =~ ^(0|[1-9][0-9]*)$ ]] && (( selected < ${#entries[@]} )) || exit 1 + if [[ $status == 10 ]]; then + printf '%s\n' "${entries[selected]}" | cliphist delete + continue + fi + # Decode first: a failed lookup must not replace the current clipboard. + printf '%s\n' "${entries[selected]}" | cliphist decode > "$payload" + wl-copy < "$payload" + notify-send 'Clipboard' 'Content copied!' -t 2000 + exit 0 + ;; + 11) + confirm_status=0 + confirmation=$(printf 'Cancel\nClear all\n' | rofi -dmenu -no-custom -format i \ + -p 'Clear clipboard history?' -theme "$HOME/.config/rofi/themes/current.rasi") || confirm_status=$? + if [[ $confirm_status == 0 && $confirmation == 1 ]]; then cliphist wipe; fi + [[ $confirm_status == 0 || $confirm_status == 1 ]] || exit 1 + ;; + *) exit "$status" ;; + esac +done diff --git a/.config/rofi/scripts/wallpaper.sh b/.config/rofi/scripts/wallpaper.sh index f063482..bbad249 100755 --- a/.config/rofi/scripts/wallpaper.sh +++ b/.config/rofi/scripts/wallpaper.sh @@ -1,29 +1,30 @@ -#!/bin/bash - -WALLPAPER_DIR="$HOME/Pictures/Wallpapers" -[ -d "$WALLPAPER_DIR" ] || WALLPAPER_DIR="$HOME/Pictures/Wallpaper" - -# Get all wallpapers -WALLPAPERS=$(find "$WALLPAPER_DIR" -type f \( -iname "*.jpg" -o -iname "*.png" -o -iname "*.jpeg" \) | sort) - -# Check if directory has wallpapers -if [ -z "$WALLPAPERS" ]; then - notify-send "Wallpaper Selector" "No wallpapers found in $WALLPAPER_DIR" - exit 1 -fi - -# Create rofi menu with image names -SELECTED=$(echo "$WALLPAPERS" | while read -r wallpaper; do - basename "$wallpaper" -done | rofi -dmenu -i -theme ~/.config/rofi/themes/current.rasi -p "Select Wallpaper" \ - -theme-str 'window {width: 600px;} listview {lines: 10;}') - -# If something was selected -if [ -n "$SELECTED" ]; then - WALLPAPER_PATH=$(echo "$WALLPAPERS" | grep "$SELECTED$") - - if [ -f "$WALLPAPER_PATH" ]; then - awww img "$WALLPAPER_PATH" --transition-type fade --transition-bezier .23,1,.32,1 --transition-duration 0.8 --transition-fps 144 - notify-send "Wallpaper Changed" "$SELECTED" -i "$WALLPAPER_PATH" -t 3000 - fi -fi +#!/usr/bin/env bash +# Select wallpaper paths by index, preserving punctuation and duplicate basenames. +set -euo pipefail +trap 'notify-send "Wallpaper" "Could not select or apply wallpaper." >&2 || true' ERR +wallpaper_dir="$HOME/Pictures/Wallpapers" +[[ -d $wallpaper_dir ]] || wallpaper_dir="$HOME/Pictures/Wallpaper" +[[ -d $wallpaper_dir ]] || { notify-send 'Wallpaper' 'Wallpaper directory not found.'; exit 1; } +# A temporary null-delimited list preserves paths and lets discovery failures surface. +list=$(mktemp) +trap 'rm -f "$list"' EXIT +find "$wallpaper_dir" -type f \( -iname '*.jpg' -o -iname '*.png' -o -iname '*.jpeg' \) -print0 | sort -z > "$list" +mapfile -d '' -t wallpapers < "$list" +(( ${#wallpapers[@]} )) || { notify-send 'Wallpaper' 'No wallpapers found.'; exit 0; } +names=() +for wallpaper in "${wallpapers[@]}"; do + name=${wallpaper#"$wallpaper_dir/"} + # Display control characters safely; the index still resolves the original path. + printf -v name '%q' "$name" + names+=("$name") +done +status=0 +selected=$(printf '%s\n' "${names[@]}" | rofi -dmenu -i -no-custom -format i \ + -theme "$HOME/.config/rofi/themes/current.rasi" -p 'Select Wallpaper' \ + -theme-str 'window {width: 600px;} listview {lines: 10;}') || status=$? +[[ $status != 1 ]] || exit 0 +[[ $status == 0 ]] || exit "$status" +[[ $selected =~ ^(0|[1-9][0-9]*)$ ]] && (( selected < ${#wallpapers[@]} )) || exit 1 +awww img "${wallpapers[selected]}" --transition-type fade --transition-bezier .23,1,.32,1 \ + --transition-duration 0.8 --transition-fps 144 +notify-send 'Wallpaper Changed' "${names[selected]}" -i "${wallpapers[selected]}" -t 3000 diff --git a/.config/rofi/scripts/wifi.sh b/.config/rofi/scripts/wifi.sh index eaa484c..54e02a9 100755 --- a/.config/rofi/scripts/wifi.sh +++ b/.config/rofi/scripts/wifi.sh @@ -1,11 +1,21 @@ -#!/bin/bash -notify-send "WiFi" "Scanning networks..." -WIFI_LIST=$(nmcli -f SSID,SECURITY,SIGNAL device wifi list | tail -n +2) -CHOSEN=$(echo "$WIFI_LIST" | rofi -dmenu -i -theme ~/.config/rofi/themes/current.rasi -p "WiFi Networks" | awk '{print $1}') - -if [ -n "$CHOSEN" ]; then - PASSWORD=$(rofi -dmenu -theme ~/.config/rofi/themes/current.rasi -password -p "Password for $CHOSEN") - nmcli device wifi connect "$CHOSEN" password "$PASSWORD" && \ - notify-send "WiFi" "Connected to $CHOSEN" || \ - notify-send "WiFi" "Failed to connect" -fi +#!/usr/bin/env bash +# Select an intact SSID and delegate credentials to NetworkManager in Kitty. +set -euo pipefail +trap 'notify-send "Wi-Fi" "Could not open the network connection prompt." >&2 || true' ERR +networks=$(nmcli --escape no --get-values SSID device wifi list) +declare -A seen=() +ssids=() +# shortcut: SSIDs containing newlines are not representable in this menu; use nmcli directly for those. +while IFS= read -r ssid; do + [[ -n $ssid ]] || continue + if [[ ! ${seen["$ssid"]+present} ]]; then ssids+=("$ssid"); seen["$ssid"]=1; fi +done <<< "$networks" +(( ${#ssids[@]} )) || { notify-send 'Wi-Fi' 'No named networks found.'; exit 0; } +status=0 +selected=$(printf '%s\n' "${ssids[@]}" | rofi -dmenu -i -no-custom -format i \ + -theme "$HOME/.config/rofi/themes/current.rasi" -p 'Wi-Fi Networks') || status=$? +[[ $status != 1 ]] || exit 0 +[[ $status == 0 ]] || exit "$status" +[[ $selected =~ ^(0|[1-9][0-9]*)$ ]] && (( selected < ${#ssids[@]} )) || exit 1 +# No password is collected or placed in command arguments by this script. +kitty --hold -e nmcli --ask device wifi connect "${ssids[selected]}" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0a6e15c..4e1eb2e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,4 +11,4 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y shellcheck jq - run: bash tests/run.sh - name: ShellCheck changed shell scripts - run: shellcheck .config/hypr/scripts/wallpaper.sh tests/*.sh + run: shellcheck .config/hypr/scripts/wallpaper.sh .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh scripts/apply-system-optimizations.sh tests/*.sh diff --git a/README.md b/README.md index 1d036e4..7bdc4cd 100644 --- a/README.md +++ b/README.md @@ -164,22 +164,18 @@ Existing conflicting configuration files or directories will be safely backed up ## System optimizations -An automated tuning script is included in `scripts/apply-system-optimizations.sh`. It configures: - -- DNF parallel downloads (capped at 2), fastest mirror selection, and default yes prompts. -- Tuned power profile set to `desktop` for responsive CPU governor scaling. -- Disables `NetworkManager-wait-online.service` to trim cold boot time. -- Inotify user watch limit raised to 524,288 for large development projects. -- Hardware video decode via RPM Fusion `mesa-va-drivers-freeworld` on AMD Radeon GPUs. -- Disables idle background daemons (`ModemManager`, `thermald`, `rsyslog`, `abrtd`, and `lvm2-monitor`). -- Installs modern developer CLI tools (`fzf`, `zoxide`, `eza`, `git-delta`, `duf`). - -To apply these tweaks: +The default script changes only the developer file-watch limit: ```bash sudo ./scripts/apply-system-optimizations.sh ``` +It preserves unrelated sysctl settings and higher watch limits, backs up changed +files, verifies the kernel value, and restores the previous setting if application +fails. It does not install packages, alter power profiles, or disable services. +See [optional system steps](docs/system-tuning.md) for individually verified +changes and rollback. Previously applied system settings are left in place. + ## Verification Run `bash tests/run.sh` for shell syntax and isolated regression checks. Tests use @@ -188,3 +184,10 @@ mock commands and temporary homes; they do not alter the running desktop. The workspace wallpaper listener has one owner per Hyprland session. `SUPER+A` runs `wallpaper.sh --once` to refresh without creating another listener. Workspaces 6–10 retain the last wallpaper. Missing images and daemon failures go to stderr. + +Rofi Wi-Fi selection opens Kitty with NetworkManager's `nmcli --ask` prompt; +saved credentials and authentication remain NetworkManager's responsibility. +Clipboard history supports Enter to copy, Delete to delete the selected entry, +and Ctrl+Delete to clear history after confirmation. Escape leaves the clipboard +and history alone. Wallpaper selection displays relative paths and handles +punctuation and duplicate filenames without regex matching. diff --git a/docs/system-tuning.md b/docs/system-tuning.md new file mode 100644 index 0000000..c012738 --- /dev/null +++ b/docs/system-tuning.md @@ -0,0 +1,67 @@ +# Optional workstation changes + +## TL;DR + +The default script only raises the developer file-watch limit. Apply the steps +below separately after recording current settings. These commands are for the +Fedora workstation; they are not automatic optimizations or measured wins. + +## DNF preferences + +Inspect `/etc/dnf/dnf.conf` and back it up with `sudo cp -a --backup=numbered +/etc/dnf/dnf.conf /etc/dnf/dnf.conf.before-dotfiles` before editing. Keep +`max_parallel_downloads=2` as the existing preference until download measurements +justify another value. `fastestmirror=True` selects by connection latency, not +throughput. `defaultyes=True` changes the default confirmation choice, not speed. +For Fedora defaults remove those overrides only after reviewing the file. Verify +with `dnf --dump-main-config` (check the installed DNF version's help first). +Restore the saved file to roll back. + +Reference: [DNF5 configuration](https://dnf5.readthedocs.io/en/latest/dnf5.conf.5.html). + +## Power profile + +Run `tuned-adm active` and `tuned-adm verify` in the actual host session. Record the +active profile before testing another. Keep the current profile by default. +If testing desktop scaling, first confirm `desktop` appears in `tuned-adm list`, +then run `sudo tuned-adm profile desktop`; verify and compare responsiveness and +idle power. Roll back with `sudo tuned-adm profile PREVIOUS_PROFILE`. A file in +`/etc/tuned` is not proof of live activation; do not disable other power managers +without understanding the host's integration. + +## Services and boot + +Measure `systemd-analyze critical-chain` and `systemd-analyze blame` first. Inspect +`systemctl list-dependencies --reverse NetworkManager-wait-online.service` and +whether network-dependent mounts/services need it. If demonstrated unnecessary, +record its enabled state, then use `sudo systemctl disable +NetworkManager-wait-online.service`; compare subsequent boots. Restore its +previous enabled state to roll back. Never blindly disable ModemManager, thermald, +rsyslog, abrtd, or lvm2-monitor; identify actual use and dependencies first. +Previously disabled services require individual review, not blanket re-enabling. + +## AMD hardware video decoding + +Inspect `rpm -q mesa-va-drivers mesa-va-drivers-freeworld` and `vainfo`; confirm +which codecs and driver the running application uses. A package's presence alone +does not establish active browser decoding. If needed, follow the current +[RPM Fusion codec instructions](https://rpmfusion.org/Howto/Multimedia) for the +installed Fedora release. Review a proposed DNF swap interactively; do not add +repositories or import keys silently. Record the transaction ID and package +versions. Review `sudo dnf history undo TRANSACTION_ID` to roll back; availability +of old packages can limit rollback. Do not claim codec acceleration until verified +in the application. + +## Developer tools + +Review and run `sudo dnf install fzf zoxide eza git-delta duf` separately. Verify +with `command -v` for each executable (`delta` is the git-delta executable). +Record the DNF transaction for rollback; avoid removing pre-existing tools. + +## File-watch rollback + +On success, the watch-limit helper prints any backup path. Restore that exact +file and apply just its watch-limit value with `sudo sysctl -w +fs.inotify.max_user_watches=PREVIOUS_VALUE`. If no file previously existed, remove +only the newly created workstation file and restore the previously recorded +kernel value. Do not apply every sysctl file as a shortcut to rollback. diff --git a/scripts/apply-system-optimizations.sh b/scripts/apply-system-optimizations.sh index 85ef969..0d1e955 100755 --- a/scripts/apply-system-optimizations.sh +++ b/scripts/apply-system-optimizations.sh @@ -1,63 +1,9 @@ #!/usr/bin/env bash +# Apply only the backed-up, verified developer file-watch setting. set -euo pipefail - -if [ "$EUID" -ne 0 ]; then - echo "[-] Please run this script with sudo: sudo ./scripts/apply-system-optimizations.sh" +if (( EUID != 0 )); then + echo 'Run: sudo ./scripts/apply-system-optimizations.sh' >&2 exit 1 fi - -echo "[+] 1. Configuring DNF for fast mirrors and parallel downloads (max 2)..." -if ! grep -q "max_parallel_downloads" /etc/dnf/dnf.conf 2>/dev/null; then - cat << 'EOF' >> /etc/dnf/dnf.conf -max_parallel_downloads=2 -fastestmirror=True -defaultyes=True -EOF - echo " Done: Added max_parallel_downloads=2, fastestmirror=True, defaultyes=True." -else - sed -i 's/^max_parallel_downloads=.*/max_parallel_downloads=2/' /etc/dnf/dnf.conf - echo " Done: Updated max_parallel_downloads to 2." -fi - -echo "[+] 2. Setting tuned power profile to 'desktop' (optimized for Ryzen desktop workloads)..." -if command -v tuned-adm >/dev/null 2>&1; then - tuned-adm profile desktop - echo " Done: Active tuned profile set to desktop." -fi - -echo "[+] 3. Shaving ~5.6s off boot time (disabling NetworkManager-wait-online.service)..." -systemctl disable NetworkManager-wait-online.service 2>/dev/null || true -echo " Done: NetworkManager-wait-online disabled." - -echo "[+] 4. Raising inotify file watch limits for dev projects..." -cat << 'EOF' > /etc/sysctl.d/99-workstation.conf -# Raise file watch limits for IDEs, Node, Rust, and Python dev -fs.inotify.max_user_watches=524288 -EOF -sysctl -p /etc/sysctl.d/99-workstation.conf -echo " Done: inotify watches set to 524288." - -echo "[+] 5. Setting up RPM Fusion Free & hardware video acceleration (VA-API freeworld)..." -if ! dnf repolist | grep -q "rpmfusion-free"; then - echo " Enabling RPM Fusion Free repository..." - dnf install -y "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" || true -fi - -if dnf repolist | grep -q "rpmfusion-free"; then - echo " Swapping mesa-va-drivers for freeworld hardware acceleration..." - dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld || true - echo " Done: Hardware video acceleration installed." -fi - -echo "[+] 6. Disabling unnecessary background daemons (ModemManager, thermald, rsyslog, abrtd, lvm2-monitor)..." -systemctl disable --now thermald ModemManager rsyslog abrtd lvm2-monitor 2>/dev/null || true -echo " Done: Unnecessary background daemons disabled." - -echo "[+] 7. Installing modern developer CLI tools (fzf, zoxide, eza, git-delta, duf)..." -if dnf install -y fzf zoxide eza git-delta duf; then - echo " Done: Modern developer CLI tools installed." -else - echo " [!] Warning: Failed to install developer CLI tools. Install manually via 'sudo dnf install fzf zoxide eza git-delta duf'." -fi - -echo "[✓] All system-level performance optimizations applied successfully!" +python3 "$(dirname "${BASH_SOURCE[0]}")/inotify.py" +echo 'File-watch setting verified. Other system settings were not changed.' diff --git a/scripts/inotify.py b/scripts/inotify.py new file mode 100644 index 0000000..f9ef17d --- /dev/null +++ b/scripts/inotify.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Raise the workstation watch limit with preserved settings and rollback on failure.""" +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile + +KEY = 'fs.inotify.max_user_watches' +MINIMUM = 524288 + + +def apply(config: Path) -> int: + """Preserve higher limits, update only KEY, and verify the running kernel. + + Args: + config: Sysctl file to update; production uses the fixed workstation path. + Returns: + The verified effective watch limit. + Raises: + ValueError: Existing configuration is malformed. + OSError: Configuration cannot be safely written. + subprocess.CalledProcessError: A sysctl command fails. + RuntimeError: The kernel does not report the requested value. + """ + if config.is_symlink(): + raise ValueError('Refusing to replace a symlinked sysctl file') + current = int(subprocess.check_output(['sysctl', '-n', KEY], text=True).strip()) + original = config.read_text() if config.exists() else '' + lines = original.splitlines(keepends=True) + pattern = re.compile(r'^\s*-?fs\.inotify\.max_user_watches\s*(?:=\s*|\s+)([^#;]+)') + values = [int(match.group(1).strip()) for line in lines if (match := pattern.match(line))] + desired = max(MINIMUM, current, *values) + # Keep exact formatting when an existing single setting already suffices. + if values == [desired]: + updated = original + else: + updated = ''.join(line for line in lines if not pattern.match(line)) + if updated and not updated.endswith('\n'): + updated += '\n' + updated += f'{KEY}={desired}\n' + backup = None + changed = updated != original + if changed: + config.parent.mkdir(parents=True, exist_ok=True) + metadata = config.stat() if config.exists() else None + if metadata: + fd, name = tempfile.mkstemp(prefix=config.name + '.backup-', dir=config.parent) + os.close(fd) + backup = Path(name) + shutil.copy2(config, backup) + fd, name = tempfile.mkstemp(prefix='.' + config.name, dir=config.parent) + temporary = Path(name) + try: + with os.fdopen(fd, 'w') as output: + os.fchmod(output.fileno(), stat.S_IMODE(metadata.st_mode) if metadata else 0o644) + if metadata: + os.fchown(output.fileno(), metadata.st_uid, metadata.st_gid) + output.write(updated) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, config) + finally: + temporary.unlink(missing_ok=True) + if backup: + print(f'Previous configuration saved to {backup}') + try: + if current != desired: + subprocess.run(['sysctl', '-w', f'{KEY}={desired}'], check=True) + effective = int(subprocess.check_output(['sysctl', '-n', KEY], text=True).strip()) + if effective != desired: + raise RuntimeError(f'Expected {desired} watches, kernel reports {effective}') + except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError): + if changed: + if backup: + os.replace(backup, config) + else: + config.unlink(missing_ok=True) + try: + subprocess.run(['sysctl', '-w', f'{KEY}={current}'], check=True) + except (OSError, subprocess.CalledProcessError) as error: + print(f'Could not restore the previous kernel value: {error}', file=sys.stderr) + raise + return effective + + +if __name__ == '__main__': + if os.geteuid() != 0: + raise SystemExit('Run through sudo ./scripts/apply-system-optimizations.sh') + print(f'Verified watch limit: {apply(Path("/etc/sysctl.d/99-workstation.conf"))}') diff --git a/tests/rofi.sh b/tests/rofi.sh new file mode 100644 index 0000000..a3774b6 --- /dev/null +++ b/tests/rofi.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Exercise picker selection, cancellation, and destructive clipboard actions. +set -euo pipefail +repo=$(cd "$(dirname "$0")/.." && pwd) +task_tmp=$(mktemp -d) +trap 'rm -rf "$task_tmp"' EXIT +export HOME="$task_tmp/home" TEST_DIR="$task_tmp" +mkdir -p "$HOME/Pictures/Wallpapers/a" "$HOME/Pictures/Wallpapers/b" "$task_tmp/bin" +cat > "$task_tmp/bin/rofi" <<'MOCK' +#!/bin/bash +cat > "$TEST_DIR/menu" +# The deletion shortcut must not collide with Rofi's default text editing key. +if [[ " $* " == *' -kb-custom-1 Delete '* && " $* " != *' -kb-remove-char-forward Control+d '* ]]; then + echo 'Delete remains assigned to two actions' >&2 + exit 2 +fi +count=0 +[[ ! -f $TEST_DIR/count ]] || read -r count < "$TEST_DIR/count" +printf '%s\n' "$((count+1))" > "$TEST_DIR/count" +case ${TEST_ACTION:-copy}:$count in + cancel:*|delete:1|wipe:2|reject:2) exit 1 ;; + delete:0) printf '0\n'; exit 10 ;; + wipe:0|reject:0) printf '0\n'; exit 11 ;; + wipe:1) printf '1\n' ;; + reject:1) printf '0\n' ;; + *) printf '%s\n' "${TEST_INDEX:-0}" ;; +esac +MOCK +cat > "$task_tmp/bin/cliphist" <<'MOCK' +#!/bin/bash +case $1 in + list) printf '1\tfirst\n2\tsecond\n' ;; + decode) cat > "$TEST_DIR/entry"; [[ ${TEST_DECODE_FAIL:-0} == 0 ]] || exit 1; printf '\211PNG\000\377' ;; + delete) cat > "$TEST_DIR/deleted" ;; + wipe) touch "$TEST_DIR/wiped" ;; +esac +MOCK +cat > "$task_tmp/bin/wl-copy" <<'MOCK' +#!/bin/bash +cat > "$TEST_DIR/copied" +MOCK +cat > "$task_tmp/bin/nmcli" <<'MOCK' +#!/bin/bash +printf 'My Home WiFi\nGuest: Network\\name\nMy Home WiFi\n' +MOCK +cat > "$task_tmp/bin/kitty" <<'MOCK' +#!/bin/bash +printf '%s\n' "$@" > "$TEST_DIR/kitty" +MOCK +cat > "$task_tmp/bin/awww" <<'MOCK' +#!/bin/bash +printf '%s\n' "$2" > "$TEST_DIR/wallpaper" +MOCK +printf '#!/bin/bash\nexit 0\n' > "$task_tmp/bin/notify-send" +chmod +x "$task_tmp/bin/"* +export PATH="$task_tmp/bin:$PATH" +clipboard="$repo/.config/rofi/scripts/clipboard_history.sh" +TEST_ACTION=cancel bash "$clipboard" +[[ ! -f $TEST_DIR/copied ]] +rm "$TEST_DIR/count" +bash "$clipboard" +printf '\211PNG\000\377' > "$task_tmp/expected" +cmp "$task_tmp/expected" "$TEST_DIR/copied" +rm "$TEST_DIR/count" "$TEST_DIR/copied" +TEST_ACTION=delete bash "$clipboard" +[[ $(cat "$TEST_DIR/deleted") == $'1\tfirst' && ! -f $TEST_DIR/copied ]] +rm "$TEST_DIR/count" +TEST_ACTION=reject bash "$clipboard" +[[ ! -f $TEST_DIR/wiped ]] +rm "$TEST_DIR/count" +TEST_ACTION=wipe bash "$clipboard" +[[ -f $TEST_DIR/wiped ]] +rm "$TEST_DIR/count" +if TEST_DECODE_FAIL=1 bash "$clipboard" >/dev/null 2>&1; then exit 1; fi +[[ ! -f $TEST_DIR/copied ]] +rm "$TEST_DIR/count" +TEST_INDEX=1 bash "$repo/.config/rofi/scripts/wifi.sh" +[[ $(tail -n 1 "$TEST_DIR/kitty") == 'Guest: Network\name' ]] +[[ $(wc -l < "$TEST_DIR/menu") == 2 ]] +rm "$TEST_DIR/count" "$TEST_DIR/kitty" +TEST_ACTION=cancel bash "$repo/.config/rofi/scripts/wifi.sh" +[[ ! -f $TEST_DIR/kitty ]] +touch "$HOME/Pictures/Wallpapers/a/wallpaper[1].png" "$HOME/Pictures/Wallpapers/b/wallpaper[1].png" +rm "$TEST_DIR/count" +TEST_INDEX=1 bash "$repo/.config/rofi/scripts/wallpaper.sh" +[[ $(cat "$TEST_DIR/wallpaper") == "$HOME/Pictures/Wallpapers/b/wallpaper[1].png" ]] +printf 'Rofi regression checks passed\n' diff --git a/tests/run.sh b/tests/run.sh index ac0b03d..db5d4da 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -4,3 +4,5 @@ set -euo pipefail cd "$(dirname "$0")/.." while IFS= read -r -d '' script; do bash -n "$script"; done < <(find scripts .config tests -type f -name '*.sh' -print0) for test in tests/*.sh; do [[ $test == tests/run.sh ]] || bash "$test"; done + +for test in tests/*.py; do [[ ! -f $test ]] || python3 -B "$test"; done diff --git a/tests/tuning.py b/tests/tuning.py new file mode 100644 index 0000000..5e39414 --- /dev/null +++ b/tests/tuning.py @@ -0,0 +1,68 @@ +"""Check sysctl preservation, verification, and rollback using disposable files.""" +import importlib.util +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('inotify', Path(__file__).resolve().parents[1] / 'scripts/inotify.py') +inotify = importlib.util.module_from_spec(spec) +spec.loader.exec_module(inotify) + +class TuningTests(unittest.TestCase): + def test_preserves_higher_limits_and_unrelated_settings(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + original = '# local comment\nvm.swappiness=80\nfs.inotify.max_user_watches=1048576\n' + config.write_text(original) + with patch.object(inotify.subprocess, 'check_output', side_effect=['524288', '1048576']), patch.object(inotify.subprocess, 'run') as run: + self.assertEqual(inotify.apply(config), 1048576) + self.assertEqual(config.read_text(), original) + run.assert_called_once_with(['sysctl', '-w', 'fs.inotify.max_user_watches=1048576'], check=True) + self.assertEqual(list(Path(directory).glob('*.backup-*')), []) + + def test_updates_once_and_backs_up(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + original = '# custom\nvm.swappiness=80\nfs.inotify.max_user_watches = 8192\n' + config.write_text(original) + with patch.object(inotify.subprocess, 'check_output', return_value='524288'), patch.object(inotify.subprocess, 'run'): + inotify.apply(config) + first = config.read_text() + inotify.apply(config) + self.assertIn('vm.swappiness=80', first) + backups = list(Path(directory).glob('*.backup-*')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), original) + + def test_failed_apply_restores_file(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + original = 'vm.swappiness=80\n' + config.write_text(original) + failure = subprocess.CalledProcessError(1, 'sysctl') + with patch.object(inotify.subprocess, 'check_output', return_value='8192'), patch.object(inotify.subprocess, 'run', side_effect=[failure, None]): + with self.assertRaises(subprocess.CalledProcessError): + inotify.apply(config) + self.assertEqual(config.read_text(), original) + + def test_failed_verification_removes_new_file(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + with patch.object(inotify.subprocess, 'check_output', side_effect=['8192', '8192']), patch.object(inotify.subprocess, 'run'): + with self.assertRaises(RuntimeError): + inotify.apply(config) + self.assertFalse(config.exists()) + + def test_malformed_existing_value_is_not_overwritten(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / 'workstation.conf' + config.write_text('fs.inotify.max_user_watches=unexpected\n') + with patch.object(inotify.subprocess, 'check_output', return_value='8192'), patch.object(inotify.subprocess, 'run') as run: + with self.assertRaises(ValueError): + inotify.apply(config) + run.assert_not_called() + +if __name__ == '__main__': + unittest.main() From b5912ae6aa8a54a85783b0926970e77db17fe423 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:44:34 -0400 Subject: [PATCH 9/9] fix: address workstation setup review findings Write Bash startup configuration atomically and preserve its mode. Canonicalize checkout paths, report missing installer prerequisites, and document supported Git versions and manual integration limits. Interleave measurement variants and strengthen regression coverage for failure paths and noninteractive loading. Co-authored-by: Codex --- README.md | 24 +++++++++++++++++++++++ docs/performance-baseline.md | 24 +++++++++++++++++------ scripts/install-symlinks.sh | 7 ++++--- scripts/migrate-shell.py | 20 ++++++++++++++++--- tests/install.sh | 22 ++++++++++++++++++++- tests/measurement.py | 31 +++++++++++++++++++++++++++++ tests/migration.py | 38 +++++++++++++++++++++++++++++++++--- 7 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 tests/measurement.py diff --git a/README.md b/README.md index 4974552..1e186ae 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,12 @@ The main modifier key is `SUPER`. ## Installation & setup +The installer requires `python3` and **Git 2.30 or later** (`git config --fixed-value`). +The option is documented in [Git 2.30's configuration reference](https://github.com/git/git/blob/v2.30.0/Documentation/git-config.txt). +`delta` is needed only for the installed Git presentation settings, not to run +the installer. Symlinked `.bashrc` and `.gitconfig` files require manual integration; +do not replace those links to run this installer. + To link all configuration directories (`hypr`, `waybar`, `kitty`, `rofi`, `swaync`, `fastfetch`, `btop`, `starship.toml`) to `~/.config/`: ```bash @@ -191,3 +197,21 @@ Clipboard history supports Enter to copy, Delete to delete the selected entry, and Ctrl+Delete to clear history after confirmation. Escape leaves the clipboard and history alone. Wallpaper selection displays relative paths and handles punctuation and duplicate filenames without regex matching. + + +### Portable Bash and Git settings + +The installer links `shell/dotfiles.sh` into `~/.bashrc.d/` and includes +`git/developer.gitconfig` from local Git configuration. Shell tools are initialized +only in interactive shells, and missing optional tools are skipped. Local Git +identity, credentials, signing, and differing presentation overrides are preserved. + +Migration removes only the exact recognized legacy Bash block and matching Git +presentation settings. It backs up changed startup files and replaces `.bashrc` +atomically, preserving its mode. Symlink conflicts are saved as `BACKUP/original`. +Restore the printed backups to undo installation. + +The Bash loader check matches literal text without executing `.bashrc`. If that +text exists only in an unreachable block, manually source the fragment from an +active part of `.bashrc`. Locally edited legacy blocks may also need manual +integration to avoid duplicate initialization. diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index 1e252a4..5c6e3ee 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -21,6 +21,11 @@ sandbox, not a complete Hyprland session; no desktop performance gain is claimed | Boot/failed services | Unverified | System and user bus access denied | | Desktop idle CPU/power | Unverified | Process visibility excludes the live desktop | +The historical samples above collected each variant as a separate batch. Cache +warm-up or changing host load can bias that difference. The repeat procedure +below interleaves variants and alternates first position within each pair; the +historical numbers have not been remeasured with this procedure. + ## Repeat shell startup measurement Run from your normal host terminal. This measures startup without terminal @@ -32,19 +37,26 @@ import pathlib, statistics, subprocess, tempfile, time original = pathlib.Path.home().joinpath('.bashrc').read_text() with tempfile.TemporaryDirectory() as directory: - for variant in ('current', 'without_fastfetch'): + variants = ('current', 'without_fastfetch') + samples = {variant: [] for variant in variants} + configs = {} + for variant in variants: rc = pathlib.Path(directory) / variant # Intercept only direct fastfetch calls in this disposable child shell. # The tracked fragment and existing rc both use direct calls. rc.write_text(('fastfetch() { :; }\n' if variant == 'without_fastfetch' else '') + original) - samples = [] - for _ in range(20): + configs[variant] = rc + for round_number in range(20): + order = variants if round_number % 2 == 0 else variants[::-1] + for variant in order: + rc = configs[variant] start = time.perf_counter() subprocess.run(['bash', '--noprofile', '--rcfile', str(rc), '-ic', 'exit'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) - samples.append((time.perf_counter() - start) * 1000) - assert len(samples) == 20 - print(variant, 'median/range ms:', statistics.median(samples), min(samples), max(samples)) + samples[variant].append((time.perf_counter() - start) * 1000) + for variant, values in samples.items(): + assert len(values) == 20 + print(variant, 'median/range ms:', statistics.median(values), min(values), max(values)) ``` This temporary function does not intercept an explicit `command fastfetch` or diff --git a/scripts/install-symlinks.sh b/scripts/install-symlinks.sh index a7190fe..0c4e729 100755 --- a/scripts/install-symlinks.sh +++ b/scripts/install-symlinks.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Link tracked configs and portable fragments, preserving local settings in backups. set -euo pipefail -DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -command -v python3 >/dev/null -command -v git >/dev/null +for tool in python3 git; do + command -v "$tool" >/dev/null || { echo "Missing required tool: $tool" >&2; exit 1; } +done +DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" # Preserve conflicts, including dangling links, before installing one source. link_item() { diff --git a/scripts/migrate-shell.py b/scripts/migrate-shell.py index f140435..c9ec52b 100644 --- a/scripts/migrate-shell.py +++ b/scripts/migrate-shell.py @@ -59,6 +59,7 @@ def migrate(path: Path) -> None: raise ValueError('Symlinked .bashrc: integrate shell/dotfiles.sh manually') original = path.read_text() if path.exists() else '' updated = original.replace(LEGACY, '') + # shortcut: loader detection matches raw text; manually source the fragment when a loader is unreachable. if DIRECTORY_LOADER not in updated and HOOK not in updated: updated = updated.rstrip('\n') + '\n\n' + HOOK if updated == original: @@ -67,7 +68,20 @@ def migrate(path: Path) -> None: with tempfile.NamedTemporaryFile(prefix=path.name + '.backup-', dir=path.parent, delete=False) as backup: shutil.copy2(path, backup.name) print(f'Bash backup: {backup.name}') - path.write_text(updated) + mode = path.stat().st_mode & 0o7777 if path.exists() else 0o600 + temporary_name = None + try: + with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', prefix=path.name + '.tmp-', + dir=path.parent, delete=False) as temporary: + temporary_name = temporary.name + temporary.write(updated) + temporary.flush() + os.fchmod(temporary.fileno(), mode) + os.fsync(temporary.fileno()) + os.replace(temporary_name, path) + finally: + if temporary_name is not None: + Path(temporary_name).unlink(missing_ok=True) def migrate_git(path: Path, include: Path) -> None: @@ -89,14 +103,14 @@ def migrate_git(path: Path, include: Path) -> None: draft.write_text(original) for key, value in settings.items(): result = subprocess.run(['git', 'config', '--file', str(draft), '--fixed-value', - '--unset-all', key, value], capture_output=True, text=True) + '--unset-all', key, value], capture_output=True, text=True, check=False) if result.returncode not in (0, 5): raise RuntimeError(f'Cannot migrate Git setting {key}: {result.stderr}') # Let Git quote the path instead of implementing its configuration grammar. prefix = Path(directory) / 'include' subprocess.run(['git', 'config', '--file', str(prefix), '--add', 'include.path', str(include)], check=True) existing = subprocess.run(['git', 'config', '--file', str(draft), '--get-all', 'include.path'], - capture_output=True, text=True) + capture_output=True, text=True, check=False) if existing.returncode not in (0, 1): raise RuntimeError('Cannot read Git include settings') updated = draft.read_text() diff --git a/tests/install.sh b/tests/install.sh index 955e904..7abc2e3 100644 --- a/tests/install.sh +++ b/tests/install.sh @@ -33,5 +33,25 @@ bash "$repo/scripts/install-symlinks.sh" >/dev/null [[ $(git config --global --get-all include.path | wc -l) == 1 ]] [[ $(find "$HOME/.config" -maxdepth 1 -name 'waybar.backup-*' | wc -l) == 1 ]] # Portable fragments must not initialize interactive tools in a script. -bash -c 'source "$HOME/.bashrc.d/dotfiles.sh"; [[ ! ${CUSTOM_LOCAL+present} ]]' +bash -c 'source "$HOME/.bashrc.d/dotfiles.sh"; ! alias syu >/dev/null 2>&1' +# A checkout reached through a symlink must still install physical source paths. +ln -s "$repo" "$task_tmp/checkout" +bash "$task_tmp/checkout/scripts/install-symlinks.sh" >/dev/null +bash "$task_tmp/checkout/scripts/install-symlinks.sh" >/dev/null +[[ $(find "$HOME/.config" -maxdepth 1 -name 'kitty.backup-*' | wc -l) == 1 ]] +[[ $(find "$HOME/.config" -maxdepth 1 -name 'waybar.backup-*' | wc -l) == 1 ]] +# Missing prerequisites must be named before any local startup file is changed. +for missing in python3 git; do + isolated="$task_tmp/missing-$missing" + mkdir -p "$isolated/bin" "$isolated/home" + if [[ $missing == python3 ]]; then other=git; else other=python3; fi + ln -s "$(command -v "$other")" "$isolated/bin/$other" + printf 'local settings\n' > "$isolated/home/.bashrc" + if HOME="$isolated/home" PATH="$isolated/bin" /bin/bash "$repo/scripts/install-symlinks.sh" > "$isolated/output" 2>&1; then + echo "Installer accepted missing $missing" >&2 + exit 1 + fi + grep -q "Missing required tool: $missing" "$isolated/output" + [[ $(cat "$isolated/home/.bashrc") == 'local settings' ]] +done printf 'Installer regression checks passed\n' diff --git a/tests/measurement.py b/tests/measurement.py new file mode 100644 index 0000000..14a7d8f --- /dev/null +++ b/tests/measurement.py @@ -0,0 +1,31 @@ +"""Check the documented benchmark's pairing without launching shell processes.""" +from contextlib import redirect_stdout +import io +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +class MeasurementTests(unittest.TestCase): + """Keep the executable measurement instructions aligned with their claims.""" + def test_variants_alternate_within_twenty_pairs(self) -> None: + """Alternate first position to avoid collecting one variant as a batch.""" + document = Path(__file__).resolve().parents[1] / 'docs/performance-baseline.md' + code = document.read_text().split('```python\n', 1)[1].split('```', 1)[0] + commands = [] + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + (home / '.bashrc').write_text('fastfetch\n') + def capture(args: list[str], **kwargs: object) -> subprocess.CompletedProcess: + """Capture each variant instead of starting an actual shell.""" + commands.append(Path(args[3]).name) + return subprocess.CompletedProcess(args, 0) + with patch.object(Path, 'home', return_value=home), patch.object(subprocess, 'run', side_effect=capture), redirect_stdout(io.StringIO()): + exec(compile(code, str(document), 'exec'), {}) + self.assertEqual(commands, ['current', 'without_fastfetch', 'without_fastfetch', 'current'] * 10) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/migration.py b/tests/migration.py index f9220c7..7ce1123 100644 --- a/tests/migration.py +++ b/tests/migration.py @@ -4,13 +4,43 @@ import subprocess import tempfile import unittest +from unittest.mock import patch spec = importlib.util.spec_from_file_location('migration', Path(__file__).resolve().parents[1] / 'scripts/migrate-shell.py') migration = importlib.util.module_from_spec(spec) spec.loader.exec_module(migration) class MigrationTests(unittest.TestCase): - def test_exact_bash_block_migrates_once(self): + """Protect local startup files and Git override precedence during migration.""" + def test_bash_replace_failure_preserves_original_and_cleans_temporary(self) -> None: + """A failed replacement must preserve startup content and its mode.""" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / '.bashrc' + original = 'export LOCAL=yes\n' + migration.LEGACY + path.write_text(original) + path.chmod(0o640) + with patch.object(migration.os, 'replace', side_effect=OSError('simulated replacement failure')): + with self.assertRaises(OSError): + migration.migrate(path) + self.assertEqual(path.read_text(), original) + self.assertEqual(path.stat().st_mode & 0o7777, 0o640) + backups = list(Path(directory).glob('.bashrc.backup-*')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), original) + self.assertEqual(list(Path(directory).glob('.bashrc.tmp-*')), []) + + def test_bash_replacement_preserves_mode(self) -> None: + """Atomic migration retains the original permissions.""" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / '.bashrc' + path.write_text(migration.LEGACY) + path.chmod(0o640) + migration.migrate(path) + self.assertEqual(path.stat().st_mode & 0o7777, 0o640) + self.assertEqual(list(Path(directory).glob('.bashrc.tmp-*')), []) + + def test_exact_bash_block_migrates_once(self) -> None: + """Remove the recognized block once and preserve unrelated settings.""" with tempfile.TemporaryDirectory() as directory: path = Path(directory) / '.bashrc' path.write_text('export LOCAL=yes\n' + migration.LEGACY) @@ -21,7 +51,8 @@ def test_exact_bash_block_migrates_once(self): self.assertEqual(path.read_text().count(migration.HOOK), 1) self.assertEqual(len(list(Path(directory).glob('.bashrc.backup-*'))), 1) - def test_git_local_override_wins_and_identity_survives(self): + def test_git_local_override_wins_and_identity_survives(self) -> None: + """Portable Git defaults must not override local identity or preferences.""" with tempfile.TemporaryDirectory(prefix='git home ') as directory: path = Path(directory) / '.gitconfig' fragment = Path(directory) / 'developer.gitconfig' @@ -29,7 +60,8 @@ def test_git_local_override_wins_and_identity_survives(self): path.write_text('[user]\n name=Local User\n[core]\n pager=less\n[delta]\n light=false\n') migration.migrate_git(path, fragment) migration.migrate_git(path, fragment) - def value(key): + def value(key: str) -> str: + """Read one effective test-config value with includes enabled.""" return subprocess.check_output(['git', 'config', '--file', str(path), '--includes', '--get', key], text=True).strip() self.assertEqual(value('core.pager'), 'less') self.assertEqual(value('user.name'), 'Local User')