Owns both bars and their icons, the bar blocks as an i3 drop-in; the battery, GPU and headset blocks leave (hardware and a person's devices), the weather needs no key; the update count and the bar watchdog become module code, the watchdog started once per session. Go tools reload, blocks, block-run and themes.
424 lines
13 KiB
Go
424 lines
13 KiB
Go
// The operator's graphical session, as a tool the node's runtime runs finds it (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 — no DISPLAY, no XAUTHORITY, no session bus. A tool that
|
|
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
|
|
// the account that is part of the session (the window manager first), the same thing `loginctl` and
|
|
// a person's own shell would point at, and says where it found them.
|
|
//
|
|
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
|
|
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
|
|
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
|
|
// die with it.
|
|
//
|
|
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
|
|
// second consumer outside the desktop wants it.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Where the session is looked for. Variables so a test can point them at a fake tree.
|
|
var (
|
|
procRoot = "/proc"
|
|
runUserDir = "/run/user"
|
|
x11Sockets = "/tmp/.X11-unix"
|
|
)
|
|
|
|
// sessionHolders are the processes whose environment is the session's, best first: the window
|
|
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
|
|
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
|
|
|
|
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
|
|
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
|
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
|
|
|
|
// Session is what a tool needs to reach the operator's desktop.
|
|
type Session struct {
|
|
UID int `json:"uid"`
|
|
Display string `json:"display,omitempty"`
|
|
XAuthority string `json:"xauthority,omitempty"`
|
|
Wayland string `json:"wayland_display,omitempty"`
|
|
Bus string `json:"bus,omitempty"`
|
|
RuntimeDir string `json:"runtime_dir,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
I3Sock string `json:"i3sock,omitempty"`
|
|
// From says where the values were found: the tool's own environment, a process, or the socket.
|
|
From string `json:"from"`
|
|
}
|
|
|
|
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
|
var ErrNoSession = errors.New("no graphical session")
|
|
|
|
// ErrTimedOut is what run answers for a command ended because it ran past its time.
|
|
var ErrTimedOut = errors.New("timed out")
|
|
|
|
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
|
|
var ErrNoBus = errors.New("no session bus")
|
|
|
|
// 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
|
|
}
|
|
|
|
// findSession finds the graphical session of the account this tool runs as, or answers
|
|
// ErrNoSession with what it looked at.
|
|
func findSession() (Session, error) {
|
|
s := findEnvironment()
|
|
if s.Display == "" && s.Wayland == "" {
|
|
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
|
|
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
|
|
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
|
|
// is running.
|
|
func findBus() (Session, error) {
|
|
s := findEnvironment()
|
|
if s.Bus == "" {
|
|
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
|
|
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func findEnvironment() Session {
|
|
uid := os.Getuid()
|
|
s := Session{UID: uid}
|
|
own := map[string]string{}
|
|
for _, k := range sessionKeys {
|
|
own[k] = os.Getenv(k)
|
|
}
|
|
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
|
|
s.fill(own)
|
|
s.From = "the tool's own environment"
|
|
} else if pid, comm, env, ok := sessionProcess(uid); ok {
|
|
s.fill(env)
|
|
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
|
|
} else if display, ok := lonelyX11Socket(); ok {
|
|
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
|
|
s.Display, s.XAuthority = display, a
|
|
s.From = "the X server socket and the account's ~/.Xauthority"
|
|
}
|
|
s.fill(own)
|
|
} else {
|
|
s.fill(own)
|
|
s.From = "nothing: no session found"
|
|
}
|
|
// The bus and the runtime directory are the account's, whether or not the process named them.
|
|
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
|
|
if s.RuntimeDir == "" && exists(runtime) {
|
|
s.RuntimeDir = runtime
|
|
}
|
|
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
|
|
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Session) fill(env map[string]string) {
|
|
set := func(dst *string, key string) {
|
|
if *dst == "" {
|
|
*dst = env[key]
|
|
}
|
|
}
|
|
set(&s.Display, "DISPLAY")
|
|
set(&s.XAuthority, "XAUTHORITY")
|
|
set(&s.Wayland, "WAYLAND_DISPLAY")
|
|
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
|
|
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
|
|
set(&s.SessionID, "XDG_SESSION_ID")
|
|
set(&s.I3Sock, "I3SOCK")
|
|
}
|
|
|
|
// sessionProcess is the best process of this uid whose environment names a display.
|
|
func sessionProcess(uid int) (int, string, map[string]string, bool) {
|
|
entries, err := os.ReadDir(procRoot)
|
|
if err != nil {
|
|
return 0, "", nil, false
|
|
}
|
|
type candidate struct {
|
|
pid int
|
|
comm string
|
|
env map[string]string
|
|
rank int
|
|
}
|
|
var found []candidate
|
|
for _, e := range entries {
|
|
pid, err := strconv.Atoi(e.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
dir := filepath.Join(procRoot, e.Name())
|
|
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
|
continue
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
env := parseEnviron(raw)
|
|
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
|
|
continue
|
|
}
|
|
comm := readTrimmed(filepath.Join(dir, "comm"))
|
|
rank := len(sessionHolders)
|
|
for i, h := range sessionHolders {
|
|
if h == comm {
|
|
rank = i
|
|
break
|
|
}
|
|
}
|
|
found = append(found, candidate{pid, comm, env, rank})
|
|
}
|
|
if len(found) == 0 {
|
|
return 0, "", nil, false
|
|
}
|
|
sort.Slice(found, func(i, j int) bool {
|
|
if found[i].rank != found[j].rank {
|
|
return found[i].rank < found[j].rank
|
|
}
|
|
return found[i].pid > found[j].pid // the newer of two equals
|
|
})
|
|
best := found[0]
|
|
return best.pid, best.comm, best.env, true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func ownerOf(path string) (int, bool) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return int(st.Uid), true
|
|
}
|
|
|
|
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
|
|
func lonelyX11Socket() (string, bool) {
|
|
entries, err := os.ReadDir(x11Sockets)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
var displays []string
|
|
for _, e := range entries {
|
|
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
|
|
if _, err := strconv.Atoi(n); err == nil {
|
|
displays = append(displays, ":"+n)
|
|
}
|
|
}
|
|
}
|
|
if len(displays) != 1 {
|
|
return "", false
|
|
}
|
|
return displays[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
|
|
}
|
|
|
|
// Env is this process's environment with the session's variables in place of its own.
|
|
func (s Session) Env() []string {
|
|
drop := map[string]bool{}
|
|
for _, k := range sessionKeys {
|
|
drop[k] = true
|
|
}
|
|
var env []string
|
|
for _, kv := range os.Environ() {
|
|
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
|
|
continue
|
|
}
|
|
env = append(env, kv)
|
|
}
|
|
add := func(k, v string) {
|
|
if v != "" {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
}
|
|
add("DISPLAY", s.Display)
|
|
add("XAUTHORITY", s.XAuthority)
|
|
add("WAYLAND_DISPLAY", s.Wayland)
|
|
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
|
|
add("XDG_RUNTIME_DIR", s.RuntimeDir)
|
|
add("XDG_SESSION_ID", s.SessionID)
|
|
add("I3SOCK", s.I3Sock)
|
|
return env
|
|
}
|
|
|
|
// mostOutput bounds what a command may answer with, per stream.
|
|
const mostOutput = 256 << 10
|
|
|
|
// Result is what a command did.
|
|
type Result struct {
|
|
Stdout string `json:"stdout"`
|
|
Stderr string `json:"stderr,omitempty"`
|
|
Code int `json:"code"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
|
|
// run runs a command in the session's environment, its input given, ended with everything it
|
|
// started after timeout. A command that is not installed is an error naming it; one that exits
|
|
// non-zero is a Result with its code, for the caller to judge.
|
|
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
|
|
path, err := exec.LookPath(name)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
|
|
}
|
|
cmd := exec.Command(path, args...)
|
|
cmd.Env = s.Env()
|
|
if home := operatorHome(); exists(home) {
|
|
cmd.Dir = home
|
|
}
|
|
if stdin != "" {
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
}
|
|
var out, errOut capped
|
|
cmd.Stdout, cmd.Stderr = &out, &errOut
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
if err := cmd.Start(); err != nil {
|
|
return Result{}, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
select {
|
|
case err = <-done:
|
|
case <-time.After(timeout):
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
<-done
|
|
return Result{Stdout: out.String(), Stderr: errOut.String()},
|
|
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
|
|
}
|
|
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
|
|
var exit *exec.ExitError
|
|
if errors.As(err, &exit) {
|
|
r.Code = exit.ExitCode()
|
|
} else if err != nil {
|
|
return r, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// detach starts a long-lived program under the account's own service manager, as a transient unit
|
|
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
|
|
// running under the same name is stopped first, so a fixed name means "at most one".
|
|
func (s Session) detach(unit string, args ...string) error {
|
|
if s.RuntimeDir == "" {
|
|
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
|
|
"cannot be reached", ErrNoBus)
|
|
}
|
|
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
|
|
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
|
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
|
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
|
|
if kv[1] != "" {
|
|
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
|
}
|
|
}
|
|
call = append(call, "--")
|
|
call = append(call, args...)
|
|
r, err := s.run(10*time.Second, "", "systemd-run", call...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.Code != 0 {
|
|
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// uniqueUnit is a transient unit name that will not collide with an earlier one.
|
|
func uniqueUnit(prefix string) string {
|
|
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
|
}
|
|
|
|
type capped struct {
|
|
bytes.Buffer
|
|
cut bool
|
|
}
|
|
|
|
func (c *capped) Write(p []byte) (int, error) {
|
|
if room := mostOutput - c.Len(); room < len(p) {
|
|
if room > 0 {
|
|
c.Buffer.Write(p[:room])
|
|
}
|
|
c.cut = true
|
|
return len(p), nil
|
|
}
|
|
return c.Buffer.Write(p)
|
|
}
|
|
|
|
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
|
|
func processesOf(comm string) []int {
|
|
entries, err := os.ReadDir(procRoot)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
uid := os.Getuid()
|
|
var pids []int
|
|
for _, e := range entries {
|
|
pid, err := strconv.Atoi(e.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
dir := filepath.Join(procRoot, e.Name())
|
|
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
|
continue
|
|
}
|
|
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
|
pids = append(pids, pid)
|
|
}
|
|
}
|
|
sort.Ints(pids)
|
|
return pids
|
|
}
|
|
|
|
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
|
func signalAll(comm string, sig syscall.Signal) []int {
|
|
var reached []int
|
|
for _, pid := range processesOf(comm) {
|
|
if syscall.Kill(pid, sig) == nil {
|
|
reached = append(reached, pid)
|
|
}
|
|
}
|
|
return reached
|
|
}
|