Add slack and jetbrains-toolbox: one start each, the apps kept as found

Slack comes from the AUR and Toolbox from JetBrains' self-updating tarball,
so neither is declared: the host installs official packages only, and a
pinned archive would fight Toolbox's own updates. Slack's start and the
operator's i3 window rules become one node-display-session contribution,
because Slack's own launch-on-login is a symlink in ~/.config/autostart.
Toolbox keeps its own autostart entry as its one start. The shared
desktop.go header now names all four bundles.
This commit is contained in:
jochen
2026-10-05 15:05:55 +02:00
parent 4f3e52b47a
commit 75f25fca21
28 changed files with 4054 additions and 6 deletions
+97
View File
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
+575
View File
@@ -0,0 +1,575 @@
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"`
}
@@ -0,0 +1,202 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client, blueman, slack and jetbrains-toolbox bundles.
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
+84
View File
@@ -0,0 +1,84 @@
// The slack module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Slack desktop app
// in the operator's session, served by the node's runtime as the operator account. The module holds
// no seat, so every tool is its own.
//
// Slack's sessions are the operator's: the tools never read its cookies, local storage or the key in
// Local State, and no answer carries a token, a cookie, a message, or the name of a person, channel
// or workspace. Accounts and workspaces are counted, never named.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "slack_status",
Description: "Slack: whether it runs (the main process's pid, since, and the scope it runs in; how " +
"many helper processes), the installed version and whether it is outside the official " +
"repositories, where its output goes, whether its icon sits in a tray, who shows its " +
"notifications, its launch and tray switches and notification method, how many accounts and " +
"workspaces it is signed in to (counted, never named), and what starts it at login. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "slack_log",
Description: "The last lines of Slack's own log: source browser (the main process, the default) or " +
"webapp (the web app's console). With problems, only error and warn entries. Tokens, cookies, " +
"message and notification text, and the names of people, channels and workspaces are " +
"replaced by <hidden>. (r)",
Input: map[string]any{
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
"source": map[string]any{"type": "string", "enum": []string{"browser", "webapp"}, "description": "browser (default) or webapp"},
"problems": map[string]any{"type": "boolean", "description": "only error and warn entries (default false)"},
},
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "lines", 100, 1, 2000)
if err != nil {
return nil, err
}
source, err := text(args, "source", false)
if err != nil {
return nil, err
}
if source == "" {
source = "browser"
}
problems, err := flag(args, "problems", false)
if err != nil {
return nil, err
}
return machine.Log(source, n, problems)
},
},
{
Name: "slack_restart",
Description: "End Slack (asked first, then forced after 8 s) and start it again to the tray in the " +
"operator's desktop session, under the account's service manager, its output in the journal. " +
"Answers the pids ended and the new main process. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "slack_check",
Description: "Check what the module promises: Slack is installed; it has exactly one start (the " +
"module's line in i3's configuration; no XDG autostart entry, no other exec); the window rules " +
"are the module's, not repeated in a file of the operator's; one Slack runs in the session with " +
"its output read (a pipe nobody reads makes its writes fail with EPIPE); its icon is in a tray; " +
"a notifier owns the session bus's notification name. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// slack's shape (novox/hq ADR 0208, ADR 0210, ADR 0212, ADR 0182): no package (Slack comes from the
// AUR, and the host installs from the official repositories only), no seat, no file; the X display on
// its own machine; one contribution to node-display-session that is both Slack's one start and its
// window rules; and the Go bundle serving exactly the listed slack_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestItInstallsNothingAndOwnsNoFile(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "slack" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("%+v", m)
}
// slack-desktop is the AUR's: a package resource would ask the host for what it cannot install.
if m.Resources != nil || m.Capabilities != nil {
t.Fatalf("no package, no file: %v %v", m.Resources, m.Capabilities)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil {
t.Fatal("it holds no seat, sets no environment and adds no session code")
}
// Slack's own setting writes ~/.config/autostart/slack.desktop (a link): never the module's.
for _, never := range []string{".config/autostart", ".config/Slack", "root-state", "Cookies"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestTheOneContributionIsTheStartAndTheWindowRules(t *testing.T) {
m, _ := readManifest(t)
if len(m.Contributions) != 1 {
t.Fatalf("%+v", m.Contributions)
}
c := m.Contributions[0]
if c.Seat != "node-display-session" || c.Kind != "config" || !strings.HasSuffix(c.Content, "\n") {
t.Fatalf("%+v", c)
}
var execs, rules []string
for _, l := range strings.Split(c.Content, "\n") {
l = strings.TrimSpace(l)
switch {
case strings.HasPrefix(l, "exec"):
execs = append(execs, l)
case strings.HasPrefix(l, "for_window"):
rules = append(rules, l)
case l == "" || strings.HasPrefix(l, "#"):
default:
t.Errorf("a line that is neither the start nor a rule: %q", l)
}
}
want := "exec --no-startup-id " + slackBin + " " + strings.Join(startArgs, " ")
if len(execs) != 1 || execs[0] != want {
t.Fatalf("one start, the package entry's arguments, as slack_restart starts it: %q", execs)
}
// The operator's rules of 2026-08 (50-slack.conf), verified then against Slack's windows.
wantRules := []string{
`for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3`,
`for_window [class="(?i)^slack$" window_role="browser-window"] floating disable`,
`for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2`,
}
if !reflect.DeepEqual(rules, wantRules) {
t.Fatalf("%q", rules)
}
for _, r := range rules {
if !strings.HasPrefix(r, ruleMark) {
t.Errorf("slack_check recognises the module's rules by %q: %s", ruleMark, r)
}
}
// The placed lines start Slack once: the fake i3 configuration below holds exactly this content.
f := newFake(t)
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+c.Content)
if s := f.starts(); len(s) != 1 || !s[0].Mine {
t.Fatalf("%+v", s)
}
if n, _ := f.windowRules(); n != 3 {
t.Fatalf("%d rules", n)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "slack_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed slack_ and described", tool.Name)
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/slack-tools" || b["binary"] != "slack-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
+678
View File
@@ -0,0 +1,678 @@
package main
// Slack as the tools see it. Slack is one Electron process tree: the main process (comm slack, no
// --type=) is both the window and the tray icon, and its helpers (--type=zygote, gpu-process,
// renderer, utility) are its children. The tools read only:
// - the account's processes, and of Slack's main process where its output goes (fd 1 and 2);
// - Slack's settings file, ~/.config/Slack/storage/root-state.json, of which only switches are
// answered (launch on login, start hidden, run from the tray, notification method, hardware
// acceleration), and local-settings.json for the Electron version;
// - Slack's own log, ~/.config/Slack/logs/default/browser*.log, from which the signed-in accounts and
// workspaces are counted (counted only: no name, no id) and which slack_log answers masked;
// - the X window tree (xwininfo), for the tray icon; the session bus, for who shows notifications.
//
// Never read: Cookies, Local Storage, IndexedDB, the os_crypt key in Local State. Those are the
// sessions' credentials, and no tool needs them.
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// Where Slack keeps things, under the operator's home, and how it is started.
const (
slackComm = "slack"
slackBin = "/usr/bin/slack"
packageFor = "slack-desktop"
restartAs = "mesh-slack"
entryName = "slack.desktop"
stateFile = ".config/Slack/storage/root-state.json"
localFile = ".config/Slack/local-settings.json"
logDir = ".config/Slack/logs/default"
i3Main = ".config/i3/config"
i3DropIns = ".config/i3/config.d"
notifyName = "org.freedesktop.Notifications"
)
// startArgs are the package's own desktop entry's arguments: GTK 3, and -s, started to the tray.
var startArgs = []string{"--gtk-version=3", "-s"}
// ruleMark is how the module's window rules are recognised wherever they are found.
const ruleMark = `for_window [class="(?i)^slack$"`
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
var readLink = os.Readlink
// isLink says whether a path is a symbolic link itself; tests replace it for the same reason.
var isLink = func(p string) bool {
fi, err := os.Lstat(p)
return err == nil && fi.Mode()&os.ModeSymlink != 0
}
// mainProcs are Slack's main processes: comm slack without --type=.
func (m *Machine) mainProcs() []Proc {
var out []Proc
for _, p := range m.procs(slackComm) {
if !strings.Contains(p.Command, "--type=") {
out = append(out, p)
}
}
return out
}
// Stream is where a process's standard output or error goes.
type Stream struct {
FD int `json:"fd"`
Goes string `json:"goes"`
// Dead is a pipe no process of the account reads: a write there fails with EPIPE.
Dead bool `json:"dead,omitempty"`
}
// streams says where fd 1 and 2 of a process go.
func (m *Machine) streams(pid int) []Stream {
var out []Stream
for _, fd := range []int{1, 2} {
target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd)))
s := Stream{FD: fd}
switch {
case err != nil:
s.Goes = "unreadable"
case target == "/dev/null":
s.Goes = "discarded (/dev/null)"
case strings.HasPrefix(target, "socket:"):
s.Goes = "a socket (the journal, when the session's output is sent there)"
case strings.HasPrefix(target, "pipe:"):
if r := m.pipeReaders(target, pid); len(r) > 0 {
s.Goes = "a pipe read by " + strings.Join(r, ", ")
} else {
s.Goes, s.Dead = "a pipe nobody reads", true
}
default:
s.Goes = m.tilde(target)
}
out = append(out, s)
}
return out
}
// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo).
func (m *Machine) pipeReaders(pipe string, except int) []string {
entries, _ := os.ReadDir(m.path("/proc"))
seen := map[string]bool{}
var out []string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == except {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
fds, _ := os.ReadDir(filepath.Join(dir, "fd"))
for _, fd := range fds {
if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe {
continue
}
if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) {
name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid)
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
}
sort.Strings(out)
return out
}
// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading.
func readOnly(fdinfo string) bool {
for _, l := range strings.Split(fdinfo, "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" {
n, err := strconv.ParseInt(f[1], 8, 64)
return err == nil && n&3 == 0
}
}
return false
}
// Settings are the switches of Slack's settings the tools answer. Every other key stays unread.
type Settings struct {
Found bool `json:"found"`
LaunchOnLogin *bool `json:"launch_on_login,omitempty"`
HideOnStartup *bool `json:"hide_on_startup,omitempty"`
RunFromTray *bool `json:"run_from_tray,omitempty"`
NotificationMethod string `json:"notification_method,omitempty"`
HardwareAccel *bool `json:"hardware_acceleration,omitempty"`
Electron string `json:"electron,omitempty"`
}
func (m *Machine) settings() Settings {
var s Settings
raw, err := readBounded(m.home(stateFile))
if err == nil {
var doc struct {
Settings struct {
LaunchOnStartup *bool `json:"launchOnStartup"`
HideOnStartup *bool `json:"hideOnStartup"`
RunFromTray *bool `json:"runFromTray"`
NotificationMethod *string `json:"notificationMethod"`
UseHwAcceleration *bool `json:"useHwAcceleration"`
} `json:"settings"`
}
if json.Unmarshal(raw, &doc) == nil {
s.Found = true
s.LaunchOnLogin, s.HideOnStartup, s.RunFromTray = doc.Settings.LaunchOnStartup, doc.Settings.HideOnStartup, doc.Settings.RunFromTray
s.HardwareAccel = doc.Settings.UseHwAcceleration
s.NotificationMethod = "Slack's default"
if doc.Settings.NotificationMethod != nil && *doc.Settings.NotificationMethod != "" {
s.NotificationMethod = *doc.Settings.NotificationMethod
}
}
}
if raw, err := readBounded(m.home(localFile)); err == nil {
var doc struct {
Electron string `json:"lastElectronVersionLaunched"`
}
if json.Unmarshal(raw, &doc) == nil {
s.Electron = doc.Electron
}
}
return s
}
// Workspaces is how many accounts Slack is signed in to, and how many workspaces they open: counted
// from the last report Slack logged of each account, since its last start. Never a name or an id.
type Workspaces struct {
Accounts int `json:"accounts"`
Workspaces int `json:"workspaces"`
AsOf string `json:"as_of,omitempty"`
Note string `json:"note,omitempty"`
}
var logStamp = regexp.MustCompile(`^\[(\d\d/\d\d/\d\d, \d\d:\d\d:\d\d:\d+)\] (\w+): `)
// browserLogs are Slack's main-process logs, oldest first (browser2.log, browser1.log, browser.log).
func (m *Machine) browserLogs() []string {
files, _ := filepath.Glob(m.home(logDir, "browser*.log"))
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
if a == nil || b == nil {
return files[i] < files[j]
}
return a.ModTime().Before(b.ModTime())
})
if len(files) > 3 {
files = files[len(files)-3:]
}
return files
}
func (m *Machine) workspaces() Workspaces {
var lines []string
for _, f := range m.browserLogs() {
l, err := tailLines(f, 1<<20)
if err == nil {
lines = append(lines, l...)
}
}
start := 0
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "] info: Store: INITIALIZE") {
start = i
break
}
}
accounts := map[string][]string{}
var asOf string
for i := start; i < len(lines); i++ {
if !strings.Contains(lines[i], "Store: STORE_USER_WORKSPACES") {
continue
}
var block strings.Builder
j := i + 1
for ; j < len(lines) && !logStamp.MatchString(lines[j]); j++ {
block.WriteString(lines[j])
}
var report struct {
User string `json:"userTeamId"`
Workspaces []string `json:"workspaceIds"`
}
if json.Unmarshal([]byte(block.String()), &report) == nil && report.User != "" {
accounts[report.User] = report.Workspaces
if mm := logStamp.FindStringSubmatch(lines[i]); mm != nil {
asOf = mm[1]
}
}
i = j - 1
}
w := Workspaces{Accounts: len(accounts), AsOf: asOf}
all := map[string]bool{}
for _, ws := range accounts {
for _, id := range ws {
all[id] = true
}
}
w.Workspaces = len(all)
if len(accounts) == 0 {
w.Note = "Slack has logged no account since its last start: signed out, or not started yet"
}
return w
}
// Tray is whether Slack's icon sits in a tray: a window of class "Slack" reparented into another
// program's window (i3bar's tray), found in the X window tree.
type Tray struct {
Present bool `json:"present"`
In string `json:"in,omitempty"`
Unknown string `json:"unknown,omitempty"`
}
var treeLine = regexp.MustCompile(`^(\s*)0x[0-9a-f]+ (?:"[^"]*"|\(has no name\)): \("([^"]*)" "([^"]*)"\)`)
// trayIn reads `xwininfo -root -tree`: each window is indented under its parent.
func trayIn(tree string) (bool, string) {
type frame struct {
indent int
class string
}
var stack []frame
for _, l := range strings.Split(tree, "\n") {
trimmed := strings.TrimLeft(l, " ")
if !strings.HasPrefix(trimmed, "0x") {
continue
}
indent := len(l) - len(trimmed)
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
stack = stack[:len(stack)-1]
}
mm := treeLine.FindStringSubmatch(l)
class := ""
if mm != nil {
class = mm[3]
if mm[2] == "slack" && class == "Slack" {
for k := len(stack) - 1; k >= 0; k-- {
if c := stack[k].class; c != "" && !strings.EqualFold(c, "slack") {
return true, c
}
}
}
}
stack = append(stack, frame{indent, class})
}
return false, ""
}
func (m *Machine) tray(s Session) Tray {
o := m.cmd(5*time.Second, s.Env(), "xwininfo", "-root", "-tree")
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
if o.Err == ErrNotInstalled {
return Tray{Unknown: "xwininfo (package xorg-xwininfo) is not installed, so the window tree cannot be read"}
}
return Tray{Unknown: err.Error()}
}
ok, in := trayIn(o.Stdout)
return Tray{Present: ok, In: in}
}
// Notifier is who shows Slack's notifications: the owner of the session bus's notification name.
type Notifier struct {
Owner string `json:"owner,omitempty"`
PID int `json:"pid,omitempty"`
// Activatable is a notifier that is not running but that the bus starts on the first notification.
Activatable bool `json:"activatable,omitempty"`
Unknown string `json:"unknown,omitempty"`
}
func (m *Machine) notifier() Notifier {
o := m.cmd(5*time.Second, m.bus(), "busctl", "--user", "list", "--no-legend", "--no-pager")
if err := failed(o, "busctl", "--user", "list"); err != nil {
return Notifier{Unknown: err.Error()}
}
for _, l := range strings.Split(o.Stdout, "\n") {
f := strings.Fields(l)
if len(f) < 3 || f[0] != notifyName {
continue
}
if pid, err := strconv.Atoi(f[1]); err == nil {
return Notifier{Owner: f[2], PID: pid}
}
return Notifier{Activatable: true}
}
return Notifier{}
}
// Status is what slack_status answers.
type Status struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_the_official_repositories,omitempty"`
Running []Proc `json:"running"`
Helpers int `json:"helper_processes"`
Output []Stream `json:"output,omitempty"`
Tray *Tray `json:"tray,omitempty"`
Notifier Notifier `json:"notifications_to"`
Settings Settings `json:"settings"`
Workspaces Workspaces `json:"signed_in"`
Starts []string `json:"starts"`
}
// foreign says whether pacman counts the package as foreign: installed, but in no sync repository.
func (m *Machine) foreign(pkg string) bool {
o := m.cmd(0, nil, "pacman", "-Qmq", pkg)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg
}
func (m *Machine) Status() (Status, error) {
s := Status{Running: m.mainProcs(), Settings: m.settings(), Workspaces: m.workspaces(), Starts: []string{}}
if s.Running == nil {
s.Running = []Proc{}
}
s.Helpers = len(m.procs(slackComm)) - len(s.Running)
if v, err := m.installed(packageFor); err == nil && v != "" {
s.Installed, s.Foreign = v, m.foreign(packageFor)
}
if len(s.Running) > 0 {
s.Output = m.streams(s.Running[0].PID)
if sess, err := m.session(); err == nil {
t := m.tray(sess)
s.Tray = &t
}
}
s.Notifier = m.notifier()
for _, st := range m.starts() {
s.Starts = append(s.Starts, st.What)
}
return s, nil
}
// Start is one thing that starts Slack at login; Mine is the module's own.
type Start struct {
What string
Mine bool
Do string
}
// starts are every start of Slack at login the tools can see: window-manager exec lines (the module's
// in i3's main file, any other in the operator's drop-ins) and XDG autostart entries.
func (m *Machine) starts() []Start {
var out []Start
main := m.tilde(filepath.Join(m.Home, i3Main)) + ":"
for _, l := range m.i3Starts(slackComm) {
if strings.HasPrefix(l, main) {
out = append(out, Start{What: "window manager (this module's line): " + l, Mine: true})
} else {
out = append(out, Start{What: "window manager: " + l, Do: "remove the line: the module's line in i3's configuration is Slack's one start"})
}
}
if a := m.autostart(entryName); a.Starts {
st := Start{What: "XDG autostart: " + a.From + " (" + a.Exec + ")"}
user := m.home(".config", "autostart", entryName)
if isLink(user) {
st.Do = "untick 'Launch app on login' in Slack's preferences: Slack made this link for that setting and removes it when unticked"
} else if strings.HasPrefix(a.From, "~") {
st.Do = "delete " + a.From + " (the predecessor's): Slack then reads 'Launch app on login' as off, and the module's line is the one start"
} else {
st.Do = "hide it with an entry of the account (Hidden=true): the module's line is Slack's one start"
}
out = append(out, st)
}
return out
}
// LogAnswer is what slack_log answers.
type LogAnswer struct {
Source string `json:"source"`
Files []string `json:"files"`
Lines []string `json:"lines"`
Cut bool `json:"cut,omitempty"`
Note string `json:"note,omitempty"`
}
const mostAnswer = 256 << 10
// The masks. A token or a cookie is hidden wherever it appears; a notification's or a message's text,
// and the names of people, channels and workspaces, are hidden by their key.
var (
slackToken = regexp.MustCompile(`xox[a-z]-[A-Za-z0-9-]+|xapp-[A-Za-z0-9-]+`)
credential = regexp.MustCompile(`(?i)\b(authorization|bearer|token|password|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:bearer\s+)?[^\s"',;&]+`)
cookieD = regexp.MustCompile(`\bd=[A-Za-z0-9%/+=._-]{20,}`)
privateKey = regexp.MustCompile(`"(title|subtitle|content|body|text|authorName|userName|channelName|workspaceName|teamName|name|email|realName|displayName)": "(?:[^"\\]|\\.)*"`)
)
func mask(s string) string {
s = slackToken.ReplaceAllString(s, "<hidden>")
s = cookieD.ReplaceAllString(s, "d=<hidden>")
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
return privateKey.ReplaceAllString(s, `"${1}": "<hidden>"`)
}
// Log answers the last n lines of Slack's main-process log (source browser) or of its web app's
// console log (source webapp), masked; problems keeps the error and warning entries.
func (m *Machine) Log(source string, n int, problems bool) (LogAnswer, error) {
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
var files []string
switch source {
case "browser":
files = m.browserLogs()
case "webapp":
files, _ = filepath.Glob(m.home(logDir, "webapp-console*.log"))
sort.Slice(files, func(i, j int) bool {
a, _ := os.Stat(files[i])
b, _ := os.Stat(files[j])
return a != nil && b != nil && a.ModTime().Before(b.ModTime())
})
default:
return a, fmt.Errorf("source is browser or webapp, not %q", source)
}
if len(files) == 0 {
a.Note = "Slack keeps no such log in ~/" + logDir
return a, nil
}
var got []string
for i := len(files) - 1; i >= 0 && len(got) < n && len(a.Files) < 3; i-- {
lines, err := tailLines(files[i], 1<<20)
if err != nil {
return a, err
}
if problems {
var keep []string
for _, l := range lines {
if mm := logStamp.FindStringSubmatch(l); mm != nil && (mm[2] == "error" || mm[2] == "warn") {
keep = append(keep, l)
}
}
lines = keep
}
if len(lines) > n-len(got) {
lines = lines[len(lines)-(n-len(got)):]
}
got = append(lines, got...)
a.Files = append(a.Files, m.tilde(strings.TrimPrefix(files[i], m.Root)))
}
size := 0
for i := len(got) - 1; i >= 0; i-- {
l := mask(got[i])
if size+len(l) > mostAnswer {
a.Cut = true
got = got[i+1:]
break
}
size += len(l) + 1
got[i] = l
}
if got == nil {
got = []string{}
}
a.Lines = got
return a, nil
}
// tailLines answers the last n lines of a file, read from its end to at most MostRead.
func tailLines(path string, n int) ([]string, error) {
h, err := os.Open(path)
if err != nil {
return nil, err
}
defer h.Close()
if st, err := h.Stat(); err == nil && st.Size() > MostRead {
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
return nil, err
}
}
var all []string
s := bufio.NewScanner(io.LimitReader(h, MostRead))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
all = append(all, string(bytes.TrimRight(s.Bytes(), "\r")))
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, s.Err()
}
// RestartAnswer is what slack_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends Slack (the main process and its helpers) and starts it again to the tray, in the
// operator's session, under the account's service manager: its output then goes to the journal.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(8*time.Second, slackComm)
if err := m.detach(s, restartAs, append([]string{slackBin}, startArgs...)...); err != nil {
return a, err
}
m.waitFor(slackComm, 6*time.Second)
a.Running = m.mainProcs()
if a.Running == nil {
a.Running = []Proc{}
}
if len(a.Running) == 0 {
return a, fmt.Errorf("Slack was started as %s but no main process appeared within 6 s: "+
"see `journalctl --user -u %s`", a.Unit, a.Unit)
}
return a, nil
}
// CheckAnswer is what slack_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises: Slack installed, one start (the module's line), the window
// rules once, one Slack running with its output read, its icon in the tray, and a notifier.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the host installs packages from the official repositories only")
}
mine := 0
for _, st := range m.starts() {
a.Starts = append(a.Starts, st.What)
if st.Mine {
mine++
} else {
add("a second start: "+st.What, st.Do)
}
}
if mine == 0 {
add("i3's configuration has not got the module's start of Slack", "assign the i3 module, which places this module's lines, and push the node")
} else if mine > 1 {
add(fmt.Sprintf("i3's configuration starts Slack %d times", mine), "push the node: the module's lines are placed once")
}
rules, operators := m.windowRules()
if rules == 0 {
add("i3's configuration has not got the module's window rules for Slack", "assign the i3 module and push the node")
}
for _, f := range operators {
add("the operator's "+f+" repeats Slack's window rules, which are this module's now", "delete "+f+": i3 reads it after the module's lines")
}
running := m.mainProcs()
sess, serr := m.session()
if serr == nil {
switch {
case len(running) == 0:
add("Slack does not run in the desktop session", "slack_restart")
case len(running) > 1:
add(fmt.Sprintf("%d Slack main processes run", len(running)), "slack_restart ends them all and starts one")
}
}
if len(running) > 0 {
for _, st := range m.streams(running[0].PID) {
if st.Dead {
add(fmt.Sprintf("Slack's fd %d is a pipe nobody reads (a session started before its output went to the journal): "+
"its logger silences the EPIPE it gets there, any other write it makes fails", st.FD),
"slack_restart: its output then goes to the journal. Every later login sends the session's output there (the i3 module's login entry)")
}
}
if serr == nil {
if t := m.tray(sess); !t.Present && t.Unknown == "" {
add("Slack's icon is in no tray: closing its window leaves it unreachable", "give the bar a tray (i3bar's tray_output), then slack_restart")
}
}
}
if n := m.notifier(); n.Unknown == "" && n.Owner == "" && !n.Activatable {
add("nobody shows notifications on the session bus ("+notifyName+"): Slack's go nowhere", "assign dunst, the node's notifier")
}
a.OK = len(a.Findings) == 0
return a, nil
}
// windowRules counts the module's rule lines in i3's main file, and names the operator's drop-ins that
// carry Slack's rules too.
func (m *Machine) windowRules() (int, []string) {
n := 0
if raw, err := readBounded(m.home(i3Main)); err == nil {
for _, l := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(strings.TrimSpace(l), ruleMark) {
n++
}
}
}
var files []string
more, _ := filepath.Glob(m.home(i3DropIns, "*.conf"))
for _, f := range more {
raw, err := readBounded(f)
if err != nil {
continue
}
for _, l := range strings.Split(string(raw), "\n") {
t := strings.ToLower(strings.TrimSpace(l))
if strings.HasPrefix(t, "for_window") && strings.Contains(t, "slack") {
files = append(files, m.tilde(strings.TrimPrefix(f, m.Root)))
break
}
}
}
return n, files
}
+314
View File
@@ -0,0 +1,314 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// Slack's settings as Slack writes them, with keys no answer may carry.
const rootState = `{"appTeams":{"selectedTeamId":null,"teamsByIndex":[]},
"settings":{"launchOnStartup":true,"hideOnStartup":true,"runFromTray":true,"notificationMethod":null,
"useHwAcceleration":true,"PrefSSBFileDownloadPath":"/home/operator/Downloads","signInMethod":"secret-sso"}}`
// A main-process log across a restart: the accounts before it are not counted.
const browserLog = `[10/03/26, 09:00:00:001] info: Store: INITIALIZE
[10/03/26, 09:00:05:001] info: Store: STORE_USER_WORKSPACES
{
"userTeamId": "TOLD0000001",
"workspaceIds": [
"TOLD0000001",
"TOLD0000002"
]
}
[10/04/26, 16:26:20:100] info: Store: INITIALIZE
[10/04/26, 16:26:30:100] info: Store: STORE_USER_WORKSPACES
{
"userTeamId": "TAAAA000001",
"workspaceIds": [
"TAAAA000001"
]
}
[10/04/26, 16:26:31:100] warn: Request failed with token xoxc-1234-5678-abcdef and cookie d=xoxd-AbCdEfGhIjKlMnOpQrStUv%2Fwx
[10/04/26, 16:27:00:100] info: Store: NEW_NOTIFICATION
{
"title": "A private message",
"workspaceName": "Secret Workspace",
"teamId": "TAAAA000001"
}
[10/04/26, 23:41:06:340] info: Store: STORE_USER_WORKSPACES
{
"userTeamId": "TBBBB000001",
"workspaceIds": [
"TBBBB000001",
"TAAAA000001"
]
}
[10/04/26, 23:41:07:000] error: Authorization: Bearer abc.def failed
`
// xwininfo -root -tree as the laptop answered it: the chat window under i3's frame, the tray icon in
// i3bar, two helper windows at the top.
const tree = `xwininfo: Window id: 0x3d7 (the root window) (has no name)
Root window id: 0x3d7 (the root window) (has no name)
Parent window id: 0x0 (none)
12 children:
0x28000d1 "slack": ("slack" "Slack") 200x200+0+0 +0+0
0x1400003 (has no name): ("i3-frame" "i3-frame") 1440x1774+0+26 +0+26
1 child:
0x2600004 "Slack | general": ("slack" "slack") 1436x1744+2+2 +2+28
0x1002168 (has no name): () 1x1+0+0 +0+0
0x1002169 "i3bar for output eDP-1": ("bar-1" "i3bar") 2880x26+0+0 +0+0
1 child:
0x2800029 "slack": ("slack" "Slack") 22x22+2832+2 +2832+2
0x2800001 "slack": ("slack" "Slack") 10x10+10+10 +10+10
`
// newSlack is a machine with Slack installed from the AUR, running from a session whose output pipe
// nobody reads, its icon in i3bar's tray, dunst on the bus.
func newSlack(t *testing.T) (*fake, map[string]string) {
f := newFake(t)
links := map[string]string{}
readLink = func(p string) (string, error) {
rel := strings.TrimPrefix(p, f.Root)
if l, ok := links[rel]; ok {
return l, nil
}
return "", errors.New("no such link")
}
t.Cleanup(func() { readLink = os.Readlink })
f.desktopSession()
f.write(testHome+"/"+stateFile, rootState)
f.write(testHome+"/"+localFile, `{"lastElectronVersionLaunched":"43.4.0","electronFeatureOverrides":["x"]}`)
f.write(testHome+"/"+logDir+"/browser.log", browserLog)
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+contribution(t))
f.proc(3867, 1000, slackComm, []string{"/usr/lib/slack/slack", "--gtk-version=3", "-s"}, "user@1000.service/app.slice/app-slack-3867.scope")
f.proc(3945, 1000, slackComm, []string{"/usr/lib/slack/slack", "--type=zygote"}, "session-c1.scope")
links["/proc/3867/fd/1"] = "pipe:[36802]"
links["/proc/3867/fd/2"] = "/dev/null"
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman" && args[0] == "-Q":
return Output{Stdout: "slack-desktop 4.51.191-1\n"}
case name == "pacman" && args[0] == "-Qmq":
return Output{Stdout: "slack-desktop\n"}
case name == "xwininfo":
return Output{Stdout: tree}
case name == "busctl":
return Output{Stdout: ":1.19 3923 dunst operator :1.19 user@1000.service - -\n" +
"org.freedesktop.Notifications 3923 dunst operator :1.19 user@1000.service - -\n"}
}
return Output{}
}
return f, links
}
func contribution(t *testing.T) string {
m, _ := readManifest(t)
return m.Contributions[0].Content
}
func noSecrets(t *testing.T, v any) string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, never := range []string{"xoxc", "xoxd", "AbCdEf", "abc.def", "private message", "Secret Workspace", "TAAAA", "TBBBB",
"secret-sso", "/home/operator"} {
if strings.Contains(s, never) {
t.Errorf("the answer carries %q: %s", never, s)
}
}
return s
}
func TestStatusCountsWorkspacesSinceTheLastStartAndNamesNone(t *testing.T) {
f, _ := newSlack(t)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
noSecrets(t, s)
if s.Installed != "4.51.191-1" || !s.Foreign || len(s.Running) != 1 || s.Running[0].PID != 3867 || s.Helpers != 1 {
t.Fatalf("%+v", s)
}
// Two accounts since the start of 10/04 (the earlier start's account is not counted), and the
// workspaces they open together: TBBBB's two, one shared with TAAAA.
if s.Workspaces.Accounts != 2 || s.Workspaces.Workspaces != 2 || s.Workspaces.AsOf != "10/04/26, 23:41:06:340" {
t.Fatalf("%+v", s.Workspaces)
}
st := s.Settings
if !st.Found || !*st.LaunchOnLogin || !*st.HideOnStartup || !*st.RunFromTray || st.NotificationMethod != "Slack's default" || st.Electron != "43.4.0" {
t.Fatalf("%+v", st)
}
if s.Tray == nil || !s.Tray.Present || s.Tray.In != "i3bar" {
t.Fatalf("%+v", s.Tray)
}
if s.Notifier.Owner != "dunst" || s.Notifier.PID != 3923 {
t.Fatalf("%+v", s.Notifier)
}
if len(s.Output) != 2 || !s.Output[0].Dead || s.Output[1].Goes != "discarded (/dev/null)" {
t.Fatalf("%+v", s.Output)
}
if len(s.Starts) != 1 || !strings.Contains(s.Starts[0], "this module's line") {
t.Fatalf("%q", s.Starts)
}
}
func TestAPipeWithAReaderIsLiveAndTheJournalIsASocket(t *testing.T) {
f, links := newSlack(t)
f.proc(500, 1000, "systemd-cat", []string{"systemd-cat", "-t", "x-session"}, "session-c1.scope")
links["/proc/500/fd/0"] = "pipe:[36802]"
f.write("/proc/500/fdinfo/0", "pos:\t0\nflags:\t02000000\nmnt_id:\t15\n")
// The writer's own end, elsewhere, is not a reader.
f.proc(501, 1000, "sh", []string{"sh"}, "session-c1.scope")
links["/proc/501/fd/1"] = "pipe:[36802]"
f.write("/proc/501/fdinfo/1", "pos:\t0\nflags:\t01\n")
links["/proc/3867/fd/2"] = "socket:[22289]"
for _, fd := range []string{"0", "1"} {
_ = os.MkdirAll(filepath.Join(f.Root, "/proc/500/fd"), 0o755)
_ = os.WriteFile(filepath.Join(f.Root, "/proc/500/fd", fd), nil, 0o644)
_ = os.MkdirAll(filepath.Join(f.Root, "/proc/501/fd"), 0o755)
_ = os.WriteFile(filepath.Join(f.Root, "/proc/501/fd", fd), nil, 0o644)
}
got := f.streams(3867)
if got[0].Dead || got[0].Goes != "a pipe read by systemd-cat (pid 500)" || !strings.HasPrefix(got[1].Goes, "a socket") {
t.Fatalf("%+v", got)
}
}
func TestTheTrayIsAnIconInsideAnotherProgramsWindow(t *testing.T) {
if ok, in := trayIn(tree); !ok || in != "i3bar" {
t.Fatal(ok, in)
}
without := strings.Replace(tree, ` 0x2800029 "slack": ("slack" "Slack") 22x22+2832+2 +2832+2`+"\n", "", 1)
if ok, _ := trayIn(without); ok {
t.Fatal("the top-level Slack windows and the managed chat window are not a tray icon")
}
}
func TestTheLogIsMasked(t *testing.T) {
f, _ := newSlack(t)
l, err := f.Log("browser", 2000, false)
if err != nil {
t.Fatal(err)
}
all := strings.Join(l.Lines, "\n")
for _, never := range []string{"xoxc", "xoxd", "AbCdEf", "abc.def", "private message", "Secret Workspace"} {
if strings.Contains(all, never) {
t.Errorf("the log carries %q", never)
}
}
if !strings.Contains(all, `"title": "<hidden>"`) || !strings.Contains(all, "token <hidden>") || l.Files[0] != "~/"+logDir+"/browser.log" {
t.Fatalf("%+v", l)
}
p, _ := f.Log("browser", 10, true)
if len(p.Lines) != 2 || !strings.Contains(p.Lines[0], "warn:") || !strings.Contains(p.Lines[1], "Authorization: <hidden>") {
t.Fatalf("%q", p.Lines)
}
if _, err := f.Log("cookies", 5, false); err == nil {
t.Fatal("an unknown source is refused")
}
if l, err := newFake(t).Log("webapp", 5, false); err != nil || l.Note == "" {
t.Fatalf("%+v %v", l, err)
}
}
func TestRestartEndsSlackAndStartsItToTheTray(t *testing.T) {
f, _ := newSlack(t)
f.onStart = func(argv []string) {
f.proc(9000, 1000, slackComm, argv, "user@1000.service/app.slice/"+restartAs+".service")
f.proc(9001, 1000, slackComm, []string{"/usr/lib/slack/slack", "--type=zygote"}, "user@1000.service/app.slice/"+restartAs+".service")
}
a, err := f.Restart()
if err != nil {
t.Fatal(err)
}
if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9000 || a.Running[0].Command != "/usr/bin/slack --gtk-version=3 -s" {
t.Fatalf("%+v", a)
}
if !f.called("systemd-run --user --collect --quiet --unit=mesh-slack --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
f.onStart = nil
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u mesh-slack.service") {
t.Fatalf("a start that shows no process: %v", err)
}
none := newFake(t)
if _, err := none.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
}
func TestCheckNamesEveryOtherStartTheOperatorsRulesAndTheDeadPipe(t *testing.T) {
f, links := newSlack(t)
c, err := f.Check()
if err != nil {
t.Fatal(err)
}
// As the desktop machine is: the one finding is the session's dead pipe.
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "fd 1 is a pipe nobody reads") || len(c.Starts) != 1 {
t.Fatalf("%+v", c)
}
links["/proc/3867/fd/1"] = "socket:[1]"
if c, _ = f.Check(); !c.OK {
t.Fatalf("%+v", c)
}
// As the laptop is before migration: the predecessor's entry, the operator's rules file.
f.write(testHome+"/.config/autostart/slack.desktop", "[Desktop Entry]\nExec=/usr/bin/slack --gtk-version=3 -s %U\nHidden=false\n")
f.write(testHome+"/.config/i3/config.d/50-slack.conf", "# Slack window rules.\n"+
`for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3`+"\n")
f.write(testHome+"/.config/i3/config.d/60-mine.conf", "exec --no-startup-id slack\n")
c, _ = f.Check()
all := noSecrets(t, c)
for _, want := range []string{"a second start: XDG autostart: ~/.config/autostart/slack.desktop", "(the predecessor's)",
"a second start: window manager: ~/.config/i3/config.d/60-mine.conf:1", "~/.config/i3/config.d/50-slack.conf repeats Slack's window rules"} {
if !strings.Contains(all, want) {
t.Errorf("no finding %q in %s", want, all)
}
}
if len(c.Starts) != 3 {
t.Fatalf("%q", c.Starts)
}
// Nothing installed, nothing placed, two Slacks, no tray, no notifier.
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n")
f.proc(4000, 1000, slackComm, []string{"/usr/lib/slack/slack"}, "s.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "xwininfo":
return Output{Stdout: strings.Replace(tree, `("slack" "Slack") 22x22`, `("other" "Other") 22x22`, 1)}
case "busctl":
return Output{Stdout: ":1.19 3923 dunst operator :1.19 user@1000.service - -\n"}
}
return Output{}
}
c, _ = f.Check()
all = noSecrets(t, c)
for _, want := range []string{"is not installed", "from the AUR", "not got the module's start", "not got the module's window rules",
"2 Slack main processes run", "icon is in no tray", "nobody shows notifications"} {
if !strings.Contains(all, want) {
t.Errorf("no finding %q in %s", want, all)
}
}
}
func TestSlacksOwnLaunchOnLoginLinkIsNamedAsItsSetting(t *testing.T) {
f, _ := newSlack(t)
// Slack's setting makes ~/.config/autostart/slack.desktop a link to the package's entry.
f.write(testHome+"/.config/autostart/slack.desktop", "[Desktop Entry]\nExec=/usr/bin/slack --gtk-version=3 -s %U\n")
orig := isLink
isLink = func(p string) bool { return strings.HasSuffix(p, "/.config/autostart/slack.desktop") }
t.Cleanup(func() { isLink = orig })
c, _ := f.Check()
if !strings.Contains(noSecrets(t, c), "untick 'Launch app on login'") {
t.Fatalf("%+v", c)
}
}