docs: rewrite README for multi-backend (Hyprland + Sway)
This commit is contained in:
parent
b24da07b6e
commit
fb40347c68
174
README.md
174
README.md
@ -2,44 +2,47 @@
|
|||||||
|
|
||||||
**Lightweight, reliable daemon for automatic monitor layout switching on Linux Wayland.**
|
**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
|
## Features
|
||||||
|
|
||||||
- **Zero black screens** — three safety layers: compositor socket events, polling fallback, systemd watchdog with 1-second restart
|
- **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
|
- **Shell hooks** — restart waybar, change wallpapers, run any script after mode switch
|
||||||
- **Single binary** — one Go binary, one YAML config, one systemd unit
|
- **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
|
- **Extensible** — `Backend` interface makes adding new compositors trivial
|
||||||
|
- **Multi-compositor** — works with Hyprland and Sway out of the box
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────┐ socket2 events ┌──────────────────┐
|
┌─ Hyprland: socket2 .socket2.sock ──────┐
|
||||||
│ Hyprland │──────────────────────▶│ monitor-lets-go daemon │
|
compositor ───┤ ├──▶ monitor-lets-go daemon
|
||||||
│ compositor │ monitoradded/removed │ │
|
└─ Sway: i3 IPC $SWAYSOCK ────────────┘ │
|
||||||
│ │ │ debounce 1200ms │
|
hotplug events │
|
||||||
│ hyprctl reload ◀─┤──────────────────────│ determine state │
|
┌───────────────────────────────
|
||||||
│ (reads monitors │ │ apply layout │
|
│ debounce 1200ms
|
||||||
│ .conf file) │ │ run hooks │
|
│ determine state (portable/docked)
|
||||||
└──────────────────┘ └──────┬───────────┘
|
│ apply layout
|
||||||
│
|
│ run hooks
|
||||||
┌──────▼───────────┐
|
└──────────────┬───────────────
|
||||||
│ systemd │
|
│
|
||||||
│ watchdog 30s │
|
┌────────▼────────┐
|
||||||
│ restart 1s │
|
│ systemd │
|
||||||
└──────────────────┘
|
│ watchdog 30s │
|
||||||
|
│ restart 1s │
|
||||||
|
└──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Startup** — daemon queries connected monitors, determines portable/docked, applies layout
|
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)
|
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.)
|
5. **Hooks** — runs shell commands after layout change (waybar, wallpapers, etc.)
|
||||||
6. **Fallback polling** — every 5s checks monitor state (catches missed events)
|
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 |
|
| 4 | **Hang recovery** — systemd watchdog kills and restarts if daemon freezes |
|
||||||
| 5 | **Graceful shutdown** — optionally restores portable layout on SIGTERM |
|
| 5 | **Graceful shutdown** — optionally restores portable layout on SIGTERM |
|
||||||
| 6 | **Socket reconnect** — exponential backoff if compositor socket drops |
|
| 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
|
### 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)
|
- **Go 1.21+** (build only, no Go needed at runtime)
|
||||||
- **systemd** user instance (for the service)
|
- **systemd** user instance (for the service)
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```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
|
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 install # → ~/.local/bin/monitor-lets-go
|
||||||
make systemd-install # → enable & start systemd user service
|
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`):
|
Add one line to `~/.config/hypr/hyprland.conf` (or `hyprland.lua`):
|
||||||
|
|
||||||
@ -91,6 +104,22 @@ Then reload:
|
|||||||
hyprctl 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
|
## Configuration
|
||||||
@ -98,7 +127,7 @@ hyprctl reload
|
|||||||
Create `~/.config/monitor-lets-go/config.yaml`:
|
Create `~/.config/monitor-lets-go/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Backend selection: "auto" (recommended), "hyprland"
|
# Backend selection: "auto" (recommended), "hyprland", or "sway"
|
||||||
backend: auto
|
backend: auto
|
||||||
|
|
||||||
# Quiet period after the last hotplug event (docks fire multiple events)
|
# 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).
|
# Plain name: matches connector (DP-1, HDMI-A-1).
|
||||||
# desc: prefix: matches by monitor description (survives port rename).
|
# desc: prefix: matches by monitor description (survives port rename).
|
||||||
external:
|
external:
|
||||||
|
- desc:Dell Inc. DELL U2723QE
|
||||||
- DP-9
|
- DP-9
|
||||||
- DP-10
|
- DP-10
|
||||||
- desc:Dell Inc. DELL U2723QE
|
|
||||||
|
|
||||||
# Monitor layouts for each mode.
|
# Monitor layouts for each mode.
|
||||||
# "portable" and "docked" are required.
|
# "portable" and "docked" are required.
|
||||||
@ -136,7 +165,7 @@ modes:
|
|||||||
mode: "2560x1440@165"
|
mode: "2560x1440@165"
|
||||||
position: "0x0"
|
position: "0x0"
|
||||||
scale: 1.0
|
scale: 1.0
|
||||||
- name: DP-9
|
- name: desc:Xiaomi Corporation Mi Monitor 3440x1440
|
||||||
enabled: true
|
enabled: true
|
||||||
mode: "3440x1440@120"
|
mode: "3440x1440@120"
|
||||||
position: "2560x0"
|
position: "2560x0"
|
||||||
@ -149,7 +178,6 @@ modes:
|
|||||||
# Tildes (~) and $HOME are expanded.
|
# Tildes (~) and $HOME are expanded.
|
||||||
hooks:
|
hooks:
|
||||||
on_dock:
|
on_dock:
|
||||||
- "killall waybar; waybar &"
|
|
||||||
- "wallpapers"
|
- "wallpapers"
|
||||||
on_undock:
|
on_undock:
|
||||||
- "wallpapers"
|
- "wallpapers"
|
||||||
@ -157,14 +185,17 @@ hooks:
|
|||||||
|
|
||||||
### Monitor matching
|
### 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 |
|
| 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 |
|
| `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
|
### Backend-specific configuration
|
||||||
|
|
||||||
@ -175,16 +206,13 @@ backend_config:
|
|||||||
output_path: ~/.config/hypr/custom-monitors.conf # override generated config path
|
output_path: ~/.config/hypr/custom-monitors.conf # override generated config path
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hyprland keys:**
|
| Backend | Key | Type | Default | Description |
|
||||||
|
|---------|-----|------|---------|-------------|
|
||||||
| 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. |
|
||||||
| `output_path` | string | `~/.config/hypr/monitors.conf` | Path to the generated monitor config file. Supports `~` expansion. |
|
|
||||||
|
|
||||||
The deprecated top-level `output_path` key still works — `backend_config` takes priority if both are set.
|
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
|
### 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.
|
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
|
1. Connect your dock
|
||||||
2. Check logs: `journalctl --user -u monitor-lets-go -f`
|
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
|
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/
|
├── internal/
|
||||||
│ ├── backend/
|
│ ├── backend/
|
||||||
│ │ ├── interface.go # Backend interface + types (MonitorInfo, Event, State)
|
│ │ ├── 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
|
│ ├── config/config.go # YAML parse, validate, defaults, monitor matching
|
||||||
│ ├── daemon/daemon.go # Event loop, debounce, state machine, safety checks
|
│ ├── daemon/daemon.go # Event loop, debounce, state machine, safety checks
|
||||||
│ └── hook/hook.go # Shell hook runner with timeouts
|
│ └── 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
|
## Extending
|
||||||
|
|
||||||
### Adding a new compositor (Sway, KDE, etc.)
|
### Adding a new compositor
|
||||||
|
|
||||||
1. Create `internal/backend/<name>.go` implementing the `Backend` interface
|
1. Create `internal/backend/<name>.go` implementing the `Backend` interface (see `interface.go`)
|
||||||
2. Register the factory in `cmd/monitor-lets-go/main.go:resolveBackend()`
|
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
|
3. That's it — the daemon auto-detects or uses the configured backend
|
||||||
|
|
||||||
Example skeleton for Sway:
|
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.
|
||||||
|
|
||||||
```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 <name> mode <mode> pos <x> <y> scale <s>
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -330,9 +321,13 @@ backends := []struct { ... }{
|
|||||||
|
|
||||||
### "No compositor backend available"
|
### "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.
|
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
|
### 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
|
```yaml
|
||||||
external:
|
external:
|
||||||
- desc:Dell Inc. DELL U2723QE # survives port rename
|
- desc:Dell Inc. DELL U2723QE # survives port rename
|
||||||
|
|
||||||
|
modes:
|
||||||
|
docked:
|
||||||
|
monitors:
|
||||||
|
- name: desc:Dell Inc. DELL U2723QE # also works here
|
||||||
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user