jetbrains_toolbox_updater/
lib.rs1use dirs::home_dir;
2use json::{JsonError, JsonValue};
3use std::fs::File;
4use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
5use std::path::{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, 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 StartupFusAssistantTimeout,
29 DoubleStartupFusAssistant,
30 BadLog(String),
31}
32
33impl From<io::Error> for UpdateError {
34 fn from(err: io::Error) -> UpdateError {
35 UpdateError::Io(err)
36 }
37}
38
39impl From<JsonError> for UpdateError {
40 fn from(err: JsonError) -> UpdateError {
41 UpdateError::Json(err)
42 }
43}
44
45impl JetBrainsToolboxInstallation {
46 fn update_all_channels<F>(&self, mut operation: F) -> Result<(), UpdateError>
47 where
48 F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
49 {
50 for file in fs::read_dir(&self.channels)? {
51 let file = file?;
52 self.update_channel(file.path(), &mut operation)?;
53 }
54 Ok(())
55 }
56
57 fn update_channel<F>(&self, path: PathBuf, operation: &mut F) -> Result<(), UpdateError>
58 where
59 F: FnMut(&PathBuf, &mut JsonValue) -> Result<(), UpdateError>,
60 {
61 let mut file = File::options().read(true).write(true).open(&path)?;
62 let mut buf = String::new();
63 file.read_to_string(&mut buf)?;
64 let mut data = json::parse(&buf)?;
65 operation(&path, &mut data)?;
66 file.seek(SeekFrom::Start(0))?; buf = data.dump();
69 file.write_all(buf.as_bytes())?; let current_position = file.stream_position()?;
71 file.set_len(current_position)?; Ok(())
74 }
75
76 fn start_minimized(&self) -> io::Result<Child> {
77 Command::new(&self.binary).arg("--minimize").spawn()
78 }
79}
80
81#[derive(Debug, Clone)]
82#[non_exhaustive]
83pub enum FindError {
84 NotFound,
85 InvalidInstallation,
86 NoHomeDir,
87 UnsupportedOS(String),
88 NoDesktopFile(String),
89 DesktopFileMissingExec,
90 MultipleMismatchingDesktopFiles(String),
91}
92
93#[cfg(target_os = "linux")]
94pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
95 let home_dir = home_dir().ok_or(FindError::NoHomeDir)?;
96 let local_share = home_dir.join(".local/share");
98 let dir = local_share.join("JetBrains/Toolbox");
99 if !dir.exists() {
100 return Err(FindError::NotFound);
101 } else if !dir.is_dir() {
102 return Err(FindError::InvalidInstallation);
104 }
105 let mut binary = dir.join("bin/jetbrains-toolbox");
107 if !binary.exists() {
108 binary = get_binary_from_desktop(&binary)?;
111 }
112 let channels = dir.join("channels");
113 if !channels.is_dir() {
114 return Err(FindError::InvalidInstallation);
115 }
116 let logs_dir = dir.join("logs");
117 if !logs_dir.is_dir() {
118 return Err(FindError::InvalidInstallation);
119 }
120 let log = logs_dir.join("toolbox.latest.log");
123
124 Ok(JetBrainsToolboxInstallation {
125 binary,
126 channels,
127 log,
128 })
129}
130
131#[cfg(target_os = "linux")]
132fn get_binary_from_desktop(orig_binary: &Path) -> Result<PathBuf, FindError> {
133 let entries = freedesktop_desktop_entry::Iter::new(freedesktop_desktop_entry::default_paths())
134 .entries::<String>(None)
135 .collect::<Vec<_>>();
136
137 let mut matches = entries
138 .iter()
139 .filter(|entry| {
140 entry
141 .path
142 .file_name()
143 .expect("Invalid desktop entry file; terminates in `..`")
144 == "jetbrains-toolbox.desktop"
145 })
146 .map(|entry| entry.exec().ok_or(FindError::DesktopFileMissingExec))
147 .collect::<Result<Vec<_>, _>>()?
148 .into_iter();
149
150 let exec = match matches.next() {
152 None => {
153 return Err(FindError::NoDesktopFile(format!(
154 "No binary was found at {}, and no desktop file named `jetbrains-toolbox.desktop` was found",
155 orig_binary.display(),
156 )))
157 }
158 Some(first) => first,
159 };
160
161 if !matches.all(|x| x == exec) {
163 return Err(FindError::MultipleMismatchingDesktopFiles("Multiple desktop files called `jetbrains-toolbox.desktop` were found, and they have different values for Exec".to_string()));
164 }
165
166 let binary = exec.trim_end_matches(" %u");
167
168 println!("Detected binary at {binary} from desktop file");
169
170 Ok(PathBuf::from(binary))
171}
172
173#[cfg(target_os = "windows")]
174pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
175 Err(FindError::UnsupportedOS("Windows".to_string())) }
177
178#[cfg(target_os = "macos")]
179pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
180 Err(FindError::UnsupportedOS("MacOS".to_string())) }
182
183#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
184pub fn find_jetbrains_toolbox() -> Result<JetBrainsToolboxInstallation, FindError> {
185 Err(FindError::UnsupportedOS(std::env::consts::OS.to_string()))
187}
188
189fn kill_all() -> Result<bool, UpdateError> {
191 println!("Killing Toolbox");
192 let mut sys = System::new_all();
193 sys.refresh_all();
194 let processes = sys
196 .processes()
197 .values()
198 .filter_map(|p| {
199 let exe = p.exe()?; let name = p.name();
201 match exe.file_name().ok_or(UpdateError::CouldNotTerminate(
202 "Error getting file_name".to_string(),
203 )) {
204 Ok(file_name)
212 if file_name == "jetbrains-toolbox"
213 && name.to_str()?.starts_with("jetbrains") =>
214 {
215 Some(Ok(p))
216 }
217 Ok(_) => None, Err(e) => Some(Err(e)), }
220 })
221 .collect::<Result<Vec<&Process>, UpdateError>>()?;
222 Ok(match processes.len() {
223 0 => false, _ => {
225 for process in processes {
226 process.kill();
227 process.wait();
228 }
229 true }
231 })
232}
233
234pub fn update_jetbrains_toolbox(
235 installation: JetBrainsToolboxInstallation,
236) -> Result<(), UpdateError> {
237 _update_jetbrains_toolbox::<false>(installation)
238}
239
240fn _update_jetbrains_toolbox<const IS_RECURSIVE: bool>(
241 installation: JetBrainsToolboxInstallation,
242) -> Result<(), UpdateError> {
243 let toolbox_was_open = kill_all()?;
245
246 let skipped_channels = change_config(&installation)?;
248
249 let redo = match actual_update(&installation) {
250 Err(e) => {
251 println!("Unexpected error encountered, resetting configuration to previous state");
252 reset_config(&installation, skipped_channels)?;
253 return Err(e);
254 }
255 Ok(redo) => redo,
256 };
257
258 reset_config(&installation, skipped_channels)?;
260
261 if toolbox_was_open {
263 println!("Re-opening Toolbox");
264 installation.start_minimized()?;
265 }
266
267 if redo {
268 if IS_RECURSIVE {
274 return Err(UpdateError::DoubleToolboxSelfUpdate);
276 }
277
278 _update_jetbrains_toolbox::<true>(installation)
279 } else {
280 Ok(())
281 }
282}
283
284fn actual_update(installation: &JetBrainsToolboxInstallation) -> Result<bool, UpdateError> {
286 let mut redo = false;
287
288 println!("Starting Toolbox");
290 installation.start_minimized()?;
291
292 let mut updates: u32 = 0;
294 let mut correct_checksums_expected: u32 = 0;
295 let start_time = Instant::now();
296 let mut startup_time = None;
297
298 let file = File::open(&installation.log)?;
299 let mut file = BufReader::new(file);
300 file.seek(SeekFrom::End(0))?;
301 loop {
302 if startup_time.is_none() && start_time + Duration::from_secs(60) < Instant::now() {
305 return Err(UpdateError::StartupFusAssistantTimeout);
306 }
307
308 if let Some(startup_time) = startup_time {
309 if updates == 0 && startup_time + Duration::from_secs(10) < Instant::now() {
311 println!("No updates found");
312 break;
313 }
314 }
315
316 let curr_position = file.stream_position()?;
317
318 let mut line = String::new();
320 file.read_line(&mut line)?;
321
322 if line.is_empty() {
323 file.seek(SeekFrom::Start(curr_position))?;
326 sleep(Duration::from_millis(100));
327 } else {
328 if line.contains("Correct checksum for") || line.contains("Downloading from") {
336 if line.contains("Correct checksum for") && correct_checksums_expected > 0 {
337 println!("Verified a checksum for an update that was started earlier");
338 correct_checksums_expected -= 1;
339 continue;
340 }
341 println!("Found an update, waiting until it finishes");
343 updates += 1;
344 if line.contains("Downloading from") {
345 correct_checksums_expected += 1;
347 }
348 } else if line.contains("Show notification") {
349 updates -= 1;
351 if updates == 0 {
352 println!("All updates finished, exiting in 2 seconds");
353 sleep(Duration::from_secs(2)); break;
355 } else {
356 println!("Update finished, waiting for other update(s) to finish")
357 }
358 } else if line.contains("Awaiting user action or background state to install.") {
359 println!(
360 "Toolbox self-update is ready. The self-update will apply automatically \
361 in 60 seconds if you don't open Toolbox, but you can also click the \
362 'Restart Toolbox App to complete update' in the settings menu now."
363 )
364 } else if line.contains(
365 "Shutting down. Reason: The updated app is starting, closing the current process",
366 ) {
367 redo = true;
370 println!(
373 "Toolbox self-update download finished. We will now wait 20 seconds for \
374 waitForPid to timeout, then we will wait another 10 seconds to make sure \
375 the self-update is fully installed."
376 );
377 sleep(Duration::from_secs(
378 20
379 + 10,
385 ));
386 break;
387 } else if line.contains("Downloaded fus-assistant.xml") {
388 if startup_time.is_some() {
389 return Err(UpdateError::DoubleStartupFusAssistant);
391 }
392 println!("Toolbox started");
393 startup_time = Some(Instant::now())
394 }
395 }
396 }
397
398 if !kill_all()? {
400 return Err(UpdateError::PrematureExit);
402 }
403
404 Ok(redo)
405}
406
407fn change_config(installation: &JetBrainsToolboxInstallation) -> Result<Vec<PathBuf>, UpdateError> {
408 let mut skipped_channels = vec![];
409 installation.update_all_channels(|channel, d| {
410 if !d.has_key("channel") {
411 return Err(UpdateError::InvalidChannel);
412 }
413 if d["channel"].has_key("autoUpdate") {
414 if d["channel"]["autoUpdate"] == true {
415 skipped_channels.push(channel.clone());
417 return Ok(());
418 } else {
419 return Err(UpdateError::InvalidChannel);
421 }
422 }
423
424 d["channel"]["autoUpdate"] = true.into();
425 Ok(())
426 })?;
427 Ok(skipped_channels)
428}
429
430fn reset_config(
431 installation: &JetBrainsToolboxInstallation,
432 skipped_channels: Vec<PathBuf>,
433) -> Result<(), UpdateError> {
434 installation.update_all_channels(|channel, d| {
435 if !d.has_key("channel") {
436 return Err(UpdateError::InvalidChannel);
437 }
438 if skipped_channels.contains(channel) {
439 return Ok(());
441 }
442 d["channel"].remove("autoUpdate");
443 Ok(())
444 })?;
445 Ok(())
446}