monitor-lets-go/README.md

10 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 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
  • ExtensibleBackend 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)
  • Go 1.21+ (build only, no Go needed at runtime)
  • systemd user instance (for the service)

Build

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:

hyprctl reload

Configuration

Create ~/.config/monitor-lets-go/config.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:

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

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/<name>.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:

// 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 <name> mode <mode> pos <x> <y> scale <s>
}

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:

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.

external:
  - desc:Dell Inc. DELL U2723QE   # survives port rename

License

MIT