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
+17 -7
View File
@@ -144,11 +144,15 @@ restore_on_exit: true
# External monitors that trigger docked mode. # External monitors that trigger docked mode.
# Plain name: matches connector (DP-1, HDMI-A-1). # Plain name: matches connector (DP-1, HDMI-A-1).
# desc: prefix: matches by monitor description (survives port rename). # desc: prefix: matches by monitor description or serial (survives port rename).
# serial: prefix: matches by serial number.
# size: prefix: matches by resolution, optionally with refresh rate (size:2560x1440@165).
# Optional — if omitted or empty, the daemon auto-detects external monitors: # Optional — if omitted or empty, the daemon auto-detects external monitors:
# any display whose connector is not eDP-/LVDS-/DSI- is treated as external. # any display whose connector is not eDP-/LVDS-/DSI- is treated as external.
external: # optional external: # optional
- desc:Dell Inc. DELL U2723QE - desc:Dell Inc. DELL U2723QE
- serial:3342300033911
- size:3440x1440
- DP-9 - DP-9
- DP-10 - DP-10
@@ -197,19 +201,22 @@ hooks:
### Monitor matching ### Monitor matching
Three match modes for the `modes` section (the `external` list supports plain names and `desc:`): Match modes for the `modes` section and the `external` list. The `external` list supports all four modes below (plus auto-detect when empty); the `modes` section supports all four as `name:` values:
| Syntax | Matches | Use case | | Syntax | Matches | Use case |
|---|---|---| |---|---|---|
| `DP-1` | Exact connector name | Simple setups, built-in displays | | `DP-1` | Exact connector name | Simple setups, built-in displays |
| `desc:Dell U2723QE` | Substring in monitor description or serial | Survives port rename across different docks | | `desc:Dell U2723QE` | Substring in monitor description or serial | Survives port rename across different docks |
| `size:2560x1440` | Exact pixel dimensions | Two identical monitors with same resolution | | `serial:3342300033911` | Substring in serial number | Uniquely identifies a specific monitor |
| `size:2560x1440` | Exact pixel dimensions | Match by resolution instead of connector name |
| `size:2560x1440@165` | Dimensions + refresh rate | Disambiguate identical models | | `size:2560x1440@165` | Dimensions + refresh rate | Disambiguate identical models |
Sway description format: `make model serial_widthxheight` (with serial omitted if `Unknown`). Sway description format: `make model serial_widthxheight` (with serial omitted if `Unknown`).
Run `hyprctl monitors all` (Hyprland) or `swaymsg -t get_outputs` (Sway) to see your monitor names and descriptions. Run `hyprctl monitors all` (Hyprland) or `swaymsg -t get_outputs` (Sway) to see your monitor names, descriptions, and serials.
**desc: and size: in modes** — when a monitor name in `modes` uses `desc:` or `size:`, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a prefix matching multiple monitors with identical resolution) cause an error. For `size:`, add `@R` (refresh rate) to disambiguate monitors with the same resolution. Unresolvable names also cause an error. **desc:, serial: and size: in modes** — when a monitor name in `modes` uses `desc:`, `serial:`, or `size:`, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a prefix matching multiple monitors) cause an error. For `size:`, add `@R` (refresh rate) to disambiguate monitors with the same resolution. Unresolvable names also cause an error.
**External list** — entries are checked against every detected monitor; a single match triggers docked mode. When the list is empty, any non-internal connector is treated as external.
### Backend-specific configuration ### Backend-specific configuration
@@ -351,20 +358,23 @@ Check the hook command works from a terminal first. Hooks run via `sh -c`, so sh
### Monitor names changed after reboot ### Monitor names changed after reboot
Use `desc:` or `size:` prefix matching instead of connector names. Both survive port renames across different docks and reboots. Use `desc:`, `serial:`, or `size:` prefix matching instead of connector names. All three survive port renames across different docks and reboots.
**Hyprland:** `hyprctl monitors all` to see descriptions. **Hyprland:** `hyprctl monitors all` to see descriptions and serials.
**Sway:** `swaymsg -t get_outputs` to see names, make/model, serial, and native resolution. **Sway:** `swaymsg -t get_outputs` to see names, make/model, serial, and native resolution.
```yaml ```yaml
external: external:
- desc:Dell Inc. DELL U2723QE # survives port rename - desc:Dell Inc. DELL U2723QE # survives port rename
- serial:3342300033911 # match a specific monitor by serial
modes: modes:
docked: docked:
monitors: monitors:
- name: desc:Dell Inc. DELL U2723QE # also works here - name: desc:Dell Inc. DELL U2723QE # also works here
enabled: true enabled: true
- name: serial:3342300033911 # and here
enabled: true
- name: size:2560x1440@165 # match by dimensions + refresh - name: size:2560x1440@165 # match by dimensions + refresh
enabled: true enabled: true
``` ```
+18 -1
View File
@@ -23,6 +23,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
@@ -37,6 +38,7 @@ var (
prepare bool prepare bool
applyMode string applyMode string
noReload bool noReload bool
logLevel string
) )
func init() { func init() {
@@ -44,13 +46,28 @@ func init() {
flag.BoolVar(&prepare, "prepare", false, "remove disabled monitors from config file and exit") flag.BoolVar(&prepare, "prepare", false, "remove disabled monitors from config file and exit")
flag.StringVar(&applyMode, "apply", "", "apply a mode (portable/docked) and exit") flag.StringVar(&applyMode, "apply", "", "apply a mode (portable/docked) and exit")
flag.BoolVar(&noReload, "no-reload", false, "when used with --apply, write config without reloading the compositor") flag.BoolVar(&noReload, "no-reload", false, "when used with --apply, write config without reloading the compositor")
flag.StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
}
// parseLogLevel converts a flag value to an slog.Level.
func parseLogLevel(s string) slog.Level {
switch strings.ToLower(s) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
} }
func main() { func main() {
flag.Parse() flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo, Level: parseLogLevel(logLevel),
})) }))
switch { switch {
+1 -1
View File
@@ -147,7 +147,7 @@ func (h *hyprlandBackend) WriteConfig(ctx context.Context, monitors []MonitorCon
"error", err) "error", err)
var plain []MonitorConfig var plain []MonitorConfig
for _, m := range monitors { 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) plain = append(plain, m)
} }
} }
+39 -4
View File
@@ -117,19 +117,20 @@ type Backend interface {
Close() error Close() error
} }
// resolveMonitorNames converts desc: or size: prefixed monitor config names // resolveMonitorNames converts desc:, serial:, or size: prefixed monitor
// to the actual connector names by matching against the current set of // config names to the actual connector names by matching against the current
// connected monitors. // set of connected monitors.
// //
// Matching logic: // Matching logic:
// - "desc:text" — substring match against description or serial // - "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" — match by exact pixel dimensions (e.g. "size:2560x1440")
// - "size:WxH@R" — match by dimensions + refresh rate (e.g. "size:2560x1440@165") // - "size:WxH@R" — match by dimensions + refresh rate (e.g. "size:2560x1440@165")
// - plain names are returned as-is // - plain names are returned as-is
func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonitors func(context.Context) ([]MonitorInfo, error), logger *slog.Logger) ([]MonitorConfig, error) { func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonitors func(context.Context) ([]MonitorInfo, error), logger *slog.Logger) ([]MonitorConfig, error) {
var needsResolution bool var needsResolution bool
for _, m := range monitors { 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 needsResolution = true
break break
} }
@@ -148,6 +149,8 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit
switch { switch {
case strings.HasPrefix(m.Name, "desc:"): case strings.HasPrefix(m.Name, "desc:"):
resolved[i] = resolveDesc(m, current, logger) resolved[i] = resolveDesc(m, current, logger)
case strings.HasPrefix(m.Name, "serial:"):
resolved[i] = resolveSerial(m, current, logger)
case strings.HasPrefix(m.Name, "size:"): case strings.HasPrefix(m.Name, "size:"):
resolved[i] = resolveSize(m, current, logger) resolved[i] = resolveSize(m, current, logger)
default: default:
@@ -161,6 +164,38 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit
return resolved, nil 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. // resolveDesc resolves a desc: prefixed name to a connector name.
func resolveDesc(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig { func resolveDesc(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig {
needle := strings.TrimPrefix(m.Name, "desc:") needle := strings.TrimPrefix(m.Name, "desc:")
+80
View File
@@ -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)
}
}
+3 -3
View File
@@ -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 // 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. // swaymsg call separated by ';' so Sway executes them as one IPC message.
// //
// Monitor names prefixed with "desc:" are resolved to actual connector names // Monitor names prefixed with "desc:", "serial:", or "size:" are resolved
// by querying the current monitor state. This makes configs portable across // to actual connector names by querying the current monitor state. This
// dock ports and reboots. // makes configs portable across dock ports and reboots.
// //
// Safety: the daemon guarantees at least one enabled monitor exists before // Safety: the daemon guarantees at least one enabled monitor exists before
// calling ApplyLayout. Sway refuses to disable the last active output, so // calling ApplyLayout. Sway refuses to disable the last active output, so
+71 -10
View File
@@ -5,7 +5,9 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"math"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -194,9 +196,14 @@ func countEnabled(entries []MonitorEntry) int {
} }
// isInternalConnector returns true if the connector name matches a known // isInternalConnector returns true if the connector name matches a known
// internal display pattern (eDP, LVDS, DSI). These are always part of the // internal display pattern (eDP, LVDS, DSI) or Hyprland's synthetic
// laptop or tablet and should never trigger docked mode. // "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 { func isInternalConnector(name string) bool {
if strings.EqualFold(name, "fallback") {
return true
}
prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"} prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"}
for _, p := range prefixes { for _, p := range prefixes {
if strings.HasPrefix(name, p) { if strings.HasPrefix(name, p) {
@@ -206,31 +213,85 @@ func isInternalConnector(name string) bool {
return false 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. // MatchesExternal checks whether a monitor matches the External list.
// Supports match modes: // Supports match modes:
// //
// - Plain name: exact match against name // - Plain name: exact match against the connector name
// - desc: prefix: substring match against description // - 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 // When the External list is empty, any monitor that is not an internal
// display connector (eDP-, LVDS-, DSI-) is automatically external. // display connector (eDP-, LVDS-, DSI-) or synthetic output (FALLBACK)
func (c *Config) MatchesExternal(name, description string) bool { // is automatically external.
func (c *Config) MatchesExternal(m ExternalMonitor) bool {
if len(c.External) == 0 { if len(c.External) == 0 {
return !isInternalConnector(name) return !isInternalConnector(m.Name)
} }
for _, ext := range c.External { for _, ext := range c.External {
switch { switch {
case strings.HasPrefix(ext, "desc:"): case strings.HasPrefix(ext, "desc:"):
desc := strings.TrimPrefix(ext, "desc:") needle := strings.TrimPrefix(ext, "desc:")
if strings.Contains(description, 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 return true
} }
default: default:
if name == ext { if m.Name == ext {
return true return true
} }
} }
} }
return false 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
}
+135
View File
@@ -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")
}
}
+21 -3
View File
@@ -183,6 +183,9 @@ func (d *Daemon) pollCheck(ctx context.Context) {
} }
// determineState checks whether any external monitor is physically connected. // determineState checks whether any external monitor is physically connected.
// Synthetic outputs that never represent a real external display (e.g.
// Hyprland's FALLBACK monitor) are excluded by MatchesExternal, so a stuck
// docked config cannot keep the daemon in docked mode after undocking.
func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State { func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State {
for _, m := range monitors { for _, m := range monitors {
// Skip phantom or disconnected monitors. // Skip phantom or disconnected monitors.
@@ -191,7 +194,14 @@ func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State {
} }
// A monitor is physically present if it reports non-zero dimensions. // A monitor is physically present if it reports non-zero dimensions.
if m.Width > 0 && m.Height > 0 { if m.Width > 0 && m.Height > 0 {
if d.config.MatchesExternal(m.Name, m.Description) { if d.config.MatchesExternal(config.ExternalMonitor{
Name: m.Name,
Description: m.Description,
Serial: m.Serial,
Width: m.Width,
Height: m.Height,
RefreshRate: m.RefreshRate,
}) {
return backend.StateDocked return backend.StateDocked
} }
} }
@@ -261,11 +271,19 @@ func (d *Daemon) applyState(ctx context.Context, state backend.State) error {
} }
// anyExternalConnected returns true if at least one configured external // anyExternalConnected returns true if at least one configured external
// monitor is physically present. // monitor is physically present. Synthetic outputs (e.g. Hyprland's FALLBACK)
// are excluded via MatchesExternal.
func (d *Daemon) anyExternalConnected(monitors []backend.MonitorInfo) bool { func (d *Daemon) anyExternalConnected(monitors []backend.MonitorInfo) bool {
for _, m := range monitors { for _, m := range monitors {
if m.Width > 0 && m.Height > 0 && m.Name != "" { if m.Width > 0 && m.Height > 0 && m.Name != "" {
if d.config.MatchesExternal(m.Name, m.Description) { if d.config.MatchesExternal(config.ExternalMonitor{
Name: m.Name,
Description: m.Description,
Serial: m.Serial,
Width: m.Width,
Height: m.Height,
RefreshRate: m.RefreshRate,
}) {
return true return true
} }
} }
+8 -2
View File
@@ -25,7 +25,10 @@ restore_on_exit: true
# External monitors that trigger docked mode. # External monitors that trigger docked mode.
# Plain name: matches the connector name (e.g. DP-1, HDMI-A-1). # Plain name: matches the connector name (e.g. DP-1, HDMI-A-1).
# desc: prefix: matches by monitor description (survives rename). # desc: prefix: matches by monitor description or serial (survives rename).
# serial: prefix: matches by serial number (e.g. serial:3342300033911).
# size: prefix: matches by resolution, optionally with refresh
# (e.g. size:2560x1440 or size:2560x1440@165).
# Optional — if omitted or left empty, the daemon auto-detects external # Optional — if omitted or left empty, the daemon auto-detects external
# monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated # monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated
# as external. # as external.
@@ -33,13 +36,16 @@ external:
- DP-1 - DP-1
- DP-2 - DP-2
- desc:Dell Inc. DELL U2723QE - desc:Dell Inc. DELL U2723QE
- serial:3342300033911
- size:3440x1440@120
# Monitor layouts for each mode. # Monitor layouts for each mode.
# "portable" and "docked" are required. # "portable" and "docked" are required.
# Monitors connected but not listed in the mode are automatically disabled. # Monitors connected but not listed in the mode are automatically disabled.
# #
# Fields: # Fields:
# name — connector name, desc:description, size:WxH, or size:WxH@R # name — connector name, desc:description, serial:XXXX, size:WxH,
# or size:WxH@R
# enabled — true to show, false to disable # enabled — true to show, false to disable
# mode — "preferred" (auto-detect), "1920x1080@60", etc. # mode — "preferred" (auto-detect), "1920x1080@60", etc.
# position — "auto", "0x0", "1920x0", etc. # position — "auto", "0x0", "1920x0", etc.