Compare commits
2
Commits
3fdccadd3c
...
3515608fd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3515608fd3 | ||
|
|
ac82e3e82a |
@@ -28,6 +28,13 @@ systemd-install:
|
||||
systemctl --user daemon-reload
|
||||
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:
|
||||
systemctl --user status monitor-lets-go
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug
|
||||
1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout
|
||||
2. **Hotplug events** — listens to compositor socket for monitor connect/disconnect 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.)
|
||||
6. **Fallback polling** — every 5s checks monitor state (catches missed events)
|
||||
|
||||
@@ -91,13 +91,15 @@ Configure `backend` in `config.yaml`:
|
||||
|
||||
### Hyprland setup
|
||||
|
||||
Add one line to `~/.config/hypr/hyprland.conf` (or `hyprland.lua`):
|
||||
Add one line to `~/.config/hypr/hyprland.lua`:
|
||||
|
||||
```
|
||||
source = ~/.config/hypr/monitors.conf
|
||||
```lua
|
||||
require("monitors")
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -215,12 +217,12 @@ Use the `backend_config` section for compositor-specific options:
|
||||
|
||||
```yaml
|
||||
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 |
|
||||
|---------|-----|------|---------|-------------|
|
||||
| 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. |
|
||||
|
||||
The deprecated top-level `output_path` key still works — `backend_config` takes priority if both are set.
|
||||
@@ -339,9 +341,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`.
|
||||
|
||||
### "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
|
||||
|
||||
|
||||
+107
-7
@@ -32,10 +32,18 @@ import (
|
||||
"monitor-lets-go/internal/hook"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
var (
|
||||
configPath string
|
||||
prepare bool
|
||||
applyMode string
|
||||
noReload bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
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")
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -45,10 +53,101 @@ func main() {
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
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) {
|
||||
logger.Error("fatal", "error", err)
|
||||
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 {
|
||||
@@ -79,10 +178,13 @@ func run(logger *slog.Logger) error {
|
||||
)
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -139,8 +241,9 @@ func defaultConfigPath() string {
|
||||
|
||||
// systemdWatchdog sends periodic WATCHDOG=1 notifications so systemd's
|
||||
// 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.
|
||||
func systemdWatchdog(ctx context.Context, logger *slog.Logger) {
|
||||
socketPath := os.Getenv("NOTIFY_SOCKET")
|
||||
@@ -148,9 +251,6 @@ func systemdWatchdog(ctx context.Context, logger *slog.Logger) {
|
||||
return
|
||||
}
|
||||
|
||||
// Notify systemd that the daemon is ready.
|
||||
notifySystemd(socketPath, "READY=1")
|
||||
|
||||
// WatchdogSec=30, so ping at half the interval.
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
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
|
||||
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
|
||||
|
||||
# Fast restart on failure: if the daemon crashes during docked mode,
|
||||
|
||||
+131
-32
@@ -19,17 +19,17 @@ import (
|
||||
// hyprlandBackend implements Backend for the Hyprland compositor.
|
||||
//
|
||||
// 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)
|
||||
//
|
||||
// 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 {
|
||||
logger *slog.Logger
|
||||
|
||||
// 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.
|
||||
outputPath string
|
||||
|
||||
@@ -45,7 +45,7 @@ type hyprlandBackend struct {
|
||||
// backend_config section. Hyprland supports:
|
||||
//
|
||||
// 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) {
|
||||
sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
if sig == "" {
|
||||
@@ -120,7 +120,7 @@ func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorCon
|
||||
|
||||
// Atomic write: temp file, write, fsync, rename.
|
||||
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.
|
||||
@@ -133,6 +133,88 @@ func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorCon
|
||||
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, "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.
|
||||
// On connection loss it attempts reconnection with exponential backoff.
|
||||
func (h *hyprlandBackend) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
||||
@@ -248,12 +330,21 @@ func eventData(line string) string {
|
||||
return line[idx+2:]
|
||||
}
|
||||
|
||||
// generateConf produces the content of a Hyprland-compatible monitor config file.
|
||||
// Old-style syntax: monitor=name,mode,pos,scale
|
||||
// Disabled monitors: monitor=name,disabled
|
||||
// generateConf produces the content of a Hyprland Lua monitor config file.
|
||||
// Enabled monitors: hl.monitor({ output = "eDP-1", mode = "1920x1200@60", position = "0x0", scale = 1 })
|
||||
// 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 {
|
||||
var buf strings.Builder
|
||||
|
||||
buf.WriteString("-- Generated by monitor-lets-go. Do not edit.\n")
|
||||
|
||||
// Ensure disabled monitors appear last so Hyprland migrates
|
||||
// workspaces to enabled ones first.
|
||||
var disabled []MonitorConfig
|
||||
@@ -264,36 +355,44 @@ func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string {
|
||||
continue
|
||||
}
|
||||
|
||||
mode := m.Mode
|
||||
if mode == "" {
|
||||
mode = "preferred"
|
||||
}
|
||||
pos := m.Position
|
||||
if pos == "" {
|
||||
pos = "auto"
|
||||
}
|
||||
scale := formatScale(m.Scale)
|
||||
buf.WriteString("hl.monitor({ output = ")
|
||||
buf.WriteString(luaQuote(m.Name))
|
||||
|
||||
buf.WriteString("monitor=")
|
||||
buf.WriteString(m.Name)
|
||||
buf.WriteString(",")
|
||||
buf.WriteString(mode)
|
||||
buf.WriteString(",")
|
||||
buf.WriteString(pos)
|
||||
buf.WriteString(",")
|
||||
buf.WriteString(scale)
|
||||
buf.WriteString("\n")
|
||||
if m.Mode != "" {
|
||||
buf.WriteString(", mode = ")
|
||||
buf.WriteString(luaQuote(m.Mode))
|
||||
}
|
||||
if m.Position != "" {
|
||||
buf.WriteString(", position = ")
|
||||
buf.WriteString(luaQuote(m.Position))
|
||||
}
|
||||
buf.WriteString(", scale = ")
|
||||
buf.WriteString(formatScale(m.Scale))
|
||||
|
||||
buf.WriteString(" })\n")
|
||||
}
|
||||
|
||||
for _, m := range disabled {
|
||||
buf.WriteString("monitor=")
|
||||
buf.WriteString(m.Name)
|
||||
buf.WriteString(",disabled\n")
|
||||
buf.WriteString("hl.monitor({ output = ")
|
||||
buf.WriteString(luaQuote(m.Name))
|
||||
buf.WriteString(", disabled = true })\n")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -349,7 +448,7 @@ func (h *hyprlandBackend) hyprConfigDir() (string, error) {
|
||||
|
||||
// resolveOutputPath returns the path for the generated monitor config file.
|
||||
// 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) {
|
||||
if h.outputPath != "" {
|
||||
p := h.outputPath
|
||||
@@ -370,7 +469,7 @@ func (h *hyprlandBackend) resolveOutputPath() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(configDir, "monitors.conf"), nil
|
||||
return filepath.Join(configDir, "monitors.lua"), nil
|
||||
}
|
||||
|
||||
func (h *hyprlandBackend) Close() error {
|
||||
|
||||
@@ -96,6 +96,18 @@ type Backend interface {
|
||||
// are enabled is ever visible.
|
||||
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.
|
||||
// The caller must read from both channels. When ctx is cancelled,
|
||||
// the backend closes both channels and stops listening.
|
||||
|
||||
@@ -554,4 +554,16 @@ func (s *swayBackend) Close() error {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ type Config struct {
|
||||
// OutputPath overrides the generated monitor config file path.
|
||||
// Supports ~ for home directory expansion.
|
||||
// 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"`
|
||||
|
||||
// BackendConfig holds backend-specific configuration options.
|
||||
|
||||
@@ -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.
|
||||
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())
|
||||
|
||||
// Phase 1: determine and apply the initial state.
|
||||
d.applyInitialState(ctx)
|
||||
if onReady != nil {
|
||||
onReady()
|
||||
}
|
||||
|
||||
// Phase 2: subscribe to compositor events.
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
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)
|
||||
if err != nil {
|
||||
d.logger.Error("cannot get monitors on startup", "error", err)
|
||||
@@ -281,7 +296,10 @@ func (d *Daemon) getMonitorsWithRetry(ctx context.Context) ([]backend.MonitorInf
|
||||
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) {
|
||||
if d.config.RestoreOnExit == nil || !*d.config.RestoreOnExit {
|
||||
return
|
||||
@@ -290,12 +308,30 @@ func (d *Daemon) onShutdown(ctx context.Context) {
|
||||
return // already portable
|
||||
}
|
||||
|
||||
d.logger.Info("shutdown: restoring portable layout")
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
d.logger.Info("shutdown: writing portable config")
|
||||
|
||||
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()
|
||||
|
||||
if err := d.applyState(ctx, backend.StatePortable); err != nil {
|
||||
d.logger.Error("shutdown restore failed", "error", err)
|
||||
if err := d.backend.WriteConfig(ctx, monitors); err != nil {
|
||||
d.logger.Error("shutdown write config failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ restore_on_exit: true
|
||||
# Backend-specific options. Keys depend on the selected backend.
|
||||
# Hyprland supports:
|
||||
# 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).
|
||||
# backend_config:
|
||||
# output_path: ~/.config/hypr/custom-monitors.conf
|
||||
# output_path: ~/.config/hypr/custom-monitors.lua
|
||||
|
||||
# External monitors that trigger docked mode.
|
||||
# Plain name: matches the connector name (e.g. DP-1, HDMI-A-1).
|
||||
|
||||
Reference in New Issue
Block a user