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