xorg: list the server's windows, and cut a window's screenshot from the screen (hq issue 345)
mesh/merge-gate pass: builds xorg → g14, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds xorg → g14, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A reminder window that showed as transparent and empty could not be looked at: the window manager's list leaves dialogs out and says nothing a compositor goes by, and xwd -id refused a window of a 32-bit visual with BadColor (X_QueryColors). xorg_windows answers every window with its class, and on request its depth, type, state, role and own opacity; a window's screenshot is now cut from the whole screen as the compositor shows it, and inline answers the image for a session that cannot read the machine's files. Declares xorg-xwininfo, which both now use.
This commit is contained in:
+16
-3
@@ -9,7 +9,7 @@ The X display server as a module (novox/hq ADR 0208, research 026, to-be 42 phas
|
||||
- **Gated by the capability `seat`**, which the host reports for a machine with a graphics device
|
||||
and a connected display. See *Blockers* for why not `graphical-session`.
|
||||
- **Packages:** `xorg-server`, `xorg-xinit`, `xorg-xrandr`, `xorg-xset`, `xorg-xrdb`, `xorg-xinput`,
|
||||
`xorg-setxkbmap`, `xorg-xwd` and `autorandr`. The GPU's driver is not here. It follows the machine,
|
||||
`xorg-setxkbmap`, `xorg-xwd`, `xorg-xwininfo` and `autorandr`. The GPU's driver is not here. It follows the machine,
|
||||
so it belongs to that machine model's hardware module.
|
||||
|
||||
## What it owns
|
||||
@@ -71,7 +71,8 @@ which is a cpp directive error under plain `xrdb -merge` but is skipped here.
|
||||
| `xorg_input_devices` | r | the input devices, and each pointer's libinput settings |
|
||||
| `xorg_input_set` | a | tap, tap drag, natural scroll, disable while typing, left handed, middle emulation, enabled, acceleration, by device id or name |
|
||||
| `xorg_keyboard` | r/a | the XKB map; set layout, variant, model, options |
|
||||
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`) |
|
||||
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`); with `inline`, the PNG in the answer as base64 (at most 4 MiB) |
|
||||
| `xorg_windows` | r | the windows as the X server knows them, managed or not: id, name, instance and class, size and place; with a class or name filter (or `detail`), each one's depth (`argb`), visual, map state, override-redirect, types, states, role and own opacity |
|
||||
| `xorg_x_log` | r | the X server's log for the running session: version, start, errors (and warnings) |
|
||||
|
||||
All answers are structured. Each tool that changes the running server says how long the change lasts.
|
||||
@@ -90,7 +91,19 @@ The bus it hands on is the user manager's (`unix:path=/run/user/<uid>/bus`). Wit
|
||||
answers `{"error": "no-graphical-session", "reason": …, "looked": […]}`.
|
||||
|
||||
The screenshot needs no screenshot program. `xwd` dumps the screen, and the module turns the dump
|
||||
into a PNG itself.
|
||||
into a PNG itself. **A window is cut from the dump of the whole screen** at the place `xwininfo` gives,
|
||||
never dumped by its id: `xwd -id` asks the server for the window's colormap entries, which a window of
|
||||
a 32-bit (alpha) visual refused with `BadColor (X_QueryColors)` on a workstation (2026-10-09), and the
|
||||
cut shows the window as the compositor shows it, which is what a question about a window that looks
|
||||
wrong needs. A window under another one shows what covers it. `inline` answers the image itself, for a
|
||||
session that cannot read the machine's files.
|
||||
|
||||
`xorg_windows` exists because a window manager lists only the windows it manages, and says nothing a
|
||||
compositor goes by. A window of depth 32 has an alpha channel: the compositor blends it with what is
|
||||
behind it, so one whose program drew nothing looks transparent rather than black. Its details take
|
||||
two commands per window (`xwininfo -all`, and `xprop` for the role and the window's own opacity), so
|
||||
they are read for at most 40 windows. `xprop` is the `picom` module's package; without it the role and
|
||||
opacity are left out.
|
||||
|
||||
## What it improves
|
||||
|
||||
|
||||
@@ -143,16 +143,34 @@ func tools(d desktop.Desk) []stdio.Tool {
|
||||
},
|
||||
{
|
||||
Name: "xorg_screenshot",
|
||||
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id. " +
|
||||
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id " +
|
||||
"(cut from the screen as the compositor shows it, so a see-through window shows what is behind it). " +
|
||||
"Written under the account's home (default ~/Pictures/Screenshots/screenshot-<time>.png). Answers " +
|
||||
"the path, size and bytes, never the image.",
|
||||
"the path, size and bytes; with inline, also the PNG itself as base64 (at most 4 MiB).",
|
||||
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)"),
|
||||
"inline": desktop.Flag("also answer the PNG as base64, for a session that cannot read the file (default false)"),
|
||||
}),
|
||||
Run: call(x.screenshot),
|
||||
},
|
||||
{
|
||||
Name: "xorg_windows",
|
||||
Description: "The windows on the X screen as the server knows them, managed or not (dialogs, tooltips, " +
|
||||
"windows no window manager lists): id, name, instance and class, size and absolute place, level in " +
|
||||
"the tree. With class or name (regular expressions), or detail, also each window's depth (argb: a " +
|
||||
"32-bit visual a compositor blends with what is behind it), visual class, map state, override-redirect, " +
|
||||
"window types and states, role and own opacity — for at most 40 windows. Without a filter, only " +
|
||||
"windows with a class, unless all.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"class": desktop.Str("only windows whose class or instance matches this regular expression"),
|
||||
"name": desktop.Str("only windows whose name (title) matches this regular expression"),
|
||||
"detail": desktop.Flag("read each window's details (default false; true when class or name is given)"),
|
||||
"all": desktop.Flag("without a filter, also windows without a class (default false)"),
|
||||
}),
|
||||
Run: call(x.windows),
|
||||
},
|
||||
{
|
||||
Name: "xorg_x_log",
|
||||
Description: "The X server's log for the running session (or the newest one): its version, when it " +
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestItInstallsTheServerItsStartAndTheToolsItsVerbsRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd"
|
||||
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd,xorg-xwininfo"
|
||||
if strings.Join(got, ",") != want {
|
||||
t.Fatalf("packages: %v", got)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
@@ -494,7 +495,14 @@ func (x xorg) keyboard(ctx context.Context, a desktop.Args) (any, error) {
|
||||
|
||||
var windowID = regexp.MustCompile(`^(0x[0-9a-fA-F]{1,8}|[0-9]{1,10})$`)
|
||||
|
||||
// mostInline is the largest PNG a screenshot carries in its answer.
|
||||
const mostInline = 4 << 20
|
||||
|
||||
func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
inline, _, err := a.Bool("inline")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stamp := time.Now().Format("20060102-150405")
|
||||
path, err := desktop.InHome(a.Opt("path", "~/Pictures/Screenshots/screenshot-"+stamp+".png"))
|
||||
if err != nil {
|
||||
@@ -514,15 +522,22 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
}
|
||||
dump.Close()
|
||||
defer os.Remove(dump.Name())
|
||||
args := []string{"-silent", "-out", dump.Name()}
|
||||
// A window is cut from the whole screen, not dumped by its id: what the screen shows is what the
|
||||
// compositor made of it (a window with an alpha channel is blended with what is behind it), and xwd
|
||||
// asks the server for a window's colormap entries, which a window of a 32-bit visual can refuse
|
||||
// with BadColor (X_QueryColors). The cut is the window's frame-less area at its absolute place.
|
||||
var cut *image4
|
||||
if w := a.Opt("window", ""); w != "" {
|
||||
if !windowID.MatchString(w) {
|
||||
return nil, fmt.Errorf("window is an X window id, e.g. 0x3a00007")
|
||||
}
|
||||
args = append(args, "-id", w)
|
||||
} else {
|
||||
args = append(args, "-root")
|
||||
g, err := x.windowGeometry(ctx, env, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cut = &g
|
||||
}
|
||||
args := []string{"-silent", "-out", dump.Name(), "-root"}
|
||||
if r := x.d.Run(ctx, env, nil, "xwd", args...); !r.OK() {
|
||||
if r.Code == 127 {
|
||||
return nil, fmt.Errorf("xwd is not installed here; the xorg module's package resource (xorg-xwd) installs it")
|
||||
@@ -538,7 +553,15 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
return nil, fmt.Errorf("the dump xwd wrote: %w", err)
|
||||
}
|
||||
var shot image.Image = img
|
||||
if name := a.Opt("output", ""); name != "" {
|
||||
if cut != nil {
|
||||
r := image.Rect(cut.X, cut.Y, cut.X+cut.W, cut.Y+cut.H).Intersect(img.Bounds())
|
||||
if r.Empty() {
|
||||
return nil, fmt.Errorf("the window is not on the screen (%dx%d at %d,%d)", cut.W, cut.H, cut.X, cut.Y)
|
||||
}
|
||||
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
|
||||
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
|
||||
shot = crop
|
||||
} else if name := a.Opt("output", ""); name != "" {
|
||||
_, outs, _, err := x.query(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -568,7 +591,16 @@ func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
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
|
||||
answer := map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}
|
||||
if inline {
|
||||
// For a session that cannot read the machine's files: the image itself, within a bound.
|
||||
if buf.Len() > mostInline {
|
||||
answer["inline"] = fmt.Sprintf("not included: %d bytes is more than %d; take one output or one window", buf.Len(), mostInline)
|
||||
} else {
|
||||
answer["png_base64"] = base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (x xorg) xlog(ctx context.Context, a desktop.Args) (any, error) {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
// The windows on the X screen, as the server knows them (xwininfo): what a window manager's own list
|
||||
// (node-display-session.windows) leaves out — a window it does not manage, a dialog, a tooltip, a
|
||||
// window that is mapped but drawn by nobody — and what a compositor goes by: the window's depth (32
|
||||
// bits is a visual with an alpha channel, which a compositor blends with what is behind it), its type,
|
||||
// whether it is mapped, and its own opacity.
|
||||
//
|
||||
// The tree comes from one `xwininfo -root -tree`. A window's details take one `xwininfo -all` and one
|
||||
// `xprop` each, so they are read only for the windows asked about, and at most mostDetailed of them.
|
||||
|
||||
const mostDetailed = 40
|
||||
|
||||
// Window is one window of the tree, and its details when they were read.
|
||||
type Window struct {
|
||||
ID string `json:"window"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
// Level is the window's depth in the tree: 1 is a child of the root (a frame or an unmanaged window).
|
||||
Level int `json:"level"`
|
||||
|
||||
// The details (detail true, or a class asked for).
|
||||
Depth int `json:"depth,omitempty"`
|
||||
ARGB *bool `json:"argb,omitempty"`
|
||||
Visual string `json:"visual_class,omitempty"`
|
||||
Mapped string `json:"map_state,omitempty"`
|
||||
OverrideRedir *bool `json:"override_redirect,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
States []string `json:"states,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Opacity *float64 `json:"opacity,omitempty"`
|
||||
DetailProblems string `json:"detail_problem,omitempty"`
|
||||
}
|
||||
|
||||
// treeLine is one line of `xwininfo -root -tree`: the id, the name (or "(has no name)"), the class
|
||||
// pair when the window has one, the size and position relative to the parent, and the absolute one.
|
||||
var treeLine = regexp.MustCompile(`^(\s*)(0x[0-9a-fA-F]+) (?:\(has no name\)|"(.*)"): \((?:"([^"]*)" "([^"]*)")?\)\s+(\d+)x(\d+)[+-]-?\d+[+-]-?\d+\s+\+?(-?\d+)\+?(-?\d+)\s*$`)
|
||||
|
||||
// ParseTree reads the windows of `xwininfo -root -tree`, in the server's stacking order per parent.
|
||||
func ParseTree(text string) []Window {
|
||||
var out []Window
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
m := treeLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
w := Window{ID: m[2], Name: m[3], Instance: m[4], Class: m[5]}
|
||||
w.Width, _ = strconv.Atoi(m[6])
|
||||
w.Height, _ = strconv.Atoi(m[7])
|
||||
w.X, _ = strconv.Atoi(m[8])
|
||||
w.Y, _ = strconv.Atoi(m[9])
|
||||
// xwininfo indents a child of the root by 5 spaces and each further level by 3.
|
||||
w.Level = 1 + (len(m[1])-5)/3
|
||||
out = append(out, w)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseDetails adds what `xwininfo -id <w> -all` says about one window.
|
||||
func ParseDetails(w *Window, text string) {
|
||||
section := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
indent := len(raw) - len(strings.TrimLeft(raw, " "))
|
||||
switch {
|
||||
case strings.HasPrefix(line, "Depth:"):
|
||||
w.Depth, _ = strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "Depth:")))
|
||||
argb := w.Depth == 32
|
||||
w.ARGB = &argb
|
||||
section = ""
|
||||
case strings.HasPrefix(line, "Visual Class:"):
|
||||
w.Visual = strings.TrimSpace(strings.TrimPrefix(line, "Visual Class:"))
|
||||
case strings.HasPrefix(line, "Map State:"):
|
||||
w.Mapped = strings.TrimSpace(strings.TrimPrefix(line, "Map State:"))
|
||||
case strings.HasPrefix(line, "Override Redirect State:"):
|
||||
v := strings.TrimSpace(strings.TrimPrefix(line, "Override Redirect State:")) == "yes"
|
||||
w.OverrideRedir = &v
|
||||
case line == "Window type:":
|
||||
section = "type"
|
||||
case line == "Window state:":
|
||||
section = "state"
|
||||
case line == "":
|
||||
section = ""
|
||||
case section != "" && indent >= 10:
|
||||
if section == "type" {
|
||||
w.Types = append(w.Types, line)
|
||||
} else {
|
||||
w.States = append(w.States, line)
|
||||
}
|
||||
default:
|
||||
if indent < 10 {
|
||||
section = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// opacityLine and roleLine are the two properties read with xprop.
|
||||
var (
|
||||
opacityLine = regexp.MustCompile(`^_NET_WM_WINDOW_OPACITY\(CARDINAL\) = (\d+)`)
|
||||
roleLine = regexp.MustCompile(`^WM_WINDOW_ROLE\(STRING\) = "(.*)"`)
|
||||
)
|
||||
|
||||
// ParseWindowProps adds the window's role and its own opacity (0 to 1) from xprop.
|
||||
func ParseWindowProps(w *Window, text string) {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if m := opacityLine.FindStringSubmatch(line); m != nil {
|
||||
if n, err := strconv.ParseUint(m[1], 10, 32); err == nil {
|
||||
v := float64(n) / float64(0xffffffff)
|
||||
v = float64(int(v*1000+0.5)) / 1000
|
||||
w.Opacity = &v
|
||||
}
|
||||
}
|
||||
if m := roleLine.FindStringSubmatch(line); m != nil {
|
||||
w.Role = m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x xorg) windows(ctx context.Context, a desktop.Args) (any, error) {
|
||||
classExpr := a.Opt("class", "")
|
||||
nameExpr := a.Opt("name", "")
|
||||
detail, _, err := a.Bool("detail")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, _, err := a.Bool("all")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var classRe, nameRe *regexp.Regexp
|
||||
if classExpr != "" {
|
||||
if classRe, err = regexp.Compile(classExpr); err != nil {
|
||||
return nil, fmt.Errorf("class is a regular expression: %w", err)
|
||||
}
|
||||
}
|
||||
if nameExpr != "" {
|
||||
if nameRe, err = regexp.Compile(nameExpr); err != nil {
|
||||
return nil, fmt.Errorf("name is a regular expression: %w", err)
|
||||
}
|
||||
}
|
||||
res, s, err := x.d.InSession(ctx, "xwininfo", "-root", "-tree")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
if res.Code == 127 {
|
||||
return nil, fmt.Errorf("xwininfo is not installed here; the xorg module's package resource (xorg-xwininfo) installs it")
|
||||
}
|
||||
return nil, res.Err()
|
||||
}
|
||||
tree := ParseTree(res.Stdout)
|
||||
var shown []Window
|
||||
for _, w := range tree {
|
||||
switch {
|
||||
case classRe != nil && !(classRe.MatchString(w.Class) || classRe.MatchString(w.Instance)):
|
||||
continue
|
||||
case nameRe != nil && !nameRe.MatchString(w.Name):
|
||||
continue
|
||||
case classRe == nil && nameRe == nil && !all && w.Class == "":
|
||||
// Without a filter, the windows a program owns: those with a class.
|
||||
continue
|
||||
}
|
||||
shown = append(shown, w)
|
||||
}
|
||||
detailed := 0
|
||||
if detail || classRe != nil || nameRe != nil {
|
||||
env := s.Env(x.d.Base)
|
||||
for i := range shown {
|
||||
if detailed == mostDetailed {
|
||||
break
|
||||
}
|
||||
detailed++
|
||||
w := &shown[i]
|
||||
info := x.d.Run(ctx, env, nil, "xwininfo", "-id", w.ID, "-all")
|
||||
if !info.OK() {
|
||||
// A window can go between the tree and its details.
|
||||
w.DetailProblems = strings.TrimSpace(info.Stderr)
|
||||
continue
|
||||
}
|
||||
ParseDetails(w, info.Stdout)
|
||||
if props := x.d.Run(ctx, env, nil, "xprop", "-id", w.ID, "WM_WINDOW_ROLE", "_NET_WM_WINDOW_OPACITY"); props.OK() {
|
||||
ParseWindowProps(w, props.Stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
answer := map[string]any{"session": s.Display, "windows": shown, "count": len(shown), "in_tree": len(tree)}
|
||||
if detailed < len(shown) && (detail || classRe != nil || nameRe != nil) {
|
||||
answer["detailed"] = fmt.Sprintf("the first %d of %d; narrow class or name for the rest", detailed, len(shown))
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// windowGeometry is a window's absolute position and size, from `xwininfo -id`.
|
||||
func (x xorg) windowGeometry(ctx context.Context, env []string, id string) (image4, error) {
|
||||
r := x.d.Run(ctx, env, nil, "xwininfo", "-id", id)
|
||||
if !r.OK() {
|
||||
if r.Code == 127 {
|
||||
return image4{}, fmt.Errorf("xwininfo is not installed here; the xorg module's package resource (xorg-xwininfo) installs it")
|
||||
}
|
||||
return image4{}, r.Err()
|
||||
}
|
||||
return ParseGeometry(r.Stdout)
|
||||
}
|
||||
|
||||
// image4 is a rectangle on the screen.
|
||||
type image4 struct{ X, Y, W, H int }
|
||||
|
||||
// ParseGeometry reads the absolute corner and the size from `xwininfo -id`.
|
||||
func ParseGeometry(text string) (image4, error) {
|
||||
var g image4
|
||||
found := 0
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
for prefix, into := range map[string]*int{
|
||||
"Absolute upper-left X:": &g.X, "Absolute upper-left Y:": &g.Y, "Width:": &g.W, "Height:": &g.H,
|
||||
} {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, prefix))); err == nil {
|
||||
*into = n
|
||||
found++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found < 4 || g.W <= 0 || g.H <= 0 {
|
||||
return image4{}, fmt.Errorf("xwininfo gave no geometry for the window")
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
// The tree and the details as xwininfo printed them on a workstation (2026-10-09), with a calendar
|
||||
// reminder of the mail client open: a dialog of a 32-bit visual.
|
||||
const treeText = `
|
||||
xwininfo: Window id: 0x3e6 (the root window) "i3"
|
||||
|
||||
Root window id: 0x3e6 (the root window) "i3"
|
||||
Parent window id: 0x0 (none)
|
||||
160 children:
|
||||
0x1800002 "Dunst": ("Dunst" "Dunst") 259x39+2291+50 +2291+50
|
||||
0x2a00e04 "Betterbird": ("eu.betterbird.Betterbird" "eu.betterbird.Betterbird") 1x1+0+0 +0+0
|
||||
2 children:
|
||||
0x2a00e08 (has no name): () 1x1+-1+-1 +-1+-1
|
||||
0x600090 "[i3 con] floating_con around 0x5594": () 602x325+979+556 +979+556
|
||||
1 child:
|
||||
0x2a00221 "1 Reminder": ("Calendar" "eu.betterbird.Betterbird") 600x300+1+24 +980+581
|
||||
0x2a00222 "say "hi": there": ("Mail" "eu.betterbird.Betterbird") 10x10+-100+-100 +-100+-100
|
||||
`
|
||||
|
||||
const detailText = `
|
||||
xwininfo: Window id: 0x2a00221 "1 Reminder"
|
||||
|
||||
Absolute upper-left X: 980
|
||||
Absolute upper-left Y: 581
|
||||
Width: 600
|
||||
Height: 300
|
||||
Depth: 32
|
||||
Visual: 0x87
|
||||
Visual Class: TrueColor
|
||||
Map State: IsViewable
|
||||
Override Redirect State: no
|
||||
Someone wants these events:
|
||||
KeyPress
|
||||
Window manager hints:
|
||||
Client accepts input or input focus: Yes
|
||||
Window type:
|
||||
Dialog
|
||||
Window state:
|
||||
Focused
|
||||
Process id: 37147 on host h
|
||||
Frame extents: 0, 0, 0, 0
|
||||
|
||||
Normal window size hints:
|
||||
`
|
||||
|
||||
func TestTheTreeIsReadWithClassesPlacesAndLevels(t *testing.T) {
|
||||
got := ParseTree(treeText)
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("%d windows: %+v", len(got), got)
|
||||
}
|
||||
r := got[4]
|
||||
if r.ID != "0x2a00221" || r.Name != "1 Reminder" || r.Instance != "Calendar" || r.Class != "eu.betterbird.Betterbird" ||
|
||||
r.Width != 600 || r.Height != 300 || r.X != 980 || r.Y != 581 || r.Level != 2 {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
if got[2].ID != "0x2a00e08" || got[2].Class != "" || got[2].X != -1 || got[2].Level != 2 {
|
||||
t.Fatalf("a nameless child at a negative place: %+v", got[2])
|
||||
}
|
||||
if got[5].Name != `say "hi": there` || got[5].X != -100 || got[5].Level != 1 {
|
||||
t.Fatalf("a name with quotes and a colon: %+v", got[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDetailsSayDepthTypeStateRoleAndOpacity(t *testing.T) {
|
||||
w := Window{ID: "0x2a00221"}
|
||||
ParseDetails(&w, detailText)
|
||||
ParseWindowProps(&w, "WM_WINDOW_ROLE(STRING) = \"AlarmWindow\"\n_NET_WM_WINDOW_OPACITY(CARDINAL) = 2147483647\n")
|
||||
if w.Depth != 32 || w.ARGB == nil || !*w.ARGB || w.Visual != "TrueColor" || w.Mapped != "IsViewable" ||
|
||||
w.OverrideRedir == nil || *w.OverrideRedir || strings.Join(w.Types, ",") != "Dialog" ||
|
||||
strings.Join(w.States, ",") != "Focused" || w.Role != "AlarmWindow" || w.Opacity == nil || *w.Opacity != 0.5 {
|
||||
t.Fatalf("%+v", w)
|
||||
}
|
||||
plain := Window{}
|
||||
ParseWindowProps(&plain, "WM_WINDOW_ROLE: not found.\n_NET_WM_WINDOW_OPACITY: not found.\n")
|
||||
if plain.Opacity != nil || plain.Role != "" {
|
||||
t.Fatalf("no opacity of its own is none, not 0: %+v", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsFiltersByClassAndReadsTheDetailsOfWhatItShows(t *testing.T) {
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"xwininfo -root -tree": {Stdout: treeText},
|
||||
"xwininfo -id 0x2a00e04 -all": {Stdout: " Depth: 32\n Map State: IsUnMapped\n"},
|
||||
"xwininfo -id 0x2a00221 -all": {Stdout: detailText},
|
||||
"xwininfo -id 0x2a00222 -all": {Code: 1, Stderr: "X Error: BadWindow"},
|
||||
"xprop -id 0x2a00221 WM_WINDOW_ROLE _NET_WM_WINDOW_OPACITY": {Stdout: "WM_WINDOW_ROLE(STRING) = \"AlarmWindow\"\n"},
|
||||
}}
|
||||
got, err := byName(t, f.desk(true), "xorg_windows")(map[string]any{"class": "(?i)betterbird"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ws := got.(map[string]any)["windows"].([]Window)
|
||||
if len(ws) != 3 || ws[1].Role != "AlarmWindow" || ws[1].Depth != 32 || ws[2].DetailProblems == "" {
|
||||
t.Fatalf("%+v", ws)
|
||||
}
|
||||
f2 := &fakeDesk{answers: map[string]desktop.Result{"xwininfo -root -tree": {Stdout: treeText}}}
|
||||
got, err = byName(t, f2.desk(true), "xorg_windows")(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := got.(map[string]any)["count"]; n != 4 || len(f2.ran) != 1 {
|
||||
t.Fatalf("without a filter: the windows with a class, and no details read: %v %v", n, f2.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowsScreenshotIsCutFromTheScreenNotDumpedByItsID(t *testing.T) {
|
||||
// The fake xwd writes a 4x3 screen whose pixel (x, y) is (x*10, y*10, 7).
|
||||
home := t.TempDir()
|
||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||
t.Setenv("HOME", home)
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"xwininfo -id 0x2a00221": {Stdout: " Absolute upper-left X: 1\n Absolute upper-left Y: 1\n Width: 2\n Height: 2\n"},
|
||||
}}
|
||||
d := f.desk(true)
|
||||
run := d.Run
|
||||
d.Run = func(ctx context.Context, env []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
if name == "xwd" {
|
||||
for i, a := range args {
|
||||
if a == "-id" {
|
||||
t.Fatal("xwd is never asked for one window")
|
||||
}
|
||||
if a == "-out" {
|
||||
os.WriteFile(args[i+1], xwdOf(4, 3, true, func(x, y int) (uint8, uint8, uint8) { return uint8(x * 10), uint8(y * 10), 7 }), 0o644)
|
||||
}
|
||||
}
|
||||
return desktop.Result{}
|
||||
}
|
||||
return run(ctx, env, stdin, name, args...)
|
||||
}
|
||||
got, err := byName(t, d, "xorg_screenshot")(map[string]any{"window": "0x2a00221", "path": "~/shot.png", "inline": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := got.(map[string]any)
|
||||
if a["width"] != 2 || a["height"] != 2 {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(a["png_base64"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := color.RGBAModel.Convert(img.At(0, 0)).(color.RGBA); c != (color.RGBA{10, 10, 7, 0xff}) {
|
||||
t.Fatalf("the cut starts at the window's corner: %v", c)
|
||||
}
|
||||
if _, err := ParseGeometry("Width: 0\n"); err == nil {
|
||||
t.Fatal("no geometry is refused")
|
||||
}
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func TestEveryToolThatNeedsTheScreenSaysPlainlyThatThereIsNoSession(t *testing.T
|
||||
{"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},
|
||||
{"xorg_screenshot", nil}, {"xorg_windows", nil},
|
||||
} {
|
||||
_, err := byName(t, d, call.tool)(orEmpty(call.args))
|
||||
if !desktop.IsNoSession(err) {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"xorg_input_set",
|
||||
"xorg_keyboard",
|
||||
"xorg_screenshot",
|
||||
"xorg_windows",
|
||||
"xorg_x_log"
|
||||
],
|
||||
"resources": [
|
||||
@@ -72,6 +73,11 @@
|
||||
"type": "package",
|
||||
"package": "xorg-xwd"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xwininfo",
|
||||
"type": "package",
|
||||
"package": "xorg-xwininfo"
|
||||
},
|
||||
{
|
||||
"id": "package-autorandr",
|
||||
"type": "package",
|
||||
|
||||
Reference in New Issue
Block a user