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
+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)
}
}