commit a4b80616bb8ec904e16687d76922b26f64e3080e Author: Maksim Totmin Date: Mon Jun 22 16:22:47 2026 +0700 Initial commit: monitor-lets-go — automatic monitor layout daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0e3aa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Binary +/monitor-lets-go +/neodock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c58b9b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +# monitor-lets-go Makefile +BIN := monitor-lets-go +SRCDIR := ./cmd/neodock +INSTALL ?= install + +.DEFAULT_GOAL := build + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- + +build: + go build -ldflags="-s -w" -o $(BIN) $(SRCDIR) + +# build with race detector for development +build-race: + go build -race -o $(BIN) $(SRCDIR) + +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- + +install: build + $(INSTALL) -Dm755 $(BIN) $(HOME)/.local/bin/$(BIN) + +systemd-install: + $(INSTALL) -Dm644 contrib/monitor-lets-go.service $(HOME)/.config/systemd/user/monitor-lets-go.service + systemctl --user daemon-reload + systemctl --user enable --now monitor-lets-go + +systemd-status: + systemctl --user status monitor-lets-go + +systemd-logs: + journalctl --user -u monitor-lets-go -f + +# --------------------------------------------------------------------------- +# Quality +# --------------------------------------------------------------------------- + +lint: + go vet ./... + +test: + go test -v -race ./... + +# --------------------------------------------------------------------------- +# Clean +# --------------------------------------------------------------------------- + +clean: + rm -f $(BIN) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7ae5f5 --- /dev/null +++ b/README.md @@ -0,0 +1,336 @@ +# 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 `monitor-lets-go-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/monitor-lets-go-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/monitor-lets-go-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 diff --git a/cmd/neodock/main.go b/cmd/neodock/main.go new file mode 100644 index 0000000..76025ad --- /dev/null +++ b/cmd/neodock/main.go @@ -0,0 +1,190 @@ +// Command monitor-lets-go is a lightweight daemon that automatically switches +// monitor layouts when connecting to or disconnecting from a docking station. +// +// It listens for compositor hotplug events and applies pre-configured +// monitor layouts for portable and docked modes. Post-switch shell hooks +// are supported for restarting bars, wallpapers, etc. +// +// Usage: +// +// monitor-lets-go -config ~/.config/monitor-lets-go/config.yaml +// +// The daemon is designed to run as a systemd user service. See contrib/ +// for an example unit file. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "monitor-lets-go/internal/backend" + "monitor-lets-go/internal/config" + "monitor-lets-go/internal/daemon" + "monitor-lets-go/internal/hook" +) + +var configPath string + +func init() { + flag.StringVar(&configPath, "config", defaultConfigPath(), "path to YAML configuration file") +} + +func main() { + flag.Parse() + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + if err := run(logger); err != nil && !errors.Is(err, context.Canceled) { + logger.Error("fatal", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + // Load and validate configuration. + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + logger.Info("config loaded", "path", configPath, "backend", cfg.Backend) + + // Resolve the backend (auto-detect or explicit). + b, err := resolveBackend(cfg.Backend, logger) + if err != nil { + return fmt.Errorf("resolve backend: %w", err) + } + defer b.Close() + + // Build dependencies. + hookRunner := hook.NewRunner(logger) + d := daemon.New(b, cfg, hookRunner, logger) + + // Set up signal handling for graceful shutdown. + ctx, stop := signal.NotifyContext( + context.Background(), + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + ) + defer stop() + + // Start systemd watchdog pings if running under systemd with Type=notify. + go systemdWatchdog(ctx, logger) + + return d.Run(ctx) +} + +// resolveBackend returns the first available backend. When backendName is +// "auto", backends are probed in priority order. To add a new backend, +// register it in the backends slice below. +func resolveBackend(backendName string, logger *slog.Logger) (backend.Backend, error) { + // Ordered by priority: preferred backends first. + backends := []struct { + name string + factory func(*slog.Logger) (backend.Backend, error) + }{ + {"hyprland", backend.NewHyprland}, + // Future: {"sway", backend.NewSway}, + // Future: {"wlroots", backend.NewWlroots}, + } + + if backendName == "auto" { + for _, entry := range backends { + b, err := entry.factory(logger) + if err == nil { + logger.Info("auto-detected backend", "name", b.Name()) + return b, nil + } + logger.Debug("backend unavailable", "name", entry.name, "error", err) + } + return nil, fmt.Errorf("no compositor backend available") + } + + // Explicit backend selection. + for _, entry := range backends { + if entry.name == backendName { + b, err := entry.factory(logger) + if err != nil { + return nil, fmt.Errorf("backend %s: %w", backendName, err) + } + return b, nil + } + } + return nil, fmt.Errorf("unknown backend %q", backendName) +} + +// defaultConfigPath returns ~/.config/monitor-lets-go/config.yaml. +func defaultConfigPath() string { + // Respect XDG_CONFIG_HOME if set, otherwise use ~/.config. + cfgHome := os.Getenv("XDG_CONFIG_HOME") + if cfgHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "monitor-lets-go.yaml" + } + cfgHome = filepath.Join(home, ".config") + } + return filepath.Join(cfgHome, "monitor-lets-go", "config.yaml") +} + +// systemdWatchdog sends periodic WATCHDOG=1 notifications so systemd's +// WatchdogSec can detect a hung daemon and restart it. Also sends +// READY=1 on startup and STOPPING=1 on shutdown. +// +// This is a no-op if NOTIFY_SOCKET is not set. +func systemdWatchdog(ctx context.Context, logger *slog.Logger) { + socketPath := os.Getenv("NOTIFY_SOCKET") + if socketPath == "" { + return + } + + // Notify systemd that the daemon is ready. + notifySystemd(socketPath, "READY=1") + + // WatchdogSec=30, so ping at half the interval. + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + // Notify systemd that we are stopping. + defer notifySystemd(socketPath, "STOPPING=1") + + logger.Debug("systemd watchdog started") + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + notifySystemd(socketPath, "WATCHDOG=1") + } + } +} + +// notifySystemd sends a single-line notification via the systemd +// notification socket. Supports both filesystem and abstract sockets. +func notifySystemd(socketPath, msg string) { + // systemd may use an abstract socket prefixed with @. + if socketPath[0] == '@' { + socketPath = "\x00" + socketPath[1:] + } + + addr := &net.UnixAddr{ + Name: socketPath, + Net: "unixgram", + } + conn, err := net.DialUnix("unixgram", nil, addr) + if err != nil { + return + } + defer conn.Close() + conn.Write([]byte(msg)) +} diff --git a/contrib/monitor-lets-go.service b/contrib/monitor-lets-go.service new file mode 100644 index 0000000..0a69f76 --- /dev/null +++ b/contrib/monitor-lets-go.service @@ -0,0 +1,32 @@ +[Unit] +Description=monitor-lets-go — automatic monitor layout daemon +Documentation=https://github.com/mat/monitor-lets-go + +# Ensure the daemon starts after the graphical session is ready and +# stops when the session ends. +PartOf=graphical-session.target +After=graphical-session.target +Requires=graphical-session.target + +[Service] +# Type=notify with WatchdogSec: the daemon periodically reports health. +# If it hangs, systemd kills and restarts it — critical for recovering +# from a black-screen state (restart re-evaluates monitors and applies +# the correct layout). +Type=notify +WatchdogSec=30 + +ExecStart=%h/.local/bin/monitor-lets-go -config %h/.config/monitor-lets-go/config.yaml + +# Fast restart on failure: if the daemon crashes during docked mode, +# it will be restarted within 1 second and immediately apply the +# correct layout for the current hardware state. +Restart=on-failure +RestartSec=1 + +# Allow up to 5 seconds for graceful shutdown (portable layout restore). +KillSignal=SIGTERM +TimeoutStopSec=5 + +[Install] +WantedBy=graphical-session.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d68e5db --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module monitor-lets-go + +go 1.26.4 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/backend/hyprland.go b/internal/backend/hyprland.go new file mode 100644 index 0000000..0908b5e --- /dev/null +++ b/internal/backend/hyprland.go @@ -0,0 +1,326 @@ +package backend + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// hyprlandBackend implements Backend for the Hyprland compositor. +// +// Monitor queries: hyprctl -j monitors all +// Layout application: write ~/.config/hypr/monitor-lets-go-monitors.conf + hyprctl reload +// Hotplug events: socket2 unix socket ($XDG_RUNTIME_DIR/hypr/$HIS/.socket2.sock) +// +// The user must add the following line to their hyprland.lua: +// +// source = os.getenv("HOME") .. "/.config/hypr/monitor-lets-go-monitors.conf" +type hyprlandBackend struct { + logger *slog.Logger + + // conn is the current socket2 connection; guarded by mu. + conn net.Conn + mu sync.Mutex +} + +// NewHyprland creates a Hyprland backend. The backend auto-detects the +// Hyprland instance signature from the environment. +func NewHyprland(logger *slog.Logger) (Backend, error) { + sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") + if sig == "" { + return nil, errors.New("HYPRLAND_INSTANCE_SIGNATURE is not set; is Hyprland running?") + } + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = "/run/user/" + fmt.Sprint(os.Getuid()) + } + + socketPath := filepath.Join(runtimeDir, "hypr", sig, ".socket2.sock") + if _, err := os.Stat(socketPath); err != nil { + return nil, fmt.Errorf("socket2 not found at %s: %w", socketPath, err) + } + + return &hyprlandBackend{logger: logger}, nil +} + +func (h *hyprlandBackend) Name() string { return "hyprland" } + +// GetMonitors calls hyprctl -j monitors all and parses the JSON output. +// It returns both active and inactive monitors. +func (h *hyprlandBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) { + cmd := exec.CommandContext(ctx, "hyprctl", "-j", "monitors", "all") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("hyprctl monitors all: %w", err) + } + + // The output is a JSON array. Unknown fields are silently ignored. + var monitors []MonitorInfo + if err := json.Unmarshal(out, &monitors); err != nil { + return nil, fmt.Errorf("parse hyprctl output: %w\nraw: %s", err, string(out)) + } + return monitors, nil +} + +// ApplyLayout writes the monitor configuration file and calls hyprctl reload. +// The config is written atomically (temp file + rename) to prevent corruption. +func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error { + content := h.generateConf(monitors) + + configDir, err := h.hyprConfigDir() + if err != nil { + return fmt.Errorf("hypr config dir: %w", err) + } + destPath := filepath.Join(configDir, "monitor-lets-go-monitors.conf") + + // Atomic write: temp file, write, fsync, rename. + if err := atomicWrite(destPath, []byte(content)); err != nil { + return fmt.Errorf("write monitors.conf: %w", err) + } + + // Reload Hyprland config to apply changes atomically. + reload := exec.CommandContext(ctx, "hyprctl", "reload") + if out, err := reload.CombinedOutput(); err != nil { + return fmt.Errorf("hyprctl reload: %w\noutput: %s", err, string(out)) + } + + h.logger.Info("layout applied", "monitors", len(monitors)) + return nil +} + +// Events connects to the socket2 unix socket and emits parsed hotplug events. +// On connection loss it attempts reconnection with exponential backoff. +func (h *hyprlandBackend) Events(ctx context.Context) (<-chan Event, <-chan error) { + eventCh := make(chan Event, 8) + errCh := make(chan error, 1) + + go func() { + defer close(eventCh) + defer close(errCh) + + backoff := 1 * time.Second + for { + if err := h.readSocket2(ctx, eventCh); err != nil { + if ctx.Err() != nil { + return // graceful shutdown + } + h.logger.Error("socket2 disconnected, reconnecting", "error", err, "backoff", backoff) + + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + + backoff = min(backoff*2, 30*time.Second) + } else { + backoff = 1 * time.Second + } + } + }() + + return eventCh, errCh +} + +// readSocket2 opens one connection to socket2 and reads events until EOF or error. +func (h *hyprlandBackend) readSocket2(ctx context.Context, eventCh chan<- Event) error { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = "/run/user/" + fmt.Sprint(os.Getuid()) + } + sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") + socketPath := filepath.Join(runtimeDir, "hypr", sig, ".socket2.sock") + + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", socketPath) + if err != nil { + return fmt.Errorf("connect socket2: %w", err) + } + defer conn.Close() + + h.mu.Lock() + h.conn = conn + h.mu.Unlock() + defer func() { + h.mu.Lock() + h.conn = nil + h.mu.Unlock() + }() + + h.logger.Info("connected to socket2", "path", socketPath) + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + evt, ok := parseSocket2Event(line) + if !ok { + continue + } + select { + case eventCh <- evt: + case <-ctx.Done(): + return ctx.Err() + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("socket2 read: %w", err) + } + return nil +} + +// parseSocket2Event parses a socket2 event line into an Event. +// Format: "monitoradded>>DP-1" or "monitorremovedv2>>DP-1" +// Returns false for events we don't care about. +func parseSocket2Event(line string) (Event, bool) { + // Events we handle: + // monitoradded>>name monitoraddedv2>>name + // monitorremoved>>name monitorremovedv2>>name + if strings.HasPrefix(line, "monitoradded") { + name := eventData(line) + if name == "" { + return Event{}, false + } + return Event{Type: EventMonitorAdded, MonitorName: name}, true + } + if strings.HasPrefix(line, "monitorremoved") { + name := eventData(line) + if name == "" { + return Event{}, false + } + return Event{Type: EventMonitorRemoved, MonitorName: name}, true + } + return Event{}, false +} + +// eventData extracts the data portion after ">>" from a socket2 event line. +func eventData(line string) string { + idx := strings.Index(line, ">>") + if idx < 0 { + return "" + } + return line[idx+2:] +} + +// generateConf produces the content of a Hyprland-compatible monitor config file. +// Old-style syntax: monitor=name,mode,pos,scale +// Disabled monitors: monitor=name,disabled +func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string { + var buf strings.Builder + + // Ensure disabled monitors appear last so Hyprland migrates + // workspaces to enabled ones first. + var disabled []MonitorConfig + + for _, m := range monitors { + if !m.Enabled { + disabled = append(disabled, m) + continue + } + + mode := m.Mode + if mode == "" { + mode = "preferred" + } + pos := m.Position + if pos == "" { + pos = "auto" + } + scale := formatScale(m.Scale) + + buf.WriteString("monitor=") + buf.WriteString(m.Name) + buf.WriteString(",") + buf.WriteString(mode) + buf.WriteString(",") + buf.WriteString(pos) + buf.WriteString(",") + buf.WriteString(scale) + buf.WriteString("\n") + } + + for _, m := range disabled { + buf.WriteString("monitor=") + buf.WriteString(m.Name) + buf.WriteString(",disabled\n") + } + + return buf.String() +} + +// formatScale formats a scale float: 1 → "1", 1.5 → "1.5" +func formatScale(s float64) string { + if s == 0 { + return "1" + } + // Use %g to strip trailing zeros, then ensure it's not scientific notation. + str := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%g", s), "0"), ".") + if str == "" { + return "1" + } + return str +} + +// hyprConfigDir returns the Hyprland config directory (~/.config/hypr). +func (h *hyprlandBackend) hyprConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".config", "hypr") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + +func (h *hyprlandBackend) Close() error { + h.mu.Lock() + defer h.mu.Unlock() + if h.conn != nil { + return h.conn.Close() + } + return nil +} + +// atomicWrite writes data to a file atomically using temp file + rename. +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".monitor-lets-go-*.tmp") + if err != nil { + return fmt.Errorf("create temp: %w", err) + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("write temp: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpPath) + return fmt.Errorf("sync temp: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("close temp: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("rename temp: %w", err) + } + return nil +} diff --git a/internal/backend/interface.go b/internal/backend/interface.go new file mode 100644 index 0000000..ac1a978 --- /dev/null +++ b/internal/backend/interface.go @@ -0,0 +1,99 @@ +// Package backend defines the abstraction layer for window-manager-specific +// monitor management. Each supported compositor (Hyprland, Sway, etc.) +// implements the Backend interface, encapsulating how monitors are detected, +// configured, and how hotplug events are received. +// +// This is the only WM-dependent code in the project. To add support for a +// new window manager, implement this interface and register the backend in +// cmd/monitor-lets-go/main.go. +package backend + +import "context" + +// MonitorInfo represents a physical display as reported by the compositor. +// Fields use JSON tags matching hyprctl -j output; other compositors +// populate equivalent fields. +type MonitorInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Make string `json:"make"` + Model string `json:"model"` + Serial string `json:"serial"` + Width int `json:"width"` + Height int `json:"height"` + RefreshRate float64 `json:"refreshRate"` + X int `json:"x"` + Y int `json:"y"` + Scale float64 `json:"scale"` + Enabled bool `json:"enabled"` +} + +// MonitorConfig describes the desired state of a single monitor. +// Used in configuration and passed to ApplyLayout. +type MonitorConfig struct { + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + Mode string `yaml:"mode"` // "1920x1080@144", "preferred", or empty + Position string `yaml:"position"` // "0x0", "auto", or empty + Scale float64 `yaml:"scale"` // 1.0, 1.5, etc. +} + +// State represents the current operational mode of the daemon. +type State int + +const ( + StateUnknown State = iota + StatePortable // built-in display only + StateDocked // external monitors connected +) + +// String returns a human-readable state name used for config keys and logging. +func (s State) String() string { + switch s { + case StatePortable: + return "portable" + case StateDocked: + return "docked" + default: + return "unknown" + } +} + +// EventType identifies the kind of monitor hotplug event. +type EventType int + +const ( + EventMonitorAdded EventType = iota + EventMonitorRemoved +) + +// Event carries a single monitor hotplug notification from the compositor. +type Event struct { + Type EventType + MonitorName string +} + +// Backend is the abstraction over a window manager's monitor management. +// Each implementation handles compositor-specific APIs for querying monitors, +// applying layouts, and subscribing to hotplug events. +type Backend interface { + // Name returns a human-readable backend identifier (e.g. "hyprland"). + Name() string + + // GetMonitors returns all monitors known to the compositor, + // both active and inactive (physically connected but disabled). + GetMonitors(ctx context.Context) ([]MonitorInfo, error) + + // ApplyLayout applies a list of monitor configurations atomically. + // The backend must ensure no intermediate state where zero monitors + // are enabled is ever visible. + ApplyLayout(ctx context.Context, monitors []MonitorConfig) error + + // Events returns a channel of hotplug events and a channel of errors. + // The caller must read from both channels. When ctx is cancelled, + // the backend closes both channels and stops listening. + Events(ctx context.Context) (<-chan Event, <-chan error) + + // Close releases any resources held by the backend. + Close() error +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a82c147 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,186 @@ +// Package config handles parsing, validation, and defaults for the +// monitor-lets-go YAML configuration file. +package config + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Duration is a time.Duration that supports YAML string unmarshaling. +type Duration time.Duration + +// UnmarshalYAML parses a duration string like "1200ms", "5s", "2m". +func (d *Duration) UnmarshalYAML(value *yaml.Node) error { + var s string + if err := value.Decode(&s); err != nil { + return err + } + dur, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", s, err) + } + *d = Duration(dur) + return nil +} + +func (d Duration) String() string { return time.Duration(d).String() } + +// Config holds the complete monitor-lets-go configuration. +type Config struct { + // Backend selects the window manager backend. + // "auto" (default) probes for an available backend. + Backend string `yaml:"backend"` + + // Debounce is the quiet period after the last hotplug event before + // querying and applying a new layout. Defaults to 1200ms. + Debounce Duration `yaml:"debounce"` + + // PollInterval is the fallback polling period when socket events + // are unavailable. Set to 0 to disable. Defaults to 5s. + PollInterval Duration `yaml:"poll_interval"` + + // RestoreOnExit, if true, applies the portable layout before the + // daemon shuts down (on SIGTERM). Defaults to true. + // Uses *bool so we can distinguish "not set" from explicit false. + RestoreOnExit *bool `yaml:"restore_on_exit"` + + // External lists monitor identifiers that trigger docked mode. + // Each entry is a name (DP-1) or a desc: prefix (desc:Dell U2723QE). + External []string `yaml:"external"` + + // Modes maps mode names to monitor layouts. + // Required keys: "portable" and "docked". + Modes map[string]Mode `yaml:"modes"` + + // Hooks maps event names to shell commands executed after a layout change. + // Supported keys: "on_dock", "on_undock". + Hooks map[string][]string `yaml:"hooks"` +} + +// Mode describes a single monitor layout. +type Mode struct { + Monitors []MonitorEntry `yaml:"monitors"` +} + +// MonitorEntry defines the desired state of one monitor in a mode. +type MonitorEntry struct { + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + Mode string `yaml:"mode"` + Position string `yaml:"position"` + Scale float64 `yaml:"scale"` +} + +// Load reads and validates a configuration file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + cfg := &Config{} + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + if err := cfg.applyDefaults(); err != nil { + return nil, err + } + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// applyDefaults fills in default values for unset fields. +func (c *Config) applyDefaults() error { + if c.Backend == "" { + c.Backend = "auto" + } + if c.Debounce == 0 { + c.Debounce = Duration(1200 * time.Millisecond) + } + if c.PollInterval == 0 { + c.PollInterval = Duration(5 * time.Second) + } + if c.RestoreOnExit == nil { + t := true + c.RestoreOnExit = &t + } + if c.Modes == nil { + c.Modes = make(map[string]Mode) + } + if c.Hooks == nil { + c.Hooks = make(map[string][]string) + } + return nil +} + +// validate checks that the configuration is usable. +func (c *Config) validate() error { + if c.Backend != "auto" && c.Backend != "hyprland" && c.Backend != "sway" { + return fmt.Errorf("unknown backend %q", c.Backend) + } + + portable, ok := c.Modes["portable"] + if !ok { + return errors.New("missing required mode 'portable'") + } + docked, ok := c.Modes["docked"] + if !ok { + return errors.New("missing required mode 'docked'") + } + + // Portable mode must have at least one enabled monitor. + if countEnabled(portable.Monitors) == 0 { + return errors.New("portable mode must have at least one enabled monitor") + } + // Docked mode must have at least one enabled monitor. + if countEnabled(docked.Monitors) == 0 { + return errors.New("docked mode must have at least one enabled monitor") + } + // At least one external monitor must be listed. + if len(c.External) == 0 { + return errors.New("at least one external monitor must be specified") + } + + return nil +} + +// countEnabled returns how many MonitorEntries are enabled. +func countEnabled(entries []MonitorEntry) int { + n := 0 + for _, e := range entries { + if e.Enabled { + n++ + } + } + return n +} + +// MatchesExternal checks whether a monitor name or description matches any +// entry in the External list. Supports two match modes: +// +// - Plain name: exact match against MonitorEntry.Name +// - desc: prefix: substring match against MonitorEntry.Description +func (c *Config) MatchesExternal(name, description string) bool { + for _, ext := range c.External { + if strings.HasPrefix(ext, "desc:") { + desc := strings.TrimPrefix(ext, "desc:") + if strings.Contains(description, desc) { + return true + } + } else { + if name == ext { + return true + } + } + } + return false +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..128f41f --- /dev/null +++ b/internal/daemon/daemon.go @@ -0,0 +1,311 @@ +// Package daemon implements the core event loop and state machine +// for automatic monitor layout switching. +// +// The daemon starts by determining the current hardware state (portable or +// docked) and applying the correct layout. It then subscribes to compositor +// hotplug events via the Backend and re-evaluates the layout after a +// debounce period. A polling fallback runs on a timer to catch missed events. +// +// Safety invariants: +// - A layout with zero enabled monitors is never applied. +// - Docked mode is never applied unless an external monitor is physically +// connected. +// - On shutdown, the portable layout is restored (configurable). +package daemon + +import ( + "context" + "fmt" + "log/slog" + "time" + + "monitor-lets-go/internal/backend" + "monitor-lets-go/internal/config" + "monitor-lets-go/internal/hook" +) + +// Daemon orchestrates monitor switching. +type Daemon struct { + backend backend.Backend + config *config.Config + hookRunner *hook.Runner + logger *slog.Logger + state backend.State +} + +// New creates a Daemon with the given dependencies. +func New(b backend.Backend, cfg *config.Config, hr *hook.Runner, logger *slog.Logger) *Daemon { + return &Daemon{ + backend: b, + config: cfg, + hookRunner: hr, + logger: logger, + state: backend.StateUnknown, + } +} + +// Run starts the daemon's event loop. It blocks until ctx is cancelled. +func (d *Daemon) Run(ctx context.Context) error { + d.logger.Info("daemon starting", "backend", d.backend.Name()) + + // Phase 1: determine and apply the initial state. + d.applyInitialState(ctx) + + // Phase 2: subscribe to compositor events. + events, _ := d.backend.Events(ctx) + + pollInterval := time.Duration(d.config.PollInterval) + var pollTicker *time.Ticker + var pollCh <-chan time.Time + if pollInterval > 0 { + pollTicker = time.NewTicker(pollInterval) + pollCh = pollTicker.C + defer pollTicker.Stop() + } + + var debounceTimer *time.Timer + var debounceCh <-chan time.Time + + for { + select { + case <-ctx.Done(): + d.onShutdown(context.Background()) + return ctx.Err() + + case evt, ok := <-events: + if !ok { + if ctx.Err() != nil { + return ctx.Err() + } + d.logger.Error("event channel closed unexpectedly") + return fmt.Errorf("backend event stream terminated") + } + + d.logger.Debug("hotplug event", + "type", evt.Type, "monitor", evt.MonitorName) + + // Reset the debounce timer on every event. + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.NewTimer(time.Duration(d.config.Debounce)) + debounceCh = debounceTimer.C + + case <-debounceCh: + debounceCh = nil + debounceTimer = nil + d.checkAndApply(ctx) + + case <-pollCh: + d.pollCheck(ctx) + } + } +} + +// applyInitialState determines the current hardware state and applies the +// matching layout. This is the failsafe: if the daemon crashed while docked, +// a restart will detect that externals are gone and re-enable the built-in. +func (d *Daemon) applyInitialState(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + monitors, err := d.getMonitorsWithRetry(ctx) + if err != nil { + d.logger.Error("cannot get monitors on startup", "error", err) + return + } + + state := d.determineState(monitors) + d.logger.Info("initial state determined", "state", state) + + if err := d.applyState(ctx, state); err != nil { + d.logger.Error("failed to apply initial state", "state", state, "error", err) + } +} + +// checkAndApply is called after the debounce timer fires. It re-queries +// monitors and applies a new layout if the state has changed. +func (d *Daemon) checkAndApply(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + monitors, err := d.getMonitorsWithRetry(ctx) + if err != nil { + d.logger.Error("cannot get monitors", "error", err) + return + } + + newState := d.determineState(monitors) + if newState == d.state { + return + } + + if err := d.applyState(ctx, newState); err != nil { + d.logger.Error("failed to apply state", "state", newState, "error", err) + } +} + +// pollCheck is the polling fallback. It hashes the current monitor state +// and applies a new layout if the hash has changed. +func (d *Daemon) pollCheck(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + monitors, err := d.backend.GetMonitors(ctx) + if err != nil { + return // silent; socket events are primary + } + + current := d.determineState(monitors) + if current == d.state { + return + } + + d.logger.Debug("polling detected state change", "state", current) + if err := d.applyState(ctx, current); err != nil { + d.logger.Error("polling apply failed", "state", current, "error", err) + } +} + +// determineState checks whether any external monitor is physically connected. +func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State { + for _, m := range monitors { + // Skip phantom or disconnected monitors. + if m.Name == "" { + continue + } + // A monitor is physically present if it reports non-zero dimensions. + if m.Width > 0 && m.Height > 0 { + if d.config.MatchesExternal(m.Name, m.Description) { + return backend.StateDocked + } + } + } + return backend.StatePortable +} + +// applyState applies a monitor layout and runs post-switch hooks. +// Safety invariants are enforced before any layout change. +func (d *Daemon) applyState(ctx context.Context, state backend.State) error { + d.logger.Debug("applying state", "state", state) + + mode, ok := d.config.Modes[state.String()] + if !ok { + return fmt.Errorf("no mode config for %s", state) + } + + enabled := countEnabled(mode.Monitors) + if enabled == 0 { + return fmt.Errorf("refusing %s mode: 0 enabled monitors", state) + } + + // Safety: if entering docked mode, verify at least one external is + // physically connected before we disable the built-in display. + if state == backend.StateDocked { + monitors, err := d.backend.GetMonitors(ctx) + if err != nil { + return fmt.Errorf("verify externals before dock: %w", err) + } + if !d.anyExternalConnected(monitors) { + d.logger.Warn("docked mode skipped: no external monitors connected") + // Stay in the current state instead of risking a black screen. + return fmt.Errorf("docked mode: no external monitors connected") + } + } + + // Convert config entries to backend format. + monitors := make([]backend.MonitorConfig, len(mode.Monitors)) + for i, e := range mode.Monitors { + monitors[i] = backend.MonitorConfig{ + Name: e.Name, + Enabled: e.Enabled, + Mode: e.Mode, + Position: e.Position, + Scale: e.Scale, + } + } + + if err := d.backend.ApplyLayout(ctx, monitors); err != nil { + return fmt.Errorf("apply layout: %w", err) + } + + d.state = state + d.logger.Info("layout applied", "state", state) + + // Run hooks asynchronously; failures are logged but never returned. + hookKey := "on_dock" + if state == backend.StatePortable { + hookKey = "on_undock" + } + if cmds, ok := d.config.Hooks[hookKey]; ok && len(cmds) > 0 { + // Hooks run with their own context so they outlive the request. + go d.hookRunner.Run(context.Background(), cmds) + } + + return nil +} + +// anyExternalConnected returns true if at least one configured external +// monitor is physically present. +func (d *Daemon) anyExternalConnected(monitors []backend.MonitorInfo) bool { + for _, m := range monitors { + if m.Width > 0 && m.Height > 0 && m.Name != "" { + if d.config.MatchesExternal(m.Name, m.Description) { + return true + } + } + } + return false +} + +// getMonitorsWithRetry calls GetMonitors up to 3 times with 500ms delays. +func (d *Daemon) getMonitorsWithRetry(ctx context.Context) ([]backend.MonitorInfo, error) { + const maxRetries = 3 + const retryDelay = 500 * time.Millisecond + + var lastErr error + for i := 0; i < maxRetries; i++ { + monitors, err := d.backend.GetMonitors(ctx) + if err == nil { + return monitors, nil + } + lastErr = err + d.logger.Debug("get monitors retry", "attempt", i+1, "error", err) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryDelay): + } + } + return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr) +} + +// onShutdown applies the portable layout before exit if configured. +func (d *Daemon) onShutdown(ctx context.Context) { + if d.config.RestoreOnExit == nil || !*d.config.RestoreOnExit { + return + } + if d.state == backend.StatePortable { + return // already portable + } + + d.logger.Info("shutdown: restoring portable layout") + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := d.applyState(ctx, backend.StatePortable); err != nil { + d.logger.Error("shutdown restore failed", "error", err) + } +} + +// countEnabled returns how many MonitorEntries are enabled. +func countEnabled(entries []config.MonitorEntry) int { + n := 0 + for _, e := range entries { + if e.Enabled { + n++ + } + } + return n +} diff --git a/internal/hook/hook.go b/internal/hook/hook.go new file mode 100644 index 0000000..ca72cef --- /dev/null +++ b/internal/hook/hook.go @@ -0,0 +1,78 @@ +// Package hook runs shell commands after monitor layout changes. +// Hooks execute asynchronously with a timeout; failures are logged +// but never interrupt the daemon's operation. +package hook + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" +) + +const defaultTimeout = 30 * time.Second + +// Runner executes shell commands from the hooks configuration. +type Runner struct { + logger *slog.Logger +} + +// NewRunner creates a hook runner with the given logger. +func NewRunner(logger *slog.Logger) *Runner { + return &Runner{logger: logger} +} + +// Run executes a list of shell commands concurrently. Each command gets +// a separate sub-shell via "sh -c". If a command exceeds defaultTimeout, +// it is killed. Errors are logged but never returned. +// +// Commands undergo basic shell-like expansion for tildes and environment +// variables before execution. +func (r *Runner) Run(ctx context.Context, commands []string) { + for _, cmdStr := range commands { + cmdStr = strings.TrimSpace(cmdStr) + if cmdStr == "" { + continue + } + + go func(cmdStr string) { + if err := r.runOne(ctx, cmdStr); err != nil { + r.logger.Error("hook failed", "command", cmdStr, "error", err) + } + }(cmdStr) + } +} + +// runOne executes a single command with a timeout. +func (r *Runner) runOne(ctx context.Context, cmdStr string) error { + cmdStr = expandPath(cmdStr) + + cmdCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + r.logger.Debug("running hook", "command", cmdStr) + + cmd := exec.CommandContext(cmdCtx, "sh", "-c", cmdStr) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("hook: %w", err) + } + return nil +} + +// expandPath performs basic tilde and $HOME expansion. +func expandPath(s string) string { + home, err := os.UserHomeDir() + if err != nil { + return s + } + s = strings.ReplaceAll(s, "~", home) + s = strings.ReplaceAll(s, "$HOME", home) + s = strings.ReplaceAll(s, "${HOME}", home) + return s +} diff --git a/monitor-lets-go.example.yaml b/monitor-lets-go.example.yaml new file mode 100644 index 0000000..ccc542d --- /dev/null +++ b/monitor-lets-go.example.yaml @@ -0,0 +1,76 @@ +# monitor-lets-go example configuration +# Copy to ~/.config/monitor-lets-go/config.yaml and adjust for your hardware. + +# Backend selection: "auto" (recommended), "hyprland", or "sway" (future). +backend: auto + +# Quiet period after the last hotplug event before re-evaluating. +# Docking stations fire multiple events; debounce coalesces them. +debounce: 1200ms + +# Polling fallback interval. The daemon listens for compositor events +# but also polls as a safety net. Set to 0 to disable polling. +poll_interval: 5s + +# Whether to restore the portable layout when the daemon shuts down. +restore_on_exit: true + +# External monitors that trigger docked mode. +# Plain name: matches the connector name (e.g. DP-1, HDMI-A-1). +# desc: prefix: matches by monitor description (survives rename). +external: + - DP-1 + - DP-2 + - desc:Dell Inc. DELL U2723QE + +# Monitor layouts for each mode. +# "portable" and "docked" are required. +# Each mode lists monitors with their desired configuration. +# +# Fields: +# name — connector name or desc:description +# enabled — true to show, false to disable +# mode — "preferred" (auto-detect), "1920x1080@60", etc. +# position — "auto", "0x0", "1920x0", etc. +# scale — 1, 1.5, 2, etc. + +modes: + portable: + monitors: + - name: eDP-1 + enabled: true + mode: preferred + position: "0x0" + scale: 1.0 + + docked: + monitors: + - name: DP-1 + enabled: true + mode: "2560x1440@144" + position: "0x0" + scale: 1.0 + - name: DP-2 + enabled: true + mode: "2560x1440@144" + position: "2560x0" + scale: 1.0 + - name: eDP-1 + enabled: false + - name: desc:Dell Inc. DELL U2723QE + enabled: true + mode: "3840x2160@60" + position: "0x0" + scale: 1.5 + +# 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: + - "systemctl --user restart waybar" + - "~/.config/monitor-lets-go/on-dock.sh" + + on_undock: + - "systemctl --user restart waybar" + - "~/.config/monitor-lets-go/on-undock.sh"