Skip to content

Commit

Permalink
#361: Notify user when app update is available
Browse files Browse the repository at this point in the history
  • Loading branch information
mtkennerly committed Jul 19, 2024
1 parent a4c35da commit ba0dbf7
Show file tree
Hide file tree
Showing 16 changed files with 175 additions and 4 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions lang/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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?
38 changes: 38 additions & 0 deletions src/gui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/gui/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions src/gui/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ pub enum Message {
CloseModal,
UpdateTime,
PruneNotifications,
AppReleaseToggle(bool),
AppReleaseChecked(Result<crate::metadata::Release, String>),
UpdateManifest,
ManifestUpdated(Vec<Result<Option<ManifestUpdate>, Error>>),
Backup(BackupPhase),
Expand Down Expand Up @@ -253,6 +255,7 @@ pub enum Message {
EditedCloudRemoteId(String),
EditedCloudPath(String),
OpenUrl(String),
OpenUrlAndCloseModal(String),
EditedCloudRemote(RemoteChoice),
ConfigureCloudSuccess(Remote),
ConfigureCloudFailure(CommandError),
Expand Down
2 changes: 2 additions & 0 deletions src/gui/icon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub enum Icon {
Lock,
LockOpen,
MoreVert,
OpenInBrowser,
OpenInNew,
PlayCircleOutline,
Refresh,
Expand Down Expand Up @@ -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}',
Expand Down
15 changes: 14 additions & 1 deletion src/gui/modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ pub enum Modal {
BackupValidation {
games: BTreeSet<String>,
},
AppUpdate {
release: crate::metadata::Release,
},
UpdatingManifest,
ConfirmCloudSync {
local: String,
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -341,6 +347,7 @@ impl Modal {
| Self::NoMissingRoots
| Self::ConfirmAddMissingRoots(_)
| Self::UpdatingManifest
| Self::AppUpdate { .. }
| Self::ConfigureFtpRemote { .. }
| Self::ConfigureSmbRemote { .. }
| Self::ConfigureWebDavRemote { .. } => vec![],
Expand All @@ -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() {
Expand Down Expand Up @@ -448,6 +456,7 @@ impl Modal {
| Self::NoMissingRoots
| Self::ConfirmAddMissingRoots(_)
| Self::BackupValidation { .. }
| Self::AppUpdate { .. }
| Self::UpdatingManifest
| Self::ConfigureFtpRemote { .. }
| Self::ConfigureSmbRemote { .. }
Expand Down Expand Up @@ -484,6 +493,7 @@ impl Modal {
| Self::NoMissingRoots
| Self::ConfirmAddMissingRoots(_)
| Self::BackupValidation { .. }
| Self::AppUpdate { .. }
| Self::UpdatingManifest
| Self::ConfigureFtpRemote { .. }
| Self::ConfigureSmbRemote { .. }
Expand All @@ -504,6 +514,7 @@ impl Modal {
| Self::NoMissingRoots
| Self::ConfirmAddMissingRoots(_)
| Self::BackupValidation { .. }
| Self::AppUpdate { .. }
| Self::UpdatingManifest
| Self::ConfigureFtpRemote { .. }
| Self::ConfigureSmbRemote { .. }
Expand All @@ -522,6 +533,7 @@ impl Modal {
| Self::NoMissingRoots
| Self::ConfirmAddMissingRoots(_)
| Self::BackupValidation { .. }
| Self::AppUpdate { .. }
| Self::UpdatingManifest
| Self::ConfigureFtpRemote { .. }
| Self::ConfigureSmbRemote { .. }
Expand All @@ -540,6 +552,7 @@ impl Modal {
| Self::NoMissingRoots
| Self::ConfirmAddMissingRoots(_)
| Self::BackupValidation { .. }
| Self::AppUpdate { .. }
| Self::UpdatingManifest
| Self::ConfigureFtpRemote { .. }
| Self::ConfigureSmbRemote { .. }
Expand Down
12 changes: 12 additions & 0 deletions src/gui/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 14 additions & 3 deletions src/lang.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = "+";
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod cli;
mod cloud;
mod gui;
mod lang;
mod metadata;
mod path;
mod prelude;
mod resource;
Expand Down
36 changes: 36 additions & 0 deletions src/metadata.rs
Original file line number Diff line number Diff line change
@@ -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<Self, crate::prelude::AnyError> {
#[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::<Response>(&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()),
}
}
}
1 change: 1 addition & 0 deletions src/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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<u32> = env!("CARGO_PKG_VERSION")
Expand Down
13 changes: 13 additions & 0 deletions src/resource/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@ 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<Root>,
pub backup: Backup,
pub restore: Restore,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Release {
pub checked: chrono::DateTime<chrono::Utc>,
pub latest: Option<semver::Version>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Migrations {
Expand Down Expand Up @@ -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
}
}
Loading

0 comments on commit ba0dbf7

Please sign in to comment.