Initial commit: monitor-lets-go — automatic monitor layout daemon

This commit is contained in:
Maksim Totmin
2026-06-22 16:22:47 +07:00
commit a4b80616bb
13 changed files with 1708 additions and 0 deletions
+326
View 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
View 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
View 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
View 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
View 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
}