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:
@@ -144,11 +144,15 @@ restore_on_exit: true
|
||||
|
||||
# External monitors that trigger docked mode.
|
||||
# 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:
|
||||
# any display whose connector is not eDP-/LVDS-/DSI- is treated as external.
|
||||
external: # optional
|
||||
- desc:Dell Inc. DELL U2723QE
|
||||
- serial:3342300033911
|
||||
- size:3440x1440
|
||||
- DP-9
|
||||
- DP-10
|
||||
|
||||
@@ -197,19 +201,22 @@ hooks:
|
||||
|
||||
### 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 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
```yaml
|
||||
external:
|
||||
- desc:Dell Inc. DELL U2723QE # survives port rename
|
||||
- serial:3342300033911 # match a specific monitor by serial
|
||||
|
||||
modes:
|
||||
docked:
|
||||
monitors:
|
||||
- name: desc:Dell Inc. DELL U2723QE # also works here
|
||||
enabled: true
|
||||
- name: serial:3342300033911 # and here
|
||||
enabled: true
|
||||
- name: size:2560x1440@165 # match by dimensions + refresh
|
||||
enabled: true
|
||||
```
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +38,7 @@ var (
|
||||
prepare bool
|
||||
applyMode string
|
||||
noReload bool
|
||||
logLevel string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -44,13 +46,28 @@ func init() {
|
||||
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.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() {
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
Level: parseLogLevel(logLevel),
|
||||
}))
|
||||
|
||||
switch {
|
||||
|
||||
@@ -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
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,9 @@ func (d *Daemon) pollCheck(ctx context.Context) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for _, m := range 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -261,11 +271,19 @@ func (d *Daemon) applyState(ctx context.Context, state backend.State) error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for _, m := range monitors {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ restore_on_exit: true
|
||||
|
||||
# External monitors that trigger docked mode.
|
||||
# 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
|
||||
# monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated
|
||||
# as external.
|
||||
@@ -33,13 +36,16 @@ external:
|
||||
- DP-1
|
||||
- DP-2
|
||||
- desc:Dell Inc. DELL U2723QE
|
||||
- serial:3342300033911
|
||||
- size:3440x1440@120
|
||||
|
||||
# Monitor layouts for each mode.
|
||||
# "portable" and "docked" are required.
|
||||
# Monitors connected but not listed in the mode are automatically disabled.
|
||||
#
|
||||
# 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
|
||||
# mode — "preferred" (auto-detect), "1920x1080@60", etc.
|
||||
# position — "auto", "0x0", "1920x0", etc.
|
||||
|
||||
Reference in New Issue
Block a user