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..2d0f426 100755 --- a/.config/hypr/scripts/wallpaper.sh +++ b/.config/hypr/scripts/wallpaper.sh @@ -1,41 +1,63 @@ -#!/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 + 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 ;; + 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/.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 new file mode 100644 index 0000000..4e1eb2e --- /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 .config/rofi/scripts/{wifi,clipboard_history,wallpaper}.sh scripts/apply-system-optimizations.sh tests/*.sh diff --git a/README.md b/README.md index 0768617..7bdc4cd 100644 --- a/README.md +++ b/README.md @@ -164,18 +164,30 @@ 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 +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 new file mode 100644 index 0000000..db5d4da --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,8 @@ +#!/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 + +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() diff --git a/tests/wallpaper.sh b/tests/wallpaper.sh new file mode 100644 index 0000000..96448de --- /dev/null +++ b/tests/wallpaper.sh @@ -0,0 +1,58 @@ +#!/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 +[[ ! -f $TEST_LOG.fail ]] || exit 1 +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 +[[ ${TEST_EVENT_FAIL:-0} != 1 ]] || touch "$TEST_LOG.fail" +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 "$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 +printf 'Wallpaper regression checks passed\n'