use std::sync::Mutex;
use tauri::{
plugin::{Builder as PluginBuilder, TauriPlugin},
Manager, Runtime,
};
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMetadata {
pub version: String,
pub current_version: String,
}
pub struct Builder {
owner: String,
repo: String,
latest_json_url: Option<String>,
releases_url: Option<String>,
}
impl Default for Builder {
fn default() -> Self {
Self::new()
}
}
impl Builder {
pub fn new() -> Self {
Self {
owner: String::new(),
repo: String::new(),
latest_json_url: None,
releases_url: None,
}
}
pub fn owner(mut self, owner: impl Into<String>) -> Self {
self.owner = owner.into();
self
}
pub fn repo(mut self, repo: impl Into<String>) -> Self {
self.repo = repo.into();
self
}
pub fn latest_json_url(mut self, url: impl Into<String>) -> Self {
self.latest_json_url = Some(url.into());
self
}
pub fn releases_url(mut self, url: impl Into<String>) -> Self {
self.releases_url = Some(url.into());
self
}
pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
let latest_json_url = self
.latest_json_url
.or_else(|| github_latest_json_url(&self.owner, &self.repo));
let releases_url = self
.releases_url
.or_else(|| github_releases_url(&self.owner, &self.repo));
PluginBuilder::<R>::new("android-update")
.setup(move |app, _api| {
let (Some(latest_json_url), Some(releases_url)) = (latest_json_url, releases_url)
else {
let message =
"owner and repo, or latest_json_url and releases_url, must be configured"
.to_string();
log::error!("failed to set up tauri-plugin-android-update: {message}");
return Err(message.into());
};
app.manage(Config {
latest_json_url,
releases_url,
});
app.manage(PendingUpdateInfo(Mutex::new(None)));
Ok(())
})
.invoke_handler(tauri::generate_handler![check, download_and_install])
.build()
}
}
fn github_latest_json_url(owner: &str, repo: &str) -> Option<String> {
repository_configured(owner, repo)
.then(|| format!("https://github.com/{owner}/{repo}/releases/latest/download/latest.json"))
}
fn github_releases_url(owner: &str, repo: &str) -> Option<String> {
repository_configured(owner, repo)
.then(|| format!("https://github.com/{owner}/{repo}/releases/latest"))
}
fn repository_configured(owner: &str, repo: &str) -> bool {
!owner.is_empty() && !repo.is_empty()
}
pub struct Config {
latest_json_url: String,
releases_url: String,
}
#[derive(Clone)]
struct PendingUpdate {
version: String,
}
pub struct PendingUpdateInfo(Mutex<Option<PendingUpdate>>);
#[derive(serde::Deserialize)]
struct LatestJson {
version: String,
}
fn compare_versions(fetched: &str, current: &str) -> Result<std::cmp::Ordering, String> {
let fetched =
semver::Version::parse(fetched.strip_prefix('v').unwrap_or(fetched)).map_err(|e| {
log::error!("failed to parse latest release version: {e}");
format!("failed to parse latest release version: {e}")
})?;
let current = semver::Version::parse(current).map_err(|e| {
log::error!("failed to parse current app version: {e}");
format!("failed to parse current app version: {e}")
})?;
Ok(fetched.cmp(¤t))
}
mod commands {
use super::{
compare_versions, Config, LatestJson, PendingUpdate, PendingUpdateInfo, UpdateMetadata,
};
use tauri::Runtime;
use tauri_plugin_opener::OpenerExt;
#[tauri::command]
pub async fn check<R: Runtime>(
app: tauri::AppHandle<R>,
config: tauri::State<'_, Config>,
pending_update: tauri::State<'_, PendingUpdateInfo>,
) -> Result<Option<UpdateMetadata>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| {
log::error!("failed to build http client: {e}");
format!("failed to build http client: {e}")
})?;
let body = client
.get(&config.latest_json_url)
.send()
.await
.map_err(|e| {
log::error!("failed to fetch latest release info: {e}");
format!("failed to fetch latest release info: {e}")
})?
.text()
.await
.map_err(|e| {
log::error!("failed to read latest release info: {e}");
format!("failed to read latest release info: {e}")
})?;
let latest_json: LatestJson = serde_json::from_str(&body).map_err(|e| {
log::error!("failed to parse latest release info: {e}");
format!("failed to parse latest release info: {e}")
})?;
let latest_version = latest_json.version.trim_start_matches('v').to_string();
let current_version = app.package_info().version.to_string();
let ordering = compare_versions(&latest_json.version, ¤t_version)?;
let mut pending = pending_update.0.lock().map_err(|e| {
log::error!("failed to lock pending update state: {e}");
format!("failed to lock pending update state: {e}")
})?;
match ordering {
std::cmp::Ordering::Greater => {
log::info!("update {latest_version} found");
*pending = Some(PendingUpdate {
version: latest_version.clone(),
});
Ok(Some(UpdateMetadata {
version: latest_version,
current_version,
}))
}
_ => {
log::info!("app is up to date ({current_version})");
*pending = None;
Ok(None)
}
}
}
#[tauri::command]
pub async fn download_and_install<R: Runtime>(
app: tauri::AppHandle<R>,
config: tauri::State<'_, Config>,
pending_update: tauri::State<'_, PendingUpdateInfo>,
) -> Result<(), String> {
let pending = pending_update
.0
.lock()
.map_err(|e| {
log::error!("failed to lock pending update state: {e}");
format!("failed to lock pending update state: {e}")
})?
.as_ref()
.cloned()
.ok_or_else(|| {
log::error!("there is no pending update");
"there is no pending update".to_string()
})?;
log::info!(
"opening releases page for update {}: {}",
pending.version,
config.releases_url
);
app.opener()
.open_url(config.releases_url.clone(), None::<String>)
.map_err(|e| {
log::error!("failed to open releases page: {e:?}");
format!("failed to open releases page: {e:?}")
})?;
log::info!("releases page opened, user can download the update manually");
Ok(())
}
}
pub use commands::{check, download_and_install};
#[cfg(test)]
mod compare_versions_tests {
use super::compare_versions;
use std::cmp::Ordering;
#[test]
fn test_compare_versions_older_than_installed_is_not_an_update() {
assert_eq!(compare_versions("1.9.0", "2.0.0"), Ok(Ordering::Less));
}
#[test]
fn test_compare_versions_equal_to_installed_is_not_an_update() {
assert_eq!(compare_versions("2.0.0", "2.0.0"), Ok(Ordering::Equal));
}
#[test]
fn test_compare_versions_newer_than_installed_is_an_update() {
assert_eq!(compare_versions("2.0.1", "2.0.0"), Ok(Ordering::Greater));
assert_eq!(compare_versions("v2.0.1", "2.0.0"), Ok(Ordering::Greater));
}
#[test]
fn test_compare_versions_malformed_is_rejected() {
assert!(compare_versions("not-a-version", "2.0.0").is_err());
}
#[test]
fn test_compare_versions_double_v_prefix_is_rejected() {
assert!(compare_versions("vv2.0.1", "2.0.0").is_err());
}
}
#[cfg(test)]
mod builder_tests {
use super::{github_latest_json_url, github_releases_url};
#[test]
fn test_github_urls_derived_from_owner_and_repo() {
assert_eq!(
github_latest_json_url("hrzlgnm", "mdns-browser"),
Some(
"https://github.com/hrzlgnm/mdns-browser/releases/latest/download/latest.json"
.to_string()
)
);
assert_eq!(
github_releases_url("hrzlgnm", "mdns-browser"),
Some("https://github.com/hrzlgnm/mdns-browser/releases/latest".to_string())
);
}
#[test]
fn test_github_urls_require_owner_and_repo() {
assert_eq!(github_latest_json_url("", "mdns-browser"), None);
assert_eq!(github_latest_json_url("hrzlgnm", ""), None);
assert_eq!(github_latest_json_url("", ""), None);
assert_eq!(github_releases_url("", "mdns-browser"), None);
}
}