jetbrains_toolbox_updater/
lib.rs

1use dirs::home_dir;
2use json::{JsonError, JsonValue};
3use std::fs::File;
4use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command};
7use std::thread::sleep;
8use std::time::{Duration, Instant};
9use std::{fs, io};
10use sysinfo::{Process, System};
11
12#[derive(Debug, Clone)]
13pub struct JetBrainsToolboxInstallation {
14    binary: PathBuf,
15    channels: PathBuf, // The folder containing configuration for individual IDE's
16    log: PathBuf,
17}
18
19#[derive(Debug)]
20#[non_exhaustive]
21pub enum UpdateError {
22    Io(io::Error),
23    Json(JsonError),
24    InvalidChannel,
25    CouldNotTerminate(String),
26    PrematureExit,
27    DoubleToolboxSelfUpdate,
28    StartupFusAssistantTimeout,
29    DoubleStartupFusAssistant,
30    BadLog(String),
31}
32
33impl From<io::Error> for UpdateError {
34    fn from(err: io::Error) -> UpdateError {
35        UpdateError::Io(err)
36    }
37}
38
39impl From<JsonError> for UpdateError {
40    fn from(err: JsonError) -> UpdateError {
41        UpdateError::Json(err)
42    }
43}
44
45impl JetBrainsToolboxInstallation {
46    fn update_all_channels<F>(&self, mut operation: F) -> Result<(), UpdateError>
47    where
48        F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
49    {
50        for file in fs::read_dir(&self.channels)? {
51            let file = file?;
52            self.update_channel(file.path(), &mut operation)?;
53        }
54        Ok(())
55    }
56
57    fn update_channel<F>(&self, path: PathBuf, operation: &mut F) -> Result<(), UpdateError>
58    where
59        F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
60    {
61        let mut file = File::options().read(true).write(true).open(&path)?;
62        let mut buf = String::new();
63        file.read_to_string(&mut buf)?;
64        let mut data = json::parse(&buf)?;
65        operation(&path, &mut data)?;
66        // Seek to the start, dump, then truncate, to avoid re-opening the file
67        file.seek(SeekFrom::Start(0))?; // Seek
68        buf = data.dump();
69        file.write_all(buf.as_bytes())?; // Dump
70        let current_position = file.stream_position()?;
71        file.set_len(current_position)?; // Truncate
72
73        Ok(())
74    }
75
76    fn start_minimized(&self) -> io::Result<Child> {
77        Command::new(&self.binary).arg("--minimize").spawn()
78    }
79}
80
81#[derive(Debug, Clone)]
82#[non_exhaustive]
83pub enum FindError {
84    NotFound,
85    InvalidInstallation,
86    NoHomeDir,
87    UnsupportedOS(String),
88    NoDesktopFile(String),
89    DesktopFileMissingExec,
90    MultipleMismatchingDesktopFiles(String),
91}
92
93#[cfg(target_os = "linux")]
94pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
95    let home_dir = home_dir().ok_or(FindError::NoHomeDir)?;
96    // TODO: allow custom dir
97    let local_share = home_dir.join(".local/share");
98    let dir = local_share.join("JetBrains/Toolbox");
99    if !dir.exists() {
100        return Err(FindError::NotFound);
101    } else if !dir.is_dir() {
102        // I don't know why there would ever be a normal file there but why not
103        return Err(FindError::InvalidInstallation);
104    }
105    // In previous versions, the binary would copy itself to {dir}/bin
106    let mut binary = dir.join("bin/jetbrains-toolbox");
107    if !binary.exists() {
108        // In newer versions, it doesn't. We use the desktop file to find
109        // the location of the binary, since the user can put it anywhere.
110        binary = get_binary_from_desktop(&binary)?;
111    }
112    let channels = dir.join("channels");
113    if !channels.is_dir() {
114        return Err(FindError::InvalidInstallation);
115    }
116    let logs_dir = dir.join("logs");
117    if !logs_dir.is_dir() {
118        return Err(FindError::InvalidInstallation);
119    }
120    // TODO: The comment below does not seem to make sense.
121    // The log itself might not exist yet, so we don't check for it here
122    let log = logs_dir.join("toolbox.latest.log");
123
124    Ok(JetBrainsToolboxInstallation {
125        binary,
126        channels,
127        log,
128    })
129}
130
131#[cfg(target_os = "linux")]
132fn get_binary_from_desktop(orig_binary: &Path) -> Result<PathBuf, FindError> {
133    let entries = freedesktop_desktop_entry::Iter::new(freedesktop_desktop_entry::default_paths())
134        .entries::<String>(None)
135        .collect::<Vec<_>>();
136
137    let mut matches = entries
138        .iter()
139        .filter(|entry| {
140            entry
141                .path
142                .file_name()
143                .expect("Invalid desktop entry file; terminates in `..`")
144                == "jetbrains-toolbox.desktop"
145        })
146        .map(|entry| entry.exec().ok_or(FindError::DesktopFileMissingExec))
147        .collect::<Result<Vec<_>, _>>()?
148        .into_iter();
149
150    // If multiple desktop files are found but they have the same `Exec` value, it's fine
151    let exec = match matches.next() {
152        None => {
153            return Err(FindError::NoDesktopFile(format!(
154                "No binary was found at {}, and no desktop file named `jetbrains-toolbox.desktop` was found",
155                orig_binary.display(),
156            )))
157        }
158        Some(first) => first,
159    };
160
161    // If they don't have the same `Exec` value, bail
162    if !matches.all(|x| x == exec) {
163        return Err(FindError::MultipleMismatchingDesktopFiles("Multiple desktop files called `jetbrains-toolbox.desktop` were found, and they have different values for Exec".to_string()));
164    }
165
166    let binary = exec.trim_end_matches(" %u");
167
168    println!("Detected binary at {binary} from desktop file");
169
170    Ok(PathBuf::from(binary))
171}
172
173#[cfg(target_os = "windows")]
174pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
175    Err(FindError::UnsupportedOS("Windows".to_string())) // TODO
176}
177
178#[cfg(target_os = "macos")]
179pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
180    Err(FindError::UnsupportedOS("MacOS".to_string())) // TODO
181}
182
183#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
184pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
185    // JetBrains Toolbox is not supported on mobile or BSD
186    Err(FindError::UnsupportedOS(std::env::consts::OS.to_string()))
187}
188
189// Returns if it was open
190fn kill_all() -> Result<bool, UpdateError> {
191    println!("Killing Toolbox");
192    let mut sys = System::new_all();
193    sys.refresh_all();
194    // TODO: this might not work on other platforms; look at this when adding support for Windows/MacOS
195    let processes = sys
196        .processes()
197        .values()
198        .filter_map(|p| {
199            let exe = p.exe()?; // Skip if no exe available
200            let name = p.name();
201            match exe.file_name().ok_or(UpdateError::CouldNotTerminate(
202                "Error getting file_name".to_string(),
203            )) {
204                // There are some weird quirks with processes here.
205                //  psutil in python never had a problem with this, but sysinfo
206                //  results in three different processes.
207                //  In addition to that, there are some other child processes with weird names,
208                //  and the names are cut off to 15 characters.
209                //  Doing it like this results in killing only those three, which is
210                //  probably the best approach.
211                Ok(file_name)
212                    if file_name == "jetbrains-toolbox"
213                        && name.to_str()?.starts_with("jetbrains") =>
214                {
215                    Some(Ok(p))
216                }
217                Ok(_) => None,          // Skip items that don't match
218                Err(e) => Some(Err(e)), // Propagate the error
219            }
220        })
221        .collect::<Result<Vec<&Process>, UpdateError>>()?;
222    Ok(match processes.len() {
223        0 => false, // Was not open
224        _ => {
225            for process in processes {
226                process.kill();
227                process.wait();
228            }
229            true // Was open
230        }
231    })
232}
233
234pub fn update_jetbrains_toolbox(
235    installation: JetBrainsToolboxInstallation,
236) -> Result<(), UpdateError> {
237    _update_jetbrains_toolbox::<false>(installation)
238}
239
240fn _update_jetbrains_toolbox<const IS_RECURSIVE: bool>(
241    installation: JetBrainsToolboxInstallation,
242) -> Result<(), UpdateError> {
243    // Close the app if it's open
244    let toolbox_was_open = kill_all()?;
245
246    // Modify the configuration to enable automatic updates
247    let skipped_channels = change_config(&installation)?;
248
249    let redo = match actual_update(&installation) {
250        Err(e) => {
251            println!("Unexpected error encountered, resetting configuration to previous state");
252            reset_config(&installation, skipped_channels)?;
253            return Err(e);
254        }
255        Ok(redo) => redo,
256    };
257
258    // Reset the configuration
259    reset_config(&installation, skipped_channels)?;
260
261    // Restart the app if it was open
262    if toolbox_was_open {
263        println!("Re-opening Toolbox");
264        installation.start_minimized()?;
265    }
266
267    if redo {
268        // We want to redo. We reset the configuration and re-opened Toolbox
269        //  (technically not needed, but this makes the process a bit simpler)
270        //  So now we just want to redo the entire process.
271        //  We do this by calling this function recursively.
272
273        if IS_RECURSIVE {
274            // Except if this was already recursive, then there must be something very wrong.
275            return Err(UpdateError::DoubleToolboxSelfUpdate);
276        }
277
278        _update_jetbrains_toolbox::<true>(installation)
279    } else {
280        Ok(())
281    }
282}
283
284/// Returns redo
285fn actual_update(installation: &JetBrainsToolboxInstallation) -> Result<bool, UpdateError> {
286    let mut redo = false;
287
288    // Start the app in the background
289    println!("Starting Toolbox");
290    installation.start_minimized()?;
291
292    // Monitor the logs for possible updates, and wait until they're complete
293    let mut updates: u32 = 0;
294    let mut correct_checksums_expected: u32 = 0;
295    let start_time = Instant::now();
296    let mut startup_time = None;
297
298    let file = File::open(&installation.log)?;
299    let mut file = BufReader::new(file);
300    file.seek(SeekFrom::End(0))?;
301    loop {
302        // If 60 seconds pass without the startup event happening, something's wrong.
303        //  Even if there are updates found.
304        if startup_time.is_none() && start_time + Duration::from_secs(60) < Instant::now() {
305            return Err(UpdateError::StartupFusAssistantTimeout);
306        }
307
308        if let Some(startup_time) = startup_time {
309            // If 10 seconds pass from startup, we assume there are no updates
310            if updates == 0 && startup_time + Duration::from_secs(10) < Instant::now() {
311                println!("No updates found");
312                break;
313            }
314        }
315
316        let curr_position = file.stream_position()?;
317
318        // Read a line
319        let mut line = String::new();
320        file.read_line(&mut line)?;
321
322        if line.is_empty() {
323            // There is no new full line, so seek back to before the (possibly partial) line was read,
324            //  and sleep for a bit.
325            file.seek(SeekFrom::Start(curr_position))?;
326            sleep(Duration::from_millis(100));
327        } else {
328            // Each update consists of first downloading, then checking the checksum, then a lot of other things.
329            //  If the download is already there, it won't say "Downloading from", it will skip that
330            //  and immediately say "Correct checksum for".
331            //  This means that a "Correct checksum for" after there was a "Downloading from"
332            //  should not be considered as the start of a separate update.
333            //  We need to support this, because pre-downloaded updates are a real thing that can
334            //  happen, e.g. with self-updates.
335            if line.contains("Correct checksum for") || line.contains("Downloading from") {
336                if line.contains("Correct checksum for") && correct_checksums_expected > 0 {
337                    println!("Verified a checksum for an update that was started earlier");
338                    correct_checksums_expected -= 1;
339                    continue;
340                }
341                // Update started
342                println!("Found an update, waiting until it finishes");
343                updates += 1;
344                if line.contains("Downloading from") {
345                    // We expect "Correct checksum for" to be broadcast exactly once after the "Downloading from".
346                    correct_checksums_expected += 1;
347                }
348            } else if line.contains("Show notification") {
349                // Update finished
350                updates -= 1;
351                if updates == 0 {
352                    println!("All updates finished, exiting in 2 seconds");
353                    sleep(Duration::from_secs(2)); // Letting it finish up
354                    break;
355                } else {
356                    println!("Update finished, waiting for other update(s) to finish")
357                }
358            } else if line.contains("Awaiting user action or background state to install.") {
359                println!(
360                    "Toolbox self-update is ready. The self-update will apply automatically \
361                    in 60 seconds if you don't open Toolbox, but you can also click the \
362                    'Restart Toolbox App to complete update' in the settings menu now."
363                )
364            } else if line.contains(
365                "Shutting down. Reason: The updated app is starting, closing the current process",
366            ) {
367                // The self-update does abide by "Downloading from" and "Correct checksum for", but
368                // since it restarted itself, the state is messed up. We want to redo the entire process once now.
369                redo = true;
370                // Somehow even manually killing the process it's waiting for doesn't convince it.
371                // The only way to continue the self-update process is by waiting for the timeout.
372                println!(
373                    "Toolbox self-update download finished. We will now wait 20 seconds for \
374                    waitForPid to timeout, then we will wait another 10 seconds to make sure \
375                    the self-update is fully installed."
376                );
377                sleep(Duration::from_secs(
378                    20
379                    // Letting it finish up. In this time, it will restart itself.
380                    //  We could theoretically wait for the restart, but that is less necessary, since
381                    //  self-updates are not very common compared to IDE updates.
382                    //  It is also (probably?) not necessary to wait for the restart to fully complete,
383                    //  we just want it to have finished any update-related shutdown or startup tasks.
384                    + 10,
385                ));
386                break;
387            } else if line.contains("Downloaded fus-assistant.xml") {
388                if startup_time.is_some() {
389                    // We expect to get it once
390                    return Err(UpdateError::DoubleStartupFusAssistant);
391                }
392                println!("Toolbox started");
393                startup_time = Some(Instant::now())
394            }
395        }
396    }
397
398    // Quit the app
399    if !kill_all()? {
400        // We expect it to be running.
401        return Err(UpdateError::PrematureExit);
402    }
403
404    Ok(redo)
405}
406
407fn change_config(installation: &JetBrainsToolboxInstallation) -> Result<Vec<PathBuf>, UpdateError> {
408    let mut skipped_channels = vec![];
409    installation.update_all_channels(|channel, d| {
410        if !d.has_key("channel") {
411            return Err(UpdateError::InvalidChannel);
412        }
413        if d["channel"].has_key("autoUpdate") {
414            if d["channel"]["autoUpdate"] == true {
415                // This channel is already auto-updating, we won't touch the configuration in this case
416                skipped_channels.push(channel.clone());
417                return Ok(());
418            } else {
419                // We expect autoUpdate to be missing if it's false
420                return Err(UpdateError::InvalidChannel);
421            }
422        }
423
424        d["channel"]["autoUpdate"] = true.into();
425        Ok(())
426    })?;
427    Ok(skipped_channels)
428}
429
430fn reset_config(
431    installation: &JetBrainsToolboxInstallation,
432    skipped_channels: Vec<PathBuf>,
433) -> Result<(), UpdateError> {
434    installation.update_all_channels(|channel, d| {
435        if !d.has_key("channel") {
436            return Err(UpdateError::InvalidChannel);
437        }
438        if skipped_channels.contains(channel) {
439            // Skip if it was skipped at the start as well
440            return Ok(());
441        }
442        d["channel"].remove("autoUpdate");
443        Ok(())
444    })?;
445    Ok(())
446}