- size:WxH and size:WxH@R resolution for config names - auto-detection of external monitors when external list is empty (any non-internal display) - ensureCleanLayout: explicitly disable connected monitors not in target layout - update docs (README, example.yaml) for all three features
13 KiB
monitor-lets-go
Lightweight, reliable daemon for automatic monitor layout switching on Linux Wayland.
Plugs into your dock — external monitors turn on, built-in turns off. Unplug — built-in comes back. No black screens, no manual commands, no bash scripts.
Features
- Zero black screens — three safety layers: compositor socket events, polling fallback, systemd watchdog with 1-second restart
- Atomic layout application — writes config file + calls reload (Hyprland), or sends batch swaymsg commands (Sway) — no intermediate broken states
- Shell hooks — restart waybar, change wallpapers, run any script after mode switch
- Single binary — one Go binary, one YAML config, one systemd unit
- ~3.5 MB stripped — no runtime dependencies beyond the compositor itself
- Extensible —
Backendinterface makes adding new compositors trivial - Multi-compositor — works with Hyprland and Sway out of the box
How it works
┌─ Hyprland: socket2 .socket2.sock ──────┐
compositor ───┤ ├──▶ monitor-lets-go daemon
└─ Sway: i3 IPC $SWAYSOCK ────────────┘ │
hotplug events │
┌───────────────────────────────
│ debounce 1200ms
│ determine state (portable/docked)
│ apply layout
│ run hooks
└──────────────┬───────────────
│
┌────────▼────────┐
│ systemd │
│ watchdog 30s │
│ restart 1s │
└──────────────────┘
- Startup — daemon queries connected monitors, determines portable/docked, applies layout
- Hotplug events — listens to compositor socket for monitor connect/disconnect events
- Debounce — waits 1200ms after the last event (docks fire multiple events)
- Apply — Hyprland: writes
monitors.conf+hyprctl reload; Sway:swaymsg 'output ...'batch commands - Hooks — runs shell commands after layout change (waybar, wallpapers, etc.)
- Fallback polling — every 5s checks monitor state (catches missed events)
Safety guarantees
| # | Guarantee |
|---|---|
| 1 | Never apply 0 monitors — layout is rejected if all monitors are disabled |
| 2 | Never disable built-in without externals — docked mode verified before applying |
| 3 | Crash recovery — systemd restarts in 1s, daemon re-evaluates state on startup |
| 4 | Hang recovery — systemd watchdog kills and restarts if daemon freezes |
| 5 | Graceful shutdown — optionally restores portable layout on SIGTERM |
| 6 | Socket reconnect — exponential backoff if compositor socket drops |
| 7 | Atomic config writes (Hyprland) — temp file + rename prevents config corruption |
| 8 | Auto-disable stale monitors — monitors connected but absent from the target layout are explicitly disabled (prevents external displays from staying on after switching to portable mode) |
Installation
Prerequisites
- Hyprland or Sway compositor (other compositors: see Extending)
- Go 1.21+ (build only, no Go needed at runtime)
- systemd user instance (for the service)
Build
git clone https://github.com/Wakatron/monitor-lets-go.git
cd monitor-lets-go
make build # → monitor-lets-go (~3.5 MB stripped)
make install # → ~/.local/bin/monitor-lets-go
make systemd-install # → enable & start systemd user service
Choosing a backend
Configure backend in config.yaml:
| Value | Behavior |
|---|---|
auto (recommended) |
Probes Hyprland first, falls back to Sway |
hyprland |
Force Hyprland backend |
sway |
Force Sway backend |
Hyprland setup
Add one line to ~/.config/hypr/hyprland.conf (or hyprland.lua):
source = ~/.config/hypr/monitors.conf
Remove any static monitor=... lines — monitor-lets-go manages monitors now.
Then reload:
hyprctl reload
Sway setup
No Sway config changes required — the daemon applies monitor layouts directly via swaymsg commands.
For systemd integration (so the daemon can reach Sway when launched as a service), add to your Sway config:
exec systemctl --user import-environment SWAYSOCK WAYLAND_DISPLAY XDG_CURRENT_DESKTOP
Verify monitors are detected:
swaymsg -t get_outputs
Configuration
Create ~/.config/monitor-lets-go/config.yaml:
# Backend selection: "auto" (recommended), "hyprland", or "sway"
backend: auto
# Quiet period after the last hotplug event (docks fire multiple events)
debounce: 1200ms
# Fallback polling interval (0 = disable)
poll_interval: 5s
# Restore portable layout on daemon shutdown
restore_on_exit: true
# External monitors that trigger docked mode.
# Plain name: matches connector (DP-1, HDMI-A-1).
# desc: prefix: matches by monitor description (survives port rename).
# Optional — if omitted or empty, the daemon auto-detects external monitors:
# any display whose connector is not eDP-/LVDS-/DSI- is treated as external.
external: # optional
- desc:Dell Inc. DELL U2723QE
- DP-9
- DP-10
# Monitor layouts for each mode.
# "portable" and "docked" are required.
# Monitors not listed in a mode are automatically disabled (prevents
# external displays from staying on when switching to portable mode).
modes:
portable:
monitors:
- name: eDP-1
enabled: true
mode: preferred # auto-detect best resolution
position: "0x0"
scale: 1.0
docked:
monitors:
- name: DP-10
enabled: true
mode: "2560x1440@165"
position: "0x0"
scale: 1.0
- name: desc:Xiaomi Corporation Mi Monitor 3440x1440
enabled: true
mode: "3440x1440@120"
position: "2560x0"
scale: 1.0
- name: eDP-1
enabled: false # turn off laptop screen when docked
- name: size:1920x1080@60 # resolve by resolution + refresh rate
enabled: true
mode: preferred
position: auto
scale: 1.0
# Shell commands run after a layout change.
# Commands run concurrently, failures are logged but never crash the daemon.
# Tildes (~) and $HOME are expanded.
hooks:
on_dock:
- "wallpapers"
on_undock:
- "wallpapers"
Monitor matching
Three match modes for the modes section (the external list supports plain names and desc:):
| Syntax | Matches | Use case |
|---|---|---|
DP-1 |
Exact connector name | Simple setups, built-in displays |
desc:Dell U2723QE |
Substring in monitor description or serial | Survives port rename across different docks |
size:2560x1440 |
Exact pixel dimensions | Two identical monitors with same resolution |
size:2560x1440@165 |
Dimensions + refresh rate | Disambiguate identical models |
Sway description format: make model serial_widthxheight (with serial omitted if Unknown).
Run hyprctl monitors all (Hyprland) or swaymsg -t get_outputs (Sway) to see your monitor names and descriptions.
desc: and size: in modes — when a monitor name in modes uses desc: or size:, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a prefix matching multiple monitors with identical resolution) cause an error. For size:, add @R (refresh rate) to disambiguate monitors with the same resolution. Unresolvable names also cause an error.
Backend-specific configuration
Use the backend_config section for compositor-specific options:
backend_config:
output_path: ~/.config/hypr/custom-monitors.conf # override generated config path
| Backend | Key | Type | Default | Description |
|---|---|---|---|---|
| Hyprland | output_path |
string | ~/.config/hypr/monitors.conf |
Path to the generated monitor config file. Supports ~ expansion. |
| Sway | (none) | — | — | Layout is applied via swaymsg commands directly — no config files needed. |
The deprecated top-level output_path key still works — backend_config takes priority if both are set.
Hook commands
Hooks are shell commands executed via sh -c. Each command gets a 30-second timeout. Commands run in parallel — one slow hook won't block others.
Examples:
hooks:
on_dock:
- "systemctl --user restart waybar"
- "~/.config/monitor-lets-go/on-dock.sh"
- "notify-send 'Docked' 'External monitors active'"
Usage
Manual
monitor-lets-go -config ~/.config/monitor-lets-go/config.yaml
systemd (recommended)
# Install and start
make systemd-install
# Check status
systemctl --user status monitor-lets-go
# View logs
journalctl --user -u monitor-lets-go -f
# Restart
systemctl --user restart monitor-lets-go
# Disable
systemctl --user disable --now monitor-lets-go
Verify it's working
- Connect your dock
- Check logs:
journalctl --user -u monitor-lets-go -f - You should see:
layout applied state=dockedand your hooks running - Disconnect the dock
- You should see:
layout applied state=portable
Architecture
monitor-lets-go/
├── cmd/monitor-lets-go/main.go # Entry point: CLI, systemd watchdog, backend registry
├── internal/
│ ├── backend/
│ │ ├── interface.go # Backend interface + types (MonitorInfo, Event, State)
│ │ ├── hyprland.go # Hyprland: hyprctl + socket2
│ │ └── sway.go # Sway: swaymsg + i3 IPC socket ($SWAYSOCK)
│ ├── config/config.go # YAML parse, validate, defaults, monitor matching
│ ├── daemon/daemon.go # Event loop, debounce, state machine, safety checks
│ └── hook/hook.go # Shell hook runner with timeouts
├── contrib/monitor-lets-go.service # systemd user unit
├── monitor-lets-go.example.yaml # Annotated config example
├── Makefile
└── go.mod
Key interfaces
Backend — the only compositor-dependent code:
type Backend interface {
Name() string
GetMonitors(ctx context.Context) ([]MonitorInfo, error)
ApplyLayout(ctx context.Context, monitors []MonitorConfig) error
Events(ctx context.Context) (<-chan Event, <-chan error)
Close() error
}
Dependencies
Only one external dependency: gopkg.in/yaml.v3. Everything else is Go standard library.
Extending
Adding a new compositor
- Create
internal/backend/<name>.goimplementing theBackendinterface (seeinterface.go) - Register the factory in
cmd/monitor-lets-go/main.go:resolveBackend() - That's it — the daemon auto-detects or uses the configured backend
See internal/backend/sway.go for a full implementation reference: monitor detection via subprocess, layout application via batch commands, hotplug events via raw IPC socket with exponential reconnection backoff, and desc: name resolution for connector-name-portability.
Troubleshooting
"No compositor backend available"
The daemon couldn't find any supported compositor.
Hyprland: HYPRLAND_INSTANCE_SIGNATURE is not set. Make sure monitor-lets-go starts after Hyprland (the systemd unit uses After=graphical-session.target). Verify with: echo $HYPRLAND_INSTANCE_SIGNATURE.
Sway: SWAYSOCK is not set and no sway IPC socket found in $XDG_RUNTIME_DIR. Make sure Sway is running and the socket is accessible. Verify with: ls $XDG_RUNTIME_DIR/sway-ipc.*.sock.
"source file not found" (Hyprland only)
The source = ~/.config/hypr/monitors.conf line must be added to hyprland.conf. The daemon creates this file on first run.
Hooks not running
Check the hook command works from a terminal first. Hooks run via sh -c, so shell syntax like &&, ;, pipes work. Each hook has a 30-second timeout.
Monitor names changed after reboot
Use desc: or size: prefix matching instead of connector names. Both survive port renames across different docks and reboots.
Hyprland: hyprctl monitors all to see descriptions.
Sway: swaymsg -t get_outputs to see names, make/model, serial, and native resolution.
external:
- desc:Dell Inc. DELL U2723QE # survives port rename
modes:
docked:
monitors:
- name: desc:Dell Inc. DELL U2723QE # also works here
enabled: true
- name: size:2560x1440@165 # match by dimensions + refresh
enabled: true
License
MIT