Harden HDMI recovery and add tested user installer release tooling

This commit is contained in:
admin committed 2026-09-23 09:16:57 +02:00
1 parent 73e727ef4c
commit d963224cb7
17 files changed
+903 -646

No files matched your search

+110 -278
View File
@@ -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=<current user>
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 <file.zip.b64> <file.zip>`.
## 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).