327 lines
8.4 KiB
Go
327 lines
8.4 KiB
Go
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/monitor-lets-go-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"
|
|
type hyprlandBackend struct {
|
|
logger *slog.Logger
|
|
|
|
// 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.
|
|
func NewHyprland(logger *slog.Logger) (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)
|
|
}
|
|
|
|
return &hyprlandBackend{logger: logger}, 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 {
|
|
content := h.generateConf(monitors)
|
|
|
|
configDir, err := h.hyprConfigDir()
|
|
if err != nil {
|
|
return fmt.Errorf("hypr config dir: %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 {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|