package main // desktop.go is the same file in the nextcloud-client, blueman, slack and jetbrains-toolbox bundles: a // tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq // ADR 0208). // // The runtime is a system service running as the operator account (ADR 0175): it has the account's // uid and none of the session's environment. A tool that starts something on the desktop finds the // session from a process of the account that carries DISPLAY (the window manager first), and starts // the program under the account's own service manager with `systemd-run --user`, never as its own // child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts. // // Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and // its signals are injected, so the tests run against a fake /proc and a fake home. // // Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended // with everything it started when it takes longer; each stream is kept to MostOutput; a file is read // to at most MostRead. import ( "bufio" "bytes" "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "syscall" "time" ) // Bounds every command and read is held to. const ( CallTimeout = 10 * time.Second MostOutput = 256 << 10 MostRead = 16 << 20 ) // Output is what a command did. type Output struct { Stdout string Stderr string Code int // Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error. Err error Cut bool } // ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err. var ( ErrNotInstalled = errors.New("not installed") ErrTimedOut = errors.New("timed out") // ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it. ErrNoSession = errors.New("no graphical session") ) // Runner runs one command with extra environment, within the context's deadline. Tests replace it. type Runner func(ctx context.Context, env []string, name string, args ...string) Output // Machine is what the tools read and act on. type Machine struct { Root string // "" on the machine; a fake root in tests Home string // the operator's home, as the machine names it UID int Run Runner Kill func(pid int, sig syscall.Signal) error Sleep func(time.Duration) Now func() time.Time Timeout time.Duration } // NewMachine is the machine the bundle runs on. func NewMachine() *Machine { return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill, Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout} } // operatorHome is the account's home: what the runtime was told, else the process's own. func operatorHome() string { if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { return h } h, _ := os.UserHomeDir() return h } func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } // home is a path under the operator's home, on this machine's filesystem. func (m *Machine) home(rel ...string) string { return filepath.Join(append([]string{m.Root, m.Home}, rel...)...) } // tilde shows a path under the home as ~/…, so an answer does not carry the account's name. func (m *Machine) tilde(p string) string { if m.Home != "" && m.Home != "/" { h := strings.TrimSuffix(m.Home, "/") if p == h { return "~" } if strings.HasPrefix(p, h+"/") { return "~/" + strings.TrimPrefix(p, h+"/") } } return p } // cmd runs a command within the machine's timeout (or a shorter one). func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output { if timeout <= 0 || timeout > m.Timeout { timeout = m.Timeout } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() return m.Run(ctx, env, name, args...) } // failed names how a command failed, or answers nil when it ran and exited 0. func failed(o Output, name string, args ...string) error { switch { case errors.Is(o.Err, ErrNotInstalled): return fmt.Errorf("%s is not installed on this machine", name) case errors.Is(o.Err, ErrTimedOut): return fmt.Errorf("%s gave no answer in time and was ended", name) case o.Err != nil: return fmt.Errorf("%s did not run: %v", name, o.Err) case o.Code != 0: said := strings.TrimSpace(o.Stderr) if said == "" { said = strings.TrimSpace(o.Stdout) } if said == "" { said = "and said nothing" } return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000)) } return nil } func tail(s string, n int) string { if len(s) <= n { return s } return "…" + s[len(s)-n:] } type capped struct { b bytes.Buffer cut bool } func (c *capped) Write(p []byte) (int, error) { if room := MostOutput - c.b.Len(); room < len(p) { if room > 0 { c.b.Write(p[:room]) } c.cut = true return len(p), nil } return c.b.Write(p) } func execRun(ctx context.Context, env []string, name string, args ...string) Output { path, err := exec.LookPath(name) if err != nil { return Output{Code: 127, Err: ErrNotInstalled} } cmd := exec.CommandContext(ctx, path, args...) cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...) // Its own process group, so that ending it on a timeout ends what it started too. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { if cmd.Process != nil { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } return nil } cmd.WaitDelay = 2 * time.Second var out, errs capped cmd.Stdout, cmd.Stderr = &out, &errs err = cmd.Run() o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut} var exit *exec.ExitError switch { case err == nil: case ctx.Err() == context.DeadlineExceeded: o.Code, o.Err = 124, ErrTimedOut case errors.As(err, &exit): o.Code = exit.ExitCode() default: o.Code, o.Err = 127, err } return o } // readBounded reads a file to at most MostRead bytes. func readBounded(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() return io.ReadAll(io.LimitReader(f, MostRead)) } // Proc is one process of the account. type Proc struct { PID int `json:"pid"` Command string `json:"command"` // StartedIn is the unit or scope it runs in: the login session's scope when the session's start // (dex, the window manager) started it, a mesh-… unit when a tool restarted it. StartedIn string `json:"started_in,omitempty"` Since string `json:"since,omitempty"` } // procs are this account's processes named comm, oldest first. func (m *Machine) procs(comm string) []Proc { entries, err := os.ReadDir(m.path("/proc")) if err != nil { return nil } boot := m.bootTime() var out []Proc for _, e := range entries { pid, err := strconv.Atoi(e.Name()) if err != nil { continue } dir := m.path(filepath.Join("/proc", e.Name())) if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID { continue } p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))} if p.Command == "" { p.Command = comm } if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" { line := strings.Split(cg, "\n")[0] p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:]) } if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok { p.Since = t.UTC().Format(time.RFC3339) } out = append(out, p) } sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID }) return out } // uidOf is the real uid on a process's status, -1 when unreadable. func (m *Machine) uidOf(dir string) int { for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") { if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" { if n, err := strconv.Atoi(f[1]); err == nil { return n } } } return -1 } func (m *Machine) bootTime() int64 { for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") { if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" { n, _ := strconv.ParseInt(f[1], 10, 64) return n } } return 0 } // startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot). func startOf(stat string, boot int64) (time.Time, bool) { i := strings.LastIndexByte(stat, ')') if i < 0 || boot == 0 { return time.Time{}, false } f := strings.Fields(stat[i+1:]) if len(f) < 20 { return time.Time{}, false } ticks, err := strconv.ParseInt(f[19], 10, 64) if err != nil { return time.Time{}, false } return time.Unix(boot+ticks/100, 0), true } func readTrimmed(path string) string { b, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(b)) } func exists(path string) bool { _, err := os.Stat(path) return err == nil } // Session is what a tool needs to start something on the operator's desktop. type Session struct { Display string `json:"display"` XAuthority string `json:"xauthority,omitempty"` Bus string `json:"bus,omitempty"` RuntimeDir string `json:"runtime_dir,omitempty"` From string `json:"found_in"` } // sessionHolders are the processes whose environment is the session's, best first. var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"} // session finds the account's graphical session, or ErrNoSession saying what it looked at. func (m *Machine) session() (Session, error) { entries, _ := os.ReadDir(m.path("/proc")) best, bestRank := -1, len(sessionHolders)+1 var env map[string]string var from string for _, e := range entries { pid, err := strconv.Atoi(e.Name()) if err != nil { continue } dir := m.path(filepath.Join("/proc", e.Name())) if m.uidOf(dir) != m.UID { continue } raw, err := os.ReadFile(filepath.Join(dir, "environ")) if err != nil { continue } vars := parseEnviron(raw) if vars["DISPLAY"] == "" { continue } comm := readTrimmed(filepath.Join(dir, "comm")) rank := len(sessionHolders) for i, h := range sessionHolders { if h == comm { rank = i } } if rank < bestRank || (rank == bestRank && pid > best) { best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid) } } if env == nil { return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+ "Is anyone logged in to the desktop?", ErrNoSession, m.UID) } s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"], RuntimeDir: env["XDG_RUNTIME_DIR"], From: from} if s.RuntimeDir == "" { s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID) } if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) { s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") } return s, nil } // bus is the account's session bus environment, which a logged-in account has with or without a // desktop: what a command needs to reach the user's service manager or a bus name. func (m *Machine) bus() []string { runtime := fmt.Sprintf("/run/user/%d", m.UID) return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"} } // Env is the session's variables, for a command that draws or speaks to the desktop. func (s Session) Env() []string { var env []string for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}, {"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} { if kv[1] != "" { env = append(env, kv[0]+"="+kv[1]) } } return env } func parseEnviron(raw []byte) map[string]string { env := map[string]string{} for _, kv := range bytes.Split(raw, []byte{0}) { if i := bytes.IndexByte(kv, '='); i > 0 { env[string(kv[:i])] = string(kv[i+1:]) } } return env } // detach starts a long-lived program under the account's service manager, as a transient unit that // carries the session's display. A unit left by an earlier start under the same name is stopped // first, so the fixed name means at most one. func (m *Machine) detach(s Session, unit string, argv ...string) error { _ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service") call := []string{"--user", "--collect", "--quiet", "--unit=" + unit} for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} { if kv[1] != "" { call = append(call, "--setenv="+kv[0]+"="+kv[1]) } } call = append(append(call, "--"), argv...) return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...) } // stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still // there after grace. It answers the pids that ended and those that had to be killed. func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) { var pids []int for _, c := range comms { for _, p := range m.procs(c) { if m.Kill(p.PID, syscall.SIGTERM) == nil { pids = append(pids, p.PID) } } } alive := func() []int { var left []int for _, pid := range pids { if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) { left = append(left, pid) } } return left } step := 200 * time.Millisecond for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step { m.Sleep(step) } left := alive() for _, pid := range left { if m.Kill(pid, syscall.SIGKILL) == nil { killed = append(killed, pid) } } gone := map[int]bool{} for _, pid := range left { gone[pid] = true } for _, pid := range pids { if !gone[pid] { ended = append(ended, pid) } } return ended, killed } // waitFor waits up to d for a process of the account named comm, and answers what it found. func (m *Machine) waitFor(comm string, d time.Duration) []Proc { step := 250 * time.Millisecond for waited := time.Duration(0); ; waited += step { if p := m.procs(comm); len(p) > 0 || waited >= d { return p } m.Sleep(step) } } // desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none. func desktopEntry(path string) map[string]string { raw, err := readBounded(path) if err != nil { return nil } out := map[string]string{} in := false s := bufio.NewScanner(bytes.NewReader(raw)) for s.Scan() { l := strings.TrimSpace(s.Text()) switch { case strings.HasPrefix(l, "["): in = l == "[Desktop Entry]" case in && l != "" && !strings.HasPrefix(l, "#"): if i := strings.IndexByte(l, '='); i > 0 { out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:]) } } } return out } // Autostart is what XDG autostart does with one entry: the account's file overrides the system's // of the same name, and Hidden=true (or the GNOME switch off) means it is not started. type Autostart struct { Entry string `json:"entry"` From string `json:"from"` Exec string `json:"exec,omitempty"` Starts bool `json:"starts"` Because string `json:"because,omitempty"` } // autostart resolves one XDG autostart entry by its file name, the account's directory first. func (m *Machine) autostart(name string) Autostart { a := Autostart{Entry: name} user := m.home(".config", "autostart", name) system := m.path(filepath.Join("/etc/xdg/autostart", name)) var e map[string]string switch { case exists(user): e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name)) case exists(system): e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name) default: a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart" return a } a.Exec = e["Exec"] switch { case strings.EqualFold(e["Hidden"], "true"): a.Because = "Hidden=true" case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"): a.Because = "X-GNOME-Autostart-enabled=false" case a.Exec == "": a.Because = "the entry has no Exec" default: a.Starts = true } return a } // i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named // word, in the configuration and its config.d: a second start beside an autostart entry. func (m *Machine) i3Starts(word string) []string { files := []string{m.home(".config", "i3", "config")} more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf")) files = append(files, more...) var out []string for _, f := range files { raw, err := readBounded(f) if err != nil { continue } for n, l := range strings.Split(string(raw), "\n") { t := strings.TrimSpace(l) if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") { continue } for _, w := range strings.Fields(t)[1:] { if filepath.Base(strings.Trim(w, `"'`)) == word { out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t)) break } } } } return out } // installed asks the package manager for one package's version; "" when it is not installed. func (m *Machine) installed(pkg string) (string, error) { o := m.cmd(0, nil, "pacman", "-Q", pkg) if o.Err != nil { return "", failed(o, "pacman", "-Q", pkg) } if o.Code != 0 { return "", nil } f := strings.Fields(o.Stdout) if len(f) < 2 { return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout) } return f[1], nil } // Finding is one thing a check found wrong, and what to do about it. type Finding struct { What string `json:"what"` Do string `json:"do,omitempty"` }