From ac2bb77113ee2f143a8285621246d4006ef53354 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Tue, 23 Jun 2026 09:06:08 +0700 Subject: [PATCH] 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. --- internal/backend/hyprland.go | 6 +++ internal/backend/interface.go | 69 ++++++++++++++++++++++++++++++++++- internal/backend/sway.go | 64 +------------------------------- 3 files changed, 75 insertions(+), 64 deletions(-) diff --git a/internal/backend/hyprland.go b/internal/backend/hyprland.go index 5717d37..1b1da39 100644 --- a/internal/backend/hyprland.go +++ b/internal/backend/hyprland.go @@ -93,6 +93,12 @@ func (h *hyprlandBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error // 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 { + var err error + monitors, err = resolveMonitorNames(ctx, monitors, h.GetMonitors, h.logger) + if err != nil { + return fmt.Errorf("resolve monitor names: %w", err) + } + content := h.generateConf(monitors) destPath, err := h.resolveOutputPath() diff --git a/internal/backend/interface.go b/internal/backend/interface.go index ac1a978..44a0886 100644 --- a/internal/backend/interface.go +++ b/internal/backend/interface.go @@ -8,7 +8,12 @@ // cmd/monitor-lets-go/main.go. package backend -import "context" +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 @@ -97,3 +102,65 @@ type Backend interface { // 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 +} diff --git a/internal/backend/sway.go b/internal/backend/sway.go index 9cb1ec5..5f2230f 100644 --- a/internal/backend/sway.go +++ b/internal/backend/sway.go @@ -202,7 +202,7 @@ func (s *swayBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) { // enable commands always precede disables. func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error { var err error - monitors, err = s.resolveMonitorNames(ctx, monitors) + monitors, err = resolveMonitorNames(ctx, monitors, s.GetMonitors, s.logger) if err != nil { return fmt.Errorf("resolve monitor names: %w", err) } @@ -222,68 +222,6 @@ func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) return nil } -// 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 (s *swayBackend) resolveMonitorNames(ctx context.Context, monitors []MonitorConfig) ([]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 := s.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 - s.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 -} - // buildCommands assembles the swaymsg command string. // Enabled monitors go first (enable + configure), then disabled ones. // Commands are joined with ';' for batch execution.