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(installation: JetBrainsToolboxInstallation) -> Result<(), UpdateError> {
177    _update_jetbrains_toolbox::<false>(installation)
178}
179
180fn _update_jetbrains_toolbox<const IS_RECURSIVE: bool>(
181    installation: JetBrainsToolboxInstallation,
182) -> Result<(), UpdateError> {
183    // Close the app if it's open
184    let toolbox_was_open = kill_all()?;
185
186    // Modify the configuration to enable automatic updates
187    let skipped_channels = change_config(&installation)?;
188
189    let redo = match actual_update(&installation) {
190        Err(e) => {
191            println!("Unexpected error encountered, resetting configuration to previous state");
192            reset_config(&installation, skipped_channels)?;
193            return Err(e);
194        }
195        Ok(redo) => redo,
196    };
197
198    // Reset the configuration
199    reset_config(&installation, skipped_channels)?;
200
201    // Restart the app if it was open
202    if toolbox_was_open {
203        installation.start_minimized()?;
204    }
205
206    if redo {
207        // We want to redo. We reset the configuration and re-opened Toolbox
208        //  (technically not needed, but this makes the process a bit simpler)
209        //  So now we just want to redo the entire process.
210        //  We do this by calling this function recursively.
211
212        if IS_RECURSIVE {
213            // Except if this was already recursive, then there must be something very wrong.
214            return Err(UpdateError::DoubleToolboxSelfUpdate);
215        }
216
217        _update_jetbrains_toolbox::<true>(installation)
218    } else {
219        Ok(())
220    }
221}
222
223/// Returns redo
224fn actual_update(installation: &JetBrainsToolboxInstallation) -> Result<bool, UpdateError> {
225    let mut redo = false;
226
227    // Start the app in the background
228    installation.start_minimized()?;
229
230    // Monitor the logs for possible updates, and wait until they're complete
231    let mut updates: u32 = 0;
232    let mut correct_checksums_expected: u32 = 0;
233    let start_time = Instant::now();
234
235    let file = File::open(&installation.log)?;
236    let mut file = BufReader::new(file);
237    file.seek(SeekFrom::End(0))?;
238    loop {
239        // TODO: Unfortunately there is no log message indicating there are no updates; so waiting is necessary it looks like.
240        //  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.
241        if updates == 0 && start_time + Duration::from_secs(10) < Instant::now() {
242            println!("No updates found.");
243            break;
244        }
245
246        let curr_position = file.stream_position()?;
247
248        // Read a line
249        let mut line = String::new();
250        file.read_line(&mut line)?;
251
252        if line.is_empty() {
253            // There is no new full line, so seek back to before the (possibly partial) line was read,
254            //   and sleep for a bit.
255            file.seek(SeekFrom::Start(curr_position))?;
256            sleep(Duration::from_millis(100));
257        } else {
258            // Each update consists of first downloading, then checking the checksum, then a lot of other things.
259            //  If the download is already there, it won't say "Downloading from", it will skip that
260            //  and immediately say "Correct checksum for".
261            //  This means that a "Correct checksum for" after there was a "Downloading from"
262            //  should not be considered as the start of a separate update.
263            if line.contains("Correct checksum for") || line.contains("Downloading from") {
264                if line.contains("Correct checksum for") && correct_checksums_expected > 0 {
265                    correct_checksums_expected -= 1;
266                    continue;
267                }
268                // Update started
269                println!("Found an update, waiting until it finishes...");
270                updates += 1;
271                if line.contains("Downloading from") {
272                    // We expect "Correct checksum for" to be broadcast exactly once after the "Downloading from".
273                    correct_checksums_expected += 1;
274                }
275            } else if line.contains("Show notification") {
276                // Update finished
277                updates -= 1;
278                if updates == 0 {
279                    println!("Update finished, exiting...");
280                    sleep(Duration::from_secs(2)); // Letting it finish up
281                    break;
282                } else {
283                    println!("Update finished, waiting for other update(s) to finish")
284                }
285            } else if line.contains(
286                "Shutting down. Reason: The updated app is starting, closing the current process",
287            ) {
288                // Toolbox (self-)update finished. This does say "Downloading from" when starting.
289                // But since it restarted itself the state is messed up. We want to re-do the entire process once now.
290                redo = true;
291                println!("Toolbox (self-)update finished.");
292                sleep(Duration::from_secs(2)); // Letting it finish up
293                break;
294            }
295        }
296    }
297
298    // Quit the app
299    if !kill_all()? {
300        // We expect it to be running.
301        return Err(UpdateError::PrematureExit);
302    }
303
304    Ok(redo)
305}
306
307fn change_config(installation: &JetBrainsToolboxInstallation) -> Result<Vec<PathBuf>, UpdateError> {
308    let mut skipped_channels = vec![];
309    installation.update_all_channels(|channel, d| {
310        if !d.has_key("channel") {
311            return Err(UpdateError::InvalidChannel);
312        }
313        if d["channel"].has_key("autoUpdate") {
314            if d["channel"]["autoUpdate"] == true {
315                // This channel is already auto-updating, we won't touch the configuration in this case
316                skipped_channels.push(channel.clone());
317                return Ok(());
318            } else {
319                // We expect autoUpdate to be missing if it's false
320                return Err(UpdateError::InvalidChannel);
321            }
322        }
323
324        d["channel"]["autoUpdate"] = true.into();
325        Ok(())
326    })?;
327    Ok(skipped_channels)
328}
329
330fn reset_config(
331    installation: &JetBrainsToolboxInstallation,
332    skipped_channels: Vec<PathBuf>,
333) -> Result<(), UpdateError> {
334    installation.update_all_channels(|channel, d| {
335        if !d.has_key("channel") {
336            return Err(UpdateError::InvalidChannel);
337        }
338        if skipped_channels.contains(channel) {
339            // Skip if it was skipped at the start as well
340            return Ok(());
341        }
342        d["channel"].remove("autoUpdate");
343        Ok(())
344    })?;
345    Ok(())
346}