Skip to main content

tauri_plugin_android_update/
lib.rs

1// Copyright 2026 hrzlgnm
2// SPDX-License-Identifier: MIT-0
3
4//! # tauri-plugin-android-update
5//!
6//! A Tauri plugin that surfaces new GitHub releases for manual download.
7//!
8//! [`tauri-plugin-updater`] cannot be used on Android, where apps must not
9//! self-install (Google Play Store regulations). This plugin fills that gap
10//! with the [`check`] and [`download_and_install`] commands — a custom API
11//! modeled on the `tauri-plugin-updater` command names — backed by the
12//! `latest.json` update manifest that the release workflow publishes: the
13//! Tauri bundler generates it via `createUpdaterArtifacts`, and
14//! `tauri-apps/tauri-action` attaches it (signed with the updater signing key
15//! configured in the workflow) to each GitHub release. Instead of downloading
16//! and installing, `download_and_install` opens the release page in the
17//! default browser so the user can install manually.
18//!
19//! The plugin registers the [`check`] and [`download_and_install`] commands
20//! under the `plugin:android-update|` namespace and manages the state they
21//! rely on. Grant the plugin's `default` permission in the app's capabilities
22//! for the frontend to invoke them. Register the plugin on platforms without
23//! self-install support (e.g. under `#[cfg(mobile)]`), where desktop apps keep
24//! using [`tauri-plugin-updater`].
25//!
26//! [`tauri-plugin-updater`]: https://docs.rs/tauri-plugin-updater
27
28use std::sync::Mutex;
29
30use tauri::{
31    plugin::{Builder as PluginBuilder, TauriPlugin},
32    Manager, Runtime,
33};
34
35/// Metadata about an available update, as returned by [`check`].
36#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct UpdateMetadata {
39    /// The version of the available update.
40    pub version: String,
41    /// The currently installed version.
42    pub current_version: String,
43}
44
45/// Configures the plugin with the GitHub repository whose releases are
46/// checked for updates.
47///
48/// Either set [`Builder::owner`] and [`Builder::repo`] to derive the URLs
49/// from the repository, or provide the URLs explicitly via
50/// [`Builder::latest_json_url`] and [`Builder::releases_url`]. Missing
51/// configuration is reported when the plugin is set up.
52pub 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    /// Creates a builder with no URLs configured.
67    ///
68    /// Configure the repository with [`Builder::owner`] and [`Builder::repo`],
69    /// or provide the URLs explicitly with [`Builder::latest_json_url`] and
70    /// [`Builder::releases_url`].
71    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    /// Sets the GitHub owner whose releases are checked for updates.
81    ///
82    /// Together with [`Builder::repo`] it derives the `latest.json` manifest
83    /// URL and the release page that [`download_and_install`] opens.
84    pub fn owner(mut self, owner: impl Into<String>) -> Self {
85        self.owner = owner.into();
86        self
87    }
88
89    /// Sets the GitHub repository whose releases are checked for updates.
90    ///
91    /// Together with [`Builder::owner`] it derives the `latest.json` manifest
92    /// URL and the release page that [`download_and_install`] opens.
93    pub fn repo(mut self, repo: impl Into<String>) -> Self {
94        self.repo = repo.into();
95        self
96    }
97
98    /// Overrides the URL of the `latest.json` update manifest that [`check`]
99    /// reads. Defaults to the manifest attached to the latest release of the
100    /// configured repository.
101    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    /// Overrides the release page that [`download_and_install`] opens.
107    /// Defaults to the latest release of the configured repository.
108    pub fn releases_url(mut self, url: impl Into<String>) -> Self {
109        self.releases_url = Some(url.into());
110        self
111    }
112
113    /// Builds the plugin.
114    ///
115    /// Registers the [`check`] and [`download_and_install`] commands and sets
116    /// up the state they rely on. Register it on platforms that cannot
117    /// self-install updates (e.g. under `#[cfg(mobile)]`) and grant the
118    /// plugin's `default` permission in the app's capabilities so the
119    /// frontend can invoke the commands under their `plugin:android-update|`
120    /// names.
121    ///
122    /// The URLs are resolved when the plugin is set up: an explicitly
123    /// configured URL wins, otherwise it is derived from `owner`/`repo`.
124    /// Plugin setup fails with an error if neither source is configured.
125    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
155/// The URL of the `latest.json` update manifest attached to the latest
156/// release of the GitHub repository `owner`/`repo`, if the repository is
157/// configured.
158fn 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
163/// The latest release page of the GitHub repository `owner`/`repo`, if the
164/// repository is configured.
165fn 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
174/// The release endpoints the plugin talks to.
175///
176/// Managed as app state by [`Builder::build`]; the [`check`] and
177/// [`download_and_install`] commands take it via `tauri::State`.
178pub struct Config {
179    latest_json_url: String,
180    releases_url: String,
181}
182
183/// The pending update stored between [`check`] and [`download_and_install`].
184#[derive(Clone)]
185struct PendingUpdate {
186    version: String,
187}
188
189/// App-managed state holding the pending update, if any.
190///
191/// Managed as app state by [`Builder::build`]; the [`check`] and
192/// [`download_and_install`] commands take it via `tauri::State`.
193pub struct PendingUpdateInfo(Mutex<Option<PendingUpdate>>);
194
195/// The `latest.json` update manifest published with each release.
196#[derive(serde::Deserialize)]
197struct LatestJson {
198    version: String,
199}
200
201/// Compares a fetched release version against the installed version.
202///
203/// A leading `v` on the fetched version is tolerated, matching how GitHub
204/// release tags and the `latest.json` manifest are formatted.
205fn 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(&current))
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    /// Checks the GitHub releases of the configured repository for a version
226    /// newer than the installed one, mirroring the `check` command of
227    /// [`tauri-plugin-updater`](https://docs.rs/tauri-plugin-updater).
228    ///
229    /// Returns the update metadata when a newer release exists and stores it as
230    /// the pending update for [`download_and_install`], or `None` when the app is
231    /// up to date.
232    ///
233    /// Registered by the plugin under the `plugin:android-update|check` name;
234    /// the app must grant the plugin's `default` permission.
235    #[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, &current_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    /// Opens the release page of the configured repository for the pending
296    /// update, where the user can download the new version manually. Named after
297    /// the `download_and_install` command of
298    /// [`tauri-plugin-updater`](https://docs.rs/tauri-plugin-updater).
299    ///
300    /// Registered by the plugin under the `plugin:android-update|`
301    /// `download_and_install` name; the app must grant the plugin's `default`
302    /// permission.
303    #[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}