# 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 `hyprctl` 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 a config file, calls `hyprctl reload` — 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** — `Backend` interface makes adding new compositors trivial --- ## How it works ``` ┌──────────────────┐ socket2 events ┌──────────────────┐ │ Hyprland │──────────────────────▶│ monitor-lets-go daemon │ │ compositor │ monitoradded/removed │ │ │ │ │ debounce 1200ms │ │ hyprctl reload ◀─┤──────────────────────│ determine state │ │ (reads monitors │ │ apply layout │ │ .conf file) │ │ run hooks │ └──────────────────┘ └──────┬───────────┘ │ ┌──────▼───────────┐ │ systemd │ │ watchdog 30s │ │ restart 1s │ └──────────────────┘ ``` 1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout 2. **Hotplug events** — listens to compositor socket for `monitoradded`/`monitorremoved` 3. **Debounce** — waits 1200ms after the last event (docks fire multiple events) 4. **Apply** — writes `monitors.conf`, calls `hyprctl reload` (atomic) 5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.) 6. **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 writes** — temp file + rename prevents config corruption | --- ## Installation ### Prerequisites - **Hyprland** compositor (other compositors: see [Extending](#extending)) - **Go 1.21+** (build only, no Go needed at runtime) - **systemd** user instance (for the service) ### Build ```bash git clone https://github.com/mat/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 ``` ### Hyprland config 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: ```bash hyprctl reload ``` --- ## Configuration Create `~/.config/monitor-lets-go/config.yaml`: ```yaml # Backend selection: "auto" (recommended), "hyprland" 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). external: - DP-9 - DP-10 - desc:Dell Inc. DELL U2723QE # Monitor layouts for each mode. # "portable" and "docked" are required. 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: DP-9 enabled: true mode: "3440x1440@120" position: "2560x0" scale: 1.0 - name: eDP-1 enabled: false # turn off laptop screen when docked # 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: - "killall waybar; waybar &" - "wallpapers" on_undock: - "wallpapers" ``` ### Monitor matching Two match modes in the `external` list: | Syntax | Matches | Use case | |---|---|---| | `DP-1` | Exact connector name | Simple setups | | `desc:Dell U2723QE` | Substring in monitor description | Survives port rename across different docks | Run `hyprctl monitors all` to see your monitor names and descriptions. ### 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: ```yaml hooks: on_dock: - "systemctl --user restart waybar" - "~/.config/monitor-lets-go/on-dock.sh" - "notify-send 'Docked' 'External monitors active'" ``` --- ## Usage ### Manual ```bash monitor-lets-go -config ~/.config/monitor-lets-go/config.yaml ``` ### systemd (recommended) ```bash # 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 1. Connect your dock 2. Check logs: `journalctl --user -u monitor-lets-go -f` 3. You should see: `state changed state=docked` and your hooks running 4. Disconnect the dock 5. You should see: `state changed 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 .socket2.sock │ ├── 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: ```go 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 (Sway, KDE, etc.) 1. Create `internal/backend/.go` implementing the `Backend` interface 2. Register the factory in `cmd/monitor-lets-go/main.go:resolveBackend()` 3. That's it — the daemon auto-detects or uses the configured backend Example skeleton for Sway: ```go // internal/backend/sway.go package backend type swayBackend struct { logger *slog.Logger } func NewSway(logger *slog.Logger) (Backend, error) { // Check if SWAYSOCK is set and swaymsg is available return &swayBackend{logger: logger}, nil } func (s *swayBackend) Name() string { return "sway" } func (s *swayBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) { // swaymsg -t get_outputs → parse JSON → []MonitorInfo } func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error { // swaymsg output mode pos scale } func (s *swayBackend) Events(ctx context.Context) (<-chan Event, <-chan error) { // sway IPC socket (SWAYSOCK) → subscribe to output events } func (s *swayBackend) Close() error { return nil } ``` Then register in `main.go`: ```go backends := []struct { ... }{ {"hyprland", backend.NewHyprland}, {"sway", backend.NewSway}, // ← add this line } ``` --- ## Troubleshooting ### "No compositor backend available" Hyprland is not running or `HYPRLAND_INSTANCE_SIGNATURE` is not set. Make sure monitor-lets-go starts **after** Hyprland (the systemd unit uses `After=graphical-session.target`). ### "source file not found" on hyprctl reload 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:` prefix matching instead of connector names. Run `hyprctl monitors all` to find the description string. ```yaml external: - desc:Dell Inc. DELL U2723QE # survives port rename ``` --- ## License MIT