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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user