diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..f2aa0f4 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,23 @@ +name: Checks +on: [push, pull_request] +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install check tools + run: sudo apt-get update && sudo apt-get install -y shellcheck jq kbd + - name: Shell checks + run: | + for script in scripts/*.sh lib/*.sh; do bash -n "$script"; done + shellcheck -x scripts/*.sh lib/*.sh + - name: Behavior tests (unprivileged) + run: | + if [ "$(id -u)" = 0 ]; then + useradd -m revive-test + chmod o+x "$HOME" + chmod -R a+rX . + runuser -u revive-test -- python3 -B -m unittest discover -s tests -v + else + python3 -B -m unittest discover -s tests -v + fi diff --git a/.gitignore b/.gitignore index 71b10e3..dac0035 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ Thumbs.db # Shell tooling .shellcheckrc.local + +# Generated release files and test caches +dist/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b83d8ee..9ba96bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented here. +## 0.2.0 - 2026-09-23 + +### Fixed + +- Session revalidation and Hyprland IPC/output checks before VT switching. +- Emergency return on catchable signals and failed VT returns. +- Private runtime directories, lock error handling and configuration validation. +- Watcher restart on event-stream loss and honest recovery failure logs. +- Explicit activation, safe update restart, consistent XDG paths and scoped uninstall. + +### Added + +- Regression tests with mocked hardware/system services and Gitea CI checks. +- Reproducible committed-source installer archives, source ZIP, Base64 and checksums. +- Review findings, hardware acceptance notes and Hyprland environment startup hook. + ## 0.1.0 - 2026-09-23 ### Added diff --git a/README.md b/README.md index 0e29ab5..5d1e7f2 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,65 @@ # Hyprland HDMI Revive -Automatically recover a Wayland/Hyprland desktop after switching a physical monitor input away from an Arch Linux workstation and back again. +Recover a black HDMI display after switching a shared physical monitor away from +an Arch Linux / Hyprland workstation and back. A DRM hotplug event triggers a +short switch away from the active graphical VT and back. There is no periodic +DPMS toggle or polling timer. -This project was built for a setup where one external HDMI monitor is shared between: - -- an Arch Linux workstation running Hyprland/Wayland -- a Windows laptop connected to another HDMI input on the same monitor - -On some monitors/GPU combinations, switching back to the Arch input leaves the display black or showing `No source`, even though Hyprland still reports the monitor as enabled, focused, and DPMS-on. - -The workaround that reliably restores the image is a short VT switch away from the active Hyprland VT and back again. This repository automates that workaround only when the kernel reports a DRM hotplug event and only while an active Wayland session exists. - -## How it works - -```text -Monitor input switch - | - v -Kernel DRM HOTPLUG=1 event - | - v -User service receives the event - | - v -Is there an active Wayland session for this user? - | | - no yes - | | -ignore detect active VT - | - v - wait for HDMI sync - | - v - Hyprland DPMS/focus refresh - | - v - temporary VT switch - | - v - switch back to Hyprland -``` - -The important detail is that the recovery is **event-driven**. There is no polling timer and no periodic DPMS off/on cycle, so the screen does not blink during normal desktop use. - -## Repository layout - -```text -. -├── config/ -│ └── revive-monitor.env.example -├── docs/ -│ ├── architecture.md -│ └── troubleshooting.md -├── scripts/ -│ ├── install.sh -│ ├── revive-monitor-drm-watch.sh -│ ├── revive-monitor-fix.sh -│ └── uninstall.sh -├── sudoers/ -│ ├── revive-monitor-chvt.example -│ └── revive-monitor-chvt.strict.example -├── systemd/ -│ └── user/ -│ └── revive-monitor-drm-watch.service -├── .gitea/ -│ ├── issue_template/ -│ └── pull_request_template.md -├── .editorconfig -├── .gitignore -├── CHANGELOG.md -├── LICENSE -├── SECURITY.md -└── README.md -``` +**Version 0.2.0.** The initial implementation and the review fixes are separate +commits. See [the code review](docs/code-review.md) and [changelog](CHANGELOG.md). ## Requirements -- Arch Linux or another systemd-based Linux distribution -- Hyprland -- `systemd` / `systemd-logind` -- `udevadm` -- `hyprctl` -- `flock` from `util-linux` -- `chvt` from `kbd` -- `sudo` - -On Arch Linux, the relevant packages are typically already present. If needed: +A systemd-based Linux desktop with Hyprland, a local `seat0` Wayland session, +Bash, coreutils (`timeout`, `stdbuf`, `stat`), `jq`, `flock`, `sudo` and +`/usr/bin/chvt`. On Arch Linux: ```bash -sudo pacman -S --needed hyprland systemd util-linux kbd sudo +sudo pacman -S --needed hyprland systemd bash coreutils jq util-linux kbd sudo ``` -## 1. Clone the repository +The intended setup is one local Hyprland session per user. The monitor must emit +DRM `HOTPLUG=1` events when its physical input changes. No event means no automatic +recovery. Hyprland must still expose the configured monitor over its IPC socket. -Example for Gitea: +## Install from the package + +Download `hyprland-hdmi-revive-0.2.0.tar.gz` and `SHA256SUMS` from the +[Gitea release](https://gitea.jr-it-services.de/johannes/hyprland-hdmi-revive/releases). +Run as your desktop user, **without sudo**: ```bash -git clone ssh://git@gitea.example.com/your-user/hyprland-hdmi-revive.git +sha256sum --ignore-missing -c SHA256SUMS +tar -xzf hyprland-hdmi-revive-0.2.0.tar.gz +cd hyprland-hdmi-revive-0.2.0 +./scripts/install.sh +``` + +This is a portable user installer bundle, not a pacman system package. It installs: + +- executables under `~/.local/bin/`; +- shared code under `~/.local/lib/hyprland-hdmi-revive/`; +- the unit under `${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/`; +- a new config under `${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env`. + +Existing configuration is preserved. A running instance is stopped before its +files are replaced. Installation alone does not enable or start the service. + +Alternatively, clone the source and use the same installer: + +```bash +git clone https://gitea.jr-it-services.de/johannes/hyprland-hdmi-revive.git cd hyprland-hdmi-revive +./scripts/install.sh ``` -## 2. Configure the monitor +## Configure -Copy the example configuration: - -```bash -mkdir -p ~/.config/revive-monitor -cp config/revive-monitor.env.example ~/.config/revive-monitor/config.env -``` - -Determine the Hyprland output name: +Find the monitor name from a terminal inside Hyprland: ```bash hyprctl monitors -``` - -Example: - -```text -Monitor HDMI-A-2 (ID 0): -``` - -Then edit: - -```bash -nano ~/.config/revive-monitor/config.env +nano "${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env" ``` Example: @@ -138,204 +72,102 @@ VT_SWITCH_DELAY_SECONDS=1 VT_RETURN_SETTLE_SECONDS=2 ``` -## 3. Install the scripts and systemd user service +Timing values are whole seconds: 0–300 for waits and 0–3600 for debounce. Config is +trusted Bash sourced as your user; do not copy untrusted shell code into it. -Run: +### Allow the VT switch -```bash -./scripts/install.sh -``` +Edit with `sudo visudo -f /etc/sudoers.d/revive-monitor-chvt`. Prefer explicit VT +arguments when the machine always uses VT1 and VT2: -The installer copies the scripts to: - -```text -~/.local/bin/revive-monitor-fix -~/.local/bin/revive-monitor-drm-watch -``` - -and installs the user service at: - -```text -~/.config/systemd/user/revive-monitor-drm-watch.service -``` - -The service is intentionally attached to `default.target`, because not every Hyprland setup activates `graphical-session.target`. - -The watcher itself performs an additional safety check and refuses to switch VTs unless it finds an **active Wayland session for the current user**. This prevents DRM hotplug events during boot from switching the display manager away from its VT. - -## 4. Configure sudo safely - -`chvt` requires root privileges. Do **not** give the script unrestricted passwordless sudo. - -### Recommended general rule - -Open a dedicated sudoers file: - -```bash -sudo visudo -f /etc/sudoers.d/revive-monitor-chvt -``` - -Add: - -```text -YOUR_USER ALL=(root) NOPASSWD: /usr/bin/chvt -``` - -Replace `YOUR_USER` with your username. - -This grants passwordless sudo only for `/usr/bin/chvt`, not for arbitrary root commands. - -### More restrictive rule - -If your graphical session always uses VT1 and the temporary VT is always VT2, use: - -```text +```sudoers YOUR_USER ALL=(root) NOPASSWD: /usr/bin/chvt 1, /usr/bin/chvt 2 ``` -A template is included in: +Replace `YOUR_USER`. For changing graphical VTs, a broader chvt-only rule is in +`sudoers/revive-monitor-chvt.example`. Never grant sudo access to these scripts. +The helper checks permission for both directions before leaving the desktop and +uses `sudo -n` with command timeouts. A return failure gets one emergency retry. -```text -sudoers/revive-monitor-chvt.strict.example +### Connect the Hyprland session + +The user service manager does not automatically inherit a terminal's Hyprland +environment. Add this line to your Hyprland configuration (commonly +`~/.config/hypr/hyprland.conf`): + +```ini +exec-once = sh -c 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"; systemctl --user import-environment HYPRLAND_INSTANCE_SIGNATURE XDG_CONFIG_HOME && systemctl --user restart revive-monitor-drm-watch.service' ``` -The scripts use `sudo -n`, so they never block waiting for a password prompt. +It imports the current IPC instance at each Hyprland login, including after +logging out and back in. A custom `XDG_CONFIG_HOME` must also be consistent with +the user manager's unit search path. See the official +[hyprctl documentation](https://wiki.hypr.land/configuring/core/advanced-configuration/using-hyprctl/) +and [systemd environment documentation](https://www.freedesktop.org/software/systemd/man/latest/environment.d.html). -Test the rule manually: - -```bash -sudo -n /usr/bin/chvt 2 -sleep 1 -sudo -n /usr/bin/chvt 1 -``` - -## 5. Start the service - -The installer enables it automatically. To do it manually: - -```bash -systemctl --user daemon-reload -systemctl --user enable --now revive-monitor-drm-watch.service -``` - -Check status: +For the current login, after configuring the output and sudoers: ```bash +./scripts/install.sh --start systemctl --user status revive-monitor-drm-watch.service +journalctl --user -u revive-monitor-drm-watch.service -b ``` -Watch logs: +`--start` imports the current instance, reloads systemd, enables and restarts the +unit. Use it for updates as well. The service remains attached to `default.target` +because not every Hyprland setup activates `graphical-session.target`; session +and IPC guards prevent pre-login VT switching. -```bash -journalctl --user -u revive-monitor-drm-watch.service -f -``` +## Behavior and limits -## Why not xrandr? - -`xrandr` is an X11 tool. Under Hyprland/Wayland it is not the correct interface for controlling physical outputs and may only see Xwayland state. Typical symptoms include: - -```text -Authorization required, but no authorization protocol specified -Can't open display :0 -``` - -This project uses `hyprctl` for compositor-side state and DRM/udev events for the actual hotplug trigger. - -## Why not poll every 20 or 30 seconds? - -A polling approach can accidentally run disruptive operations while the desktop is already working. In early versions, a periodic DPMS toggle caused the monitor to go dark and come back every few seconds. - -This implementation does not use a timer. Recovery is triggered only by: - -```text -SUBSYSTEM=drm -HOTPLUG=1 -``` - -and only if an active Wayland session exists. - -## Why the active Wayland-session check matters - -During boot, GPUs and displays can generate DRM hotplug events before the display manager or Hyprland session is ready. - -Without a session guard, a recovery script that always runs: - -```bash -chvt 2 -chvt 1 -``` - -can leave the machine on tty1 while the display manager is actually running on tty2. - -The watcher therefore queries `systemd-logind` via `loginctl` and only runs recovery when it finds a session matching all of these conditions: - -```text -Name= -Type=wayland -Active=yes -VTNr>0 -``` - -The current graphical VT is detected dynamically and passed to the recovery script. No VT is hard-coded. - -## Example successful log - -```text -revive-monitor-drm-watch: DRM hotplug detected - Wayland session 3 on VT 1 -revive-monitor-fix: Recovery started for HDMI-A-2 on VT 1 -revive-monitor-fix: VT refresh: 2 -> 1 -sudo: COMMAND=/usr/bin/chvt 2 -sudo: COMMAND=/usr/bin/chvt 1 -revive-monitor-fix: Recovery completed for HDMI-A-2 -revive-monitor-drm-watch: Recovery after DRM hotplug finished -``` - -## Verify the boot-safety fix - -After rebooting, check: - -```bash -journalctl -b --no-pager | grep -E 'revive-monitor|COMMAND=/usr/bin/chvt|chvt' -``` - -Before logging into Hyprland, you should either see no recovery at all or a message similar to: - -```text -No active Wayland session - DRM hotplug ignored -``` - -There should be no `chvt` call before the graphical user session exists. +- Events are accepted only for this UID's active local Wayland session on seat0. + A reported non-Hyprland desktop is excluded. +- The helper independently verifies the requested VT and rechecks the session + after settling and permission checks. +- A private `XDG_RUNTIME_DIR` is required. Locks prevent duplicate watchers and + overlapping recoveries; there is no shared `/tmp` fallback. +- Missing IPC, invalid monitor names, command failures and lost event streams + produce error logs. A terminated event stream causes systemd to restart the watcher. +- Signals trigger a best-effort return to the graphical VT. SIGKILL, power loss + and persistent sudo/kernel failures cannot be recovered by a shell trap. +- DRM events are not reliably attributed to a particular monitor. Another + output's hotplug can trigger this workaround too. +- Debounce is a cooldown measured after a recovery attempt. It suppresses queued + events, including events caused by the VT switch. It can also suppress a real + rapid input return; if that happens, try a shorter `DEBOUNCE_SECONDS` or run + the helper manually with the correct graphical VT. ## Uninstall -Run: +From the unpacked bundle or checkout: ```bash ./scripts/uninstall.sh ``` -Then remove the sudoers file manually: +The user manager must be reachable. Config and sudoers remain intact. Remove the +Hyprland startup line and, if no longer needed, the dedicated sudoers entry with +`visudo`. Failure state for unrelated services is preserved. + +## Build and validate + +Python 3 is needed only for tests; ShellCheck is a development dependency. ```bash -sudo rm /etc/sudoers.d/revive-monitor-chvt +for script in scripts/*.sh lib/*.sh; do bash -n "$script" || exit; done +shellcheck -x scripts/*.sh lib/*.sh +python3 -B -m unittest discover -s tests -v +./scripts/build-release.sh ``` -The uninstall script intentionally does not remove sudoers configuration automatically. +The release builder requires clean tracked files and packages **committed HEAD**. +It creates a `.tar.gz` installer bundle, source ZIP, Base64 ZIP and `SHA256SUMS` in +`dist/`. Untracked files and local config are excluded. The Base64 ZIP can also +be decoded on Windows with `certutil -decode `. -## Troubleshooting +Tests mock loginctl, Hyprland IPC, udev, sudo and systemd. They never switch the +real display. A hardware input-switch test after installation remains necessary. +CI runs the shell checks and behavioral tests on a configured Gitea Actions runner. -See [docs/troubleshooting.md](docs/troubleshooting.md). - -## Security notes - -- The service runs as a normal systemd **user service**. -- Only `chvt` requires root privileges. -- `sudo -n` prevents unattended password prompts. -- A lock file prevents overlapping recovery runs. -- Debouncing suppresses repeated DRM hotplug events. -- The recovery runs only for an active Wayland session belonging to the current user. -- The sudoers rule should be limited to `/usr/bin/chvt`, or ideally only the specific VTs used on the machine. - -## License - -MIT. See [LICENSE](LICENSE). +See [architecture](docs/architecture.md), [troubleshooting](docs/troubleshooting.md) +and [security notes](SECURITY.md). MIT license: [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 81f3fcd..a4c0ee0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,8 +31,21 @@ This prevents the systemd user service from hanging on a password prompt. ## Boot safety -DRM hotplug events can occur during boot. The watcher therefore verifies that the current user has an active Wayland session with a valid VT before any recovery or VT switch is attempted. +DRM hotplug events can occur during boot. The watcher and helper verify the current UID, an active Wayland session on seat0, and a valid VT. The helper rechecks the session after waiting and immediately before switching. IPC must identify an available configured Hyprland output. This check is not atomic with the VT switch; use one local Hyprland session per user. ## Reporting security issues If this repository is published publicly, report security-sensitive findings privately through the repository owner's preferred security contact rather than opening a public issue containing exploitable details. + +## Runtime and configuration + +Runtime state requires a private, user-owned mode-0700 XDG_RUNTIME_DIR; there is +no shared /tmp fallback. Configuration is trusted Bash executed as the user. +Never run the installer or recovery scripts as root or allow untrusted users to +edit the scripts/config. Only chvt belongs in sudoers. + +## Best-effort recovery + +The helper checks both VT permissions and attempts to return on catchable signals +or failures. SIGKILL, power loss and persistent kernel/sudo failures can still +leave another VT active. The temporary VT can briefly expose another console. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/docs/architecture.md b/docs/architecture.md index 0fc7314..ac4af22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,81 +1,31 @@ # Architecture -## Problem +The workaround resets the display below the compositor by briefly leaving the +active graphical VT. It is intended for one local Hyprland session on `seat0`. -A monitor shared between multiple physical HDMI inputs can keep its EDID/output state visible to Linux even while the monitor is displaying another source. When switching back, Hyprland may report the output as completely healthy while the physical monitor remains black or reports no source. +1. A systemd user service runs `udevadm monitor --kernel --property + --subsystem-match=drm`. The watcher owns a per-user lock. +2. Each `HOTPLUG=1` line causes a loginctl lookup for the current UID, Wayland, + active state, seat0 and a valid VT. A known different desktop is excluded. +3. Events inside the cooldown are ignored. The clock is Bash's elapsed seconds; + timestamps are not read from shared files or affected by wall-clock changes. +4. The synchronous recovery helper locks, independently identifies the active + session, waits for HDMI settling, checks the Hyprland IPC output and refreshes + DPMS/focus. The instance signature comes from the Hyprland startup hook. +5. Before switching, the helper rechecks session identity and VT and checks sudo + permission for both directions. It rechecks session identity after those calls. +6. The helper sets an EXIT/signal trap, switches to VT2 (when graphical VT=1) or + VT1 (otherwise), and returns to the detected graphical VT. +7. Failed returns and catchable signals cause an emergency return attempt. Success + is logged only after the final DPMS/focus refresh succeeds. +8. The watcher logs the helper's actual exit status and starts the cooldown at + completion. Unexpected event-stream EOF always returns nonzero to systemd. -Typical Hyprland state in the broken condition: +Shared validation and session detection live in `lib/common.sh`. Runtime state +is restricted to a user-owned mode-0700 `XDG_RUNTIME_DIR/revive-monitor`. Root is +used only for `chvt`; user scripts are never invoked through sudo. -```text -focused: yes -dpmsStatus: 1 -disabled: false -active workspace: 1 -``` - -Commands such as `hyprctl reload`, `hyprctl dispatch dpms on`, and `hyprctl dispatch focusmonitor` may all return `ok` without restoring the image. - -## Discovery - -Listening to Hyprland's event socket did not provide a reliable monitor add/remove event for the physical input switch. - -The kernel DRM subsystem did, however, emit: - -```text -SUBSYSTEM=drm -HOTPLUG=1 -``` - -via: - -```bash -udevadm monitor --kernel --property --subsystem-match=drm -``` - -A manual VT switch restored the image, indicating that the required reset happens below the compositor level. - -## Final design - -The long-running watcher consumes kernel DRM events. For every `HOTPLUG=1` event it: - -1. checks `systemd-logind` for an active Wayland session belonging to the current user; -2. ignores the event if no such session exists; -3. dynamically determines that session's VT; -4. applies debounce protection; -5. calls the recovery script with the detected VT. - -The recovery script: - -1. uses `flock` to prevent overlap; -2. waits for the HDMI handshake to settle; -3. asks Hyprland to enable/focus the output; -4. uses `chvt` to move to another VT; -5. returns to the dynamically detected graphical VT; -6. refreshes Hyprland DPMS/focus once more. - -## Why the Wayland-session guard is critical - -GPU initialization during boot can generate DRM hotplug events. If the recovery script blindly executes a hard-coded `chvt 2` followed by `chvt 1`, the machine can end up showing tty1 even though the display manager is running on tty2. - -The current implementation prevents this by requiring: - -```text -Name= -Type=wayland -Active=yes -VTNr>0 -``` - -before performing any recovery. - -## Privilege boundary - -The watcher and recovery script run as the normal user. Only `chvt` needs elevated privileges. - -The recommended sudoers rule limits passwordless sudo to: - -```text -/usr/bin/chvt -``` - -The script always calls it through `sudo -n`, which fails instead of prompting for a password. +The session check is not atomic with the kernel VT switch. The temporary VT can +contain another console, and the workaround can briefly expose it. Multi-seat +setups, multiple concurrent desktops for one user and hardware that emits no DRM +event are outside the supported design. See README for cooldown tradeoffs. diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..e7df607 --- /dev/null +++ b/docs/code-review.md @@ -0,0 +1,44 @@ +# Code-Review – 23.09.2026 + +Geprüft wurde der ursprüngliche Stand `73e727e` (Version 0.1.0). Die folgenden +Befunde wurden für Version 0.2.0 behoben. Es gab noch keine vorhandene Git-Historie; +der gelieferte Stand wurde deshalb vor Änderungen als eigener Commit gesichert. + +| Priorität | Befund im Ausgangscode | Auswirkung | Korrektur | +| --- | --- | --- | --- | +| Hoch | `revive-monitor-fix.sh`: kein EXIT-/Signal-Trap nach dem ersten `chvt` | Abbruch oder gescheiterter Rückwechsel kann den Benutzer auf einer anderen Konsole lassen. | Rückkehr-Trap, Prüfung beider sudo-Berechtigungen, Zeitlimits und ein Notfall-Rückkehrversuch. | +| Hoch | Nur der Watcher prüft eine aktive Wayland-Sitzung, danach mehrere Sekunden Wartezeit | Bei Abmeldung oder Sitzungswechsel kann die Recovery eine inzwischen fremde VT aktivieren; direkte Helper-Aufrufe umgehen den Schutz. | Eigenständige Helper-Prüfung, UID/seat0/VT-Abgleich und erneute Prüfung unmittelbar vor dem Wechsel. | +| Hoch | systemd erhält keine Hyprland-Instanz; `hyprctl`-Fehler werden ignoriert | Der Dienst kann auf die falsche/keine Instanz zugreifen und trotzdem VTs wechseln bzw. Erfolg melden. | Dokumentierter Login-Hook, Umgebungsimport im Installer, IPC-/Monitorprüfung und propagierte Fehler. | +| Mittel | `/tmp/revive-monitor` als gemeinsamer Fallback; Fehler beim Lock-Öffnen ungeprüft | Benutzerübergreifende Kollisionen, Symlink-Ziele und unwirksame Sperren möglich. | Nur privates, eigenes Runtime-Verzeichnis mit Modus 0700; kein `/tmp`-Fallback; Lockfehler führen zum Abbruch. | +| Mittel | Watcher-Pipeline ohne `pipefail`, EOF ergibt reguläres Ende | `udevadm` kann ausfallen, ohne dass `Restart=on-failure` den Dienst neu startet. | Pipeline-Status erfassen; jedes unerwartete Stream-Ende endet mit Fehlerstatus. | +| Mittel | Watcher meldet nach jedem Helper-Aufruf Erfolg; Zeit-/Monitorwerte ungeprüft | Diagnose verschleiert echte Fehler; fehlerhafte Einstellungen können Schutz- und Wartefunktionen beeinträchtigen. | Statusabhängige Logs, begrenzte Integer-Werte, erlaubte Output-Namen. | +| Mittel | Installer startet sofort; `enable --now` startet aktive Dienste bei Updates nicht neu; uneinheitliches XDG; globales `reset-failed` im Deinstaller | Aktivierung vor Konfiguration, alter Prozess trotz neuer Dateien, falscher Unit-Pfad, Löschen fremder Fehlerzustände. | Explizites `--start`, Stop vor Dateiaustausch, Restart bei Aktivierung, konsistente XDG-Pfade, Root-Verweigerung und gezieltes Zurücksetzen. | + +## Validierung + +- 25 bestandene Verhaltenstests mit echten Bash-Prozessen und simulierten loginctl-, hyprctl-, + udev-, sudo- und systemd-Befehlen; keine realen VT-Wechsel. +- Abgedeckt: Erfolg, Vor-Login-Schutz, fremde Benutzer/Seats/Desktops, falsche VT, + Sitzungswechsel während der Wartezeit, IPC-/Output-Fehler, fehlende Rechte, + Rückkehrfehler und Signalbehandlung, Runtime-/Symlink-Schutz, Konfiguration, + parallele Recovery, Hotplug-Bursts, Stream-Ende und Fehlerprotokollierung. +- Installation, wiederholte Installation, Update-Aktivierung und Deinstallation + unter temporärem HOME/XDG; vorhandene Konfiguration bleibt erhalten. +- Bash-Syntax und ShellCheck 0.11.0 ohne Befunde; statische systemd-Prüfung mit auf das Checkout + umgebogenem ExecStart, ohne Installation des Dienstes auf diesem Desktop. +- Release aus committed HEAD; Archivinhalt, Prüfsummen und entpackter Installer + werden separat geprüft. + +## Verbleibende Grenzen + +Ein Shell-Trap hilft nicht gegen SIGKILL, Stromausfall oder dauerhafte Kernel-/sudo- +Fehler. Prüfung und VT-Wechsel sind nicht atomar. Die temporäre VT kann einen +anderen Konsoleninhalt zeigen. Unterstützt ist eine lokale Hyprland-Sitzung auf +seat0; mehrere gleichzeitige Desktops desselben Benutzers sind nicht abgedeckt. + +DRM-Hotplug identifiziert nicht zuverlässig den betroffenen Monitor. Der Cooldown +kann einen echten schnellen Rückwechsel unterdrücken. Das bleibt eine bewusste +Einschränkung dieses hardwareabhängigen Workarounds und ist in README beschrieben. + +Ein realer Test von HDMI-Eingangswechsel, Ab-/Anmeldung und Boot-Verhalten wurde +in diesem Review nicht ausgeführt. Er bleibt nach Installation erforderlich. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f1fe16a..0c4e0f1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,139 +1,89 @@ # Troubleshooting -## Check the Hyprland output name - -```bash -hyprctl monitors -``` - -Update: - -```text -~/.config/revive-monitor/config.env -``` - -if the output is not `HDMI-A-2`. - -## Check the user service +## Status and logs ```bash systemctl --user status revive-monitor-drm-watch.service +journalctl --user -u revive-monitor-drm-watch.service -b -f ``` -## Follow logs +Both scripts write to stderr; systemd captures their messages in the unit journal. +Direct manual runs write to the terminal. There is no separate logger/syslog tag. -```bash -journalctl --user -u revive-monitor-drm-watch.service -f -``` +## Missing Hyprland instance / IPC failure -For the recovery script tag: +From a Hyprland terminal, run `./scripts/install.sh --start` and add the startup +hook from README so each new login updates `HYPRLAND_INSTANCE_SIGNATURE`. +`hyprctl monitors` must list the output configured in `config.env`. A stale +signature, failed IPC call or absent output aborts recovery without switching VTs. -```bash -journalctl --user -t revive-monitor-fix -f -``` +## No recovery after switching inputs -## Verify DRM hotplug events manually +Observe events without starting another watcher: ```bash udevadm monitor --kernel --property --subsystem-match=drm ``` -Switch the physical monitor input away and back. Look for: - -```text -HOTPLUG=1 -``` - -If no DRM hotplug event is emitted, this project has no reliable event to trigger from. - -## Verify the active Wayland session +Look for `HOTPLUG=1`. Then inspect the session: ```bash loginctl list-sessions +loginctl show-session SESSION_ID -p User -p Type -p Active -p Seat -p VTNr -p Desktop ``` -Then: +Expected: your numeric UID, `Type=wayland`, `Active=yes`, `Seat=seat0`, a VT from +1 to 63, and either a Hyprland desktop or an empty Desktop property. The helper +also checks these properties when invoked manually. + +## Invalid runtime directory + +`XDG_RUNTIME_DIR` must be your login-managed, user-owned directory with mode 0700 +(normally `/run/user/UID`). Do not set it to `/tmp` or run the service with sudo. +The systemd unit sets it from `%t`. + +## sudo / VT return failures + +Use `visudo` to verify the explicit VT permissions. Check authorization without +switching the display (replace the VTs to match your session): ```bash -for s in $(loginctl list-sessions --no-legend | awk '{print $1}'); do - echo "=== Session $s ===" - loginctl show-session "$s" -p Name -p Type -p Active -p VTNr -done +sudo -n -l /usr/bin/chvt 2 +sudo -n -l /usr/bin/chvt 1 ``` -A normal Hyprland session should look similar to: +A denied return permission aborts before the outward switch. Actual chvt errors +are still possible; a return failure is retried once and logged. A failed emergency +return requires manually returning to the graphical VT, e.g. Ctrl+Alt+F1 for VT1. -```text -Name=your-user -VTNr=1 -Type=wayland -Active=yes -``` +## Debounce hides a rapid return / repeated recoveries -## Display manager appears only after manually switching TTY +`DEBOUNCE_SECONDS` is a cooldown after each attempt, not an event queue. A genuine +rapid return can be ignored along with duplicate and self-generated events. +Reduce the interval if needed; increasing it can help noisy hardware. Setting it +to zero disables cooldown and can cause a recovery loop on some GPUs. -Check the boot journal: +## Manual recovery + +From a terminal inside the active Hyprland session, pass its actual VT number: ```bash -journalctl -b --no-pager | grep -E 'revive-monitor|COMMAND=/usr/bin/chvt|chvt' +~/.local/bin/revive-monitor-fix 1 ``` -If `chvt` runs before login, verify that you installed the current watcher script. It must ignore hotplug events when there is no active Wayland session. +This will visibly switch VTs. If the monitor needs more handshake time, increase +`HDMI_SETTLE_SECONDS` (default 5). Manual recovery still needs valid IPC, the +configured output, a private runtime directory and sudo permission. -Expected pre-login behavior: +## Updating / uninstalling -```text -No active Wayland session - DRM hotplug ignored -``` +Updates preserve `config.env`. Run `./scripts/install.sh --start` inside Hyprland +to restart with the new scripts. Plain `install.sh` leaves the service stopped. +For uninstall, the systemd user manager must be reachable. Remove the startup +hook manually as documented in README. -## sudo / chvt fails +## Hardware acceptance -Test non-interactively: - -```bash -sudo -n /usr/bin/chvt 2 -``` - -If sudo asks for a password or exits with an error, verify: - -```text -/etc/sudoers.d/revive-monitor-chvt -``` - -Always edit sudoers files with `visudo`. - -## Recovery runs multiple times - -Increase: - -```bash -DEBOUNCE_SECONDS=30 -``` - -in: - -```text -~/.config/revive-monitor/config.env -``` - -For noisy hardware, `60` may be more appropriate. - -## Recovery runs but the monitor still stays black - -Increase: - -```bash -HDMI_SETTLE_SECONDS=5 -``` - -to `7` or `10` and test again. - -Also manually verify that a VT switch actually restores the image: - -```bash -sudo chvt 2 -sleep 1 -sudo chvt 1 -``` - -Adjust the numbers to match the machine's graphical VT. +After installation, check a normal input change, a rapid away/back change and a +logout/login cycle. Before Hyprland login there must be no chvt invocation from +this service. Automated tests use mocks and do not replace this hardware check. diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..0e05e8b --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Shared by the watcher and recovery helper. Config is trusted user-owned Bash. + +log() { + printf '%s: %s\n' "$LOGTAG" "$*" >&2 +} + +# These settings are consumed by scripts sourcing this library and via ${!name}. +# shellcheck disable=SC2034 +load_config() { + MONITOR=HDMI-A-2 + DEBOUNCE_SECONDS=30 + HDMI_SETTLE_SECONDS=5 + VT_SWITCH_DELAY_SECONDS=1 + VT_RETURN_SETTLE_SECONDS=2 + local config="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env" + if [[ -r "$config" ]]; then + # shellcheck disable=SC1090 + source "$config" || return 1 + fi + if [[ ! "$MONITOR" =~ ^[a-zA-Z0-9_-]+$ ]]; then + log 'Invalid MONITOR: expected an output name such as HDMI-A-2' + return 1 + fi + local name value limit + for name in DEBOUNCE_SECONDS HDMI_SETTLE_SECONDS VT_SWITCH_DELAY_SECONDS VT_RETURN_SETTLE_SECONDS; do + value="${!name}" + limit=300 + [[ "$name" == DEBOUNCE_SECONDS ]] && limit=3600 + if [[ ! "$value" =~ ^[0-9]{1,4}$ ]] || (( 10#$value > limit )); then + log "Invalid $name: expected an integer between 0 and $limit" + return 1 + fi + printf -v "$name" '%d' "$((10#$value))" + done +} + +require_commands() { + local command_name + for command_name in "$@"; do + if ! command -v "$command_name" >/dev/null 2>&1; then + log "Required command not found: $command_name" + return 1 + fi + done +} + +prepare_runtime() { + # No shared /tmp fallback: locks must live in a private per-user directory. + local runtime="${XDG_RUNTIME_DIR:-}" + if [[ "$runtime" != /* || ! -d "$runtime" || -L "$runtime" || ! -O "$runtime" ]] || + [[ "$(stat -c %a -- "$runtime" 2>/dev/null)" != 700 ]]; then + log 'XDG_RUNTIME_DIR must be an existing, user-owned directory with mode 0700' + return 1 + fi + STATE_DIR="$runtime/revive-monitor" + if [[ -L "$STATE_DIR" ]]; then + log 'Refusing a symlink as runtime state directory' + return 1 + fi + (umask 077; mkdir -p -- "$STATE_DIR") || return 1 + if [[ ! -O "$STATE_DIR" || "$(stat -c %a -- "$STATE_DIR")" != 700 ]]; then + log 'Runtime state directory must be user-owned with mode 0700' + return 1 + fi +} + +get_active_wayland_session() { + local sessions sid rest properties key value uid type active vt seat desktop + sessions="$(timeout 5 loginctl list-sessions --no-legend --no-pager 2>/dev/null)" || return 1 + while read -r sid rest; do + [[ -n "$sid" ]] || continue + properties="$(timeout 5 loginctl show-session "$sid" -p User -p Type -p Active -p VTNr -p Seat -p Desktop 2>/dev/null)" || continue + uid='' type='' active='' vt='' seat='' desktop='' + while IFS='=' read -r key value; do + case "$key" in + User) uid="$value" ;; + Type) type="$value" ;; + Active) active="$value" ;; + VTNr) vt="$value" ;; + Seat) seat="$value" ;; + Desktop) desktop="${value,,}" ;; + esac + done <<< "$properties" + [[ "$uid" == "$UID" && "$type" == wayland && "$active" == yes && "$seat" == seat0 ]] || continue + [[ -z "$desktop" || "$desktop" == *hyprland* ]] || continue + if [[ "$vt" =~ ^[1-9][0-9]?$ ]] && (( vt <= 63 )); then + printf '%s:%s\n' "$sid" "$vt" + return 0 + fi + done <<< "$sessions" + return 1 +} diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..17d70bd --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Build only committed sources; generated artifacts never include local secrets. +set -euo pipefail +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +if ! git diff --quiet || ! git diff --cached --quiet; then + printf 'Commit tracked changes before building a release.\n' >&2 + exit 1 +fi +VERSION="$(git show HEAD:VERSION)" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { printf 'Invalid VERSION\n' >&2; exit 1; } +NAME="hyprland-hdmi-revive-$VERSION" +mkdir -p dist +git archive --format=tar --prefix="$NAME/" HEAD | gzip -n > "dist/$NAME.tar.gz" +git archive --format=zip --prefix="$NAME/" HEAD > "dist/$NAME.zip" +base64 -w 64 "dist/$NAME.zip" > "dist/$NAME.zip.b64" +( + cd dist + sha256sum "$NAME.tar.gz" "$NAME.zip" "$NAME.zip.b64" > SHA256SUMS + sha256sum -c SHA256SUMS +) +printf 'Built %s from commit %s\n' "$NAME" "$(git rev-parse HEAD)" diff --git a/scripts/install.sh b/scripts/install.sh index 261239c..1d20417 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,35 +1,72 @@ #!/usr/bin/env bash set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +START_SERVICE=0 +case "${1:-}" in + '') ;; + --start) START_SERVICE=1 ;; + --help) printf 'Usage: %s [--start]\nInstall for the current user; --start also enables and restarts the service.\n' "$0"; exit 0 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; exit 2 ;; +esac +(( $# <= 1 )) || { printf 'Too many arguments\n' >&2; exit 2; } +if (( EUID == 0 )); then + printf 'Run this installer as the desktop user, without sudo.\n' >&2 + exit 1 +fi +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" BIN_DIR="$HOME/.local/bin" -SYSTEMD_DIR="$HOME/.config/systemd/user" -CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor" - -mkdir -p "$BIN_DIR" "$SYSTEMD_DIR" "$CONFIG_DIR" +LIB_DIR="$HOME/.local/lib/hyprland-hdmi-revive" +CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +[[ "$CONFIG_HOME" == /* ]] || { printf 'XDG_CONFIG_HOME must be absolute\n' >&2; exit 1; } +SYSTEMD_DIR="$CONFIG_HOME/systemd/user" +CONFIG_DIR="$CONFIG_HOME/revive-monitor" +LOGTAG=revive-monitor-install +# shellcheck source=lib/common.sh +source "$REPO_ROOT/lib/common.sh" +require_commands systemctl udevadm stdbuf timeout loginctl hyprctl jq flock sudo stat install || exit 1 +[[ -x /usr/bin/chvt ]] || { printf 'Missing /usr/bin/chvt (Arch package: kbd)\n' >&2; exit 1; } +# Validate existing config before overwriting any installed files. +load_config || exit 1 +if (( START_SERVICE )); then + [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]] || { + printf 'Use --start from a terminal inside Hyprland.\n' >&2; exit 1; + } +fi +# Avoid replacing Bash files while a running watcher/recovery is reading them. +if systemctl --user is-active --quiet revive-monitor-drm-watch.service; then + systemctl --user stop revive-monitor-drm-watch.service +fi +mkdir -p -- "$BIN_DIR" "$LIB_DIR" "$SYSTEMD_DIR" "$CONFIG_DIR" install -m 0755 "$REPO_ROOT/scripts/revive-monitor-fix.sh" "$BIN_DIR/revive-monitor-fix" install -m 0755 "$REPO_ROOT/scripts/revive-monitor-drm-watch.sh" "$BIN_DIR/revive-monitor-drm-watch" +install -m 0644 "$REPO_ROOT/lib/common.sh" "$LIB_DIR/common.sh" install -m 0644 "$REPO_ROOT/systemd/user/revive-monitor-drm-watch.service" "$SYSTEMD_DIR/revive-monitor-drm-watch.service" - if [[ ! -e "$CONFIG_DIR/config.env" ]]; then - install -m 0644 "$REPO_ROOT/config/revive-monitor.env.example" "$CONFIG_DIR/config.env" + install -m 0600 "$REPO_ROOT/config/revive-monitor.env.example" "$CONFIG_DIR/config.env" printf 'Created %s\n' "$CONFIG_DIR/config.env" - printf 'Edit MONITOR before using the service if your output is not HDMI-A-2.\n' else printf 'Keeping existing config: %s\n' "$CONFIG_DIR/config.env" fi -systemctl --user daemon-reload -systemctl --user enable --now revive-monitor-drm-watch.service - +if (( START_SERVICE )); then + systemctl --user import-environment HYPRLAND_INSTANCE_SIGNATURE + # Keep the service and installer on the same configuration root. + export XDG_CONFIG_HOME="$CONFIG_HOME" + systemctl --user import-environment XDG_CONFIG_HOME + systemctl --user daemon-reload + systemctl --user enable revive-monitor-drm-watch.service + systemctl --user restart revive-monitor-drm-watch.service + systemctl --user is-active --quiet revive-monitor-drm-watch.service +else + printf 'Files installed. Activation is explicit; follow README.md before starting.\n' +fi cat <<'MSG' -Installed successfully. - Next steps: - 1. Verify ~/.config/revive-monitor/config.env - 2. Configure /etc/sudoers.d/revive-monitor-chvt using one of the templates in sudoers/ - 3. Test with: systemctl --user status revive-monitor-drm-watch.service - 4. Watch logs: journalctl --user -u revive-monitor-drm-watch.service -f + 1. Set MONITOR in ${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env + 2. Configure chvt permission with visudo using the sudoers/ templates. + 3. Add the Hyprland environment/start hook documented in README.md. + 4. Run ./scripts/install.sh --start from Hyprland to activate or update. + 5. Check: journalctl --user -u revive-monitor-drm-watch.service -b MSG diff --git a/scripts/revive-monitor-drm-watch.sh b/scripts/revive-monitor-drm-watch.sh index a92e03d..485ce9a 100755 --- a/scripts/revive-monitor-drm-watch.sh +++ b/scripts/revive-monitor-drm-watch.sh @@ -1,115 +1,64 @@ #!/usr/bin/env bash -set -u +set -uo pipefail -LOGTAG="revive-monitor-drm-watch" -CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env" -FIX_SCRIPT="${REVIVE_MONITOR_FIX_SCRIPT:-$HOME/.local/bin/revive-monitor-fix}" - -# Defaults. Override in ~/.config/revive-monitor/config.env -DEBOUNCE_SECONDS="30" - -if [[ -r "$CONFIG_FILE" ]]; then - # shellcheck disable=SC1090 - source "$CONFIG_FILE" +LOGTAG=revive-monitor-drm-watch +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +COMMON="$SCRIPT_DIR/../lib/hyprland-hdmi-revive/common.sh" +[[ -f "$COMMON" ]] || COMMON="$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=lib/common.sh +source "$COMMON" || exit 1 +load_config || exit 1 +require_commands udevadm stdbuf timeout loginctl flock stat || exit 1 +prepare_runtime || exit 1 +if [[ -n "${REVIVE_MONITOR_FIX_SCRIPT:-}" ]]; then + FIX_SCRIPT="$REVIVE_MONITOR_FIX_SCRIPT" +else + FIX_SCRIPT="$SCRIPT_DIR/revive-monitor-fix" + [[ -e "$FIX_SCRIPT" ]] || FIX_SCRIPT="$SCRIPT_DIR/revive-monitor-fix.sh" fi - -STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/revive-monitor" -LAST_RUN_FILE="$STATE_DIR/last-drm-hotplug-run" - -mkdir -p "$STATE_DIR" - -log() { - logger -t "$LOGTAG" -- "$*" -} - -get_active_wayland_session() { - local sid - local name - local type - local active - local vt - - while read -r sid; do - [[ -n "$sid" ]] || continue - - name="$(loginctl show-session "$sid" -p Name --value 2>/dev/null)" - [[ "$name" == "$USER" ]] || continue - - type="$(loginctl show-session "$sid" -p Type --value 2>/dev/null)" - [[ "$type" == "wayland" ]] || continue - - active="$(loginctl show-session "$sid" -p Active --value 2>/dev/null)" - [[ "$active" == "yes" ]] || continue - - vt="$(loginctl show-session "$sid" -p VTNr --value 2>/dev/null)" - - if [[ "$vt" =~ ^[1-9][0-9]*$ ]]; then - printf '%s:%s\n' "$sid" "$vt" - return 0 - fi - done < <( - loginctl list-sessions --no-legend --no-pager 2>/dev/null | awk '{print $1}' - ) - - return 1 -} - -run_recovery() { - local session_info - local session_id - local graphical_vt - local now - local last="0" - local diff - - if ! session_info="$(get_active_wayland_session)"; then - log "No active Wayland session - DRM hotplug ignored" - return 0 - fi - - session_id="${session_info%%:*}" - graphical_vt="${session_info##*:}" - - now="$(date +%s)" - - if [[ -r "$LAST_RUN_FILE" ]]; then - last="$(cat "$LAST_RUN_FILE" 2>/dev/null || printf '0')" - fi - - [[ "$last" =~ ^[0-9]+$ ]] || last="0" - diff=$((now - last)) - - if (( diff < DEBOUNCE_SECONDS )); then - log "DRM hotplug ignored because of debounce (${diff}s)" - return 0 - fi - - printf '%s\n' "$now" > "$LAST_RUN_FILE" - - log "DRM hotplug detected - Wayland session $session_id on VT $graphical_vt" - - "$FIX_SCRIPT" "$graphical_vt" - - log "Recovery after DRM hotplug finished" -} - -if ! command -v udevadm >/dev/null 2>&1; then - log "udevadm not found" - exit 1 -fi - if [[ ! -x "$FIX_SCRIPT" ]]; then log "Recovery script is not executable: $FIX_SCRIPT" exit 1 fi +exec 8>"$STATE_DIR/watcher.lock" || exit 1 +if ! flock -n 8; then + log 'Another watcher is running or the watcher lock is unavailable' + exit 1 +fi -log "Listening for DRM hotplug events" +run_recovery() { + local session_info now status + session_info="$(get_active_wayland_session)" || { + log 'No active Wayland session - DRM hotplug ignored' + return 0 + } + now=$SECONDS + if (( last_run >= 0 && now - last_run < DEBOUNCE_SECONDS )); then + log 'DRM hotplug ignored because of debounce' + return 0 + fi + log "DRM hotplug detected - Wayland session ${session_info%%:*} on VT ${session_info##*:}" + "$FIX_SCRIPT" "${session_info##*:}" + status=$? + # Measure from completion, so events queued during our own VT refresh are suppressed. + last_run=$SECONDS + if (( status != 0 )); then + log "Recovery after DRM hotplug failed (exit $status)" + else + log 'Recovery helper finished; see its log for completion or skip reason' + fi +} +log 'Listening for DRM hotplug events' +# pipefail propagates a failed producer. EOF (even exit 0) is unexpected for +# this long-running service and must also trigger Restart=on-failure. stdbuf -oL udevadm monitor --kernel --property --subsystem-match=drm | while IFS= read -r line; do + last_run=${last_run:--1} case "$line" in - HOTPLUG=1) - run_recovery - ;; + HOTPLUG=1) run_recovery ;; esac done +status=$? +log "DRM event stream ended (exit $status) - requesting service restart" +exit 1 diff --git a/scripts/revive-monitor-fix.sh b/scripts/revive-monitor-fix.sh index 7b36a0b..45d6bf8 100755 --- a/scripts/revive-monitor-fix.sh +++ b/scripts/revive-monitor-fix.sh @@ -1,77 +1,118 @@ #!/usr/bin/env bash -set -u +set -uo pipefail -LOGTAG="revive-monitor-fix" -CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/revive-monitor/config.env" - -# Defaults. Override in ~/.config/revive-monitor/config.env -MONITOR="HDMI-A-2" -HDMI_SETTLE_SECONDS="5" -VT_SWITCH_DELAY_SECONDS="1" -VT_RETURN_SETTLE_SECONDS="2" - -if [[ -r "$CONFIG_FILE" ]]; then - # shellcheck disable=SC1090 - source "$CONFIG_FILE" -fi +LOGTAG=revive-monitor-fix +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# Source-tree and installed layouts. +COMMON="$SCRIPT_DIR/../lib/hyprland-hdmi-revive/common.sh" +[[ -f "$COMMON" ]] || COMMON="$SCRIPT_DIR/../lib/common.sh" +# shellcheck source=lib/common.sh +source "$COMMON" || exit 1 +load_config || exit 1 +require_commands timeout loginctl hyprctl jq sudo flock stat sleep || exit 1 +prepare_runtime || exit 1 GRAPHICAL_VT="${1:-}" -STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}/revive-monitor" -LOCK_FILE="$STATE_DIR/fix.lock" +if [[ ! "$GRAPHICAL_VT" =~ ^[1-9][0-9]?$ ]] || (( GRAPHICAL_VT > 63 )); then + log 'No valid graphical VT supplied (expected 1..63)' + exit 1 +fi -mkdir -p "$STATE_DIR" +exec 9>"$STATE_DIR/fix.lock" || exit 1 +flock -n -E 75 9 +lock_status=$? +if (( lock_status == 75 )); then + log 'Recovery already running - skipping' + exit 0 +elif (( lock_status != 0 )); then + log 'Cannot acquire recovery lock' + exit 1 +fi -log() { - logger -t "$LOGTAG" -- "$*" +SESSION="$(get_active_wayland_session)" || { log 'No active Wayland session - recovery skipped'; exit 0; } +if [[ "${SESSION##*:}" != "$GRAPHICAL_VT" ]]; then + log 'Requested VT does not belong to the active Wayland session' + exit 1 +fi +if [[ -z "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then + log 'Missing HYPRLAND_INSTANCE_SIGNATURE; import the Hyprland environment into systemd --user' + exit 1 +fi + +monitor_present() { + timeout 5 hyprctl -j monitors | + jq -e --arg name "$MONITOR" 'any(.[]; .name == $name)' >/dev/null 2>&1 } -run_quiet() { - "$@" >/dev/null 2>&1 +refresh_monitor() { + timeout 5 hyprctl dispatch dpms on "$MONITOR" >/dev/null 2>&1 && + timeout 5 hyprctl dispatch focusmonitor "$MONITOR" >/dev/null 2>&1 } -if ! [[ "$GRAPHICAL_VT" =~ ^[1-9][0-9]*$ ]]; then - log "No valid graphical VT supplied - recovery aborted" - exit 0 -fi - -if [[ "$GRAPHICAL_VT" -eq 1 ]]; then - TEMP_VT=2 -else - TEMP_VT=1 -fi - -exec 9>"$LOCK_FILE" -if ! flock -n 9; then - log "Recovery already running - skipping" - exit 0 -fi - log "Recovery started for $MONITOR on VT $GRAPHICAL_VT" +sleep "$HDMI_SETTLE_SECONDS" || exit 1 +if ! monitor_present; then + log "Hyprland IPC unavailable or configured output $MONITOR absent - recovery aborted" + exit 1 +fi +if ! refresh_monitor; then + log 'Hyprland refresh failed - recovery aborted' + exit 1 +fi -sleep "$HDMI_SETTLE_SECONDS" +# Recheck after settling/IPC, immediately before the privileged switch. +if [[ "$(get_active_wayland_session)" != "$SESSION" ]]; then + log 'Active session changed while settling - recovery skipped' + exit 0 +fi +TEMP_VT=1 +[[ "$GRAPHICAL_VT" == 1 ]] && TEMP_VT=2 +# Check both directions before leaving the desktop (actual sudo can still fail). +for vt in "$TEMP_VT" "$GRAPHICAL_VT"; do + if ! timeout 5 sudo -n -l /usr/bin/chvt "$vt" >/dev/null 2>&1; then + log "sudo permission check failed for VT $vt - recovery aborted" + exit 1 + fi +done -run_quiet hyprctl dispatch dpms on "$MONITOR" -run_quiet hyprctl dispatch focusmonitor "$MONITOR" - -sleep 1 +if [[ "$(get_active_wayland_session)" != "$SESSION" ]]; then + log 'Active session changed during permission checks - recovery skipped' + exit 0 +fi +NEEDS_RETURN=0 +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + if (( NEEDS_RETURN )); then + log "Attempting emergency return to graphical VT $GRAPHICAL_VT" + if ! timeout 5 sudo -n /usr/bin/chvt "$GRAPHICAL_VT"; then + log "Emergency return failed; manually switch to VT $GRAPHICAL_VT" + status=1 + fi + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM log "VT refresh: $TEMP_VT -> $GRAPHICAL_VT" - -if ! sudo -n /usr/bin/chvt "$TEMP_VT"; then +# Set before chvt to cover a signal arriving as the command completes. +NEEDS_RETURN=1 +if ! timeout 5 sudo -n /usr/bin/chvt "$TEMP_VT"; then log "Failed to switch to temporary VT $TEMP_VT" exit 1 fi - -sleep "$VT_SWITCH_DELAY_SECONDS" - -if ! sudo -n /usr/bin/chvt "$GRAPHICAL_VT"; then +sleep "$VT_SWITCH_DELAY_SECONDS" || exit 1 +if ! timeout 5 sudo -n /usr/bin/chvt "$GRAPHICAL_VT"; then log "Failed to return to graphical VT $GRAPHICAL_VT" exit 1 fi - -sleep "$VT_RETURN_SETTLE_SECONDS" - -run_quiet hyprctl dispatch dpms on "$MONITOR" -run_quiet hyprctl dispatch focusmonitor "$MONITOR" - +NEEDS_RETURN=0 +sleep "$VT_RETURN_SETTLE_SECONDS" || exit 1 +if ! refresh_monitor; then + log 'Final Hyprland refresh failed' + exit 1 +fi log "Recovery completed for $MONITOR" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 47b3f70..0552c34 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -1,24 +1,28 @@ #!/usr/bin/env bash set -euo pipefail +if (( EUID == 0 )); then + printf 'Run as the desktop user, without sudo.\n' >&2 + exit 1 +fi BIN_DIR="$HOME/.local/bin" -SYSTEMD_DIR="$HOME/.config/systemd/user" - -systemctl --user disable --now revive-monitor-drm-watch.service 2>/dev/null || true - -rm -f "$SYSTEMD_DIR/revive-monitor-drm-watch.service" -rm -f "$BIN_DIR/revive-monitor-fix" -rm -f "$BIN_DIR/revive-monitor-drm-watch" +SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" +LIB_DIR="$HOME/.local/lib/hyprland-hdmi-revive" +SERVICE=revive-monitor-drm-watch.service +[[ "$SYSTEMD_DIR" == /* ]] || { printf 'XDG_CONFIG_HOME must be absolute\n' >&2; exit 1; } +systemctl --user show-environment >/dev/null +# Stop must succeed before removing a running service's files. +if systemctl --user cat "$SERVICE" >/dev/null 2>&1; then + systemctl --user disable --now "$SERVICE" +fi +rm -f -- "$SYSTEMD_DIR/$SERVICE" "$BIN_DIR/revive-monitor-fix" "$BIN_DIR/revive-monitor-drm-watch" "$LIB_DIR/common.sh" +rmdir -- "$LIB_DIR" 2>/dev/null || true systemctl --user daemon-reload -systemctl --user reset-failed - +# Never reset failure information for unrelated user services. +systemctl --user reset-failed "$SERVICE" 2>/dev/null || true cat <<'MSG' Removed the user service and installed scripts. - -Not removed automatically: - ~/.config/revive-monitor/config.env - /etc/sudoers.d/revive-monitor-chvt - -Remove those manually if you no longer need them. +Preserved config.env and /etc/sudoers.d/revive-monitor-chvt. +Remove the Hyprland startup hook and sudoers entry manually if no longer needed. MSG diff --git a/systemd/user/revive-monitor-drm-watch.service b/systemd/user/revive-monitor-drm-watch.service index 801974f..df2fd5d 100644 --- a/systemd/user/revive-monitor-drm-watch.service +++ b/systemd/user/revive-monitor-drm-watch.service @@ -6,6 +6,7 @@ Type=simple ExecStart=%h/.local/bin/revive-monitor-drm-watch Restart=on-failure RestartSec=2 +TimeoutStopSec=15 Environment=XDG_RUNTIME_DIR=%t [Install] diff --git a/tests/test_recovery.py b/tests/test_recovery.py new file mode 100644 index 0000000..0d3ac42 --- /dev/null +++ b/tests/test_recovery.py @@ -0,0 +1,278 @@ +"""Behavior tests using fake IPC, udev, sudo and systemd; never switch real VTs.""" +import fcntl +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +MOCK = r'''#!/usr/bin/env python3 +import json, os, pathlib, signal, sys, time +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +state = pathlib.Path(os.environ['TEST_STATE']) +with (state / 'calls').open('a') as f: + f.write(json.dumps([name] + args) + '\n') +if name == 'loginctl': + if args[0] == 'list-sessions': + if not os.environ.get('NO_SESSION'): + print('7 1000 desktop seat0 tty1') + else: + active = 'no' if (state / 'inactive').exists() else 'yes' + print(f"User={os.environ.get('SESSION_UID', os.getuid())}\nType={os.environ.get('SESSION_TYPE', 'wayland')}\nActive={active}\nVTNr={os.environ.get('SESSION_VT', '1')}\nSeat={os.environ.get('SESSION_SEAT', 'seat0')}\nDesktop={os.environ.get('SESSION_DESKTOP', 'Hyprland')}") +elif name == 'hyprctl': + if os.environ.get('HYPR_FAIL'): + sys.exit(1) + if args == ['-j', 'monitors']: + if os.environ.get('BAD_JSON'): + print('invalid') + else: + print(json.dumps([{'name': os.environ.get('OUTPUT', 'HDMI-A-2')}])) + else: + print('ok') +elif name == 'sleep': + if os.environ.get('CHANGE_SESSION'): + (state / 'inactive').touch() + if os.environ.get('SIGNAL_AFTER_SWITCH') and (state / 'away').exists(): + os.kill(os.getppid(), signal.SIGTERM) + if os.environ.get('BLOCK_AFTER_SWITCH') and (state / 'away').exists(): + (state / 'sleeping').touch() + time.sleep(30) +elif name == 'sudo': + vt = args[-1] + if '-l' in args: + if os.environ.get('CHANGE_DURING_PERMISSION_CHECK'): + (state / 'inactive').touch() + sys.exit(1 if os.environ.get('DENY_RETURN') and vt == '1' else 0) + if vt == '2': + (state / 'away').touch() + if vt == '1' and os.environ.get('RETURN_FAIL_ONCE') and not (state / 'return-tried').exists(): + (state / 'return-tried').touch() + sys.exit(1) + if os.environ.get('SUDO_FAIL'): + sys.exit(1) +elif name == 'udevadm': + for _ in range(int(os.environ.get('EVENT_COUNT', '1'))): + print('KERNEL[1.0] change /devices/drm/card0 (drm)\nHOTPLUG=1\n', flush=True) + sys.exit(int(os.environ.get('UDEV_EXIT', '0'))) +elif name == 'fake-fix': + sys.exit(int(os.environ.get('FIX_EXIT', '0'))) +elif name == 'systemctl': + if 'is-active' in args: + sys.exit(0 if os.environ.get('SERVICE_ACTIVE') else 3) +''' + + +class RecoveryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + for part in ('bin', 'runtime', 'config', 'home', 'state'): + (self.root / part).mkdir(mode=0o700) + self.env = dict(os.environ, HOME=str(self.root / 'home'), + XDG_CONFIG_HOME=str(self.root / 'config'), + XDG_RUNTIME_DIR=str(self.root / 'runtime'), + TEST_STATE=str(self.root / 'state'), + HYPRLAND_INSTANCE_SIGNATURE='test-instance', + PATH=str(self.root / 'bin') + ':' + os.environ['PATH']) + for name in ('loginctl', 'hyprctl', 'sudo', 'sleep', 'udevadm', 'fake-fix', 'systemctl'): + p = self.root / 'bin' / name + p.write_text(MOCK) + p.chmod(0o755) + self.config = self.root / 'config/revive-monitor/config.env' + self.config.parent.mkdir() + self.config.write_text('HDMI_SETTLE_SECONDS=0\nVT_SWITCH_DELAY_SECONDS=0\nVT_RETURN_SETTLE_SECONDS=0\n') + + def run_script(self, name='revive-monitor-fix.sh', args=('1',), **env): + return subprocess.run(['bash', str(ROOT / 'scripts' / name), *args], + env=dict(self.env, **env), text=True, capture_output=True, timeout=15) + + def calls(self, name): + p = self.root / 'state/calls' + return [a[1:] for a in map(json.loads, p.read_text().splitlines()) if a[0] == name] if p.exists() else [] + + def switches(self): + return [a[-1] for a in self.calls('sudo') if '-l' not in a] + + def test_success(self): + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(self.switches(), ['2', '1']) + self.assertIn('Recovery completed', r.stderr) + + def test_invalid_vt(self): + for vt in ('', '0', '64', '01', 'word', '99999999999999999'): + with self.subTest(vt=vt): + self.assertNotEqual(self.run_script(args=(vt,)).returncode, 0) + self.assertEqual(self.switches(), []) + + def test_session_guards(self): + for env in ({'NO_SESSION': '1'}, {'SESSION_UID': '99999'}, {'SESSION_TYPE': 'x11'}, + {'SESSION_SEAT': 'seat1'}, {'SESSION_DESKTOP': 'GNOME'}): + with self.subTest(env=env): + self.assertEqual(self.run_script(**env).returncode, 0) + self.assertEqual(self.switches(), []) + + def test_wrong_vt(self): + self.assertNotEqual(self.run_script(args=('2',)).returncode, 0) + self.assertEqual(self.switches(), []) + + def test_session_changes_during_settle(self): + self.assertEqual(self.run_script(CHANGE_SESSION='1').returncode, 0) + self.assertEqual(self.switches(), []) + + def test_ipc_and_output_guards(self): + for env in ({'HYPRLAND_INSTANCE_SIGNATURE': ''}, {'HYPR_FAIL': '1'}, + {'OUTPUT': 'DP-1'}, {'BAD_JSON': '1'}): + with self.subTest(env=env): + self.assertNotEqual(self.run_script(**env).returncode, 0) + self.assertEqual(self.switches(), []) + + def test_permission_checked_before_leaving(self): + self.assertNotEqual(self.run_script(DENY_RETURN='1').returncode, 0) + self.assertEqual(self.switches(), []) + + def test_return_failure_retried_and_reported(self): + r = self.run_script(RETURN_FAIL_ONCE='1') + self.assertNotEqual(r.returncode, 0) + self.assertEqual(self.switches(), ['2', '1', '1']) + self.assertNotIn('Recovery completed', r.stderr) + + def test_termination_returns_to_desktop(self): + r = self.run_script(SIGNAL_AFTER_SWITCH='1') + self.assertEqual(r.returncode, 143, r.stderr) + self.assertEqual(self.switches(), ['2', '1']) + + def test_process_group_termination_returns_to_desktop(self): + proc = subprocess.Popen(['bash', str(ROOT / 'scripts/revive-monitor-fix.sh'), '1'], + env=dict(self.env, BLOCK_AFTER_SWITCH='1'), + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + try: + deadline = time.monotonic() + 5 + while not (self.root / 'state/sleeping').exists() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue((self.root / 'state/sleeping').exists()) + os.killpg(proc.pid, signal.SIGTERM) + _, stderr = proc.communicate(timeout=10) + self.assertEqual(proc.returncode, 143, stderr) + self.assertEqual(self.switches(), ['2', '1']) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.communicate() + + def test_session_changes_during_permission_check(self): + self.assertEqual(self.run_script(CHANGE_DURING_PERMISSION_CHECK='1').returncode, 0) + self.assertEqual(self.switches(), []) + + def test_lock_open_failure_aborts(self): + state = self.root / 'runtime/revive-monitor' + state.mkdir(mode=0o700) + (state / 'fix.lock').mkdir() + self.assertNotEqual(self.run_script().returncode, 0) + self.assertEqual(self.switches(), []) + + def test_sudo_failure_not_success(self): + r = self.run_script(SUDO_FAIL='1') + self.assertNotEqual(r.returncode, 0) + self.assertIn('Emergency return failed', r.stderr) + self.assertNotIn('Recovery completed', r.stderr) + + def test_no_shared_tmp_fallback(self): + self.assertNotEqual(self.run_script(XDG_RUNTIME_DIR='').returncode, 0) + self.assertEqual(self.switches(), []) + + def test_unsafe_runtime_rejected(self): + (self.root / 'runtime').chmod(0o755) + self.assertNotEqual(self.run_script().returncode, 0) + + def test_symlink_state_rejected(self): + (self.root / 'runtime/revive-monitor').symlink_to(self.root / 'state') + self.assertNotEqual(self.run_script().returncode, 0) + self.assertFalse((self.root / 'state/fix.lock').exists()) + + def test_invalid_config(self): + for entry in ('DEBOUNCE_SECONDS=-1', 'HDMI_SETTLE_SECONDS=9999', + 'VT_SWITCH_DELAY_SECONDS=abc', 'MONITOR="HDMI-A-2,garbage"'): + self.config.write_text(entry + '\n') + with self.subTest(entry=entry): + self.assertNotEqual(self.run_script().returncode, 0) + self.assertEqual(self.switches(), []) + + def test_leading_zero_timing_is_decimal(self): + self.config.write_text('HDMI_SETTLE_SECONDS=08\n') + self.assertEqual(self.run_script().returncode, 0) + self.assertEqual(self.calls('sleep')[0], ['8']) + + def test_lock_blocks_overlapping_recovery(self): + state = self.root / 'runtime/revive-monitor' + state.mkdir(mode=0o700) + with (state / 'fix.lock').open('w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + r = self.run_script() + self.assertEqual(r.returncode, 0) + self.assertIn('already running', r.stderr) + self.assertEqual(self.switches(), []) + + def watcher(self, **env): + return self.run_script('revive-monitor-drm-watch.sh', (), + REVIVE_MONITOR_FIX_SCRIPT=str(self.root / 'bin/fake-fix'), **env) + + def test_watcher_eof_requests_restart(self): + for status in ('0', '7'): + with self.subTest(status=status): + r = self.watcher(UDEV_EXIT=status, EVENT_COUNT='0') + self.assertEqual(r.returncode, 1) + self.assertIn(f'event stream ended (exit {status})', r.stderr) + + def test_watcher_debounces_burst(self): + r = self.watcher(EVENT_COUNT='4') + self.assertEqual(r.returncode, 1) # Mock stream ends. + self.assertEqual(self.calls('fake-fix'), [['1']]) + + def test_watcher_reports_recovery_failure(self): + r = self.watcher(FIX_EXIT='17') + self.assertIn('failed (exit 17)', r.stderr) + self.assertNotIn('helper finished', r.stderr) + + def test_watcher_ignores_prelogin_events(self): + self.watcher(NO_SESSION='1') + self.assertEqual(self.calls('fake-fix'), []) + + def test_watcher_missing_helper_aborts(self): + r = self.run_script('revive-monitor-drm-watch.sh', (), + REVIVE_MONITOR_FIX_SCRIPT=str(self.root / 'missing')) + self.assertEqual(r.returncode, 1) + self.assertEqual(self.calls('udevadm'), []) + + @unittest.skipIf(os.geteuid() == 0, 'Installer intentionally rejects root') + def test_install_update_uninstall(self): + before = self.config.read_bytes() + for _ in range(2): + r = self.run_script('install.sh', ()) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(self.config.read_bytes(), before) + self.assertTrue((self.root / 'config/systemd/user/revive-monitor-drm-watch.service').exists()) + helper = self.root / 'home/.local/bin/revive-monitor-fix' + self.assertTrue(os.access(helper, os.X_OK)) + r = subprocess.run([str(helper), '1'], env=self.env, text=True, capture_output=True, timeout=15) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertNotIn('enable', str(self.calls('systemctl'))) + r = self.run_script('install.sh', ('--start',), SERVICE_ACTIVE='1') + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn(['--user', 'restart', 'revive-monitor-drm-watch.service'], self.calls('systemctl')) + r = self.run_script('uninstall.sh', ()) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertFalse(helper.exists()) + self.assertEqual(self.config.read_bytes(), before) + self.assertNotIn(['--user', 'reset-failed'], self.calls('systemctl')) + + +if __name__ == '__main__': + unittest.main()