2026-06-22 21:36:21 +07:00

192 lines
5.1 KiB
Go

// 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).
// EffectiveBackendConfig merges deprecated top-level keys
// into the backend_config map with proper priority.
b, err := resolveBackend(cfg.Backend, cfg.EffectiveBackendConfig(), 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, backendConfig map[string]any, logger *slog.Logger) (backend.Backend, error) {
// Ordered by priority: preferred backends first.
backends := []struct {
name string
factory func(*slog.Logger, map[string]any) (backend.Backend, error)
}{
{"hyprland", backend.NewHyprland},
{"sway", backend.NewSway},
}
if backendName == "auto" {
for _, entry := range backends {
b, err := entry.factory(logger, backendConfig)
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, backendConfig)
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))
}