191 lines
4.9 KiB
Go
191 lines
4.9 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).
|
|
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))
|
|
}
|