feat: add Sway backend
This commit is contained in:
parent
cc1137608c
commit
b24da07b6e
@ -95,8 +95,7 @@ func resolveBackend(backendName string, backendConfig map[string]any, logger *sl
|
|||||||
factory func(*slog.Logger, map[string]any) (backend.Backend, error)
|
factory func(*slog.Logger, map[string]any) (backend.Backend, error)
|
||||||
}{
|
}{
|
||||||
{"hyprland", backend.NewHyprland},
|
{"hyprland", backend.NewHyprland},
|
||||||
// Future: {"sway", backend.NewSway},
|
{"sway", backend.NewSway},
|
||||||
// Future: {"wlroots", backend.NewWlroots},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if backendName == "auto" {
|
if backendName == "auto" {
|
||||||
|
|||||||
619
internal/backend/sway.go
Normal file
619
internal/backend/sway.go
Normal file
@ -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 <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 = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# monitor-lets-go example configuration
|
# monitor-lets-go example configuration
|
||||||
# Copy to ~/.config/monitor-lets-go/config.yaml and adjust for your hardware.
|
# 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
|
backend: auto
|
||||||
|
|
||||||
# Quiet period after the last hotplug event before re-evaluating.
|
# Quiet period after the last hotplug event before re-evaluating.
|
||||||
@ -19,6 +19,7 @@ restore_on_exit: true
|
|||||||
# Hyprland supports:
|
# Hyprland supports:
|
||||||
# output_path — path to the generated monitor config file
|
# output_path — path to the generated monitor config file
|
||||||
# (default: ~/.config/hypr/monitors.conf)
|
# (default: ~/.config/hypr/monitors.conf)
|
||||||
|
# Sway supports no backend-specific options (uses swaymsg commands directly).
|
||||||
# backend_config:
|
# backend_config:
|
||||||
# output_path: ~/.config/hypr/custom-monitors.conf
|
# output_path: ~/.config/hypr/custom-monitors.conf
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user