From fb40347c687e9dc45bcde80002aa5b69bff2e319 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 22 Jun 2026 21:41:46 +0700 Subject: [PATCH] docs: rewrite README for multi-backend (Hyprland + Sway) --- README.md | 174 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 89 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index cbac86a..30b999a 100644 --- a/README.md +++ b/README.md @@ -2,44 +2,47 @@ **Lightweight, reliable daemon for automatic monitor layout switching on Linux Wayland.** -Plugs into your dock — external monitors turn on, built-in turns off. Unplug — built-in comes back. No black screens, no manual `hyprctl` commands, no bash scripts. +Plugs into your dock — external monitors turn on, built-in turns off. Unplug — built-in comes back. No black screens, no manual commands, no bash scripts. --- ## Features - **Zero black screens** — three safety layers: compositor socket events, polling fallback, systemd watchdog with 1-second restart -- **Atomic layout application** — writes a config file, calls `hyprctl reload` — no intermediate broken states +- **Atomic layout application** — writes config file + calls reload (Hyprland), or sends batch swaymsg commands (Sway) — no intermediate broken states - **Shell hooks** — restart waybar, change wallpapers, run any script after mode switch - **Single binary** — one Go binary, one YAML config, one systemd unit -- **3.5 MB stripped** — no runtime dependencies beyond the compositor itself +- **~3.5 MB stripped** — no runtime dependencies beyond the compositor itself - **Extensible** — `Backend` interface makes adding new compositors trivial +- **Multi-compositor** — works with Hyprland and Sway out of the box --- ## How it works ``` -┌──────────────────┐ socket2 events ┌──────────────────┐ -│ Hyprland │──────────────────────▶│ monitor-lets-go daemon │ -│ compositor │ monitoradded/removed │ │ -│ │ │ debounce 1200ms │ -│ hyprctl reload ◀─┤──────────────────────│ determine state │ -│ (reads monitors │ │ apply layout │ -│ .conf file) │ │ run hooks │ -└──────────────────┘ └──────┬───────────┘ - │ - ┌──────▼───────────┐ - │ systemd │ - │ watchdog 30s │ - │ restart 1s │ - └──────────────────┘ + ┌─ Hyprland: socket2 .socket2.sock ──────┐ + compositor ───┤ ├──▶ monitor-lets-go daemon + └─ Sway: i3 IPC $SWAYSOCK ────────────┘ │ + hotplug events │ + ┌─────────────────────────────── + │ debounce 1200ms + │ determine state (portable/docked) + │ apply layout + │ run hooks + └──────────────┬─────────────── + │ + ┌────────▼────────┐ + │ systemd │ + │ watchdog 30s │ + │ restart 1s │ + └──────────────────┘ ``` 1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout -2. **Hotplug events** — listens to compositor socket for `monitoradded`/`monitorremoved` +2. **Hotplug events** — listens to compositor socket for monitor connect/disconnect events 3. **Debounce** — waits 1200ms after the last event (docks fire multiple events) -4. **Apply** — writes `monitors.conf`, calls `hyprctl reload` (atomic) +4. **Apply** — Hyprland: writes `monitors.conf` + `hyprctl reload`; Sway: `swaymsg 'output ...'` batch commands 5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.) 6. **Fallback polling** — every 5s checks monitor state (catches missed events) @@ -53,7 +56,7 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug | 4 | **Hang recovery** — systemd watchdog kills and restarts if daemon freezes | | 5 | **Graceful shutdown** — optionally restores portable layout on SIGTERM | | 6 | **Socket reconnect** — exponential backoff if compositor socket drops | -| 7 | **Atomic writes** — temp file + rename prevents config corruption | +| 7 | **Atomic config writes (Hyprland)** — temp file + rename prevents config corruption | --- @@ -61,21 +64,31 @@ Plugs into your dock — external monitors turn on, built-in turns off. Unplug ### Prerequisites -- **Hyprland** compositor (other compositors: see [Extending](#extending)) +- **Hyprland** or **Sway** compositor (other compositors: see [Extending](#extending)) - **Go 1.21+** (build only, no Go needed at runtime) - **systemd** user instance (for the service) ### Build ```bash -git clone https://github.com/mat/monitor-lets-go.git +git clone https://github.com/Wakatron/monitor-lets-go.git cd monitor-lets-go -make build # → monitor-lets-go (3.5 MB stripped) +make build # → monitor-lets-go (~3.5 MB stripped) make install # → ~/.local/bin/monitor-lets-go make systemd-install # → enable & start systemd user service ``` -### Hyprland config setup +### Choosing a backend + +Configure `backend` in `config.yaml`: + +| Value | Behavior | +|-------|----------| +| `auto` (recommended) | Probes Hyprland first, falls back to Sway | +| `hyprland` | Force Hyprland backend | +| `sway` | Force Sway backend | + +### Hyprland setup Add one line to `~/.config/hypr/hyprland.conf` (or `hyprland.lua`): @@ -91,6 +104,22 @@ Then reload: hyprctl reload ``` +### Sway setup + +No Sway config changes required — the daemon applies monitor layouts directly via `swaymsg` commands. + +For systemd integration (so the daemon can reach Sway when launched as a service), add to your Sway config: + +``` +exec systemctl --user import-environment SWAYSOCK WAYLAND_DISPLAY XDG_CURRENT_DESKTOP +``` + +Verify monitors are detected: + +```bash +swaymsg -t get_outputs +``` + --- ## Configuration @@ -98,7 +127,7 @@ hyprctl reload Create `~/.config/monitor-lets-go/config.yaml`: ```yaml -# Backend selection: "auto" (recommended), "hyprland" +# Backend selection: "auto" (recommended), "hyprland", or "sway" backend: auto # Quiet period after the last hotplug event (docks fire multiple events) @@ -114,9 +143,9 @@ restore_on_exit: true # Plain name: matches connector (DP-1, HDMI-A-1). # desc: prefix: matches by monitor description (survives port rename). external: + - desc:Dell Inc. DELL U2723QE - DP-9 - DP-10 - - desc:Dell Inc. DELL U2723QE # Monitor layouts for each mode. # "portable" and "docked" are required. @@ -136,7 +165,7 @@ modes: mode: "2560x1440@165" position: "0x0" scale: 1.0 - - name: DP-9 + - name: desc:Xiaomi Corporation Mi Monitor 3440x1440 enabled: true mode: "3440x1440@120" position: "2560x0" @@ -149,7 +178,6 @@ modes: # Tildes (~) and $HOME are expanded. hooks: on_dock: - - "killall waybar; waybar &" - "wallpapers" on_undock: - "wallpapers" @@ -157,14 +185,17 @@ hooks: ### Monitor matching -Two match modes in the `external` list: +Two match modes, supported in both the `external` list and the `modes` section: | Syntax | Matches | Use case | |---|---|---| -| `DP-1` | Exact connector name | Simple setups | +| `DP-1` | Exact connector name | Simple setups, built-in displays | | `desc:Dell U2723QE` | Substring in monitor description | Survives port rename across different docks | -Run `hyprctl monitors all` to see your monitor names and descriptions. +Sway description format: `make model serial_widthxheight` (with serial omitted if `Unknown`). +Run `hyprctl monitors all` (Hyprland) or `swaymsg -t get_outputs` (Sway) to see your monitor names and descriptions. + +**desc: in modes** — when a monitor name in `modes` uses the `desc:` prefix, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a desc matching multiple monitors) cause an error. ### Backend-specific configuration @@ -175,16 +206,13 @@ backend_config: output_path: ~/.config/hypr/custom-monitors.conf # override generated config path ``` -**Hyprland keys:** - -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `output_path` | string | `~/.config/hypr/monitors.conf` | Path to the generated monitor config file. Supports `~` expansion. | +| Backend | Key | Type | Default | Description | +|---------|-----|------|---------|-------------| +| Hyprland | `output_path` | string | `~/.config/hypr/monitors.conf` | Path to the generated monitor config file. Supports `~` expansion. | +| Sway | _(none)_ | — | — | Layout is applied via `swaymsg` commands directly — no config files needed. | The deprecated top-level `output_path` key still works — `backend_config` takes priority if both are set. -When adding new compositor backends, their options go into the same `backend_config` section without changing the core config structure. - ### Hook commands Hooks are shell commands executed via `sh -c`. Each command gets a **30-second timeout**. Commands run in parallel — one slow hook won't block others. @@ -232,9 +260,9 @@ systemctl --user disable --now monitor-lets-go 1. Connect your dock 2. Check logs: `journalctl --user -u monitor-lets-go -f` -3. You should see: `state changed state=docked` and your hooks running +3. You should see: `layout applied state=docked` and your hooks running 4. Disconnect the dock -5. You should see: `state changed state=portable` +5. You should see: `layout applied state=portable` --- @@ -246,7 +274,8 @@ monitor-lets-go/ ├── internal/ │ ├── backend/ │ │ ├── interface.go # Backend interface + types (MonitorInfo, Event, State) -│ │ └── hyprland.go # Hyprland: hyprctl + socket2 .socket2.sock +│ │ ├── hyprland.go # Hyprland: hyprctl + socket2 +│ │ └── sway.go # Sway: swaymsg + i3 IPC socket ($SWAYSOCK) │ ├── config/config.go # YAML parse, validate, defaults, monitor matching │ ├── daemon/daemon.go # Event loop, debounce, state machine, safety checks │ └── hook/hook.go # Shell hook runner with timeouts @@ -278,51 +307,13 @@ Only one external dependency: `gopkg.in/yaml.v3`. Everything else is Go standard ## Extending -### Adding a new compositor (Sway, KDE, etc.) +### Adding a new compositor -1. Create `internal/backend/.go` implementing the `Backend` interface +1. Create `internal/backend/.go` implementing the `Backend` interface (see `interface.go`) 2. Register the factory in `cmd/monitor-lets-go/main.go:resolveBackend()` 3. That's it — the daemon auto-detects or uses the configured backend -Example skeleton for Sway: - -```go -// internal/backend/sway.go -package backend - -type swayBackend struct { logger *slog.Logger } - -func NewSway(logger *slog.Logger, opts map[string]any) (Backend, error) { - // Check if SWAYSOCK is set and swaymsg is available - // opts carries backend_config from the config file - return &swayBackend{logger: logger}, nil -} - -func (s *swayBackend) Name() string { return "sway" } - -func (s *swayBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) { - // swaymsg -t get_outputs → parse JSON → []MonitorInfo -} - -func (s *swayBackend) ApplyLayout(ctx context.Context, monitors []MonitorConfig) error { - // swaymsg output mode pos scale -} - -func (s *swayBackend) Events(ctx context.Context) (<-chan Event, <-chan error) { - // sway IPC socket (SWAYSOCK) → subscribe to output events -} - -func (s *swayBackend) Close() error { return nil } -``` - -Then register in `main.go`: - -```go -backends := []struct { ... }{ - {"hyprland", backend.NewHyprland}, - {"sway", backend.NewSway}, // ← add this line -} -``` +See `internal/backend/sway.go` for a full implementation reference: monitor detection via subprocess, layout application via batch commands, hotplug events via raw IPC socket with exponential reconnection backoff, and `desc:` name resolution for connector-name-portability. --- @@ -330,9 +321,13 @@ backends := []struct { ... }{ ### "No compositor backend available" -Hyprland is not running or `HYPRLAND_INSTANCE_SIGNATURE` is not set. Make sure monitor-lets-go starts **after** Hyprland (the systemd unit uses `After=graphical-session.target`). +The daemon couldn't find any supported compositor. -### "source file not found" on hyprctl reload +**Hyprland:** `HYPRLAND_INSTANCE_SIGNATURE` is not set. Make sure monitor-lets-go starts **after** Hyprland (the systemd unit uses `After=graphical-session.target`). Verify with: `echo $HYPRLAND_INSTANCE_SIGNATURE`. + +**Sway:** `SWAYSOCK` is not set and no sway IPC socket found in `$XDG_RUNTIME_DIR`. Make sure Sway is running and the socket is accessible. Verify with: `ls $XDG_RUNTIME_DIR/sway-ipc.*.sock`. + +### "source file not found" (Hyprland only) The `source = ~/.config/hypr/monitors.conf` line must be added to hyprland.conf. The daemon creates this file on first run. @@ -342,11 +337,20 @@ Check the hook command works from a terminal first. Hooks run via `sh -c`, so sh ### Monitor names changed after reboot -Use `desc:` prefix matching instead of connector names. Run `hyprctl monitors all` to find the description string. +Use `desc:` prefix matching instead of connector names. This survives port renames across different docks and reboots. + +**Hyprland:** `hyprctl monitors all` to see descriptions. +**Sway:** `swaymsg -t get_outputs` to see names, make/model, serial, and native resolution. ```yaml external: - desc:Dell Inc. DELL U2723QE # survives port rename + +modes: + docked: + monitors: + - name: desc:Dell Inc. DELL U2723QE # also works here + enabled: true ``` ---