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 = 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 }