Root cause of the periodic drops (found via the new logs): mpv was exiting cleanly (err=nil, exit 0) with its status line at 100% — i.e. end-of-stream. UniFi Protect closes some cameras' RTSP connections every 20-40s; with --keep-open=no --idle=no mpv treated that as the file ending, exited, and the supervisor relaunched it ~3s later (the visible drop). - Add --loop-file=inf so mpv reopens the stream the instant it EOFs, in the same process and window: sub-second recovery, no teardown, no supervisor bounce. The supervisor now only fires for real crashes. - Clean up the logged exit "reason": strip ANSI escapes and skip mpv's transient A/V status prints, so a genuine error surfaces instead of "\x1b[KV: 00:00:35 / 00:00:35 (100%)". Tests cover the parsing. Also fold in the for_window IPC fix: sway's IPC parser splits comma- joined command lists at the top level and does not fold the continuation into for_window (unlike the config-file parser), so the map-time placement rules errored with "Only views can have borders" and never installed. Register each command separately; drop the redundant border none (the kiosk config already sets default_border none). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
216 lines
6.8 KiB
Go
216 lines
6.8 KiB
Go
// Package compositor drives a running sway session to tile mpv windows into a
|
|
// grid. The daemon does NOT launch sway; the standard kiosk pattern is for
|
|
// sway (started at boot via autologin) to exec the daemon, so SWAYSOCK and
|
|
// WAYLAND_DISPLAY are inherited. This package shells out to swaymsg, which is
|
|
// always present alongside sway and speaks the sway IPC for us.
|
|
package compositor
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
)
|
|
|
|
// Rect is a pixel rectangle on the output. The JSON tags match sway's
|
|
// get_tree/get_outputs "rect" objects so it can be decoded directly.
|
|
type Rect struct {
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
W int `json:"width"`
|
|
H int `json:"height"`
|
|
}
|
|
|
|
// GridRects splits a WxH area into cols*rows cells in row-major order. The
|
|
// final column and row absorb any rounding remainder so there are no gaps.
|
|
func GridRects(width, height, cols, rows int) []Rect {
|
|
rects := make([]Rect, 0, cols*rows)
|
|
for r := 0; r < rows; r++ {
|
|
for c := 0; c < cols; c++ {
|
|
x := c * width / cols
|
|
y := r * height / rows
|
|
// Right/bottom edge computed from the next cell boundary to avoid
|
|
// cumulative rounding gaps.
|
|
x2 := (c + 1) * width / cols
|
|
y2 := (r + 1) * height / rows
|
|
if c == cols-1 {
|
|
x2 = width
|
|
}
|
|
if r == rows-1 {
|
|
y2 = height
|
|
}
|
|
rects = append(rects, Rect{X: x, Y: y, W: x2 - x, H: y2 - y})
|
|
}
|
|
}
|
|
return rects
|
|
}
|
|
|
|
// TileRect computes the pixel rectangle for a tile spanning (colspan x rowspan)
|
|
// cells starting at (col,row) in a cols x rows base grid over a WxH output.
|
|
// Boundaries are computed from cell edges so adjacent tiles meet exactly and
|
|
// the far edges reach the full width/height with no rounding gaps.
|
|
func TileRect(width, height, cols, rows, col, row, colspan, rowspan int) Rect {
|
|
x := col * width / cols
|
|
y := row * height / rows
|
|
x2 := (col + colspan) * width / cols
|
|
y2 := (row + rowspan) * height / rows
|
|
if col+colspan >= cols {
|
|
x2 = width
|
|
}
|
|
if row+rowspan >= rows {
|
|
y2 = height
|
|
}
|
|
return Rect{X: x, Y: y, W: x2 - x, H: y2 - y}
|
|
}
|
|
|
|
// Client talks to sway via swaymsg.
|
|
type Client struct {
|
|
bin string
|
|
}
|
|
|
|
// New returns a compositor client. It verifies swaymsg is on PATH.
|
|
func New() (*Client, error) {
|
|
bin, err := exec.LookPath("swaymsg")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("swaymsg not found on PATH: %w", err)
|
|
}
|
|
return &Client{bin: bin}, nil
|
|
}
|
|
|
|
// run executes a raw sway command string.
|
|
func (c *Client) run(ctx context.Context, command string) error {
|
|
out, err := exec.CommandContext(ctx, c.bin, command).CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("swaymsg %q: %w: %s", command, err, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Output describes a connected display from `swaymsg -t get_outputs`.
|
|
type Output struct {
|
|
Name string `json:"name"`
|
|
Active bool `json:"active"`
|
|
Focused bool `json:"focused"`
|
|
CurrentMode struct {
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
} `json:"current_mode"`
|
|
Rect Rect `json:"-"`
|
|
}
|
|
|
|
// PrimaryOutput returns the focused active output (or the first active one),
|
|
// used to auto-detect resolution when the config doesn't pin it.
|
|
func (c *Client) PrimaryOutput(ctx context.Context) (*Output, error) {
|
|
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_outputs", "-r").Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get_outputs: %w", err)
|
|
}
|
|
var outputs []Output
|
|
if err := json.Unmarshal(out, &outputs); err != nil {
|
|
return nil, err
|
|
}
|
|
var first *Output
|
|
for i := range outputs {
|
|
o := &outputs[i]
|
|
if !o.Active {
|
|
continue
|
|
}
|
|
if first == nil {
|
|
first = o
|
|
}
|
|
if o.Focused {
|
|
return o, nil
|
|
}
|
|
}
|
|
if first == nil {
|
|
return nil, fmt.Errorf("no active output found")
|
|
}
|
|
return first, nil
|
|
}
|
|
|
|
// treeNode is the recursive shape of `swaymsg -t get_tree`.
|
|
type treeNode struct {
|
|
PID int `json:"pid"`
|
|
Name string `json:"name"`
|
|
AppID string `json:"app_id"`
|
|
Rect Rect `json:"rect"`
|
|
Nodes []treeNode `json:"nodes"`
|
|
Float []treeNode `json:"floating_nodes"`
|
|
}
|
|
|
|
func (n *treeNode) walk(fn func(*treeNode)) {
|
|
fn(n)
|
|
for i := range n.Nodes {
|
|
n.Nodes[i].walk(fn)
|
|
}
|
|
for i := range n.Float {
|
|
n.Float[i].walk(fn)
|
|
}
|
|
}
|
|
|
|
// WindowRects returns the geometry of every mapped window keyed by owning
|
|
// pid, from a single get_tree round trip. The daemon checks all tiles against
|
|
// one snapshot instead of issuing a swaymsg per window.
|
|
func (c *Client) WindowRects(ctx context.Context) (map[int]Rect, error) {
|
|
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get_tree: %w", err)
|
|
}
|
|
var root treeNode
|
|
if err := json.Unmarshal(out, &root); err != nil {
|
|
return nil, err
|
|
}
|
|
rects := map[int]Rect{}
|
|
root.walk(func(n *treeNode) {
|
|
if n.PID != 0 && (n.AppID != "" || n.Name != "") {
|
|
rects[n.PID] = n.Rect
|
|
}
|
|
})
|
|
return rects, nil
|
|
}
|
|
|
|
// Place floats and positions the window owned by pid at the given rect. Using
|
|
// the pid criterion means we never depend on window titles or app-ids.
|
|
func (c *Client) Place(ctx context.Context, pid int, r Rect) error {
|
|
cmd := fmt.Sprintf(
|
|
"[pid=%d] floating enable, border none, move absolute position %d %d, resize set %d %d",
|
|
pid, r.X, r.Y, r.W, r.H,
|
|
)
|
|
return c.run(ctx, cmd)
|
|
}
|
|
|
|
// PrepareForMPV installs a global rule so every mpv window maps floating,
|
|
// ready for the daemon to position. Borders are already off via the kiosk
|
|
// config's `default_border none`, so this rule is a single command: sway's
|
|
// IPC parser splits a comma-joined command list at the top level (it does NOT
|
|
// fold the continuation into for_window the way the config-file parser does),
|
|
// so a multi-command for_window must be registered one command per call.
|
|
// Safe to call repeatedly.
|
|
func (c *Client) PrepareForMPV(ctx context.Context) error {
|
|
return c.run(ctx, `for_window [app_id="mpv"] floating enable`)
|
|
}
|
|
|
|
// PlaceOnMap installs for_window rules that position and size any window with
|
|
// the given title the moment it maps — so a restarting stream lands in its
|
|
// cell without a wrong-place flash, before the corrective loop ever runs. The
|
|
// title is anchored (^...$) so slot-1 never matches slot-10. Because sway's
|
|
// IPC command parser splits on commas at the top level, each rule carries a
|
|
// single command and is registered separately (a comma-joined for_window would
|
|
// silently drop everything after the first command and run the rest
|
|
// immediately against the focused container). Re-registering a rule for the
|
|
// same criteria replaces it.
|
|
func (c *Client) PlaceOnMap(ctx context.Context, title string, r Rect) error {
|
|
crit := fmt.Sprintf(`[title="^%s$"]`, title)
|
|
cmds := []string{
|
|
fmt.Sprintf("for_window %s floating enable", crit),
|
|
fmt.Sprintf("for_window %s move absolute position %d %d", crit, r.X, r.Y),
|
|
fmt.Sprintf("for_window %s resize set %d %d", crit, r.W, r.H),
|
|
}
|
|
for _, cmd := range cmds {
|
|
if err := c.run(ctx, cmd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|