package backend import ( "bufio" "context" "encoding/json" "errors" "fmt" "log/slog" "net" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) // hyprlandBackend implements Backend for the Hyprland compositor. // // Monitor queries: hyprctl -j monitors all // 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/monitors.conf" type hyprlandBackend struct { logger *slog.Logger // outputPath overrides the generated monitor config file path. // Empty means the default: ~/.config/hypr/monitors.conf. // Read from backend_config.output_path at construction time. outputPath string // conn is the current socket2 connection; guarded by mu. conn net.Conn mu sync.Mutex } // NewHyprland creates a Hyprland backend. The backend auto-detects the // Hyprland instance signature from the environment. // // opts carries backend-specific configuration from the config file's // backend_config section. Hyprland supports: // // output_path — path to the generated monitor config file // (default: ~/.config/hypr/monitors.conf) func NewHyprland(logger *slog.Logger, opts map[string]any) (Backend, error) { sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") if sig == "" { return nil, errors.New("HYPRLAND_INSTANCE_SIGNATURE is not set; is Hyprland running?") } runtimeDir := os.Getenv("XDG_RUNTIME_DIR") if runtimeDir == "" { runtimeDir = "/run/user/" + fmt.Sprint(os.Getuid()) } socketPath := filepath.Join(runtimeDir, "hypr", sig, ".socket2.sock") if _, err := os.Stat(socketPath); err != nil { return nil, fmt.Errorf("socket2 not found at %s: %w", socketPath, err) } var outputPath string if opts != nil { if v, ok := opts["output_path"]; ok { outputPath, _ = v.(string) } } return &hyprlandBackend{logger: logger, outputPath: outputPath}, nil } func (h *hyprlandBackend) Name() string { return "hyprland" } // GetMonitors calls hyprctl -j monitors all and parses the JSON output. // It returns both active and inactive monitors. func (h *hyprlandBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) { cmd := exec.CommandContext(ctx, "hyprctl", "-j", "monitors", "all") out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("hyprctl monitors all: %w", err) } // The output is a JSON array. Unknown fields are silently ignored. var monitors []MonitorInfo if err := json.Unmarshal(out, &monitors); err != nil { return nil, fmt.Errorf("parse hyprctl output: %w\nraw: %s", err, string(out)) } return monitors, nil } // ApplyLayout writes the monitor configuration file and calls hyprctl reload. // The config is written atomically (temp file + rename) to prevent corruption. func (h *hyprlandBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error { var err error monitors, err = resolveMonitorNames(ctx, monitors, h.GetMonitors, h.logger) if err != nil { return fmt.Errorf("resolve monitor names: %w", err) } content := h.generateConf(monitors) destPath, err := h.resolveOutputPath() if err != nil { return fmt.Errorf("resolve output path: %w", err) } // Atomic write: temp file, write, fsync, rename. if err := atomicWrite(destPath, []byte(content)); err != nil { return fmt.Errorf("write monitors.conf: %w", err) } // Reload Hyprland config to apply changes atomically. reload := exec.CommandContext(ctx, "hyprctl", "reload") if out, err := reload.CombinedOutput(); err != nil { return fmt.Errorf("hyprctl reload: %w\noutput: %s", err, string(out)) } h.logger.Info("layout applied", "monitors", len(monitors)) return nil } // Events connects to the socket2 unix socket and emits parsed hotplug events. // On connection loss it attempts reconnection with exponential backoff. func (h *hyprlandBackend) Events(ctx context.Context) (<-chan Event, <-chan error) { eventCh := make(chan Event, 8) errCh := make(chan error, 1) go func() { defer close(eventCh) defer close(errCh) backoff := 1 * time.Second for { if err := h.readSocket2(ctx, eventCh); err != nil { if ctx.Err() != nil { return // graceful shutdown } h.logger.Error("socket2 disconnected, reconnecting", "error", err, "backoff", backoff) select { case <-ctx.Done(): return case <-time.After(backoff): } backoff = min(backoff*2, 30*time.Second) } else { backoff = 1 * time.Second } } }() return eventCh, errCh } // readSocket2 opens one connection to socket2 and reads events until EOF or error. func (h *hyprlandBackend) readSocket2(ctx context.Context, eventCh chan<- Event) error { runtimeDir := os.Getenv("XDG_RUNTIME_DIR") if runtimeDir == "" { runtimeDir = "/run/user/" + fmt.Sprint(os.Getuid()) } sig := os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") socketPath := filepath.Join(runtimeDir, "hypr", sig, ".socket2.sock") var d net.Dialer conn, err := d.DialContext(ctx, "unix", socketPath) if err != nil { return fmt.Errorf("connect socket2: %w", err) } defer conn.Close() h.mu.Lock() h.conn = conn h.mu.Unlock() defer func() { h.mu.Lock() h.conn = nil h.mu.Unlock() }() h.logger.Info("connected to socket2", "path", socketPath) scanner := bufio.NewScanner(conn) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } evt, ok := parseSocket2Event(line) if !ok { continue } select { case eventCh <- evt: case <-ctx.Done(): return ctx.Err() } } if err := scanner.Err(); err != nil { return fmt.Errorf("socket2 read: %w", err) } return nil } // parseSocket2Event parses a socket2 event line into an Event. // Format: "monitoradded>>DP-1" or "monitorremovedv2>>DP-1" // Returns false for events we don't care about. func parseSocket2Event(line string) (Event, bool) { // Events we handle: // monitoradded>>name monitoraddedv2>>name // monitorremoved>>name monitorremovedv2>>name if strings.HasPrefix(line, "monitoradded") { name := eventData(line) if name == "" { return Event{}, false } return Event{Type: EventMonitorAdded, MonitorName: name}, true } if strings.HasPrefix(line, "monitorremoved") { name := eventData(line) if name == "" { return Event{}, false } return Event{Type: EventMonitorRemoved, MonitorName: name}, true } return Event{}, false } // eventData extracts the data portion after ">>" from a socket2 event line. func eventData(line string) string { idx := strings.Index(line, ">>") if idx < 0 { return "" } return line[idx+2:] } // generateConf produces the content of a Hyprland-compatible monitor config file. // Old-style syntax: monitor=name,mode,pos,scale // Disabled monitors: monitor=name,disabled func (h *hyprlandBackend) generateConf(monitors []MonitorConfig) string { var buf strings.Builder // Ensure disabled monitors appear last so Hyprland migrates // workspaces to enabled ones first. var disabled []MonitorConfig for _, m := range monitors { if !m.Enabled { disabled = append(disabled, m) continue } mode := m.Mode if mode == "" { mode = "preferred" } pos := m.Position if pos == "" { pos = "auto" } scale := formatScale(m.Scale) buf.WriteString("monitor=") buf.WriteString(m.Name) buf.WriteString(",") buf.WriteString(mode) buf.WriteString(",") buf.WriteString(pos) buf.WriteString(",") buf.WriteString(scale) buf.WriteString("\n") } for _, m := range disabled { buf.WriteString("monitor=") buf.WriteString(m.Name) buf.WriteString(",disabled\n") } return buf.String() } // formatScale formats a scale float: 1 → "1", 1.5 → "1.5" func formatScale(s float64) string { if s == 0 { return "1" } // Use %g to strip trailing zeros, then ensure it's not scientific notation. str := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%g", s), "0"), ".") if str == "" { return "1" } return str } // hyprConfigDir returns the Hyprland config directory (~/.config/hypr). func (h *hyprlandBackend) hyprConfigDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } dir := filepath.Join(home, ".config", "hypr") if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } 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() if h.conn != nil { return h.conn.Close() } return nil } // atomicWrite writes data to a file atomically using temp file + rename. func atomicWrite(path string, data []byte) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".monitor-lets-go-*.tmp") if err != nil { return fmt.Errorf("create temp: %w", err) } tmpPath := tmp.Name() if _, err := tmp.Write(data); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("write temp: %w", err) } if err := tmp.Sync(); err != nil { tmp.Close() os.Remove(tmpPath) return fmt.Errorf("sync temp: %w", err) } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return fmt.Errorf("close temp: %w", err) } if err := os.Rename(tmpPath, path); err != nil { os.Remove(tmpPath) return fmt.Errorf("rename temp: %w", err) } return nil }