312 lines
8.6 KiB
Go
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
|
|
}
|