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:
@@ -147,7 +147,7 @@ func (h *hyprlandBackend) WriteConfig(ctx context.Context, monitors []MonitorCon
|
||||
"error", err)
|
||||
var plain []MonitorConfig
|
||||
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:") {
|
||||
plain = append(plain, m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(testDiscard{}, nil))
|
||||
}
|
||||
|
||||
type testDiscard struct{}
|
||||
|
||||
func (testDiscard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func TestResolveMonitorNamesSerial(t *testing.T) {
|
||||
current := []MonitorInfo{
|
||||
{Name: "eDP-1", Description: "Lenovo", Width: 1920, Height: 1200},
|
||||
{Name: "DP-12", Description: "Xiaomi Mi Monitor", Serial: "3342300033911", Width: 2560, Height: 1440},
|
||||
{Name: "DP-11", Description: "Xiaomi Mi Monitor", Serial: "", Width: 3440, Height: 1440},
|
||||
}
|
||||
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||
|
||||
monitors := []MonitorConfig{
|
||||
{Name: "serial:3342300033911", Enabled: true},
|
||||
{Name: "eDP-1", Enabled: false},
|
||||
}
|
||||
resolved, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMonitorNames: %v", err)
|
||||
}
|
||||
if resolved[0].Name != "DP-12" {
|
||||
t.Errorf("serial should resolve to DP-12, got %q", resolved[0].Name)
|
||||
}
|
||||
if resolved[1].Name != "eDP-1" {
|
||||
t.Errorf("plain name should pass through, got %q", resolved[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMonitorNamesSerialNoMatch(t *testing.T) {
|
||||
current := []MonitorInfo{
|
||||
{Name: "DP-12", Description: "Xiaomi", Serial: "3342300033911"},
|
||||
}
|
||||
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||
|
||||
monitors := []MonitorConfig{{Name: "serial:9999999999", Enabled: true}}
|
||||
if _, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger()); err == nil {
|
||||
t.Error("unresolvable serial should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMonitorNamesSerialMultipleMatches(t *testing.T) {
|
||||
current := []MonitorInfo{
|
||||
{Name: "DP-1", Serial: "SN-XYZ"},
|
||||
{Name: "DP-2", Serial: "SN-XYZ"},
|
||||
}
|
||||
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||
|
||||
monitors := []MonitorConfig{{Name: "serial:SN-XYZ", Enabled: true}}
|
||||
if _, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger()); err == nil {
|
||||
t.Error("ambiguous serial match should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMonitorNamesDescAlsoMatchesSerial(t *testing.T) {
|
||||
current := []MonitorInfo{
|
||||
{Name: "DP-12", Description: "Generic", Serial: "3342300033911"},
|
||||
}
|
||||
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||
|
||||
monitors := []MonitorConfig{{Name: "desc:3342300033911", Enabled: true}}
|
||||
resolved, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMonitorNames: %v", err)
|
||||
}
|
||||
if resolved[0].Name != "DP-12" {
|
||||
t.Errorf("desc should match via serial, got %q", resolved[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -193,9 +193,9 @@ func (s *swayBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) {
|
||||
// then disabled monitors are turned off. All commands are sent in a single
|
||||
// swaymsg call separated by ';' so Sway executes them as one IPC message.
|
||||
//
|
||||
// Monitor names prefixed with "desc:" are resolved to actual connector names
|
||||
// by querying the current monitor state. This makes configs portable across
|
||||
// dock ports and reboots.
|
||||
// Monitor names prefixed with "desc:", "serial:", or "size:" are resolved
|
||||
// to actual connector names by querying the current monitor state. This
|
||||
// makes configs portable across dock ports and reboots.
|
||||
//
|
||||
// Safety: the daemon guarantees at least one enabled monitor exists before
|
||||
// calling ApplyLayout. Sway refuses to disable the last active output, so
|
||||
|
||||
Reference in New Issue
Block a user