Initial commit: monitor-lets-go — automatic monitor layout daemon
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user