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