Initial commit: monitor-lets-go — automatic monitor layout daemon
This commit is contained in:
commit
a4b80616bb
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Binary
|
||||||
|
/monitor-lets-go
|
||||||
|
/neodock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
52
Makefile
Normal file
52
Makefile
Normal file
@ -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)
|
||||||
336
README.md
Normal file
336
README.md
Normal file
@ -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/<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/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
|
||||||
190
cmd/neodock/main.go
Normal file
190
cmd/neodock/main.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
32
contrib/monitor-lets-go.service
Normal file
32
contrib/monitor-lets-go.service
Normal file
@ -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
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module monitor-lets-go
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -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=
|
||||||
326
internal/backend/hyprland.go
Normal file
326
internal/backend/hyprland.go
Normal file
@ -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
|
||||||
|
}
|
||||||
99
internal/backend/interface.go
Normal file
99
internal/backend/interface.go
Normal file
@ -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
|
||||||
|
}
|
||||||
186
internal/config/config.go
Normal file
186
internal/config/config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
311
internal/daemon/daemon.go
Normal file
311
internal/daemon/daemon.go
Normal file
@ -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
|
||||||
|
}
|
||||||
78
internal/hook/hook.go
Normal file
78
internal/hook/hook.go
Normal file
@ -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
|
||||||
|
}
|
||||||
76
monitor-lets-go.example.yaml
Normal file
76
monitor-lets-go.example.yaml
Normal file
@ -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"
|
||||||
Loading…
x
Reference in New Issue
Block a user