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
+71 -10
View File
@@ -5,7 +5,9 @@ package config
import (
"errors"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
@@ -194,9 +196,14 @@ func countEnabled(entries []MonitorEntry) int {
}
// isInternalConnector returns true if the connector name matches a known
// internal display pattern (eDP, LVDS, DSI). These are always part of the
// laptop or tablet and should never trigger docked mode.
// 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) {
@@ -206,31 +213,85 @@ func isInternalConnector(name string) bool {
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 name
// - desc: prefix: substring match against description
// - 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-) is automatically external.
func (c *Config) MatchesExternal(name, description string) bool {
// 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(name)
return !isInternalConnector(m.Name)
}
for _, ext := range c.External {
switch {
case strings.HasPrefix(ext, "desc:"):
desc := strings.TrimPrefix(ext, "desc:")
if strings.Contains(description, 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 name == ext {
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
}