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.
350 lines
12 KiB
Go
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
|
|
}
|