From b24da07b6ed53b73b8a69f59d147fa1a8d2e1d84 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 22 Jun 2026 21:36:21 +0700 Subject: [PATCH] feat: add Sway backend --- cmd/monitor-lets-go/main.go | 3 +- internal/backend/sway.go | 619 +++++++++++++++++++++++++++++++++++ monitor-lets-go.example.yaml | 3 +- 3 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 internal/backend/sway.go diff --git a/cmd/monitor-lets-go/main.go b/cmd/monitor-lets-go/main.go index 1388b1b..cce3103 100644 --- a/cmd/monitor-lets-go/main.go +++ b/cmd/monitor-lets-go/main.go @@ -95,8 +95,7 @@ func resolveBackend(backendName string, backendConfig map[string]any, logger *sl factory func(*slog.Logger, map[string]any) (backend.Backend, error) }{ {"hyprland", backend.NewHyprland}, - // Future: {"sway", backend.NewSway}, - // Future: {"wlroots", backend.NewWlroots}, + {"sway", backend.NewSway}, } if backendName == "auto" { diff --git a/internal/backend/sway.go b/internal/backend/sway.go new file mode 100644 index 0000000..9cb1ec5 --- /dev/null +++ b/internal/backend/sway.go @@ -0,0 +1,619 @@ +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 enable; output resolution ... position ... scale ...; output 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 = s.resolveMonitorNames(ctx, monitors) + 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 +} + +// resolveMonitorNames converts desc: prefixed monitor config names to the +// actual connector names (e.g. "desc:Xiaomi Corporation Mi Monitor" → "DP-9") +// by matching against the current set of connected monitors. +// +// Matching logic: +// - "desc:make model" — substring match against description (make + " " + model) +// - "desc:serial" — substring match against serial number +// - plain names are returned as-is +func (s *swayBackend) resolveMonitorNames(ctx context.Context, monitors []MonitorConfig) ([]MonitorConfig, error) { + var needsDescResolution bool + for _, m := range monitors { + if strings.HasPrefix(m.Name, "desc:") { + needsDescResolution = true + break + } + } + if !needsDescResolution { + return monitors, nil + } + + current, err := s.GetMonitors(ctx) + if err != nil { + return nil, fmt.Errorf("get current monitors: %w", err) + } + + resolved := make([]MonitorConfig, len(monitors)) + for i, m := range monitors { + if !strings.HasPrefix(m.Name, "desc:") { + resolved[i] = m + continue + } + + needle := strings.TrimPrefix(m.Name, "desc:") + var matches []MonitorInfo + for _, mi := range current { + if strings.Contains(mi.Description, needle) || strings.Contains(mi.Serial, needle) { + matches = append(matches, mi) + } + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("no connected monitor matches desc:%q", needle) + case 1: + resolved[i] = m + resolved[i].Name = matches[0].Name + s.logger.Debug("resolved desc to connector", + "desc", needle, "connector", matches[0].Name) + default: + var names []string + for _, mat := range matches { + names = append(names, mat.Name) + } + return nil, fmt.Errorf( + "desc:%q matches multiple monitors: %s — use a more specific identifier", + needle, strings.Join(names, ", ")) + } + } + + return resolved, 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 +} + + diff --git a/monitor-lets-go.example.yaml b/monitor-lets-go.example.yaml index 602e996..eb25167 100644 --- a/monitor-lets-go.example.yaml +++ b/monitor-lets-go.example.yaml @@ -1,7 +1,7 @@ # monitor-lets-go example configuration # Copy to ~/.config/monitor-lets-go/config.yaml and adjust for your hardware. -# Backend selection: "auto" (recommended), "hyprland", or "sway" (future). +# Backend selection: "auto" (recommended), "hyprland", or "sway". backend: auto # Quiet period after the last hotplug event before re-evaluating. @@ -19,6 +19,7 @@ restore_on_exit: true # Hyprland supports: # output_path — path to the generated monitor config file # (default: ~/.config/hypr/monitors.conf) +# Sway supports no backend-specific options (uses swaymsg commands directly). # backend_config: # output_path: ~/.config/hypr/custom-monitors.conf