From ba0dbf7f2e6da1ab2086dae14c26839ba48337bb Mon Sep 17 00:00:00 2001 From: mtkennerly Date: Fri, 19 Jul 2024 10:25:32 -0400 Subject: [PATCH] #361: Notify user when app update is available --- CHANGELOG.md | 2 ++ Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + lang/en-US.ftl | 3 +++ src/gui/app.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/gui/button.rs | 4 ++++ src/gui/common.rs | 3 +++ src/gui/icon.rs | 2 ++ src/gui/modal.rs | 15 ++++++++++++++- src/gui/screen.rs | 12 ++++++++++++ src/lang.rs | 17 ++++++++++++++--- src/main.rs | 1 + src/metadata.rs | 36 ++++++++++++++++++++++++++++++++++++ src/prelude.rs | 1 + src/resource/cache.rs | 13 +++++++++++++ src/resource/config.rs | 21 +++++++++++++++++++++ 16 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 src/metadata.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 74139b29..38e3aba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * You can now ignore specific manifests during scans. For example, if you only want to back up custom games, you can now disable the primary manifest's entries. + * GUI: On startup, Ludusavi will check if a new version is available and notify you. + This happens at most once per 7 days. * Fixed: * CLI: Some commands would fail with relative path arguments. diff --git a/Cargo.lock b/Cargo.lock index bd1d5017..045b231f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2435,6 +2435,7 @@ dependencies = [ "reqwest", "rusqlite", "schemars", + "semver", "serde", "serde_json", "serde_yaml", @@ -3604,6 +3605,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58bf37232d3bb9a2c4e641ca2a11d83b5062066f88df7fed36c28772046d65ba" +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + [[package]] name = "serde" version = "1.0.197" diff --git a/Cargo.toml b/Cargo.toml index 72385a98..241f86c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ regex = "1.10.3" reqwest = { version = "0.11.25", features = ["blocking", "gzip", "rustls-tls"], default-features = false } rusqlite = { version = "0.31.0", features = ["bundled"] } schemars = { version = "0.8.21", features = ["chrono"] } +semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.114" serde_yaml = "0.8.25" diff --git a/lang/en-US.ftl b/lang/en-US.ftl index 5ec04154..b5087157 100644 --- a/lang/en-US.ftl +++ b/lang/en-US.ftl @@ -269,3 +269,6 @@ back-up-specific-game = restore-specific-game = .confirm = Restore save data for {$game}? .failed = Failed to restore save data for {$game} + +new-version-check = Check for application updates automatically +new-version-available = An application update is available: {$version}. Would you like to view the release notes? diff --git a/src/gui/app.rs b/src/gui/app.rs index a3d83186..bb9df810 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -1271,6 +1271,13 @@ impl Application for App { )); } + if config.release.check && cache.should_check_app_update() { + commands.push(Command::perform( + async move { crate::metadata::Release::fetch().await }, + |join| Message::AppReleaseChecked(join.map_err(|x| x.to_string())), + )); + } + ( Self { backup_screen: screen::Backup::new(&config, &cache), @@ -1324,6 +1331,36 @@ impl Application for App { } Command::none() } + Message::AppReleaseToggle(enabled) => { + self.config.release.check = enabled; + self.save_config(); + Command::none() + } + Message::AppReleaseChecked(outcome) => { + self.save_cache(); + self.cache.release.checked = chrono::offset::Utc::now(); + + match outcome { + Ok(release) => { + let previous_latest = self.cache.release.latest.clone(); + self.cache.release.latest = Some(release.version.clone()); + + if previous_latest.as_ref() != Some(&release.version) { + // The latest available version has changed (or this is our first time checking) + if let Ok(current) = semver::Version::parse(*crate::VERSION) { + if release.version > current { + return self.show_modal(Modal::AppUpdate { release }); + } + } + } + } + Err(e) => { + log::warn!("App update check failed: {e:?}"); + } + } + + Command::none() + } Message::UpdateManifest => { self.updating_manifest = true; let manifest_config = self.config.manifest.clone(); @@ -2409,6 +2446,7 @@ impl Application for App { Command::none() } Message::OpenUrl(url) => Self::open_url(url), + Message::OpenUrlAndCloseModal(url) => Command::batch([Self::open_url(url), self.close_modal()]), Message::EditedCloudRemote(choice) => { if let Ok(remote) = Remote::try_from(choice) { match &remote { diff --git a/src/gui/button.rs b/src/gui/button.rs index d49db6dc..2029f2c7 100644 --- a/src/gui/button.rs +++ b/src/gui/button.rs @@ -225,6 +225,10 @@ pub fn open_url<'a>(label: String, url: String) -> Element<'a> { template(text(label).width(125), Some(Message::OpenUrl(url)), None) } +pub fn open_url_icon<'a>(url: String) -> Element<'a> { + template(Icon::OpenInBrowser.text(), Some(Message::OpenUrl(url)), None) +} + pub fn nav<'a>(screen: Screen, current_screen: Screen) -> Button<'a> { let label = match screen { Screen::Backup => TRANSLATOR.nav_backup_button(), diff --git a/src/gui/common.rs b/src/gui/common.rs index f18df4cd..968828d2 100644 --- a/src/gui/common.rs +++ b/src/gui/common.rs @@ -106,6 +106,8 @@ pub enum Message { CloseModal, UpdateTime, PruneNotifications, + AppReleaseToggle(bool), + AppReleaseChecked(Result), UpdateManifest, ManifestUpdated(Vec, Error>>), Backup(BackupPhase), @@ -253,6 +255,7 @@ pub enum Message { EditedCloudRemoteId(String), EditedCloudPath(String), OpenUrl(String), + OpenUrlAndCloseModal(String), EditedCloudRemote(RemoteChoice), ConfigureCloudSuccess(Remote), ConfigureCloudFailure(CommandError), diff --git a/src/gui/icon.rs b/src/gui/icon.rs index 0956ce9c..6769f769 100644 --- a/src/gui/icon.rs +++ b/src/gui/icon.rs @@ -26,6 +26,7 @@ pub enum Icon { Lock, LockOpen, MoreVert, + OpenInBrowser, OpenInNew, PlayCircleOutline, Refresh, @@ -61,6 +62,7 @@ impl Icon { Self::Lock => '\u{e897}', Self::LockOpen => '\u{e898}', Self::MoreVert => '\u{E5D4}', + Self::OpenInBrowser => '\u{e89d}', Self::OpenInNew => '\u{E89E}', Self::PlayCircleOutline => '\u{E039}', Self::Refresh => '\u{E5D5}', diff --git a/src/gui/modal.rs b/src/gui/modal.rs index e50f06bd..c979d77a 100644 --- a/src/gui/modal.rs +++ b/src/gui/modal.rs @@ -131,6 +131,9 @@ pub enum Modal { BackupValidation { games: BTreeSet, }, + AppUpdate { + release: crate::metadata::Release, + }, UpdatingManifest, ConfirmCloudSync { local: String, @@ -157,7 +160,8 @@ impl Modal { | Self::ConfirmAddMissingRoots(..) | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } - | Self::ConfigureWebDavRemote { .. } => ModalVariant::Confirm, + | Self::ConfigureWebDavRemote { .. } + | Self::AppUpdate { .. } => ModalVariant::Confirm, Self::BackupValidation { games } => { if games.is_empty() { ModalVariant::Info @@ -189,6 +193,7 @@ impl Modal { Self::ConfirmRestore { .. } => TRANSLATOR.confirm_restore(&config.restore.path, true), Self::NoMissingRoots => TRANSLATOR.no_missing_roots(), Self::ConfirmAddMissingRoots(missing) => TRANSLATOR.confirm_add_missing_roots(missing), + Self::AppUpdate { release } => TRANSLATOR.new_version_available(release.version.to_string().as_str()), Self::UpdatingManifest => TRANSLATOR.updating_manifest(), Self::BackupValidation { games } => { if games.is_empty() { @@ -235,6 +240,7 @@ impl Modal { games: games.clone(), })), Self::ConfirmAddMissingRoots(missing) => Some(Message::ConfirmAddMissingRoots(missing.clone())), + Self::AppUpdate { release } => Some(Message::OpenUrlAndCloseModal(release.url.clone())), Self::UpdatingManifest => None, Self::ConfirmCloudSync { direction, state, .. } => { if state.done() { @@ -341,6 +347,7 @@ impl Modal { | Self::NoMissingRoots | Self::ConfirmAddMissingRoots(_) | Self::UpdatingManifest + | Self::AppUpdate { .. } | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } | Self::ConfigureWebDavRemote { .. } => vec![], @@ -363,6 +370,7 @@ impl Modal { | Self::ConfirmRestore { .. } | Self::NoMissingRoots | Self::ConfirmAddMissingRoots(_) + | Self::AppUpdate { .. } | Self::UpdatingManifest => (), Self::BackupValidation { games } => { for game in games.iter().sorted() { @@ -448,6 +456,7 @@ impl Modal { | Self::NoMissingRoots | Self::ConfirmAddMissingRoots(_) | Self::BackupValidation { .. } + | Self::AppUpdate { .. } | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } @@ -484,6 +493,7 @@ impl Modal { | Self::NoMissingRoots | Self::ConfirmAddMissingRoots(_) | Self::BackupValidation { .. } + | Self::AppUpdate { .. } | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } @@ -504,6 +514,7 @@ impl Modal { | Self::NoMissingRoots | Self::ConfirmAddMissingRoots(_) | Self::BackupValidation { .. } + | Self::AppUpdate { .. } | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } @@ -522,6 +533,7 @@ impl Modal { | Self::NoMissingRoots | Self::ConfirmAddMissingRoots(_) | Self::BackupValidation { .. } + | Self::AppUpdate { .. } | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } @@ -540,6 +552,7 @@ impl Modal { | Self::NoMissingRoots | Self::ConfirmAddMissingRoots(_) | Self::BackupValidation { .. } + | Self::AppUpdate { .. } | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } diff --git a/src/gui/screen.rs b/src/gui/screen.rs index 0419a1c9..bda28ab6 100644 --- a/src/gui/screen.rs +++ b/src/gui/screen.rs @@ -26,6 +26,7 @@ use crate::{ }; const RCLONE_URL: &str = "https://rclone.org/downloads"; +const RELEASE_URL: &str = "https://github.com/mtkennerly/ludusavi/releases"; fn template(content: Column) -> Element { Container::new(content.spacing(15).align_items(Alignment::Center)) @@ -349,6 +350,17 @@ pub fn other<'a>( .style(style::PickList::Primary), ), ) + .push( + Row::new() + .align_items(iced::Alignment::Center) + .spacing(20) + .push(checkbox( + TRANSLATOR.new_version_check(), + config.release.check, + Message::AppReleaseToggle, + )) + .push(button::open_url_icon(RELEASE_URL.to_string())), + ) .push( Column::new().spacing(5).push(text(TRANSLATOR.scan_field())).push( Container::new( diff --git a/src/lang.rs b/src/lang.rs index f65506f9..b188999e 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -7,7 +7,7 @@ use regex::Regex; use unic_langid::LanguageIdentifier; use crate::{ - prelude::{CommandError, Error, StrictPath, VARIANT, VERSION}, + prelude::{CommandError, Error, StrictPath, VARIANT}, resource::{ config::{BackupFormat, CustomGameKind, RedirectKind, Root, SortKey, Theme, ZipCompression}, manifest::Store, @@ -29,6 +29,7 @@ const CODE: &str = "code"; const MESSAGE: &str = "message"; const APP: &str = "app"; const GAME: &str = "game"; +const VERSION: &str = "version"; pub const TRANSLATOR: Translator = Translator {}; pub const ADD_SYMBOL: &str = "+"; @@ -346,8 +347,8 @@ impl Translator { pub fn window_title(&self) -> String { let name = self.app_name(); match VARIANT { - Some(variant) => format!("{} v{} ({})", name, *VERSION, variant), - None => format!("{} v{}", name, *VERSION), + Some(variant) => format!("{} v{} ({})", name, *crate::prelude::VERSION, variant), + None => format!("{} v{}", name, *crate::prelude::VERSION), } } @@ -1369,6 +1370,16 @@ impl Translator { args.set(GAME, game); translate_args("restore-specific-game.failed", &args) } + + pub fn new_version_check(&self) -> String { + translate("new-version-check") + } + + pub fn new_version_available(&self, version: &str) -> String { + let mut args = FluentArgs::new(); + args.set(VERSION, version); + translate_args("new-version-available", &args) + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index decb88f1..a54392a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod cli; mod cloud; mod gui; mod lang; +mod metadata; mod path; mod prelude; mod resource; diff --git a/src/metadata.rs b/src/metadata.rs new file mode 100644 index 00000000..ce700925 --- /dev/null +++ b/src/metadata.rs @@ -0,0 +1,36 @@ +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct Release { + pub version: semver::Version, + pub url: String, +} + +impl Release { + const URL: &'static str = "https://api.github.com/repos/mtkennerly/ludusavi/releases/latest"; + + pub async fn fetch() -> Result { + #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] + pub struct Response { + pub html_url: String, + pub tag_name: String, + } + + let req = reqwest::Client::new() + .get(Self::URL) + .header(reqwest::header::USER_AGENT, &*crate::prelude::USER_AGENT); + let res = req.send().await?; + + match res.status() { + reqwest::StatusCode::OK => { + let bytes = res.bytes().await?.to_vec(); + let raw = String::from_utf8(bytes)?; + let parsed = serde_json::from_str::(&raw)?; + + Ok(Self { + version: semver::Version::parse(parsed.tag_name.trim_start_matches('v'))?, + url: parsed.html_url, + }) + } + code => Err(format!("status code: {code:?}").into()), + } + } +} diff --git a/src/prelude.rs b/src/prelude.rs index 9bc74d4a..4dbe6ee4 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -11,6 +11,7 @@ use crate::{path::CommonPath, resource::manifest::Os}; pub static VERSION: Lazy<&'static str> = Lazy::new(|| option_env!("LUDUSAVI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))); +pub static USER_AGENT: Lazy = Lazy::new(|| format!("ludusavi/{}", *VERSION)); pub static VARIANT: Option<&'static str> = option_env!("LUDUSAVI_VARIANT"); pub static CANONICAL_VERSION: Lazy<(u32, u32, u32)> = Lazy::new(|| { let version_parts: Vec = env!("CARGO_PKG_VERSION") diff --git a/src/resource/cache.rs b/src/resource/cache.rs index c3971137..64030070 100644 --- a/src/resource/cache.rs +++ b/src/resource/cache.rs @@ -14,6 +14,7 @@ use crate::{ #[serde(default)] pub struct Cache { pub version: Option<(u32, u32, u32)>, + pub release: Release, pub migrations: Migrations, pub manifests: Manifests, pub roots: BTreeSet, @@ -21,6 +22,13 @@ pub struct Cache { pub restore: Restore, } +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct Release { + pub checked: chrono::DateTime, + pub latest: Option, +} + #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct Migrations { @@ -120,4 +128,9 @@ impl Cache { } }) } + + pub fn should_check_app_update(&self) -> bool { + let now = chrono::offset::Utc::now(); + now.signed_duration_since(self.release.checked).num_days() >= 7 + } } diff --git a/src/resource/config.rs b/src/resource/config.rs index b9917e14..a38ecc45 100644 --- a/src/resource/config.rs +++ b/src/resource/config.rs @@ -29,6 +29,7 @@ fn default_backup_dir() -> StrictPath { #[serde(default, rename_all = "camelCase")] pub struct Config { pub runtime: Runtime, + pub release: Release, pub manifest: ManifestConfig, pub language: Language, pub theme: Theme, @@ -49,6 +50,20 @@ pub struct Runtime { pub threads: Option, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[serde(default, rename_all = "camelCase")] +pub struct Release { + /// Whether to check for new releases. + /// If enabled, Ludusavi will check at most once every 7 days. + pub check: bool, +} + +impl Default for Release { + fn default() -> Self { + Self { check: true } + } +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] #[serde(default, rename_all = "camelCase")] pub struct ManifestConfig { @@ -1925,6 +1940,8 @@ mod tests { fn can_parse_optional_fields_when_present_in_config() { let config = Config::load_from_string( r#" + release: + check: true manifest: url: example.com etag: "foo" @@ -1985,6 +2002,7 @@ mod tests { assert_eq!( Config { runtime: Default::default(), + release: Release { check: true }, manifest: ManifestConfig { url: s("example.com"), enable: true, @@ -2075,6 +2093,8 @@ mod tests { --- runtime: threads: ~ +release: + check: true manifest: url: example.com enable: true @@ -2171,6 +2191,7 @@ customGames: .trim(), serde_yaml::to_string(&Config { runtime: Default::default(), + release: Default::default(), manifest: ManifestConfig { url: s("example.com"), enable: true,