jetbrains_toolbox_updater/
lib.rs1#[cfg(target_os = "linux")]
2use dirs::home_dir;
3#[cfg(target_os = "linux")]
4use log::debug;
5use std::fs::File;
6use std::io::{BufRead as _, BufReader, Read as _, Seek as _, SeekFrom, Write as _};
7#[cfg(target_os = "linux")]
8use std::path::Path;
9use std::path::PathBuf;
10use std::process::{Child, Command};
11use std::thread::sleep;
12use std::time::{Duration, Instant};
13use std::{fs, io};
14use sysinfo::{Process, System};
15
16#[derive(Debug, Clone)]
17pub struct JetBrainsToolboxInstallation {
18 binary: PathBuf,
19 channels: PathBuf, log: PathBuf,
21}
22
23#[derive(Debug)]
24#[non_exhaustive]
25pub enum UpdateError {
26 Io(io::Error),
27 Json(serde_json::Error),
28 InvalidChannel,
29 CouldNotTerminate(String),
30 PrematureExit,
31 DoubleToolboxSelfUpdate,
32 StartupFusAssistantTimeout,
33 DoubleStartupFusAssistant,
34 BadLog(String),
35}
36
37impl From<io::Error> for UpdateError {
38 fn from(err: io::Error) -> Self {
39 Self::Io(err)
40 }
41}
42
43impl From<serde_json::Error> for UpdateError {
44 fn from(err: serde_json::Error) -> Self {
45 Self::Json(err)
46 }
47}
48
49impl JetBrainsToolboxInstallation {
50 fn update_all_channels<F>(&self, mut operation: F) -> Result<(), UpdateError>
51 where
52 F: FnMut(&PathBuf, &mut serde_json::Value) -> Result<(), UpdateError>,
53 {
54 for file in fs::read_dir(&self.channels)? {
55 let file = file?;
56 self.update_channel(file.path(), &mut operation)?;
57 }
58 Ok(())
59 }
60
61 fn update_channel<F>(&self, path: PathBuf, operation: &mut F) -> Result<(), UpdateError>
62 where
63 F: FnMut(&PathBuf, &mut serde_json::Value) -> Result<(), UpdateError>,
64 {
65 let mut file = File::options().read(true).write(true).open(&path)?;
66 let mut buf = String::new();
67 file.read_to_string(&mut buf)?;
68 let mut data: serde_json::Value = serde_json::from_str(&buf)?;
69 operation(&path, &mut data)?;
70 file.seek(SeekFrom::Start(0))?; buf = serde_json::to_string_pretty(&data)?;
73 file.write_all(buf.as_bytes())?; let current_position = file.stream_position()?;
75 file.set_len(current_position)?; Ok(())
78 }
79
80 fn start_minimized(&self) -> io::Result<Child> {
81 Command::new(&self.binary).arg("--minimize").spawn()
82 }
83}
84
85#[derive(Debug, Clone)]
86#[non_exhaustive]
87pub enum FindError {
88 NotFound,
89 InvalidInstallation,
90 NoHomeDir,
91 UnsupportedOS(String),
92 NoDesktopFile(String),
93 DesktopFileMissingExec,
94 MultipleMismatchingDesktopFiles(String),
95}
96
97#[cfg(target_os = "linux")]
98pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
99 let home_dir = home_dir().ok_or(FindError::NoHomeDir)?;
100 let local_share = home_dir.join(".local/share");
102 let dir = local_share.join("JetBrains/Toolbox");
103 if !dir.exists() {
104 return Err(FindError::NotFound);
105 } else if !dir.is_dir() {
106 return Err(FindError::InvalidInstallation);
108 }
109 let mut binary = dir.join("bin/jetbrains-toolbox");
111 if !binary.exists() {
112 binary = get_binary_from_desktop(&binary)?;
115 }
116 let channels = dir.join("channels");
117 if !channels.is_dir() {
118 return Err(FindError::InvalidInstallation);
119 }
120 let logs_dir = dir.join("logs");
121 if !logs_dir.is_dir() {
122 return Err(FindError::InvalidInstallation);
123 }
124 let log = logs_dir.join("toolbox.latest.log");
126
127 Ok(JetBrainsToolboxInstallation {
128 binary,
129 channels,
130 log,
131 })
132}
133
134#[cfg(target_os = "linux")]
135fn get_binary_from_desktop(orig_binary: &Path) -> Result<PathBuf, FindError> {
136 let entries = freedesktop_desktop_entry::Iter::new(freedesktop_desktop_entry::default_paths())
137 .entries::<String>(None)
138 .collect::<Vec<_>>();
139
140 let mut matches = entries
141 .iter()
142 .filter(|entry| {
143 entry
144 .path
145 .file_name()
146 .expect("Invalid desktop entry file; terminates in `..`")
147 == "jetbrains-toolbox.desktop"
148 })
149 .map(|entry| entry.exec().ok_or(FindError::DesktopFileMissingExec))
150 .collect::<Result<Vec<_>, _>>()?
151 .into_iter();
152
153 let exec = match matches.next() {
155 None => {
156 return Err(FindError::NoDesktopFile(format!(
157 "No binary was found at {}, and no desktop file named `jetbrains-toolbox.desktop` was found",
158 orig_binary.display(),
159 )));
160 }
161 Some(first) => first,
162 };
163
164 if !matches.all(|x| x == exec) {
166 return Err(FindError::MultipleMismatchingDesktopFiles("Multiple desktop files called `jetbrains-toolbox.desktop` were found, and they have different values for Exec".to_owned()));
167 }
168
169 let binary = exec.trim_end_matches(" %u");
170
171 debug!("Detected binary at {binary} from desktop file");
172
173 Ok(PathBuf::from(binary))
174}
175
176#[cfg(target_os = "windows")]
177pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
178 Err(FindError::UnsupportedOS("Windows".to_string())) }
180
181#[cfg(target_os = "macos")]
182pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
183 Err(FindError::UnsupportedOS("MacOS".to_string())) }
185
186#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
187pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
188 Err(FindError::UnsupportedOS(std::env::consts::OS.to_string()))
190}
191
192fn kill_all() -> Result<bool, UpdateError> {
194 println!("Killing Toolbox");
195 let mut sys = System::new_all();
196 sys.refresh_all();
197 let processes = sys
199 .processes()
200 .values()
201 .filter_map(|p| {
202 let exe = p.exe()?; let name = p.name();
204 match exe.file_name().ok_or(UpdateError::CouldNotTerminate(
205 "Error getting file_name".to_owned(),
206 )) {
207 Ok(file_name)
215 if file_name == "jetbrains-toolbox"
216 && name.to_str()?.starts_with("jetbrains") =>
217 {
218 Some(Ok(p))
219 }
220 Ok(_) => None, Err(e) => Some(Err(e)), }
223 })
224 .collect::<Result<Vec<&Process>, UpdateError>>()?;
225 Ok(match processes.len() {
226 0 => false, _ => {
228 for process in processes {
229 process.kill();
230 process.wait();
231 }
232 true }
234 })
235}
236
237pub fn update_jetbrains_toolbox(
238 installation: JetBrainsToolboxInstallation,
239) -> Result<(), UpdateError> {
240 _update_jetbrains_toolbox::<false>(installation)
241}
242
243fn _update_jetbrains_toolbox<const IS_RECURSIVE: bool>(
244 installation: JetBrainsToolboxInstallation,
245) -> Result<(), UpdateError> {
246 let toolbox_was_open = kill_all()?;
248
249 let skipped_channels = change_config(&installation)?;
251
252 let redo = match actual_update(&installation) {
253 Err(e) => {
254 println!("Unexpected error encountered, resetting configuration to previous state");
255 reset_config(&installation, skipped_channels)?;
256 return Err(e);
257 }
258 Ok(redo) => redo,
259 };
260
261 reset_config(&installation, skipped_channels)?;
263
264 if toolbox_was_open {
266 println!("Re-opening Toolbox");
267 installation.start_minimized()?;
268 }
269
270 if redo {
271 if IS_RECURSIVE {
277 return Err(UpdateError::DoubleToolboxSelfUpdate);
279 }
280
281 _update_jetbrains_toolbox::<true>(installation)
282 } else {
283 Ok(())
284 }
285}
286
287fn actual_update(installation: &JetBrainsToolboxInstallation) -> Result<bool, UpdateError> {
289 let mut redo = false;
290
291 println!("Starting Toolbox");
293 installation.start_minimized()?;
294
295 let mut updates: u32 = 0;
297 let mut correct_checksums_expected: u32 = 0;
298 let start_time = Instant::now();
299 let mut startup_time = None;
300
301 let file = File::open(&installation.log)?;
302 let mut file = BufReader::new(file);
303 file.seek(SeekFrom::End(0))?;
304 loop {
305 if startup_time.is_none() && start_time + Duration::from_secs(60) < Instant::now() {
308 return Err(UpdateError::StartupFusAssistantTimeout);
309 }
310
311 if let Some(startup_time) = startup_time
312 && updates == 0 && startup_time + Duration::from_secs(10) < Instant::now()
314 {
315 println!("No updates found");
316 break;
317 }
318
319 let curr_position = file.stream_position()?;
320
321 let mut line = String::new();
323 file.read_line(&mut line)?;
324
325 if line.is_empty() {
326 file.seek(SeekFrom::Start(curr_position))?;
329 sleep(Duration::from_millis(100));
330 } else {
331 if line.contains("Correct checksum for") || line.contains("Downloading from") {
339 if line.contains("Correct checksum for") && correct_checksums_expected > 0 {
340 println!("Verified a checksum for an update that was started earlier");
341 correct_checksums_expected -= 1;
342 continue;
343 }
344 println!("Found an update, waiting until it finishes");
346 updates += 1;
347 if line.contains("Downloading from") {
348 correct_checksums_expected += 1;
350 }
351 } else if line.contains("update-notification") {
352 updates -= 1;
354 if updates == 0 {
355 println!("All updates finished, exiting in 30 seconds");
356 sleep(Duration::from_secs(30)); break;
358 } else {
359 println!("Update finished, waiting for other update(s) to finish");
360 }
361 } else if line.contains("Awaiting user action or background state to install.") {
362 println!(
363 "Toolbox self-update is ready. The self-update will apply automatically \
364 in 60 seconds if you don't open Toolbox, but you can also click the \
365 'Restart Toolbox App to complete update' in the settings menu now."
366 );
367 } else if line.contains(
368 "Shutting down. Reason: The updated app is starting, closing the current process",
369 ) {
370 redo = true;
373 println!(
376 "Toolbox self-update download finished. We will now wait 20 seconds for \
377 waitForPid to timeout, then we will wait another 10 seconds to make sure the \
378 self-update is fully installed. Then we will restart the update process once."
379 );
380 sleep(Duration::from_secs(
381 20
382 + 10,
388 ));
389 break;
390 } else if line.contains("Downloaded fus-assistant.xml") {
391 if startup_time.is_some() {
392 return Err(UpdateError::DoubleStartupFusAssistant);
394 }
395 println!("Toolbox started");
396 startup_time = Some(Instant::now());
397 }
398 }
399 }
400
401 if !kill_all()? {
403 return Err(UpdateError::PrematureExit);
405 }
406
407 Ok(redo)
408}
409
410fn change_config(installation: &JetBrainsToolboxInstallation) -> Result<Vec<PathBuf>, UpdateError> {
411 let mut skipped_channels = vec![];
412 installation.update_all_channels(|channel, d| {
413 if !d["channel"].is_object() {
414 return Err(UpdateError::InvalidChannel);
415 }
416 if let Some(auto_update) = d["channel"].get("autoUpdate") {
417 if auto_update.as_bool() == Some(true) {
418 skipped_channels.push(channel.clone());
420 return Ok(());
421 } else {
422 return Err(UpdateError::InvalidChannel);
424 }
425 }
426
427 d["channel"]["autoUpdate"] = true.into();
428 Ok(())
429 })?;
430 Ok(skipped_channels)
431}
432
433fn reset_config(
434 installation: &JetBrainsToolboxInstallation,
435 skipped_channels: Vec<PathBuf>,
436) -> Result<(), UpdateError> {
437 installation.update_all_channels(|channel, d| {
438 if d.get("channel").is_none() {
439 return Err(UpdateError::InvalidChannel);
440 }
441 if skipped_channels.contains(channel) {
442 return Ok(());
444 }
445 if let Some(channel_obj) = d.get_mut("channel").and_then(|v| v.as_object_mut()) {
446 channel_obj.remove("autoUpdate");
447 }
448 Ok(())
449 })?;
450 Ok(())
451}