Files
mesh-catalog/modules/xorg/cmd/xorg/tools.go
T
jschoubben 4fe8958dda
mesh/merge-gate pass: builds xorg → g14, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
xorg: refuse a window that is not on the screen, and keep an inline screenshot within the bus (review of #154)
A window that is unmapped or on a hidden workspace was cut from the screen anyway, which shows what is
there instead of it. An inline image of up to 4 MiB could not cross the bus, whose largest message is
1 MiB; it is now held to 700 KiB and halved in size until it fits, saying so.
2026-10-09 12:23:51 +02:00

676 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"xorg/internal/desktop"
)
type xorg struct{ d desktop.Desk }
// restored is what every answer that changed the running server says about lasting.
const untilLogin = "until the next login: the session's start applies the declared defaults and the matching layout profile again"
var outputName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
func (x xorg) query(ctx context.Context) (Screen, []Output, *desktop.Session, error) {
res, s, err := x.d.InSession(ctx, "xrandr", "--prop")
if err != nil {
return Screen{}, nil, nil, err
}
if !res.OK() {
return Screen{}, nil, s, res.Err()
}
screen, outs := ParseXrandr(res.Stdout)
return screen, outs, s, nil
}
func (x xorg) displays(ctx context.Context, a desktop.Args) (any, error) {
all, _, err := a.Bool("all")
if err != nil {
return nil, err
}
modes, given, err := a.Bool("modes")
if err != nil {
return nil, err
}
if !given {
modes = true
}
screen, outs, s, err := x.query(ctx)
if err != nil {
return nil, err
}
var shown []Output
var off []string
for _, o := range outs {
if !o.Connected && !all {
off = append(off, o.Name)
continue
}
if !modes {
o.Modes = nil
}
shown = append(shown, o)
}
answer := map[string]any{"session": s.Display, "screen": screen, "outputs": shown, "disconnected": off}
if r := x.d.Run(ctx, s.Env(x.d.Base), nil, "autorandr", "--current"); r.OK() {
answer["profile"] = firstName(r.Stdout)
}
return answer, nil
}
func firstName(text string) string {
for n := range names(text) {
return n
}
return ""
}
func (x xorg) layout(ctx context.Context, a desktop.Args) (any, error) {
action, err := a.OneOf("action", "", "list", "save", "apply")
if err != nil {
return nil, err
}
dirs := ProfileDirs(desktop.Home())
if action == "list" {
profiles := ReadProfiles(dirs)
answer := map[string]any{"profiles": profiles, "where": dirs[0]}
s, err := x.d.Find()
if err != nil {
answer["session"] = nil
answer["note"] = "no graphical session: which profile matches the monitors connected is not known"
return answer, nil
}
env := s.Env(x.d.Base)
det := x.d.Run(ctx, env, nil, "autorandr", "--detected")
if det.Code == 127 {
return nil, fmt.Errorf("autorandr is not installed here; the xorg module's package resource installs it")
}
detected, current := names(det.Stdout), names(x.d.Run(ctx, env, nil, "autorandr", "--current").Stdout)
for i := range profiles {
profiles[i].Detected, profiles[i].Current = detected[profiles[i].Name], current[profiles[i].Name]
}
answer["profiles"] = profiles
return answer, nil
}
name, err := a.Text("name")
if err != nil {
return nil, err
}
if !profileName.MatchString(name) {
return nil, fmt.Errorf("a profile name is letters, digits, dot, dash and underscore, up to 64")
}
args := []string{"--load", name}
if action == "save" {
replace, _, err := a.Bool("replace")
if err != nil {
return nil, err
}
args = []string{"--save", name}
if replace {
args = append(args, "--force")
}
}
res, s, err := x.d.InSession(ctx, "autorandr", args...)
if err != nil {
return nil, err
}
if res.Code == 127 {
return nil, fmt.Errorf("autorandr is not installed here; the xorg module's package resource installs it")
}
if !res.OK() {
return nil, res.Err()
}
answer := map[string]any{"action": action, "name": name, "session": s.Display, "said": strings.TrimSpace(res.Stdout + res.Stderr)}
if action == "save" {
answer["kept"] = "in " + dirs[0] + ": the operator's data; the session's start applies it whenever these monitors are connected"
}
return answer, nil
}
// ModeArgs is the xrandr command line set-mode asks for, refused before anything runs when it is not
// one xrandr would take.
func ModeArgs(a desktop.Args) ([]string, error) {
output, err := a.Text("output")
if err != nil {
return nil, err
}
if !outputName.MatchString(output) {
return nil, fmt.Errorf("%q is not an output name", output)
}
args := []string{"--output", output}
off, _, err := a.Bool("off")
if err != nil {
return nil, err
}
if off {
return append(args, "--off"), nil
}
switch mode := a.Opt("mode", ""); {
case mode == "auto":
args = append(args, "--auto")
case mode != "":
if !regexp.MustCompile(`^\d{2,5}x\d{2,5}i?$`).MatchString(mode) {
return nil, fmt.Errorf("mode is WxH, e.g. 2560x1440, or auto")
}
args = append(args, "--mode", mode)
}
if rate, given, err := a.Number("rate"); err != nil {
return nil, err
} else if given {
if rate < 1 || rate > 1000 {
return nil, fmt.Errorf("rate is in Hz, 1 to 1000")
}
args = append(args, "--rate", strconv.FormatFloat(rate, 'f', 2, 64))
}
if a.Has("rotate") {
r, err := a.OneOf("rotate", "", "normal", "left", "right", "inverted")
if err != nil {
return nil, err
}
args = append(args, "--rotate", r)
}
if scale, given, err := a.Number("scale"); err != nil {
return nil, err
} else if given {
if scale < 0.25 || scale > 4 {
return nil, fmt.Errorf("scale is from 0.25 to 4")
}
f := strconv.FormatFloat(scale, 'f', -1, 64)
args = append(args, "--scale", f+"x"+f)
}
if a.Has("relation") {
rel, err := a.OneOf("relation", "", "right-of", "left-of", "above", "below", "same-as")
if err != nil {
return nil, err
}
of, err := a.Text("of")
if err != nil || !outputName.MatchString(of) {
return nil, fmt.Errorf("relation needs of: the output it is beside")
}
args = append(args, "--"+rel, of)
} else if a.Has("x") || a.Has("y") {
px, err := a.Whole("x", 0, -32768, 32768)
if err != nil {
return nil, err
}
py, err := a.Whole("y", 0, -32768, 32768)
if err != nil {
return nil, err
}
args = append(args, "--pos", fmt.Sprintf("%dx%d", px, py))
}
if p, _, err := a.Bool("primary"); err != nil {
return nil, err
} else if p {
args = append(args, "--primary")
}
if len(args) == 2 {
return nil, fmt.Errorf("nothing to change: give a mode, rate, rotate, scale, position, off or primary")
}
return args, nil
}
func (x xorg) setMode(ctx context.Context, a desktop.Args) (any, error) {
args, err := ModeArgs(a)
if err != nil {
return nil, err
}
if dry, _, _ := a.Bool("dry_run"); dry {
return map[string]any{"dry_run": true, "command": append([]string{"xrandr"}, args...)}, nil
}
res, s, err := x.d.InSession(ctx, "xrandr", args...)
if err != nil {
return nil, err
}
if !res.OK() {
return nil, res.Err()
}
_, outs, _, _ := x.query(ctx)
var now *Output
for i := range outs {
if outs[i].Name == args[1] {
o := outs[i]
o.Modes = nil
now = &o
}
}
return map[string]any{"session": s.Display, "command": res.Command, "output": now, "lasts": untilLogin}, nil
}
func (x xorg) primary(ctx context.Context, a desktop.Args) (any, error) {
if name := a.Opt("output", ""); name != "" {
if !outputName.MatchString(name) {
return nil, fmt.Errorf("%q is not an output name", name)
}
res, _, err := x.d.InSession(ctx, "xrandr", "--output", name, "--primary")
if err != nil {
return nil, err
}
if !res.OK() {
return nil, res.Err()
}
}
_, outs, s, err := x.query(ctx)
if err != nil {
return nil, err
}
answer := map[string]any{"session": s.Display, "primary": nil}
for _, o := range outs {
if o.Primary {
answer["primary"] = o.Name
}
}
if a.Has("output") {
answer["lasts"] = untilLogin
}
return answer, nil
}
// ResourceValue is one resource's value from `xrdb -query` output.
func ResourceValue(query, name string) string {
for _, l := range strings.Split(query, "\n") {
if k, v, ok := strings.Cut(l, ":"); ok && strings.TrimSpace(k) == name {
return strings.TrimSpace(v)
}
}
return ""
}
func (x xorg) dpi(ctx context.Context, a desktop.Args) (any, error) {
value, err := a.Whole("value", 0, 48, 480)
if err != nil {
return nil, err
}
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
if value != 0 {
v := strconv.Itoa(value)
if r := x.d.Run(ctx, env, []byte("Xft.dpi: "+v+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
return nil, r.Err()
}
if r := x.d.Run(ctx, env, nil, "xrandr", "--dpi", v); !r.OK() {
return nil, r.Err()
}
}
q := x.d.Run(ctx, env, nil, "xrdb", "-query")
if !q.OK() {
return nil, q.Err()
}
_, outs, _, err := x.query(ctx)
if err != nil {
return nil, err
}
physical := map[string]float64{}
for _, o := range outs {
if o.DPI > 0 {
physical[o.Name] = o.DPI
}
}
answer := map[string]any{"session": s.Display, "xft_dpi": ResourceValue(q.Stdout, "Xft.dpi"), "physical": physical}
if value != 0 {
answer["set"] = value
answer["lasts"] = "for programs started from now on, " + untilLogin
}
return answer, nil
}
func (x xorg) devices(ctx context.Context, env []string) ([]Device, error) {
r := x.d.Run(ctx, env, nil, "xinput", "list")
if !r.OK() {
return nil, r.Err()
}
return ParseDevices(r.Stdout), nil
}
func (x xorg) inputDevices(ctx context.Context, a desktop.Args) (any, error) {
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
all, err := x.devices(ctx, env)
if err != nil {
return nil, err
}
filter := strings.ToLower(a.Opt("name", ""))
var out []Device
for _, dev := range all {
if filter != "" && !strings.Contains(strings.ToLower(dev.Name), filter) {
continue
}
if dev.Role == "slave" && dev.Kind == "pointer" && !strings.Contains(dev.Name, "XTEST") {
if r := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID)); r.OK() {
dev.Settings = ParseProps(r.Stdout)
}
}
out = append(out, dev)
}
return map[string]any{"session": s.Display, "devices": out}, nil
}
func (x xorg) inputSet(ctx context.Context, a desktop.Args) (any, error) {
which, err := a.Text("device")
if err != nil {
return nil, err
}
type change struct {
knob Knob
value string
}
var changes []change
for _, k := range Knobs {
if !a.Has(k.Arg) {
continue
}
switch k.Kind {
case "bool":
b, _, err := a.Bool(k.Arg)
if err != nil {
return nil, err
}
changes = append(changes, change{k, map[bool]string{true: "1", false: "0"}[b]})
case "float":
f, _, err := a.Number(k.Arg)
if err != nil {
return nil, err
}
if f < -1 || f > 1 {
return nil, fmt.Errorf("%s is from -1 to 1", k.Arg)
}
changes = append(changes, change{k, strconv.FormatFloat(f, 'f', 3, 64)})
}
}
if len(changes) == 0 {
return nil, fmt.Errorf("nothing to change: name at least one setting")
}
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
all, err := x.devices(ctx, env)
if err != nil {
return nil, err
}
var ids []Device
for _, dev := range all {
if dev.Role == "slave" && (strconv.Itoa(dev.ID) == which || dev.Name == which) {
ids = append(ids, dev)
}
}
if len(ids) == 0 {
return nil, fmt.Errorf("no input device is %q: xorg_input_devices lists them", which)
}
type outcome struct {
ID int `json:"id"`
Name string `json:"name"`
Set []string `json:"set"`
Skipped []string `json:"skipped,omitempty"`
Now map[string]any `json:"now"`
}
var done []outcome
for _, dev := range ids {
props := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID))
have := ParseProps(props.Stdout)
o := outcome{ID: dev.ID, Name: dev.Name, Set: []string{}}
for _, c := range changes {
if _, ok := have[c.knob.Arg]; !ok {
o.Skipped = append(o.Skipped, c.knob.Arg)
continue
}
r := x.d.Run(ctx, env, nil, "xinput", "set-prop", strconv.Itoa(dev.ID), c.knob.Property, c.value)
if !r.OK() {
return nil, r.Err()
}
o.Set = append(o.Set, c.knob.Arg)
}
after := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID))
o.Now = ParseProps(after.Stdout)
done = append(done, o)
}
return map[string]any{"session": s.Display, "devices": done, "lasts": "until the device reconnects or the next login"}, nil
}
func (x xorg) keyboard(ctx context.Context, a desktop.Args) (any, error) {
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
var args []string
for _, k := range []string{"layout", "variant", "model"} {
if v := a.Opt(k, ""); v != "" {
if !xkbName.MatchString(v) {
return nil, fmt.Errorf("%s %q is not an XKB name", k, v)
}
args = append(args, "-"+k, v)
}
}
if a.Has("options") {
opts, err := a.Strings("options")
if err != nil {
return nil, err
}
args = append(args, "-option", "")
for _, o := range opts {
if !xkbName.MatchString(o) {
return nil, fmt.Errorf("option %q is not an XKB name", o)
}
args = append(args, "-option", o)
}
}
if len(args) > 0 {
if r := x.d.Run(ctx, env, nil, "setxkbmap", args...); !r.OK() {
return nil, r.Err()
}
}
q := x.d.Run(ctx, env, nil, "setxkbmap", "-query")
if !q.OK() {
return nil, q.Err()
}
answer := map[string]any{"session": s.Display, "keyboard": ParseKeyboard(q.Stdout)}
if len(args) > 0 {
answer["lasts"] = untilLogin
}
return answer, nil
}
var windowID = regexp.MustCompile(`^(0x[0-9a-fA-F]{1,8}|[0-9]{1,10})$`)
// mostInline is the largest PNG a screenshot carries in its answer. A tool's answer crosses the bus,
// whose largest message is 1 MiB; 700 KiB is about 935 KiB as base64, which leaves room for the rest.
const mostInline = 700 << 10
// shrink averages each by×by block of an image into one pixel.
func shrink(img image.Image, by int) *image.RGBA {
b := img.Bounds()
out := image.NewRGBA(image.Rect(0, 0, b.Dx()/by, b.Dy()/by))
for y := 0; y < out.Bounds().Dy(); y++ {
for x := 0; x < out.Bounds().Dx(); x++ {
var r, g, bl, n uint32
for dy := 0; dy < by; dy++ {
for dx := 0; dx < by; dx++ {
cr, cg, cb, _ := img.At(b.Min.X+x*by+dx, b.Min.Y+y*by+dy).RGBA()
r, g, bl, n = r+cr, g+cg, bl+cb, n+1
}
}
out.SetRGBA(x, y, color.RGBA{uint8(r / n >> 8), uint8(g / n >> 8), uint8(bl / n >> 8), 0xff})
}
}
return out
}
func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
inline, _, err := a.Bool("inline")
if err != nil {
return nil, err
}
stamp := time.Now().Format("20060102-150405")
path, err := desktop.InHome(a.Opt("path", "~/Pictures/Screenshots/screenshot-"+stamp+".png"))
if err != nil {
return nil, err
}
if !strings.HasSuffix(strings.ToLower(path), ".png") {
return nil, fmt.Errorf("the screenshot is a PNG: the path ends in .png")
}
s, err := x.d.Find()
if err != nil {
return nil, err
}
env := s.Env(x.d.Base)
dump, err := os.CreateTemp("", "xorg-screenshot-*.xwd")
if err != nil {
return nil, err
}
dump.Close()
defer os.Remove(dump.Name())
// A window is cut from the whole screen, not dumped by its id: what the screen shows is what the
// compositor made of it (a window with an alpha channel is blended with what is behind it), and xwd
// asks the server for a window's colormap entries, which a window of a 32-bit visual can refuse
// with BadColor (X_QueryColors). The cut is the window's frame-less area at its absolute place.
var cut *image4
if w := a.Opt("window", ""); w != "" {
if !windowID.MatchString(w) {
return nil, fmt.Errorf("window is an X window id, e.g. 0x3a00007")
}
g, err := x.windowGeometry(ctx, env, w)
if err != nil {
return nil, err
}
// A window that is not viewable is not on the screen: its rectangle would show what is there
// instead, which is a picture of the wrong thing.
if g.Mapped != "" && g.Mapped != "IsViewable" {
return nil, fmt.Errorf("window %s is %s, not on the screen (IsUnMapped: not shown; IsUnviewable: "+
"its parent is not shown, e.g. on another workspace): bring it to the screen first", w, g.Mapped)
}
cut = &g
}
args := []string{"-silent", "-out", dump.Name(), "-root"}
if r := x.d.Run(ctx, env, nil, "xwd", args...); !r.OK() {
if r.Code == 127 {
return nil, fmt.Errorf("xwd is not installed here; the xorg module's package resource (xorg-xwd) installs it")
}
return nil, r.Err()
}
raw, err := os.ReadFile(dump.Name())
if err != nil {
return nil, err
}
img, err := DecodeXWD(raw)
if err != nil {
return nil, fmt.Errorf("the dump xwd wrote: %w", err)
}
var shot image.Image = img
if cut != nil {
r := image.Rect(cut.X, cut.Y, cut.X+cut.W, cut.Y+cut.H).Intersect(img.Bounds())
if r.Empty() {
return nil, fmt.Errorf("the window is not on the screen (%dx%d at %d,%d)", cut.W, cut.H, cut.X, cut.Y)
}
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
shot = crop
} else if name := a.Opt("output", ""); name != "" {
_, outs, _, err := x.query(ctx)
if err != nil {
return nil, err
}
var g *Geometry
for _, o := range outs {
if o.Name == name {
g = o.Geometry
}
}
if g == nil {
return nil, fmt.Errorf("output %q is not on: displays lists the outputs in use", name)
}
r := image.Rect(g.X, g.Y, g.X+g.Width, g.Y+g.Height).Intersect(img.Bounds())
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
shot = crop
}
var buf bytes.Buffer
if err := png.Encode(&buf, shot); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return nil, err
}
b := shot.Bounds()
answer := map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}
if inline {
// For a session that cannot read the machine's files: the image itself, within the bus's bound.
// An image larger than mostInline is halved until it fits, and the answer says by how much; the
// file keeps the full size.
small, scale := buf.Bytes(), 1
for len(small) > mostInline && shot.Bounds().Dx()/(scale*2) >= 64 {
scale *= 2
var b bytes.Buffer
if err := png.Encode(&b, shrink(shot, scale)); err != nil {
return nil, err
}
small = b.Bytes()
}
if len(small) > mostInline {
answer["inline"] = fmt.Sprintf("not included: %d bytes even at 1/%d; take one output or one window", len(small), scale)
} else {
answer["png_base64"] = base64.StdEncoding.EncodeToString(small)
if scale > 1 {
answer["inline_scale"] = fmt.Sprintf("1/%d of the file's size, to fit the bus", scale)
}
}
}
return answer, nil
}
func (x xorg) xlog(ctx context.Context, a desktop.Args) (any, error) {
warnings, _, err := a.Bool("warnings")
if err != nil {
return nil, err
}
limit, err := a.Whole("limit", 100, 1, 1000)
if err != nil {
return nil, err
}
display := -1
if s, err := x.d.Find(); err == nil {
if n, ok := desktop.DisplayNumber(s.Display); ok {
display = n
}
}
files := XLogFiles(display, desktop.Home())
if display >= 0 {
files = append(files, XLogFiles(-1, desktop.Home())...)
}
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
continue
}
return ParseXLog(f, string(raw), warnings, limit), nil
}
sort.Strings(files)
return nil, fmt.Errorf("no X server log is readable here; looked at %s", strings.Join(files, ", "))
}