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,
16    log: PathBuf,
17}
18
19#[derive(Debug)]
20pub enum UpdateError {
21    Io(io::Error),
22    Json(JsonError),
23    InvalidChannel,
24    CouldNotTerminate(String),
25}
26
27impl JetBrainsToolboxInstallation {
28    fn update_all_channels<F>(&self, mut operation: F) -> Result<(), UpdateError>
29    where
30        F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
31    {
32        for file in fs::read_dir(&self.channels).map_err(UpdateError::Io)? {
33            let file = file.map_err(UpdateError::Io)?;
34            self.update_channel(file.path(), &mut operation)?;
35        }
36        Ok(())
37    }
38
39    fn update_channel<F>(&self, path: PathBuf, operation: &mut F) -> Result<(), UpdateError>
40    where
41        F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
42    {
43        let mut file = File::options()
44            .read(true)
45            .write(true)
46            .open(&path)
47            .map_err(UpdateError::Io)?;
48        let mut buf = String::new();
49        file.read_to_string(&mut buf).map_err(UpdateError::Io)?;
50        let mut data = json::parse(&buf).map_err(UpdateError::Json)?;
51        operation(&path, &mut data)?;
52        // Seek to the start, dump, then truncate, to avoid re-opening the file
53        file.seek(SeekFrom::Start(0)).map_err(UpdateError::Io)?;
54        buf = data.dump();
55        file.write_all(buf.as_bytes()).map_err(UpdateError::Io)?;
56        let current_position = file.stream_position().map_err(UpdateError::Io)?;
57        file.set_len(current_position).map_err(UpdateError::Io)?;
58
59        Ok(())
60    }
61
62    fn start_minimized(&self) -> io::Result<Child> {
63        Command::new(&self.binary).arg("--minimize").spawn()
64    }
65}
66
67#[derive(Debug, Clone)]
68pub enum FindError {
69    NotFound,
70    InvalidInstallation,
71    NoHomeDir,
72    UnsupportedOS,
73}
74
75#[cfg(target_os = "linux")]
76pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
77    let home_dir = home_dir().ok_or(FindError::NoHomeDir)?;
78    let dir = home_dir.join(".local/share/JetBrains/Toolbox");
79    if !dir.exists() {
80        return Err(FindError::NotFound);
81    } else if !dir.is_dir() {
82        // I don't know why there would ever be a normal file there but why not
83        return Err(FindError::InvalidInstallation);
84    }
85    let binary = dir.join("bin/jetbrains-toolbox");
86    if !binary.exists() {
87        return Err(FindError::InvalidInstallation);
88    }
89    let channels = dir.join("channels");
90    if !channels.is_dir() {
91        return Err(FindError::InvalidInstallation);
92    }
93    let logs_dir = dir.join("logs");
94    if !logs_dir.is_dir() {
95        return Err(FindError::InvalidInstallation);
96    }
97    let log = logs_dir.join("toolbox.log"); // The log itself might not exist, so we don't check for it here
98
99    Ok(JetBrainsToolboxInstallation {
100        binary,
101        channels,
102        log,
103    })
104}
105
106#[cfg(target_os = "windows")]
107fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
108    FindError::UnsupportedOS // TODO
109}
110
111#[cfg(target_os = "macos")]
112fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
113    FindError::UnsupportedOS // TODO
114}
115
116#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
117fn find_jetbrains_toolbox() -> Result<Installation, FindError> {
118    // JetBrains Toolbox is not supported on mobile or BSD
119    FindError::UnsupportedOS
120}
121
122fn kill_all() -> Result<bool, UpdateError> {
123    let mut sys = System::new_all();
124    sys.refresh_all();
125    // TODO: this might not work on other platforms
126    let processes = sys
127        .processes()
128        .values()
129        .filter_map(|p| {
130            let exe = p.exe()?; // Skip if no exe available
131            let name = p.name();
132            match exe.file_name().ok_or(UpdateError::CouldNotTerminate(
133                "Error getting file_name".to_string(),
134            )) {
135                Ok(file_name)
136                    if file_name == "jetbrains-toolbox"
137                        && name.to_str()?.starts_with("jetbrains") =>
138                {
139                    Some(Ok(p))
140                }
141                Ok(_) => None,          // Skip items that don't match
142                Err(e) => Some(Err(e)), // Propagate the error
143            }
144        })
145        .collect::<Result<Vec<&Process>, UpdateError>>()?;
146    Ok(match processes.len() {
147        0 => false,
148        _ => {
149            println!("Found {} processes", processes.len());
150            for process in processes {
151                process.kill();
152                process.wait();
153            }
154            true
155        }
156    })
157}
158
159pub fn update_jetbrains_toolbox(
160    installation: JetBrainsToolboxInstallation,
161) -> Result<(), UpdateError> {
162    // Close the app if it's open
163    let toolbox_was_open = kill_all()?;
164
165    // Modify the configuration to enable automatic updates
166    let mut skipped_channels = vec![];
167    installation.update_all_channels(|channel, d| {
168        if !d.has_key("channel") {
169            return Err(UpdateError::InvalidChannel);
170        }
171        if d["channel"].has_key("autoUpdate") {
172            if d["channel"]["autoUpdate"] == true {
173                // This channel is already auto-updating, we won't touch the configuration in this case
174                skipped_channels.push(channel.clone());
175                return Ok(());
176            } else {
177                return Err(UpdateError::InvalidChannel);
178            }
179        }
180
181        d["channel"]["autoUpdate"] = true.into();
182        Ok(())
183    })?;
184
185    // Start the app in the background
186    installation.start_minimized().map_err(UpdateError::Io)?;
187
188    // Monitor the logs for possible updates, and wait until they're complete
189    let mut updates: u32 = 0;
190    let mut correct_checksums_expected: u32 = 0;
191    let start_time = Instant::now();
192
193    let file = File::open(&installation.log).map_err(UpdateError::Io)?;
194    let mut file = BufReader::new(file);
195    file.seek(SeekFrom::End(0)).map_err(UpdateError::Io)?;
196    loop {
197        // TODO: Unfortunately there is no log message indicating there are no updates; so waiting is necessary it looks like.
198        //  Unless we can do something with "Downloaded fus-assistant.xml"? Maybe shorten the time to 1/2 seconds after that message, seems to be fine.
199        if updates == 0 && start_time + Duration::from_secs(10) < Instant::now() {
200            println!("No updates found.");
201            break;
202        }
203
204        let curr_position = file.stream_position().map_err(UpdateError::Io)?;
205
206        let mut line = String::new();
207        file.read_line(&mut line).map_err(UpdateError::Io)?;
208
209        if line.is_empty() {
210            file.seek(SeekFrom::Start(curr_position))
211                .map_err(UpdateError::Io)?;
212            sleep(Duration::from_millis(100));
213        } else {
214            // If the download is already there, it won't say "Downloading from", but immediately "Correct checksum for".
215            if line.contains("Correct checksum for") || line.contains("Downloading from") {
216                if line.contains("Correct checksum for") && correct_checksums_expected > 0 {
217                    correct_checksums_expected -= 1;
218                    continue;
219                }
220                // Update started
221                println!("Found an update, waiting until it finishes...");
222                updates += 1;
223                if line.contains("Downloading from") {
224                    // We expect "Correct checksum for" to be broadcast exactly once after the downloading from.
225                    correct_checksums_expected += 1;
226                }
227            } else if line.contains("Show notification") {
228                // Update finished
229                updates -= 1;
230                if updates == 0 {
231                    println!("Update finished, exiting...");
232                    sleep(Duration::from_secs(2)); // Letting it finish up
233                    break;
234                } else {
235                    println!("Update finished, waiting for other update(s) to finish")
236                }
237            }
238        }
239    }
240
241    // Quit the app
242    assert!(kill_all()?);
243
244    // Reset the configuration
245    installation.update_all_channels(|channel, d| {
246        if !d.has_key("channel") {
247            return Err(UpdateError::InvalidChannel);
248        }
249        if skipped_channels.contains(channel) {
250            return Ok(());
251        }
252        d["channel"].remove("autoUpdate");
253        Ok(())
254    })?;
255
256    // Restart the app if it was open
257    if toolbox_was_open {
258        installation.start_minimized().map_err(UpdateError::Io)?;
259    }
260
261    Ok(())
262}