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
  • ExtensibleBackend interface 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      │
                                              └──────────────────┘
  1. Startup — daemon queries connected monitors, determines portable/docked, applies layout
  2. Hotplug events — listens to compositor socket for monitor connect/disconnect events
  3. Debounce — waits 1200ms after the last event (docks fire multiple events)
  4. Apply — Hyprland: writes monitors.conf + hyprctl reload; Sway: swaymsg 'output ...' batch commands
  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 config writes (Hyprland) — temp file + rename prevents config corruption

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).
external:
  - desc:Dell Inc. DELL U2723QE
  - DP-9
  - DP-10

# 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: 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

# 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

Two match modes, supported in both the external list and the modes section:

Syntax Matches Use case
DP-1 Exact connector name Simple setups, built-in displays
desc:Dell U2723QE Substring in monitor description Survives port rename across different docks

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: in modes — when a monitor name in modes uses the desc: prefix, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a desc matching multiple monitors) 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
# 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: layout applied state=docked and your hooks running
  4. Disconnect the dock
  5. 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

  1. Create internal/backend/<name>.go implementing the Backend interface (see interface.go)
  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

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: prefix matching instead of connector names. This survives 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

License

MIT

Description
Lightweight, reliable daemon for automatic monitor layout switching on Linux Wayland.
Readme 122 KiB
Languages
Go 97.7%
Makefile 2.3%