Harden HDMI recovery and add tested user installer release tooling
This commit is contained in:
1 parent
73e727ef4c
commit
d963224cb7
17 files changed
+903
-646
No files matched your search
@@ -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).
|
||||
Reference in new issue
Block a user