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

Slack comes from the AUR and Toolbox from JetBrains' self-updating tarball,
so neither is declared: the host installs official packages only, and a
pinned archive would fight Toolbox's own updates. Slack's start and the
operator's i3 window rules become one node-display-session contribution,
because Slack's own launch-on-login is a symlink in ~/.config/autostart.
Toolbox keeps its own autostart entry as its one start. The shared
desktop.go header now names all four bundles.
This commit is contained in:
jochen
2026-10-05 15:05:55 +02:00
parent 4f3e52b47a
commit 75f25fca21
28 changed files with 4054 additions and 6 deletions
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
@@ -0,0 +1,575 @@
package main
// desktop.go is the same file in the nextcloud-client, blueman, slack and jetbrains-toolbox bundles: a
// tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq
// ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var pids []int
for _, c := range comms {
for _, p := range m.procs(c) {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procs(comm); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,202 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client, blueman, slack and jetbrains-toolbox bundles.
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,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)) }
@@ -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() },
},
}
}
@@ -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)
}
}
}
@@ -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/<id>.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
}
@@ -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)
}
}