ajour_core/
utility.rs

1use crate::config::{Flavor, SelfUpdateChannel};
2use crate::error::DownloadError;
3#[cfg(target_os = "macos")]
4use crate::error::FilesystemError;
5use crate::network::{download_file, request_async};
6
7use isahc::AsyncReadResponseExt;
8use regex::Regex;
9use retry::delay::Fibonacci;
10use retry::{retry, Error as RetryError, OperationResult};
11use serde::Deserialize;
12
13use std::ffi::OsStr;
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18/// Takes a `&str` and formats it into a proper
19/// World of Warcraft release version.
20///
21/// Eg. 90001 would be 9.0.1.
22pub fn format_interface_into_game_version(interface: &str) -> String {
23    if interface.len() == 5 {
24        let major = interface[..1].parse::<u8>();
25        let minor = interface[1..3].parse::<u8>();
26        let patch = interface[3..5].parse::<u8>();
27        if let (Ok(major), Ok(minor), Ok(patch)) = (major, minor, patch) {
28            return format!("{}.{}.{}", major, minor, patch);
29        }
30    }
31
32    interface.to_owned()
33}
34
35/// Takes a `&str` and strips any non-digit.
36/// This is used to unify and compare addon versions:
37///
38/// A string looking like 213r323 would return 213323.
39/// A string looking like Rematch_4_10_15.zip would return 41015.
40pub(crate) fn strip_non_digits(string: &str) -> String {
41    let re = Regex::new(r"[\D]").unwrap();
42    let stripped = re.replace_all(string, "").to_string();
43    stripped
44}
45
46#[derive(Debug, Deserialize, Clone)]
47pub struct Release {
48    pub tag_name: String,
49    pub prerelease: bool,
50    pub assets: Vec<ReleaseAsset>,
51    pub body: String,
52}
53
54#[derive(Debug, Deserialize, Clone)]
55pub struct ReleaseAsset {
56    pub name: String,
57    #[serde(rename = "browser_download_url")]
58    pub download_url: String,
59}
60
61pub async fn get_latest_release(channel: SelfUpdateChannel) -> Option<Release> {
62    log::debug!("checking for application update");
63
64    let mut resp = request_async(
65        "https://api.github.com/repos/ajour/ajour/releases",
66        vec![],
67        None,
68    )
69    .await
70    .ok()?;
71
72    let releases: Vec<Release> = resp.json().await.ok()?;
73
74    releases.into_iter().find(|r| {
75        if channel == SelfUpdateChannel::Beta {
76            // If beta, always want latest release
77            true
78        } else {
79            // Otherwise ONLY non-prereleases
80            !r.prerelease
81        }
82    })
83}
84
85/// Downloads the latest release file that matches `bin_name`, renames the current
86/// executable to a temp path, renames the new version as the original file name,
87/// then returns both the original file name (new version) and temp path (old version)
88pub async fn download_update_to_temp_file(
89    bin_name: String,
90    release: Release,
91) -> Result<(PathBuf, PathBuf), DownloadError> {
92    #[cfg(not(target_os = "linux"))]
93    let current_bin_path = std::env::current_exe()?;
94
95    #[cfg(target_os = "linux")]
96    let current_bin_path = PathBuf::from(
97        std::env::var("APPIMAGE").map_err(|_| DownloadError::SelfUpdateLinuxNonAppImage)?,
98    );
99
100    // Path to download the new version to
101    let download_path = current_bin_path
102        .parent()
103        .unwrap()
104        .join(&format!("tmp_{}", bin_name));
105
106    // Path to temporarily force rename current process to, se we can then
107    // rename `download_path` to `current_bin_path` and then launch new version
108    // cleanly as `current_bin_path`
109    let tmp_path = current_bin_path
110        .parent()
111        .unwrap()
112        .join(&format!("tmp2_{}", bin_name));
113
114    // On macos, we actually download an archive with the new binary inside. Let's extract
115    // that file and remove the archive.
116    #[cfg(target_os = "macos")]
117    {
118        let asset_name = format!("{}-macos.tar.gz", bin_name);
119
120        let asset = release
121            .assets
122            .iter()
123            .find(|a| a.name == asset_name)
124            .cloned()
125            .ok_or(DownloadError::MissingSelfUpdateRelease { bin_name })?;
126
127        let archive_path = current_bin_path.parent().unwrap().join(&asset_name);
128
129        download_file(&asset.download_url, &archive_path).await?;
130
131        extract_binary_from_tar(&archive_path, &download_path, "ajour")?;
132
133        std::fs::remove_file(&archive_path)?;
134    }
135
136    // For windows & linux, we download the new binary directly
137    #[cfg(not(target_os = "macos"))]
138    {
139        let asset = release
140            .assets
141            .iter()
142            .find(|a| a.name == bin_name)
143            .cloned()
144            .ok_or(DownloadError::MissingSelfUpdateRelease { bin_name })?;
145
146        download_file(&asset.download_url, &download_path).await?;
147    }
148
149    // Make executable
150    #[cfg(not(target_os = "windows"))]
151    {
152        use async_std::fs;
153        use std::os::unix::fs::PermissionsExt;
154
155        let mut permissions = fs::metadata(&download_path).await?.permissions();
156        permissions.set_mode(0o755);
157        fs::set_permissions(&download_path, permissions).await?;
158    }
159
160    rename(&current_bin_path, &tmp_path)?;
161
162    rename(&download_path, &current_bin_path)?;
163
164    Ok((current_bin_path, tmp_path))
165}
166
167/// Extracts the Ajour binary from a `tar.gz` archive to temp_file path
168#[cfg(target_os = "macos")]
169fn extract_binary_from_tar(
170    archive_path: &Path,
171    temp_file: &Path,
172    bin_name: &str,
173) -> Result<(), FilesystemError> {
174    use flate2::read::GzDecoder;
175    use std::fs::File;
176    use std::io::copy;
177    use tar::Archive;
178
179    let mut archive = Archive::new(GzDecoder::new(File::open(&archive_path)?));
180
181    let mut temp_file = File::create(temp_file)?;
182
183    for file in archive.entries()? {
184        let mut file = file?;
185
186        let path = file.path()?;
187
188        if let Some(name) = path.to_str() {
189            if name == bin_name {
190                copy(&mut file, &mut temp_file)?;
191
192                return Ok(());
193            }
194        }
195    }
196
197    Err(FilesystemError::BinMissingFromTar {
198        bin_name: bin_name.to_owned(),
199    })
200}
201
202/// Logic to help pick the right World of Warcraft folder.
203pub fn wow_path_resolution(path: Option<PathBuf>) -> Option<PathBuf> {
204    if let Some(path) = path {
205        // Known folders in World of Warcraft dir
206        let known_folders = Flavor::ALL
207            .iter()
208            .map(|f| f.folder_name())
209            .collect::<Vec<String>>();
210
211        // If chosen path has any of the known Wow folders, we have the right one.
212        for folder in known_folders.iter() {
213            if path.join(folder).exists() {
214                return Some(path);
215            }
216        }
217
218        // Iterate ancestors. If we find any of the known folders we can guess the root.
219        for ancestor in path.as_path().ancestors() {
220            if let Some(file_name) = ancestor.file_name() {
221                for folder in known_folders.iter() {
222                    if file_name == OsStr::new(folder) {
223                        return ancestor.parent().map(|p| p.to_path_buf());
224                    }
225                }
226            }
227        }
228    }
229
230    None
231}
232
233/// Rename a file or directory to a new name, retrying if the operation fails because of permissions
234///
235/// Will retry for ~30 seconds with longer and longer delays between each, to allow for virus scan
236/// and other automated operations to complete.
237pub fn rename<F, T>(from: F, to: T) -> io::Result<()>
238where
239    F: AsRef<Path>,
240    T: AsRef<Path>,
241{
242    // 21 Fibonacci steps starting at 1 ms is ~28 seconds total
243    // See https://github.com/rust-lang/rustup/pull/1873 where this was used by Rustup to work around
244    // virus scanning file locks
245    let from = from.as_ref();
246    let to = to.as_ref();
247
248    retry(Fibonacci::from_millis(1).take(21), || {
249        match fs::rename(from, to) {
250            Ok(_) => OperationResult::Ok(()),
251            Err(e) => match e.kind() {
252                io::ErrorKind::PermissionDenied => OperationResult::Retry(e),
253                _ => OperationResult::Err(e),
254            },
255        }
256    })
257    .map_err(|e| match e {
258        RetryError::Operation { error, .. } => error,
259        RetryError::Internal(message) => io::Error::new(io::ErrorKind::Other, message),
260    })
261}
262
263/// Remove a file, retrying if the operation fails because of permissions
264///
265/// Will retry for ~30 seconds with longer and longer delays between each, to allow for virus scan
266/// and other automated operations to complete.
267pub fn remove_file<P>(path: P) -> io::Result<()>
268where
269    P: AsRef<Path>,
270{
271    // 21 Fibonacci steps starting at 1 ms is ~28 seconds total
272    // See https://github.com/rust-lang/rustup/pull/1873 where this was used by Rustup to work around
273    // virus scanning file locks
274    let path = path.as_ref();
275
276    retry(
277        Fibonacci::from_millis(1).take(21),
278        || match fs::remove_file(path) {
279            Ok(_) => OperationResult::Ok(()),
280            Err(e) => match e.kind() {
281                io::ErrorKind::PermissionDenied => OperationResult::Retry(e),
282                _ => OperationResult::Err(e),
283            },
284        },
285    )
286    .map_err(|e| match e {
287        RetryError::Operation { error, .. } => error,
288        RetryError::Internal(message) => io::Error::new(io::ErrorKind::Other, message),
289    })
290}
291
292pub(crate) fn truncate(s: &str, max_chars: usize) -> &str {
293    match s.char_indices().nth(max_chars) {
294        None => s,
295        Some((idx, _)) => &s[..idx],
296    }
297}
298
299pub(crate) fn regex_html_tags_to_newline() -> Regex {
300    regex::Regex::new(r"<br ?/?>|#.\s").unwrap()
301}
302
303pub(crate) fn regex_html_tags_to_space() -> Regex {
304    regex::Regex::new(r"<[^>]*>|&#?\w+;|[gl]t;").unwrap()
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_wow_path_resolution() {
313        let classic_addon_path =
314            PathBuf::from(r"/Applications/World of Warcraft/_classic_/Interface/Addons");
315        let retail_addon_path =
316            PathBuf::from(r"/Applications/World of Warcraft/_retail_/Interface/Addons");
317        let retail_interface_path =
318            PathBuf::from(r"/Applications/World of Warcraft/_retail_/Interface");
319        let classic_interface_path =
320            PathBuf::from(r"/Applications/World of Warcraft/_classic_/Interface");
321        let classic_alternate_path = PathBuf::from(r"/Applications/Wow/_classic_");
322
323        let root_alternate_path = PathBuf::from(r"/Applications/Wow");
324        let root_path = PathBuf::from(r"/Applications/World of Warcraft");
325
326        assert_eq!(
327            root_path.eq(&wow_path_resolution(Some(classic_addon_path)).unwrap()),
328            true
329        );
330        assert_eq!(
331            root_path.eq(&wow_path_resolution(Some(retail_addon_path)).unwrap()),
332            true
333        );
334        assert_eq!(
335            root_path.eq(&wow_path_resolution(Some(retail_interface_path)).unwrap()),
336            true
337        );
338        assert_eq!(
339            root_path.eq(&wow_path_resolution(Some(classic_interface_path)).unwrap()),
340            true
341        );
342        assert_eq!(
343            root_alternate_path.eq(&wow_path_resolution(Some(classic_alternate_path)).unwrap()),
344            true
345        );
346    }
347
348    #[test]
349    fn test_interface() {
350        let interface = "90001";
351        assert_eq!("9.0.1", format_interface_into_game_version(interface));
352
353        let interface = "11305";
354        assert_eq!("1.13.5", format_interface_into_game_version(interface));
355
356        let interface = "100000";
357        assert_eq!("100000", format_interface_into_game_version(interface));
358
359        let interface = "9.0.1";
360        assert_eq!("9.0.1", format_interface_into_game_version(interface));
361    }
362}