tiny_update_notifier/
lib.rs

1#![allow(clippy::multiple_crate_versions)] // TODO: Remove this when possible
2use directories::ProjectDirs;
3/// Use either `tiny_update_notifier::{check_cratesIO, check_github}`
4/// spawns a new thread to check for updates and notify user if there is a new version available.
5///
6/// ## Examples
7///
8/// ```rust,no_run
9/// tiny_update_notifier::check_cratesIO(
10///     env!("CARGO_PKG_VERSION"),
11///     env!("CARGO_PKG_NAME"),
12/// );
13///
14/// tiny_update_notifier::check_github(
15///     env!("CARGO_PKG_VERSION"),
16///     env!("CARGO_PKG_NAME"),
17///     env!("CARGO_PKG_REPOSITORY"),
18/// );
19/// ```
20use notify_rust::Notification;
21
22use std::{
23    fs,
24    io::{self, Error, ErrorKind},
25    time::Duration,
26};
27
28/// Source to check for updates
29pub enum Source {
30    /// Check for updates on Crates.io
31    ///
32    /// (Only works if the package is published on Crates.io)
33    CratesIO,
34    /// Check for updates on GitHub
35    ///
36    /// (Only works if the package is published on GitHub)
37    GitHub,
38}
39
40/// Spawns a thread to check for updates on Crates.io and notify user if there is a new version available.
41///
42/// This function returns immediately and does not block the current thread.
43///
44/// ## Examples
45///
46/// ```rust,no_run
47/// tiny_update_notifier::check_cratesIO(
48///     env!("CARGO_PKG_VERSION"),
49///     env!("CARGO_PKG_NAME"),
50/// );
51/// ```
52#[allow(non_snake_case)]
53pub fn check_cratesIO(version: &'static str, name: &'static str) {
54    spawn(Source::CratesIO, version, name, "");
55}
56
57/// Spawns a thread to check for updates on GitHub Releases and notify user if there is a new version available.
58///
59/// This function returns immediately and does not block the current thread.
60///
61/// ## Examples
62///
63/// ```rust,no_run
64/// tiny_update_notifier::check_github(
65///     env!("CARGO_PKG_VERSION"),
66///     env!("CARGO_PKG_NAME"),
67///     env!("CARGO_PKG_REPOSITORY"),
68/// );  
69/// ```
70pub fn check_github(version: &'static str, name: &'static str, repo_url: &'static str) {
71    spawn(Source::GitHub, version, name, repo_url);
72}
73
74fn spawn(source: Source, version: &'static str, name: &'static str, repo_url: &'static str) {
75    std::thread::spawn(move || {
76        Notifier::new(source, version, name, repo_url).run();
77    });
78}
79
80/// Use `Notifier::new(source, pkg_version, pkg_name, pkg_repo_url).run()`
81/// to check for updates and notify user if there is a new version available.
82///
83/// ## Examples
84///
85/// ```rust,no_run
86/// use tiny_update_notifier::{Notifier, Source};
87/// std::thread::spawn(|| {
88///     Notifier::new(
89///         Source::GitHub,
90///         env!("CARGO_PKG_VERSION"),
91///         env!("CARGO_PKG_NAME"),
92///         env!("CARGO_PKG_REPOSITORY"),
93///     )
94///     .interval(Duration::from_secs(60 * 60 * 24 * 7)) // Change interval to 7 days (Default is 24H)
95///     .run();
96/// });
97/// ```
98pub struct Notifier {
99    version: &'static str,
100    name: &'static str,
101    repo_url: &'static str,
102    source: Source,
103    interval: Duration,
104}
105
106impl Notifier {
107    /// Use `Notifier::new(source, pkg_version, pkg_name, pkg_repo_url).run()`
108    /// to check for updates and notify user if there is a new version available.
109    ///
110    /// ## Examples
111    ///
112    /// ```rust,no_run
113    /// use tiny_update_notifier::{Notifier, Source};
114    /// std::thread::spawn(|| {
115    ///     Notifier::new(
116    ///         Source::GitHub,
117    ///         env!("CARGO_PKG_VERSION"),
118    ///         env!("CARGO_PKG_NAME"),
119    ///         env!("CARGO_PKG_REPOSITORY"),
120    ///     )
121    ///    .interval(Duration::from_secs(60 * 60 * 24 * 7)) // Change interval to 7 days (Default is 24H)
122    ///    .run();
123    /// });
124    /// ```
125    #[must_use]
126    pub const fn new(
127        source: Source,
128        version: &'static str,
129        name: &'static str,
130        repo_url: &'static str,
131    ) -> Self {
132        Self {
133            version,
134            name,
135            repo_url,
136            source,
137            interval: Duration::from_secs(60 * 60 * 24), // Default 24H
138        }
139    }
140
141    /// Change the interval between checks for updates
142    /// (Default is 24H)
143    #[must_use]
144    pub const fn interval(mut self, interval: Duration) -> Self {
145        self.interval = interval;
146        self
147    }
148
149    /// Run the notifier
150    pub fn run(&mut self) {
151        match Self::should_check_update(self) {
152            Err(e) => {
153                Self::notification(self, &format!("Error: should_check_update() Failed: \n{e}"));
154            }
155            Ok(true) => Self::check_version(self),
156            Ok(false) => (),
157        };
158    }
159
160    fn check_version(&mut self) {
161        if let Ok(new_version) = Self::get_latest_version(self) {
162            if new_version != self.version {
163                let link = if self.repo_url.is_empty() {
164                    String::new()
165                } else {
166                    format!(
167                        "\n{repo_url}/releases/tag/{new_version}",
168                        repo_url = self.repo_url,
169                    )
170                };
171
172                Self::notification(
173                    self,
174                    &format!(
175                        "A new release of {pkg_name} is available: \n\
176        v{current_version} -> v{new_version}{link}",
177                        pkg_name = self.name,
178                        current_version = self.version
179                    ),
180                );
181            }
182
183            Self::write_last_checked(self).unwrap_or_else(|e| {
184                Self::notification(self, &format!("Error: write_last_checked() failed: \n{e}"));
185            });
186        }
187    }
188
189    fn notification(&mut self, body: &str) {
190        Notification::new()
191            .summary(self.name)
192            .body(body)
193            .icon("/usr/share/icons/hicolor/256x256/apps/gnome-software.png")
194            .timeout(5000)
195            .show()
196            .ok();
197    }
198
199    fn get_latest_version(&mut self) -> io::Result<String> {
200        let output = std::process::Command::new("curl")
201            .arg("--silent")
202            .arg(self.get_api_link()?)
203            .output();
204
205        match output {
206            Ok(output) => {
207                let stdout = String::from_utf8_lossy(&output.stdout);
208                let data: serde_json::Value = serde_json::from_str(&stdout)?;
209                let version = self.extract_version_from_json(&data);
210                Ok(version)
211            }
212            Err(e) => {
213                Self::notification(self, &format!("Error: get_latest_version() failed: \n{e}"));
214                Err(e)
215            }
216        }
217    }
218
219    fn get_api_link(&self) -> io::Result<String> {
220        match self.source {
221            Source::CratesIO => Ok(format!("https://crates.io/api/v1/crates/{}", self.name)),
222            Source::GitHub => {
223                let repo_url = self.repo_url;
224                let data = repo_url.split('/').collect::<Vec<&str>>();
225                if data.len() < 5 {
226                    return Err(Error::new(
227                        ErrorKind::InvalidInput,
228                        "Invalid GitHub repo url",
229                    ));
230                };
231
232                Ok(format!(
233                    "https://api.github.com/repos/{owner}/{repo}/releases/latest",
234                    owner = data[3],
235                    repo = data[4]
236                ))
237            }
238        }
239    }
240
241    fn extract_version_from_json(&self, data: &serde_json::Value) -> String {
242        match self.source {
243            Source::CratesIO => data["crate"]["max_stable_version"]
244                .to_string()
245                .trim_matches('"')
246                .to_string(),
247            Source::GitHub => data["tag_name"]
248                .to_string()
249                .trim_matches('"')
250                .trim_start_matches('v')
251                .to_string(),
252        }
253    }
254
255    fn should_check_update(&mut self) -> io::Result<bool> {
256        let binding = Self::get_cache_dir(self)?;
257        let cache_dir = binding.cache_dir();
258        if !cache_dir.exists() {
259            fs::create_dir_all(cache_dir)?;
260        }
261        let path = cache_dir.join(format!("{}-last-update-check", self.name));
262        if path.exists() {
263            let metadata = fs::metadata(path)?;
264            let last_modified_diff = metadata.modified()?.elapsed().unwrap_or_default();
265            Ok(last_modified_diff > self.interval)
266        } else {
267            Ok(true)
268        }
269    }
270
271    fn write_last_checked(&mut self) -> io::Result<()> {
272        let path = Self::get_cache_dir(self)?
273            .cache_dir()
274            .join(format!("{}-last-update-check", self.name));
275        fs::write(path, "")
276    }
277
278    fn get_cache_dir(&mut self) -> io::Result<ProjectDirs> {
279        let project_dir = ProjectDirs::from("", "", self.name);
280        project_dir
281            .ok_or_else(|| io::Error::new(ErrorKind::Other, "Could not get project directory"))
282    }
283}