312 lines
8.6 KiB
Go

// 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
}