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