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, ", ")) }