- external list now accepts serial: and size: prefixes; desc: matches serial too. Fixes docked-mode deadlock where Hyprland's synthetic FALLBACK output (created when the built-in is disabled by a stale docked config) was treated as a real external monitor, leaving the daemon stuck in docked after undocking. - modes name resolution gains serial: prefix (resolveSerial). - add -log-level flag for observability. - add unit tests for external matching and name resolution. - update README and example config.
298 lines
8.3 KiB
Go
298 lines
8.3 KiB
Go
// Package config handles parsing, validation, and defaults for the
|
|
// monitor-lets-go YAML configuration file.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"strconv"
|
|
"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"`
|
|
|
|
// OutputPath overrides the generated monitor config file path.
|
|
// Supports ~ for home directory expansion.
|
|
// Deprecated: use backend_config.output_path instead.
|
|
// Empty means the default: ~/.config/hypr/monitors.lua.
|
|
OutputPath string `yaml:"output_path"`
|
|
|
|
// BackendConfig holds backend-specific configuration options.
|
|
// Keys and values vary by the selected backend.
|
|
// Hyprland supports:
|
|
// output_path — path to the generated monitor config file
|
|
BackendConfig map[string]any `yaml:"backend_config"`
|
|
|
|
// 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")
|
|
}
|
|
// External list is optional. When empty, the daemon auto-detects
|
|
// external monitors (any non-internal display with non-zero dimensions).
|
|
|
|
return nil
|
|
}
|
|
|
|
// EffectiveBackendConfig merges top-level deprecated backend keys
|
|
// (OutputPath) into the BackendConfig map. backend_config values
|
|
// take priority over deprecated top-level keys.
|
|
//
|
|
// The caller receives a non-nil map even when no backend_config is
|
|
// set and no deprecated keys are present.
|
|
func (c *Config) EffectiveBackendConfig() map[string]any {
|
|
bc := c.BackendConfig
|
|
if bc == nil {
|
|
bc = make(map[string]any)
|
|
}
|
|
if c.OutputPath != "" {
|
|
if _, exists := bc["output_path"]; !exists {
|
|
bc["output_path"] = c.OutputPath
|
|
}
|
|
}
|
|
return bc
|
|
}
|
|
|
|
// countEnabled returns how many MonitorEntries are enabled.
|
|
func countEnabled(entries []MonitorEntry) int {
|
|
n := 0
|
|
for _, e := range entries {
|
|
if e.Enabled {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// isInternalConnector returns true if the connector name matches a known
|
|
// internal display pattern (eDP, LVDS, DSI) or Hyprland's synthetic
|
|
// "FALLBACK" output. Internal and synthetic displays are always part of the
|
|
// laptop/tablet (or a no-display fallback) and should never trigger docked
|
|
// mode.
|
|
func isInternalConnector(name string) bool {
|
|
if strings.EqualFold(name, "fallback") {
|
|
return true
|
|
}
|
|
prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"}
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(name, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ExternalMonitor carries the identity of a detected monitor so the
|
|
// external list can match by name, description, serial, or resolution.
|
|
type ExternalMonitor struct {
|
|
Name string
|
|
Description string
|
|
Serial string
|
|
Width int
|
|
Height int
|
|
RefreshRate float64
|
|
}
|
|
|
|
// MatchesExternal checks whether a monitor matches the External list.
|
|
// Supports match modes:
|
|
//
|
|
// - Plain name: exact match against the connector name
|
|
// - desc: prefix: substring match against description or serial
|
|
// - serial: prefix: substring match against serial
|
|
// - size:WxH / size:WxH@R: exact pixel dimensions, optionally plus
|
|
// refresh rate (within ±1 Hz)
|
|
//
|
|
// When the External list is empty, any monitor that is not an internal
|
|
// display connector (eDP-, LVDS-, DSI-) or synthetic output (FALLBACK)
|
|
// is automatically external.
|
|
func (c *Config) MatchesExternal(m ExternalMonitor) bool {
|
|
if len(c.External) == 0 {
|
|
return !isInternalConnector(m.Name)
|
|
}
|
|
|
|
for _, ext := range c.External {
|
|
switch {
|
|
case strings.HasPrefix(ext, "desc:"):
|
|
needle := strings.TrimPrefix(ext, "desc:")
|
|
if strings.Contains(m.Description, needle) || strings.Contains(m.Serial, needle) {
|
|
return true
|
|
}
|
|
case strings.HasPrefix(ext, "serial:"):
|
|
needle := strings.TrimPrefix(ext, "serial:")
|
|
if strings.Contains(m.Serial, needle) {
|
|
return true
|
|
}
|
|
case strings.HasPrefix(ext, "size:"):
|
|
if matchSize(ext, m.Width, m.Height, m.RefreshRate) {
|
|
return true
|
|
}
|
|
default:
|
|
if m.Name == ext {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchSize reports whether a monitor matches a "size:WxH" or "size:WxH@R"
|
|
// spec by exact pixel dimensions and, when a refresh rate is given, by
|
|
// refresh rate within ±1 Hz.
|
|
func matchSize(spec string, width, height int, refresh float64) bool {
|
|
spec = strings.TrimPrefix(spec, "size:")
|
|
|
|
parts := strings.Split(spec, "@")
|
|
dimParts := strings.Split(parts[0], "x")
|
|
if len(dimParts) != 2 {
|
|
return false
|
|
}
|
|
|
|
w, errW := strconv.Atoi(strings.TrimSpace(dimParts[0]))
|
|
h, errH := strconv.Atoi(strings.TrimSpace(dimParts[1]))
|
|
if errW != nil || errH != nil {
|
|
return false
|
|
}
|
|
if w != width || h != height {
|
|
return false
|
|
}
|
|
|
|
if len(parts) == 2 {
|
|
r, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
|
if err != nil || math.Abs(r-refresh) > 1.0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|