Maksim Totmin ac2bb77113 fix(hyprland): resolve desc: monitor names before generating config
Extract shared resolveMonitorNames to backend package so both
Hyprland and Sway backends resolve desc: prefixed names to actual
connector names before applying monitor layout.

Previously the Hyprland backend wrote desc:... literally into
monitors.conf, which Hyprland ignores, causing monitors to be
positioned incorrectly or not configured at all.
2026-06-23 09:06:08 +07:00

167 lines
5.2 KiB
Go

// 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
}