feat: serial:/size: external matching, exclude Hyprland FALLBACK output

- 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.
This commit is contained in:
Maksim Totmin
2026-08-06 12:25:47 +07:00
parent 3515608fd3
commit ab6cad0505
10 changed files with 393 additions and 31 deletions
+39 -4
View File
@@ -117,19 +117,20 @@ type Backend interface {
Close() error
}
// resolveMonitorNames converts desc: or size: prefixed monitor config names
// to the actual connector names by matching against the current set of
// connected monitors.
// resolveMonitorNames converts desc:, serial:, or size: prefixed monitor
// config names to the actual connector names by matching against the current
// set of connected monitors.
//
// Matching logic:
// - "desc:text" — substring match against description or serial
// - "serial:text" — substring match against serial
// - "size:WxH" — match by exact pixel dimensions (e.g. "size:2560x1440")
// - "size:WxH@R" — match by dimensions + refresh rate (e.g. "size:2560x1440@165")
// - 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 needsResolution bool
for _, m := range monitors {
if strings.HasPrefix(m.Name, "desc:") || strings.HasPrefix(m.Name, "size:") {
if strings.HasPrefix(m.Name, "desc:") || strings.HasPrefix(m.Name, "serial:") || strings.HasPrefix(m.Name, "size:") {
needsResolution = true
break
}
@@ -148,6 +149,8 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit
switch {
case strings.HasPrefix(m.Name, "desc:"):
resolved[i] = resolveDesc(m, current, logger)
case strings.HasPrefix(m.Name, "serial:"):
resolved[i] = resolveSerial(m, current, logger)
case strings.HasPrefix(m.Name, "size:"):
resolved[i] = resolveSize(m, current, logger)
default:
@@ -161,6 +164,38 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit
return resolved, nil
}
// resolveSerial resolves a serial: prefixed name to a connector name.
func resolveSerial(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig {
needle := strings.TrimPrefix(m.Name, "serial:")
var matches []MonitorInfo
for _, mi := range current {
if strings.Contains(mi.Serial, needle) {
matches = append(matches, mi)
}
}
switch len(matches) {
case 0:
// Return as-is so the caller sees the empty name.
m.Name = ""
return m
case 1:
logger.Debug("resolved serial to connector",
"serial", needle, "connector", matches[0].Name)
m.Name = matches[0].Name
return m
default:
var names []string
for _, mat := range matches {
names = append(names, mat.Name)
}
logger.Warn("serial:%q matches multiple monitors: %s — use a more specific identifier",
needle, strings.Join(names, ", "))
m.Name = ""
return m
}
}
// resolveDesc resolves a desc: prefixed name to a connector name.
func resolveDesc(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig {
needle := strings.TrimPrefix(m.Name, "desc:")