tauri_plugin_android_update/
lib.rs1use std::sync::Mutex;
29
30use tauri::{
31 plugin::{Builder as PluginBuilder, TauriPlugin},
32 Manager, Runtime,
33};
34
35#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct UpdateMetadata {
39 pub version: String,
41 pub current_version: String,
43}
44
45pub struct Builder {
53 owner: String,
54 repo: String,
55 latest_json_url: Option<String>,
56 releases_url: Option<String>,
57}
58
59impl Default for Builder {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65impl Builder {
66 pub fn new() -> Self {
72 Self {
73 owner: String::new(),
74 repo: String::new(),
75 latest_json_url: None,
76 releases_url: None,
77 }
78 }
79
80 pub fn owner(mut self, owner: impl Into<String>) -> Self {
85 self.owner = owner.into();
86 self
87 }
88
89 pub fn repo(mut self, repo: impl Into<String>) -> Self {
94 self.repo = repo.into();
95 self
96 }
97
98 pub fn latest_json_url(mut self, url: impl Into<String>) -> Self {
102 self.latest_json_url = Some(url.into());
103 self
104 }
105
106 pub fn releases_url(mut self, url: impl Into<String>) -> Self {
109 self.releases_url = Some(url.into());
110 self
111 }
112
113 pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
126 let latest_json_url = self
127 .latest_json_url
128 .or_else(|| github_latest_json_url(&self.owner, &self.repo));
129 let releases_url = self
130 .releases_url
131 .or_else(|| github_releases_url(&self.owner, &self.repo));
132
133 PluginBuilder::<R>::new("android-update")
134 .setup(move |app, _api| {
135 let (Some(latest_json_url), Some(releases_url)) = (latest_json_url, releases_url)
136 else {
137 let message =
138 "owner and repo, or latest_json_url and releases_url, must be configured"
139 .to_string();
140 log::error!("failed to set up tauri-plugin-android-update: {message}");
141 return Err(message.into());
142 };
143 app.manage(Config {
144 latest_json_url,
145 releases_url,
146 });
147 app.manage(PendingUpdateInfo(Mutex::new(None)));
148 Ok(())
149 })
150 .invoke_handler(tauri::generate_handler![check, download_and_install])
151 .build()
152 }
153}
154
155fn github_latest_json_url(owner: &str, repo: &str) -> Option<String> {
159 repository_configured(owner, repo)
160 .then(|| format!("https://github.com/{owner}/{repo}/releases/latest/download/latest.json"))
161}
162
163fn github_releases_url(owner: &str, repo: &str) -> Option<String> {
166 repository_configured(owner, repo)
167 .then(|| format!("https://github.com/{owner}/{repo}/releases/latest"))
168}
169
170fn repository_configured(owner: &str, repo: &str) -> bool {
171 !owner.is_empty() && !repo.is_empty()
172}
173
174pub struct Config {
179 latest_json_url: String,
180 releases_url: String,
181}
182
183#[derive(Clone)]
185struct PendingUpdate {
186 version: String,
187}
188
189pub struct PendingUpdateInfo(Mutex<Option<PendingUpdate>>);
194
195#[derive(serde::Deserialize)]
197struct LatestJson {
198 version: String,
199}
200
201fn compare_versions(fetched: &str, current: &str) -> Result<std::cmp::Ordering, String> {
206 let fetched =
207 semver::Version::parse(fetched.strip_prefix('v').unwrap_or(fetched)).map_err(|e| {
208 log::error!("failed to parse latest release version: {e}");
209 format!("failed to parse latest release version: {e}")
210 })?;
211 let current = semver::Version::parse(current).map_err(|e| {
212 log::error!("failed to parse current app version: {e}");
213 format!("failed to parse current app version: {e}")
214 })?;
215 Ok(fetched.cmp(¤t))
216}
217
218mod commands {
219 use super::{
220 compare_versions, Config, LatestJson, PendingUpdate, PendingUpdateInfo, UpdateMetadata,
221 };
222 use tauri::Runtime;
223 use tauri_plugin_opener::OpenerExt;
224
225 #[tauri::command]
236 pub async fn check<R: Runtime>(
237 app: tauri::AppHandle<R>,
238 config: tauri::State<'_, Config>,
239 pending_update: tauri::State<'_, PendingUpdateInfo>,
240 ) -> Result<Option<UpdateMetadata>, String> {
241 let client = reqwest::Client::builder()
242 .timeout(std::time::Duration::from_secs(15))
243 .build()
244 .map_err(|e| {
245 log::error!("failed to build http client: {e}");
246 format!("failed to build http client: {e}")
247 })?;
248 let body = client
249 .get(&config.latest_json_url)
250 .send()
251 .await
252 .map_err(|e| {
253 log::error!("failed to fetch latest release info: {e}");
254 format!("failed to fetch latest release info: {e}")
255 })?
256 .text()
257 .await
258 .map_err(|e| {
259 log::error!("failed to read latest release info: {e}");
260 format!("failed to read latest release info: {e}")
261 })?;
262 let latest_json: LatestJson = serde_json::from_str(&body).map_err(|e| {
263 log::error!("failed to parse latest release info: {e}");
264 format!("failed to parse latest release info: {e}")
265 })?;
266 let latest_version = latest_json.version.trim_start_matches('v').to_string();
267 let current_version = app.package_info().version.to_string();
268
269 let ordering = compare_versions(&latest_json.version, ¤t_version)?;
270
271 let mut pending = pending_update.0.lock().map_err(|e| {
272 log::error!("failed to lock pending update state: {e}");
273 format!("failed to lock pending update state: {e}")
274 })?;
275
276 match ordering {
277 std::cmp::Ordering::Greater => {
278 log::info!("update {latest_version} found");
279 *pending = Some(PendingUpdate {
280 version: latest_version.clone(),
281 });
282 Ok(Some(UpdateMetadata {
283 version: latest_version,
284 current_version,
285 }))
286 }
287 _ => {
288 log::info!("app is up to date ({current_version})");
289 *pending = None;
290 Ok(None)
291 }
292 }
293 }
294
295 #[tauri::command]
304 pub async fn download_and_install<R: Runtime>(
305 app: tauri::AppHandle<R>,
306 config: tauri::State<'_, Config>,
307 pending_update: tauri::State<'_, PendingUpdateInfo>,
308 ) -> Result<(), String> {
309 let pending = pending_update
310 .0
311 .lock()
312 .map_err(|e| {
313 log::error!("failed to lock pending update state: {e}");
314 format!("failed to lock pending update state: {e}")
315 })?
316 .as_ref()
317 .cloned()
318 .ok_or_else(|| {
319 log::error!("there is no pending update");
320 "there is no pending update".to_string()
321 })?;
322
323 log::info!(
324 "opening releases page for update {}: {}",
325 pending.version,
326 config.releases_url
327 );
328 app.opener()
329 .open_url(config.releases_url.clone(), None::<String>)
330 .map_err(|e| {
331 log::error!("failed to open releases page: {e:?}");
332 format!("failed to open releases page: {e:?}")
333 })?;
334
335 log::info!("releases page opened, user can download the update manually");
336 Ok(())
337 }
338}
339
340pub use commands::{check, download_and_install};
341
342#[cfg(test)]
343mod compare_versions_tests {
344 use super::compare_versions;
345 use std::cmp::Ordering;
346
347 #[test]
348 fn test_compare_versions_older_than_installed_is_not_an_update() {
349 assert_eq!(compare_versions("1.9.0", "2.0.0"), Ok(Ordering::Less));
350 }
351
352 #[test]
353 fn test_compare_versions_equal_to_installed_is_not_an_update() {
354 assert_eq!(compare_versions("2.0.0", "2.0.0"), Ok(Ordering::Equal));
355 }
356
357 #[test]
358 fn test_compare_versions_newer_than_installed_is_an_update() {
359 assert_eq!(compare_versions("2.0.1", "2.0.0"), Ok(Ordering::Greater));
360 assert_eq!(compare_versions("v2.0.1", "2.0.0"), Ok(Ordering::Greater));
361 }
362
363 #[test]
364 fn test_compare_versions_malformed_is_rejected() {
365 assert!(compare_versions("not-a-version", "2.0.0").is_err());
366 }
367
368 #[test]
369 fn test_compare_versions_double_v_prefix_is_rejected() {
370 assert!(compare_versions("vv2.0.1", "2.0.0").is_err());
371 }
372}
373
374#[cfg(test)]
375mod builder_tests {
376 use super::{github_latest_json_url, github_releases_url};
377
378 #[test]
379 fn test_github_urls_derived_from_owner_and_repo() {
380 assert_eq!(
381 github_latest_json_url("hrzlgnm", "mdns-browser"),
382 Some(
383 "https://github.com/hrzlgnm/mdns-browser/releases/latest/download/latest.json"
384 .to_string()
385 )
386 );
387 assert_eq!(
388 github_releases_url("hrzlgnm", "mdns-browser"),
389 Some("https://github.com/hrzlgnm/mdns-browser/releases/latest".to_string())
390 );
391 }
392
393 #[test]
394 fn test_github_urls_require_owner_and_repo() {
395 assert_eq!(github_latest_json_url("", "mdns-browser"), None);
396 assert_eq!(github_latest_json_url("hrzlgnm", ""), None);
397 assert_eq!(github_latest_json_url("", ""), None);
398 assert_eq!(github_releases_url("", "mdns-browser"), None);
399 }
400}