// Command monitor-lets-go is a lightweight daemon that automatically switches // monitor layouts when connecting to or disconnecting from a docking station. // // It listens for compositor hotplug events and applies pre-configured // monitor layouts for portable and docked modes. Post-switch shell hooks // are supported for restarting bars, wallpapers, etc. // // Usage: // // monitor-lets-go -config ~/.config/monitor-lets-go/config.yaml // // The daemon is designed to run as a systemd user service. See contrib/ // for an example unit file. package main import ( "context" "errors" "flag" "fmt" "log/slog" "net" "os" "os/signal" "path/filepath" "syscall" "time" "monitor-lets-go/internal/backend" "monitor-lets-go/internal/config" "monitor-lets-go/internal/daemon" "monitor-lets-go/internal/hook" ) var configPath string func init() { flag.StringVar(&configPath, "config", defaultConfigPath(), "path to YAML configuration file") } func main() { flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, })) if err := run(logger); err != nil && !errors.Is(err, context.Canceled) { logger.Error("fatal", "error", err) os.Exit(1) } } func run(logger *slog.Logger) error { // Load and validate configuration. cfg, err := config.Load(configPath) if err != nil { return fmt.Errorf("load config: %w", err) } logger.Info("config loaded", "path", configPath, "backend", cfg.Backend) // Resolve the backend (auto-detect or explicit). b, err := resolveBackend(cfg.Backend, logger) if err != nil { return fmt.Errorf("resolve backend: %w", err) } defer b.Close() // Build dependencies. hookRunner := hook.NewRunner(logger) d := daemon.New(b, cfg, hookRunner, logger) // Set up signal handling for graceful shutdown. ctx, stop := signal.NotifyContext( context.Background(), syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, ) defer stop() // Start systemd watchdog pings if running under systemd with Type=notify. go systemdWatchdog(ctx, logger) return d.Run(ctx) } // resolveBackend returns the first available backend. When backendName is // "auto", backends are probed in priority order. To add a new backend, // register it in the backends slice below. func resolveBackend(backendName string, logger *slog.Logger) (backend.Backend, error) { // Ordered by priority: preferred backends first. backends := []struct { name string factory func(*slog.Logger) (backend.Backend, error) }{ {"hyprland", backend.NewHyprland}, // Future: {"sway", backend.NewSway}, // Future: {"wlroots", backend.NewWlroots}, } if backendName == "auto" { for _, entry := range backends { b, err := entry.factory(logger) if err == nil { logger.Info("auto-detected backend", "name", b.Name()) return b, nil } logger.Debug("backend unavailable", "name", entry.name, "error", err) } return nil, fmt.Errorf("no compositor backend available") } // Explicit backend selection. for _, entry := range backends { if entry.name == backendName { b, err := entry.factory(logger) if err != nil { return nil, fmt.Errorf("backend %s: %w", backendName, err) } return b, nil } } return nil, fmt.Errorf("unknown backend %q", backendName) } // defaultConfigPath returns ~/.config/monitor-lets-go/config.yaml. func defaultConfigPath() string { // Respect XDG_CONFIG_HOME if set, otherwise use ~/.config. cfgHome := os.Getenv("XDG_CONFIG_HOME") if cfgHome == "" { home, err := os.UserHomeDir() if err != nil { return "monitor-lets-go.yaml" } cfgHome = filepath.Join(home, ".config") } return filepath.Join(cfgHome, "monitor-lets-go", "config.yaml") } // systemdWatchdog sends periodic WATCHDOG=1 notifications so systemd's // WatchdogSec can detect a hung daemon and restart it. Also sends // READY=1 on startup and STOPPING=1 on shutdown. // // This is a no-op if NOTIFY_SOCKET is not set. func systemdWatchdog(ctx context.Context, logger *slog.Logger) { socketPath := os.Getenv("NOTIFY_SOCKET") if socketPath == "" { return } // Notify systemd that the daemon is ready. notifySystemd(socketPath, "READY=1") // WatchdogSec=30, so ping at half the interval. ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() // Notify systemd that we are stopping. defer notifySystemd(socketPath, "STOPPING=1") logger.Debug("systemd watchdog started") for { select { case <-ctx.Done(): return case <-ticker.C: notifySystemd(socketPath, "WATCHDOG=1") } } } // notifySystemd sends a single-line notification via the systemd // notification socket. Supports both filesystem and abstract sockets. func notifySystemd(socketPath, msg string) { // systemd may use an abstract socket prefixed with @. if socketPath[0] == '@' { socketPath = "\x00" + socketPath[1:] } addr := &net.UnixAddr{ Name: socketPath, Net: "unixgram", } conn, err := net.DialUnix("unixgram", nil, addr) if err != nil { return } defer conn.Close() conn.Write([]byte(msg)) }