Merge pull request 'Remove jetbrains-toolbox: the operator retired JetBrains' (#61) from chore/remove-jetbrains into main

This commit was merged in pull request #61.
This commit is contained in:
2026-10-05 13:09:53 +00:00
21 changed files with 13 additions and 1742 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
package main
// desktop.go is the same file in the nextcloud-client, blueman, slack and jetbrains-toolbox bundles: a
// desktop.go is the same file in the nextcloud-client, blueman and slack bundles: a
// tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq
// ADR 0208).
//
@@ -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, blueman, slack and jetbrains-toolbox bundles.
// nextcloud-client, blueman and slack bundles.
import (
"context"
-119
View File
@@ -1,119 +0,0 @@
# 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) | <ul><li>whether Toolbox is installed, its version (`bin/build.txt`) and the version its state was last written by</li><li>whether it runs: pid, since, and the unit or scope</li><li>what starts it at login</li><li>its switches: launch at login (and whether that is the default), update the tools automatically, the shell scripts' folder, the theme, how many versions it keeps for a rollback</li><li>every tool it installed: name, version, build, **update channel** (Release, Early Access Program …), whether that tool updates itself, where it is and whether it is on disk</li><li>any directory under `apps/` that its list does not name</li></ul> |
| `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) | <ul><li>Toolbox is installed</li><li>at most one start: the entry is there exactly when the setting is on, it runs Toolbox, `dex` is installed, and no window-manager exec</li><li>at most one Toolbox runs, and one runs when it starts at login</li><li>every tool in its list is on disk</li></ul>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).
@@ -1,97 +0,0 @@
// 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
}
@@ -1,575 +0,0 @@
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"`
}
@@ -1,202 +0,0 @@
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))
}
}
@@ -1,10 +0,0 @@
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)) }
@@ -1,52 +0,0 @@
// 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() },
},
}
}
@@ -1,96 +0,0 @@
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)
}
}
}
@@ -1,349 +0,0 @@
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
}
@@ -1,198 +0,0 @@
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)
}
}
-5
View File
@@ -1,5 +0,0 @@
module jetbrains-toolbox
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
-2
View File
@@ -1,2 +0,0 @@
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=
-27
View File
@@ -1,27 +0,0 @@
{
"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"
]
}
]
}
}
@@ -1,6 +1,6 @@
package main
// desktop.go is the same file in the nextcloud-client, blueman, slack and jetbrains-toolbox bundles: a
// desktop.go is the same file in the nextcloud-client, blueman and slack bundles: a
// tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq
// ADR 0208).
//
@@ -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, blueman, slack and jetbrains-toolbox bundles.
// nextcloud-client, blueman and slack bundles.
import (
"context"
+1 -1
View File
@@ -1,6 +1,6 @@
package main
// desktop.go is the same file in the nextcloud-client, blueman, slack and jetbrains-toolbox bundles: a
// desktop.go is the same file in the nextcloud-client, blueman and slack bundles: a
// tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq
// ADR 0208).
//
@@ -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, blueman, slack and jetbrains-toolbox bundles.
// nextcloud-client, blueman and slack bundles.
import (
"context"
+6 -2
View File
@@ -28,7 +28,7 @@ or reads `/proc`. It is for the two workstations only.
| the folders themselves (`~/Downloads`, …) | the person's, held as found | they hold the person's files. Declaring a directory would make its mode the mesh's (ADR 0182), and on one workstation `~/Desktop` is a file, not a folder |
| `~/.config/autostart/*`, `/etc/xdg/autostart/*` | each application's | an autostart entry belongs to the module of the application it starts (below) |
| `~/.config/xdg-desktop-portal/portals.conf` | `adwaita` | the portal's backends are the theme's business |
| `~/.local/share/applications/*.desktop` | the person's and their installers' | JetBrains Toolbox, Steam, Telegram and the agent write their own launchers and handlers there |
| `~/.local/share/applications/*.desktop` | the person's and their installers' | Steam, Telegram and the agent write their own launchers and handlers there |
## The folders
@@ -99,7 +99,7 @@ GLib's `gio`, against copies:
`Postman.desktop`, and a Fleet launcher whose name is the id one machine's JetBrains Toolbox
generated (a different one on the other machine);
- kept: Firefox for the web, HTML and PDF; Thunar for folders; ghostwriter for Markdown; Plexamp, Slack,
Telegram and JetBrains Toolbox for their own links; and mpv, mplayer, vlc for MP4.
Telegram for their own links; and mpv, mplayer, vlc for MP4.
The person's list keeps every line as found, the dropped ones included. `xdg_check` names them.
@@ -180,3 +180,7 @@ no list names a default for it: one `xdg_default` with `set` settles it.
## Blockers
- The three settings must be set before the first assignment (above).
**JetBrains removed (2026-10-05).** The operator retired JetBrains: its link handler left the mesh's list, and
the Toolbox, its IDEs and launchers were moved off both workstations.
-1
View File
@@ -22,7 +22,6 @@ x-scheme-handler/https=firefox.desktop;
text/markdown=org.kde.ghostwriter.desktop;
inode/directory=thunar.desktop;
x-scheme-handler/abc=plexamp.desktop;
x-scheme-handler/jetbrains=jetbrains-toolbox.desktop;
x-scheme-handler/slack=slack.desktop;
x-scheme-handler/tg=org.telegram.desktop.desktop;
+1 -1
View File
@@ -51,7 +51,7 @@
"type": "file",
"path": "/etc/xdg/mimeapps.list",
"mode": "0644",
"content": "# The machine's default applications (module xdg, novox/hq ADR 0182), written by the mesh and\n# replaced at every push.\n#\n# This is the lowest list an application consults. The account's own ~/.config/mimeapps.list comes\n# first: every program that offers \"always open with\" writes there, and so does xdg_default. That\n# file is the person's and is never written by the mesh, so a choice made there always wins; this\n# list answers only what the person has not chosen. Adopted from the workstations' own lists of\n# 2026-10-05, without the entries naming an application neither workstation has installed (see the\n# module's README).\n[Default Applications]\napplication/pdf=firefox.desktop;\napplication/x-extension-htm=firefox.desktop;\napplication/x-extension-shtml=firefox.desktop;\napplication/x-extension-xht=firefox.desktop;\napplication/x-extension-xhtml=firefox.desktop;\napplication/xhtml+xml=firefox.desktop;\ntext/html=firefox.desktop;\nx-scheme-handler/chrome=firefox.desktop;\nx-scheme-handler/ftp=firefox.desktop;\nx-scheme-handler/http=firefox.desktop;\nx-scheme-handler/https=firefox.desktop;\ntext/markdown=org.kde.ghostwriter.desktop;\ninode/directory=thunar.desktop;\nx-scheme-handler/abc=plexamp.desktop;\nx-scheme-handler/jetbrains=jetbrains-toolbox.desktop;\nx-scheme-handler/slack=slack.desktop;\nx-scheme-handler/tg=org.telegram.desktop.desktop;\n\n[Added Associations]\nvideo/mp4=mpv.desktop;mplayer.desktop;vlc.desktop;\n"
"content": "# The machine's default applications (module xdg, novox/hq ADR 0182), written by the mesh and\n# replaced at every push.\n#\n# This is the lowest list an application consults. The account's own ~/.config/mimeapps.list comes\n# first: every program that offers \"always open with\" writes there, and so does xdg_default. That\n# file is the person's and is never written by the mesh, so a choice made there always wins; this\n# list answers only what the person has not chosen. Adopted from the workstations' own lists of\n# 2026-10-05, without the entries naming an application neither workstation has installed (see the\n# module's README).\n[Default Applications]\napplication/pdf=firefox.desktop;\napplication/x-extension-htm=firefox.desktop;\napplication/x-extension-shtml=firefox.desktop;\napplication/x-extension-xht=firefox.desktop;\napplication/x-extension-xhtml=firefox.desktop;\napplication/xhtml+xml=firefox.desktop;\ntext/html=firefox.desktop;\nx-scheme-handler/chrome=firefox.desktop;\nx-scheme-handler/ftp=firefox.desktop;\nx-scheme-handler/http=firefox.desktop;\nx-scheme-handler/https=firefox.desktop;\ntext/markdown=org.kde.ghostwriter.desktop;\ninode/directory=thunar.desktop;\nx-scheme-handler/abc=plexamp.desktop;\nx-scheme-handler/slack=slack.desktop;\nx-scheme-handler/tg=org.telegram.desktop.desktop;\n\n[Added Associations]\nvideo/mp4=mpv.desktop;mplayer.desktop;vlc.desktop;\n"
}
],
"build": {