1
0
Fork 0
mirror of https://github.com/jugeeya/UltimateTrainingModpack.git synced 2024-11-20 00:46:34 +00:00

Automatically Keep the Modpack Up-to-Date (#595)

* Grab releases from Github and install

* Refactor

* Fix crash

* Work on fixing install() crash

* Fix the crash by increasing stack size

* Block main() execution on the auto-updater

* Improve error handling

* Delete hash.txt

* Fix get_update_policy()

* Use current time as default last_update_version; Compare publish date to last_update_version to determine if update should be applied.

* Use skyline_web dialogs for user_wants_to_install. Use default selections on emulator instead.

* Fix some logic

* Convert CURRENT_VERSION to a Mutex; implement functions to update the config file

* Adjust logging

* Remove unneeded file

* Allow unwrap after is_some() check

* Fix format

* Auto-updater (squashed)

* Move update policy from config file to menu

* Skip version check on emulator

* Rustfmt, clippy

---------

Co-authored-by: jugeeya <jugeeya@live.com>
This commit is contained in:
asimon-1 2023-08-18 00:21:59 -04:00 committed by GitHub
parent c360ce1ad5
commit cc78dc7e4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1386 additions and 953 deletions

4
.cargo/config.toml Normal file
View file

@ -0,0 +1,4 @@
[env]
AUTHOR = "jugeeya"
REPO_NAME = "UltimateTrainingModpack"
USER_AGENT = "UltimateTrainingModpack"

View file

@ -35,6 +35,9 @@ training_mod_tui = { path = "training_mod_tui" }
native-tls = { version = "0.2.11", features = ["vendored"] }
log = "0.4.17"
byte-unit = "4.0.18"
zip = { version = "0.6", default-features = false, features = ["deflate"]}
anyhow = "1.0.72"
[patch.crates-io]
native-tls = { git = "https://github.com/skyline-rs/rust-native-tls", branch = "switch-timeout-panic" }

62
src/common/dialog.rs Normal file
View file

@ -0,0 +1,62 @@
use crate::common::is_emulator;
use skyline_web::dialog::{Dialog, DialogOption};
use skyline_web::dialog_ok::DialogOk;
/// Returns true for yes, false for no
/// Returns false if you cancel with B
/// Returns `emulator_default` if you're on emulator
pub fn yes_no(prompt: String, emulator_default: bool) -> bool {
if is_emulator() {
return emulator_default;
}
Dialog::yes_no(prompt)
}
/// Returns true for yes, false for no
/// Returns false if you cancel with B
/// Returns `emulator_default` if you're on emulator
pub fn no_yes(prompt: String, emulator_default: bool) -> bool {
if is_emulator() {
return emulator_default;
}
Dialog::no_yes(prompt)
}
/// Returns true for ok, false for cancel
/// Returns false if you cancel with B
/// Returns `emulator_default` if you're on emulator
pub fn ok_cancel(prompt: String, emulator_default: bool) -> bool {
if is_emulator() {
return emulator_default;
}
Dialog::ok_cancel(prompt)
}
/// Returns `left` for the left option,
/// Returns `right` for the right option
/// Returns `default` if you cancel with B
/// Returns `emulator_default` if you're on emulator
pub fn left_right(
prompt: String,
left: String,
right: String,
default: String,
emulator_default: String,
) -> String {
if is_emulator() {
return emulator_default;
}
match Dialog::new(prompt, left.clone(), right.clone()).show() {
DialogOption::Left => left,
DialogOption::Right => right,
DialogOption::Default => default,
}
}
/// Always returns true after you accept the prompt
pub fn dialog_ok(prompt: String) -> bool {
if is_emulator() {
return true;
}
DialogOk::ok(prompt)
}

View file

@ -151,7 +151,7 @@ impl Event {
device_id: DEVICE_ID.get().unwrap().to_string(),
event_time,
session_id: SESSION_ID.get().unwrap().to_string(),
mod_version: CURRENT_VERSION.to_string(),
mod_version: CURRENT_VERSION.lock().to_string(),
smash_version: smash_version(),
..Default::default()
}

View file

@ -9,6 +9,7 @@ use crate::common::consts::*;
pub mod button_config;
pub mod consts;
pub mod dev_config;
pub mod dialog;
pub mod events;
pub mod input;
pub mod menu;

View file

@ -1,74 +1,224 @@
use std::fs;
use skyline_web::dialog_ok::DialogOk;
use crate::consts::{
LEGACY_MENU_OPTIONS_PATH, MENU_DEFAULT_OPTIONS_PATH, MENU_OPTIONS_PATH, VERSION_TXT_PATH,
};
#![allow(clippy::unnecessary_unwrap)]
use crate::consts::*;
use crate::dialog;
use crate::logging::*;
use crate::MENU;
use anyhow::{anyhow, Result};
use lazy_static::lazy_static;
use parking_lot::Mutex;
use serde_json::Value;
use std::io::{Error, ErrorKind};
use zip::ZipArchive;
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
enum VersionCheck {
Current,
NoFile,
Update,
lazy_static! {
pub static ref CURRENT_VERSION: Mutex<String> =
Mutex::new(get_current_version().expect("Could not determine current version!"));
}
fn is_current_version(fpath: &str) -> VersionCheck {
// Create a blank version file if it doesn't exists
if fs::metadata(fpath).is_err() {
fs::File::create(fpath).expect("Could not create version file!");
return VersionCheck::NoFile;
#[derive(Debug)]
pub struct Release {
pub url: String,
pub tag: String,
pub published_at: String,
}
if fs::read_to_string(fpath).unwrap_or_else(|_| "".to_string()) == CURRENT_VERSION {
VersionCheck::Current
} else {
VersionCheck::Update
}
}
fn record_current_version(fpath: &str) {
// Write the current version to the version file
fs::write(fpath, CURRENT_VERSION).expect("Could not record current version!")
}
pub fn version_check() {
match is_current_version(VERSION_TXT_PATH) {
VersionCheck::Current => {
// Version is current, no need to take any action
}
VersionCheck::Update => {
// Display dialog box on launch if changing versions
DialogOk::ok(
format!(
"Thank you for installing version {CURRENT_VERSION} of the Training Modpack.\n\n\
Due to a breaking change in this version, your menu selections and defaults must be reset once.\n\n\
Please refer to the Github page and the Discord server for a full list of recent features, bugfixes, and other changes."
)
impl Release {
/// Downloads and installs the release
pub fn install(self: &Release) -> Result<()> {
info!("Installing asset from URL: {}", &self.url);
let response = minreq::get(&self.url)
.with_header("User-Agent", "UltimateTrainingModpack")
.with_header("Accept", "application/octet-stream")
.send_lazy()?;
info!(
"Ok response from Github. Status Code: {}",
&response.status_code
);
// Remove old menu selections, silently ignoring errors (i.e. if the file doesn't exist)
[
MENU_OPTIONS_PATH,
MENU_DEFAULT_OPTIONS_PATH,
LEGACY_MENU_OPTIONS_PATH,
]
.iter()
.for_each(|path| {
fs::remove_file(path).unwrap_or_else(|_| error!("Couldn't remove {path}"))
let mut vec = Vec::new();
for result in response {
let (byte, length) = result?;
vec.reserve(length);
vec.push(byte);
}
info!("Finished receiving .zip file from GitHub.");
info!("Unpacking .zip file...");
let mut zip = ZipArchive::new(std::io::Cursor::new(vec))?;
zip.extract(UNPACK_PATH)?;
info!("Finished unpacking update");
info!("Updating config file with last update time...");
TrainingModpackConfig::change_last_update_version(&self.published_at)?;
dialog::dialog_ok(
"The Training Modpack has been updated.\n\n\
Your game will now restart."
.to_string(),
);
info!("Finished. Restarting...");
unsafe {
skyline::nn::oe::RequestToRelaunchApplication();
}
// Don't need a return type here because this area is unreachable
}
pub fn to_string(self: &Release) -> String {
format!("{} - {}", self.tag, self.published_at)
}
pub fn is_older_than_installed(self: &Release) -> bool {
// String comparison is good enough because for RFC3339 format,
// alphabetical order == chronological order
//
// https://datatracker.ietf.org/doc/html/rfc3339#section-5.1
let current_version = CURRENT_VERSION.lock();
self.published_at.as_str() < current_version.as_str()
}
}
fn get_update_policy() -> UpdatePolicy {
unsafe { MENU.update_policy }
}
fn get_release(beta: bool) -> Result<Release> {
// Get the list of releases from Github
let url = format!(
"https://api.github.com/repos/{}/{}/releases",
env!("AUTHOR"),
env!("REPO_NAME")
);
let response = minreq::get(url)
.with_header("User-Agent", env!("USER_AGENT"))
.with_header("Accept", "application/json")
.send()?;
let json: Vec<Value> = serde_json::from_str(response.as_str()?)?;
// Parse the list to determine the latest stable and beta release
let mut stable_release: Option<Release> = None;
let mut beta_release: Option<Release> = None;
for release in json.into_iter() {
// The list is ordered by date w/ most recent releases first
// so we only need to get the first of each type
let is_prerelease = release["prerelease"]
.as_bool()
.ok_or_else(|| anyhow!("prerelease is not a bool"))?;
if is_prerelease && beta_release.is_none() {
// Assumes that the first asset exists and is the right one
let url = release["assets"][0]["url"]
.as_str()
.ok_or_else(|| anyhow!("Could not parse beta asset url"))?;
let tag = release["tag_name"]
.as_str()
.ok_or_else(|| anyhow!("Could not parse beta asset tag_name"))?;
let published_at = release["published_at"]
.as_str()
.ok_or_else(|| anyhow!("Could not parse beta asset published_at"))?;
beta_release = Some(Release {
url: url.to_string(),
tag: tag.to_string(),
published_at: published_at.to_string(),
});
} else if !is_prerelease && stable_release.is_none() {
// Assumes that the first asset exists and is the right one
let url = release["assets"][0]["url"]
.as_str()
.ok_or_else(|| anyhow!("Could not parse stable asset url"))?;
let tag = release["tag_name"]
.as_str()
.ok_or_else(|| anyhow!("Could not parse stable asset tag_name"))?;
let published_at = release["published_at"]
.as_str()
.ok_or_else(|| anyhow!("Could not parse stable asset published_at"))?;
stable_release = Some(Release {
url: url.to_string(),
tag: tag.to_string(),
published_at: published_at.to_string(),
});
record_current_version(VERSION_TXT_PATH);
}
VersionCheck::NoFile => {
// Display dialog box on fresh installation
DialogOk::ok(
format!(
"Thank you for installing version {CURRENT_VERSION} of the Training Modpack.\n\n\
Please refer to the Github page and the Discord server for a full list of features and instructions on how to utilize the improved Training Mode."
if beta_release.is_some() && stable_release.is_some() {
// Don't iterate needlessly, we already found both releases
break;
}
}
if beta && beta_release.is_some() {
Ok(beta_release.unwrap())
} else if !beta && stable_release.is_some() {
Ok(stable_release.unwrap())
} else {
Err(anyhow!(
"The specified release was not found in the GitHub JSON response!"
))
}
}
fn user_wants_to_install() -> bool {
dialog::yes_no(
"There is a new update available for the Training Modpack. \n\n\
Do you want to install it?"
.to_string(),
true,
)
);
record_current_version(VERSION_TXT_PATH);
}
fn get_current_version() -> Result<String> {
let config = TrainingModpackConfig::load();
match config {
Ok(c) => {
info!("Config file found and parsed. Loading...");
Ok(c.update.last_update_version)
}
Err(e)
if e.is::<Error>()
&& e.downcast_ref::<Error>().unwrap().kind() == ErrorKind::NotFound =>
{
warn!("No config file found, creating default...");
TrainingModpackConfig::create_default()?;
get_current_version()
}
Err(e) => {
// Some other error, re-raise it
Err(e)
}
}
}
pub fn perform_version_check() {
let update_policy = get_update_policy();
info!("Update Policy is {}", update_policy);
let mut release_to_apply = match update_policy {
UpdatePolicy::Stable => get_release(false),
UpdatePolicy::Beta => get_release(true),
UpdatePolicy::Disabled => {
// User does not want to update at all
Err(anyhow!("Updates are disabled per UpdatePolicy"))
}
};
if release_to_apply.is_ok() {
let published_at = release_to_apply.as_ref().unwrap().published_at.clone();
let current_version = CURRENT_VERSION.lock();
info!("Current version: {}", current_version);
info!("Github version: {}", published_at);
drop(current_version); // Explicitly unlock, since we also acquire a lock in is_older_than_installed()
if release_to_apply.as_ref().unwrap().is_older_than_installed() {
release_to_apply = Err(anyhow!(
"Update is older than the current installed version.",
))
}
}
// Perform Update
match release_to_apply {
Ok(release) => {
if user_wants_to_install() {
info!("Installing update: {}", &release.to_string());
if let Err(e) = release.install() {
error!("Failed to install the update. Reason: {:?}", e);
}
} else {
info!("User declined the update.");
}
}
Err(e) => {
warn!("Did not install update. Reason: {:?}", e);
}
}
}

View file

@ -71,6 +71,7 @@ pub fn main() {
init_logger().unwrap();
info!("Initialized.");
unsafe {
EVENT_QUEUE.push(Event::smash_open());
notification("Training Modpack".to_string(), "Welcome!".to_string(), 60);
@ -99,11 +100,21 @@ pub fn main() {
});
}
info!("Performing version check...");
release::version_check();
menu::load_from_file();
if !is_emulator() {
info!("Performing version check...");
let _updater = std::thread::Builder::new()
.stack_size(0x20000)
.spawn(move || {
release::perform_version_check();
})
.unwrap();
let _result = _updater.join();
} else {
info!("Skipping version check because we are using an emulator");
}
unsafe {
notification("Training Modpack".to_string(), "Welcome!".to_string(), 60);
notification("Open Menu".to_string(), MENU.menu_open.to_string(), 120);

View file

@ -15,7 +15,10 @@ serde = { version = "1.0", features = ["derive"] }
serde_repr = "0.1.8"
serde_json = "1"
bitflags_serde_shim = "0.2"
skyline = { git = "https://github.com/ultimate-research/skyline-rs.git" }
skyline_smash = { git = "https://github.com/ultimate-research/skyline-smash.git", optional = true }
toml = "0.5.9"
anyhow = "1.0.72"
[features]
default = ["smash"]

View file

@ -0,0 +1,146 @@
use crate::files::*;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use skyline::nn::time;
use std::fs;
use std::io::{Error, ErrorKind};
use std::time::{SystemTime, UNIX_EPOCH};
/// Top level struct which represents the entirety of the modpack config
/// (Does not include in-game menu settings)
/// Each field here is a section of training_modpack.toml
#[derive(Serialize, Deserialize)]
pub struct TrainingModpackConfig {
pub update: UpdaterConfig,
}
impl TrainingModpackConfig {
pub fn new() -> TrainingModpackConfig {
TrainingModpackConfig {
update: UpdaterConfig::default(),
}
}
/// Attempts to load the config from file
pub fn load() -> Result<TrainingModpackConfig> {
if fs::metadata(TRAINING_MODPACK_TOML_PATH).is_ok() {
let toml_config_str = fs::read_to_string(TRAINING_MODPACK_TOML_PATH)?;
let parsed = toml::from_str::<TrainingModpackConfig>(&toml_config_str)?;
Ok(parsed)
} else {
Err(Error::from(ErrorKind::NotFound).into())
}
}
pub fn load_or_create() -> Result<TrainingModpackConfig> {
match TrainingModpackConfig::load() {
Ok(c) => Ok(c),
Err(e)
if e.is::<Error>()
&& e.downcast_ref::<Error>().unwrap().kind() == ErrorKind::NotFound =>
{
TrainingModpackConfig::create_default()?;
TrainingModpackConfig::load()
}
Err(e) => {
// Some other error, re-raise it
Err(e)
}
}
}
/// Creates a default config and saves to file
/// Returns Err if the file already exists
pub fn create_default() -> Result<()> {
if fs::metadata(TRAINING_MODPACK_TOML_PATH).is_ok() {
Err(Error::from(ErrorKind::AlreadyExists).into())
} else {
let default_config: TrainingModpackConfig = TrainingModpackConfig::new();
let contents = toml::to_string(&default_config)?;
fs::write(TRAINING_MODPACK_TOML_PATH, contents)?;
Ok(())
}
}
pub fn change_last_update_version(last_update_version: &str) -> Result<()> {
let mut config = TrainingModpackConfig::load()?;
config.update.last_update_version = last_update_version.to_string();
let contents = toml::to_string(&config)?;
fs::write(TRAINING_MODPACK_TOML_PATH, contents)?;
Ok(())
}
}
/// Since we can't rely on most time based libraries, this is a seconds -> date/time string based on the `chrono` crates implementation
/// God bless blujay and Raytwo
/// https://github.com/Raytwo/ARCropolis/blob/9dc1d59d1e8a3dcac433b10a90bb5b3fabad6c00/src/logging.rs#L15-L49
fn format_time_string(seconds: u64) -> String {
let leapyear = |year| -> bool { year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) };
static YEAR_TABLE: [[u64; 12]; 2] = [
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
];
let mut year = 1970;
let seconds_in_day = seconds % 86400;
let mut day_number = seconds / 86400;
let sec = seconds_in_day % 60;
let min = (seconds_in_day % 3600) / 60;
let hours = seconds_in_day / 3600;
loop {
let year_length = if leapyear(year) { 366 } else { 365 };
if day_number >= year_length {
day_number -= year_length;
year += 1;
} else {
break;
}
}
let mut month = 0;
while day_number >= YEAR_TABLE[if leapyear(year) { 1 } else { 0 }][month] {
day_number -= YEAR_TABLE[if leapyear(year) { 1 } else { 0 }][month];
month += 1;
}
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year,
month + 1,
day_number + 1,
hours,
min,
sec
)
}
fn now_utc() -> String {
unsafe {
time::Initialize();
}
let current_epoch_seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
format_time_string(current_epoch_seconds)
}
/// Config section for the automatic updater
#[derive(Serialize, Deserialize, Clone)]
pub struct UpdaterConfig {
pub last_update_version: String,
}
impl UpdaterConfig {
pub fn default() -> UpdaterConfig {
UpdaterConfig {
last_update_version: now_utc(),
}
}
}

View file

@ -12,3 +12,4 @@ pub const LEGACY_MENU_OPTIONS_PATH: &str =
"sd:/ultimate/TrainingModpack/training_modpack_menu.json";
pub const MENU_DEFAULT_OPTIONS_PATH: &str =
"sd:/ultimate/TrainingModpack/training_modpack_menu_defaults.conf";
pub const UNPACK_PATH: &str = "sd:/";

View file

@ -14,6 +14,8 @@ pub mod options;
pub use options::*;
pub mod files;
pub use files::*;
pub mod config;
pub use config::*;
#[repr(C)]
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
@ -87,6 +89,7 @@ pub struct TrainingModpackMenu {
pub input_record: ButtonConfig,
pub input_playback: ButtonConfig,
pub recording_crop: OnOff,
pub update_policy: UpdatePolicy,
}
#[repr(C)]
@ -191,6 +194,7 @@ pub static DEFAULTS_MENU: TrainingModpackMenu = TrainingModpackMenu {
input_record: ButtonConfig::ZR.union(ButtonConfig::DPAD_DOWN),
input_playback: ButtonConfig::ZR.union(ButtonConfig::DPAD_UP),
recording_crop: OnOff::On,
update_policy: UpdatePolicy::default(),
};
pub static mut MENU: TrainingModpackMenu = DEFAULTS_MENU;
@ -774,6 +778,14 @@ pub unsafe fn ui_menu(menu: TrainingModpackMenu) -> UiMenu {
true,
&(menu.hud as u32),
);
misc_tab.add_submenu_with_toggles::<UpdatePolicy>(
"Auto-Update".to_string(),
"update_policy".to_string(),
"Auto-Update: What type of Training Modpack updates to automatically apply. (CONSOLE ONLY)"
.to_string(),
true,
&(menu.update_policy as u32),
);
overall_menu.tabs.push(misc_tab);
let mut input_tab = Tab {

View file

@ -1663,3 +1663,43 @@ impl fmt::Display for ButtonConfig {
extra_bitflag_impls! {ButtonConfig}
impl_serde_for_bitflags!(ButtonConfig);
#[repr(u32)]
#[derive(
Debug, Clone, Copy, PartialEq, FromPrimitive, EnumIter, Serialize_repr, Deserialize_repr,
)]
pub enum UpdatePolicy {
Stable,
Beta,
Disabled,
}
impl UpdatePolicy {
pub const fn default() -> UpdatePolicy {
UpdatePolicy::Stable
}
}
impl fmt::Display for UpdatePolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}",
match *self {
UpdatePolicy::Stable => "Stable",
UpdatePolicy::Beta => "Beta",
UpdatePolicy::Disabled => "Disabled",
}
)
}
}
impl ToggleTrait for UpdatePolicy {
fn to_toggle_vals() -> Vec<u32> {
UpdatePolicy::iter().map(|i| i as u32).collect()
}
fn to_toggle_strings() -> Vec<String> {
UpdatePolicy::iter().map(|i| i.to_string()).collect()
}
}