Files
mesh-catalog/modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox.go
T
jochen 75f25fca21 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.
2026-10-05 15:05:55 +02:00

350 lines
12 KiB
Go

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
}