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::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}
31
32impl From<io::Error> for UpdateError {
33    fn from(err: io::Error) -> UpdateError {
34        UpdateError::Io(err)
35    }
36}
37
38impl From<JsonError> for UpdateError {
39    fn from(err: JsonError) -> UpdateError {
40        UpdateError::Json(err)
41    }
42}
43
44impl JetBrainsToolboxInstallation {
45    fn update_all_channels<F>(&self, mut operation: F) -> Result<(), UpdateError>
46    where
47        F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
48    {
49        for file in fs::read_dir(&self.channels)? {
50            let file = file?;
51            self.update_channel(file.path(), &mut operation)?;
52        }
53        Ok(())
54    }
55
56    fn update_channel<F>(&self, path: PathBuf, operation: &mut F) -> Result<(), UpdateError>
57    where
58        F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
59    {
60        let mut file = File::options().read(true).write(true).open(&path)?;
61        let mut buf = String::new();
62        file.read_to_string(&mut buf)?;
63        let mut data = json::parse(&buf)?;
64        operation(&path, &mut data)?;
65        // Seek to the start, dump, then truncate, to avoid re-opening the file
66        file.seek(SeekFrom::Start(0))?; // Seek
67        buf = data.dump();
68        file.write_all(buf.as_bytes())?; // Dump
69        let current_position = file.stream_position()?;
70        file.set_len(current_position)?; // Truncate
71
72        Ok(())
73    }
74
75    fn start_minimized(&self) -> io::Result<Child> {
76        Command::new(&self.binary).arg("--minimize").spawn()
77    }
78}
79
80#[derive(Debug, Clone)]
81#[non_exhaustive]
82pub enum FindError {
83    NotFound,
84    InvalidInstallation,
85    NoHomeDir,
86    UnsupportedOS(String),
87}
88
89#[cfg(target_os = "linux")]
90pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
91    let home_dir = home_dir().ok_or(FindError::NoHomeDir)?;
92    let dir = home_dir.join(".local/share/JetBrains/Toolbox");
93    if !dir.exists() {
94        return Err(FindError::NotFound);
95    } else if !dir.is_dir() {
96        // I don't know why there would ever be a normal file there but why not
97        return Err(FindError::InvalidInstallation);
98    }
99    let binary = dir.join("bin/jetbrains-toolbox");
100    if !binary.exists() {
101        return Err(FindError::InvalidInstallation);
102    }
103    let channels = dir.join("channels");
104    if !channels.is_dir() {
105        return Err(FindError::InvalidInstallation);
106    }
107    let logs_dir = dir.join("logs");
108    if !logs_dir.is_dir() {
109        return Err(FindError::InvalidInstallation);
110    }
111    let log = logs_dir.join("toolbox.latest.log"); // The log itself might not exist yet, so we don't check for it here
112
113    Ok(JetBrainsToolboxInstallation {
114        binary,
115        channels,
116        log,
117    })
118}
119
120#[cfg(target_os = "windows")]
121pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
122    Err(FindError::UnsupportedOS("Windows".to_string())) // TODO
123}
124
125#[cfg(target_os = "macos")]
126pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
127    Err(FindError::UnsupportedOS("MacOS".to_string())) // TODO
128}
129
130#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
131pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
132    // JetBrains Toolbox is not supported on mobile or BSD
133    Err(FindError::UnsupportedOS(std::env::consts::OS.to_string()))
134}
135
136// Returns if it was open
137fn kill_all() -> Result<bool, UpdateError> {
138    let mut sys = System::new_all();
139    sys.refresh_all();
140    // TODO: this might not work on other platforms; look at this when adding support for Windows/MacOS
141    let processes = sys
142        .processes()
143        .values()
144        .filter_map(|p| {
145            let exe = p.exe()?; // Skip if no exe available
146            let name = p.name();
147            match exe.file_name().ok_or(UpdateError::CouldNotTerminate(
148                "Error getting file_name".to_string(),
149            )) {
150                // There are some weird quirks with processes here.
151                //  psutil in python never had a problem with this, but sysinfo
152                //  results in three different processes.
153                //  In addition to that, there are some other child processes with weird names,
154                //  and the names are cut off to 15 characters.
155                //  Doing it like this results in killing only those three, which is
156                //  probably the best approach.
157                Ok(file_name)
158                    if file_name == "jetbrains-toolbox"
159                        && name.to_str()?.starts_with("jetbrains") =>
160                {
161                    Some(Ok(p))
162                }
163                Ok(_) => None,          // Skip items that don't match
164                Err(e) => Some(Err(e)), // Propagate the error
165            }
166        })
167        .collect::<Result<Vec<&Process>, UpdateError>>()?;
168    Ok(match processes.len() {
169        0 => false, // Was not open
170        _ => {
171            for process in processes {
172                process.kill();
173                process.wait();
174            }
175            true // Was open
176        }
177    })
178}
179
180pub fn update_jetbrains_toolbox(
181    installation: JetBrainsToolboxInstallation,
182) -> Result<(), UpdateError> {
183    _update_jetbrains_toolbox::<false>(installation)
184}
185
186fn _update_jetbrains_toolbox<const IS_RECURSIVE: bool>(
187    installation: JetBrainsToolboxInstallation,
188) -> Result<(), UpdateError> {
189    // Close the app if it's open
190    let toolbox_was_open = kill_all()?;
191
192    // Modify the configuration to enable automatic updates
193    let skipped_channels = change_config(&installation)?;
194
195    let redo = match actual_update(&installation) {
196        Err(e) => {
197            println!("Unexpected error encountered, resetting configuration to previous state");
198            reset_config(&installation, skipped_channels)?;
199            return Err(e);
200        }
201        Ok(redo) => redo,
202    };
203
204    // Reset the configuration
205    reset_config(&installation, skipped_channels)?;
206
207    // Restart the app if it was open
208    if toolbox_was_open {
209        installation.start_minimized()?;
210    }
211
212    if redo {
213        // We want to redo. We reset the configuration and re-opened Toolbox
214        //  (technically not needed, but this makes the process a bit simpler)
215        //  So now we just want to redo the entire process.
216        //  We do this by calling this function recursively.
217
218        if IS_RECURSIVE {
219            // Except if this was already recursive, then there must be something very wrong.
220            return Err(UpdateError::DoubleToolboxSelfUpdate);
221        }
222
223        _update_jetbrains_toolbox::<true>(installation)
224    } else {
225        Ok(())
226    }
227}
228
229/// Returns redo
230fn actual_update(installation: &JetBrainsToolboxInstallation) -> Result<bool, UpdateError> {
231    let mut redo = false;
232
233    // Start the app in the background
234    installation.start_minimized()?;
235
236    // Monitor the logs for possible updates, and wait until they're complete
237    let mut updates: u32 = 0;
238    let mut correct_checksums_expected: u32 = 0;
239    let start_time = Instant::now();
240    let mut startup_time = None;
241
242    let file = File::open(&installation.log)?;
243    let mut file = BufReader::new(file);
244    file.seek(SeekFrom::End(0))?;
245    loop {
246        // If 60 seconds pass without the startup event happening, something's wrong.
247        //  Even if there are updates found.
248        if startup_time.is_none() && start_time + Duration::from_secs(60) < Instant::now() {
249            return Err(UpdateError::StartupFusAssistantTimeout);
250        }
251
252        if let Some(startup_time) = startup_time {
253            // If 10 seconds pass from startup, we assume there are no updates
254            if updates == 0 && startup_time + Duration::from_secs(10) < Instant::now() {
255                println!("No updates found.");
256                break;
257            }
258        }
259
260        let curr_position = file.stream_position()?;
261
262        // Read a line
263        let mut line = String::new();
264        file.read_line(&mut line)?;
265
266        if line.is_empty() {
267            // There is no new full line, so seek back to before the (possibly partial) line was read,
268            //   and sleep for a bit.
269            file.seek(SeekFrom::Start(curr_position))?;
270            sleep(Duration::from_millis(100));
271        } else {
272            // Each update consists of first downloading, then checking the checksum, then a lot of other things.
273            //  If the download is already there, it won't say "Downloading from", it will skip that
274            //  and immediately say "Correct checksum for".
275            //  This means that a "Correct checksum for" after there was a "Downloading from"
276            //  should not be considered as the start of a separate update.
277            if line.contains("Correct checksum for") || line.contains("Downloading from") {
278                if line.contains("Correct checksum for") && correct_checksums_expected > 0 {
279                    correct_checksums_expected -= 1;
280                    continue;
281                }
282                // Update started
283                println!("Found an update, waiting until it finishes...");
284                updates += 1;
285                if line.contains("Downloading from") {
286                    // We expect "Correct checksum for" to be broadcast exactly once after the "Downloading from".
287                    correct_checksums_expected += 1;
288                }
289            } else if line.contains("Show notification") {
290                // Update finished
291                updates -= 1;
292                if updates == 0 {
293                    println!("Update finished, exiting...");
294                    sleep(Duration::from_secs(2)); // Letting it finish up
295                    break;
296                } else {
297                    println!("Update finished, waiting for other update(s) to finish")
298                }
299            } else if line.contains(
300                "Shutting down. Reason: The updated app is starting, closing the current process",
301            ) {
302                // Toolbox (self-)update finished. This does say "Downloading from" when starting.
303                // But since it restarted itself the state is messed up. We want to re-do the entire process once now.
304                redo = true;
305                println!("Toolbox (self-)update finished.");
306                // Letting it finish up. In this time, it will restart itself.
307                //  We could theoretically wait for the restart, but that is less necessary, since
308                //  self-updates are not very common compared to IDE updates.
309                sleep(Duration::from_secs(10));
310                break;
311            } else if line.contains("Downloaded fus-assistant.xml") {
312                if startup_time.is_some() {
313                    // We expect to get it once
314                    return Err(UpdateError::DoubleStartupFusAssistant);
315                }
316                startup_time = Some(Instant::now())
317            }
318        }
319    }
320
321    // Quit the app
322    if !kill_all()? {
323        // We expect it to be running.
324        return Err(UpdateError::PrematureExit);
325    }
326
327    Ok(redo)
328}
329
330fn change_config(installation: &JetBrainsToolboxInstallation) -> Result<Vec<PathBuf>, UpdateError> {
331    let mut skipped_channels = vec![];
332    installation.update_all_channels(|channel, d| {
333        if !d.has_key("channel") {
334            return Err(UpdateError::InvalidChannel);
335        }
336        if d["channel"].has_key("autoUpdate") {
337            if d["channel"]["autoUpdate"] == true {
338                // This channel is already auto-updating, we won't touch the configuration in this case
339                skipped_channels.push(channel.clone());
340                return Ok(());
341            } else {
342                // We expect autoUpdate to be missing if it's false
343                return Err(UpdateError::InvalidChannel);
344            }
345        }
346
347        d["channel"]["autoUpdate"] = true.into();
348        Ok(())
349    })?;
350    Ok(skipped_channels)
351}
352
353fn reset_config(
354    installation: &JetBrainsToolboxInstallation,
355    skipped_channels: Vec<PathBuf>,
356) -> Result<(), UpdateError> {
357    installation.update_all_channels(|channel, d| {
358        if !d.has_key("channel") {
359            return Err(UpdateError::InvalidChannel);
360        }
361        if skipped_channels.contains(channel) {
362            // Skip if it was skipped at the start as well
363            return Ok(());
364        }
365        d["channel"].remove("autoUpdate");
366        Ok(())
367    })?;
368    Ok(())
369}