The X server, its start and its tools as one module. It provides x11-display with the machine's reach, gated by the host's seat capability. It writes a block at the start of ~/.xinitrc: the account's environment, an explicit import into the user manager, the mesh's X resources merged without cpp, autorandr, the xinitrc slots, ~/.xinitrc.local, and the session's exec from the last slot. Its Go tools serve the seat's displays and layout (autorandr profiles keyed by EDID), and set-mode, primary, dpi, input devices and settings, keyboard, a screenshot (xwd decoded in Go) and the server's log. internal/desktop is how every desktop tool finds the operator's session from the runtime, which has none: from the session's own processes, reading only its words, confirmed with logind.
603 lines
16 KiB
Go
603 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
"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})$`)
|
|
|
|
func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
|
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())
|
|
args := []string{"-silent", "-out", dump.Name()}
|
|
if w := a.Opt("window", ""); w != "" {
|
|
if !windowID.MatchString(w) {
|
|
return nil, fmt.Errorf("window is an X window id, e.g. 0x3a00007")
|
|
}
|
|
args = append(args, "-id", w)
|
|
} else {
|
|
args = append(args, "-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 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()
|
|
return map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}, 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, ", "))
|
|
}
|