From ac82e3e82a28ba295b4b6e5f2b80274ea7a25baf Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Sun, 2 Aug 2026 12:50:37 +0700 Subject: [PATCH] feat: restore portable layout on shutdown and before suspend Add WriteConfig/Prepare backend methods (real for Hyprland, no-op for Sway), -apply/-prepare/-no-reload CLI flags, ExecStartPre cleanup and suspend systemd hooks so a stale docked config can never leave the built-in display disabled after resume. --- Makefile | 7 ++ cmd/monitor-lets-go/main.go | 120 ++++++++++++++++++++++-- contrib/monitor-lets-go-suspend.service | 20 ++++ contrib/monitor-lets-go-system-sleep | 28 ++++++ contrib/monitor-lets-go.service | 5 + internal/backend/hyprland.go | 82 ++++++++++++++++ internal/backend/interface.go | 12 +++ internal/backend/sway.go | 12 +++ internal/daemon/daemon.go | 48 ++++++++-- 9 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 contrib/monitor-lets-go-suspend.service create mode 100644 contrib/monitor-lets-go-system-sleep diff --git a/Makefile b/Makefile index 87f9a59..74a2951 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/monitor-lets-go/main.go b/cmd/monitor-lets-go/main.go index cce3103..432257b 100644 --- a/cmd/monitor-lets-go/main.go +++ b/cmd/monitor-lets-go/main.go @@ -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,12 +53,103 @@ func main() { Level: slog.LevelInfo, })) - if err := run(logger); err != nil && !errors.Is(err, context.Canceled) { - logger.Error("fatal", "error", err) - os.Exit(1) + 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 { // Load and validate configuration. cfg, err := config.Load(configPath) @@ -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() diff --git a/contrib/monitor-lets-go-suspend.service b/contrib/monitor-lets-go-suspend.service new file mode 100644 index 0000000..7ad36d8 --- /dev/null +++ b/contrib/monitor-lets-go-suspend.service @@ -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 diff --git a/contrib/monitor-lets-go-system-sleep b/contrib/monitor-lets-go-system-sleep new file mode 100644 index 0000000..c61f905 --- /dev/null +++ b/contrib/monitor-lets-go-system-sleep @@ -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 diff --git a/contrib/monitor-lets-go.service b/contrib/monitor-lets-go.service index 0a69f76..ba55550 100644 --- a/contrib/monitor-lets-go.service +++ b/contrib/monitor-lets-go.service @@ -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, diff --git a/internal/backend/hyprland.go b/internal/backend/hyprland.go index 0a178b2..d8eaf13 100644 --- a/internal/backend/hyprland.go +++ b/internal/backend/hyprland.go @@ -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 monitor=...,disabled lines from the monitors.conf +// 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.HasPrefix(trimmed, "monitor=") && strings.HasSuffix(trimmed, ",disabled") { + continue + } + cleaned = append(cleaned, line) + } + + content := strings.Join(cleaned, "\n") + if strings.TrimSpace(content) == "" { + content = "monitor=eDP-1,preferred,auto,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) { diff --git a/internal/backend/interface.go b/internal/backend/interface.go index b2b919d..2b2fff1 100644 --- a/internal/backend/interface.go +++ b/internal/backend/interface.go @@ -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. diff --git a/internal/backend/sway.go b/internal/backend/sway.go index 5f2230f..9940e0b 100644 --- a/internal/backend/sway.go +++ b/internal/backend/sway.go @@ -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 +} + diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 128f41f..5686078 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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) } }