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,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11,\n $12, $13, $14,\n $15, $16,\n $17, jsonb($18), jsonb($19),\n $20, $21, $22, $23,\n $24, $25, $26,\n $27\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n\n created = $12,\n modified = $13,\n last_played = $14,\n\n submitted_time_played = $15,\n recent_time_played = $16,\n\n override_java_path = $17,\n override_extra_launch_args = jsonb($18),\n override_custom_env_vars = jsonb($19),\n override_mc_memory_max = $20,\n override_mc_force_fullscreen = $21,\n override_mc_game_resolution_x = $22,\n override_mc_game_resolution_y = $23,\n\n override_hook_pre_launch = $24,\n override_hook_wrapper = $25,\n override_hook_post_exit = $26,\n\n protocol_version = $27\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 27
},
"nullable": []
},
"hash": "06368b9c4d9d386e9ba03ca91bced46853d4e8b369d5f97303241993d9d3a8e3"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version, launcher_feature_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11,\n $12, $13, $14,\n $15, $16,\n $17, jsonb($18), jsonb($19),\n $20, $21, $22, $23,\n $24, $25, $26,\n $27, $28\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n\n created = $12,\n modified = $13,\n last_played = $14,\n\n submitted_time_played = $15,\n recent_time_played = $16,\n\n override_java_path = $17,\n override_extra_launch_args = jsonb($18),\n override_custom_env_vars = jsonb($19),\n override_mc_memory_max = $20,\n override_mc_force_fullscreen = $21,\n override_mc_game_resolution_x = $22,\n override_mc_game_resolution_y = $23,\n\n override_hook_pre_launch = $24,\n override_hook_wrapper = $25,\n override_hook_post_exit = $26,\n\n protocol_version = $27,\n launcher_feature_version = $28\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 28
},
"nullable": []
},
"hash": "27283e20fc86c941c7d6d09259d8f4ec2e248f751f98140f77bea4f9d5971ef1"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1",
"query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))",
"describe": {
"columns": [
{
@@ -14,129 +14,134 @@
"type_info": "Text"
},
{
"name": "name",
"name": "launcher_feature_version",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "icon_path",
"name": "name",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "game_version",
"name": "icon_path",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "protocol_version",
"name": "game_version",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "protocol_version",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "mod_loader",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "mod_loader_version",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "groups!: serde_json::Value",
"name": "mod_loader_version",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "groups!: serde_json::Value",
"ordinal": 9,
"type_info": "Null"
},
{
"name": "linked_project_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "linked_version_id",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "locked",
"name": "linked_version_id",
"ordinal": 11,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "created",
"name": "locked",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "modified",
"name": "created",
"ordinal": 13,
"type_info": "Integer"
},
{
"name": "last_played",
"name": "modified",
"ordinal": 14,
"type_info": "Integer"
},
{
"name": "submitted_time_played",
"name": "last_played",
"ordinal": 15,
"type_info": "Integer"
},
{
"name": "recent_time_played",
"name": "submitted_time_played",
"ordinal": 16,
"type_info": "Integer"
},
{
"name": "override_java_path",
"name": "recent_time_played",
"ordinal": 17,
"type_info": "Integer"
},
{
"name": "override_java_path",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "override_extra_launch_args!: serde_json::Value",
"ordinal": 18,
"type_info": "Null"
},
{
"name": "override_custom_env_vars!: serde_json::Value",
"ordinal": 19,
"type_info": "Null"
},
{
"name": "override_mc_memory_max",
"name": "override_custom_env_vars!: serde_json::Value",
"ordinal": 20,
"type_info": "Integer"
"type_info": "Null"
},
{
"name": "override_mc_force_fullscreen",
"name": "override_mc_memory_max",
"ordinal": 21,
"type_info": "Integer"
},
{
"name": "override_mc_game_resolution_x",
"name": "override_mc_force_fullscreen",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "override_mc_game_resolution_y",
"name": "override_mc_game_resolution_x",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "override_hook_pre_launch",
"name": "override_mc_game_resolution_y",
"ordinal": 24,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "override_hook_wrapper",
"name": "override_hook_pre_launch",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "override_hook_post_exit",
"name": "override_hook_wrapper",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "override_hook_post_exit",
"ordinal": 27,
"type_info": "Text"
}
],
"parameters": {
@@ -146,6 +151,7 @@
false,
false,
false,
false,
true,
false,
true,
@@ -172,5 +178,5 @@
true
]
},
"hash": "30f436efc20582d160f9485ebfff6d3ababcde700fa4cf8324d87fa2181fc47d"
"hash": "6a434cc55635b6e325e9e5f06d21b787af281db4402c8ff45fe6a77b5be6c929"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "\n SELECT display_status\n FROM attached_world_data\n WHERE profile_path = $1 and world_type = $2 and world_id = $3\n ",
"describe": {
"columns": [
{
"name": "display_status",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false
]
},
"hash": "a2184fc5d62570aec0a15c0a8d628a597e90c2bf7ce5dc1b39edb6977e2f6da6"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n path, install_stage, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))",
"query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1",
"describe": {
"columns": [
{
@@ -14,129 +14,134 @@
"type_info": "Text"
},
{
"name": "name",
"name": "launcher_feature_version",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "icon_path",
"name": "name",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "game_version",
"name": "icon_path",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "protocol_version",
"name": "game_version",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "protocol_version",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "mod_loader",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "mod_loader_version",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "groups!: serde_json::Value",
"name": "mod_loader_version",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "groups!: serde_json::Value",
"ordinal": 9,
"type_info": "Null"
},
{
"name": "linked_project_id",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "linked_version_id",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "locked",
"name": "linked_version_id",
"ordinal": 11,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "created",
"name": "locked",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "modified",
"name": "created",
"ordinal": 13,
"type_info": "Integer"
},
{
"name": "last_played",
"name": "modified",
"ordinal": 14,
"type_info": "Integer"
},
{
"name": "submitted_time_played",
"name": "last_played",
"ordinal": 15,
"type_info": "Integer"
},
{
"name": "recent_time_played",
"name": "submitted_time_played",
"ordinal": 16,
"type_info": "Integer"
},
{
"name": "override_java_path",
"name": "recent_time_played",
"ordinal": 17,
"type_info": "Integer"
},
{
"name": "override_java_path",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "override_extra_launch_args!: serde_json::Value",
"ordinal": 18,
"type_info": "Null"
},
{
"name": "override_custom_env_vars!: serde_json::Value",
"ordinal": 19,
"type_info": "Null"
},
{
"name": "override_mc_memory_max",
"name": "override_custom_env_vars!: serde_json::Value",
"ordinal": 20,
"type_info": "Integer"
"type_info": "Null"
},
{
"name": "override_mc_force_fullscreen",
"name": "override_mc_memory_max",
"ordinal": 21,
"type_info": "Integer"
},
{
"name": "override_mc_game_resolution_x",
"name": "override_mc_force_fullscreen",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "override_mc_game_resolution_y",
"name": "override_mc_game_resolution_x",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "override_hook_pre_launch",
"name": "override_mc_game_resolution_y",
"ordinal": 24,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "override_hook_wrapper",
"name": "override_hook_pre_launch",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "override_hook_post_exit",
"name": "override_hook_wrapper",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "override_hook_post_exit",
"ordinal": 27,
"type_info": "Text"
}
],
"parameters": {
@@ -146,6 +151,7 @@
false,
false,
false,
false,
true,
false,
true,
@@ -172,5 +178,5 @@
true
]
},
"hash": "1b9181a1f130a097ef016aec5d14e69cc86189d182f04ae50ef8f894053d93cb"
"hash": "c108849d77c7627d6a11d5be34984938c41283e6091a1301fc3ca0b355ffcfa9"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO attached_world_data (profile_path, world_type, world_id, display_status)\nVALUES ($1, $2, $3, $4)\nON CONFLICT (profile_path, world_type, world_id) DO UPDATE\n SET display_status = $4",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "df600f2615979ab61bfe235a04add18a4900021ee6ccfc165c9a6dad41046cba"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "\n SELECT world_type, world_id, display_status\n FROM attached_world_data\n WHERE profile_path = $1\n ",
"describe": {
"columns": [
{
"name": "world_type",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "world_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "display_status",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "fd834e256e142820f25305ccffaf07f736c5772045b973dcc10573b399111344"
}

View File

@@ -19,6 +19,7 @@ flate2 = "1.0.28"
tempfile = "3.5.0"
dashmap = { version = "6.0.1", features = ["serde"] }
quick-xml = { version = "0.37", features = ["async-tokio"] }
enumset = "1.1"
chrono = { version = "0.4.19", features = ["serde"] }
daedalus = { path = "../../packages/daedalus" }
@@ -47,6 +48,7 @@ tokio-util = "0.7"
async-recursion = "1.0.4"
fs4 = { version = "0.13", features = ["tokio"] }
async-walkdir = "2.1"
async-compression = { version = "0.4", default-features = false, features = ["tokio", "gzip"] }
notify = { version = "6.1.1", default-features = false }
notify-debouncer-mini = { version = "0.4.1", default-features = false }

View File

@@ -0,0 +1 @@
ALTER TABLE profiles ADD COLUMN launcher_feature_version TEXT NOT NULL DEFAULT 'none'

View File

@@ -0,0 +1,10 @@
CREATE TABLE attached_world_data (
profile_path TEXT NOT NULL,
world_type TEXT CHECK ( world_type in ('singleplayer', 'server') ) NOT NULL,
world_id TEXT NOT NULL,
display_status TEXT NOT NULL DEFAULT 'normal',
PRIMARY KEY (profile_path, world_type, world_id),
FOREIGN KEY (profile_path) REFERENCES profiles(path) ON DELETE CASCADE
);
CREATE INDEX attached_world_data_profile_path ON attached_world_data(profile_path);

View File

@@ -42,8 +42,8 @@ impl CensoredString {
pub fn censor(mut s: String, credentials_set: &Vec<Credentials>) -> Self {
let username = whoami::username();
s = s
.replace(&format!("/{}/", username), "/{COMPUTER_USERNAME}/")
.replace(&format!("\\{}\\", username), "\\{COMPUTER_USERNAME}\\");
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
.replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\");
for credentials in credentials_set {
s = s
.replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}")

View File

@@ -30,7 +30,7 @@ pub async fn get_loader_versions(loader: &str) -> crate::Result<Manifest> {
)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor(format!("{} loader versions", loader))
crate::ErrorKind::NoValueFor(format!("{loader} loader versions"))
})?;
Ok(loaders.manifest)

View File

@@ -162,7 +162,7 @@ pub async fn import_atlauncher(
profile_path: profile_path.to_string(),
};
let backup_name = format!("ATLauncher-{}", instance_folder);
let backup_name = format!("ATLauncher-{instance_folder}");
let minecraft_folder = atlauncher_instance_path;
import_atlauncher_unmanaged(
@@ -190,8 +190,7 @@ async fn import_atlauncher_unmanaged(
let mod_loader: ModLoader = serde_json::from_str::<ModLoader>(&mod_loader)
.map_err(|_| {
crate::ErrorKind::InputError(format!(
"Could not parse mod loader type: {}",
mod_loader
"Could not parse mod loader type: {mod_loader}"
))
})?;

View File

@@ -266,10 +266,7 @@ pub async fn install_zipped_mrpack_files(
emit_loading(
&loading_bar,
30.0 / total_len as f64,
Some(&format!(
"Extracting override {}/{}",
index, total_len
)),
Some(&format!("Extracting override {index}/{total_len}")),
)?;
}
}

View File

@@ -1,7 +1,7 @@
//! Theseus profile management interface
use crate::launcher::get_loader_version_from_profile;
use crate::settings::Hooks;
use crate::state::{LinkedData, ProfileInstallStage};
use crate::state::{LauncherFeatureVersion, LinkedData, ProfileInstallStage};
use crate::util::io::{self, canonicalize};
use crate::{
event::{emit::emit_profile, ProfilePayloadType},
@@ -74,6 +74,7 @@ pub async fn profile_create(
let mut profile = Profile {
path: path.clone(),
install_stage: ProfileInstallStage::NotInstalled,
launcher_feature_version: LauncherFeatureVersion::MOST_RECENT,
name,
icon_path: None,
game_version,

View File

@@ -470,8 +470,7 @@ pub async fn export_mrpack(
state.io_semaphore.0.acquire().await?;
let profile = get(profile_path).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to export a nonexistent or unloaded profile at path {}!",
profile_path
"Tried to export a nonexistent or unloaded profile at path {profile_path}!"
))
})?;
@@ -617,8 +616,7 @@ fn pack_get_relative_path(
.strip_prefix(profile_path)
.map_err(|_| {
crate::ErrorKind::FSError(format!(
"Path {path:?} does not correspond to a profile",
path = path
"Path {path:?} does not correspond to a profile"
))
})?
.components()
@@ -656,8 +654,7 @@ pub async fn run_credentials(
let settings = Settings::get(&state.pool).await?;
let profile = get(path).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to run a nonexistent or unloaded profile at path {}!",
path
"Tried to run a nonexistent or unloaded profile at path {path}!"
))
})?;
@@ -753,8 +750,7 @@ pub async fn try_update_playtime(path: &str) -> crate::Result<()> {
let profile = get(path).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to update playtime for a nonexistent or unloaded profile at path {}!",
path
"Tried to update playtime for a nonexistent or unloaded profile at path {path}!"
))
})?;
let updated_recent_playtime = profile.recent_time_played;

View File

@@ -25,7 +25,7 @@ pub async fn update_managed_modrinth_version(
let unmanaged_err = || {
crate::ErrorKind::InputError(
format!("Profile at {} is not a managed modrinth pack, or has been disconnected.", profile_path),
format!("Profile at {profile_path} is not a managed modrinth pack, or has been disconnected."),
)
};
@@ -59,7 +59,7 @@ pub async fn repair_managed_modrinth(profile_path: &str) -> crate::Result<()> {
let unmanaged_err = || {
crate::ErrorKind::InputError(
format!("Profile at {} is not a managed modrinth pack, or has been disconnected.", profile_path),
format!("Profile at {profile_path} is not a managed modrinth pack, or has been disconnected."),
)
};

View File

@@ -1,7 +1,10 @@
use crate::data::ModLoader;
use crate::launcher::get_loader_version_from_profile;
use crate::profile::get_full_path;
use crate::state::{server_join_log, Profile, ProfileInstallStage};
use crate::state::attached_world_data::AttachedWorldData;
use crate::state::{
attached_world_data, server_join_log, Profile, ProfileInstallStage,
};
pub use crate::util::server_ping::{
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
};
@@ -11,6 +14,7 @@ use async_walkdir::WalkDir;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Local, TimeZone, Utc};
use either::Either;
use enumset::{EnumSet, EnumSetType};
use fs4::tokio::AsyncFileExt;
use futures::StreamExt;
use lazy_static::lazy_static;
@@ -42,10 +46,83 @@ pub struct World {
with = "either::serde_untagged_optional"
)]
pub icon: Option<Either<PathBuf, Url>>,
pub display_status: DisplayStatus,
#[serde(flatten)]
pub details: WorldDetails,
}
impl World {
pub fn world_type(&self) -> WorldType {
match self.details {
WorldDetails::Singleplayer { .. } => WorldType::Singleplayer,
WorldDetails::Server { .. } => WorldType::Server,
}
}
pub fn world_id(&self) -> &str {
match &self.details {
WorldDetails::Singleplayer { path, .. } => path,
WorldDetails::Server { address, .. } => address,
}
}
}
#[derive(
Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash, Default,
)]
#[serde(rename_all = "snake_case")]
pub enum WorldType {
#[default]
Singleplayer,
Server,
}
impl WorldType {
pub fn as_str(self) -> &'static str {
match self {
Self::Singleplayer => "singleplayer",
Self::Server => "server",
}
}
pub fn from_string(string: &str) -> Self {
match string {
"singleplayer" => Self::Singleplayer,
"server" => Self::Server,
_ => Self::Singleplayer,
}
}
}
#[derive(Deserialize, Serialize, EnumSetType, Debug, Default)]
#[serde(rename_all = "snake_case")]
#[enumset(serialize_repr = "list")]
pub enum DisplayStatus {
#[default]
Normal,
Hidden,
Favorite,
}
impl DisplayStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Normal => "normal",
Self::Hidden => "hidden",
Self::Favorite => "favorite",
}
}
pub fn from_string(string: &str) -> Self {
match string {
"normal" => Self::Normal,
"hidden" => Self::Hidden,
"favorite" => Self::Favorite,
_ => Self::Normal,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WorldDetails {
@@ -101,7 +178,10 @@ impl From<ServerPackStatus> for Option<bool> {
}
}
pub async fn get_recent_worlds(limit: usize) -> Result<Vec<WorldWithProfile>> {
pub async fn get_recent_worlds(
limit: usize,
display_statuses: EnumSet<DisplayStatus>,
) -> Result<Vec<WorldWithProfile>> {
let state = State::get().await?;
let profiles_dir = state.directories.profiles_dir();
@@ -133,6 +213,9 @@ pub async fn get_recent_worlds(limit: usize) -> Result<Vec<WorldWithProfile>> {
if result.len() >= limit && is_older {
continue;
}
if !display_statuses.contains(world.display_status) {
continue;
}
if is_older {
least_recent_time = world.last_played;
}
@@ -166,6 +249,21 @@ async fn get_all_worlds_in_profile(
get_singleplayer_worlds_in_profile(profile_dir, &mut worlds).await?;
get_server_worlds_in_profile(profile_path, profile_dir, &mut worlds)
.await?;
let state = State::get().await?;
let attached_data =
AttachedWorldData::get_all_for_instance(profile_path, &state.pool)
.await?;
if !attached_data.is_empty() {
for world in worlds.iter_mut() {
if let Some(data) = attached_data
.get(&(world.world_type(), world.world_id().to_owned()))
{
attach_world_data_to_world(world, data);
}
}
}
Ok(worlds)
}
@@ -193,10 +291,25 @@ async fn get_singleplayer_worlds_in_profile(
}
pub async fn get_singleplayer_world(
profile_path: &Path,
instance: &str,
world: &str,
) -> Result<World> {
read_singleplayer_world(get_world_dir(profile_path, world)).await
let state = State::get().await?;
let profile_path = state.directories.profiles_dir().join(instance);
let mut world =
read_singleplayer_world(get_world_dir(&profile_path, world)).await?;
if let Some(data) = AttachedWorldData::get_for_world(
instance,
world.world_type(),
world.world_id(),
&state.pool,
)
.await?
{
attach_world_data_to_world(&mut world, &data);
}
Ok(world)
}
async fn read_singleplayer_world(world_path: PathBuf) -> Result<World> {
@@ -252,6 +365,7 @@ async fn read_singleplayer_world_maybe_locked(
name: level_data.level_name,
last_played: Utc.timestamp_millis_opt(level_data.last_played).single(),
icon: icon.map(Either::Left),
display_status: DisplayStatus::Normal,
details: WorldDetails::Singleplayer {
path: world_path
.file_name()
@@ -286,7 +400,7 @@ async fn get_server_worlds_in_profile(
continue;
}
let icon = server.icon.and_then(|icon| {
Url::parse(&format!("data:image/png;base64,{}", icon)).ok()
Url::parse(&format!("data:image/png;base64,{icon}")).ok()
});
let last_played = join_log
.as_ref()
@@ -299,6 +413,7 @@ async fn get_server_worlds_in_profile(
name: server.name,
last_played,
icon: icon.map(Either::Right),
display_status: DisplayStatus::Normal,
details: WorldDetails::Server {
index,
address: server.ip,
@@ -311,6 +426,28 @@ async fn get_server_worlds_in_profile(
Ok(())
}
fn attach_world_data_to_world(world: &mut World, data: &AttachedWorldData) {
world.display_status = data.display_status;
}
pub async fn set_world_display_status(
instance: &str,
world_type: WorldType,
world_id: &str,
display_status: DisplayStatus,
) -> Result<()> {
let state = State::get().await?;
attached_world_data::set_display_status(
instance,
world_type,
world_id,
display_status,
&state.pool,
)
.await?;
Ok(())
}
pub async fn rename_world(
instance: &Path,
world: &str,
@@ -365,7 +502,7 @@ pub async fn backup_world(instance: &Path, world: &str) -> Result<u64> {
let name_base = {
let now = Local::now();
let formatted_time = now.format("%Y-%m-%d_%H-%M-%S");
format!("{}_{}", formatted_time, world)
format!("{formatted_time}_{world}")
};
let output_path =
backups_dir.join(find_available_name(&backups_dir, &name_base, ".zip"));
@@ -671,8 +808,7 @@ pub async fn get_profile_protocol_version(
) -> Result<Option<i32>> {
let mut profile = super::profile::get(profile).await?.ok_or_else(|| {
ErrorKind::UnmanagedProfileError(format!(
"Could not find profile {}",
profile
"Could not find profile {profile}"
))
})?;
if profile.install_stage != ProfileInstallStage::Installed {
@@ -809,22 +945,21 @@ async fn resolve_server_address(
return Ok((host.to_owned(), port));
}
let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(match resolver
.srv_lookup(format!("_minecraft._tcp.{}", host))
.await
{
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
Ok(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
{
None
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
Err(e) => return Err(e.into()),
Ok(lookup) => lookup
.into_iter()
.next()
.map(|r| (r.target().to_string(), r.port())),
}
.unwrap_or_else(|| (host.to_owned(), port)))
.unwrap_or_else(|| (host.to_owned(), port)),
)
}

View File

@@ -438,16 +438,14 @@ pub async fn get_processor_main_class(
.map_err(|e| IOError::with_path(e, &path))?;
let mut archive = zip::ZipArchive::new(zipfile).map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Cannot read processor at {}",
path
"Cannot read processor at {path}"
))
.as_error()
})?;
let file = archive.by_name("META-INF/MANIFEST.MF").map_err(|_| {
crate::ErrorKind::LauncherError(format!(
"Cannot read processor manifest at {}",
path
"Cannot read processor manifest at {path}"
))
.as_error()
})?;

View File

@@ -297,8 +297,7 @@ pub async fn install_minecraft(
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Java path invalid or non-functional: {:?}",
java_version
"Java path invalid or non-functional: {java_version:?}"
))
})?;
@@ -406,8 +405,7 @@ pub async fn install_minecraft(
&loading_bar,
30.0 / total_length as f64,
Some(&format!(
"Running forge processor {}/{}",
index, total_length
"Running forge processor {index}/{total_length}"
)),
)?;
}
@@ -679,10 +677,10 @@ pub async fn launch_minecraft(
// check if the regex exists in the file
if !re.is_match(&options_string) {
// The key was not found in the file, so append it
options_string.push_str(&format!("\n{}:{}", key, value));
options_string.push_str(&format!("\n{key}:{value}"));
} else {
let replaced_string = re
.replace_all(&options_string, &format!("{}:{}", key, value))
.replace_all(&options_string, &format!("{key}:{value}"))
.to_string();
options_string = replaced_string;
}
@@ -700,12 +698,10 @@ pub async fn launch_minecraft(
let mut censor_strings = HashMap::new();
let username = whoami::username();
censor_strings
.insert(format!("/{username}/"), "/{COMPUTER_USERNAME}/".to_string());
censor_strings.insert(
format!("/{}/", username),
"/{COMPUTER_USERNAME}/".to_string(),
);
censor_strings.insert(
format!("\\{}\\", username),
format!("\\{username}\\"),
"\\{COMPUTER_USERNAME}\\".to_string(),
);
censor_strings.insert(

View File

@@ -0,0 +1,99 @@
use crate::worlds::{DisplayStatus, WorldType};
use paste::paste;
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct AttachedWorldData {
pub display_status: DisplayStatus,
}
impl AttachedWorldData {
pub async fn get_for_world(
instance: &str,
world_type: WorldType,
world_id: &str,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Option<Self>> {
let world_type = world_type.as_str();
let attached_data = sqlx::query!(
"
SELECT display_status
FROM attached_world_data
WHERE profile_path = $1 and world_type = $2 and world_id = $3
",
instance,
world_type,
world_id
)
.fetch_optional(exec)
.await?;
Ok(attached_data.map(|x| AttachedWorldData {
display_status: DisplayStatus::from_string(&x.display_status),
}))
}
pub async fn get_all_for_instance(
instance: &str,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<HashMap<(WorldType, String), Self>> {
let attached_data = sqlx::query!(
"
SELECT world_type, world_id, display_status
FROM attached_world_data
WHERE profile_path = $1
",
instance
)
.fetch_all(exec)
.await?;
Ok(attached_data
.into_iter()
.map(|x| {
let world_type = WorldType::from_string(&x.world_type);
let display_status =
DisplayStatus::from_string(&x.display_status);
(
(world_type, x.world_id),
AttachedWorldData { display_status },
)
})
.collect())
}
}
macro_rules! attached_data_setter {
($parameter:ident: $parameter_type:ty, $column:expr $(=> $adapter:expr)?) => {
paste! {
pub async fn [<set_ $parameter>](
instance: &str,
world_type: WorldType,
world_id: &str,
$parameter: $parameter_type,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let world_type = world_type.as_str();
$(let $parameter = $adapter;)?
sqlx::query!(
"INSERT INTO attached_world_data (profile_path, world_type, world_id, " + $column + ")\n" +
"VALUES ($1, $2, $3, $4)\n" +
"ON CONFLICT (profile_path, world_type, world_id) DO UPDATE\n" +
" SET " + $column + " = $4",
instance,
world_type,
world_id,
$parameter
)
.execute(exec)
.await?;
Ok(())
}
}
}
}
attached_data_setter!(display_status: DisplayStatus, "display_status" => display_status.as_str());

View File

@@ -843,7 +843,7 @@ impl CachedEntry {
fetch_semaphore: &FetchSemaphore,
pool: &SqlitePool,
) -> crate::Result<Vec<T>> {
const MAX_REQUEST_SIZE: usize = 1000;
const MAX_REQUEST_SIZE: usize = 800;
let urls = keys
.iter()
@@ -1072,7 +1072,7 @@ impl CachedEntry {
CacheValueType::File => {
let mut versions = fetch_json::<HashMap<String, Version>>(
Method::POST,
&format!("{}version_files", MODRINTH_API_URL),
&format!("{MODRINTH_API_URL}version_files"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1307,7 +1307,7 @@ impl CachedEntry {
});
let version_update_url =
format!("{}version_files/update", MODRINTH_API_URL);
format!("{MODRINTH_API_URL}version_files/update");
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
@@ -1481,7 +1481,7 @@ pub async fn cache_file_hash(
CachedEntry::upsert_many(
&[CacheValue::FileHash(CachedFileHash {
path: format!("{}/{}", profile_path, path),
path: format!("{profile_path}/{path}"),
size: size as u64,
hash,
project_type,

View File

@@ -21,8 +21,7 @@ impl DiscordGuard {
let dipc =
DiscordIpcClient::new("1123683254248148992").map_err(|e| {
crate::ErrorKind::OtherError(format!(
"Could not create Discord client {}",
e,
"Could not create Discord client {e}",
))
})?;
@@ -90,8 +89,7 @@ impl DiscordGuard {
let res = client.set_activity(activity.clone());
let could_not_set_err = |e: Box<dyn serde::ser::StdError>| {
crate::ErrorKind::OtherError(format!(
"Could not update Discord activity {}",
e,
"Could not update Discord activity {e}",
))
};
@@ -99,8 +97,7 @@ impl DiscordGuard {
if let Err(_e) = res {
client.reconnect().map_err(|e| {
crate::ErrorKind::OtherError(format!(
"Could not reconnect to Discord IPC {}",
e,
"Could not reconnect to Discord IPC {e}",
))
})?;
return Ok(client
@@ -131,8 +128,7 @@ impl DiscordGuard {
let could_not_clear_err = |e: Box<dyn serde::ser::StdError>| {
crate::ErrorKind::OtherError(format!(
"Could not clear Discord activity {}",
e,
"Could not clear Discord activity {e}",
))
};
@@ -140,8 +136,7 @@ impl DiscordGuard {
if res.is_err() {
client.reconnect().map_err(|e| {
crate::ErrorKind::OtherError(format!(
"Could not reconnect to Discord IPC {}",
e,
"Could not reconnect to Discord IPC {e}",
))
})?;
return Ok(client

View File

@@ -155,28 +155,47 @@ pub(crate) async fn watch_profile(
let profile_path = dirs.profiles_dir().join(profile_path);
if profile_path.exists() && profile_path.is_dir() {
for folder in ProjectType::iterator().map(|x| x.get_folder()).chain([
for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([
"crash-reports",
"saves",
"servers.dat",
]) {
let path = profile_path.join(folder);
let full_path = profile_path.join(sub_path);
if !path.exists() && !path.is_symlink() && !folder.contains(".") {
if let Err(e) = crate::util::io::create_dir_all(&path).await {
tracing::error!(
"Failed to create directory for watcher {path:?}: {e}"
);
return;
if !full_path.exists() && !full_path.is_symlink() {
if !sub_path.contains(".") {
if let Err(e) =
crate::util::io::create_dir_all(&full_path).await
{
tracing::error!(
"Failed to create directory for watcher {full_path:?}: {e}"
);
return;
}
} else if sub_path == "servers.dat" {
const EMPTY_NBT: &[u8] = &[
10, // Compound tag
0, 0, // Empty name
0, // End of compound tag
];
if let Err(e) =
crate::util::io::write(&full_path, EMPTY_NBT).await
{
tracing::error!(
"Failed to create file for watcher {full_path:?}: {e}"
);
return;
}
}
}
let mut watcher = watcher.write().await;
if let Err(e) =
watcher.watcher().watch(&path, RecursiveMode::Recursive)
if let Err(e) = watcher
.watcher()
.watch(&full_path, RecursiveMode::Recursive)
{
tracing::error!(
"Failed to watch directory for watcher {path:?}: {e}"
"Failed to watch directory for watcher {full_path:?}: {e}"
);
return;
}

View File

@@ -5,9 +5,9 @@ use crate::state;
use crate::state::{
CacheValue, CachedEntry, CachedFile, CachedFileHash, CachedFileUpdate,
Credentials, DefaultPage, DependencyType, DeviceToken, DeviceTokenKey,
DeviceTokenPair, FileType, Hooks, LinkedData, MemorySettings,
ModrinthCredentials, Profile, ProfileInstallStage, TeamMember, Theme,
VersionFile, WindowSize,
DeviceTokenPair, FileType, Hooks, LauncherFeatureVersion, LinkedData,
MemorySettings, ModrinthCredentials, Profile, ProfileInstallStage,
TeamMember, Theme, VersionFile, WindowSize,
};
use crate::util::fetch::{read_json, IoSemaphore};
use chrono::{DateTime, Utc};
@@ -317,6 +317,7 @@ where
ProfileInstallStage::NotInstalled
}
},
launcher_feature_version: LauncherFeatureVersion::None,
name: profile.metadata.name,
icon_path: profile.metadata.icon,
game_version: profile.metadata.game_version,

View File

@@ -1202,5 +1202,5 @@ fn generate_oauth_challenge() -> String {
let mut rng = rand::thread_rng();
let bytes: Vec<u8> = (0..64).map(|_| rng.gen::<u8>()).collect();
bytes.iter().map(|byte| format!("{:02x}", byte)).collect()
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

View File

@@ -45,6 +45,7 @@ pub use self::mr_auth::*;
mod legacy_converter;
pub mod attached_world_data;
pub mod server_join_log;
// Global state

View File

@@ -86,7 +86,7 @@ impl ProcessManager {
now.format("%Y-%m-%d %H:%M:%S")
)
.map_err(|e| IOError::with_path(e, &log_path))?;
writeln!(log_file, "# Profile: {} \n", profile_path)
writeln!(log_file, "# Profile: {profile_path} \n")
.map_err(|e| IOError::with_path(e, &log_path))?;
writeln!(log_file).map_err(|e| IOError::with_path(e, &log_path))?;
}
@@ -318,7 +318,7 @@ impl Process {
formatted_time,
thread,
if !logger.is_empty() {
format!("{}/", logger)
format!("{logger}/")
} else {
String::new()
},
@@ -383,7 +383,7 @@ impl Process {
formatted_time,
thread,
if !logger.is_empty() {
format!("{}/", logger)
format!("{logger}/")
} else {
String::new()
},
@@ -659,10 +659,7 @@ impl Process {
if log_path.exists() {
if let Err(e) = Process::append_to_log_file(
&log_path,
&format!(
"\n# Process exited with status: {}\n",
mc_exit_status
),
&format!("\n# Process exited with status: {mc_exit_status}\n"),
) {
tracing::warn!(
"Failed to write exit status to log file: {}",

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>,

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use chrono::{DateTime, TimeZone, Utc};
#[derive(Default)]
pub struct JoinLogEntry {
pub profile_path: String,
pub host: String,

View File

@@ -44,6 +44,8 @@ pub struct Settings {
pub enum FeatureFlag {
PagePath,
ProjectBackground,
WorldsTab,
WorldsInHome,
}
impl Settings {

View File

@@ -256,6 +256,19 @@ pub async fn remove_file(
})
}
// open file
pub async fn open_file(
path: impl AsRef<std::path::Path>,
) -> Result<tokio::fs::File, IOError> {
let path = path.as_ref();
tokio::fs::File::open(path)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: path.to_string_lossy().to_string(),
})
}
// remove dir
pub async fn remove_dir(
path: impl AsRef<std::path::Path>,

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="options.length > 1" class="flex flex-wrap gap-1 items-center">
<div v-if="showAllOptions || options.length > 1" class="flex flex-wrap gap-1 items-center">
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button
v-for="filter in options"
@@ -28,6 +28,7 @@ const selectedFilters = defineModel<string[]>({ required: true })
const props = defineProps<{
options: FilterBarOption[]
showAllOptions?: boolean
}>()
watch(

View File

@@ -26,7 +26,7 @@ const selected: Ref<string[]> = computed(() =>
const allSelected = ref(false)
const model = defineModel()
const model = defineModel<string[]>()
function updateSelection() {
model.value = selected.value

View File

@@ -1,27 +1,15 @@
<script setup>
<script setup lang="ts" generic="T extends string">
import { MoonIcon, RadioButtonCheckedIcon, RadioButtonIcon, SunIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
defineProps({
updateColorTheme: {
type: Function,
required: true,
},
currentTheme: {
type: String,
required: true,
},
themeOptions: {
type: Array,
required: true,
},
systemThemeColor: {
type: String,
required: true,
},
})
defineProps<{
updateColorTheme: (theme: T) => void
currentTheme: T
themeOptions: readonly T[]
systemThemeColor: T
}>()
const colorTheme = defineMessages({
title: {
@@ -61,6 +49,10 @@ const colorTheme = defineMessages({
defaultMessage: 'Preferred dark theme',
},
})
function asString(theme: T): string {
return theme
}
</script>
<template>
@@ -82,7 +74,7 @@ const colorTheme = defineMessages({
<div class="label">
<RadioButtonCheckedIcon v-if="currentTheme === option" class="radio" />
<RadioButtonIcon v-else class="radio" />
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
{{ colorTheme[asString(option)] ? formatMessage(colorTheme[asString(option)]) : option }}
<SunIcon
v-if="'light' === option"
v-tooltip="formatMessage(colorTheme.preferredLight)"

View File

@@ -67,6 +67,7 @@ export interface Project {
team: ModrinthId
thread_id: ModrinthId
organization: ModrinthId
issues_url?: string
source_url?: string
@@ -125,6 +126,46 @@ export interface SearchResult {
license: string
}
export type Organization = {
id: ModrinthId
slug: string
name: string
team_id: ModrinthId
description: string
icon_url: string
color: number
members: OrganizationMember[]
}
export type OrganizationPermissions = number
export type OrganizationMember = {
team_id: ModrinthId
user: User
role: string
is_owner: boolean
permissions: TeamMemberPermissions
organization_permissions: OrganizationPermissions
accepted: boolean
payouts_split: number
ordering: number
}
export type Collection = {
id: ModrinthId
user: User
name: string
description: string
icon_url: string
color: number
status: CollectionStatus
created: string
updated: string
projects: ModrinthId[]
}
export type CollectionStatus = 'listed' | 'unlisted' | 'private' | 'unknown'
export type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
export interface VersionDependency {
@@ -239,6 +280,7 @@ export interface TeamMember {
accepted: boolean
payouts_split: number
ordering: number
is_owner: boolean
}
export type Report = {