openrazer (the official driver, daemon and library), polychromatic (the AUR tray, kept as found; its i3 line moves out of the i3 module into its own node-display-session contribution), forticlient (the AUR VPN client: its service declared, its configuration never read) and nm-applet (the desktop half of NetworkManager, apart from the server-side module). The openrazer daemon fails on both workstations because the account is not in the openrazer group; openrazer_check names it and the README carries the one-off step, since the account's user resource is zsh's. desktop.go learns to tell a program from another sharing its 15-character command name, so polychromatic's tools never count or end themselves, and a copies test holds the six carriers to one text.
602 lines
18 KiB
Go
602 lines
18 KiB
Go
package main
|
|
|
|
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
|
// holds them to one text): 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
|
|
}
|
|
|
|
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
|
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
|
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
|
// The program is the first word of the command line, or the second for a script run by its
|
|
// interpreter. An empty word keeps every process named comm.
|
|
func (m *Machine) procsOf(comm, word string) []Proc {
|
|
var out []Proc
|
|
for _, p := range m.procs(comm) {
|
|
f := strings.Fields(p.Command)
|
|
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
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 ps []Proc
|
|
for _, c := range comms {
|
|
ps = append(ps, m.procs(c)...)
|
|
}
|
|
return m.stopProcs(grace, ps)
|
|
}
|
|
|
|
// stopProcs ends the processes given, as stop does.
|
|
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
|
var pids []int
|
|
for _, p := range ps {
|
|
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 { return m.waitForOf(comm, "", d) }
|
|
|
|
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
|
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
|
step := 250 * time.Millisecond
|
|
for waited := time.Duration(0); ; waited += step {
|
|
if p := m.procsOf(comm, word); 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"`
|
|
}
|