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.
446 lines
14 KiB
Go
446 lines
14 KiB
Go
// 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
|
|
}
|