From 15691c09180e29227971c88967d3f726adb83a72 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 22 Jun 2026 20:37:07 +0700 Subject: [PATCH] feat: add optional output_path, change default to monitors.conf --- README.md | 6 ++--- cmd/monitor-lets-go/main.go | 10 ++++---- internal/backend/hyprland.go | 50 +++++++++++++++++++++++++++++------- internal/config/config.go | 5 ++++ monitor-lets-go.example.yaml | 5 ++++ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a7ae5f5..554a7b5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug 1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout 2. **Hotplug events** — listens to compositor socket for `monitoradded`/`monitorremoved` 3. **Debounce** — waits 1200ms after the last event (docks fire multiple events) -4. **Apply** — writes `monitor-lets-go-monitors.conf`, calls `hyprctl reload` (atomic) +4. **Apply** — writes `monitors.conf`, calls `hyprctl reload` (atomic) 5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.) 6. **Fallback polling** — every 5s checks monitor state (catches missed events) @@ -80,7 +80,7 @@ make systemd-install # → enable & start systemd user service Add one line to `~/.config/hypr/hyprland.conf` (or `hyprland.lua`): ``` -source = ~/.config/hypr/monitor-lets-go-monitors.conf +source = ~/.config/hypr/monitors.conf ``` Remove any static `monitor=...` lines — monitor-lets-go manages monitors now. @@ -314,7 +314,7 @@ Hyprland is not running or `HYPRLAND_INSTANCE_SIGNATURE` is not set. Make sure m ### "source file not found" on hyprctl reload -The `source = ~/.config/hypr/monitor-lets-go-monitors.conf` line must be added to hyprland.conf. The daemon creates this file on first run. +The `source = ~/.config/hypr/monitors.conf` line must be added to hyprland.conf. The daemon creates this file on first run. ### Hooks not running diff --git a/cmd/monitor-lets-go/main.go b/cmd/monitor-lets-go/main.go index 76025ad..3563266 100644 --- a/cmd/monitor-lets-go/main.go +++ b/cmd/monitor-lets-go/main.go @@ -60,7 +60,7 @@ func run(logger *slog.Logger) error { logger.Info("config loaded", "path", configPath, "backend", cfg.Backend) // Resolve the backend (auto-detect or explicit). - b, err := resolveBackend(cfg.Backend, logger) + b, err := resolveBackend(cfg.Backend, cfg.OutputPath, logger) if err != nil { return fmt.Errorf("resolve backend: %w", err) } @@ -86,11 +86,11 @@ func run(logger *slog.Logger) error { // 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) { +func resolveBackend(backendName, outputPath string, logger *slog.Logger) (backend.Backend, error) { // Ordered by priority: preferred backends first. backends := []struct { name string - factory func(*slog.Logger) (backend.Backend, error) + factory func(*slog.Logger, string) (backend.Backend, error) }{ {"hyprland", backend.NewHyprland}, // Future: {"sway", backend.NewSway}, @@ -99,7 +99,7 @@ func resolveBackend(backendName string, logger *slog.Logger) (backend.Backend, e if backendName == "auto" { for _, entry := range backends { - b, err := entry.factory(logger) + b, err := entry.factory(logger, outputPath) if err == nil { logger.Info("auto-detected backend", "name", b.Name()) return b, nil @@ -112,7 +112,7 @@ func resolveBackend(backendName string, logger *slog.Logger) (backend.Backend, e // Explicit backend selection. for _, entry := range backends { if entry.name == backendName { - b, err := entry.factory(logger) + b, err := entry.factory(logger, outputPath) if err != nil { return nil, fmt.Errorf("backend %s: %w", backendName, err) } diff --git a/internal/backend/hyprland.go b/internal/backend/hyprland.go index 0908b5e..e7bf379 100644 --- a/internal/backend/hyprland.go +++ b/internal/backend/hyprland.go @@ -19,23 +19,30 @@ import ( // hyprlandBackend implements Backend for the Hyprland compositor. // // Monitor queries: hyprctl -j monitors all -// Layout application: write ~/.config/hypr/monitor-lets-go-monitors.conf + hyprctl reload +// Layout application: write ~/.config/hypr/monitors.conf + hyprctl reload // Hotplug events: socket2 unix socket ($XDG_RUNTIME_DIR/hypr/$HIS/.socket2.sock) // // The user must add the following line to their hyprland.lua: // -// source = os.getenv("HOME") .. "/.config/hypr/monitor-lets-go-monitors.conf" +// source = os.getenv("HOME") .. "/.config/hypr/monitors.conf" type hyprlandBackend struct { logger *slog.Logger + // outputPath overrides the generated monitor config file path. + // Empty means the default: ~/.config/hypr/monitors.conf. + outputPath string + // conn is the current socket2 connection; guarded by mu. - conn net.Conn - mu sync.Mutex + conn net.Conn + mu sync.Mutex } // NewHyprland creates a Hyprland backend. The backend auto-detects the // Hyprland instance signature from the environment. -func NewHyprland(logger *slog.Logger) (Backend, error) { +// +// outputPath overrides the generated monitor config file location. +// When empty, defaults to ~/.config/hypr/monitors.conf. +func NewHyprland(logger *slog.Logger, outputPath string) (Backend, error) { sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") if sig == "" { return nil, errors.New("HYPRLAND_INSTANCE_SIGNATURE is not set; is Hyprland running?") @@ -50,7 +57,7 @@ func NewHyprland(logger *slog.Logger) (Backend, error) { return nil, fmt.Errorf("socket2 not found at %s: %w", socketPath, err) } - return &hyprlandBackend{logger: logger}, nil + return &hyprlandBackend{logger: logger, outputPath: outputPath}, nil } func (h *hyprlandBackend) Name() string { return "hyprland" } @@ -77,11 +84,10 @@ func (h *hyprlandBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error { content := h.generateConf(monitors) - configDir, err := h.hyprConfigDir() + destPath, err := h.resolveOutputPath() if err != nil { - return fmt.Errorf("hypr config dir: %w", err) + return fmt.Errorf("resolve output path: %w", err) } - destPath := filepath.Join(configDir, "monitor-lets-go-monitors.conf") // Atomic write: temp file, write, fsync, rename. if err := atomicWrite(destPath, []byte(content)); err != nil { @@ -285,6 +291,32 @@ func (h *hyprlandBackend) hyprConfigDir() (string, error) { return dir, nil } +// resolveOutputPath returns the path for the generated monitor config file. +// When outputPath is set (via constructor), it is used after ~ expansion. +// Otherwise the default ~/.config/hypr/monitors.conf is returned. +func (h *hyprlandBackend) resolveOutputPath() (string, error) { + if h.outputPath != "" { + p := h.outputPath + if strings.HasPrefix(p, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + p = filepath.Join(home, p[1:]) + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return "", fmt.Errorf("create output directory: %w", err) + } + return p, nil + } + + configDir, err := h.hyprConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "monitors.conf"), nil +} + func (h *hyprlandBackend) Close() error { h.mu.Lock() defer h.mu.Unlock() diff --git a/internal/config/config.go b/internal/config/config.go index a82c147..7717cea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,6 +50,11 @@ type Config struct { // Uses *bool so we can distinguish "not set" from explicit false. RestoreOnExit *bool `yaml:"restore_on_exit"` + // OutputPath overrides the generated monitor config file path. + // Supports ~ for home directory expansion. + // Empty means the default: ~/.config/hypr/monitors.conf. + OutputPath string `yaml:"output_path"` + // External lists monitor identifiers that trigger docked mode. // Each entry is a name (DP-1) or a desc: prefix (desc:Dell U2723QE). External []string `yaml:"external"` diff --git a/monitor-lets-go.example.yaml b/monitor-lets-go.example.yaml index ccc542d..d5d1976 100644 --- a/monitor-lets-go.example.yaml +++ b/monitor-lets-go.example.yaml @@ -15,6 +15,11 @@ poll_interval: 5s # Whether to restore the portable layout when the daemon shuts down. restore_on_exit: true +# Optional: override where the generated Hyprland monitor config is written. +# Supports ~ for home directory expansion. +# Defaults to ~/.config/hypr/monitors.conf. +# output_path: ~/.config/hypr/custom-monitors.conf + # External monitors that trigger docked mode. # Plain name: matches the connector name (e.g. DP-1, HDMI-A-1). # desc: prefix: matches by monitor description (survives rename).