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:
jochen
2026-10-04 13:21:47 +02:00
parent b8982b4a7c
commit b2c170acda
22 changed files with 3974 additions and 0 deletions
+189
View File
@@ -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 `# <module>` 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/<uid>/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 `# <module>` 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.
+232
View File
@@ -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
}
+137
View File
@@ -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_:+(),.-]+$`)
+105
View File
@@ -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
}
+167
View File
@@ -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),
},
}
}
+243
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
rules: evdev
model: pc105
layout: us
+25
View File
@@ -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)]
+48
View File
@@ -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
View File
@@ -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
+602
View File
@@ -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, ", "))
}
+103
View File
@@ -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
}
+338
View File
@@ -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)
}
+83
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
module xorg
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+160
View File
@@ -0,0 +1,160 @@
package desktop
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
)
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
type Args map[string]any
// Text is a required string, trimmed.
func (a Args) Text(name string) (string, error) {
v, ok := a[name].(string)
if !ok || strings.TrimSpace(v) == "" {
return "", fmt.Errorf("%s is required, as text", name)
}
return strings.TrimSpace(v), nil
}
// Opt is an optional string, trimmed, or def.
func (a Args) Opt(name, def string) string {
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return def
}
// Has is whether the caller gave the argument at all.
func (a Args) Has(name string) bool {
v, ok := a[name]
return ok && v != nil
}
// Bool is an optional boolean: its value, and whether it was given.
func (a Args) Bool(name string) (bool, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return false, false, nil
}
b, isBool := v.(bool)
if !isBool {
return false, false, fmt.Errorf("%s is true or false", name)
}
return b, true, nil
}
// Number is an optional number: its value, and whether it was given.
func (a Args) Number(name string) (float64, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return 0, false, nil
}
f, isNum := v.(float64)
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, false, fmt.Errorf("%s is a number", name)
}
return f, true, nil
}
// Whole is an optional whole number within [lo, hi], or def.
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
f, given, err := a.Number(name)
if err != nil {
return 0, err
}
if !given {
return def, nil
}
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
}
return int(f), nil
}
// OneOf is an optional string that must be one of choices, or def.
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
v := a.Opt(name, def)
for _, c := range choices {
if v == c {
return v, nil
}
}
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
}
// Strings is an optional list of strings.
func (a Args) Strings(name string) ([]string, error) {
v, ok := a[name]
if !ok || v == nil {
return nil, nil
}
list, isList := v.([]any)
if !isList {
return nil, fmt.Errorf("%s is a list of text", name)
}
out := make([]string, 0, len(list))
for _, x := range list {
s, isText := x.(string)
if !isText {
return nil, fmt.Errorf("%s is a list of text", name)
}
out = append(out, s)
}
return out, nil
}
// Home is the operator account's home: the runtime's word for it, else this process's.
func Home() string {
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
return h
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return "/"
}
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
func InHome(path string) (string, error) {
home := Home()
switch {
case path == "~":
path = home
case strings.HasPrefix(path, "~/"):
path = filepath.Join(home, path[2:])
case !filepath.IsAbs(path):
path = filepath.Join(home, path)
}
path = filepath.Clean(path)
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
return "", fmt.Errorf("%s is outside the account's home", path)
}
return path, nil
}
// Schema builds a tool's input schema from property descriptions; required names those that must
// be given. A property is a string unless its description object says otherwise.
func Schema(props map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
s["required"] = required
}
return s
}
// Str, Num, Flag, List and Enum describe one property.
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
func List(desc string) map[string]any {
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
}
func Enum(desc string, values ...string) map[string]any {
return map[string]any{"type": "string", "enum": values, "description": desc}
}
@@ -0,0 +1,42 @@
package desktop
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// The desktop modules that carry this package. Each builds alone, so each has its own copy; this
// test, itself one of the copied files, holds them to one text wherever the siblings are present.
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
mine, err := filepath.Glob("*.go")
if err != nil || len(mine) == 0 {
t.Fatal("no files of this package found", err)
}
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
if _, err := os.Stat(dir); err != nil {
continue
}
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
if len(theirs) != len(mine) {
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
continue
}
for _, f := range mine {
a, _ := os.ReadFile(f)
b, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(a, b) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
+232
View File
@@ -0,0 +1,232 @@
package desktop
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
// fits in a tool's reply.
const (
DefaultTimeout = 10 * time.Second
MostOutput = 256 << 10
)
// Result is what one command did.
type Result struct {
Command []string `json:"command"`
Code int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
}
// OK is whether the command ran and exited 0.
func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut }
// Err is the command's failure as an error naming it and what it said, or nil.
func (r Result) Err() error {
if r.OK() {
return nil
}
said := strings.TrimSpace(r.Stderr)
if said == "" {
said = strings.TrimSpace(r.Stdout)
}
if r.TimedOut {
return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " "))
}
return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said)
}
// Runner runs a command with an environment and answers what it did. Tools take one, so their
// tests replace the machine with a table of answers.
type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result
// Exec is the machine's Runner: the command in its own process group, ended with everything it
// started at the deadline, each stream cut at MostOutput.
func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
}
res := Result{Command: append([]string{name}, args...)}
cmd := exec.Command(name, args...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, errb := &capped{}, &capped{}
cmd.Stdout, cmd.Stderr = out, errb
if err := cmd.Start(); err != nil {
res.Code = 127
res.Stderr = err.Error()
return res
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
err = <-done
res.TimedOut = true
}
res.Stdout, res.Stderr = out.String(), errb.String()
res.Truncated = out.cut || errb.cut
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
res.Code = exit.ExitCode()
if res.Code < 0 {
res.Code = 128
}
default:
res.Code = 1
if res.Stderr == "" {
res.Stderr = err.Error()
}
}
return res
}
// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an
// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write.
type capped struct {
buf bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.buf.Len(); room < len(p) {
if room > 0 {
c.buf.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *capped) String() string { return c.buf.String() }
// Desk is what a desktop tool needs: how to find the session, and how to run a command.
type Desk struct {
Find func() (*Session, error)
Run Runner
// Base is the environment a command starts from, before the session's words.
Base []string
}
// Machine is the real Desk, preferring the named processes as the session's.
func Machine(prefer ...string) Desk {
return Desk{
Find: func() (*Session, error) { return Find(prefer...) },
Run: Exec,
Base: os.Environ(),
}
}
// InSession runs a command in the operator's session, or answers NoSession.
func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil
}
// InSessionWith is InSession with standard input.
func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil
}
// Plain runs a command with the base environment: for what needs no session.
func (d Desk) Plain(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, d.Base, nil, name, args...)
}
// AsUser runs a command with the account's own runtime directory and bus, and no display.
func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...)
}
// Launched is how a program was started in the session.
type Launched struct {
Unit string `json:"unit,omitempty"`
PID int `json:"pid,omitempty"`
How string `json:"how"`
}
// Launch starts a program in the operator's session that outlives the call and the runtime.
//
// **Not as a child of this process.** The runtime is a system service; everything it starts is in
// its control group, and the service manager ends that group whenever the runtime restarts — which
// is every push that changes it. So the program is handed to the account's own service manager as a
// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the
// operator's user manager does. Without a user manager it is started detached as a last resort, and
// the answer says it will end with the runtime.
func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) {
if len(argv) == 0 {
return Launched{}, errors.New("nothing to launch")
}
env := s.Env(d.Base)
unit := "mesh-" + name + "-" + token()
args := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} {
if v := lookup(env, w); v != "" {
args = append(args, "--setenv="+w+"="+v)
}
}
args = append(args, "--")
args = append(args, argv...)
res := d.Run(ctx, env, nil, "systemd-run", args...)
if res.OK() {
return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil
}
if s.Bus != "" {
return Launched{}, res.Err()
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return Launched{}, err
}
pid := cmd.Process.Pid
go func() { _ = cmd.Wait() }()
return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil
}
func lookup(env []string, name string) string {
for i := len(env) - 1; i >= 0; i-- {
if k, v, ok := strings.Cut(env[i], "="); ok && k == name {
return v
}
}
return ""
}
func token() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+445
View File
@@ -0,0 +1,445 @@
// Package desktop is how a desktop module's tools act in the operator's graphical session
// (novox/hq ADR 0208, research 026/05).
//
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
// process of node-tools.service, started by the system's service manager as the operator account,
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
// was started elsewhere, by the login manager, and the only place its values are written down is
// the environment of the processes it started. So this package finds the session the way a person
// would: it looks at the operator account's own processes, takes the one that is plainly the
// session's (the window manager, or the oldest process carrying a display), confirms with logind
// that its session is a live local one, and checks that the display's socket is really there.
//
// **Only the session's own words are read.** A session's processes also carry whatever its start
// script exported — on the workstations that was a file of secrets — so the environment is filtered
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
//
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
// whenever it exists, because that is where the portal, the notifier and every user service
// listen. A session started on a private bus (a stale session, measured on one workstation) is
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
//
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
// every copy together — the modules' tests compare them.
package desktop
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// SessionWords are the only environment words read from a session's process: the ones that say
// where the session is. Everything else in that environment is the operator's, and is never read.
var SessionWords = []string{
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
"I3SOCK", "SWAYSOCK",
}
// Session is the operator's running graphical session, as a tool needs it.
type Session struct {
UID int `json:"uid"`
ID string `json:"session_id,omitempty"`
Type string `json:"type"`
Display string `json:"display,omitempty"`
WaylandDisplay string `json:"wayland_display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
RuntimeDir string `json:"runtime_dir"`
Bus string `json:"bus,omitempty"`
SessionBus string `json:"session_bus,omitempty"`
Desktop string `json:"desktop,omitempty"`
// FoundIn is the process whose environment named the session.
FoundIn Process `json:"found_in"`
// Active is logind's word on the session, when logind answered.
Active *bool `json:"active,omitempty"`
words map[string]string
}
// Process is one process the search looked at.
type Process struct {
PID int `json:"pid"`
Command string `json:"command"`
start uint64
}
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
// that returns it as its error still answers structured data.
type NoSession struct {
Reason string `json:"reason"`
Looked []string `json:"looked"`
}
func (e *NoSession) Error() string {
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
return string(b)
}
// IsNoSession is whether err says there is no session.
func IsNoSession(err error) bool {
var n *NoSession
return errors.As(err, &n)
}
// Finder holds where the search looks, so a test can point it at a tree of its own.
type Finder struct {
Proc string // the process table: /proc
X11Sockets string // where X servers listen: /tmp/.X11-unix
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
UID int // whose session
// Prefer names the processes that are the session's own, best first: the session's holder.
Prefer []string
// Logind answers `loginctl show-session` for one id; nil skips the check.
Logind func(id string) (map[string]string, error)
}
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
func DefaultFinder(prefer ...string) Finder {
uid := os.Getuid()
if uid == 0 {
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
if u, err := user.Lookup(name); err == nil {
if n, err := strconv.Atoi(u.Uid); err == nil {
uid = n
}
}
}
}
return Finder{
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
UID: uid, Prefer: prefer, Logind: loginctl,
}
}
// Find is the operator's session on this machine, preferring a process named in prefer.
func Find(prefer ...string) (*Session, error) {
return DefaultFinder(prefer...).Find()
}
type candidate struct {
proc Process
words map[string]string
rank int
logind map[string]string
}
// Find looks for the session.
func (f Finder) Find() (*Session, error) {
entries, err := os.ReadDir(f.Proc)
if err != nil {
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
}
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
var found []candidate
stale := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(f.Proc, e.Name())
info, err := os.Stat(dir)
if err != nil {
continue
}
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
continue
}
words := readWords(filepath.Join(dir, "environ"))
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
continue
}
if !f.reachable(words) {
stale++
continue
}
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
}
if len(found) == 0 {
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
if stale > 0 {
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
}
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
}
// logind's word on each session the candidates name, asked once per session.
asked := map[string]map[string]string{}
for i := range found {
id := found[i].words["XDG_SESSION_ID"]
if f.Logind == nil || id == "" {
found[i].rank = 1
continue
}
props, done := asked[id]
if !done {
props, _ = f.Logind(id)
asked[id] = props
}
found[i].logind = props
switch {
case props == nil:
found[i].rank = 1
case props["Remote"] == "yes":
found[i].rank = 3
case props["Active"] == "yes" && props["State"] != "closing":
found[i].rank = 0
case props["State"] == "closing":
found[i].rank = 3
default:
found[i].rank = 2
}
}
if f.Logind != nil {
looked = append(looked, "logind's sessions")
}
preferred := func(c candidate) int {
for i, p := range f.Prefer {
if c.proc.Command == p {
return i
}
}
return len(f.Prefer)
}
sort.SliceStable(found, func(i, j int) bool {
a, b := found[i], found[j]
if a.rank != b.rank {
return a.rank < b.rank
}
if pa, pb := preferred(a), preferred(b); pa != pb {
return pa < pb
}
if a.proc.start != b.proc.start {
return a.proc.start < b.proc.start
}
return a.proc.PID < b.proc.PID
})
best := found[0]
if best.rank == 3 {
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
}
return f.session(best), nil
}
func (f Finder) session(c candidate) *Session {
w := c.words
s := &Session{
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
}
s.Desktop = w["XDG_CURRENT_DESKTOP"]
if s.Desktop == "" {
s.Desktop = w["XDG_SESSION_DESKTOP"]
}
switch {
case w["XDG_SESSION_TYPE"] != "":
s.Type = w["XDG_SESSION_TYPE"]
case s.WaylandDisplay != "":
s.Type = "wayland"
default:
s.Type = "x11"
}
if s.RuntimeDir == "" {
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
s.SessionBus = own
}
if c.logind != nil {
active := c.logind["Active"] == "yes"
s.Active = &active
}
return s
}
// reachable is whether the display a process names is still served: the X server's socket, or the
// Wayland compositor's. A process outliving its session still carries the session's words.
func (f Finder) reachable(w map[string]string) bool {
if d := w["WAYLAND_DISPLAY"]; d != "" {
path := d
if !filepath.IsAbs(d) {
dir := w["XDG_RUNTIME_DIR"]
if dir == "" {
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
path = filepath.Join(dir, d)
}
if isSocket(path) {
return true
}
}
n, ok := DisplayNumber(w["DISPLAY"])
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
}
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
// another host — an ssh session's forwarded one — is not the local session and answers false.
func DisplayNumber(display string) (int, bool) {
host, rest, ok := strings.Cut(display, ":")
if !ok || (host != "" && host != "unix") {
return 0, false
}
num, _, _ := strings.Cut(rest, ".")
n, err := strconv.Atoi(num)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Word is one of the session's words as its process had it ("" when it had none).
func (s *Session) Word(name string) string { return s.words[name] }
// Env is base with the session's words in place of whatever base said for them.
func (s *Session) Env(base []string) []string {
drop := map[string]bool{}
for _, w := range SessionWords {
drop[w] = true
}
out := make([]string, 0, len(base)+8)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if !drop[k] {
out = append(out, kv)
}
}
bus := s.Bus
if bus == "" {
bus = s.SessionBus
}
for _, kv := range [][2]string{
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
} {
if kv[1] != "" {
out = append(out, kv[0]+"="+kv[1])
}
}
return out
}
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
// user manager or the session bus and needs no display — it works with no session at all.
func UserEnv(base []string, uid int) []string {
dir := filepath.Join("/run/user", strconv.Itoa(uid))
out := make([]string, 0, len(base)+2)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
out = append(out, kv)
}
}
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
}
// readWords reads a process's environment and keeps only SessionWords.
func readWords(path string) map[string]string {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
keep := map[string]bool{}
for _, w := range SessionWords {
keep[w] = true
}
out := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
k, v, ok := bytes.Cut(kv, []byte{'='})
if ok && keep[string(k)] {
out[string(k)] = string(v)
}
}
return out
}
func comm(dir string) string {
b, err := os.ReadFile(filepath.Join(dir, "comm"))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
// Read after the command's closing parenthesis, because the command may hold spaces.
func startTime(dir string) uint64 {
b, err := os.ReadFile(filepath.Join(dir, "stat"))
if err != nil {
return ^uint64(0)
}
i := bytes.LastIndexByte(b, ')')
if i < 0 {
return ^uint64(0)
}
fields := strings.Fields(string(b[i+1:]))
// fields[0] is the state, field 3 of the line; start time is field 22.
if len(fields) < 20 {
return ^uint64(0)
}
n, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return ^uint64(0)
}
return n
}
func isSocket(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode()&os.ModeSocket != 0
}
// loginctl asks logind about one session, by its property lines.
func loginctl(id string) (map[string]string, error) {
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
var out bytes.Buffer
cmd.Stdout = &out
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return nil, err
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
return nil, err
}
case <-time.After(3 * time.Second):
_ = cmd.Process.Kill()
return nil, errors.New("loginctl did not answer in 3s")
}
return ParseProperties(out.String()), nil
}
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
func ParseProperties(text string) map[string]string {
out := map[string]string{}
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
out[k] = v
}
}
return out
}
@@ -0,0 +1,255 @@
package desktop
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
type fakeMachine struct {
t *testing.T
proc, x11, runtime string
uid int
}
func newMachine(t *testing.T) *fakeMachine {
root, err := os.MkdirTemp("", "desk")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
return m
}
func (m *fakeMachine) socket(path string) {
l, err := net.Listen("unix", path)
if err != nil {
m.t.Fatal(err)
}
m.t.Cleanup(func() { l.Close() })
}
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
dir := filepath.Join(m.proc, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
m.t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
// cutime cstime priority nice threads itrealvalue starttime ...
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
}
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
}
func active(id string) (map[string]string, error) {
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
}
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X1"))
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "bus"))
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
s, err := m.finder(active, "i3").Find()
if err != nil {
t.Fatal(err)
}
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
t.Fatalf("session: %+v", s)
}
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
}
if s.Active == nil || !*s.Active {
t.Fatal("logind's word is carried")
}
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
if !strings.Contains(env, want) {
t.Errorf("env lacks %s:\n%s", want, env)
}
}
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
}
b, _ := json.Marshal(s)
if strings.Contains(string(b), "secret") {
t.Fatal("the answer carries a word outside the session's")
}
}
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
s, err := m.finder(nil).Find()
if err != nil || s.FoundIn.PID != 400 {
t.Fatalf("%+v %v", s, err)
}
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
}
}
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
m := newMachine(t)
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
_, err := m.finder(active, "i3").Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
t.Fatalf("%v", err)
}
var answer map[string]any
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
t.Fatalf("the refusal is structured: %s", err)
}
}
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
m := newMachine(t)
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
_, err := m.finder(active).Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
t.Fatalf("%v", err)
}
}
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.socket(filepath.Join(m.x11, "X1"))
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
logind := func(id string) (map[string]string, error) {
if id == "a" {
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
}
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
}
s, err := m.finder(logind, "i3").Find()
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
t.Fatalf("the active session: %+v %v", s, err)
}
remote := func(string) (map[string]string, error) {
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
}
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
t.Fatalf("a remote session is not the operator's desktop: %v", err)
}
}
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
m := newMachine(t)
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "wayland-1"))
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
s, err := m.finder(nil, "sway").Find()
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
t.Fatalf("%+v %v", s, err)
}
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
t.Fatal("the compositor's socket word passes")
}
}
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
if _, ok := DisplayNumber(d); ok != want {
t.Errorf("%q: %v", d, ok)
}
}
}
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
if !r.OK() || r.Stdout != "hello" {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
if r.OK() || r.Code != 127 {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
if !r.Truncated || len(r.Stdout) != MostOutput {
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
}
}
func TestArgumentsAreReadStrictly(t *testing.T) {
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if v, err := a.Text("name"); err != nil || v != "x" {
t.Fatal(v, err)
}
if _, err := a.Text("missing"); err == nil {
t.Fatal("a missing required text")
}
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
t.Fatal(n, err)
}
if _, err := a.Whole("f", 0, 0, 5); err == nil {
t.Fatal("1.5 is not whole")
}
if _, err := a.Whole("n", 0, 4, 5); err == nil {
t.Fatal("out of range")
}
if b, given, err := a.Bool("b"); !b || !given || err != nil {
t.Fatal("bool")
}
if _, _, err := a.Bool("name"); err == nil {
t.Fatal("text is not a bool")
}
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
t.Fatal(l, err)
}
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
t.Fatal("not one of")
}
}
func TestAPathIsKeptInsideTheHome(t *testing.T) {
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
if got, err := InHome(in); err != nil || got != want {
t.Errorf("%s: %s %v", in, got, err)
}
}
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
if _, err := InHome(out); err == nil {
t.Errorf("%s was accepted", out)
}
}
}
func TestPropertiesAreParsed(t *testing.T) {
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
t.Fatal(p)
}
}
+121
View File
@@ -0,0 +1,121 @@
{
"module": "xorg",
"version": "1",
"capabilities": [
"package-manager",
"seat"
],
"provides": [
{
"name": "x11-display",
"reach": "machine"
}
],
"claims": [
{
"name": "node-display-server",
"scope": "node",
"serves": [
"displays",
"layout"
]
}
],
"tools": [
"xorg_set_mode",
"xorg_primary",
"xorg_dpi",
"xorg_input_devices",
"xorg_input_set",
"xorg_keyboard",
"xorg_screenshot",
"xorg_x_log"
],
"resources": [
{
"id": "package-xorg-server",
"type": "package",
"package": "xorg-server"
},
{
"id": "package-xorg-xinit",
"type": "package",
"package": "xorg-xinit"
},
{
"id": "package-xorg-xrandr",
"type": "package",
"package": "xorg-xrandr"
},
{
"id": "package-xorg-xset",
"type": "package",
"package": "xorg-xset"
},
{
"id": "package-xorg-xrdb",
"type": "package",
"package": "xorg-xrdb"
},
{
"id": "package-xorg-xinput",
"type": "package",
"package": "xorg-xinput"
},
{
"id": "package-xorg-setxkbmap",
"type": "package",
"package": "xorg-setxkbmap"
},
{
"id": "package-xorg-xwd",
"type": "package",
"package": "xorg-xwd"
},
{
"id": "package-autorandr",
"type": "package",
"package": "autorandr"
},
{
"id": "config",
"type": "directory",
"path": "${machine:account-home}/.config/xorg",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "xresources",
"type": "file",
"path": "${machine:account-home}/.config/xorg/xresources",
"owner": "${machine:account}",
"mode": "0644",
"content": "! The mesh's X resources (module xorg, novox/hq ADR 0208 §4), replaced at every push. The session's\n! start merges them with xrdb -nocpp before ~/.Xresources, so your own resources there win. Without\n! the preprocessor there is no #include or #define here, and a line starting with # is skipped,\n! which is what the line naming each contributing module is.\n!\n! Font rendering, as both workstations had it. 96 DPI becomes a setting with issue 168; xorg_dpi\n! changes it for the running session.\nXft.dpi: 96\nXft.antialias: 1\nXft.hinting: 1\nXft.hintstyle: hintslight\nXft.rgba: rgb\n${shell:xresources:first}${shell:xresources:normal}${shell:xresources:last}\n"
},
{
"id": "xinitrc",
"type": "file",
"path": "${machine:account-home}/.xinitrc",
"owner": "${machine:account}",
"mode": "0755",
"into": "block",
"at": "start",
"content": "# The mesh's block (module xorg, novox/hq ADR 0208 §5): the graphical session's start, replaced at\n# every push. It ends by starting the session, so no line below it in this file runs any more. A line\n# of your own that must run at every session start goes in ~/.xinitrc.local, which this block sources\n# just before the session starts (the module's README lists where each of today's lines went).\n\n# 1. The account's environment (ADR 0203): the file every shell sources, written by node-environment.\nif [ -r \"${machine:account-home}/.config/mesh/environment.sh\" ]; then\n . \"${machine:account-home}/.config/mesh/environment.sh\"\nfi\n\n# The session bus is the user manager's. pam_systemd normally hands it over; when it did not, take\n# the canonical socket rather than starting a second bus, which runs every D-Bus-activated service\n# twice (two notifiers, two portals) and hides one copy from the other.\nif [ -z \"$DBUS_SESSION_BUS_ADDRESS\" ] && [ -S \"/run/user/$(id -u)/bus\" ]; then\n DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/$(id -u)/bus\"\n export DBUS_SESSION_BUS_ADDRESS\nfi\n\n# 2. The session's own words into the user manager and D-Bus activation, so the portal, the notifier\n# and every user service see the display and the desktop's identity. An explicit list and never\n# --all: a session's environment may hold things no user service should be able to read back.\nmesh_words=\nfor mesh_word in DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP \\\n XDG_CONFIG_HOME XDG_DATA_DIRS GTK_THEME GTK2_RC_FILES QT_QPA_PLATFORMTHEME QT_STYLE_OVERRIDE \\\n QT_SELECT XCURSOR_THEME XCURSOR_SIZE TERMINAL; do\n if printenv \"$mesh_word\" >/dev/null 2>&1; then\n mesh_words=\"$mesh_words $mesh_word\"\n fi\ndone\ndbus-update-activation-environment --systemd $mesh_words\nunset mesh_word mesh_words\n\n# 3. X resources: the mesh's, then yours in ~/.Xresources, which win. The mesh's file is merged\n# without the C preprocessor, so no compiler is needed for it.\nxrdb -nocpp -merge \"${machine:account-home}/.config/xorg/xresources\"\nif [ -r \"$HOME/.Xresources\" ]; then\n xrdb -merge \"$HOME/.Xresources\"\nfi\n\n# 4. The monitors: the saved layout profile that matches the monitors connected, if one does.\nif command -v autorandr >/dev/null 2>&1; then\n autorandr --change >/dev/null 2>&1 || true\nfi\n\n# 5. Other modules' session lines (ADR 0208 §4), then yours.\n${shell:xinitrc:first}${shell:xinitrc:normal}if [ -r \"$HOME/.xinitrc.local\" ]; then\n . \"$HOME/.xinitrc.local\"\nfi\n\n# 6. The session itself: the holder of node-display-session starts it from the last slot.\n${shell:xinitrc:last}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/xorg",
"binary": "xorg",
"loads": [
"xorg"
]
}
]
}
}