// 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" "fmt" "log/slog" "strings" ) // 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 } // resolveMonitorNames converts desc: prefixed monitor config names to the // actual connector names (e.g. "desc:Xiaomi Corporation Mi Monitor" → "DP-9") // by matching against the current set of connected monitors. // // Matching logic: // - "desc:make model" — substring match against description (make + " " + model) // - "desc:serial" — substring match against serial number // - plain names are returned as-is func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonitors func(context.Context) ([]MonitorInfo, error), logger *slog.Logger) ([]MonitorConfig, error) { var needsDescResolution bool for _, m := range monitors { if strings.HasPrefix(m.Name, "desc:") { needsDescResolution = true break } } if !needsDescResolution { return monitors, nil } current, err := getMonitors(ctx) if err != nil { return nil, fmt.Errorf("get current monitors: %w", err) } resolved := make([]MonitorConfig, len(monitors)) for i, m := range monitors { if !strings.HasPrefix(m.Name, "desc:") { resolved[i] = m continue } needle := strings.TrimPrefix(m.Name, "desc:") var matches []MonitorInfo for _, mi := range current { if strings.Contains(mi.Description, needle) || strings.Contains(mi.Serial, needle) { matches = append(matches, mi) } } switch len(matches) { case 0: return nil, fmt.Errorf("no connected monitor matches desc:%q", needle) case 1: resolved[i] = m resolved[i].Name = matches[0].Name logger.Debug("resolved desc to connector", "desc", needle, "connector", matches[0].Name) default: var names []string for _, mat := range matches { names = append(names, mat.Name) } return nil, fmt.Errorf( "desc:%q matches multiple monitors: %s — use a more specific identifier", needle, strings.Join(names, ", ")) } } return resolved, nil }