// Package daemon implements the core event loop and state machine // for automatic monitor layout switching. // // The daemon starts by determining the current hardware state (portable or // docked) and applying the correct layout. It then subscribes to compositor // hotplug events via the Backend and re-evaluates the layout after a // debounce period. A polling fallback runs on a timer to catch missed events. // // Safety invariants: // - A layout with zero enabled monitors is never applied. // - Docked mode is never applied unless an external monitor is physically // connected. // - On shutdown, the portable layout is restored (configurable). package daemon import ( "context" "fmt" "log/slog" "time" "monitor-lets-go/internal/backend" "monitor-lets-go/internal/config" "monitor-lets-go/internal/hook" ) // Daemon orchestrates monitor switching. type Daemon struct { backend backend.Backend config *config.Config hookRunner *hook.Runner logger *slog.Logger state backend.State } // New creates a Daemon with the given dependencies. func New(b backend.Backend, cfg *config.Config, hr *hook.Runner, logger *slog.Logger) *Daemon { return &Daemon{ backend: b, config: cfg, hookRunner: hr, logger: logger, state: backend.StateUnknown, } } // Run starts the daemon's event loop. It blocks until ctx is cancelled. func (d *Daemon) Run(ctx context.Context) error { d.logger.Info("daemon starting", "backend", d.backend.Name()) // Phase 1: determine and apply the initial state. d.applyInitialState(ctx) // Phase 2: subscribe to compositor events. events, _ := d.backend.Events(ctx) pollInterval := time.Duration(d.config.PollInterval) var pollTicker *time.Ticker var pollCh <-chan time.Time if pollInterval > 0 { pollTicker = time.NewTicker(pollInterval) pollCh = pollTicker.C defer pollTicker.Stop() } var debounceTimer *time.Timer var debounceCh <-chan time.Time for { select { case <-ctx.Done(): d.onShutdown(context.Background()) return ctx.Err() case evt, ok := <-events: if !ok { if ctx.Err() != nil { return ctx.Err() } d.logger.Error("event channel closed unexpectedly") return fmt.Errorf("backend event stream terminated") } d.logger.Debug("hotplug event", "type", evt.Type, "monitor", evt.MonitorName) // Reset the debounce timer on every event. if debounceTimer != nil { debounceTimer.Stop() } debounceTimer = time.NewTimer(time.Duration(d.config.Debounce)) debounceCh = debounceTimer.C case <-debounceCh: debounceCh = nil debounceTimer = nil d.checkAndApply(ctx) case <-pollCh: d.pollCheck(ctx) } } } // 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. func (d *Daemon) applyInitialState(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() monitors, err := d.getMonitorsWithRetry(ctx) if err != nil { d.logger.Error("cannot get monitors on startup", "error", err) return } state := d.determineState(monitors) d.logger.Info("initial state determined", "state", state) if err := d.applyState(ctx, state); err != nil { d.logger.Error("failed to apply initial state", "state", state, "error", err) } } // checkAndApply is called after the debounce timer fires. It re-queries // monitors and applies a new layout if the state has changed. func (d *Daemon) checkAndApply(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() monitors, err := d.getMonitorsWithRetry(ctx) if err != nil { d.logger.Error("cannot get monitors", "error", err) return } newState := d.determineState(monitors) if newState == d.state { return } if err := d.applyState(ctx, newState); err != nil { d.logger.Error("failed to apply state", "state", newState, "error", err) } } // pollCheck is the polling fallback. It hashes the current monitor state // and applies a new layout if the hash has changed. func (d *Daemon) pollCheck(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() monitors, err := d.backend.GetMonitors(ctx) if err != nil { return // silent; socket events are primary } current := d.determineState(monitors) if current == d.state { return } d.logger.Debug("polling detected state change", "state", current) if err := d.applyState(ctx, current); err != nil { d.logger.Error("polling apply failed", "state", current, "error", err) } } // determineState checks whether any external monitor is physically connected. func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State { for _, m := range monitors { // Skip phantom or disconnected monitors. if m.Name == "" { continue } // A monitor is physically present if it reports non-zero dimensions. if m.Width > 0 && m.Height > 0 { if d.config.MatchesExternal(m.Name, m.Description) { return backend.StateDocked } } } return backend.StatePortable } // applyState applies a monitor layout and runs post-switch hooks. // Safety invariants are enforced before any layout change. func (d *Daemon) applyState(ctx context.Context, state backend.State) error { d.logger.Debug("applying state", "state", state) mode, ok := d.config.Modes[state.String()] if !ok { return fmt.Errorf("no mode config for %s", state) } enabled := countEnabled(mode.Monitors) if enabled == 0 { return fmt.Errorf("refusing %s mode: 0 enabled monitors", state) } // Safety: if entering docked mode, verify at least one external is // physically connected before we disable the built-in display. if state == backend.StateDocked { monitors, err := d.backend.GetMonitors(ctx) if err != nil { return fmt.Errorf("verify externals before dock: %w", err) } if !d.anyExternalConnected(monitors) { d.logger.Warn("docked mode skipped: no external monitors connected") // Stay in the current state instead of risking a black screen. return fmt.Errorf("docked mode: no external monitors connected") } } // Convert config entries to backend format. 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, } } if err := d.backend.ApplyLayout(ctx, monitors); err != nil { return fmt.Errorf("apply layout: %w", err) } d.state = state d.logger.Info("layout applied", "state", state) // Run hooks asynchronously; failures are logged but never returned. hookKey := "on_dock" if state == backend.StatePortable { hookKey = "on_undock" } if cmds, ok := d.config.Hooks[hookKey]; ok && len(cmds) > 0 { // Hooks run with their own context so they outlive the request. go d.hookRunner.Run(context.Background(), cmds) } return nil } // anyExternalConnected returns true if at least one configured external // monitor is physically present. func (d *Daemon) anyExternalConnected(monitors []backend.MonitorInfo) bool { for _, m := range monitors { if m.Width > 0 && m.Height > 0 && m.Name != "" { if d.config.MatchesExternal(m.Name, m.Description) { return true } } } return false } // getMonitorsWithRetry calls GetMonitors up to 3 times with 500ms delays. func (d *Daemon) getMonitorsWithRetry(ctx context.Context) ([]backend.MonitorInfo, error) { const maxRetries = 3 const retryDelay = 500 * time.Millisecond var lastErr error for i := 0; i < maxRetries; i++ { monitors, err := d.backend.GetMonitors(ctx) if err == nil { return monitors, nil } lastErr = err d.logger.Debug("get monitors retry", "attempt", i+1, "error", err) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(retryDelay): } } return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr) } // onShutdown applies the portable layout before exit if configured. func (d *Daemon) onShutdown(ctx context.Context) { if d.config.RestoreOnExit == nil || !*d.config.RestoreOnExit { return } if d.state == backend.StatePortable { return // already portable } d.logger.Info("shutdown: restoring portable layout") ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if err := d.applyState(ctx, backend.StatePortable); err != nil { d.logger.Error("shutdown restore failed", "error", err) } } // countEnabled returns how many MonitorEntries are enabled. func countEnabled(entries []config.MonitorEntry) int { n := 0 for _, e := range entries { if e.Enabled { n++ } } return n }