Maksim Totmin ac2bb77113 fix(hyprland): resolve desc: monitor names before generating config
Extract shared resolveMonitorNames to backend package so both
Hyprland and Sway backends resolve desc: prefixed names to actual
connector names before applying monitor layout.

Previously the Hyprland backend wrote desc:... literally into
monitors.conf, which Hyprland ignores, causing monitors to be
positioned incorrectly or not configured at all.
2026-06-23 09:06:08 +07:00

558 lines
16 KiB
Go

package backend
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// i3 IPC protocol constants.
// Sway uses the same protocol as i3 with the socket at $SWAYSOCK.
const (
i3Magic = "i3-ipc"
i3Command = 0 // run a command
i3Subscribe = 2 // subscribe to events
i3GetOutputs = 3 // get output (monitor) list
i3EventOutput = 1 // event: output added, removed, or changed
i3EventMask uint32 = 0x80000000 // flag on event message types
)
// swayBackend implements Backend for the Sway Wayland compositor via the
// i3-compatible IPC protocol.
//
// Monitor queries: swaymsg -t get_outputs
// Layout application: swaymsg 'output <name> enable; output <name> resolution ... position ... scale ...; output <name> disable'
// Hotplug events: raw IPC socket ($SWAYSOCK), subscribing to ["output"]
type swayBackend struct {
logger *slog.Logger
// socketPath is the sway IPC socket ($SWAYSOCK or discovered).
socketPath string
// conn is the current IPC connection for the event stream; guarded by mu.
conn net.Conn
mu sync.Mutex
}
// NewSway creates a Sway backend. It discovers the IPC socket from the
// SWAYSOCK environment variable or by scanning XDG_RUNTIME_DIR.
//
// opts carries backend-specific configuration from the config file's
// backend_config section. Sway currently supports no backend-specific
// options — layout is applied via swaymsg commands, not config files.
func NewSway(logger *slog.Logger, _ map[string]any) (Backend, error) {
socketPath, err := findSwaySocket()
if err != nil {
return nil, err
}
return &swayBackend{logger: logger, socketPath: socketPath}, nil
}
func (s *swayBackend) Name() string { return "sway" }
// ---------------------------------------------------------------------------
// Monitor querying
// ---------------------------------------------------------------------------
// swayOutput mirrors the JSON structure returned by swaymsg -t get_outputs
// and the output event payload. Pointer fields handle null values for
// disabled outputs.
type swayOutput struct {
Name string `json:"name"`
Make string `json:"make"`
Model string `json:"model"`
Serial string `json:"serial"`
Active bool `json:"active"`
Scale float64 `json:"scale"`
CurrentMode *swayMode `json:"current_mode"`
Rect swayRect `json:"rect"`
Modes []swayMode `json:"modes"`
}
type swayMode struct {
Width int `json:"width"`
Height int `json:"height"`
Refresh float64 `json:"refresh"` // millihertz, e.g. 60002 = 60.002 Hz
}
type swayRect struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// nativeResolution returns the highest-pixel-count mode from the modes list
// as a stable hardware identifier, regardless of the display's current power
// or enabled state.
func nativeResolution(modes []swayMode) (int, int) {
var bestW, bestH, bestPixels int
for _, m := range modes {
if p := m.Width * m.Height; p > bestPixels {
bestW, bestH, bestPixels = m.Width, m.Height, p
}
}
return bestW, bestH
}
// toMonitorInfo converts a raw swayOutput into the backend-agnostic
// MonitorInfo. Disabled outputs have a nil CurrentMode and report
// Width=0, Height=0 so determineState correctly skips them.
func (so *swayOutput) toMonitorInfo() MonitorInfo {
// Build a description from make + model + serial.
// Serial helps differentiate monitors with identical make+model
// (e.g. two Xiaomi Mi Monitors). We also append the native resolution
// (largest pixel count from the modes list) as a stable hardware
// identifier that never changes.
desc := strings.TrimSpace(so.Make + " " + so.Model)
if so.Serial != "" && so.Serial != "Unknown" {
desc += " " + so.Serial
}
// Include the native resolution from the modes list. This gives a
// stable unique identifier that works even when the monitor is disabled
// (CurrentMode is nil but modes list is always populated).
if maxW, maxH := nativeResolution(so.Modes); maxW > 0 && maxH > 0 {
desc += fmt.Sprintf(" %dx%d", maxW, maxH)
}
width := 0
height := 0
refresh := 0.0
if so.CurrentMode != nil {
width = so.CurrentMode.Width
height = so.CurrentMode.Height
refresh = so.CurrentMode.Refresh / 1000.0 // mHz → Hz
}
// Disabled outputs carry rect={0,0,0,0}; use sensible defaults.
x := so.Rect.X
y := so.Rect.Y
if so.Scale <= 0 {
so.Scale = 1
}
return MonitorInfo{
Name: so.Name,
Description: desc,
Make: so.Make,
Model: so.Model,
Serial: so.Serial,
Width: width,
Height: height,
RefreshRate: refresh,
X: x,
Y: y,
Scale: so.Scale,
Enabled: so.Active,
}
}
// GetMonitors calls swaymsg -t get_outputs and parses the JSON response.
// It returns both active and inactive monitors so the daemon can detect
// physically-connected but disabled externals.
func (s *swayBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) {
cmd := exec.CommandContext(ctx, "swaymsg", "-t", "get_outputs")
cmd.Env = s.swayEnv()
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("swaymsg get_outputs: %w", err)
}
var raw []swayOutput
if err := json.Unmarshal(out, &raw); err != nil {
return nil, fmt.Errorf("parse swaymsg output: %w\nraw: %s", err, string(out))
}
monitors := make([]MonitorInfo, len(raw))
for i, so := range raw {
monitors[i] = so.toMonitorInfo()
}
return monitors, nil
}
// ---------------------------------------------------------------------------
// Layout application
// ---------------------------------------------------------------------------
// ApplyLayout applies monitor configurations via swaymsg commands.
// Enabled monitors are configured first (enable → resolution → position → scale),
// then disabled monitors are turned off. All commands are sent in a single
// swaymsg call separated by ';' so Sway executes them as one IPC message.
//
// Monitor names prefixed with "desc:" are resolved to actual connector names
// by querying the current monitor state. This makes configs portable across
// dock ports and reboots.
//
// Safety: the daemon guarantees at least one enabled monitor exists before
// calling ApplyLayout. Sway refuses to disable the last active output, so
// enable commands always precede disables.
func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error {
var err error
monitors, err = resolveMonitorNames(ctx, monitors, s.GetMonitors, s.logger)
if err != nil {
return fmt.Errorf("resolve monitor names: %w", err)
}
cmd := s.buildCommands(monitors)
if cmd == "" {
return errors.New("no commands to apply")
}
c := exec.CommandContext(ctx, "swaymsg", cmd)
c.Env = s.swayEnv()
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("swaymsg: %w\noutput: %s", err, string(out))
}
s.logger.Info("layout applied", "monitors", len(monitors))
return nil
}
// buildCommands assembles the swaymsg command string.
// Enabled monitors go first (enable + configure), then disabled ones.
// Commands are joined with ';' for batch execution.
func (s *swayBackend) buildCommands(monitors []MonitorConfig) string {
var cmds []string
for _, m := range monitors {
if !m.Enabled {
continue
}
cmds = append(cmds, fmt.Sprintf("output %s enable", m.Name))
// Explicitly wake the monitor from DPMS sleep. Sway sometimes
// keeps dpms=false after docking cycles, leaving the screen black.
cmds = append(cmds, fmt.Sprintf("output %s dpms on", m.Name))
if mode := s.formatMode(m.Mode); mode != "" {
cmds = append(cmds, fmt.Sprintf("output %s resolution %s", m.Name, mode))
}
if x, y, ok := parseSwayPosition(m.Position); ok {
cmds = append(cmds, fmt.Sprintf("output %s position %d %d", m.Name, x, y))
}
if m.Scale > 0 {
cmds = append(cmds, fmt.Sprintf("output %s scale %s", m.Name, formatScale(m.Scale)))
}
}
// Disable monitors only after all enabled ones are configured.
for _, m := range monitors {
if m.Enabled {
continue
}
cmds = append(cmds, fmt.Sprintf("output %s disable", m.Name))
}
return strings.Join(cmds, "; ")
}
// formatMode converts a user-configured mode (e.g. "2560x1440@144")
// into Sway's syntax ("2560x1440@144Hz"). Returns "" for "preferred"
// or empty, meaning Sway should use its default mode.
func (s *swayBackend) formatMode(mode string) string {
if mode == "" || mode == "preferred" {
return ""
}
if !strings.HasSuffix(mode, "Hz") {
mode += "Hz"
}
return mode
}
// parseSwayPosition splits a position string "1920x0" into (1920, 0).
// Returns false for "auto", empty, or invalid formats.
func parseSwayPosition(pos string) (int, int, bool) {
if pos == "" || pos == "auto" {
return 0, 0, false
}
parts := strings.SplitN(pos, "x", 2)
if len(parts) != 2 {
return 0, 0, false
}
// Simple atoi; errors are rare at this point since the config
// is validated, but we guard anyway.
x, errX := parseInt(parts[0])
y, errY := parseInt(parts[1])
if errX != nil || errY != nil {
return 0, 0, false
}
return x, y, true
}
// parseInt is a small helper for parseSwayPosition.
func parseInt(s string) (int, error) {
var n int
neg := false
if len(s) > 0 && s[0] == '-' {
neg = true
s = s[1:]
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return 0, fmt.Errorf("invalid digit %c", ch)
}
n = n*10 + int(ch-'0')
}
if neg {
n = -n
}
return n, nil
}
// ---------------------------------------------------------------------------
// Hotplug events via raw IPC socket
// ---------------------------------------------------------------------------
// Events opens a connection to the Sway IPC socket, subscribes to output
// events, and emits parsed hotplug notifications. On connection loss it
// attempts reconnection with exponential backoff.
//
// The caller MUST read from both channels. The event channel is buffered
// (depth 8) to absorb bursts during reconnection.
func (s *swayBackend) 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 := s.readEvents(ctx, eventCh); err != nil {
if ctx.Err() != nil {
return // graceful shutdown
}
s.logger.Error("sway IPC 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
}
// readEvents connects to the Sway IPC socket, sends a SUBSCRIBE message
// for ["output"], then reads events until the connection breaks or ctx
// is cancelled.
func (s *swayBackend) readEvents(ctx context.Context, eventCh chan<- Event) error {
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", s.socketPath)
if err != nil {
return fmt.Errorf("connect to %s: %w", s.socketPath, err)
}
defer conn.Close()
s.mu.Lock()
s.conn = conn
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.conn = nil
s.mu.Unlock()
}()
s.logger.Info("connected to sway IPC", "socket", s.socketPath)
// Subscribe to output events.
subscribePayload := `["output"]`
if err := writeIPCMessage(conn, i3Subscribe, []byte(subscribePayload)); err != nil {
return fmt.Errorf("subscribe: %w", err)
}
// Read the subscribe response. Sway/i3 may reply with type
// SUBSCRIBE (2) or COMMAND (0); both carry {"success": true}.
msgType, payload, err := readIPCMessage(conn)
if err != nil {
return fmt.Errorf("read subscribe response: %w", err)
}
if msgType != i3Command && msgType != i3Subscribe {
s.logger.Warn("unexpected subscribe response type",
"type", msgType, "payload", string(payload))
}
// Read events indefinitely. Each event message carries the type
// i3EventOutput | i3EventMask.
for {
msgType, payload, err := readIPCMessage(conn)
if err != nil {
return fmt.Errorf("read event: %w", err)
}
if msgType != (i3EventOutput | i3EventMask) {
s.logger.Debug("ignoring non-output event", "type", msgType&^i3EventMask)
continue
}
evt, ok := parseOutputEvent(payload)
if !ok {
continue
}
select {
case eventCh <- evt:
case <-ctx.Done():
return ctx.Err()
}
}
}
// swayEvent is a minimal view of an output event payload.
// We only need the change type and monitor name; additional fields
// (make, model, etc.) are ignored because the daemon calls
// GetMonitors after the debounce period to get the full state.
type swayEvent struct {
Change string `json:"change"`
Name string `json:"name"`
}
// parseOutputEvent extracts a monitor hotplug event from an IPC payload.
// Sway emits "added" and "removed" change values. Other change types
// (e.g. mode/scale changes fired by our own layout application) are
// ignored to avoid noise.
func parseOutputEvent(payload []byte) (Event, bool) {
var se swayEvent
if err := json.Unmarshal(payload, &se); err != nil {
return Event{}, false
}
switch se.Change {
case "added":
return Event{Type: EventMonitorAdded, MonitorName: se.Name}, true
case "removed":
return Event{Type: EventMonitorRemoved, MonitorName: se.Name}, true
default:
// "unknown", "unspecified", or our own configuration changes.
// Ignore to avoid reacting to our own ApplyLayout calls.
return Event{}, false
}
}
// ---------------------------------------------------------------------------
// IPC protocol helpers
// ---------------------------------------------------------------------------
// readIPCMessage reads one framed message from a Sway IPC connection.
// The i3 protocol header is:
//
// "i3-ipc" (6 bytes magic)
// uint32 LE (4 bytes payload length)
// uint32 LE (4 bytes message type)
// payload (variable bytes)
func readIPCMessage(r io.Reader) (msgType uint32, payload []byte, err error) {
var magic [6]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return 0, nil, fmt.Errorf("read magic: %w", err)
}
if string(magic[:]) != i3Magic {
return 0, nil, fmt.Errorf("bad magic: %q", string(magic[:]))
}
var length uint32
if err := binary.Read(r, binary.LittleEndian, &length); err != nil {
return 0, nil, fmt.Errorf("read length: %w", err)
}
if err := binary.Read(r, binary.LittleEndian, &msgType); err != nil {
return 0, nil, fmt.Errorf("read type: %w", err)
}
payload = make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
return 0, nil, fmt.Errorf("read payload: %w", err)
}
return msgType, payload, nil
}
// writeIPCMessage sends one framed message to a Sway IPC connection.
func writeIPCMessage(w io.Writer, msgType uint32, payload []byte) error {
var buf bytes.Buffer
buf.WriteString(i3Magic)
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(payload)))
_ = binary.Write(&buf, binary.LittleEndian, msgType)
buf.Write(payload)
_, err := w.Write(buf.Bytes())
return err
}
// ---------------------------------------------------------------------------
// Socket discovery
// ---------------------------------------------------------------------------
// findSwaySocket locates the Sway IPC socket. It checks the SWAYSOCK
// environment variable first, then falls back to scanning XDG_RUNTIME_DIR
// for sway-ipc.*.sock files.
func findSwaySocket() (string, error) {
if sock := os.Getenv("SWAYSOCK"); sock != "" {
return sock, nil
}
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
}
entries, err := os.ReadDir(runtimeDir)
if err != nil {
return "", fmt.Errorf("cannot read runtime dir %s: %w", runtimeDir, err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), "sway-ipc.") && strings.HasSuffix(e.Name(), ".sock") {
return runtimeDir + "/" + e.Name(), nil
}
}
return "", errors.New("cannot find Sway IPC socket; set SWAYSOCK or ensure Sway is running")
}
// swayEnv returns the environment for swaymsg subprocesses, injecting
// SWAYSOCK so the commands work even when launched from systemd or a
// context where the variable is not inherited.
func (s *swayBackend) swayEnv() []string {
return append(os.Environ(), "SWAYSOCK="+s.socketPath)
}
// ---------------------------------------------------------------------------
// Close
// ---------------------------------------------------------------------------
func (s *swayBackend) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
return s.conn.Close()
}
return nil
}