xorg: the display server holds node-display-server and writes the session's start (hq ADR 0208)
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.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Screen is the X screen as xrandr reports it.
|
||||
type Screen struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
MaxWidth int `json:"max_width"`
|
||||
MaxHeight int `json:"max_height"`
|
||||
}
|
||||
|
||||
// Output is one connector: a monitor when connected.
|
||||
type Output struct {
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Geometry *Geometry `json:"geometry,omitempty"`
|
||||
Rotation string `json:"rotation,omitempty"`
|
||||
Reflect string `json:"reflect,omitempty"`
|
||||
WidthMM int `json:"width_mm,omitempty"`
|
||||
HeightMM int `json:"height_mm,omitempty"`
|
||||
// DPI is the physical density of the current mode, from the size the monitor reports.
|
||||
DPI float64 `json:"dpi,omitempty"`
|
||||
Monitor *Identity `json:"monitor,omitempty"`
|
||||
Current *ModeRate `json:"current,omitempty"`
|
||||
Preferred *ModeRate `json:"preferred,omitempty"`
|
||||
Modes []Mode `json:"modes,omitempty"`
|
||||
|
||||
edid []byte
|
||||
}
|
||||
|
||||
// Geometry is where an output's picture sits on the screen.
|
||||
type Geometry struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
// Mode is one resolution and the refresh rates it is offered at.
|
||||
type Mode struct {
|
||||
Size string `json:"size"`
|
||||
Rates []float64 `json:"rates"`
|
||||
}
|
||||
|
||||
// ModeRate is one resolution at one rate.
|
||||
type ModeRate struct {
|
||||
Size string `json:"size"`
|
||||
Rate float64 `json:"rate"`
|
||||
}
|
||||
|
||||
// Identity is who the monitor is, from its EDID: what an autorandr profile is keyed by, and a name a
|
||||
// person recognises.
|
||||
type Identity struct {
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Product string `json:"product"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
// Fingerprint is the first 12 hex characters of the EDID's sha256: one monitor, whatever port.
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
var (
|
||||
screenLine = regexp.MustCompile(`^Screen \d+: minimum \d+ x \d+, current (\d+) x (\d+), maximum (\d+) x (\d+)`)
|
||||
geometryRe = regexp.MustCompile(`^(\d+)x(\d+)\+(-?\d+)\+(-?\d+)$`)
|
||||
sizeMM = regexp.MustCompile(`\)\s+(\d+)mm x (\d+)mm`)
|
||||
)
|
||||
|
||||
// ParseXrandr reads `xrandr --prop` (or `--query`): the screen, and every output with its modes and,
|
||||
// where the properties carry one, its EDID decoded.
|
||||
func ParseXrandr(text string) (Screen, []Output) {
|
||||
var screen Screen
|
||||
var outs []Output
|
||||
var cur *Output
|
||||
inEDID := false
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<22)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "Screen "):
|
||||
if m := screenLine.FindStringSubmatch(line); m != nil {
|
||||
screen = Screen{atoi(m[1]), atoi(m[2]), atoi(m[3]), atoi(m[4])}
|
||||
}
|
||||
inEDID = false
|
||||
case line != "" && line[0] != ' ' && line[0] != '\t':
|
||||
outs = append(outs, parseOutputLine(line))
|
||||
cur = &outs[len(outs)-1]
|
||||
inEDID = false
|
||||
case cur == nil:
|
||||
case strings.HasPrefix(line, "\t\t"):
|
||||
if inEDID {
|
||||
if b, err := hex.DecodeString(strings.TrimSpace(line)); err == nil {
|
||||
cur.edid = append(cur.edid, b...)
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(line, "\t"):
|
||||
inEDID = strings.HasPrefix(strings.TrimSpace(line), "EDID:")
|
||||
case strings.HasPrefix(line, " "):
|
||||
inEDID = false
|
||||
parseModeLine(cur, line)
|
||||
}
|
||||
}
|
||||
for i := range outs {
|
||||
o := &outs[i]
|
||||
if len(o.edid) >= 128 {
|
||||
o.Monitor = DecodeEDID(o.edid)
|
||||
}
|
||||
if o.Geometry != nil && o.WidthMM > 0 {
|
||||
w := o.Geometry.Width
|
||||
if o.Rotation == "left" || o.Rotation == "right" {
|
||||
w = o.Geometry.Height
|
||||
}
|
||||
o.DPI = float64(int(float64(w)/(float64(o.WidthMM)/25.4)*10+0.5)) / 10
|
||||
}
|
||||
}
|
||||
return screen, outs
|
||||
}
|
||||
|
||||
func parseOutputLine(line string) Output {
|
||||
f := strings.Fields(line)
|
||||
o := Output{Name: f[0], Connected: len(f) > 1 && f[1] == "connected"}
|
||||
for _, tok := range f[2:] {
|
||||
if strings.HasPrefix(tok, "(") {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case tok == "primary":
|
||||
o.Primary = true
|
||||
case geometryRe.MatchString(tok):
|
||||
g := geometryRe.FindStringSubmatch(tok)
|
||||
o.Geometry = &Geometry{atoi(g[1]), atoi(g[2]), atoi(g[3]), atoi(g[4])}
|
||||
o.Enabled = true
|
||||
case tok == "normal" || tok == "left" || tok == "inverted" || tok == "right":
|
||||
o.Rotation = tok
|
||||
case tok == "X" || tok == "Y" || tok == "and" || tok == "axis":
|
||||
o.Reflect = strings.TrimSpace(o.Reflect + " " + tok)
|
||||
}
|
||||
}
|
||||
if o.Enabled && o.Rotation == "" {
|
||||
o.Rotation = "normal"
|
||||
}
|
||||
if m := sizeMM.FindStringSubmatch(line); m != nil {
|
||||
o.WidthMM, o.HeightMM = atoi(m[1]), atoi(m[2])
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// parseModeLine reads ` 2880x1800 60.00*+ 120.00 +`: a rate marked * is in use, + preferred,
|
||||
// and xrandr writes the + as a token of its own after a rate not in use. A panel may mark several
|
||||
// rates preferred; the first is the monitor's own preference.
|
||||
func parseModeLine(o *Output, line string) {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 0 || !strings.Contains(f[0], "x") {
|
||||
return
|
||||
}
|
||||
mode := Mode{Size: f[0]}
|
||||
for _, tok := range f[1:] {
|
||||
if tok == "+" {
|
||||
if n := len(mode.Rates); n > 0 && o.Preferred == nil {
|
||||
o.Preferred = &ModeRate{mode.Size, mode.Rates[n-1]}
|
||||
}
|
||||
continue
|
||||
}
|
||||
current := strings.Contains(tok, "*")
|
||||
preferred := strings.Contains(tok, "+")
|
||||
rate, err := strconv.ParseFloat(strings.Trim(tok, "*+"), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mode.Rates = append(mode.Rates, rate)
|
||||
if current {
|
||||
o.Current = &ModeRate{mode.Size, rate}
|
||||
}
|
||||
if preferred && o.Preferred == nil {
|
||||
o.Preferred = &ModeRate{mode.Size, rate}
|
||||
}
|
||||
}
|
||||
o.Modes = append(o.Modes, mode)
|
||||
}
|
||||
|
||||
// DecodeEDID reads the vendor block and the descriptors of an EDID.
|
||||
func DecodeEDID(e []byte) *Identity {
|
||||
if len(e) < 128 {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256(e)
|
||||
id := &Identity{Fingerprint: hex.EncodeToString(sum[:])[:12]}
|
||||
v := uint16(e[8])<<8 | uint16(e[9])
|
||||
id.Manufacturer = string([]byte{
|
||||
byte('A' - 1 + (v>>10)&0x1f), byte('A' - 1 + (v>>5)&0x1f), byte('A' - 1 + v&0x1f),
|
||||
})
|
||||
id.Product = fmt.Sprintf("%04X", uint16(e[10])|uint16(e[11])<<8)
|
||||
if serial := uint32(e[12]) | uint32(e[13])<<8 | uint32(e[14])<<16 | uint32(e[15])<<24; serial != 0 {
|
||||
id.Serial = strconv.FormatUint(uint64(serial), 10)
|
||||
}
|
||||
if e[17] != 0 {
|
||||
id.Year = 1990 + int(e[17])
|
||||
}
|
||||
for _, at := range []int{54, 72, 90, 108} {
|
||||
d := e[at : at+18]
|
||||
if d[0] != 0 || d[1] != 0 || d[2] != 0 {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(strings.SplitN(string(d[5:18]), "\n", 2)[0])
|
||||
switch d[3] {
|
||||
case 0xfc:
|
||||
id.Name = text
|
||||
case 0xff:
|
||||
if text != "" {
|
||||
id.Serial = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Device is one input device the X server knows.
|
||||
type Device struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"` // master or slave
|
||||
Kind string `json:"kind"` // pointer or keyboard
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
var deviceLine = regexp.MustCompile(`^[^A-Za-z0-9]*(.+?)\s+id=(\d+)\s+\[(master|slave|floating)\s+(pointer|keyboard)?`)
|
||||
|
||||
// ParseDevices reads `xinput list`, the tree's drawing characters stripped.
|
||||
func ParseDevices(text string) []Device {
|
||||
var out []Device
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
m := deviceLine.FindStringSubmatch(sc.Text())
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := strconv.Atoi(m[2])
|
||||
kind := m[4]
|
||||
if kind == "" {
|
||||
kind = "floating"
|
||||
}
|
||||
out = append(out, Device{ID: id, Name: strings.TrimSpace(m[1]), Role: m[3], Kind: kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Knob is one setting input-set changes, and the libinput property that holds it.
|
||||
type Knob struct {
|
||||
Arg, Property, Kind string // Kind: bool or float
|
||||
}
|
||||
|
||||
// Knobs are what input-set may change, by libinput's own property names (libinput(4)).
|
||||
var Knobs = []Knob{
|
||||
{"enabled", "Device Enabled", "bool"},
|
||||
{"tap", "libinput Tapping Enabled", "bool"},
|
||||
{"tap_drag", "libinput Tapping Drag Enabled", "bool"},
|
||||
{"natural_scroll", "libinput Natural Scrolling Enabled", "bool"},
|
||||
{"disable_while_typing", "libinput Disable While Typing Enabled", "bool"},
|
||||
{"left_handed", "libinput Left Handed Enabled", "bool"},
|
||||
{"middle_emulation", "libinput Middle Emulation Enabled", "bool"},
|
||||
{"accel_speed", "libinput Accel Speed", "float"},
|
||||
}
|
||||
|
||||
var propLine = regexp.MustCompile(`^\s+(.+?) \(\d+\):\s*(.*)$`)
|
||||
|
||||
// ParseProps reads `xinput list-props` into the settings Knobs name, as booleans and numbers. A
|
||||
// device without a property (a keyboard has no tapping) simply lacks the setting.
|
||||
func ParseProps(text string) map[string]any {
|
||||
byProp := map[string]string{}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
if m := propLine.FindStringSubmatch(sc.Text()); m != nil {
|
||||
byProp[m[1]] = strings.TrimSpace(m[2])
|
||||
}
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, k := range Knobs {
|
||||
v, ok := byProp[k.Property]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
first := strings.TrimSpace(strings.Split(v, ",")[0])
|
||||
switch k.Kind {
|
||||
case "bool":
|
||||
out[k.Arg] = first == "1"
|
||||
case "float":
|
||||
if f, err := strconv.ParseFloat(first, 64); err == nil {
|
||||
out[k.Arg] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := byProp["libinput Accel Profile Enabled"]; ok {
|
||||
profiles := []string{"adaptive", "flat", "custom"}
|
||||
for i, bit := range strings.Split(v, ",") {
|
||||
if strings.TrimSpace(bit) == "1" && i < len(profiles) {
|
||||
out["accel_profile"] = profiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Keyboard is the X keyboard map as setxkbmap reports it.
|
||||
type Keyboard struct {
|
||||
Rules string `json:"rules,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
// ParseKeyboard reads `setxkbmap -query`.
|
||||
func ParseKeyboard(text string) Keyboard {
|
||||
k := Keyboard{Options: []string{}}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
key, value, ok := strings.Cut(sc.Text(), ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "rules":
|
||||
k.Rules = value
|
||||
case "model":
|
||||
k.Model = value
|
||||
case "layout":
|
||||
k.Layout = value
|
||||
case "variant":
|
||||
k.Variant = value
|
||||
case "options":
|
||||
for _, o := range strings.Split(value, ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
k.Options = append(k.Options, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// xkbName is what setxkbmap accepts as a layout, variant, model or option: letters, digits and the
|
||||
// punctuation XKB names use. Anything else is refused before it reaches a command line.
|
||||
var xkbName = regexp.MustCompile(`^[A-Za-z0-9_:+(),.-]+$`)
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Profile is one saved autorandr layout: the monitors it is for, by output and EDID fingerprint.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Monitors map[string]string `json:"monitors"`
|
||||
Detected bool `json:"detected,omitempty"`
|
||||
Current bool `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
// profileName is what a profile may be called: it becomes a directory name.
|
||||
var profileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ProfileDirs are where autorandr keeps profiles, the account's first (autorandr(1)).
|
||||
func ProfileDirs(home string) []string {
|
||||
cfg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if cfg == "" {
|
||||
cfg = filepath.Join(home, ".config")
|
||||
}
|
||||
return []string{filepath.Join(cfg, "autorandr"), "/etc/xdg/autorandr"}
|
||||
}
|
||||
|
||||
// ReadProfiles lists the profiles in dirs; a name in an earlier directory hides a later one, as in
|
||||
// autorandr itself. Each profile's monitors come from its `setup` file: `<output> <edid>` per line.
|
||||
func ReadProfiles(dirs []string) []Profile {
|
||||
seen := map[string]bool{}
|
||||
var out []Profile
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
setup := filepath.Join(dir, e.Name(), "setup")
|
||||
f, err := os.Open(setup)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p := Profile{Name: e.Name(), Path: filepath.Join(dir, e.Name()), Monitors: map[string]string{}}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) == 2 {
|
||||
p.Monitors[fields[0]] = shortEDID(fields[1])
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
seen[e.Name()] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// shortEDID is the fingerprint DecodeEDID gives for the same monitor, so a profile's monitors and the
|
||||
// connected ones are compared by one value.
|
||||
func shortEDID(hexEDID string) string {
|
||||
b := make([]byte, 0, len(hexEDID)/2)
|
||||
for i := 0; i+1 < len(hexEDID); i += 2 {
|
||||
var v byte
|
||||
for _, c := range hexEDID[i : i+2] {
|
||||
v <<= 4
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
v |= byte(c - '0')
|
||||
case c >= 'a' && c <= 'f':
|
||||
v |= byte(c - 'a' + 10)
|
||||
case c >= 'A' && c <= 'F':
|
||||
v |= byte(c - 'A' + 10)
|
||||
default:
|
||||
return hexEDID
|
||||
}
|
||||
}
|
||||
b = append(b, v)
|
||||
}
|
||||
if id := DecodeEDID(b); id != nil {
|
||||
return id.Fingerprint
|
||||
}
|
||||
return hexEDID
|
||||
}
|
||||
|
||||
// names reads autorandr's one-name-per-line answers (--detected, --current).
|
||||
func names(text string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
out[strings.Fields(l)[0]] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// xorg's tools (novox/hq ADR 0208, research 026/05): node-display-server's verbs `displays` and
|
||||
// `layout`, and the module's own tools for one output's mode, the primary output, DPI, input devices,
|
||||
// the keyboard map, a screenshot and the server's log.
|
||||
//
|
||||
// Every tool that touches the screen acts in the operator's running session, which it finds through
|
||||
// internal/desktop: the node's runtime has none of its own. With no session, the answer says so as
|
||||
// structured data. A tool that changes the running server changes it until the next login; the
|
||||
// answer says what makes it last.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
// The session's holders this module prefers as the session's own process.
|
||||
var sessionHolders = []string{"i3", "sway", "openbox", "bspwm", "awesome", "xmonad"}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(desktop.Machine(sessionHolders...))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// call bounds one tool call below the runtime's 30 s limit.
|
||||
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
return run(ctx, desktop.Args(args))
|
||||
}
|
||||
}
|
||||
|
||||
func tools(d desktop.Desk) []stdio.Tool {
|
||||
x := xorg{d: d}
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-display-server.displays",
|
||||
Description: "The X screen and every output: connected or not, primary, where it sits, its rotation, " +
|
||||
"physical size and DPI, the monitor's identity from its EDID (manufacturer, product, serial, name, " +
|
||||
"fingerprint), the current and preferred mode, and every mode with its rates. Disconnected outputs " +
|
||||
"are listed by name only unless all is true. Also the autorandr profile in force, when one matches.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"all": desktop.Flag("list disconnected outputs in full (default false)"),
|
||||
"modes": desktop.Flag("list every mode of each output (default true)"),
|
||||
}),
|
||||
Run: call(x.displays),
|
||||
},
|
||||
{
|
||||
Name: "node-display-server.layout",
|
||||
Description: "Monitor layout profiles (autorandr), keyed by the connected monitors' identities, so one " +
|
||||
"profile needs no machine's name. list: every profile with the monitors it is for, which match " +
|
||||
"the monitors connected now and which is in force. save: the current arrangement under a name " +
|
||||
"(replace true to overwrite). apply: load one. The profiles are the operator's data, never the mesh's.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"action": desktop.Enum("list, save or apply", "list", "save", "apply"),
|
||||
"name": desktop.Str("the profile to save or apply"),
|
||||
"replace": desktop.Flag("save over an existing profile of that name (default false)"),
|
||||
}, "action"),
|
||||
Run: call(x.layout),
|
||||
},
|
||||
{
|
||||
Name: "xorg_set_mode",
|
||||
Description: "Change one output now, through xrandr: its mode (WxH, or auto for the preferred one), rate, " +
|
||||
"rotation, scale, position (x/y, or beside another output), on or off, primary. dry_run answers " +
|
||||
"the command without running it. Lasts until the next login or hotplug: save a layout profile to keep it.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"output": desktop.Str("the output, as displays names it (e.g. eDP-1)"),
|
||||
"mode": desktop.Str("WxH, or auto for the monitor's preferred mode"),
|
||||
"rate": desktop.Num("refresh rate in Hz"),
|
||||
"rotate": desktop.Enum("rotation", "normal", "left", "right", "inverted"),
|
||||
"scale": desktop.Num("scale factor, 0.25 to 4 (1 = none)"),
|
||||
"x": desktop.Int("left edge on the screen, in pixels"),
|
||||
"y": desktop.Int("top edge on the screen, in pixels"),
|
||||
"relation": desktop.Enum("place it beside another output instead of at x/y", "right-of", "left-of", "above", "below", "same-as"),
|
||||
"of": desktop.Str("the output the relation is to"),
|
||||
"off": desktop.Flag("switch the output off"),
|
||||
"primary": desktop.Flag("make it the primary output"),
|
||||
"dry_run": desktop.Flag("answer the command, run nothing"),
|
||||
}, "output"),
|
||||
Run: call(x.setMode),
|
||||
},
|
||||
{
|
||||
Name: "xorg_primary",
|
||||
Description: "Which output is primary (the one the bar's tray and new windows prefer); with output, make it primary now.",
|
||||
Input: desktop.Schema(map[string]any{"output": desktop.Str("the output to make primary (optional)")}),
|
||||
Run: call(x.primary),
|
||||
},
|
||||
{
|
||||
Name: "xorg_dpi",
|
||||
Description: "The DPI X programs are told (Xft.dpi) beside each monitor's physical DPI. With value, set " +
|
||||
"Xft.dpi and the screen's DPI now: programs started after it use it. The module's resources file " +
|
||||
"sets it again at the next login (96 until it is a setting).",
|
||||
Input: desktop.Schema(map[string]any{"value": desktop.Int("dots per inch, 48 to 480 (optional)")}),
|
||||
Run: call(x.dpi),
|
||||
},
|
||||
{
|
||||
Name: "xorg_input_devices",
|
||||
Description: "The input devices the X server knows: id, name, pointer or keyboard, master or slave, and " +
|
||||
"for each pointer its libinput settings (tap, natural scroll, acceleration, disable while typing, " +
|
||||
"left handed, enabled).",
|
||||
Input: desktop.Schema(map[string]any{"name": desktop.Str("only devices whose name contains this (optional)")}),
|
||||
Run: call(x.inputDevices),
|
||||
},
|
||||
{
|
||||
Name: "xorg_input_set",
|
||||
Description: "Change libinput settings of a device now: tap, tap_drag, natural_scroll, disable_while_typing, " +
|
||||
"left_handed, middle_emulation, enabled (true/false), accel_speed (-1 to 1). The device is an id or a " +
|
||||
"name; a name applies to every device of that name that has the setting. Lasts until the device " +
|
||||
"reconnects or the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"device": desktop.Str("the device's id or exact name"),
|
||||
"tap": desktop.Flag("tap to click"),
|
||||
"tap_drag": desktop.Flag("tap and drag"),
|
||||
"natural_scroll": desktop.Flag("natural (reversed) scrolling"),
|
||||
"disable_while_typing": desktop.Flag("ignore the touchpad while typing"),
|
||||
"left_handed": desktop.Flag("swap the buttons"),
|
||||
"middle_emulation": desktop.Flag("both buttons together are the middle one"),
|
||||
"enabled": desktop.Flag("the device takes input at all"),
|
||||
"accel_speed": desktop.Num("pointer acceleration, -1 to 1"),
|
||||
}, "device"),
|
||||
Run: call(x.inputSet),
|
||||
},
|
||||
{
|
||||
Name: "xorg_keyboard",
|
||||
Description: "The X keyboard map: rules, model, layout, variant and options. With layout, variant, model " +
|
||||
"or options, set them now (options replace the current ones; an empty list clears them). Lasts " +
|
||||
"until the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"layout": desktop.Str("e.g. us, be, us,be"),
|
||||
"variant": desktop.Str("e.g. intl"),
|
||||
"model": desktop.Str("e.g. pc105"),
|
||||
"options": desktop.List("e.g. [\"caps:escape\", \"compose:ralt\"]"),
|
||||
}),
|
||||
Run: call(x.keyboard),
|
||||
},
|
||||
{
|
||||
Name: "xorg_screenshot",
|
||||
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id. " +
|
||||
"Written under the account's home (default ~/Pictures/Screenshots/screenshot-<time>.png). Answers " +
|
||||
"the path, size and bytes, never the image.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"output": desktop.Str("only this output (optional)"),
|
||||
"window": desktop.Str("only this window, by its X id, e.g. 0x3a00007 (optional)"),
|
||||
"path": desktop.Str("where to write it, inside the home (optional)"),
|
||||
}),
|
||||
Run: call(x.screenshot),
|
||||
},
|
||||
{
|
||||
Name: "xorg_x_log",
|
||||
Description: "The X server's log for the running session (or the newest one): its version, when it " +
|
||||
"started, and its errors — with warnings when asked — newest last.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"warnings": desktop.Flag("include warnings (default false)"),
|
||||
"limit": desktop.Int("at most this many lines (default 100, at most 1000)"),
|
||||
}),
|
||||
Run: call(x.xlog),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
// xorg's shape (novox/hq ADR 0208): it holds node-display-server and provides the display with the
|
||||
// machine's reach; it writes the session's start as a block at the start of ~/.xinitrc in the order
|
||||
// §5 gives, and the resources file every module contributes to. The block is also run, with the
|
||||
// programs it calls replaced by recorders, to prove what it does rather than what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Provides []map[string]any `json:"provides"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Seats any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment map[string]any `json:"environment"`
|
||||
Shell []map[string]any `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestItHoldsTheDisplayServerSeatAndServesItsVerbs(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-display-server" || m.Claims[0].Scope != "node" {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if strings.Join(m.Claims[0].Serves, ",") != "displays,layout" {
|
||||
t.Fatalf("serves: %v", m.Claims[0].Serves)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools((&fakeDesk{}).desk(false)) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
for _, v := range m.Claims[0].Serves {
|
||||
if !served["node-display-server."+v] {
|
||||
t.Errorf("the bundle does not serve the seat's %s", v)
|
||||
}
|
||||
}
|
||||
own := 0
|
||||
for name := range served {
|
||||
if !strings.HasPrefix(name, "node-display-server.") {
|
||||
own++
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] || !strings.HasPrefix(name, "xorg_") {
|
||||
t.Errorf("the manifest lists %s, which the bundle does not serve as its own", name)
|
||||
}
|
||||
}
|
||||
if own != len(m.Tools) {
|
||||
t.Errorf("the bundle serves %d tools of its own, the manifest lists %d", own, len(m.Tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDisplayIsProvidedOnThisMachineOnlyAndGatedByASeatOfHardware(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Provides) != 1 || m.Provides[0]["name"] != "x11-display" || m.Provides[0]["reach"] != "machine" {
|
||||
t.Fatalf("provides: %v", m.Provides)
|
||||
}
|
||||
caps := strings.Join(m.Capabilities, ",")
|
||||
if caps != "package-manager,seat" {
|
||||
t.Fatalf("capabilities %s: seat is the hardware a display server needs; graphical-session is whether one runs now", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItInstallsTheServerItsStartAndTheToolsItsVerbsRun(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
var got []string
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
got = append(got, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd"
|
||||
if strings.Join(got, ",") != want {
|
||||
t.Fatalf("packages: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheResourcesFileHoldsTheDefaultsAndTheThreeSlots(t *testing.T) {
|
||||
r := readManifest(t).resource(t, "xresources")
|
||||
c := r["content"].(string)
|
||||
if r["path"] != "${machine:account-home}/.config/xorg/xresources" || r["into"] != nil {
|
||||
t.Fatalf("an owned file: %v", r)
|
||||
}
|
||||
first, normal, last := strings.Index(c, "${shell:xresources:first}"), strings.Index(c, "${shell:xresources:normal}"), strings.Index(c, "${shell:xresources:last}")
|
||||
if !(first > strings.Index(c, "Xft.dpi: 96") && first < normal && normal < last) {
|
||||
t.Fatal("the defaults, then first, normal and last")
|
||||
}
|
||||
for _, line := range strings.Split(c, "\n") {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
t.Errorf("merged without cpp, a # line is skipped: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionStartIsABlockAtTheStartInTheOrderTheDecisionGives(t *testing.T) {
|
||||
r := readManifest(t).resource(t, "xinitrc")
|
||||
if r["path"] != "${machine:account-home}/.xinitrc" || r["into"] != "block" || r["at"] != "start" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
c := r["content"].(string)
|
||||
order := []string{
|
||||
". \"${machine:account-home}/.config/mesh/environment.sh\"",
|
||||
"dbus-update-activation-environment --systemd $mesh_words",
|
||||
"xrdb -nocpp -merge \"${machine:account-home}/.config/xorg/xresources\"",
|
||||
"autorandr --change",
|
||||
"${shell:xinitrc:first}",
|
||||
"${shell:xinitrc:normal}",
|
||||
". \"$HOME/.xinitrc.local\"",
|
||||
"${shell:xinitrc:last}",
|
||||
}
|
||||
at := -1
|
||||
for _, step := range order {
|
||||
i := strings.Index(c, step)
|
||||
if i <= at {
|
||||
t.Fatalf("%q is not after the step before it", step)
|
||||
}
|
||||
at = i
|
||||
if strings.Count(c, step) != 1 {
|
||||
t.Fatalf("%q appears more than once", step)
|
||||
}
|
||||
}
|
||||
var code []string
|
||||
for _, line := range strings.Split(c, "\n") {
|
||||
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
code = append(code, line)
|
||||
}
|
||||
}
|
||||
lines := strings.Join(code, "\n")
|
||||
if strings.Contains(lines, "--all") || strings.Contains(lines, "exec ") || strings.Contains(lines, "dbus-launch") {
|
||||
t.Fatal("never --all, no exec of the block's own (the session's is contributed), no second bus")
|
||||
}
|
||||
if strings.Contains(c, "hal/env") || strings.Contains(c, "gnome-keyring") {
|
||||
t.Fatal("the predecessor's secrets file and the keyring are not the display server's")
|
||||
}
|
||||
}
|
||||
|
||||
// render fills the block's placeholders as the controller would, with one contribution per slot.
|
||||
func render(c, home string, slots map[string]string) string {
|
||||
c = strings.ReplaceAll(c, "${machine:account-home}", home)
|
||||
for _, slot := range []string{"first", "normal", "last"} {
|
||||
code := ""
|
||||
if s, ok := slots[slot]; ok {
|
||||
code = "# a-module\n" + s + "\n"
|
||||
}
|
||||
c = strings.ReplaceAll(c, "${shell:xinitrc:"+slot+"}", code)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestTheBlockRunsTheEnvironmentTheImportTheResourcesTheSlotsAndEndsInTheSession(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("no sh")
|
||||
}
|
||||
home := t.TempDir()
|
||||
bin := filepath.Join(home, "bin")
|
||||
log := filepath.Join(home, "log")
|
||||
os.MkdirAll(filepath.Join(home, ".config", "mesh"), 0o755)
|
||||
os.MkdirAll(bin, 0o755)
|
||||
os.WriteFile(filepath.Join(home, ".config", "mesh", "environment.sh"),
|
||||
[]byte("export XDG_CURRENT_DESKTOP=i3\nexport GTK_THEME=Adwaita:dark\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(home, ".xinitrc.local"), []byte("echo local >> \""+log+"\"\n"), 0o644)
|
||||
for _, prog := range []string{"dbus-update-activation-environment", "xrdb", "autorandr", "the-session"} {
|
||||
os.WriteFile(filepath.Join(bin, prog), []byte("#!/bin/sh\necho \""+prog+" $*\" >> \""+log+"\"\n"), 0o755)
|
||||
}
|
||||
block := render(readManifest(t).resource(t, "xinitrc")["content"].(string), home, map[string]string{
|
||||
"normal": "echo normal >> \"" + log + "\"",
|
||||
"last": "exec the-session",
|
||||
})
|
||||
file := "#!/bin/sh\n# BEGIN mesh xorg.xinitrc\n" + block + "# END mesh xorg.xinitrc\n" +
|
||||
"export SECRET_TOKEN=x\necho operator-below >> \"" + log + "\"\nexec i3\n"
|
||||
os.WriteFile(filepath.Join(home, ".xinitrc"), []byte(file), 0o755)
|
||||
os.WriteFile(filepath.Join(home, ".Xresources"), []byte("XTerm*saveLines: 1\n"), 0o644)
|
||||
cmd := exec.Command(sh, filepath.Join(home, ".xinitrc"))
|
||||
cmd.Env = []string{"HOME=" + home, "PATH=" + bin + ":/usr/bin:/bin", "DISPLAY=:7", "XAUTHORITY=" + home + "/.Xauthority",
|
||||
"XDG_SESSION_TYPE=x11", "DBUS_SESSION_BUS_ADDRESS=unix:path=/nowhere", "SECRET_KEY=y"}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
raw, _ := os.ReadFile(log)
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
want := []string{
|
||||
"dbus-update-activation-environment --systemd DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP GTK_THEME",
|
||||
"xrdb -nocpp -merge " + home + "/.config/xorg/xresources",
|
||||
"xrdb -merge " + home + "/.Xresources",
|
||||
"autorandr --change",
|
||||
"normal",
|
||||
"local",
|
||||
"the-session",
|
||||
}
|
||||
if strings.Join(lines, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("ran:\n%s\nwant:\n%s", strings.Join(lines, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBundleIsGoBuiltForArch(t *testing.T) {
|
||||
a := readManifest(t).Build.Artifacts
|
||||
if len(a) != 1 || a[0]["language"] != "go" || a[0]["system"] != "arch" || a[0]["from"] != "cmd/xorg" || a[0]["binary"] != "xorg" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
rules: evdev
|
||||
model: pc105
|
||||
layout: us
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
⎡ Virtual core pointer id=2 [master pointer (3)]
|
||||
⎜ ↳ Virtual core XTEST pointer id=4 [slave pointer (2)]
|
||||
⎜ ↳ Logitech MX Master 3 id=7 [slave pointer (2)]
|
||||
⎜ ↳ Logitech MX Master 3 id=8 [slave pointer (2)]
|
||||
⎜ ↳ Logitech MX Keys id=9 [slave pointer (2)]
|
||||
⎜ ↳ Razer Razer Basilisk V3 Pro id=10 [slave pointer (2)]
|
||||
⎜ ↳ Razer Razer Basilisk V3 Pro id=11 [slave pointer (2)]
|
||||
⎜ ↳ ITE Tech. Inc. ITE Device(8910) id=13 [slave pointer (2)]
|
||||
⎜ ↳ ASUP1208:00 093A:3011 Mouse id=15 [slave pointer (2)]
|
||||
⎜ ↳ ASUP1208:00 093A:3011 Touchpad id=16 [slave pointer (2)]
|
||||
⎜ ↳ ydotoold virtual device id=20 [slave pointer (2)]
|
||||
⎣ Virtual core keyboard id=3 [master keyboard (2)]
|
||||
↳ Virtual core XTEST keyboard id=5 [slave keyboard (3)]
|
||||
↳ Video Bus id=6 [slave keyboard (3)]
|
||||
↳ Razer Razer Basilisk V3 Pro id=12 [slave keyboard (3)]
|
||||
↳ Video Bus id=14 [slave keyboard (3)]
|
||||
↳ Power Button id=17 [slave keyboard (3)]
|
||||
↳ Sleep Button id=18 [slave keyboard (3)]
|
||||
↳ Asus WMI hotkeys id=19 [slave keyboard (3)]
|
||||
↳ Logitech MX Master 3 id=21 [slave keyboard (3)]
|
||||
↳ Logitech MX Master 3 id=22 [slave keyboard (3)]
|
||||
↳ Logitech MX Keys id=23 [slave keyboard (3)]
|
||||
↳ Razer Razer Basilisk V3 Pro id=24 [slave keyboard (3)]
|
||||
↳ ITE Tech. Inc. ITE Device(8910) id=25 [slave keyboard (3)]
|
||||
↳ ydotoold virtual device id=26 [slave keyboard (3)]
|
||||
@@ -0,0 +1,48 @@
|
||||
Device 'ASUP1208:00 093A:3011 Touchpad':
|
||||
Device Enabled (187): 1
|
||||
Coordinate Transformation Matrix (189): 1.000000, 0.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000, 0.000000, 1.000000
|
||||
libinput Tapping Enabled (347): 1
|
||||
libinput Tapping Enabled Default (348): 0
|
||||
libinput Tapping Drag Enabled (349): 1
|
||||
libinput Tapping Drag Enabled Default (350): 1
|
||||
libinput Tapping Drag Lock Enabled (351): 0
|
||||
libinput Tapping Drag Lock Enabled Default (352): 0
|
||||
libinput Tapping Button Mapping Enabled (353): 1, 0
|
||||
libinput Tapping Button Mapping Default (354): 1, 0
|
||||
libinput Natural Scrolling Enabled (318): 1
|
||||
libinput Natural Scrolling Enabled Default (319): 0
|
||||
libinput Disable While Typing Enabled (355): 1
|
||||
libinput Disable While Typing Enabled Default (356): 1
|
||||
libinput Scroll Methods Available (320): 1, 1, 0
|
||||
libinput Scroll Method Enabled (321): 1, 0, 0
|
||||
libinput Scroll Method Enabled Default (322): 1, 0, 0
|
||||
libinput Click Methods Available (357): 1, 1
|
||||
libinput Click Method Enabled (358): 1, 0
|
||||
libinput Click Method Enabled Default (359): 1, 0
|
||||
libinput Clickfinger Button Mapping Enabled (360): 1, 0
|
||||
libinput Clickfinger Button Mapping Default (361): 1, 0
|
||||
libinput Middle Emulation Enabled (327): 0
|
||||
libinput Middle Emulation Enabled Default (328): 0
|
||||
libinput Accel Speed (329): 0.150000
|
||||
libinput Accel Speed Default (330): 0.000000
|
||||
libinput Accel Profiles Available (331): 1, 1, 1
|
||||
libinput Accel Profile Enabled (332): 1, 0, 0
|
||||
libinput Accel Profile Enabled Default (333): 1, 0, 0
|
||||
libinput Accel Custom Fallback Points (334): <no items>
|
||||
libinput Accel Custom Fallback Step (335): 0.000000
|
||||
libinput Accel Custom Motion Points (336): <no items>
|
||||
libinput Accel Custom Motion Step (337): 0.000000
|
||||
libinput Accel Custom Scroll Points (338): <no items>
|
||||
libinput Accel Custom Scroll Step (339): 0.000000
|
||||
libinput Left Handed Enabled (340): 0
|
||||
libinput Left Handed Enabled Default (341): 0
|
||||
libinput Send Events Modes Available (303): 1, 1
|
||||
libinput Send Events Mode Enabled (304): 0, 0
|
||||
libinput Send Events Mode Enabled Default (305): 0, 0
|
||||
Device Node (306): "/dev/input/event20"
|
||||
Device Product ID (307): 2362, 12305
|
||||
libinput Drag Lock Buttons (342): <no items>
|
||||
libinput Horizontal Scroll Enabled (343): 1
|
||||
libinput Scrolling Pixel Distance (344): 15
|
||||
libinput Scrolling Pixel Distance Default (345): 15
|
||||
libinput High Resolution Wheel Scroll Enabled (346): 1
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
Screen 0: minimum 320 x 200, current 2880 x 1800, maximum 16384 x 16384
|
||||
eDP-1 connected primary 2880x1800+0+0 (normal left inverted right x axis y axis) 302mm x 189mm
|
||||
EDID:
|
||||
00ffffffffffff004c839c4100000000
|
||||
00210104b51e137803cfd1ae513eb623
|
||||
0b505400000001010101010101010101
|
||||
010101010101cbfe4064b00838772008
|
||||
88002ebd1000001b000000fd003078da
|
||||
da420100000000000000000000fe0053
|
||||
44430a202020202020202020000000fc
|
||||
0041544e413430435530352d30200127
|
||||
702079020020000c4c83009c41000000
|
||||
00002100260009040000000000500000
|
||||
220014e7f309853f0b63001f00070007
|
||||
071700070007002b000c270030770000
|
||||
2700303b000081001f731a0000030330
|
||||
7800a07402600278000000008de30580
|
||||
00e60605017460022e00061810405ed0
|
||||
6000000000000000000000000000fc90
|
||||
panel_type: OLED
|
||||
supported: unknown, OLED
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 1
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 383
|
||||
supported: 383
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
2880x1800 60.00*+ 120.00 +
|
||||
2880x1620 59.96 59.97
|
||||
2560x1600 59.99 59.97
|
||||
2560x1440 59.99 59.99 59.96 59.95
|
||||
2048x1536 85.00 75.00 60.00
|
||||
1920x1440 85.00 75.00 60.00
|
||||
1856x1392 75.00 60.01
|
||||
1792x1344 75.00 60.01
|
||||
2048x1152 59.99 59.98 59.90 59.91
|
||||
1920x1200 120.00 59.88 59.95
|
||||
1920x1080 120.00 60.01 59.97 59.96 59.93
|
||||
1600x1200 120.00 85.00 75.00 70.00 65.00 60.00
|
||||
1680x1050 120.00 59.95 59.88
|
||||
1400x1050 74.76 59.98
|
||||
1600x900 59.99 59.94 59.95 59.82
|
||||
1280x1024 120.00 85.02 75.02 60.02
|
||||
1440x900 120.00
|
||||
1400x900 59.96 59.88
|
||||
1280x960 85.00 60.00
|
||||
1440x810 60.00 59.97
|
||||
1368x768 59.88 59.85
|
||||
1280x800 120.00 59.99 59.97 59.81 59.91
|
||||
1152x864 75.00
|
||||
1280x720 120.00 60.00 59.99 59.86 59.74
|
||||
1024x768 120.00 85.00 75.05 60.04 85.00 75.03 70.07 60.00
|
||||
1024x768i 86.96
|
||||
960x720 85.00 75.00 60.00
|
||||
928x696 75.00 60.05
|
||||
896x672 75.05 60.01
|
||||
1024x576 59.95 59.96 59.90 59.82
|
||||
960x600 59.93 60.00
|
||||
832x624 74.55
|
||||
960x540 59.96 59.99 59.63 59.82
|
||||
800x600 120.00 85.00 75.00 70.00 65.00 60.00 85.14 72.19 75.00 60.32 56.25
|
||||
840x525 60.01 59.88
|
||||
864x486 59.92 59.57
|
||||
700x525 74.76 59.98
|
||||
800x450 59.95 59.82
|
||||
640x512 85.02 75.02 60.02
|
||||
700x450 59.96 59.88
|
||||
640x480 120.00 85.09 60.00 85.01 72.81 75.00 59.94
|
||||
720x405 59.51 58.99
|
||||
720x400 85.04
|
||||
684x384 59.88 59.85
|
||||
640x400 59.88 59.98 85.08
|
||||
576x432 75.00
|
||||
640x360 59.86 59.83 59.84 59.32
|
||||
640x350 85.08
|
||||
512x384 85.00 75.03 70.07 60.00
|
||||
512x384i 87.06
|
||||
512x288 60.00 59.92
|
||||
416x312 74.66
|
||||
480x270 59.63 59.82
|
||||
400x300 85.27 72.19 75.12 60.32 56.34
|
||||
432x243 59.92 59.57
|
||||
320x240 85.18 72.81 75.00 60.05
|
||||
360x202 59.51 59.13
|
||||
360x200 85.04
|
||||
320x200 85.27
|
||||
320x180 59.84 59.32
|
||||
320x175 85.27
|
||||
DP-1 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 391
|
||||
supported: 391
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-2 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 398
|
||||
supported: 398
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-3 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 405
|
||||
supported: 405
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-4 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 411
|
||||
supported: 411
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-5 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 417
|
||||
supported: 417
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-6 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 423
|
||||
supported: 423
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-7 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 429
|
||||
supported: 429
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-8 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 435
|
||||
supported: 435
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-1-0 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: DisplayPort
|
||||
supported: DisplayPort
|
||||
ConnectorType: DisplayPort
|
||||
ConnectorNumber: 0
|
||||
_ConnectorLocation: 0
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
DP-1-1 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: TMDS
|
||||
supported: TMDS
|
||||
ConnectorType: DisplayPort
|
||||
ConnectorNumber: 0
|
||||
_ConnectorLocation: 0
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
HDMI-1-0 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: TMDS
|
||||
supported: TMDS
|
||||
ConnectorType: HDMI
|
||||
ConnectorNumber: 2
|
||||
_ConnectorLocation: 2
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
DP-1-2 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: DisplayPort
|
||||
supported: DisplayPort
|
||||
ConnectorType: Panel
|
||||
ConnectorNumber: 3
|
||||
_ConnectorLocation: 3
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
@@ -0,0 +1,602 @@
|
||||
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, ", "))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LogLine is one marked line of the X server's log.
|
||||
type LogLine struct {
|
||||
At float64 `json:"at_seconds"`
|
||||
Level string `json:"level"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// XLog is what x-log answers.
|
||||
type XLog struct {
|
||||
File string `json:"file"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Started string `json:"started,omitempty"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
Lines []LogLine `json:"lines"`
|
||||
Cut bool `json:"cut,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
logMark = regexp.MustCompile(`^\[\s*([0-9.]+)\] \((EE|WW)\) (.*)$`)
|
||||
logStart = regexp.MustCompile(`\(==\) Log file: "[^"]*", Time: (.+)$`)
|
||||
)
|
||||
|
||||
// ParseXLog reads an Xorg log: the server's version, when it started, and its errors and (when
|
||||
// asked) warnings, the last `limit` of them. The legend line that explains the markers is not one.
|
||||
func ParseXLog(file, text string, warnings bool, limit int) XLog {
|
||||
out := XLog{File: file, Lines: []LogLine{}}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<22)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if out.Server == "" && strings.HasPrefix(strings.TrimSpace(line), "X.Org X Server ") {
|
||||
out.Server = strings.TrimSpace(line)
|
||||
}
|
||||
if m := logStart.FindStringSubmatch(line); m != nil && out.Started == "" {
|
||||
out.Started = m[1]
|
||||
}
|
||||
m := logMark.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
at, _ := strconv.ParseFloat(m[1], 64)
|
||||
level := map[string]string{"EE": "error", "WW": "warning"}[m[2]]
|
||||
if level == "error" {
|
||||
out.Errors++
|
||||
} else {
|
||||
out.Warnings++
|
||||
if !warnings {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.Lines = append(out.Lines, LogLine{At: at, Level: level, Text: m[3]})
|
||||
}
|
||||
if limit > 0 && len(out.Lines) > limit {
|
||||
out.Lines = out.Lines[len(out.Lines)-limit:]
|
||||
out.Cut = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// XLogFiles are where an X server on display n logs, best first: a root server's log, then a
|
||||
// rootless one's under the account's data directory. With no display, every log found, newest first.
|
||||
func XLogFiles(display int, home string) []string {
|
||||
dirs := []string{"/var/log", filepath.Join(home, ".local/share/xorg")}
|
||||
if display >= 0 {
|
||||
var out []string
|
||||
for _, d := range dirs {
|
||||
out = append(out, filepath.Join(d, "Xorg."+strconv.Itoa(display)+".log"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
type found struct {
|
||||
path string
|
||||
mod int64
|
||||
}
|
||||
var all []found
|
||||
for _, d := range dirs {
|
||||
matches, _ := filepath.Glob(filepath.Join(d, "Xorg.*.log"))
|
||||
for _, p := range matches {
|
||||
if info, err := os.Stat(p); err == nil {
|
||||
all = append(all, found{p, info.ModTime().UnixNano()})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].mod > all[j].mod })
|
||||
var out []string
|
||||
for _, f := range all {
|
||||
out = append(out, f.path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T, name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// fakeDesk answers commands from a table keyed by the command line, and records what ran.
|
||||
type fakeDesk struct {
|
||||
answers map[string]desktop.Result
|
||||
ran []string
|
||||
env [][]string
|
||||
}
|
||||
|
||||
func (f *fakeDesk) desk(session bool) desktop.Desk {
|
||||
return desktop.Desk{
|
||||
Find: func() (*desktop.Session, error) {
|
||||
if !session {
|
||||
return nil, &desktop.NoSession{Reason: "no process of uid 1000 carries a display"}
|
||||
}
|
||||
return &desktop.Session{UID: 1000, Display: ":1", Type: "x11", XAuthority: "/home/op/.Xauthority", RuntimeDir: "/run/user/1000"}, nil
|
||||
},
|
||||
Run: func(_ context.Context, env []string, _ []byte, name string, args ...string) desktop.Result {
|
||||
line := strings.Join(append([]string{name}, args...), " ")
|
||||
f.ran = append(f.ran, line)
|
||||
f.env = append(f.env, env)
|
||||
if r, ok := f.answers[line]; ok {
|
||||
r.Command = append([]string{name}, args...)
|
||||
return r
|
||||
}
|
||||
return desktop.Result{Command: append([]string{name}, args...), Code: 127, Stderr: "not in the table: " + line}
|
||||
},
|
||||
Base: []string{"PATH=/usr/bin"},
|
||||
}
|
||||
}
|
||||
|
||||
func byName(t *testing.T, d desktop.Desk, name string) func(map[string]any) (any, error) {
|
||||
for _, tool := range tools(d) {
|
||||
if tool.Name == name {
|
||||
return tool.Run
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTheOutputsAndTheMonitorsIdentityAreReadFromXrandr(t *testing.T) {
|
||||
screen, outs := ParseXrandr(fixture(t, "xrandr-prop.txt"))
|
||||
if screen.Width != 2880 || screen.Height != 1800 || screen.MaxWidth != 16384 {
|
||||
t.Fatalf("screen: %+v", screen)
|
||||
}
|
||||
if len(outs) != 13 {
|
||||
t.Fatalf("%d outputs", len(outs))
|
||||
}
|
||||
e := outs[0]
|
||||
if e.Name != "eDP-1" || !e.Connected || !e.Primary || !e.Enabled || e.Rotation != "normal" || e.WidthMM != 302 {
|
||||
t.Fatalf("eDP-1: %+v", e)
|
||||
}
|
||||
if *e.Geometry != (Geometry{2880, 1800, 0, 0}) || e.DPI != 242.2 {
|
||||
t.Fatalf("geometry and DPI: %+v %v", e.Geometry, e.DPI)
|
||||
}
|
||||
if e.Current == nil || e.Current.Rate != 60 || e.Preferred == nil || e.Preferred.Size != "2880x1800" || e.Preferred.Rate != 60 {
|
||||
t.Fatalf("current %+v preferred %+v", e.Current, e.Preferred)
|
||||
}
|
||||
if e.Monitor == nil || e.Monitor.Manufacturer != "SDC" || e.Monitor.Product != "419C" || e.Monitor.Name != "ATNA40CU05-0" || len(e.Monitor.Fingerprint) != 12 {
|
||||
t.Fatalf("identity: %+v", e.Monitor)
|
||||
}
|
||||
if len(e.Modes) < 20 || e.Modes[0].Size != "2880x1800" || len(e.Modes[0].Rates) != 2 {
|
||||
t.Fatalf("modes: %+v", e.Modes[:2])
|
||||
}
|
||||
if outs[1].Connected || outs[1].Enabled || outs[1].Name != "DP-1" {
|
||||
t.Fatalf("a disconnected output: %+v", outs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestARotatedSecondMonitorIsReadWithItsPlaceAndDensity(t *testing.T) {
|
||||
text := "Screen 0: minimum 8 x 8, current 4000 x 2560, maximum 32767 x 32767\n" +
|
||||
"DP-2 connected 1440x2560+2560+0 left (normal left inverted right x axis y axis) 597mm x 336mm\n" +
|
||||
" 2560x1440 59.95 + 74.97*\n"
|
||||
_, outs := ParseXrandr(text)
|
||||
o := outs[0]
|
||||
if o.Rotation != "left" || o.Geometry.X != 2560 || o.Current.Rate != 74.97 || o.Preferred.Rate != 59.95 {
|
||||
t.Fatalf("%+v %+v %+v", o, o.Current, o.Preferred)
|
||||
}
|
||||
if o.DPI < 108 || o.DPI > 110 {
|
||||
t.Fatalf("a rotated output's density is its long side over its long side: %v", o.DPI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetModeBuildsOnlyCommandsXrandrTakes(t *testing.T) {
|
||||
args, err := ModeArgs(desktop.Args{"output": "DP-2", "mode": "2560x1440", "rate": 144.0, "rotate": "left", "scale": 1.5, "relation": "right-of", "of": "eDP-1", "primary": true})
|
||||
want := "--output DP-2 --mode 2560x1440 --rate 144.00 --rotate left --scale 1.5x1.5 --right-of eDP-1 --primary"
|
||||
if err != nil || strings.Join(args, " ") != want {
|
||||
t.Fatalf("%v %v", args, err)
|
||||
}
|
||||
if args, _ := ModeArgs(desktop.Args{"output": "HDMI-1", "off": true, "mode": "1x1"}); strings.Join(args, " ") != "--output HDMI-1 --off" {
|
||||
t.Fatalf("off wins: %v", args)
|
||||
}
|
||||
if args, _ := ModeArgs(desktop.Args{"output": "DP-1", "x": 1920.0, "y": 0.0}); strings.Join(args, " ") != "--output DP-1 --pos 1920x0" {
|
||||
t.Fatalf("position: %v", args)
|
||||
}
|
||||
for _, bad := range []desktop.Args{
|
||||
{"output": "DP-1; rm -rf ~"}, {"output": "DP-1"}, {"output": "DP-1", "mode": "big"},
|
||||
{"output": "DP-1", "scale": 9.0}, {"output": "DP-1", "relation": "right-of"}, {"output": "DP-1", "rotate": "sideways"},
|
||||
} {
|
||||
if _, err := ModeArgs(bad); err == nil {
|
||||
t.Errorf("accepted %v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunRunsNothing(t *testing.T) {
|
||||
f := &fakeDesk{}
|
||||
got, err := byName(t, f.desk(true), "xorg_set_mode")(map[string]any{"output": "eDP-1", "mode": "auto", "dry_run": true})
|
||||
if err != nil || len(f.ran) != 0 || !strings.Contains(asJSON(got), `"--auto"`) {
|
||||
t.Fatalf("%v %v %v", got, err, f.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputDevicesAndTheirLibinputSettingsAreRead(t *testing.T) {
|
||||
devs := ParseDevices(fixture(t, "xinput-list.txt"))
|
||||
if len(devs) != 25 {
|
||||
t.Fatalf("%d devices", len(devs))
|
||||
}
|
||||
var pad *Device
|
||||
for i := range devs {
|
||||
if devs[i].ID == 16 {
|
||||
pad = &devs[i]
|
||||
}
|
||||
}
|
||||
if pad == nil || pad.Name != "ASUP1208:00 093A:3011 Touchpad" || pad.Role != "slave" || pad.Kind != "pointer" {
|
||||
t.Fatalf("%+v", pad)
|
||||
}
|
||||
if devs[0].Name != "Virtual core pointer" || devs[0].Role != "master" {
|
||||
t.Fatalf("the tree's drawing is stripped: %+v", devs[0])
|
||||
}
|
||||
s := ParseProps(fixture(t, "xinput-props-16.txt"))
|
||||
if s["tap"] != true || s["natural_scroll"] != true || s["accel_speed"] != 0.15 || s["accel_profile"] != "adaptive" || s["left_handed"] != false {
|
||||
t.Fatalf("%v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputSetChangesOnlyWhatADeviceHasAndSaysHowLongItLasts(t *testing.T) {
|
||||
props := fixture(t, "xinput-props-16.txt")
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"xinput list": {Stdout: fixture(t, "xinput-list.txt")},
|
||||
"xinput list-props 16": {Stdout: props},
|
||||
"xinput list-props 15": {Stdout: "Device 'Mouse':\n\tDevice Enabled (187):\t1\n"},
|
||||
"xinput set-prop 16 libinput Tapping Enabled 0": {},
|
||||
}}
|
||||
got, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16", "tap": false})
|
||||
if err != nil || !strings.Contains(asJSON(got), `"set":["tap"]`) || !strings.Contains(asJSON(got), "lasts") {
|
||||
t.Fatalf("%s %v", asJSON(got), err)
|
||||
}
|
||||
if _, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16"}); err == nil {
|
||||
t.Fatal("nothing to change is refused")
|
||||
}
|
||||
if _, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16", "accel_speed": 3.0}); err == nil {
|
||||
t.Fatal("accel_speed out of range")
|
||||
}
|
||||
got, err = byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "15", "tap": true})
|
||||
if err != nil || !strings.Contains(asJSON(got), `"skipped":["tap"]`) {
|
||||
t.Fatalf("a mouse has no tapping: %s %v", asJSON(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeyboardMapIsReadAndSetOnlyWithXKBNames(t *testing.T) {
|
||||
k := ParseKeyboard(fixture(t, "setxkbmap.txt") + "options: caps:escape,compose:ralt\n")
|
||||
if k.Layout != "us" || k.Model != "pc105" || len(k.Options) != 2 {
|
||||
t.Fatalf("%+v", k)
|
||||
}
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"setxkbmap -layout us,be -option -option caps:escape": {},
|
||||
"setxkbmap -query": {Stdout: fixture(t, "setxkbmap.txt")},
|
||||
}}
|
||||
if _, err := byName(t, f.desk(true), "xorg_keyboard")(map[string]any{"layout": "us,be", "options": []any{"caps:escape"}}); err != nil {
|
||||
t.Fatal(err, f.ran)
|
||||
}
|
||||
if _, err := byName(t, f.desk(true), "xorg_keyboard")(map[string]any{"layout": "us; xterm"}); err == nil {
|
||||
t.Fatal("a layout that is not an XKB name")
|
||||
}
|
||||
}
|
||||
|
||||
// xwdOf writes a w×h dump of 32-bit pixels in the given byte order, coloured by colourAt.
|
||||
func xwdOf(w, h int, lsb bool, colourAt func(x, y int) (r, g, b uint8)) []byte {
|
||||
var buf bytes.Buffer
|
||||
name := []byte("xwdump\x00\x00")
|
||||
header := []uint32{uint32(100 + len(name)), 7, 2, 24, uint32(w), uint32(h), 0, 0, 32, 0, 32, 32, uint32(w * 4), 4,
|
||||
0xff0000, 0x00ff00, 0x0000ff, 8, 256, 0, uint32(w), uint32(h), 0, 0, 0}
|
||||
if lsb {
|
||||
header[7] = 0
|
||||
} else {
|
||||
header[7] = 1
|
||||
}
|
||||
for _, v := range header {
|
||||
binary.Write(&buf, binary.BigEndian, v)
|
||||
}
|
||||
buf.Write(name)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
r, g, b := colourAt(x, y)
|
||||
p := uint32(r)<<16 | uint32(g)<<8 | uint32(b)
|
||||
if lsb {
|
||||
binary.Write(&buf, binary.LittleEndian, p)
|
||||
} else {
|
||||
binary.Write(&buf, binary.BigEndian, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestAWindowDumpBecomesThePictureItHolds(t *testing.T) {
|
||||
paint := func(x, y int) (uint8, uint8, uint8) { return uint8(x * 40), uint8(y * 60), 0xde }
|
||||
for _, lsb := range []bool{true, false} {
|
||||
img, err := DecodeXWD(xwdOf(5, 3, lsb, paint))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() != 5 || img.Bounds().Dy() != 3 {
|
||||
t.Fatalf("bounds %v", img.Bounds())
|
||||
}
|
||||
if got := img.RGBAAt(4, 2); got != (color.RGBA{160, 120, 0xde, 0xff}) {
|
||||
t.Fatalf("lsb=%v: %v", lsb, got)
|
||||
}
|
||||
}
|
||||
dump := xwdOf(2, 2, true, paint)
|
||||
if _, err := DecodeXWD(dump[:len(dump)-3]); err == nil {
|
||||
t.Fatal("a cut dump is refused")
|
||||
}
|
||||
binary.BigEndian.PutUint32(dump[4:], 6)
|
||||
if _, err := DecodeXWD(dump); err == nil {
|
||||
t.Fatal("another file version is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheServersLogAnswersItsErrorsAndNotTheLegend(t *testing.T) {
|
||||
text := "[ 16.118] \nX.Org X Server 1.21.1.24\n[ 16.118] Markers: (--) probed,\n" +
|
||||
"[ 16.118] \t(WW) warning, (EE) error, (NI) not implemented, (??) unknown.\n" +
|
||||
"[ 16.118] (==) Log file: \"/var/log/Xorg.1.log\", Time: Fri Oct 2 13:08:45 2026\n" +
|
||||
"[ 16.123] (WW) The directory \"/usr/share/fonts/misc\" does not exist.\n" +
|
||||
"[ 16.181] (EE) Failed to load module \"ati\" (module does not exist, 0)\n" +
|
||||
"[ 16.184] (EE) Failed to load module \"nouveau\" (module does not exist, 0)\n"
|
||||
got := ParseXLog("/var/log/Xorg.1.log", text, false, 1)
|
||||
if got.Server != "X.Org X Server 1.21.1.24" || got.Started != "Fri Oct 2 13:08:45 2026" || got.Errors != 2 || got.Warnings != 1 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if len(got.Lines) != 1 || !got.Cut || !strings.Contains(got.Lines[0].Text, "nouveau") || got.Lines[0].At != 16.184 {
|
||||
t.Fatalf("the newest error, cut to the limit: %+v", got.Lines)
|
||||
}
|
||||
if all := ParseXLog("", text, true, 0); len(all.Lines) != 3 {
|
||||
t.Fatalf("with warnings: %+v", all.Lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilesAreReadWithTheMonitorsTheyAreFor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, outs := ParseXrandr(fixture(t, "xrandr-prop.txt"))
|
||||
os.MkdirAll(filepath.Join(dir, "laptop"), 0o755)
|
||||
os.WriteFile(filepath.Join(dir, "laptop", "setup"), []byte("eDP-1 "+hex.EncodeToString(outs[0].edid)+"\n"), 0o644)
|
||||
os.MkdirAll(filepath.Join(dir, "empty"), 0o755) // no setup: not a profile
|
||||
got := ReadProfiles([]string{dir, filepath.Join(dir, "missing")})
|
||||
if len(got) != 1 || got[0].Name != "laptop" || got[0].Monitors["eDP-1"] != outs[0].Monitor.Fingerprint {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if !profileName.MatchString("home-office_2") || profileName.MatchString("../x") || profileName.MatchString("") {
|
||||
t.Fatal("profile names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolThatNeedsTheScreenSaysPlainlyThatThereIsNoSession(t *testing.T) {
|
||||
f := &fakeDesk{}
|
||||
d := f.desk(false)
|
||||
for _, call := range []struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}{
|
||||
{"node-display-server.displays", nil},
|
||||
{"node-display-server.layout", map[string]any{"action": "apply", "name": "home"}},
|
||||
{"xorg_set_mode", map[string]any{"output": "eDP-1", "mode": "auto"}},
|
||||
{"xorg_primary", nil}, {"xorg_dpi", nil}, {"xorg_input_devices", nil},
|
||||
{"xorg_input_set", map[string]any{"device": "16", "tap": true}}, {"xorg_keyboard", nil},
|
||||
{"xorg_screenshot", nil},
|
||||
} {
|
||||
_, err := byName(t, d, call.tool)(orEmpty(call.args))
|
||||
if !desktop.IsNoSession(err) {
|
||||
t.Errorf("%s: %v", call.tool, err)
|
||||
}
|
||||
}
|
||||
got, err := byName(t, d, "node-display-server.layout")(map[string]any{"action": "list"})
|
||||
if err != nil || !strings.Contains(asJSON(got), "no graphical session") {
|
||||
t.Fatalf("listing profiles needs no session, and says what it could not tell: %v %v", got, err)
|
||||
}
|
||||
if len(f.ran) != 0 {
|
||||
t.Fatalf("nothing ran without a session: %v", f.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandRunsWithTheSessionsDisplay(t *testing.T) {
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{"xrandr --prop": {Stdout: fixture(t, "xrandr-prop.txt")}}}
|
||||
if _, err := byName(t, f.desk(true), "node-display-server.displays")(map[string]any{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := strings.Join(f.env[0], " ")
|
||||
if !strings.Contains(env, "DISPLAY=:1") || !strings.Contains(env, "XAUTHORITY=/home/op/.Xauthority") {
|
||||
t.Fatalf("%s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func orEmpty(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
// DecodeXWD reads an X window dump as `xwd` writes it (XWDFile.h, file version 7): the screenshot
|
||||
// tool's one dependency is xwd, from the X apps the display server already ships, and the PNG is made
|
||||
// here — no screenshot program, no image library.
|
||||
//
|
||||
// Only what an X server dumps in practice is read: a ZPixmap of 24 or 32 bits per pixel with a
|
||||
// TrueColor or DirectColor visual, in either byte order. Anything else is refused by name.
|
||||
func DecodeXWD(b []byte) (*image.RGBA, error) {
|
||||
const headerWords = 25
|
||||
if len(b) < headerWords*4 {
|
||||
return nil, errors.New("too short for an xwd header")
|
||||
}
|
||||
h := make([]uint32, headerWords)
|
||||
for i := range h {
|
||||
h[i] = binary.BigEndian.Uint32(b[i*4:])
|
||||
}
|
||||
headerSize, version, format := h[0], h[1], h[2]
|
||||
width, height := int(h[4]), int(h[5])
|
||||
byteOrder, bpp, bytesPerLine, class := h[7], int(h[11]), int(h[12]), h[13]
|
||||
red, green, blue := h[14], h[15], h[16]
|
||||
ncolors := int(h[19])
|
||||
switch {
|
||||
case version != 7:
|
||||
return nil, fmt.Errorf("xwd file version %d; 7 is read", version)
|
||||
case format != 2:
|
||||
return nil, fmt.Errorf("pixmap format %d; ZPixmap (2) is read", format)
|
||||
case bpp != 24 && bpp != 32:
|
||||
return nil, fmt.Errorf("%d bits per pixel; 24 and 32 are read", bpp)
|
||||
case class != 4 && class != 5:
|
||||
return nil, fmt.Errorf("visual class %d; TrueColor and DirectColor are read", class)
|
||||
case width <= 0 || height <= 0 || width > 32768 || height > 32768:
|
||||
return nil, fmt.Errorf("a %dx%d image", width, height)
|
||||
case bytesPerLine < width*bpp/8:
|
||||
return nil, fmt.Errorf("%d bytes per line for %d pixels", bytesPerLine, width)
|
||||
}
|
||||
start := int(headerSize) + ncolors*12
|
||||
if start < 0 || len(b) < start+bytesPerLine*height {
|
||||
return nil, fmt.Errorf("the image data is cut short: %d bytes, %d needed", len(b), start+bytesPerLine*height)
|
||||
}
|
||||
pixels := b[start:]
|
||||
step := bpp / 8
|
||||
channel := func(p, mask uint32) uint8 {
|
||||
if mask == 0 {
|
||||
return 0
|
||||
}
|
||||
shift := bits.TrailingZeros32(mask)
|
||||
width := bits.OnesCount32(mask)
|
||||
v := (p & mask) >> shift
|
||||
if width >= 8 {
|
||||
return uint8(v >> (width - 8))
|
||||
}
|
||||
return uint8(v << (8 - width))
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
row := pixels[y*bytesPerLine:]
|
||||
for x := 0; x < width; x++ {
|
||||
px := row[x*step : x*step+step]
|
||||
var p uint32
|
||||
if byteOrder == 0 { // LSBFirst
|
||||
for i := step - 1; i >= 0; i-- {
|
||||
p = p<<8 | uint32(px[i])
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < step; i++ {
|
||||
p = p<<8 | uint32(px[i])
|
||||
}
|
||||
}
|
||||
img.SetRGBA(x, y, color.RGBA{channel(p, red), channel(p, green), channel(p, blue), 0xff})
|
||||
}
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
Reference in New Issue
Block a user