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:
+71
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchesExternalAutoDetect(t *testing.T) {
|
||||
c := &Config{} // empty external list → auto-detect
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
m ExternalMonitor
|
||||
want bool
|
||||
}{
|
||||
{"internal eDP", ExternalMonitor{Name: "eDP-1"}, false},
|
||||
{"internal LVDS", ExternalMonitor{Name: "LVDS-1"}, false},
|
||||
{"internal DSI", ExternalMonitor{Name: "DSI-1"}, false},
|
||||
{"synthetic fallback upper", ExternalMonitor{Name: "FALLBACK"}, false},
|
||||
{"synthetic fallback lower", ExternalMonitor{Name: "fallback"}, false},
|
||||
{"external DP", ExternalMonitor{Name: "DP-3"}, true},
|
||||
{"external HDMI", ExternalMonitor{Name: "HDMI-A-1"}, true},
|
||||
{"empty name", ExternalMonitor{Name: ""}, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := c.MatchesExternal(tc.m); got != tc.want {
|
||||
t.Errorf("MatchesExternal(%+v) = %v, want %v", tc.m, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesExternalPlainName(t *testing.T) {
|
||||
c := &Config{External: []string{"DP-1", "DP-2"}}
|
||||
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-1"}) {
|
||||
t.Error("exact DP-1 should match")
|
||||
}
|
||||
if c.MatchesExternal(ExternalMonitor{Name: "DP-3"}) {
|
||||
t.Error("DP-3 should not match when external lists DP-1/DP-2")
|
||||
}
|
||||
if c.MatchesExternal(ExternalMonitor{Name: "eDP-1"}) {
|
||||
t.Error("eDP-1 should not match explicit external list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesExternalDescMatchesDescriptionAndSerial(t *testing.T) {
|
||||
c := &Config{External: []string{"desc:Xiaomi"}}
|
||||
|
||||
m := ExternalMonitor{
|
||||
Name: "DP-12",
|
||||
Description: "Xiaomi Corporation Mi Monitor 3342300033911",
|
||||
Serial: "3342300033911",
|
||||
}
|
||||
if !c.MatchesExternal(m) {
|
||||
t.Error("desc should match via description")
|
||||
}
|
||||
|
||||
m.Serial = ""
|
||||
m.Description = "Generic Monitor"
|
||||
if c.MatchesExternal(m) {
|
||||
t.Error("desc should not match when neither description nor serial contains needle")
|
||||
}
|
||||
|
||||
m.Description = "Generic Monitor"
|
||||
m.Serial = "Xiaomi 3342300033911"
|
||||
if !c.MatchesExternal(m) {
|
||||
t.Error("desc should match via serial even when description lacks the needle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesExternalSerial(t *testing.T) {
|
||||
c := &Config{External: []string{"serial:3342300033911"}}
|
||||
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: "3342300033911"}) {
|
||||
t.Error("serial exact should match")
|
||||
}
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: "WXYZ3342300033911ABC"}) {
|
||||
t.Error("serial substring should match")
|
||||
}
|
||||
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: ""}) {
|
||||
t.Error("empty serial should not match serial prefix")
|
||||
}
|
||||
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: "3342300033912"}) {
|
||||
t.Error("different serial should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesExternalSize(t *testing.T) {
|
||||
c := &Config{External: []string{"size:2560x1440"}}
|
||||
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440}) {
|
||||
t.Error("size exact should match")
|
||||
}
|
||||
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 1920, Height: 1200}) {
|
||||
t.Error("different size should not match")
|
||||
}
|
||||
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1080}) {
|
||||
t.Error("mixed dimensions should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesExternalSizeWithRefresh(t *testing.T) {
|
||||
c := &Config{External: []string{"size:2560x1440@165"}}
|
||||
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440, RefreshRate: 165}) {
|
||||
t.Error("size+refresh exact should match")
|
||||
}
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440, RefreshRate: 164.999}) {
|
||||
t.Error("size+refresh within tolerance should match")
|
||||
}
|
||||
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440, RefreshRate: 120}) {
|
||||
t.Error("different refresh should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesExternalPrefersAnyMatchingEntry(t *testing.T) {
|
||||
c := &Config{External: []string{"DP-1", "serial:3342300033911"}}
|
||||
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-1"}) {
|
||||
t.Error("plain name entry should match")
|
||||
}
|
||||
if !c.MatchesExternal(ExternalMonitor{Name: "DP-9", Serial: "3342300033911"}) {
|
||||
t.Error("serial entry should match via second list item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSizeInvalid(t *testing.T) {
|
||||
if matchSize("size:not-a-size", 2560, 1440, 0) {
|
||||
t.Error("garbage size spec should not match")
|
||||
}
|
||||
if matchSize("size:2560", 2560, 0, 0) {
|
||||
t.Error("malformed dimensions should not match")
|
||||
}
|
||||
if matchSize("size:2560x1440@bogus", 2560, 1440, 165) {
|
||||
t.Error("malformed refresh rate should not match")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user