From 11d94079021bcc77cde52e820a3e8e1746a02acb Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:14:41 -0400 Subject: [PATCH 1/3] 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 bcee2beea71d8a8597ae366d75867ef8a6f9ebf7 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:19:18 -0400 Subject: [PATCH 2/3] 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 97c6698b493250a18a36e9ce1564184113866655 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 12 Oct 2026 23:26:29 -0400 Subject: [PATCH 3/3] 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 --- .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()