MR App 0.9.5 - Big bugfix update (#3585)

* Add launcher_feature_version to Profile

* Misc fixes

- Add typing to theme and settings stuff
- Push instance route on creation from installing a modpack
- Fixed servers not reloading properly when first added

* Make old instances scan the logs folder for joined servers on launcher startup

* Create AttachedWorldData

* Change AttachedWorldData interface

* Rename WorldType::World to WorldType::Singleplayer

* Implement world display status system

* Fix Minecraft font

* Fix set_world_display_status Tauri error

* Add 'Play instance' option

* Add option to disable worlds showing in Home

* Fixes

- Fix available server filter only showing if there are some available
- Fixed server and singleplayer filters sometimes showing when there are only servers or singleplayer worlds
- Fixed new worlds not being automatically added when detected
- Rephrased Jump back into worlds option description

* Fixed sometimes more than 6 items showing up in Jump back in

* Fix servers.dat issue with instances you haven't played before

* Fix too large of bulk requests being made, limit max to 800 #3430

* Add hiding from home page, add types to Mods.vue

* Make recent worlds go into grid when display is huge

* Fix lint

* Remove redundant media query

* Fix protocol version on home page, and home page being blocked by pinging servers

* Clippy fix

* More Clippy fixes

* Fix Prettier lints

* Undo `from_string` changes

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
Prospector
2025-05-01 16:13:13 -07:00
committed by GitHub
parent 4a2605bc1e
commit 3dad6b317f
123 changed files with 1622 additions and 744 deletions

View File

@@ -1,23 +1,32 @@
use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::profile::get_full_path;
use crate::state::server_join_log::JoinLogEntry;
use crate::state::{
cache_file_hash, CacheBehaviour, CachedEntry, CachedFileHash,
};
use crate::util;
use crate::util::fetch::{write_cached_icon, FetchSemaphore, IoSemaphore};
use crate::util::io::{self};
use chrono::{DateTime, TimeZone, Utc};
use chrono::{DateTime, TimeDelta, TimeZone, Utc};
use dashmap::DashMap;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashSet;
use std::convert::TryFrom;
use std::convert::TryInto;
use std::path::Path;
use tokio::fs::DirEntry;
use tokio::io::{AsyncBufReadExt, AsyncRead};
use tokio::task::JoinSet;
// Represent a Minecraft instance.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Profile {
pub path: String,
pub install_stage: ProfileInstallStage,
pub launcher_feature_version: LauncherFeatureVersion,
pub name: String,
pub icon_path: Option<String>,
@@ -87,6 +96,38 @@ impl ProfileInstallStage {
}
}
#[derive(
Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd,
)]
#[serde(rename_all = "snake_case")]
pub enum LauncherFeatureVersion {
None,
MigratedServerLastPlayTime,
}
impl LauncherFeatureVersion {
pub const MOST_RECENT: Self = Self::MigratedServerLastPlayTime;
pub fn as_str(&self) -> &'static str {
match *self {
Self::None => "none",
Self::MigratedServerLastPlayTime => {
"migrated_server_last_play_time"
}
}
}
pub fn from_str(val: &str) -> Self {
match val {
"none" => Self::None,
"migrated_server_last_play_time" => {
Self::MigratedServerLastPlayTime
}
_ => Self::None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LinkedData {
pub project_id: String,
@@ -263,6 +304,7 @@ struct ProfileQueryResult {
override_hook_wrapper: Option<String>,
override_hook_post_exit: Option<String>,
protocol_version: Option<i64>,
launcher_feature_version: String,
}
impl TryFrom<ProfileQueryResult> for Profile {
@@ -272,6 +314,9 @@ impl TryFrom<ProfileQueryResult> for Profile {
Ok(Profile {
path: x.path,
install_stage: ProfileInstallStage::from_str(&x.install_stage),
launcher_feature_version: LauncherFeatureVersion::from_str(
&x.launcher_feature_version,
),
name: x.name,
icon_path: x.icon_path,
game_version: x.game_version,
@@ -339,7 +384,7 @@ macro_rules! select_profiles_with_predicate {
ProfileQueryResult,
r#"
SELECT
path, install_stage, name, icon_path,
path, install_stage, launcher_feature_version, name, icon_path,
game_version, protocol_version, mod_loader, mod_loader_version,
json(groups) as "groups!: serde_json::Value",
linked_project_id, linked_version_id, locked,
@@ -402,6 +447,8 @@ impl Profile {
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let install_stage = self.install_stage.as_str();
let launcher_feature_version = self.launcher_feature_version.as_str();
let mod_loader = self.loader.as_str();
let groups = serde_json::to_string(&self.groups)?;
@@ -439,7 +486,7 @@ impl Profile {
override_java_path, override_extra_launch_args, override_custom_env_vars,
override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,
override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,
protocol_version
protocol_version, launcher_feature_version
)
VALUES (
$1, $2, $3, $4,
@@ -451,7 +498,7 @@ impl Profile {
$17, jsonb($18), jsonb($19),
$20, $21, $22, $23,
$24, $25, $26,
$27
$27, $28
)
ON CONFLICT (path) DO UPDATE SET
install_stage = $2,
@@ -487,7 +534,8 @@ impl Profile {
override_hook_wrapper = $25,
override_hook_post_exit = $26,
protocol_version = $27
protocol_version = $27,
launcher_feature_version = $28
",
self.path,
install_stage,
@@ -516,6 +564,7 @@ impl Profile {
self.hooks.wrapper,
self.hooks.post_exit,
self.protocol_version,
launcher_feature_version
)
.execute(exec)
.await?;
@@ -565,10 +614,10 @@ impl Profile {
let mut all = Self::get_all(&state.pool).await?;
let mut keys = vec![];
let mut migrations = JoinSet::new();
for profile in &mut all {
let path =
crate::api::profile::get_full_path(&profile.path).await?;
let path = get_full_path(&profile.path).await?;
for project_type in ProjectType::iterator() {
let folder = project_type.get_folder();
@@ -610,7 +659,42 @@ impl Profile {
profile.install_stage = ProfileInstallStage::NotInstalled;
profile.upsert(&state.pool).await?;
}
if profile.launcher_feature_version
< LauncherFeatureVersion::MOST_RECENT
{
let state = state.clone();
let profile_path = profile.path.clone();
migrations.spawn(async move {
let Ok(Some(mut profile)) = Self::get(&profile_path, &state.pool).await else {
tracing::error!("Failed to find instance '{}' for migration", profile_path);
return;
};
drop(profile_path);
tracing::info!(
"Migrating profile '{}' from launcher feature version {:?} to {:?}",
profile.path, profile.launcher_feature_version, LauncherFeatureVersion::MOST_RECENT
);
loop {
let result = profile.perform_launcher_feature_migration(&state).await;
if result.is_err() || profile.launcher_feature_version == LauncherFeatureVersion::MOST_RECENT {
if let Err(err) = result {
tracing::error!("Failed to migrate instance '{}': {}", profile.path, err);
return;
}
if let Err(err) = profile.upsert(&state.pool).await {
tracing::error!("Failed to update instance '{}' migration state: {}", profile.path, err);
return;
}
break;
}
}
tracing::info!("Finished migration for profile '{}'", profile.path);
});
}
}
migrations.join_all().await;
let file_hashes = CachedEntry::get_file_hash_many(
&keys.iter().map(|s| &**s).collect::<Vec<_>>(),
@@ -651,6 +735,144 @@ impl Profile {
Ok(())
}
async fn perform_launcher_feature_migration(
&mut self,
state: &crate::State,
) -> crate::Result<()> {
match self.launcher_feature_version {
LauncherFeatureVersion::None => {
if self.last_played.is_none() {
self.launcher_feature_version =
LauncherFeatureVersion::MigratedServerLastPlayTime;
return Ok(());
}
let mut join_log_entry = JoinLogEntry {
profile_path: self.path.clone(),
..Default::default()
};
let logs_path = state.directories.profile_logs_dir(&self.path);
let Ok(mut directory) = io::read_dir(&logs_path).await else {
self.launcher_feature_version =
LauncherFeatureVersion::MigratedServerLastPlayTime;
return Ok(());
};
let existing_joins_map =
super::server_join_log::get_joins(&self.path, &state.pool)
.await?;
let existing_joins = existing_joins_map
.keys()
.map(|x| (&x.0 as &str, x.1))
.collect::<HashSet<_>>();
while let Some(log_file) = directory.next_entry().await? {
if let Err(err) = Self::parse_log_file(
&log_file,
|host, port| existing_joins.contains(&(host, port)),
state,
&mut join_log_entry,
)
.await
{
tracing::error!(
"Failed to parse log file '{}': {}",
log_file.path().display(),
err
);
}
}
self.launcher_feature_version =
LauncherFeatureVersion::MigratedServerLastPlayTime;
}
LauncherFeatureVersion::MOST_RECENT => unreachable!(
"LauncherFeatureVersion::MOST_RECENT was not updated"
),
}
Ok(())
}
// Parses a log file on a best-effort basis, using the log's creation time, rather than the
// actual times mentioned in the log file, which are missing date information.
async fn parse_log_file(
log_file: &DirEntry,
should_skip: impl Fn(&str, u16) -> bool,
state: &crate::State,
join_entry: &mut JoinLogEntry,
) -> crate::Result<()> {
let file_name = log_file.file_name();
let Some(file_name) = file_name.to_str() else {
return Ok(());
};
let log_time = io::metadata(&log_file.path()).await?.created()?.into();
if file_name == "latest.log" {
let file = io::open_file(&log_file.path()).await?;
Self::parse_open_log_file(
file,
should_skip,
log_time,
state,
join_entry,
)
.await
} else if file_name.ends_with(".log.gz") {
let file = io::open_file(&log_file.path()).await?;
let file = tokio::io::BufReader::new(file);
let file =
async_compression::tokio::bufread::GzipDecoder::new(file);
Self::parse_open_log_file(
file,
should_skip,
log_time,
state,
join_entry,
)
.await
} else {
Ok(())
}
}
async fn parse_open_log_file(
reader: impl AsyncRead + Unpin,
should_skip: impl Fn(&str, u16) -> bool,
mut log_time: DateTime<Utc>,
state: &crate::State,
join_entry: &mut JoinLogEntry,
) -> crate::Result<()> {
lazy_static! {
static ref LOG_LINE_REGEX: Regex = Regex::new(r"^\[[0-9]{2}(?::[0-9]{2}){2}] \[.+?/[A-Z]+?]: Connecting to (.+?), ([1-9][0-9]{0,4})$").unwrap();
}
let reader = tokio::io::BufReader::new(reader);
let mut lines = reader.lines();
while let Some(log_line) = lines.next_line().await? {
let Some(log_line) = LOG_LINE_REGEX.captures(&log_line) else {
continue;
};
let Some(host) = log_line.get(1) else {
continue;
};
let host = host.as_str();
let Some(port) = log_line.get(2) else {
continue;
};
let Ok(port) = port.as_str().parse::<u16>() else {
continue;
};
if should_skip(host, port) {
continue;
}
join_entry.host = host.to_string();
join_entry.port = port;
join_entry.join_time = log_time;
join_entry.upsert(&state.pool).await?;
log_time += TimeDelta::seconds(1);
}
Ok(())
}
pub async fn get_projects(
&self,
cache_behaviour: Option<CacheBehaviour>,