Skip to main content

jetbrains_toolbox_updater/
lib.rs

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