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:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user