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.
This commit is contained in:
Maksim Totmin
2026-08-02 12:50:37 +07:00
parent 3fdccadd3c
commit ac82e3e82a
9 changed files with 318 additions and 16 deletions
+7
View File
@@ -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
+107 -7
View File
@@ -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,11 +53,102 @@ 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 {
// Load and validate configuration.
@@ -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()
+20
View File
@@ -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
+28
View File
@@ -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
+5
View File
@@ -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,
+82
View File
@@ -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) {
+12
View File
@@ -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.
+12
View File
@@ -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
}
+42 -6
View File
@@ -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)
}
}