From 75f25fca21aa9eb2daef5a766304360e9510660f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 15:05:55 +0200 Subject: [PATCH] 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. --- .gitignore | 4 + modules/blueman/cmd/blueman-tools/desktop.go | 5 +- .../blueman/cmd/blueman-tools/desktop_test.go | 2 +- modules/jetbrains-toolbox/README.md | 119 +++ .../cmd/toolbox-tools/args.go | 97 +++ .../cmd/toolbox-tools/desktop.go | 575 +++++++++++++++ .../cmd/toolbox-tools/desktop_test.go | 202 ++++++ .../cmd/toolbox-tools/helpers_test.go | 10 + .../cmd/toolbox-tools/main.go | 52 ++ .../cmd/toolbox-tools/manifest_test.go | 96 +++ .../cmd/toolbox-tools/toolbox.go | 349 +++++++++ .../cmd/toolbox-tools/toolbox_test.go | 198 +++++ modules/jetbrains-toolbox/go.mod | 5 + modules/jetbrains-toolbox/go.sum | 2 + modules/jetbrains-toolbox/module.json | 27 + .../cmd/nextcloud-client-tools/desktop.go | 5 +- .../nextcloud-client-tools/desktop_test.go | 2 +- modules/slack/README.md | 162 +++++ modules/slack/cmd/slack-tools/args.go | 97 +++ modules/slack/cmd/slack-tools/desktop.go | 575 +++++++++++++++ modules/slack/cmd/slack-tools/desktop_test.go | 202 ++++++ modules/slack/cmd/slack-tools/main.go | 84 +++ .../slack/cmd/slack-tools/manifest_test.go | 156 ++++ modules/slack/cmd/slack-tools/slack.go | 678 ++++++++++++++++++ modules/slack/cmd/slack-tools/slack_test.go | 314 ++++++++ modules/slack/go.mod | 5 + modules/slack/go.sum | 2 + modules/slack/module.json | 35 + 28 files changed, 4054 insertions(+), 6 deletions(-) create mode 100644 modules/jetbrains-toolbox/README.md create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/args.go create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/desktop.go create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/desktop_test.go create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/helpers_test.go create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/main.go create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/manifest_test.go create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox.go create mode 100644 modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox_test.go create mode 100644 modules/jetbrains-toolbox/go.mod create mode 100644 modules/jetbrains-toolbox/go.sum create mode 100644 modules/jetbrains-toolbox/module.json create mode 100644 modules/slack/README.md create mode 100644 modules/slack/cmd/slack-tools/args.go create mode 100644 modules/slack/cmd/slack-tools/desktop.go create mode 100644 modules/slack/cmd/slack-tools/desktop_test.go create mode 100644 modules/slack/cmd/slack-tools/main.go create mode 100644 modules/slack/cmd/slack-tools/manifest_test.go create mode 100644 modules/slack/cmd/slack-tools/slack.go create mode 100644 modules/slack/cmd/slack-tools/slack_test.go create mode 100644 modules/slack/go.mod create mode 100644 modules/slack/go.sum create mode 100644 modules/slack/module.json diff --git a/.gitignore b/.gitignore index b947077..197b8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ node_modules/ dist/ + +# Go tool bundles built in place (go build in a module's cmd/-tools) are build output. +modules/slack/cmd/slack-tools/slack-tools +modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools diff --git a/modules/blueman/cmd/blueman-tools/desktop.go b/modules/blueman/cmd/blueman-tools/desktop.go index f2efcf4..9df0080 100644 --- a/modules/blueman/cmd/blueman-tools/desktop.go +++ b/modules/blueman/cmd/blueman-tools/desktop.go @@ -1,7 +1,8 @@ package main -// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the -// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208). +// 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 diff --git a/modules/blueman/cmd/blueman-tools/desktop_test.go b/modules/blueman/cmd/blueman-tools/desktop_test.go index 19b27df..3835b38 100644 --- a/modules/blueman/cmd/blueman-tools/desktop_test.go +++ b/modules/blueman/cmd/blueman-tools/desktop_test.go @@ -1,7 +1,7 @@ package main // The fake machine the tests run against, and the tests of desktop.go. The same in the -// nextcloud-client and blueman bundles. +// nextcloud-client, blueman, slack and jetbrains-toolbox bundles. import ( "context" diff --git a/modules/jetbrains-toolbox/README.md b/modules/jetbrains-toolbox/README.md new file mode 100644 index 0000000..1c6368a --- /dev/null +++ b/modules/jetbrains-toolbox/README.md @@ -0,0 +1,119 @@ +# jetbrains-toolbox + +JetBrains Toolbox on the workstations, as a module (novox/hq ADR 0208). Toolbox installs, updates and +launches the JetBrains IDEs. It requires `x11-display`, so it is assigned only where a display server +is held on the same machine. + +## Owns + +Nothing on disk. It holds no seat, makes no contribution and writes no file. What it owns is the rule +that Toolbox has **one start**, and the tools that see Toolbox and its IDEs. + +- **Not a package.** The distribution does not package Toolbox. JetBrains ships a tarball, which the + operator unpacked once. Toolbox keeps itself in `~/.local/share/JetBrains/Toolbox`: + - its launcher and runtime, in `bin/`; + - the IDEs, in `apps/`; + - its settings, state and update channels, in `.settings.json`, `state.json` and `channels/`; + - its account, in `accounts.json` and `.securestorage`; + - its logs. +- **Kept as found, not a pinned archive (ADR 0205).** ADR 0205 ships software the distribution lacks as + a pinned archive of the module's own. Toolbox is the exception it does not foresee: + - it **updates itself in place**, and it updates the IDEs. A pinned copy would be a second writer of + `bin/`: either the push rolls Toolbox back after every self-update, or Toolbox overwrites the + mesh's copy; + - its licence is JetBrains', not one the mesh's store may redistribute. + + So the module installs nothing. The operator installs Toolbox once, by its own instructions + (`Install-linux-tar.txt` beside the launcher), and Toolbox keeps itself current. `toolbox_check` + says when it is missing. +- **Toolbox's files are found** (ADR 0182). The module never declares, writes or removes any of them. + The tools read the version, the switches, the tool list and the channels. They never read the + account, the secure storage or the logs. + +## How it starts: Toolbox's own autostart entry, and nothing else + +One process has one starter (the rule `picom` states for the desktop modules). Toolbox's starter is +**its own XDG autostart entry**, `~/.config/autostart/jetbrains-toolbox.desktop` +(`…/bin/jetbrains-toolbox --minimize`). + +- Toolbox writes that entry while its setting *Launch Toolbox App at system startup* is on, and + removes it when the setting is off. The setting is `autostart` in `.settings.json`; absent means on, + Toolbox's default. +- The session runs every XDG autostart entry once at login: the `i3` module's + `dex --autostart --environment i3`. + +**Why not a contribution to `node-display-session`:** Toolbox would still write its own entry +whenever the setting is on, and the session would start it twice. The module cannot own the entry +either: Toolbox rewrites it, and that would be two writers. So, as `nextcloud-client` does, the module +adds no start, and `toolbox_check` holds the rule: + +- the entry exists exactly when the setting says so; +- no window-manager `exec` starts Toolbox as well. + +**Off is an answer, not a fault.** With the setting off, Toolbox does not start at login and runs +when the operator opens it (from the launcher, or a `jetbrains://` link). `toolbox_check` notes that +and finds nothing wrong. + +## Tools + +They are served by the node's runtime as the operator account (ADR 0175), and are read-only except +`restart`. **No answer carries the JetBrains account**: neither its id nor anything from +`accounts.json` or `.securestorage`. Paths under the home are answered as `~/…`. + +| tool | does | +|---|---| +| `toolbox_status` (r) | | +| `toolbox_restart` (a) | ends Toolbox (SIGTERM, forced after 8 s) and starts `…/bin/jetbrains-toolbox --minimize` in the operator's session. The start is a transient user unit `mesh-jetbrains-toolbox`, so it outlives the tools runtime. The IDEs Toolbox launched are their own processes and keep running. Answers the pids. Refused plainly when nobody is logged in to the desktop, or when Toolbox is not installed | +| `toolbox_check` (r) | Each finding says what to do. Notes cover the setting being off, and directories it does not list | + +**Which process is Toolbox:** the kernel keeps 15 characters of a process's name, so Toolbox's comm +is `jetbrains-toolb`. A process with that comm counts only when its command is Toolbox's launcher. +**The bundle is named `toolbox-tools`, not `jetbrains-toolbox-tools`.** The longer name cuts to the +same comm, and `toolbox_restart` would have ended the tools themselves. + +The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment, +as the other desktop modules do. Every command has a timeout and capped output. Everything runs through +an injected runner and a fake root in the tests. + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| Toolbox | none: Toolbox 2.8.1.52155 in `~/.local/share/JetBrains/Toolbox` (tarball install), not running. Its files are from 2025-09-04, the last time it ran, so it has not updated itself or the IDEs since | none: Toolbox 3.2.0.65851, the same place (`.installation-type` says `APP`), last run 2026-02-23; not running | +| start | none: *Launch at startup* is **off** (`autostart: false`) and there is no entry, so nothing starts it at login. `toolbox_check`: ok, with a note | none on disk: the setting is at its default (on), and Toolbox's own entry (`--minimize`) is there. The `i3` module's dex starts it at the next login. The session running now (since 2026-10-04) began before dex was installed, which is why Toolbox does not run. `toolbox_check` names that until `toolbox_restart` or the next login | +| IDEs | Fleet 1.48.261, PyCharm 2025.2.1.1, Rider 2025.2.0.1, WebStorm 2025.2.2: all on the **Early Access Program** channel | RustRover 2026.1 EAP and Fleet 1.48.261 on **Early Access Program**; PyCharm 2025.3.2.1, Rider 2025.3.2 and WebStorm 2025.3.2 on **Release** | +| updates | update the tools automatically: on | the same; one version kept for a rollback | + +The two machines differ in Toolbox's major version and in the IDEs' channels. Both are the +operator's choices in Toolbox, and the module reports them rather than aligning them. + +## Migration (ADR 0182) + +Nothing is required on either machine. The module removes nothing and adds no start. + +- **shanks:** run `toolbox_restart`, or log out and in, and Toolbox runs from its one start. + `toolbox_check` then answers `ok`. Toolbox wrote its entry with the executable bit set (0744), which + systemd's autostart generator warns about at every login. That is harmless under i3, and the entry + is Toolbox's, so the module leaves it. +- **g14:** nothing. To have Toolbox start at login there too, open it and tick *Launch Toolbox App at + system startup*. It writes its own entry, and it then updates itself and the IDEs, which it has not + done since 2025-09. +- **Not the module's:** `~/.profile` on g14 adds Toolbox's `scripts/` folder to `PATH` by hand. The + account's environment is `node-environment`'s holder's. If the IDE launch scripts are wanted on + `PATH` on both machines, that is a contribution to it, not a line in `~/.profile`. + +## Leaves as found + +- `~/.local/share/JetBrains/Toolbox/`, entirely: the launcher, the runtime, the IDEs, the settings, + the state, the account, the logs, and the link Toolbox itself keeps in `scripts/` for Fleet. +- `~/.config/autostart/jetbrains-toolbox.desktop`, Toolbox's own. +- The desktop entries Toolbox writes for itself and each IDE in `~/.local/share/applications/`. Those + include `jetbrainsd.desktop` on shanks. +- The `x-scheme-handler/jetbrains` default, which is the `xdg` module's. + +## Relies on + +- **`i3`'s `dex` line for the start.** Nothing in the mesh says so yet: XDG autostart has no seat, and + a module without a seat or contribution has no way to depend on another module. Assigned without + `i3`, Toolbox is not started at login. `toolbox_check` says so when its setting is on. +- A display server on the same machine (`x11-display`, ADR 0208 §3). diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/args.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/args.go @@ -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 +} diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/desktop.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/desktop.go new file mode 100644 index 0000000..9df0080 --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/desktop.go @@ -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"` +} diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/desktop_test.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/desktop_test.go new file mode 100644 index 0000000..3835b38 --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/desktop_test.go @@ -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)) + } +} diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/helpers_test.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/helpers_test.go new file mode 100644 index 0000000..f296c41 --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/helpers_test.go @@ -0,0 +1,10 @@ +package main + +import ( + "os" + "path/filepath" +) + +func chmodX(f *fake, path string) error { return os.Chmod(filepath.Join(f.Root, path), 0o755) } + +func removeAll(f *fake, path string) { _ = os.RemoveAll(filepath.Join(f.Root, path)) } diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/main.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/main.go new file mode 100644 index 0000000..506f91b --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/main.go @@ -0,0 +1,52 @@ +// The jetbrains-toolbox module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): JetBrains +// Toolbox, which installs and updates the IDEs, 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. +// +// Toolbox and what it installs are kept as found: the tools read its files and never write them, and +// no answer carries the JetBrains account or anything from its secure storage. +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: "toolbox_status", + Description: "JetBrains Toolbox: whether it is installed and its version, whether it runs (pid, since, " + + "and the unit or session scope), what starts it at login, its switches (launch at login, update " + + "the tools automatically, shell scripts, theme), and every IDE it installed with its version, " + + "build, update channel (Release, EAP …), whether it updates itself, and whether it is on disk. " + + "Never the JetBrains account. (r)", + Run: func(map[string]any) (any, error) { return machine.Status() }, + }, + { + Name: "toolbox_restart", + Description: "End Toolbox (asked first, then forced after 8 s) and start it again minimised to the tray " + + "in the operator's desktop session, under the account's service manager. The IDEs it launched " + + "keep running. Answers the pids ended and the new one. Needs someone logged in to the desktop. (a)", + Run: func(map[string]any) (any, error) { return machine.Restart() }, + }, + { + Name: "toolbox_check", + Description: "Check what the module promises: Toolbox is installed where it keeps itself; it starts at " + + "most once (its own XDG autostart entry, in agreement with its launch-at-login switch; no " + + "window-manager exec); it runs at most once; every IDE in its list is on disk. Answers ok, each " + + "finding with what to do, and notes. (r)", + Run: func(map[string]any) (any, error) { return machine.Check() }, + }, + } +} diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/manifest_test.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/manifest_test.go new file mode 100644 index 0000000..e76e4de --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/manifest_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// jetbrains-toolbox's shape (novox/hq ADR 0205, ADR 0208, ADR 0182): Toolbox is kept as found, so no +// package, no archive, no file and no start of the module's own (Toolbox writes its own autostart +// entry); the X display on its own machine; and the Go bundle serving exactly the listed toolbox_ 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 []any `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 TestItKeepsToolboxAsFound(t *testing.T) { + m, _ := readManifest(t) + if m.Module != "jetbrains-toolbox" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { + t.Fatalf("%+v", m) + } + // Toolbox replaces its own files when it updates itself: a package, an archive or a file of the + // module's in its directory would be a second writer. + if m.Resources != nil || m.Capabilities != nil { + t.Fatalf("no package, no archive, no file: %v %v", m.Resources, m.Capabilities) + } + // Its own autostart entry is its one start: a contribution or a session slot would be a second. + if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil { + t.Fatal("it holds no seat, sets no environment and adds no start") + } +} + +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, "toolbox_") || strings.TrimSpace(tool.Description) == "" { + t.Errorf("%s: prefixed toolbox_ 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/toolbox-tools" || b["binary"] != "toolbox-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) + } + } +} diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox.go new file mode 100644 index 0000000..9d7814a --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox.go @@ -0,0 +1,349 @@ +package main + +// JetBrains Toolbox as the tools see it. Toolbox is not a distribution package: it is JetBrains' +// tarball, unpacked once by the operator into ~/.local/share/JetBrains/Toolbox, which then updates +// itself in place and installs and updates the IDEs under apps/. The tools read only: +// - bin/build.txt, Toolbox's version; +// - .settings.json, of which only the switches are answered (launch at login, update the tools +// automatically, where the shell scripts go, the theme), never the account it names; +// - state.json, the installed tools (name, version, build, where), and channels/.json, each +// tool's update channel (Release, EAP …) and whether it updates itself; +// - its XDG autostart entry, which Toolbox writes and removes itself for its launch-at-login switch. +// +// Never read: accounts.json, .securestorage, the logs. They carry the account and its tokens. + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Where Toolbox keeps things, under the operator's home. +const ( + toolboxDir = ".local/share/JetBrains/Toolbox" + toolboxComm = "jetbrains-toolb" // the kernel keeps 15 characters of jetbrains-toolbox + entryName = "jetbrains-toolbox.desktop" + restartAs = "mesh-jetbrains-toolbox" +) + +// running are Toolbox's processes: comm jetbrains-toolb and a command that is Toolbox's launcher. The +// comm alone is not enough, because the kernel cuts every name starting jetbrains-toolb… to it; the +// bundle is named toolbox-tools for that reason too, so stopping Toolbox by its comm never stops the +// tools. +func (m *Machine) running() []Proc { + out := []Proc{} + for _, p := range m.procs(toolboxComm) { + if f := strings.Fields(p.Command); len(f) > 0 && filepath.Base(f[0]) == "jetbrains-toolbox" { + out = append(out, p) + } + } + return out +} + +func (m *Machine) toolbox(rel ...string) string { + return m.home(append([]string{toolboxDir}, rel...)...) +} + +// binary is Toolbox's launcher as the machine names it (no fake root), for starting it. +func (m *Machine) binary() string { + return filepath.Join(m.Home, toolboxDir, "bin", "jetbrains-toolbox") +} + +// Settings are Toolbox's switches the tools answer. +type Settings struct { + Found bool `json:"found"` + // LaunchAtLogin is Toolbox's "Launch Toolbox App at system startup": absent means on, its default. + LaunchAtLogin bool `json:"launch_at_login"` + LaunchDefault bool `json:"launch_at_login_is_default,omitempty"` + UpdateTools *bool `json:"update_tools_automatically,omitempty"` + ShellScripts string `json:"shell_scripts,omitempty"` + Theme string `json:"theme,omitempty"` + RollbackKeeps *int `json:"rollback_versions_kept,omitempty"` +} + +func (m *Machine) settings() (Settings, error) { + s := Settings{LaunchAtLogin: true, LaunchDefault: true} + raw, err := readBounded(m.toolbox(".settings.json")) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return s, fmt.Errorf("reading Toolbox's settings: %w", err) + } + var doc struct { + Autostart *bool `json:"autostart"` + Tools struct { + UpdateAll *bool `json:"update_all_automatically"` + } `json:"tools"` + ShellScripts struct { + Location string `json:"location"` + } `json:"shell_scripts"` + UI struct { + Theme string `json:"theme"` + } `json:"ui"` + Rollback *int `json:"channel_rollback_max_history"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return s, fmt.Errorf("Toolbox's settings are not JSON: %w", err) + } + s.Found = true + if doc.Autostart != nil { + s.LaunchAtLogin, s.LaunchDefault = *doc.Autostart, false + } + s.UpdateTools, s.Theme, s.RollbackKeeps = doc.Tools.UpdateAll, doc.UI.Theme, doc.Rollback + if doc.ShellScripts.Location != "" { + s.ShellScripts = m.tilde(doc.ShellScripts.Location) + } + return s, nil +} + +// Tool is one IDE (or other tool) Toolbox has installed. +type Tool struct { + Name string `json:"name"` + ID string `json:"id"` + Version string `json:"version"` + Build string `json:"build,omitempty"` + // Channel is the update channel the operator chose for it in Toolbox: Release, EAP, …. + Channel string `json:"channel,omitempty"` + AutoUpdate *bool `json:"updates_itself,omitempty"` + Location string `json:"location"` + Present bool `json:"present"` + location string +} + +// State is what state.json says: Toolbox's version when it last wrote it, and the tools. +type State struct { + Found bool `json:"found"` + AppVersion string `json:"app_version,omitempty"` + Tools []Tool `json:"tools"` + // Untracked are directories under apps/ that state.json does not name. + Untracked []string `json:"untracked,omitempty"` +} + +func (m *Machine) state() (State, error) { + st := State{Tools: []Tool{}} + raw, err := readBounded(m.toolbox("state.json")) + if os.IsNotExist(err) { + return st, nil + } + if err != nil { + return st, fmt.Errorf("reading Toolbox's state: %w", err) + } + var doc struct { + AppVersion string `json:"appVersion"` + Tools []struct { + ChannelID string `json:"channelId"` + ToolID string `json:"toolId"` + Name string `json:"displayName"` + Version string `json:"displayVersion"` + Build string `json:"buildNumber"` + Location string `json:"installLocation"` + } `json:"tools"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return st, fmt.Errorf("Toolbox's state is not JSON: %w", err) + } + st.Found, st.AppVersion = true, doc.AppVersion + known := map[string]bool{} + for _, t := range doc.Tools { + tool := Tool{Name: t.Name, ID: t.ToolID, Version: t.Version, Build: t.Build, location: t.Location, + Location: m.tilde(t.Location), Present: t.Location != "" && exists(filepath.Join(m.Root, t.Location))} + tool.Channel, tool.AutoUpdate = m.channel(t.ChannelID) + known[filepath.Base(t.Location)] = true + st.Tools = append(st.Tools, tool) + } + sort.Slice(st.Tools, func(i, j int) bool { return st.Tools[i].Name < st.Tools[j].Name }) + entries, _ := os.ReadDir(m.toolbox("apps")) + for _, e := range entries { + if e.IsDir() && !known[e.Name()] { + st.Untracked = append(st.Untracked, "~/"+toolboxDir+"/apps/"+e.Name()) + } + } + return st, nil +} + +// channel reads one tool's channel file: the quality filter's name, and its own update switch. +func (m *Machine) channel(id string) (string, *bool) { + if id == "" || strings.ContainsAny(id, "/\\") { + return "", nil + } + raw, err := readBounded(m.toolbox("channels", id+".json")) + if err != nil { + return "", nil + } + var doc struct { + Channel struct { + Filter struct { + Quality struct { + Name string `json:"name"` + } `json:"quality_filter"` + } `json:"updateFilter"` + AutoUpdate *bool `json:"autoUpdate"` + } `json:"channel"` + } + if json.Unmarshal(raw, &doc) != nil { + return "", nil + } + return doc.Channel.Filter.Quality.Name, doc.Channel.AutoUpdate +} + +// version is Toolbox's own version, from the build file beside its launcher. +func (m *Machine) version() string { return readTrimmed(m.toolbox("bin", "build.txt")) } + +func (m *Machine) installedHere() bool { + fi, err := os.Stat(m.toolbox("bin", "jetbrains-toolbox")) + return err == nil && fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 +} + +// homeless shows every path under the home in a text as ~/…: Toolbox's command line and its entry's +// Exec name its own location, and an answer does not carry the account's name. +func (m *Machine) homeless(s string) string { + if h := strings.TrimSuffix(m.Home, "/"); h != "" { + return strings.ReplaceAll(s, h+"/", "~/") + } + return s +} + +func (m *Machine) homelessProcs(ps []Proc) []Proc { + out := []Proc{} + for _, p := range ps { + p.Command = m.homeless(p.Command) + out = append(out, p) + } + return out +} + +// Status is what toolbox_status answers. +type Status struct { + Installed bool `json:"installed"` + Where string `json:"where"` + Version string `json:"version,omitempty"` + Running []Proc `json:"running"` + Settings Settings `json:"settings"` + State State `json:"installed_tools"` + StartedBy Autostart `json:"started_by"` +} + +func (m *Machine) Status() (Status, error) { + s := Status{Installed: m.installedHere(), Where: "~/" + toolboxDir, Version: m.version(), + Running: m.homelessProcs(m.running()), StartedBy: m.autostart(entryName)} + s.StartedBy.Exec = m.homeless(s.StartedBy.Exec) + var err error + if s.Settings, err = m.settings(); err != nil { + return s, err + } + if s.State, err = m.state(); err != nil { + return s, err + } + return s, nil +} + +// RestartAnswer is what toolbox_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 Toolbox and starts it again minimised to the tray, as its autostart entry does, in the +// operator's session under the account's service manager. The IDEs it launched are their own +// processes and keep running. +func (m *Machine) Restart() (RestartAnswer, error) { + if !m.installedHere() { + return RestartAnswer{}, fmt.Errorf("Toolbox is not installed in ~/%s: nothing to start", toolboxDir) + } + 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, toolboxComm) + if err := m.detach(s, restartAs, m.binary(), "--minimize"); err != nil { + return a, err + } + m.waitFor(toolboxComm, 6*time.Second) + a.Running = m.homelessProcs(m.running()) + if len(a.Running) == 0 { + return a, fmt.Errorf("Toolbox was started as %s but no %s process appeared within 6 s: "+ + "see `journalctl --user -u %s`", a.Unit, toolboxComm, a.Unit) + } + return a, nil +} + +// CheckAnswer is what toolbox_check answers. +type CheckAnswer struct { + OK bool `json:"ok"` + Findings []Finding `json:"findings"` + Starts []string `json:"starts"` + Notes []string `json:"notes,omitempty"` +} + +// Check verifies what the module promises: Toolbox is where it is kept, it starts at most once (its +// own autostart entry, in agreement with its setting), it runs at most once, and every tool it lists +// is on disk. +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}) } + if !m.installedHere() { + add("Toolbox is not installed in ~/"+toolboxDir, + "download JetBrains' tarball and run its bin/jetbrains-toolbox once: the module does not install it (see its README)") + a.OK = false + return a, nil + } + set, err := m.settings() + if err != nil { + return a, err + } + entry := m.autostart(entryName) + if entry.Starts { + a.Starts = append(a.Starts, "XDG autostart: "+entry.From) + if !strings.Contains(entry.Exec, "jetbrains-toolbox") { + add("the autostart entry runs "+m.homeless(entry.Exec)+", not Toolbox", "untick and tick 'Launch Toolbox App at system startup' in Toolbox's settings: it writes the entry again") + } + if !set.LaunchAtLogin { + add("Toolbox's setting says not to launch at login, but its autostart entry is there", + "tick and untick 'Launch Toolbox App at system startup', or delete "+entry.From) + } + if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil { + add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it") + } + } else if set.LaunchAtLogin { + add("Toolbox does not start with the session ("+entry.Because+"), although its setting says it does", + "untick and tick 'Launch Toolbox App at system startup' in Toolbox's settings: it writes its own entry") + } else { + a.Notes = append(a.Notes, "Toolbox does not start at login: its setting is off, so it runs when the operator opens it") + } + for _, l := range m.i3Starts("jetbrains-toolbox") { + a.Starts = append(a.Starts, "window manager: "+l) + add("a second start: "+l, "remove the line; Toolbox's own autostart entry is its one start") + } + running := m.running() + if _, err := m.session(); err == nil { + switch { + case len(running) > 1: + add(fmt.Sprintf("%d Toolbox processes run", len(running)), "toolbox_restart ends them all and starts one") + case len(running) == 0 && entry.Starts: + add("Toolbox does not run in the desktop session although it starts at login", "toolbox_restart") + } + } + st, err := m.state() + if err != nil { + return a, err + } + for _, t := range st.Tools { + if !t.Present { + add(t.Name+" "+t.Version+" is in Toolbox's list but not on disk ("+t.Location+")", "reinstall or remove it in Toolbox") + } + } + for _, u := range st.Untracked { + a.Notes = append(a.Notes, u+" is not in Toolbox's list: an IDE installed by hand or left behind by Toolbox") + } + a.OK = len(a.Findings) == 0 + return a, nil +} diff --git a/modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox_test.go b/modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox_test.go new file mode 100644 index 0000000..d10dd63 --- /dev/null +++ b/modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +const tb = testHome + "/" + toolboxDir + +// newToolbox is a Toolbox as the desktop machine keeps it: three IDEs, one on the EAP channel, its +// launch-at-login switch at its default, its own autostart entry, an account no answer may name. +func newToolbox(t *testing.T) *fake { + f := newFake(t) + f.write(tb+"/bin/jetbrains-toolbox", "\x7fELF") + if err := chmodX(f, tb+"/bin/jetbrains-toolbox"); err != nil { + t.Fatal(err) + } + f.write(tb+"/bin/build.txt", "3.2.0.65851") + f.write(tb+"/.settings.json", `{"advanced":{"build_for_installed":true},"channel_rollback_max_history":1, +"shell_scripts":{"location":"/home/operator/.local/share/JetBrains/Toolbox/scripts"},"ui":{"theme":"dark"}, +"tools":{"update_all_automatically":true},"jetbrains_account":{"active":"1838624"}}`) + f.write(tb+"/accounts.json", `{"accounts":[{"email":"operator@example.test","token":"secret-token"}]}`) + f.write(tb+"/state.json", `{"version":1,"appVersion":"3.2.0.65851","tools":[ +{"channelId":"RustRover-dd65","toolId":"RustRover","displayName":"RustRover","displayVersion":"2026.1 EAP","buildNumber":"261.21525.29", + "installLocation":"/home/operator/.local/share/JetBrains/Toolbox/apps/rustrover","launchCommand":"x"}, +{"channelId":"Rider-8c11","toolId":"Rider","displayName":"Rider","displayVersion":"2025.3.2","buildNumber":"253.30387.148", + "installLocation":"/home/operator/.local/share/JetBrains/Toolbox/apps/rider"}, +{"channelId":"../../escape","toolId":"Fleet","displayName":"Fleet","displayVersion":"1.48.261 Public Preview", + "installLocation":"/home/operator/.local/share/JetBrains/Toolbox/apps/fleet"}]}`) + f.write(tb+"/channels/RustRover-dd65.json", `{"channel":{"updateFilter":{"application_type":"RustRover", +"quality_filter":{"name":"Early Access Program","order_value":40000}},"autoUpdate":true}}`) + f.write(tb+"/channels/Rider-8c11.json", `{"channel":{"updateFilter":{"quality_filter":{"name":"Release"}}}}`) + for _, d := range []string{"rustrover", "rider", "fleet", "webstorm-old"} { + f.write(tb+"/apps/"+d+"/build.txt", "x") + } + f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/home/operator/.local/share/JetBrains/Toolbox/bin/jetbrains-toolbox --minimize\n"+ + "X-GNOME-Autostart-enabled=true\n") + f.answer = func(name string, args []string) Output { return Output{} } + return f +} + +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{"1838624", "example.test", "secret-token", "/home/operator"} { + if strings.Contains(s, never) { + t.Errorf("the answer carries %q: %s", never, s) + } + } + return s +} + +func TestStatusListsTheIDEsWithTheirChannelsAndNoAccount(t *testing.T) { + f := newToolbox(t) + f.proc(4100, 1000, toolboxComm, []string{"/home/operator/.local/share/JetBrains/Toolbox/bin/jetbrains-toolbox", "--minimize"}, "session-4.scope") + s, err := f.Status() + if err != nil { + t.Fatal(err) + } + noSecrets(t, s) + if !s.Installed || s.Version != "3.2.0.65851" || len(s.Running) != 1 || !s.StartedBy.Starts { + t.Fatalf("%+v", s) + } + set := s.Settings + if !set.Found || !set.LaunchAtLogin || !set.LaunchDefault || !*set.UpdateTools || set.ShellScripts != "~/"+toolboxDir+"/scripts" || *set.RollbackKeeps != 1 { + t.Fatalf("%+v", set) + } + st := s.State + if st.AppVersion != "3.2.0.65851" || len(st.Tools) != 3 { + t.Fatalf("%+v", st) + } + fleet, rider, rr := st.Tools[0], st.Tools[1], st.Tools[2] + if rr.Name != "RustRover" || rr.Version != "2026.1 EAP" || rr.Channel != "Early Access Program" || rr.AutoUpdate == nil || !*rr.AutoUpdate || + rr.Location != "~/"+toolboxDir+"/apps/rustrover" || !rr.Present { + t.Fatalf("%+v", rr) + } + if rider.Channel != "Release" || rider.AutoUpdate != nil { + t.Fatalf("%+v", rider) + } + // A channel id that would leave the channels directory is not read. + if fleet.Name != "Fleet" || fleet.Channel != "" { + t.Fatalf("%+v", fleet) + } + if len(st.Untracked) != 1 || st.Untracked[0] != "~/"+toolboxDir+"/apps/webstorm-old" { + t.Fatalf("%q", st.Untracked) + } +} + +func TestOnlyToolboxsLauncherIsToolbox(t *testing.T) { + f := newToolbox(t) + f.proc(4100, 1000, toolboxComm, []string{"/home/operator/.local/share/JetBrains/Toolbox/bin/jetbrains-toolbox"}, "s.scope") + // A program whose name the kernel cuts to the same comm. + f.proc(4200, 1000, toolboxComm, []string{"/usr/lib/mesh/bundles/jetbrains-toolbox-tools"}, "s.scope") + if r := f.running(); len(r) != 1 || r[0].PID != 4100 { + t.Fatalf("%+v", r) + } +} + +func TestTheLaunchSwitchIsReadAndAbsentMeansOn(t *testing.T) { + f := newToolbox(t) + f.write(tb+"/.settings.json", `{"autostart":false}`) + s, _ := f.settings() + if s.LaunchAtLogin || s.LaunchDefault { + t.Fatalf("%+v", s) + } + none := newFake(t) + if s, err := none.settings(); err != nil || s.Found || !s.LaunchAtLogin { + t.Fatalf("%+v %v", s, err) + } + if st, err := none.Status(); err != nil || st.Installed || len(st.State.Tools) != 0 { + t.Fatalf("%+v %v", st, err) + } +} + +func TestRestartStartsToolboxMinimisedUnderTheServiceManager(t *testing.T) { + f := newToolbox(t) + if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") { + t.Fatalf("without a desktop: %v", err) + } + f.desktopSession() + f.proc(4100, 1000, toolboxComm, []string{"jetbrains-toolbox"}, "session-4.scope") + f.onStart = func(argv []string) { f.proc(9100, 1000, toolboxComm, argv, "app.slice/"+restartAs+".service") } + a, err := f.Restart() + if err != nil { + t.Fatal(err) + } + if len(a.Ended) != 1 || a.Ended[0] != 4100 || len(a.Running) != 1 || a.Running[0].PID != 9100 || + a.Running[0].Command != "~/"+toolboxDir+"/bin/jetbrains-toolbox --minimize" { + t.Fatalf("%+v", a) + } + if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs) { + t.Fatalf("%q", f.calls) + } + gone := newFake(t) + gone.desktopSession() + if _, err := gone.Restart(); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Fatalf("%v", err) + } +} + +func TestCheckHoldsOneStartInAgreementWithTheSwitch(t *testing.T) { + f := newToolbox(t) + f.desktopSession() + f.proc(4100, 1000, toolboxComm, []string{"jetbrains-toolbox"}, "session-4.scope") + c, err := f.Check() + if err != nil { + t.Fatal(err) + } + noSecrets(t, c) + if !c.OK || len(c.Starts) != 1 || len(c.Notes) != 1 { + t.Fatalf("%+v", c) + } + // The laptop's way: the switch off, no entry, not running: no finding, a note. + f.write(tb+"/.settings.json", `{"autostart":false}`) + removeAll(f, testHome+"/.config/autostart/"+entryName) + removeAll(f, "/proc/4100") + if c, _ = f.Check(); !c.OK || len(c.Starts) != 0 || !strings.Contains(strings.Join(c.Notes, " "), "its setting is off") { + t.Fatalf("%+v", c) + } + // Everything wrong at once. + f.write(tb+"/.settings.json", `{}`) + f.write(testHome+"/.config/i3/config.d/70-ide.conf", "exec --no-startup-id ~/.local/share/JetBrains/Toolbox/bin/jetbrains-toolbox\n") + removeAll(f, tb+"/apps/rider") + f.proc(4100, 1000, toolboxComm, []string{"jetbrains-toolbox"}, "s.scope") + f.proc(4101, 1000, toolboxComm, []string{"jetbrains-toolbox"}, "s.scope") + c, _ = f.Check() + all := noSecrets(t, c) + for _, want := range []string{"does not start with the session", "a second start: ~/.config/i3/config.d/70-ide.conf:1", + "2 Toolbox processes run", "Rider 2025.3.2 is in Toolbox's list but not on disk"} { + if !strings.Contains(all, want) { + t.Errorf("no finding %q in %s", want, all) + } + } + f.write(tb+"/.settings.json", `{"autostart":false}`) + f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=something-else\n") + f.answer = func(name string, _ []string) Output { + if name == "dex" { + return Output{Code: 127, Err: ErrNotInstalled} + } + return Output{} + } + c, _ = f.Check() + all = noSecrets(t, c) + for _, want := range []string{"runs something-else, not Toolbox", "says not to launch at login, but its autostart entry is there", "dex"} { + if !strings.Contains(all, want) { + t.Errorf("no finding %q in %s", want, all) + } + } + removeAll(f, tb+"/bin/jetbrains-toolbox") + if c, _ = f.Check(); c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].Do, "does not install it") { + t.Fatalf("%+v", c) + } +} diff --git a/modules/jetbrains-toolbox/go.mod b/modules/jetbrains-toolbox/go.mod new file mode 100644 index 0000000..63b27e8 --- /dev/null +++ b/modules/jetbrains-toolbox/go.mod @@ -0,0 +1,5 @@ +module jetbrains-toolbox + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/jetbrains-toolbox/go.sum b/modules/jetbrains-toolbox/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/jetbrains-toolbox/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/jetbrains-toolbox/module.json b/modules/jetbrains-toolbox/module.json new file mode 100644 index 0000000..80e8b7e --- /dev/null +++ b/modules/jetbrains-toolbox/module.json @@ -0,0 +1,27 @@ +{ + "module": "jetbrains-toolbox", + "version": "1", + "requires": [ + "x11-display" + ], + "tools": [ + "toolbox_status", + "toolbox_restart", + "toolbox_check" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/toolbox-tools", + "binary": "toolbox-tools", + "loads": [ + "toolbox-tools" + ] + } + ] + } +} diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go index f2efcf4..9df0080 100644 --- a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go @@ -1,7 +1,8 @@ package main -// desktop.go is the same file in the nextcloud-client and blueman bundles: a tray application of the -// operator's graphical session, seen from the node's tool runtime (novox/hq ADR 0208). +// 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 diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go index 19b27df..3835b38 100644 --- a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go +++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go @@ -1,7 +1,7 @@ package main // The fake machine the tests run against, and the tests of desktop.go. The same in the -// nextcloud-client and blueman bundles. +// nextcloud-client, blueman, slack and jetbrains-toolbox bundles. import ( "context" diff --git a/modules/slack/README.md b/modules/slack/README.md new file mode 100644 index 0000000..8669895 --- /dev/null +++ b/modules/slack/README.md @@ -0,0 +1,162 @@ +# slack + +The Slack desktop app on the workstations, as a module (novox/hq ADR 0208). One Electron process is +both Slack's window and its tray icon. The module requires `x11-display`, so it is assigned only where +a display server is held on the same machine. + +## Owns + +| what | where | +|---|---| +| Slack's one start at login, and its window rules | a `config` contribution to `node-display-session` (ADR 0212), which the seat's holder (`i3`) places in its configuration | + +Nothing else. It holds no seat and writes no file. + +- **No package.** Both workstations run `slack-desktop` 4.51.191-1, installed explicitly, from the + AUR: `pacman` counts it as foreign (no sync repository has it, and its packager is "Unknown + Packager"). The host installs packages from the official repositories only, so the module does not + declare it, and **installing and upgrading Slack stays the operator's** (an AUR helper, by hand). + `slack_status` says whether it is outside the official repositories, and `slack_check` says when it + is missing. +- **Why not a pinned archive (ADR 0205):** Slack is a binary release of about 330 MB under Slack's own + licence. ADR 0205 vendors free software the distribution lacks; redistributing Slack's binary from + the mesh's store is not the module's to do. ADR 0205 does not apply, and the package stays as found. +- **Slack's own files are found** (ADR 0182): `~/.config/Slack/` holds the sessions (cookies, local + storage, the encryption key in `Local State`), the settings (`storage/root-state.json`), the caches + and the logs. The module never declares or writes any of it. The tools read only the settings' + switches and the logs, and never the session files. + +## How it starts: the module's line in i3's configuration, and nothing else + +One process has one starter (the rule `picom` states for the desktop modules). Slack's starter is +**this module's contribution**: `exec --no-startup-id /usr/bin/slack --gtk-version=3 -s`, which `i3` +runs once when the session starts. Those are the arguments of the package's own desktop entry. `-s` +starts Slack hidden, to the tray. + +**Why not an XDG autostart entry**, as `nextcloud-client` and `blueman` start: + +- Slack's own setting *Launch app on login* is the existence of `~/.config/autostart/slack.desktop`. + Slack reads the file's presence back into the setting at every start. Ticking the setting makes the + file **a symbolic link** to `/usr/share/applications/slack.desktop`, and unticking it deletes the + file. This is in Slack 4.51's own code, not a guess. +- If the module owned that path as a file, two writers would hold it: the mesh writing it, and Slack + deleting it whenever the setting is unticked. +- If the entry were left to Slack, the start would be a link that Slack makes in the operator's home, + which this mesh's rules do not want there. +- A contribution is a start the mesh owns, beside the window rules it belongs with, in the window + manager that runs it. Under sway, `sway` holds the same seat with the same grammar. + +**Excluded, and named by `slack_check` as a second start:** + +- any `~/.config/autostart/slack.desktop`: the predecessor's file, or Slack's link if *Launch app on + login* is ticked again (untick it; Slack removes the link); +- an `exec … slack` of the operator's in `~/.config/i3/config.d/`; +- `/etc/xdg/autostart/slack.desktop`, which the package does not ship. + +**Not a start:** systemd's XDG autostart generator makes a unit `app-slack@autostart.service` from the +entry while the entry exists. Only a desktop that starts `xdg-desktop-autostart.target` runs it, and +i3 does not. The unit goes when the entry goes. + +**Slack's cgroup does not say who started it.** Slack moves its main process into a scope of its own +(`app-slack-.scope`) whoever starts it. `slack_status` therefore names the starts from the +configuration, not from the cgroup. + +## The window rules + +The operator's `~/.config/i3/config.d/50-slack.conf` (2026-08) became this contribution as it was: + +- Slack's chat window goes to workspace 3 (`$ws3`, i3's variable, in scope where the contributions are + placed); +- it is tiled, not floating; +- it has a 2-pixel border. + +The criteria are those of the operator's file. They were verified live then, and the window tree of +both workstations on 2026-10-05 still agrees: + +- Slack owns four X windows. The only one i3 manages has the class `slack` in lower case. +- The three of class `Slack` (the packaged entry's `StartupWMClass`) are unmanaged helpers and the tray + icon. A rule on `class="Slack"` therefore matches nothing. +- `window_role="browser-window"` keeps a call or screen-share window out of the rules. + +**Once the module is assigned, the operator deletes `50-slack.conf`** (below). i3 accepts the same +`for_window` twice, so nothing breaks while both are there. But the rules belong in one place, and +`slack_check` names the file until it is gone. + +## Tools + +They are served by the node's runtime as the operator account (ADR 0175), and are read-only except +`restart`. **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. + +| tool | does | +|---|---| +| `slack_status` (r) |
  • whether Slack runs: the main process's pid, since and scope, and how many helper processes it has
  • the installed version, and whether it is outside the official repositories
  • where its output goes (fd 1 and 2): the journal, `/dev/null`, a pipe someone reads, or a pipe nobody reads
  • whether its icon sits in a tray, and whose (from the X window tree)
  • who owns the session bus's notification name (dunst)
  • the switches: launch on login, hide on start, run from the tray, the notification method, hardware acceleration; and the Electron version
  • how many accounts and workspaces it is signed in to since its last start, counted from the reports Slack logs
  • what starts it at login
| +| `slack_log` (r) | the last `lines` (default 100, at most 2000) of Slack's main-process log (`source: browser`, across its rotations) or of the web app's console (`source: webapp`). `problems: true` keeps `error` and `warn` entries. Tokens (`xox…`), the `d` cookie, anything shaped like a credential, notification and message text, and the names of people, channels and workspaces become ``. Answers are capped at 256 KiB | +| `slack_restart` (a) | ends Slack (SIGTERM, forced after 8 s) and starts `/usr/bin/slack --gtk-version=3 -s` in the operator's session. The start is a transient user unit `mesh-slack`, so it outlives the tools runtime, and its output goes to the journal. Answers the pids. Refused plainly when nobody is logged in to the desktop | +| `slack_check` (r) |
  • Slack is installed (if not: from the AUR, by the operator)
  • exactly one start: the module's line is in i3's configuration, with no autostart entry and no other exec
  • the window rules are placed, and no file of the operator's repeats them
  • one Slack runs in the desktop session
  • **its output is read**: a pipe nobody reads is the session's dead output (below)
  • its icon is in a tray (closing the window otherwise leaves it unreachable)
  • a notifier owns `org.freedesktop.Notifications`
Each finding says what to do | + +**The output check (EPIPE).** A session started before the `i3` module's login entry sent the +session's output to the journal (`systemd-cat -t x-session`) gives every program it starts a stdout +pipe whose reader is gone. An Electron app's write there fails with EPIPE, and an unhandled one is the +"write EPIPE" dialog. Slack's own logger works around it: it silences its console output on EPIPE. Any +other write still fails. So `slack_check` finds the pipe by its reader: it looks for a process of the +account holding the pipe open for reading (`/proc//fdinfo`). It names it when there is none. +`slack_restart` cures it, and every later login does too. + +**Where the tools read:** + +- the processes, and the fd links and fdinfo of Slack's main process, in `/proc` (the account's own + only); +- the settings' switches, from `~/.config/Slack/storage/root-state.json`, and the Electron version from + `local-settings.json`; +- the logs in `~/.config/Slack/logs/default/`. The accounts are counted from the + `STORE_USER_WORKSPACES` entries since the last `INITIALIZE`; +- the window tree from `xwininfo -root -tree`, with the session's `DISPLAY`. The session is found from + the window manager's own environment, as the other desktop modules find it; +- the notifier from `busctl --user list`. + +Every command has a timeout and capped output. Everything runs through an injected runner, a fake root +and a fake link reader in the tests. + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| package | none: `slack-desktop` 4.51.191-1, AUR, installed explicitly | the same | +| start | i3's configuration gains the module's start. **Until the predecessor's `~/.config/autostart/slack.desktop` is deleted, the next login starts Slack twice.** The second start hands over to the first and exits, because Slack is single-instance. Running now: started by dex from that entry at the login of 2026-10-04 16:26 | the same entry, the same until deleted. Running now: since 2026-10-04 17:05, **with its stdout on a pipe nobody reads** (that session predates the `i3` module's login entry), so `slack_check` names it until `slack_restart` or the next login | +| window rules | i3's configuration gains them. The operator's `50-slack.conf` repeats them until deleted | the same | +| settings | *Launch app on login* reads on (the entry exists), start hidden, run from the tray, default notifications (to dunst); 1 account, 1 workspace | the same switches | +| tray, notifications | icon in i3bar's tray; dunst owns the notification name | the same | + +## Migration (ADR 0182), on each workstation, once the module is assigned and pushed + +1. **Delete `~/.config/autostart/slack.desktop`.** It is the predecessor's file (its comment names the + retired desktop module), the second start. At its next start Slack reads *Launch app on login* as + off. Leave the setting off: ticking it makes the entry again, and `slack_check` names it. +2. **Delete `~/.config/i3/config.d/50-slack.conf`.** It is the operator's own file, and its rules are + now the module's. +3. **shanks only:** run `slack_restart`, or log out and in, so that Slack's output is read. + +`slack_check` then answers `ok`. Deleting both files before the module is assigned would leave Slack +without a start and without its rules until it is. + +## Leaves as found + +- `~/.config/Slack/`: the sessions, the settings, the caches, the logs, more than a dozen + `.org.chromium.Chromium.*` leftovers and `StaleCookies-*` files from past upgrades. They are Slack's + to keep and the operator's to delete. +- The package and its desktop entry, `/usr/share/applications/slack.desktop`. +- The `x-scheme-handler/slack` default, which is the `xdg` module's. + +## Relies on + +- **`i3` (the holder of `node-display-session`), which places the contribution.** The contribution is a + dependency on that seat (ADR 0210 §3). Assigning `slack` where no module holds it is refused. +- **`dunst` (the holder of `node-notifier`)** for Slack's notifications, which Electron sends to + `org.freedesktop.Notifications`. Nothing in the mesh declares this dependency, because the module + makes no contribution to that seat. `slack_check` reports a missing notifier instead. +- **A tray in the bar** (i3bar's `tray_output`). Without one, Slack runs with no icon, and closing its + window leaves it unreachable. `slack_check` reports it. +- **`xwininfo` (`xorg-xwininfo`)** for the tray question. No module declares it, and this one does not + install it for a status line. Without it, `slack_status` answers the tray as unknown. +- A display server on the same machine (`x11-display`, ADR 0208 §3). diff --git a/modules/slack/cmd/slack-tools/args.go b/modules/slack/cmd/slack-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/slack/cmd/slack-tools/args.go @@ -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 +} diff --git a/modules/slack/cmd/slack-tools/desktop.go b/modules/slack/cmd/slack-tools/desktop.go new file mode 100644 index 0000000..9df0080 --- /dev/null +++ b/modules/slack/cmd/slack-tools/desktop.go @@ -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"` +} diff --git a/modules/slack/cmd/slack-tools/desktop_test.go b/modules/slack/cmd/slack-tools/desktop_test.go new file mode 100644 index 0000000..3835b38 --- /dev/null +++ b/modules/slack/cmd/slack-tools/desktop_test.go @@ -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)) + } +} diff --git a/modules/slack/cmd/slack-tools/main.go b/modules/slack/cmd/slack-tools/main.go new file mode 100644 index 0000000..bc17665 --- /dev/null +++ b/modules/slack/cmd/slack-tools/main.go @@ -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 . (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() }, + }, + } +} diff --git a/modules/slack/cmd/slack-tools/manifest_test.go b/modules/slack/cmd/slack-tools/manifest_test.go new file mode 100644 index 0000000..b3a90e5 --- /dev/null +++ b/modules/slack/cmd/slack-tools/manifest_test.go @@ -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) + } + } +} diff --git a/modules/slack/cmd/slack-tools/slack.go b/modules/slack/cmd/slack-tools/slack.go new file mode 100644 index 0000000..13ec92d --- /dev/null +++ b/modules/slack/cmd/slack-tools/slack.go @@ -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, "") + s = cookieD.ReplaceAllString(s, "d=") + s = credential.ReplaceAllString(s, "${1}${2}") + return privateKey.ReplaceAllString(s, `"${1}": ""`) +} + +// 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 +} diff --git a/modules/slack/cmd/slack-tools/slack_test.go b/modules/slack/cmd/slack-tools/slack_test.go new file mode 100644 index 0000000..15837ea --- /dev/null +++ b/modules/slack/cmd/slack-tools/slack_test.go @@ -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": ""`) || !strings.Contains(all, "token ") || 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: ") { + 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) + } +} diff --git a/modules/slack/go.mod b/modules/slack/go.mod new file mode 100644 index 0000000..a6ddb7e --- /dev/null +++ b/modules/slack/go.mod @@ -0,0 +1,5 @@ +module slack + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/slack/go.sum b/modules/slack/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/slack/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/slack/module.json b/modules/slack/module.json new file mode 100644 index 0000000..b0b6d76 --- /dev/null +++ b/modules/slack/module.json @@ -0,0 +1,35 @@ +{ + "module": "slack", + "version": "1", + "requires": [ + "x11-display" + ], + "tools": [ + "slack_status", + "slack_log", + "slack_restart", + "slack_check" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/slack-tools", + "binary": "slack-tools", + "loads": [ + "slack-tools" + ] + } + ] + }, + "contributions": [ + { + "seat": "node-display-session", + "kind": "config", + "content": "# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n" + } + ] +}