Compare commits
6
Commits
fb40347c68
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eefe7bcaf7 | ||
|
|
ab6cad0505 | ||
|
|
3515608fd3 | ||
|
|
ac82e3e82a | ||
|
|
3fdccadd3c | ||
|
|
ac2bb77113 |
@@ -28,6 +28,13 @@ systemd-install:
|
|||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable --now monitor-lets-go
|
systemctl --user enable --now monitor-lets-go
|
||||||
|
|
||||||
|
# Install a system-sleep hook (requires root). This writes the portable
|
||||||
|
# monitor config to disk before suspend so the compositor never loads a
|
||||||
|
# stale docked config on resume. Without this hook, the daemon's polling
|
||||||
|
# mechanism catches the change within seconds after resume.
|
||||||
|
system-sleep-install:
|
||||||
|
sudo $(INSTALL) -Dm755 contrib/monitor-lets-go-system-sleep /usr/lib/systemd/system-sleep/monitor-lets-go
|
||||||
|
|
||||||
systemd-status:
|
systemd-status:
|
||||||
systemctl --user status monitor-lets-go
|
systemctl --user status monitor-lets-go
|
||||||
|
|
||||||
|
|||||||
@@ -20,29 +20,33 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug
|
|||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
┌─ Hyprland: socket2 .socket2.sock ──────┐
|
flowchart LR
|
||||||
compositor ───┤ ├──▶ monitor-lets-go daemon
|
subgraph WM["Compositor"]
|
||||||
└─ Sway: i3 IPC $SWAYSOCK ────────────┘ │
|
HYPR["Hyprland<br/>socket2 .socket2.sock"]
|
||||||
hotplug events │
|
SWAY["Sway<br/>i3 IPC $SWAYSOCK"]
|
||||||
┌───────────────────────────────
|
end
|
||||||
│ debounce 1200ms
|
|
||||||
│ determine state (portable/docked)
|
HYPR -->|hotplug events| DAEMON["monitor-lets-go daemon"]
|
||||||
│ apply layout
|
SWAY -->|hotplug events| DAEMON
|
||||||
│ run hooks
|
|
||||||
└──────────────┬───────────────
|
subgraph LOOP["Event loop"]
|
||||||
│
|
DAEMON --> DEBOUNCE["debounce 1200ms"]
|
||||||
┌────────▼────────┐
|
DEBOUNCE --> STATE["determine state<br/>(portable / docked)"]
|
||||||
│ systemd │
|
STATE --> APPLY["apply layout"]
|
||||||
│ watchdog 30s │
|
APPLY --> HOOKS["run hooks"]
|
||||||
│ restart 1s │
|
end
|
||||||
└──────────────────┘
|
|
||||||
|
APPLY -->|Hyprland: monitors.lua + hyprctl reload<br/>Sway: swaymsg batch| WM
|
||||||
|
|
||||||
|
SYSTEMD["systemd<br/>watchdog 30s / restart 1s"] --> DAEMON
|
||||||
|
POLL["fallback polling 5s"] --> STATE
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout
|
1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout
|
||||||
2. **Hotplug events** — listens to compositor socket for monitor connect/disconnect events
|
2. **Hotplug events** — listens to compositor socket for monitor connect/disconnect events
|
||||||
3. **Debounce** — waits 1200ms after the last event (docks fire multiple events)
|
3. **Debounce** — waits 1200ms after the last event (docks fire multiple events)
|
||||||
4. **Apply** — Hyprland: writes `monitors.conf` + `hyprctl reload`; Sway: `swaymsg 'output ...'` batch commands
|
4. **Apply** — Hyprland: writes `monitors.lua` + `hyprctl reload`; Sway: `swaymsg 'output ...'` batch commands
|
||||||
5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.)
|
5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.)
|
||||||
6. **Fallback polling** — every 5s checks monitor state (catches missed events)
|
6. **Fallback polling** — every 5s checks monitor state (catches missed events)
|
||||||
|
|
||||||
@@ -57,6 +61,7 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug
|
|||||||
| 5 | **Graceful shutdown** — optionally restores portable layout on SIGTERM |
|
| 5 | **Graceful shutdown** — optionally restores portable layout on SIGTERM |
|
||||||
| 6 | **Socket reconnect** — exponential backoff if compositor socket drops |
|
| 6 | **Socket reconnect** — exponential backoff if compositor socket drops |
|
||||||
| 7 | **Atomic config writes (Hyprland)** — temp file + rename prevents config corruption |
|
| 7 | **Atomic config writes (Hyprland)** — temp file + rename prevents config corruption |
|
||||||
|
| 8 | **Auto-disable stale monitors** — monitors connected but absent from the target layout are explicitly disabled (prevents external displays from staying on after switching to portable mode) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -90,13 +95,15 @@ Configure `backend` in `config.yaml`:
|
|||||||
|
|
||||||
### Hyprland setup
|
### Hyprland setup
|
||||||
|
|
||||||
Add one line to `~/.config/hypr/hyprland.conf` (or `hyprland.lua`):
|
Add one line to `~/.config/hypr/hyprland.lua`:
|
||||||
|
|
||||||
```
|
```lua
|
||||||
source = ~/.config/hypr/monitors.conf
|
require("monitors")
|
||||||
```
|
```
|
||||||
|
|
||||||
Remove any static `monitor=...` lines — monitor-lets-go manages monitors now.
|
Remove any static `monitor=...` lines — monitor-lets-go manages monitors now.
|
||||||
|
The daemon writes `~/.config/hypr/monitors.lua` (Lua, `hl.monitor` calls) and
|
||||||
|
reloads Hyprland automatically.
|
||||||
|
|
||||||
Then reload:
|
Then reload:
|
||||||
|
|
||||||
@@ -141,14 +148,22 @@ 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).
|
||||||
external:
|
# 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
|
- desc:Dell Inc. DELL U2723QE
|
||||||
|
- serial:3342300033911
|
||||||
|
- size:3440x1440
|
||||||
- DP-9
|
- DP-9
|
||||||
- DP-10
|
- DP-10
|
||||||
|
|
||||||
# Monitor layouts for each mode.
|
# Monitor layouts for each mode.
|
||||||
# "portable" and "docked" are required.
|
# "portable" and "docked" are required.
|
||||||
|
# Monitors not listed in a mode are automatically disabled (prevents
|
||||||
|
# external displays from staying on when switching to portable mode).
|
||||||
modes:
|
modes:
|
||||||
portable:
|
portable:
|
||||||
monitors:
|
monitors:
|
||||||
@@ -172,6 +187,11 @@ modes:
|
|||||||
scale: 1.0
|
scale: 1.0
|
||||||
- name: eDP-1
|
- name: eDP-1
|
||||||
enabled: false # turn off laptop screen when docked
|
enabled: false # turn off laptop screen when docked
|
||||||
|
- name: size:1920x1080@60 # resolve by resolution + refresh rate
|
||||||
|
enabled: true
|
||||||
|
mode: preferred
|
||||||
|
position: auto
|
||||||
|
scale: 1.0
|
||||||
|
|
||||||
# Shell commands run after a layout change.
|
# Shell commands run after a layout change.
|
||||||
# Commands run concurrently, failures are logged but never crash the daemon.
|
# Commands run concurrently, failures are logged but never crash the daemon.
|
||||||
@@ -185,17 +205,22 @@ hooks:
|
|||||||
|
|
||||||
### Monitor matching
|
### Monitor matching
|
||||||
|
|
||||||
Two match modes, supported in both the `external` list and the `modes` section:
|
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 | Survives port rename across different docks |
|
| `desc:Dell U2723QE` | Substring in monitor description or serial | Survives port rename across different docks |
|
||||||
|
| `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`).
|
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: in modes** — when a monitor name in `modes` uses the `desc:` prefix, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a desc matching multiple monitors) 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
|
||||||
|
|
||||||
@@ -203,12 +228,12 @@ Use the `backend_config` section for compositor-specific options:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
backend_config:
|
backend_config:
|
||||||
output_path: ~/.config/hypr/custom-monitors.conf # override generated config path
|
output_path: ~/.config/hypr/custom-monitors.lua # override generated config path
|
||||||
```
|
```
|
||||||
|
|
||||||
| Backend | Key | Type | Default | Description |
|
| Backend | Key | Type | Default | Description |
|
||||||
|---------|-----|------|---------|-------------|
|
|---------|-----|------|---------|-------------|
|
||||||
| Hyprland | `output_path` | string | `~/.config/hypr/monitors.conf` | Path to the generated monitor config file. Supports `~` expansion. |
|
| Hyprland | `output_path` | string | `~/.config/hypr/monitors.lua` | Path to the generated monitor config file. Supports `~` expansion. |
|
||||||
| Sway | _(none)_ | — | — | Layout is applied via `swaymsg` commands directly — no config files needed. |
|
| Sway | _(none)_ | — | — | Layout is applied via `swaymsg` commands directly — no config files needed. |
|
||||||
|
|
||||||
The deprecated top-level `output_path` key still works — `backend_config` takes priority if both are set.
|
The deprecated top-level `output_path` key still works — `backend_config` takes priority if both are set.
|
||||||
@@ -327,9 +352,9 @@ The daemon couldn't find any supported compositor.
|
|||||||
|
|
||||||
**Sway:** `SWAYSOCK` is not set and no sway IPC socket found in `$XDG_RUNTIME_DIR`. Make sure Sway is running and the socket is accessible. Verify with: `ls $XDG_RUNTIME_DIR/sway-ipc.*.sock`.
|
**Sway:** `SWAYSOCK` is not set and no sway IPC socket found in `$XDG_RUNTIME_DIR`. Make sure Sway is running and the socket is accessible. Verify with: `ls $XDG_RUNTIME_DIR/sway-ipc.*.sock`.
|
||||||
|
|
||||||
### "source file not found" (Hyprland only)
|
### "module not found: monitors" (Hyprland only)
|
||||||
|
|
||||||
The `source = ~/.config/hypr/monitors.conf` line must be added to hyprland.conf. The daemon creates this file on first run.
|
The `require("monitors")` line must be added to `~/.config/hypr/hyprland.lua`. The daemon creates `~/.config/hypr/monitors.lua` on first run.
|
||||||
|
|
||||||
### Hooks not running
|
### Hooks not running
|
||||||
|
|
||||||
@@ -337,20 +362,25 @@ 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:` prefix matching instead of connector names. This survives 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
|
||||||
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+125
-8
@@ -23,6 +23,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,24 +33,139 @@ import (
|
|||||||
"monitor-lets-go/internal/hook"
|
"monitor-lets-go/internal/hook"
|
||||||
)
|
)
|
||||||
|
|
||||||
var configPath string
|
var (
|
||||||
|
configPath string
|
||||||
|
prepare bool
|
||||||
|
applyMode string
|
||||||
|
noReload bool
|
||||||
|
logLevel string
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
flag.StringVar(&configPath, "config", defaultConfigPath(), "path to YAML configuration file")
|
flag.StringVar(&configPath, "config", defaultConfigPath(), "path to YAML configuration file")
|
||||||
|
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() {
|
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 {
|
||||||
|
case prepare:
|
||||||
|
os.Exit(runPrepare(logger))
|
||||||
|
case applyMode != "":
|
||||||
|
os.Exit(runApply(logger, applyMode))
|
||||||
|
default:
|
||||||
if err := run(logger); err != nil && !errors.Is(err, context.Canceled) {
|
if err := run(logger); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
logger.Error("fatal", "error", err)
|
logger.Error("fatal", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPrepare removes disabled entries from the monitor config file.
|
||||||
|
// Useful as ExecStartPre in systemd or before starting the compositor.
|
||||||
|
func runPrepare(logger *slog.Logger) int {
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("load config", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := resolveBackend(cfg.Backend, cfg.EffectiveBackendConfig(), logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("resolve backend", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := b.Prepare(ctx); err != nil {
|
||||||
|
logger.Error("prepare failed", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
logger.Info("prepare: cleaned disabled monitors from config")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// runApply applies a single mode (portable/docked) and exits.
|
||||||
|
// When --no-reload is set, writes the config file without reloading
|
||||||
|
// the compositor (useful in suspend hooks).
|
||||||
|
func runApply(logger *slog.Logger, mode string) int {
|
||||||
|
cfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("load config", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := resolveBackend(cfg.Backend, cfg.EffectiveBackendConfig(), logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("resolve backend", "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
modeCfg, ok := cfg.Modes[mode]
|
||||||
|
if !ok {
|
||||||
|
keys := make([]string, 0, len(cfg.Modes))
|
||||||
|
for k := range cfg.Modes {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
logger.Error("unknown mode", "mode", mode, "available", keys)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
monitors := make([]backend.MonitorConfig, len(modeCfg.Monitors))
|
||||||
|
for i, e := range modeCfg.Monitors {
|
||||||
|
monitors[i] = backend.MonitorConfig{
|
||||||
|
Name: e.Name,
|
||||||
|
Enabled: e.Enabled,
|
||||||
|
Mode: e.Mode,
|
||||||
|
Position: e.Position,
|
||||||
|
Scale: e.Scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if noReload {
|
||||||
|
if err := b.WriteConfig(ctx, monitors); err != nil {
|
||||||
|
logger.Error("write config failed", "mode", mode, "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
logger.Info("config written to disk", "mode", mode)
|
||||||
|
} else {
|
||||||
|
if err := b.ApplyLayout(ctx, monitors); err != nil {
|
||||||
|
logger.Error("apply layout failed", "mode", mode, "error", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
logger.Info("layout applied", "mode", mode)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func run(logger *slog.Logger) error {
|
func run(logger *slog.Logger) error {
|
||||||
// Load and validate configuration.
|
// Load and validate configuration.
|
||||||
@@ -79,10 +195,13 @@ func run(logger *slog.Logger) error {
|
|||||||
)
|
)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
// Start systemd watchdog pings if running under systemd with Type=notify.
|
// Start systemd watchdog pings (WATCHDOG=1, STOPPING=1).
|
||||||
go systemdWatchdog(ctx, logger)
|
go systemdWatchdog(ctx, logger)
|
||||||
|
|
||||||
return d.Run(ctx)
|
// Run the daemon. READY=1 is sent after the initial state is applied.
|
||||||
|
return d.Run(ctx, func() {
|
||||||
|
notifySystemd(os.Getenv("NOTIFY_SOCKET"), "READY=1")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveBackend returns the first available backend. When backendName is
|
// resolveBackend returns the first available backend. When backendName is
|
||||||
@@ -139,8 +258,9 @@ func defaultConfigPath() string {
|
|||||||
|
|
||||||
// systemdWatchdog sends periodic WATCHDOG=1 notifications so systemd's
|
// systemdWatchdog sends periodic WATCHDOG=1 notifications so systemd's
|
||||||
// WatchdogSec can detect a hung daemon and restart it. Also sends
|
// WatchdogSec can detect a hung daemon and restart it. Also sends
|
||||||
// READY=1 on startup and STOPPING=1 on shutdown.
|
// STOPPING=1 on shutdown.
|
||||||
//
|
//
|
||||||
|
// READY=1 is now sent by Run() after the initial state is applied.
|
||||||
// This is a no-op if NOTIFY_SOCKET is not set.
|
// This is a no-op if NOTIFY_SOCKET is not set.
|
||||||
func systemdWatchdog(ctx context.Context, logger *slog.Logger) {
|
func systemdWatchdog(ctx context.Context, logger *slog.Logger) {
|
||||||
socketPath := os.Getenv("NOTIFY_SOCKET")
|
socketPath := os.Getenv("NOTIFY_SOCKET")
|
||||||
@@ -148,9 +268,6 @@ func systemdWatchdog(ctx context.Context, logger *slog.Logger) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify systemd that the daemon is ready.
|
|
||||||
notifySystemd(socketPath, "READY=1")
|
|
||||||
|
|
||||||
// WatchdogSec=30, so ping at half the interval.
|
// WatchdogSec=30, so ping at half the interval.
|
||||||
ticker := time.NewTicker(15 * time.Second)
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=monitor-lets-go -- restore portable layout before suspend
|
||||||
|
Documentation=https://github.com/mat/monitor-lets-go
|
||||||
|
|
||||||
|
# Run before the system enters sleep. The ExecStop action writes a portable
|
||||||
|
# monitor config so that on resume the compositor never loads a stale docked
|
||||||
|
# config that would leave the built-in display disabled.
|
||||||
|
Before=sleep.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# RemainAfterExit: ExecStop runs when the service is stopped (before sleep).
|
||||||
|
# This is the standard pattern for systemd suspend hooks.
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
ExecStart=/bin/true
|
||||||
|
ExecStop=%h/.local/bin/monitor-lets-go -config %h/.config/monitor-lets-go/config.yaml -apply portable -no-reload
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sleep.target
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# system-sleep hook for monitor-lets-go
|
||||||
|
# Install to /usr/lib/systemd/system-sleep/monitor-lets-go
|
||||||
|
#
|
||||||
|
# Writes the portable monitor config before suspend so the compositor
|
||||||
|
# never loads a stale docked config on resume (prevents black screen).
|
||||||
|
#
|
||||||
|
# Requires: sudo (passwordless for the user's own commands)
|
||||||
|
|
||||||
|
case $1/$2 in
|
||||||
|
pre/*)
|
||||||
|
USER_NAME="$(logname 2>/dev/null || echo "${SUDO_USER:-$USER}")"
|
||||||
|
USER_UID="$(id -u "$USER_NAME" 2>/dev/null || echo 1000)"
|
||||||
|
|
||||||
|
# Extract HYPRLAND_INSTANCE_SIGNATURE from the user's systemd session
|
||||||
|
HYPR_ENV="$(systemctl --user -M "${USER_UID}@" show-environment 2>/dev/null)"
|
||||||
|
HYPR_INSTANCE="$(echo "$HYPR_ENV" | grep HYPRLAND_INSTANCE_SIGNATURE | cut -d= -f2)"
|
||||||
|
|
||||||
|
if [ -n "$HYPR_INSTANCE" ] && [ -n "$USER_NAME" ]; then
|
||||||
|
XDG_RUNTIME_DIR="/run/user/$USER_UID" \
|
||||||
|
HYPRLAND_INSTANCE_SIGNATURE="$HYPR_INSTANCE" \
|
||||||
|
sudo -u "$USER_NAME" \
|
||||||
|
/home/mat/.local/bin/monitor-lets-go \
|
||||||
|
-config /home/mat/.config/monitor-lets-go/config.yaml \
|
||||||
|
-apply portable -no-reload
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -16,6 +16,11 @@ Requires=graphical-session.target
|
|||||||
Type=notify
|
Type=notify
|
||||||
WatchdogSec=30
|
WatchdogSec=30
|
||||||
|
|
||||||
|
# Remove disabled entries from the monitor config before starting.
|
||||||
|
# Prevents black screen if a stale docked config disables the built-in
|
||||||
|
# display (e.g. after undocking while suspended).
|
||||||
|
ExecStartPre=%h/.local/bin/monitor-lets-go -config %h/.config/monitor-lets-go/config.yaml -prepare
|
||||||
|
|
||||||
ExecStart=%h/.local/bin/monitor-lets-go -config %h/.config/monitor-lets-go/config.yaml
|
ExecStart=%h/.local/bin/monitor-lets-go -config %h/.config/monitor-lets-go/config.yaml
|
||||||
|
|
||||||
# Fast restart on failure: if the daemon crashes during docked mode,
|
# Fast restart on failure: if the daemon crashes during docked mode,
|
||||||
|
|||||||
+176
-32
@@ -19,17 +19,17 @@ import (
|
|||||||
// hyprlandBackend implements Backend for the Hyprland compositor.
|
// hyprlandBackend implements Backend for the Hyprland compositor.
|
||||||
//
|
//
|
||||||
// Monitor queries: hyprctl -j monitors all
|
// Monitor queries: hyprctl -j monitors all
|
||||||
// Layout application: write ~/.config/hypr/monitors.conf + hyprctl reload
|
// Layout application: write ~/.config/hypr/monitors.lua + hyprctl reload
|
||||||
// Hotplug events: socket2 unix socket ($XDG_RUNTIME_DIR/hypr/$HIS/.socket2.sock)
|
// Hotplug events: socket2 unix socket ($XDG_RUNTIME_DIR/hypr/$HIS/.socket2.sock)
|
||||||
//
|
//
|
||||||
// The user must add the following line to their hyprland.lua:
|
// The daemon writes a Lua config file that hyprland.lua loads via:
|
||||||
//
|
//
|
||||||
// source = os.getenv("HOME") .. "/.config/hypr/monitors.conf"
|
// require("monitors")
|
||||||
type hyprlandBackend struct {
|
type hyprlandBackend struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
// outputPath overrides the generated monitor config file path.
|
// outputPath overrides the generated monitor config file path.
|
||||||
// Empty means the default: ~/.config/hypr/monitors.conf.
|
// Empty means the default: ~/.config/hypr/monitors.lua.
|
||||||
// Read from backend_config.output_path at construction time.
|
// Read from backend_config.output_path at construction time.
|
||||||
outputPath string
|
outputPath string
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ type hyprlandBackend struct {
|
|||||||
// backend_config section. Hyprland supports:
|
// backend_config section. Hyprland supports:
|
||||||
//
|
//
|
||||||
// output_path — path to the generated monitor config file
|
// output_path — path to the generated monitor config file
|
||||||
// (default: ~/.config/hypr/monitors.conf)
|
// (default: ~/.config/hypr/monitors.lua)
|
||||||
func NewHyprland(logger *slog.Logger, opts map[string]any) (Backend, error) {
|
func NewHyprland(logger *slog.Logger, opts map[string]any) (Backend, error) {
|
||||||
sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
||||||
if sig == "" {
|
if sig == "" {
|
||||||
@@ -92,7 +92,25 @@ func (h *hyprlandBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error
|
|||||||
|
|
||||||
// ApplyLayout writes the monitor configuration file and calls hyprctl reload.
|
// ApplyLayout writes the monitor configuration file and calls hyprctl reload.
|
||||||
// The config is written atomically (temp file + rename) to prevent corruption.
|
// The config is written atomically (temp file + rename) to prevent corruption.
|
||||||
|
//
|
||||||
|
// Any physically connected monitor not mentioned in the desired layout is
|
||||||
|
// explicitly disabled. This prevents external monitors from remaining enabled
|
||||||
|
// when switching to portable mode (where only eDP-* is listed).
|
||||||
func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error {
|
func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error {
|
||||||
|
var err error
|
||||||
|
monitors, err = resolveMonitorNames(ctx, monitors, h.GetMonitors, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve monitor names: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the current set of connected monitors and explicitly disable
|
||||||
|
// any that are not part of the target layout.
|
||||||
|
current, err := h.GetMonitors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get current monitors for cleanup: %w", err)
|
||||||
|
}
|
||||||
|
monitors = h.ensureCleanLayout(monitors, current)
|
||||||
|
|
||||||
content := h.generateConf(monitors)
|
content := h.generateConf(monitors)
|
||||||
|
|
||||||
destPath, err := h.resolveOutputPath()
|
destPath, err := h.resolveOutputPath()
|
||||||
@@ -102,7 +120,7 @@ func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorCon
|
|||||||
|
|
||||||
// Atomic write: temp file, write, fsync, rename.
|
// Atomic write: temp file, write, fsync, rename.
|
||||||
if err := atomicWrite(destPath, []byte(content)); err != nil {
|
if err := atomicWrite(destPath, []byte(content)); err != nil {
|
||||||
return fmt.Errorf("write monitors.conf: %w", err)
|
return fmt.Errorf("write monitor config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload Hyprland config to apply changes atomically.
|
// Reload Hyprland config to apply changes atomically.
|
||||||
@@ -115,6 +133,88 @@ func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorCon
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteConfig writes the monitor configuration to disk without calling
|
||||||
|
// hyprctl reload. This is used during shutdown when the compositor may
|
||||||
|
// not be available. Resolves monitor names with a short timeout; falls
|
||||||
|
// back to plain-name-only entries if resolution fails.
|
||||||
|
func (h *hyprlandBackend) WriteConfig(ctx context.Context, monitors []MonitorConfig) error {
|
||||||
|
resolveCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resolved, err := resolveMonitorNames(resolveCtx, monitors, h.GetMonitors, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("cannot resolve monitor names for WriteConfig — using plain names only",
|
||||||
|
"error", err)
|
||||||
|
var plain []MonitorConfig
|
||||||
|
for _, m := range monitors {
|
||||||
|
if !strings.HasPrefix(m.Name, "desc:") && !strings.HasPrefix(m.Name, "serial:") && !strings.HasPrefix(m.Name, "size:") {
|
||||||
|
plain = append(plain, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(plain) == 0 {
|
||||||
|
plain = []MonitorConfig{
|
||||||
|
{Name: "eDP-1", Enabled: true, Mode: "preferred", Position: "auto", Scale: 1.0},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved = plain
|
||||||
|
}
|
||||||
|
|
||||||
|
content := h.generateConf(resolved)
|
||||||
|
destPath, err := h.resolveOutputPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve output path: %w", err)
|
||||||
|
}
|
||||||
|
if err := atomicWrite(destPath, []byte(content)); err != nil {
|
||||||
|
return fmt.Errorf("write monitor config: %w", err)
|
||||||
|
}
|
||||||
|
h.logger.Info("config written to disk", "path", destPath, "monitors", len(resolved))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare removes all hl.monitor({...disabled = true...}) lines from the
|
||||||
|
// monitor config file. This prevents the "no active displays" issue when
|
||||||
|
// Hyprland starts with a stale config. If the resulting file would be empty
|
||||||
|
// (or doesn't exist), a generic built-in entry is written as a fallback.
|
||||||
|
func (h *hyprlandBackend) Prepare(ctx context.Context) error {
|
||||||
|
destPath, err := h.resolveOutputPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve output path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(destPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
h.logger.Debug("prepare: no config file to clean")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read %s: %w", destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
var cleaned []string
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(trimmed, "disabled = true") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.Join(cleaned, "\n")
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
content = "hl.monitor({ output = \"eDP-1\" })\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := atomicWrite(destPath, []byte(content)); err != nil {
|
||||||
|
return fmt.Errorf("write cleaned config: %w", err)
|
||||||
|
}
|
||||||
|
h.logger.Info("prepare: cleaned disabled entries from config", "path", destPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Events connects to the socket2 unix socket and emits parsed hotplug events.
|
// Events connects to the socket2 unix socket and emits parsed hotplug events.
|
||||||
// On connection loss it attempts reconnection with exponential backoff.
|
// On connection loss it attempts reconnection with exponential backoff.
|
||||||
func (h *hyprlandBackend) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
func (h *hyprlandBackend) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
||||||
@@ -230,12 +330,21 @@ func eventData(line string) string {
|
|||||||
return line[idx+2:]
|
return line[idx+2:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateConf produces the content of a Hyprland-compatible monitor config file.
|
// generateConf produces the content of a Hyprland Lua monitor config file.
|
||||||
// Old-style syntax: monitor=name,mode,pos,scale
|
// Enabled monitors: hl.monitor({ output = "eDP-1", mode = "1920x1200@60", position = "0x0", scale = 1 })
|
||||||
// Disabled monitors: monitor=name,disabled
|
// Disabled monitors: hl.monitor({ output = "DP-1", disabled = true })
|
||||||
|
//
|
||||||
|
// The file is meant to be loaded from hyprland.lua via:
|
||||||
|
//
|
||||||
|
// require("monitors")
|
||||||
|
//
|
||||||
|
// mode and position are omitted when empty so Hyprland uses its defaults
|
||||||
|
// ("preferred" and "auto" respectively).
|
||||||
func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string {
|
func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
|
|
||||||
|
buf.WriteString("-- Generated by monitor-lets-go. Do not edit.\n")
|
||||||
|
|
||||||
// Ensure disabled monitors appear last so Hyprland migrates
|
// Ensure disabled monitors appear last so Hyprland migrates
|
||||||
// workspaces to enabled ones first.
|
// workspaces to enabled ones first.
|
||||||
var disabled []MonitorConfig
|
var disabled []MonitorConfig
|
||||||
@@ -246,36 +355,71 @@ func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
mode := m.Mode
|
buf.WriteString("hl.monitor({ output = ")
|
||||||
if mode == "" {
|
buf.WriteString(luaQuote(m.Name))
|
||||||
mode = "preferred"
|
|
||||||
}
|
|
||||||
pos := m.Position
|
|
||||||
if pos == "" {
|
|
||||||
pos = "auto"
|
|
||||||
}
|
|
||||||
scale := formatScale(m.Scale)
|
|
||||||
|
|
||||||
buf.WriteString("monitor=")
|
if m.Mode != "" {
|
||||||
buf.WriteString(m.Name)
|
buf.WriteString(", mode = ")
|
||||||
buf.WriteString(",")
|
buf.WriteString(luaQuote(m.Mode))
|
||||||
buf.WriteString(mode)
|
}
|
||||||
buf.WriteString(",")
|
if m.Position != "" {
|
||||||
buf.WriteString(pos)
|
buf.WriteString(", position = ")
|
||||||
buf.WriteString(",")
|
buf.WriteString(luaQuote(m.Position))
|
||||||
buf.WriteString(scale)
|
}
|
||||||
buf.WriteString("\n")
|
buf.WriteString(", scale = ")
|
||||||
|
buf.WriteString(formatScale(m.Scale))
|
||||||
|
|
||||||
|
buf.WriteString(" })\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range disabled {
|
for _, m := range disabled {
|
||||||
buf.WriteString("monitor=")
|
buf.WriteString("hl.monitor({ output = ")
|
||||||
buf.WriteString(m.Name)
|
buf.WriteString(luaQuote(m.Name))
|
||||||
buf.WriteString(",disabled\n")
|
buf.WriteString(", disabled = true })\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// luaQuote wraps s in double quotes, escaping characters that would break
|
||||||
|
// a Lua string literal.
|
||||||
|
func luaQuote(s string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"\\", "\\\\",
|
||||||
|
"\"", "\\\"",
|
||||||
|
"\n", "\\n",
|
||||||
|
"\r", "\\r",
|
||||||
|
)
|
||||||
|
return "\"" + replacer.Replace(s) + "\""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCleanLayout adds disabled entries for any physically connected
|
||||||
|
// monitor that is not present in the target layout. This prevents
|
||||||
|
// monitors from remaining enabled when switching to a mode that only
|
||||||
|
// configures a subset of displays (e.g. portable mode).
|
||||||
|
func (h *hyprlandBackend) ensureCleanLayout(target []MonitorConfig, connected []MonitorInfo) []MonitorConfig {
|
||||||
|
names := make(map[string]bool, len(target))
|
||||||
|
for _, m := range target {
|
||||||
|
names[m.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []MonitorConfig
|
||||||
|
result = append(result, target...)
|
||||||
|
|
||||||
|
for _, mi := range connected {
|
||||||
|
if !names[mi.Name] {
|
||||||
|
h.logger.Debug("explicitly disabling unlisted monitor",
|
||||||
|
"monitor", mi.Name)
|
||||||
|
result = append(result, MonitorConfig{
|
||||||
|
Name: mi.Name,
|
||||||
|
Enabled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// formatScale formats a scale float: 1 → "1", 1.5 → "1.5"
|
// formatScale formats a scale float: 1 → "1", 1.5 → "1.5"
|
||||||
func formatScale(s float64) string {
|
func formatScale(s float64) string {
|
||||||
if s == 0 {
|
if s == 0 {
|
||||||
@@ -304,7 +448,7 @@ func (h *hyprlandBackend) hyprConfigDir() (string, error) {
|
|||||||
|
|
||||||
// resolveOutputPath returns the path for the generated monitor config file.
|
// resolveOutputPath returns the path for the generated monitor config file.
|
||||||
// When outputPath is set (via constructor), it is used after ~ expansion.
|
// When outputPath is set (via constructor), it is used after ~ expansion.
|
||||||
// Otherwise the default ~/.config/hypr/monitors.conf is returned.
|
// Otherwise the default ~/.config/hypr/monitors.lua is returned.
|
||||||
func (h *hyprlandBackend) resolveOutputPath() (string, error) {
|
func (h *hyprlandBackend) resolveOutputPath() (string, error) {
|
||||||
if h.outputPath != "" {
|
if h.outputPath != "" {
|
||||||
p := h.outputPath
|
p := h.outputPath
|
||||||
@@ -325,7 +469,7 @@ func (h *hyprlandBackend) resolveOutputPath() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(configDir, "monitors.conf"), nil
|
return filepath.Join(configDir, "monitors.lua"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *hyprlandBackend) Close() error {
|
func (h *hyprlandBackend) Close() error {
|
||||||
|
|||||||
@@ -8,7 +8,14 @@
|
|||||||
// cmd/monitor-lets-go/main.go.
|
// cmd/monitor-lets-go/main.go.
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// MonitorInfo represents a physical display as reported by the compositor.
|
// MonitorInfo represents a physical display as reported by the compositor.
|
||||||
// Fields use JSON tags matching hyprctl -j output; other compositors
|
// Fields use JSON tags matching hyprctl -j output; other compositors
|
||||||
@@ -89,6 +96,18 @@ type Backend interface {
|
|||||||
// are enabled is ever visible.
|
// are enabled is ever visible.
|
||||||
ApplyLayout(ctx context.Context, monitors []MonitorConfig) error
|
ApplyLayout(ctx context.Context, monitors []MonitorConfig) error
|
||||||
|
|
||||||
|
// WriteConfig persists the monitor configuration to disk without
|
||||||
|
// reloading the compositor. Used during shutdown (SIGTERM) when
|
||||||
|
// the compositor may not be available. For backends that don't
|
||||||
|
// use a config file (e.g. Sway), this is a no-op.
|
||||||
|
WriteConfig(ctx context.Context, monitors []MonitorConfig) error
|
||||||
|
|
||||||
|
// Prepare ensures the monitor config file has no disabled entries.
|
||||||
|
// This prevents the "no active displays" issue when the compositor
|
||||||
|
// starts with a stale config. For backends that don't use a config
|
||||||
|
// file (e.g. Sway), this is a no-op.
|
||||||
|
Prepare(ctx context.Context) error
|
||||||
|
|
||||||
// Events returns a channel of hotplug events and a channel of errors.
|
// Events returns a channel of hotplug events and a channel of errors.
|
||||||
// The caller must read from both channels. When ctx is cancelled,
|
// The caller must read from both channels. When ctx is cancelled,
|
||||||
// the backend closes both channels and stops listening.
|
// the backend closes both channels and stops listening.
|
||||||
@@ -97,3 +116,187 @@ type Backend interface {
|
|||||||
// Close releases any resources held by the backend.
|
// Close releases any resources held by the backend.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, "serial:") || strings.HasPrefix(m.Name, "size:") {
|
||||||
|
needsResolution = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !needsResolution {
|
||||||
|
return monitors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := getMonitors(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get current monitors: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := make([]MonitorConfig, len(monitors))
|
||||||
|
for i, m := range monitors {
|
||||||
|
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:
|
||||||
|
resolved[i] = m
|
||||||
|
}
|
||||||
|
if resolved[i].Name == "" {
|
||||||
|
return nil, fmt.Errorf("cannot resolve monitor name %q", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:")
|
||||||
|
var matches []MonitorInfo
|
||||||
|
for _, mi := range current {
|
||||||
|
if strings.Contains(mi.Description, needle) || 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 desc to connector",
|
||||||
|
"desc", 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("desc:%q matches multiple monitors: %s — use a more specific identifier",
|
||||||
|
needle, strings.Join(names, ", "))
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSize resolves a size: prefixed name to a connector name.
|
||||||
|
// Format: "size:WxH" or "size:WxH@R" where W and H are pixel dimensions
|
||||||
|
// and R is the refresh rate in Hz.
|
||||||
|
func resolveSize(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig {
|
||||||
|
spec := strings.TrimPrefix(m.Name, "size:")
|
||||||
|
|
||||||
|
parts := strings.Split(spec, "@")
|
||||||
|
dimParts := strings.Split(parts[0], "x")
|
||||||
|
if len(dimParts) != 2 {
|
||||||
|
logger.Warn("invalid size spec", "spec", spec)
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
targetW, errW := strconv.Atoi(strings.TrimSpace(dimParts[0]))
|
||||||
|
targetH, errH := strconv.Atoi(strings.TrimSpace(dimParts[1]))
|
||||||
|
if errW != nil || errH != nil {
|
||||||
|
logger.Warn("invalid size dimensions", "spec", spec)
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetR float64
|
||||||
|
hasRefresh := len(parts) == 2
|
||||||
|
if hasRefresh {
|
||||||
|
var err error
|
||||||
|
targetR, err = strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("invalid refresh rate in size spec", "spec", spec)
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []MonitorInfo
|
||||||
|
for _, mi := range current {
|
||||||
|
if mi.Width != targetW || mi.Height != targetH {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasRefresh {
|
||||||
|
if math.Abs(mi.RefreshRate-targetR) > 1.0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches = append(matches, mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(matches) {
|
||||||
|
case 0:
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
case 1:
|
||||||
|
logger.Debug("resolved size to connector",
|
||||||
|
"size", spec, "connector", matches[0].Name)
|
||||||
|
m.Name = matches[0].Name
|
||||||
|
return m
|
||||||
|
default:
|
||||||
|
var names []string
|
||||||
|
for _, mat := range matches {
|
||||||
|
names = append(names, mat.Name)
|
||||||
|
}
|
||||||
|
if hasRefresh {
|
||||||
|
logger.Warn("size:%q matches multiple monitors: %s — add @R to disambiguate",
|
||||||
|
spec, strings.Join(names, ", "))
|
||||||
|
} else {
|
||||||
|
logger.Warn("size:%q matches multiple monitors: %s — use size:WxH@R for disambiguation",
|
||||||
|
spec, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-66
@@ -193,16 +193,16 @@ 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
|
||||||
// enable commands always precede disables.
|
// enable commands always precede disables.
|
||||||
func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error {
|
func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error {
|
||||||
var err error
|
var err error
|
||||||
monitors, err = s.resolveMonitorNames(ctx, monitors)
|
monitors, err = resolveMonitorNames(ctx, monitors, s.GetMonitors, s.logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve monitor names: %w", err)
|
return fmt.Errorf("resolve monitor names: %w", err)
|
||||||
}
|
}
|
||||||
@@ -222,68 +222,6 @@ func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveMonitorNames converts desc: prefixed monitor config names to the
|
|
||||||
// actual connector names (e.g. "desc:Xiaomi Corporation Mi Monitor" → "DP-9")
|
|
||||||
// by matching against the current set of connected monitors.
|
|
||||||
//
|
|
||||||
// Matching logic:
|
|
||||||
// - "desc:make model" — substring match against description (make + " " + model)
|
|
||||||
// - "desc:serial" — substring match against serial number
|
|
||||||
// - plain names are returned as-is
|
|
||||||
func (s *swayBackend) resolveMonitorNames(ctx context.Context, monitors []MonitorConfig) ([]MonitorConfig, error) {
|
|
||||||
var needsDescResolution bool
|
|
||||||
for _, m := range monitors {
|
|
||||||
if strings.HasPrefix(m.Name, "desc:") {
|
|
||||||
needsDescResolution = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !needsDescResolution {
|
|
||||||
return monitors, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
current, err := s.GetMonitors(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get current monitors: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved := make([]MonitorConfig, len(monitors))
|
|
||||||
for i, m := range monitors {
|
|
||||||
if !strings.HasPrefix(m.Name, "desc:") {
|
|
||||||
resolved[i] = m
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
needle := strings.TrimPrefix(m.Name, "desc:")
|
|
||||||
var matches []MonitorInfo
|
|
||||||
for _, mi := range current {
|
|
||||||
if strings.Contains(mi.Description, needle) || strings.Contains(mi.Serial, needle) {
|
|
||||||
matches = append(matches, mi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch len(matches) {
|
|
||||||
case 0:
|
|
||||||
return nil, fmt.Errorf("no connected monitor matches desc:%q", needle)
|
|
||||||
case 1:
|
|
||||||
resolved[i] = m
|
|
||||||
resolved[i].Name = matches[0].Name
|
|
||||||
s.logger.Debug("resolved desc to connector",
|
|
||||||
"desc", needle, "connector", matches[0].Name)
|
|
||||||
default:
|
|
||||||
var names []string
|
|
||||||
for _, mat := range matches {
|
|
||||||
names = append(names, mat.Name)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"desc:%q matches multiple monitors: %s — use a more specific identifier",
|
|
||||||
needle, strings.Join(names, ", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildCommands assembles the swaymsg command string.
|
// buildCommands assembles the swaymsg command string.
|
||||||
// Enabled monitors go first (enable + configure), then disabled ones.
|
// Enabled monitors go first (enable + configure), then disabled ones.
|
||||||
// Commands are joined with ';' for batch execution.
|
// Commands are joined with ';' for batch execution.
|
||||||
@@ -616,4 +554,16 @@ func (s *swayBackend) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WriteConfig / Prepare (no-ops for Sway — no persistent config file)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *swayBackend) WriteConfig(ctx context.Context, monitors []MonitorConfig) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *swayBackend) Prepare(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+97
-17
@@ -5,7 +5,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ type Config struct {
|
|||||||
// OutputPath overrides the generated monitor config file path.
|
// OutputPath overrides the generated monitor config file path.
|
||||||
// Supports ~ for home directory expansion.
|
// Supports ~ for home directory expansion.
|
||||||
// Deprecated: use backend_config.output_path instead.
|
// Deprecated: use backend_config.output_path instead.
|
||||||
// Empty means the default: ~/.config/hypr/monitors.conf.
|
// Empty means the default: ~/.config/hypr/monitors.lua.
|
||||||
OutputPath string `yaml:"output_path"`
|
OutputPath string `yaml:"output_path"`
|
||||||
|
|
||||||
// BackendConfig holds backend-specific configuration options.
|
// BackendConfig holds backend-specific configuration options.
|
||||||
@@ -157,10 +159,8 @@ func (c *Config) validate() error {
|
|||||||
if countEnabled(docked.Monitors) == 0 {
|
if countEnabled(docked.Monitors) == 0 {
|
||||||
return errors.New("docked mode must have at least one enabled monitor")
|
return errors.New("docked mode must have at least one enabled monitor")
|
||||||
}
|
}
|
||||||
// At least one external monitor must be listed.
|
// External list is optional. When empty, the daemon auto-detects
|
||||||
if len(c.External) == 0 {
|
// external monitors (any non-internal display with non-zero dimensions).
|
||||||
return errors.New("at least one external monitor must be specified")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -195,23 +195,103 @@ func countEnabled(entries []MonitorEntry) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchesExternal checks whether a monitor name or description matches any
|
// isInternalConnector returns true if the connector name matches a known
|
||||||
// entry in the External list. Supports two match modes:
|
// internal display pattern (eDP, LVDS, DSI) or Hyprland's synthetic
|
||||||
//
|
// "FALLBACK" output. Internal and synthetic displays are always part of the
|
||||||
// - Plain name: exact match against MonitorEntry.Name
|
// laptop/tablet (or a no-display fallback) and should never trigger docked
|
||||||
// - desc: prefix: substring match against MonitorEntry.Description
|
// mode.
|
||||||
func (c *Config) MatchesExternal(name, description string) bool {
|
func isInternalConnector(name string) bool {
|
||||||
for _, ext := range c.External {
|
if strings.EqualFold(name, "fallback") {
|
||||||
if strings.HasPrefix(ext, "desc:") {
|
|
||||||
desc := strings.TrimPrefix(ext, "desc:")
|
|
||||||
if strings.Contains(description, desc) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else {
|
prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"}
|
||||||
if name == ext {
|
for _, p := range prefixes {
|
||||||
|
if strings.HasPrefix(name, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 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-) or synthetic output (FALLBACK)
|
||||||
|
// is automatically external.
|
||||||
|
func (c *Config) MatchesExternal(m ExternalMonitor) bool {
|
||||||
|
if len(c.External) == 0 {
|
||||||
|
return !isInternalConnector(m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ext := range c.External {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ext, "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 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,11 +45,15 @@ func New(b backend.Backend, cfg *config.Config, hr *hook.Runner, logger *slog.Lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the daemon's event loop. It blocks until ctx is cancelled.
|
// Run starts the daemon's event loop. It blocks until ctx is cancelled.
|
||||||
func (d *Daemon) Run(ctx context.Context) error {
|
// onReady is called after the initial state is applied (may be nil).
|
||||||
|
func (d *Daemon) Run(ctx context.Context, onReady func()) error {
|
||||||
d.logger.Info("daemon starting", "backend", d.backend.Name())
|
d.logger.Info("daemon starting", "backend", d.backend.Name())
|
||||||
|
|
||||||
// Phase 1: determine and apply the initial state.
|
// Phase 1: determine and apply the initial state.
|
||||||
d.applyInitialState(ctx)
|
d.applyInitialState(ctx)
|
||||||
|
if onReady != nil {
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2: subscribe to compositor events.
|
// Phase 2: subscribe to compositor events.
|
||||||
events, _ := d.backend.Events(ctx)
|
events, _ := d.backend.Events(ctx)
|
||||||
@@ -105,10 +109,21 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
// applyInitialState determines the current hardware state and applies the
|
// applyInitialState determines the current hardware state and applies the
|
||||||
// matching layout. This is the failsafe: if the daemon crashed while docked,
|
// matching layout. This is the failsafe: if the daemon crashed while docked,
|
||||||
// a restart will detect that externals are gone and re-enable the built-in.
|
// a restart will detect that externals are gone and re-enable the built-in.
|
||||||
|
//
|
||||||
|
// Before determining state, it runs Prepare() to remove disabled entries
|
||||||
|
// from the monitor config file, preventing a black screen when loading a
|
||||||
|
// stale docked config (e.g. after undocking while suspended).
|
||||||
func (d *Daemon) applyInitialState(ctx context.Context) {
|
func (d *Daemon) applyInitialState(ctx context.Context) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Step 1: safe fallback — remove disabled entries so at least one
|
||||||
|
// monitor is active even if state detection or layout application fail.
|
||||||
|
if err := d.backend.Prepare(ctx); err != nil {
|
||||||
|
d.logger.Error("safe fallback (prepare) failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: determine state and apply the correct layout.
|
||||||
monitors, err := d.getMonitorsWithRetry(ctx)
|
monitors, err := d.getMonitorsWithRetry(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.logger.Error("cannot get monitors on startup", "error", err)
|
d.logger.Error("cannot get monitors on startup", "error", err)
|
||||||
@@ -168,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.
|
||||||
@@ -176,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,7 +314,10 @@ func (d *Daemon) getMonitorsWithRetry(ctx context.Context) ([]backend.MonitorInf
|
|||||||
return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
|
return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onShutdown applies the portable layout before exit if configured.
|
// onShutdown writes the portable monitor config before exit if configured.
|
||||||
|
// It writes directly to disk without calling hyprctl reload because the
|
||||||
|
// compositor may already be unavailable during shutdown. The config file
|
||||||
|
// will be read next time the compositor starts.
|
||||||
func (d *Daemon) onShutdown(ctx context.Context) {
|
func (d *Daemon) onShutdown(ctx context.Context) {
|
||||||
if d.config.RestoreOnExit == nil || !*d.config.RestoreOnExit {
|
if d.config.RestoreOnExit == nil || !*d.config.RestoreOnExit {
|
||||||
return
|
return
|
||||||
@@ -290,12 +326,30 @@ func (d *Daemon) onShutdown(ctx context.Context) {
|
|||||||
return // already portable
|
return // already portable
|
||||||
}
|
}
|
||||||
|
|
||||||
d.logger.Info("shutdown: restoring portable layout")
|
d.logger.Info("shutdown: writing portable config")
|
||||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
|
mode, ok := d.config.Modes["portable"]
|
||||||
|
if !ok {
|
||||||
|
d.logger.Error("shutdown restore: no portable mode in config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
monitors := make([]backend.MonitorConfig, len(mode.Monitors))
|
||||||
|
for i, e := range mode.Monitors {
|
||||||
|
monitors[i] = backend.MonitorConfig{
|
||||||
|
Name: e.Name,
|
||||||
|
Enabled: e.Enabled,
|
||||||
|
Mode: e.Mode,
|
||||||
|
Position: e.Position,
|
||||||
|
Scale: e.Scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := d.applyState(ctx, backend.StatePortable); err != nil {
|
if err := d.backend.WriteConfig(ctx, monitors); err != nil {
|
||||||
d.logger.Error("shutdown restore failed", "error", err)
|
d.logger.Error("shutdown write config failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,25 +18,34 @@ restore_on_exit: true
|
|||||||
# Backend-specific options. Keys depend on the selected backend.
|
# Backend-specific options. Keys depend on the selected backend.
|
||||||
# Hyprland supports:
|
# Hyprland supports:
|
||||||
# output_path — path to the generated monitor config file
|
# output_path — path to the generated monitor config file
|
||||||
# (default: ~/.config/hypr/monitors.conf)
|
# (default: ~/.config/hypr/monitors.lua)
|
||||||
# Sway supports no backend-specific options (uses swaymsg commands directly).
|
# Sway supports no backend-specific options (uses swaymsg commands directly).
|
||||||
# backend_config:
|
# backend_config:
|
||||||
# output_path: ~/.config/hypr/custom-monitors.conf
|
# output_path: ~/.config/hypr/custom-monitors.lua
|
||||||
|
|
||||||
# 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
|
||||||
|
# monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated
|
||||||
|
# as external.
|
||||||
external:
|
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.
|
||||||
# Each mode lists monitors with their desired configuration.
|
# Monitors connected but not listed in the mode are automatically disabled.
|
||||||
#
|
#
|
||||||
# Fields:
|
# Fields:
|
||||||
# name — connector name or desc:description
|
# 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.
|
||||||
@@ -70,6 +79,11 @@ modes:
|
|||||||
mode: "3840x2160@60"
|
mode: "3840x2160@60"
|
||||||
position: "0x0"
|
position: "0x0"
|
||||||
scale: 1.5
|
scale: 1.5
|
||||||
|
- name: size:1920x1080@60 # resolve by resolution + refresh rate
|
||||||
|
enabled: true
|
||||||
|
mode: preferred
|
||||||
|
position: auto
|
||||||
|
scale: 1.0
|
||||||
|
|
||||||
# Shell commands run after a layout change.
|
# Shell commands run after a layout change.
|
||||||
# Commands run concurrently; failures are logged but never crash the daemon.
|
# Commands run concurrently; failures are logged but never crash the daemon.
|
||||||
|
|||||||
Reference in New Issue
Block a user