From b2c170acda02b62637cec16709777c4ab5592841 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:54:13 +0200 Subject: [PATCH 1/5] 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. --- modules/xorg/README.md | 189 ++++++ modules/xorg/cmd/xorg/displays.go | 232 +++++++ modules/xorg/cmd/xorg/input.go | 137 ++++ modules/xorg/cmd/xorg/layout.go | 105 +++ modules/xorg/cmd/xorg/main.go | 167 +++++ modules/xorg/cmd/xorg/manifest_test.go | 243 +++++++ modules/xorg/cmd/xorg/testdata/setxkbmap.txt | 3 + .../xorg/cmd/xorg/testdata/xinput-list.txt | 25 + .../cmd/xorg/testdata/xinput-props-16.txt | 48 ++ .../xorg/cmd/xorg/testdata/xrandr-prop.txt | 437 +++++++++++++ modules/xorg/cmd/xorg/tools.go | 602 ++++++++++++++++++ modules/xorg/cmd/xorg/xlog.go | 103 +++ modules/xorg/cmd/xorg/xorg_test.go | 338 ++++++++++ modules/xorg/cmd/xorg/xwd.go | 83 +++ modules/xorg/go.mod | 5 + modules/xorg/go.sum | 2 + modules/xorg/internal/desktop/args.go | 160 +++++ modules/xorg/internal/desktop/copies_test.go | 42 ++ modules/xorg/internal/desktop/run.go | 232 +++++++ modules/xorg/internal/desktop/session.go | 445 +++++++++++++ modules/xorg/internal/desktop/session_test.go | 255 ++++++++ modules/xorg/module.json | 121 ++++ 22 files changed, 3974 insertions(+) create mode 100644 modules/xorg/README.md create mode 100644 modules/xorg/cmd/xorg/displays.go create mode 100644 modules/xorg/cmd/xorg/input.go create mode 100644 modules/xorg/cmd/xorg/layout.go create mode 100644 modules/xorg/cmd/xorg/main.go create mode 100644 modules/xorg/cmd/xorg/manifest_test.go create mode 100644 modules/xorg/cmd/xorg/testdata/setxkbmap.txt create mode 100644 modules/xorg/cmd/xorg/testdata/xinput-list.txt create mode 100644 modules/xorg/cmd/xorg/testdata/xinput-props-16.txt create mode 100644 modules/xorg/cmd/xorg/testdata/xrandr-prop.txt create mode 100644 modules/xorg/cmd/xorg/tools.go create mode 100644 modules/xorg/cmd/xorg/xlog.go create mode 100644 modules/xorg/cmd/xorg/xorg_test.go create mode 100644 modules/xorg/cmd/xorg/xwd.go create mode 100644 modules/xorg/go.mod create mode 100644 modules/xorg/go.sum create mode 100644 modules/xorg/internal/desktop/args.go create mode 100644 modules/xorg/internal/desktop/copies_test.go create mode 100644 modules/xorg/internal/desktop/run.go create mode 100644 modules/xorg/internal/desktop/session.go create mode 100644 modules/xorg/internal/desktop/session_test.go create mode 100644 modules/xorg/module.json diff --git a/modules/xorg/README.md b/modules/xorg/README.md new file mode 100644 index 0000000..cdc9785 --- /dev/null +++ b/modules/xorg/README.md @@ -0,0 +1,189 @@ +# xorg + +The X display server as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2 step 2). + +- **Claims `node-display-server`** and serves its verbs `displays` and `layout`. +- **Provides `x11-display` with the machine's reach**: i3, xterm, picom and the X lock screen require + it, and a requirement for it is met only by this module on the same machine. It is never pulled in + for whatever asked. +- **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, + so it belongs to that machine model's hardware module. + +## What it owns + +| path | class (ADR 0182) | what | +|---|---|---| +| `~/.xinitrc`, a block at the start | written into (`block`, `at: start`) | the session's start, below | +| `~/.config/xorg/` | owned directory | | +| `~/.config/xorg/xresources` | owned | the mesh's X resources: font rendering (`Xft.*`, DPI 96) and the three `xresources` slots | + +**The session's start**, in ADR 0208 §5's order: + +1. It sources `~/.config/mesh/environment.sh` (ADR 0203). If pam did not hand over a session bus, it + takes the user manager's socket, never a second bus. +2. It imports an explicit list of the session's words into the user manager and D-Bus activation, + only those that are set. The list is `DISPLAY`, `XAUTHORITY`, `XDG_SESSION_TYPE`, + `XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`, `XDG_CONFIG_HOME`, `XDG_DATA_DIRS`, `GTK_THEME`, + `GTK2_RC_FILES`, `QT_QPA_PLATFORMTHEME`, `QT_STYLE_OVERRIDE`, `QT_SELECT`, `XCURSOR_THEME`, + `XCURSOR_SIZE` and `TERMINAL`. It never uses `--all`. +3. It merges `~/.config/xorg/xresources` with `xrdb -nocpp`, then `~/.Xresources` if you keep one, + so yours win. +4. It runs `autorandr --change`, which applies the saved profile matching the connected monitors. +5. It runs the `xinitrc` slots `first` and `normal`, then sources **`~/.xinitrc.local`** if it exists. +6. It runs the `last` slot, where the holder of `node-display-session` starts the session (`exec i3`). + +**Who contributes where**, among the desktop's modules: + +| slot | module | what | +|---|---|---| +| `first` | `gnome-keyring` | `SSH_AUTH_SOCK` | +| `normal` | `adwaita` | its GSettings keys | +| `normal` | `clipmenu` | `clipmenud &` | +| `normal` | `feh` | the wallpaper | +| `normal` | `i3status-rust` | the bar watchdog | +| `normal` | `screen-lock` | the timeouts and the `xss-lock` loop | +| `last` | `i3` | `exec i3` | + +**Slot `last` is the session's.** A module contributing session lines uses `first` or `normal`. +Code in `last` that sorts after the session holder's name would come after its `exec` and never run. + +**Why `~/.xinitrc.local`.** The block ends by starting the session, so no line below it runs. Your own +session lines go into `~/.xinitrc.local`. Like `~/.zshrc.local`, it is yours: found, never written. +This is the one step this module adds to ADR 0208 §5. Without it, a line no module carries yet (a +`DOTNET_ROOT`, a wallpaper) would have nowhere to run. + +**Resources without the preprocessor.** `xrdb -nocpp` needs no C compiler on the machine, and it +skips a line starting with `#`. The controller precedes each contribution with a `# ` line, +which is a cpp directive error under plain `xrdb -merge` but is skipped here. + +## Tools + +| tool | | what | +|---|---|---| +| `node-display-server.displays` | r | the screen and every output: position, rotation, size, DPI, the monitor's EDID identity (manufacturer, product, serial, name, fingerprint), current and preferred mode, every mode and rate, the autorandr profile in force | +| `node-display-server.layout` | r/a | autorandr profiles: `list` (each with the monitors it is for, which match now, which is current), `save` (`replace` to overwrite), `apply` | +| `xorg_set_mode` | a | one output's mode, rate, rotation, scale, position (x/y or beside another), off, primary; `dry_run` | +| `xorg_primary` | r/a | which output is primary; set it | +| `xorg_dpi` | r/a | `Xft.dpi` beside each monitor's physical DPI; set it for the running session | +| `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_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. + +**How a tool reaches the session** (`internal/desktop`, the same copy in every desktop module). The +runtime is a system service running as the operator account. It has no `DISPLAY`, no `XAUTHORITY` and +no session bus. The tool reads them from the session's own processes: + +1. It looks at the account's processes, preferring the window manager. +2. It reads only a fixed list of words, never the rest. A session's environment held secrets on these + machines. +3. It asks logind whether that session is active and local. +4. It checks that the display's socket still exists. + +The bus it hands on is the user manager's (`unix:path=/run/user//bus`). With no session, a tool +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. + +## What it improves + +- **One environment.** The session sources the shells' environment file. It no longer exports a + hand-kept second copy, and never sources the predecessor's secrets file. +- **No compiler needed for the mesh's resources**, and contributions are placed in order rather than + `#include`d. +- **Monitor layouts by the monitors' identity** (autorandr, research 026/04), not scripts with port + names baked in. The package's own udev rule and service apply the matching profile on hotplug. +- **No second bus.** The `dbus-launch` fallback that once ran the whole session on a private bus is + gone. That stale session can still be seen today on one workstation: `session_bus` in a tool's + answer shows it. + +## What it leaves found + +`~/.xprofile`, `~/.Xresources`, `~/.Xresources.d/`, `~/.screenlayout/`, the arandr scripts, every +line of yours below the block, and `/etc/X11/xinit/xinitrc.d/`. + +## The one-off migration (ADR 0182) + +**Assign the desktop's modules together** (`xorg`, `lemurs`, `i3`, `xterm`, `adwaita` and the +companions above) and do this migration first. **Until `i3` is assigned, nothing changes for you.** The block's `last` slot is empty, so the block +runs and falls through to your own lines below it, which still end in `exec i3`. The environment is +sourced twice and the import runs twice, which is harmless. + +**Once `i3` is assigned, nothing below the block runs.** Before that push, sort today's +`~/.xinitrc` lines (both workstations hold the same file): + +| today's line | where it goes | +|---|---| +| the `/etc/X11/xinit/xinitrc.d/?*.sh` loop | **delete**: the block imports `DISPLAY` and `XAUTHORITY` itself, and the login manager's X setup runs that directory too | +| `eval $(gnome-keyring-daemon --start …)` and `export SSH_AUTH_SOCK` | **delete** once `gnome-keyring` is assigned. PAM starts and unlocks the keyring, and that module names the ssh agent's socket in the `xinitrc` slot `first`. Until then, `~/.xinitrc.local` | +| `export PATH=…` (nine entries) | `~/.local/bin`, `~/scripts` and `~/scripts/bin` come from `zsh`'s environment already. **Delete** `~/scripts/i3-sessions/commands` and `~/scripts/mediahuis`: neither exists on either workstation. Move `~/.cargo/bin`, `~/.config/rofi/scripts`, `~/.dotnet` and `~/.dotnet/tools` to `~/.xinitrc.local` until a module carries them | +| `DOTNET_ROOT`, `DOTNET_CLI_TELEMETRY_OPTOUT` | `~/.xinitrc.local` | +| `XDG_CONFIG_HOME` | **delete**: `zsh` contributes it | +| `XDG_DATA_DIRS` with the flatpak directories | `~/.xinitrc.local` until the `flatpak` module | +| `QT_QPA_PLATFORMTHEME`, `GTK_THEME`, `QT_STYLE_OVERRIDE`, `GTK2_RC_FILES`, `QT_SELECT` | **delete** once `adwaita` is assigned (its environment contributions) | +| `XDG_SESSION_DESKTOP`, `XDG_CURRENT_DESKTOP` and their comment | **delete** once `i3` is assigned | +| `dbus-update-activation-environment --systemd …` and its comment | **delete**: the block's step 2 | +| `~/scripts/xdg-appearance \|\| true` | **delete** once `adwaita` is assigned | +| `MY_KV_PATH`, `MY_LIB_PATH`, `MY_STREAMING_PATH` | `~/.xinitrc.local` (they are yours) | +| `[ -f "$HOME/.config/hal/env" ] && . …` (the secrets file) | **delete** (ADR 0208 §5, research 027 Q2). Whatever in the session needed one of those tokens gets it the way research 027 settles | +| `xset s 1800`, `xset dpms 1800 1800 3600` | **delete** once `screen-lock` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` | +| `~/.fehbg &` | **delete** once `feh` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` | +| the `xss-lock` respawn loop | **delete** once `screen-lock` is assigned. Until then, `~/.xinitrc.local` | +| `exec i3 --shmlog-size=26214400` | **delete**: `i3` contributes `exec i3` to the `last` slot | + +Then **delete everything below the block**. The old `#!/bin/sh` line now sits below the block too and +means nothing there. The file is run with `sh` (by the login manager's entry and by `startx`), never +executed by its first line. + +**`~/.xprofile`.** The login manager's X setup sources it, and it sources `~/.xinitrc`. Once `i3` and +`lemurs` are assigned, the session entry `/etc/lemurs/wms/i3` runs `~/.xinitrc` itself, so **delete +`~/.xprofile`**. Its bus logic is the block's now. If you keep it, delete its `. ~/.xinitrc` line, or +the session starts from `.xprofile` and the login manager's entry is never reached. Either way works +once, but only one should. + +**`~/.Xresources`** holds `#include ".Xresources.d/xterm"`, `#include ".Xresources.d/xft"` and the +two `Xcursor` lines: + +- The `xft` include and `~/.Xresources.d/xft` are **deleted** now. This module's file carries the + same five values. +- The `xterm` include and `~/.Xresources.d/xterm` are **deleted** once `xterm` is assigned. Kept, + they win over the module's font and colours. +- The `Xcursor` lines are **deleted** once `adwaita` is assigned. +- A file left empty is deleted. + +**Monitor layouts.** For each place you use, arrange the monitors (`xorg_set_mode`, or arandr once +more), then `layout` `save` it under a name. Once every place has a profile, these retire: the +`~/.screenlayout/` and `~/scripts/.screenlayouts/` scripts, the laptop's hotplug rule, and its i3 +bindings. Those bindings belong to that machine's hardware module, not here. + +## What changes when it is assigned + +| | g14 (laptop) | shanks (desktop) | +|---|---|---| +| packages | `autorandr` installed; the rest are there already | the same | +| files | `~/.config/xorg/xresources` new; a block added at the start of `~/.xinitrc` | the same | +| running session | nothing: everything takes effect at the next login | nothing | +| next login, before `i3` is assigned | the block runs, then the old file below it. Fonts unchanged (96 DPI); `autorandr --change` does nothing with no profiles | the same. Its session moves to the user manager's bus at the next login, as it should have | +| next login, after `i3` is assigned | only the block: what the table above did not move is gone | the same | + +## Blockers + +- **The capability.** ADR 0208 §3 says `graphical-session` gates the display server. The host + reports that capability from its own environment. It is a root daemon with no `DISPLAY`, so the + capability is **no on both workstations** (`node show`, 2026-10-04). Gated on it, `xorg` could + never be assigned. This manifest uses **`seat`** instead: graphics hardware with a connected + display, yes on both. The ADR's wording needs a progressive insight, or the host needs a probe + that finds the session the way `internal/desktop` does. +- **Unassigning removes the packages.** The host takes a package away when its declaration goes, + and `xorg-server` is among them. Do not unassign `xorg` from a workstation you are sitting at + without another display server assigned. +- **The `# ` naming lines in the `xresources` slots** are safe only because this module + merges with `-nocpp`. If the controller rendered `!` for `xresources`, that would be the + format's own comment. diff --git a/modules/xorg/cmd/xorg/displays.go b/modules/xorg/cmd/xorg/displays.go new file mode 100644 index 0000000..26a1900 --- /dev/null +++ b/modules/xorg/cmd/xorg/displays.go @@ -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 +} diff --git a/modules/xorg/cmd/xorg/input.go b/modules/xorg/cmd/xorg/input.go new file mode 100644 index 0000000..6ffb7d5 --- /dev/null +++ b/modules/xorg/cmd/xorg/input.go @@ -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_:+(),.-]+$`) diff --git a/modules/xorg/cmd/xorg/layout.go b/modules/xorg/cmd/xorg/layout.go new file mode 100644 index 0000000..8845fab --- /dev/null +++ b/modules/xorg/cmd/xorg/layout.go @@ -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: ` ` 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 +} diff --git a/modules/xorg/cmd/xorg/main.go b/modules/xorg/cmd/xorg/main.go new file mode 100644 index 0000000..fd28adf --- /dev/null +++ b/modules/xorg/cmd/xorg/main.go @@ -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-