package main // JetBrains Toolbox as the tools see it. Toolbox is not a distribution package: it is JetBrains' // tarball, unpacked once by the operator into ~/.local/share/JetBrains/Toolbox, which then updates // itself in place and installs and updates the IDEs under apps/. The tools read only: // - bin/build.txt, Toolbox's version; // - .settings.json, of which only the switches are answered (launch at login, update the tools // automatically, where the shell scripts go, the theme), never the account it names; // - state.json, the installed tools (name, version, build, where), and channels/.json, each // tool's update channel (Release, EAP …) and whether it updates itself; // - its XDG autostart entry, which Toolbox writes and removes itself for its launch-at-login switch. // // Never read: accounts.json, .securestorage, the logs. They carry the account and its tokens. import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" ) // Where Toolbox keeps things, under the operator's home. const ( toolboxDir = ".local/share/JetBrains/Toolbox" toolboxComm = "jetbrains-toolb" // the kernel keeps 15 characters of jetbrains-toolbox entryName = "jetbrains-toolbox.desktop" restartAs = "mesh-jetbrains-toolbox" ) // running are Toolbox's processes: comm jetbrains-toolb and a command that is Toolbox's launcher. The // comm alone is not enough, because the kernel cuts every name starting jetbrains-toolb… to it; the // bundle is named toolbox-tools for that reason too, so stopping Toolbox by its comm never stops the // tools. func (m *Machine) running() []Proc { out := []Proc{} for _, p := range m.procs(toolboxComm) { if f := strings.Fields(p.Command); len(f) > 0 && filepath.Base(f[0]) == "jetbrains-toolbox" { out = append(out, p) } } return out } func (m *Machine) toolbox(rel ...string) string { return m.home(append([]string{toolboxDir}, rel...)...) } // binary is Toolbox's launcher as the machine names it (no fake root), for starting it. func (m *Machine) binary() string { return filepath.Join(m.Home, toolboxDir, "bin", "jetbrains-toolbox") } // Settings are Toolbox's switches the tools answer. type Settings struct { Found bool `json:"found"` // LaunchAtLogin is Toolbox's "Launch Toolbox App at system startup": absent means on, its default. LaunchAtLogin bool `json:"launch_at_login"` LaunchDefault bool `json:"launch_at_login_is_default,omitempty"` UpdateTools *bool `json:"update_tools_automatically,omitempty"` ShellScripts string `json:"shell_scripts,omitempty"` Theme string `json:"theme,omitempty"` RollbackKeeps *int `json:"rollback_versions_kept,omitempty"` } func (m *Machine) settings() (Settings, error) { s := Settings{LaunchAtLogin: true, LaunchDefault: true} raw, err := readBounded(m.toolbox(".settings.json")) if os.IsNotExist(err) { return s, nil } if err != nil { return s, fmt.Errorf("reading Toolbox's settings: %w", err) } var doc struct { Autostart *bool `json:"autostart"` Tools struct { UpdateAll *bool `json:"update_all_automatically"` } `json:"tools"` ShellScripts struct { Location string `json:"location"` } `json:"shell_scripts"` UI struct { Theme string `json:"theme"` } `json:"ui"` Rollback *int `json:"channel_rollback_max_history"` } if err := json.Unmarshal(raw, &doc); err != nil { return s, fmt.Errorf("Toolbox's settings are not JSON: %w", err) } s.Found = true if doc.Autostart != nil { s.LaunchAtLogin, s.LaunchDefault = *doc.Autostart, false } s.UpdateTools, s.Theme, s.RollbackKeeps = doc.Tools.UpdateAll, doc.UI.Theme, doc.Rollback if doc.ShellScripts.Location != "" { s.ShellScripts = m.tilde(doc.ShellScripts.Location) } return s, nil } // Tool is one IDE (or other tool) Toolbox has installed. type Tool struct { Name string `json:"name"` ID string `json:"id"` Version string `json:"version"` Build string `json:"build,omitempty"` // Channel is the update channel the operator chose for it in Toolbox: Release, EAP, …. Channel string `json:"channel,omitempty"` AutoUpdate *bool `json:"updates_itself,omitempty"` Location string `json:"location"` Present bool `json:"present"` location string } // State is what state.json says: Toolbox's version when it last wrote it, and the tools. type State struct { Found bool `json:"found"` AppVersion string `json:"app_version,omitempty"` Tools []Tool `json:"tools"` // Untracked are directories under apps/ that state.json does not name. Untracked []string `json:"untracked,omitempty"` } func (m *Machine) state() (State, error) { st := State{Tools: []Tool{}} raw, err := readBounded(m.toolbox("state.json")) if os.IsNotExist(err) { return st, nil } if err != nil { return st, fmt.Errorf("reading Toolbox's state: %w", err) } var doc struct { AppVersion string `json:"appVersion"` Tools []struct { ChannelID string `json:"channelId"` ToolID string `json:"toolId"` Name string `json:"displayName"` Version string `json:"displayVersion"` Build string `json:"buildNumber"` Location string `json:"installLocation"` } `json:"tools"` } if err := json.Unmarshal(raw, &doc); err != nil { return st, fmt.Errorf("Toolbox's state is not JSON: %w", err) } st.Found, st.AppVersion = true, doc.AppVersion known := map[string]bool{} for _, t := range doc.Tools { tool := Tool{Name: t.Name, ID: t.ToolID, Version: t.Version, Build: t.Build, location: t.Location, Location: m.tilde(t.Location), Present: t.Location != "" && exists(filepath.Join(m.Root, t.Location))} tool.Channel, tool.AutoUpdate = m.channel(t.ChannelID) known[filepath.Base(t.Location)] = true st.Tools = append(st.Tools, tool) } sort.Slice(st.Tools, func(i, j int) bool { return st.Tools[i].Name < st.Tools[j].Name }) entries, _ := os.ReadDir(m.toolbox("apps")) for _, e := range entries { if e.IsDir() && !known[e.Name()] { st.Untracked = append(st.Untracked, "~/"+toolboxDir+"/apps/"+e.Name()) } } return st, nil } // channel reads one tool's channel file: the quality filter's name, and its own update switch. func (m *Machine) channel(id string) (string, *bool) { if id == "" || strings.ContainsAny(id, "/\\") { return "", nil } raw, err := readBounded(m.toolbox("channels", id+".json")) if err != nil { return "", nil } var doc struct { Channel struct { Filter struct { Quality struct { Name string `json:"name"` } `json:"quality_filter"` } `json:"updateFilter"` AutoUpdate *bool `json:"autoUpdate"` } `json:"channel"` } if json.Unmarshal(raw, &doc) != nil { return "", nil } return doc.Channel.Filter.Quality.Name, doc.Channel.AutoUpdate } // version is Toolbox's own version, from the build file beside its launcher. func (m *Machine) version() string { return readTrimmed(m.toolbox("bin", "build.txt")) } func (m *Machine) installedHere() bool { fi, err := os.Stat(m.toolbox("bin", "jetbrains-toolbox")) return err == nil && fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 } // homeless shows every path under the home in a text as ~/…: Toolbox's command line and its entry's // Exec name its own location, and an answer does not carry the account's name. func (m *Machine) homeless(s string) string { if h := strings.TrimSuffix(m.Home, "/"); h != "" { return strings.ReplaceAll(s, h+"/", "~/") } return s } func (m *Machine) homelessProcs(ps []Proc) []Proc { out := []Proc{} for _, p := range ps { p.Command = m.homeless(p.Command) out = append(out, p) } return out } // Status is what toolbox_status answers. type Status struct { Installed bool `json:"installed"` Where string `json:"where"` Version string `json:"version,omitempty"` Running []Proc `json:"running"` Settings Settings `json:"settings"` State State `json:"installed_tools"` StartedBy Autostart `json:"started_by"` } func (m *Machine) Status() (Status, error) { s := Status{Installed: m.installedHere(), Where: "~/" + toolboxDir, Version: m.version(), Running: m.homelessProcs(m.running()), StartedBy: m.autostart(entryName)} s.StartedBy.Exec = m.homeless(s.StartedBy.Exec) var err error if s.Settings, err = m.settings(); err != nil { return s, err } if s.State, err = m.state(); err != nil { return s, err } return s, nil } // RestartAnswer is what toolbox_restart answers. type RestartAnswer struct { Ended []int `json:"ended"` Killed []int `json:"killed,omitempty"` Running []Proc `json:"running"` Session Session `json:"session"` Unit string `json:"unit"` } // Restart ends Toolbox and starts it again minimised to the tray, as its autostart entry does, in the // operator's session under the account's service manager. The IDEs it launched are their own // processes and keep running. func (m *Machine) Restart() (RestartAnswer, error) { if !m.installedHere() { return RestartAnswer{}, fmt.Errorf("Toolbox is not installed in ~/%s: nothing to start", toolboxDir) } s, err := m.session() if err != nil { return RestartAnswer{}, err } a := RestartAnswer{Session: s, Unit: restartAs + ".service"} a.Ended, a.Killed = m.stop(8*time.Second, toolboxComm) if err := m.detach(s, restartAs, m.binary(), "--minimize"); err != nil { return a, err } m.waitFor(toolboxComm, 6*time.Second) a.Running = m.homelessProcs(m.running()) if len(a.Running) == 0 { return a, fmt.Errorf("Toolbox was started as %s but no %s process appeared within 6 s: "+ "see `journalctl --user -u %s`", a.Unit, toolboxComm, a.Unit) } return a, nil } // CheckAnswer is what toolbox_check answers. type CheckAnswer struct { OK bool `json:"ok"` Findings []Finding `json:"findings"` Starts []string `json:"starts"` Notes []string `json:"notes,omitempty"` } // Check verifies what the module promises: Toolbox is where it is kept, it starts at most once (its // own autostart entry, in agreement with its setting), it runs at most once, and every tool it lists // is on disk. func (m *Machine) Check() (CheckAnswer, error) { a := CheckAnswer{Findings: []Finding{}, Starts: []string{}} add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) } if !m.installedHere() { add("Toolbox is not installed in ~/"+toolboxDir, "download JetBrains' tarball and run its bin/jetbrains-toolbox once: the module does not install it (see its README)") a.OK = false return a, nil } set, err := m.settings() if err != nil { return a, err } entry := m.autostart(entryName) if entry.Starts { a.Starts = append(a.Starts, "XDG autostart: "+entry.From) if !strings.Contains(entry.Exec, "jetbrains-toolbox") { add("the autostart entry runs "+m.homeless(entry.Exec)+", not Toolbox", "untick and tick 'Launch Toolbox App at system startup' in Toolbox's settings: it writes the entry again") } if !set.LaunchAtLogin { add("Toolbox's setting says not to launch at login, but its autostart entry is there", "tick and untick 'Launch Toolbox App at system startup', or delete "+entry.From) } if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil { add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it") } } else if set.LaunchAtLogin { add("Toolbox does not start with the session ("+entry.Because+"), although its setting says it does", "untick and tick 'Launch Toolbox App at system startup' in Toolbox's settings: it writes its own entry") } else { a.Notes = append(a.Notes, "Toolbox does not start at login: its setting is off, so it runs when the operator opens it") } for _, l := range m.i3Starts("jetbrains-toolbox") { a.Starts = append(a.Starts, "window manager: "+l) add("a second start: "+l, "remove the line; Toolbox's own autostart entry is its one start") } running := m.running() if _, err := m.session(); err == nil { switch { case len(running) > 1: add(fmt.Sprintf("%d Toolbox processes run", len(running)), "toolbox_restart ends them all and starts one") case len(running) == 0 && entry.Starts: add("Toolbox does not run in the desktop session although it starts at login", "toolbox_restart") } } st, err := m.state() if err != nil { return a, err } for _, t := range st.Tools { if !t.Present { add(t.Name+" "+t.Version+" is in Toolbox's list but not on disk ("+t.Location+")", "reinstall or remove it in Toolbox") } } for _, u := range st.Untracked { a.Notes = append(a.Notes, u+" is not in Toolbox's list: an IDE installed by hand or left behind by Toolbox") } a.OK = len(a.Findings) == 0 return a, nil }