monitor-lets-go/README.md

337 lines
10 KiB
Markdown

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