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