Merge commit '74cf3f076eff43755bb4bef62f1c1bb3fc0e6c2a' into feature-clean

This commit is contained in:
2025-05-26 17:59:09 +03:00
497 changed files with 15033 additions and 9421 deletions

View File

@@ -2,8 +2,8 @@ use std::path::PathBuf;
use crate::{
event::{
emit::{emit_command, emit_warning},
CommandPayload,
emit::{emit_command, emit_warning},
},
util::io,
};

View File

@@ -6,12 +6,13 @@ use dashmap::DashMap;
use reqwest::Method;
use serde::Deserialize;
use std::path::PathBuf;
use sysinfo::{MemoryRefreshKind, RefreshKind};
use crate::util::io;
use crate::util::jre::extract_java_majorminor_version;
use crate::{
util::jre::{self},
LoadingBarType, State,
util::jre::{self},
};
pub async fn get_java_versions() -> crate::Result<DashMap<u32, JavaVersion>> {
@@ -175,11 +176,10 @@ pub async fn test_jre(
// Gets maximum memory in KiB.
pub async fn get_max_memory() -> crate::Result<u64> {
Ok(sys_info::mem_info()
.map_err(|_| {
crate::Error::from(crate::ErrorKind::LauncherError(
"Unable to get computer memory".to_string(),
))
})?
.total)
Ok(sysinfo::System::new_with_specifics(
RefreshKind::nothing()
.with_memory(MemoryRefreshKind::nothing().with_ram()),
)
.total_memory()
/ 1024)
}

View File

@@ -9,9 +9,9 @@ use tokio::{
};
use crate::{
State,
prelude::Credentials,
util::io::{self, IOError},
State,
};
#[derive(Serialize, Debug)]
@@ -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}")
@@ -298,7 +298,7 @@ pub async fn get_latest_log_cursor(
profile_path: &str,
cursor: u64, // 0 to start at beginning of file
) -> crate::Result<LatestLogCursor> {
get_generic_live_log_cursor(profile_path, "latest.log", cursor).await
get_generic_live_log_cursor(profile_path, "launcher_log.txt", cursor).await
}
#[tracing::instrument]

View File

@@ -1,5 +1,5 @@
use crate::state::CachedEntry;
use crate::State;
use crate::state::CachedEntry;
pub use daedalus::minecraft::VersionManifest;
pub use daedalus::modded::Manifest;
@@ -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

@@ -1,7 +1,7 @@
//! Authentication flow interface
use crate::state::{Credentials, MinecraftLoginFlow};
use crate::State;
use crate::state::{Credentials, MinecraftLoginFlow};
#[tracing::instrument]
pub async fn begin_login() -> crate::Result<MinecraftLoginFlow> {

View File

@@ -12,7 +12,8 @@ pub mod process;
pub mod profile;
pub mod settings;
pub mod tags;
pub mod download;
pub mod download; // AstralRinth
pub mod worlds;
pub mod data {
pub use crate::state::{
@@ -27,12 +28,12 @@ pub mod data {
pub mod prelude {
pub use crate::{
State,
data::*,
event::CommandPayload,
jre, metadata, minecraft_auth, mr_auth, pack, process,
profile::{self, create, Profile},
profile::{self, Profile, create},
settings,
util::io::{canonicalize, IOError},
State,
util::io::{IOError, canonicalize},
};
}

View File

@@ -3,6 +3,7 @@ use std::{collections::HashMap, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
State,
pack::{
self,
import::{self, copy_dotminecraft},
@@ -11,7 +12,6 @@ use crate::{
prelude::ModLoader,
state::{LinkedData, ProfileInstallStage},
util::io,
State,
};
#[derive(Serialize, Deserialize)]
@@ -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

@@ -3,13 +3,13 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
State,
prelude::ModLoader,
state::ProfileInstallStage,
util::{
fetch::{fetch, write_cached_icon},
io,
},
State,
};
use super::{copy_dotminecraft, recache_icon};

View File

@@ -2,7 +2,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{prelude::ModLoader, state::ProfileInstallStage, util::io, State};
use crate::{State, prelude::ModLoader, state::ProfileInstallStage, util::io};
use super::{copy_dotminecraft, recache_icon};

View File

@@ -1,14 +1,14 @@
use std::path::{Path, PathBuf};
use serde::{de, Deserialize, Serialize};
use serde::{Deserialize, Serialize, de};
use crate::{
State,
pack::{
import::{self, copy_dotminecraft},
install_from::{self, CreatePackDescription, PackDependency},
},
util::io,
State,
};
// instance.cfg

View File

@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use crate::{
event::{
emit::{emit_loading, init_or_edit_loading},
LoadingBarId,
emit::{emit_loading, init_or_edit_loading},
},
util::{
fetch::{self, IoSemaphore},
@@ -71,7 +71,7 @@ pub async fn get_importable_instances(
return Err(crate::ErrorKind::InputError(
"Launcher type Unknown".to_string(),
)
.into())
.into());
}
};
@@ -187,11 +187,7 @@ pub fn get_default_launcher_path(
ImportLauncherType::Unknown => None,
};
let path = path?;
if path.exists() {
Some(path)
} else {
None
}
if path.exists() { Some(path) } else { None }
}
/// Checks if this PathBuf is a valid instance for the given launcher type

View File

@@ -1,10 +1,10 @@
use crate::State;
use crate::data::ModLoader;
use crate::event::emit::{emit_loading, init_loading};
use crate::event::{LoadingBarId, LoadingBarType};
use crate::state::{CachedEntry, LinkedData, ProfileInstallStage, SideType};
use crate::util::fetch::{fetch, fetch_advanced, write_cached_icon};
use crate::util::io;
use crate::State;
use reqwest::Method;
use serde::{Deserialize, Serialize};

View File

@@ -1,21 +1,21 @@
use crate::event::LoadingBarType;
use crate::event::emit::{
emit_loading, init_or_edit_loading, loading_try_for_each_concurrent,
};
use crate::event::LoadingBarType;
use crate::pack::install_from::{
set_profile_information, EnvType, PackFile, PackFileHash,
EnvType, PackFile, PackFileHash, set_profile_information,
};
use crate::state::{
cache_file_hash, CacheBehaviour, CachedEntry, ProfileInstallStage, SideType,
CacheBehaviour, CachedEntry, ProfileInstallStage, SideType, cache_file_hash,
};
use crate::util::fetch::{fetch_mirrors, write};
use crate::util::io;
use crate::{profile, State};
use crate::{State, profile};
use async_zip::base::read::seek::ZipFileReader;
use super::install_from::{
generate_pack_from_file, generate_pack_from_version_id, CreatePack,
CreatePackLocation, PackFormat,
CreatePack, CreatePackLocation, PackFormat, generate_pack_from_file,
generate_pack_from_version_id,
};
use crate::data::ProjectType;
use std::io::Cursor;
@@ -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

@@ -2,8 +2,8 @@
use crate::state::ProcessMetadata;
pub use crate::{
state::{Hooks, MemorySettings, Profile, Settings, WindowSize},
State,
state::{Hooks, MemorySettings, Profile, Settings, WindowSize},
};
use uuid::Uuid;

View File

@@ -1,14 +1,14 @@
//! 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::{ErrorKind, pack, profile};
pub use crate::{State, state::Profile};
use crate::{
event::{emit::emit_profile, ProfilePayloadType},
event::{ProfilePayloadType, emit::emit_profile},
prelude::ModLoader,
};
use crate::{pack, profile, ErrorKind};
pub use crate::{state::Profile, State};
use chrono::Utc;
use std::path::PathBuf;
use tracing::{info, trace};
@@ -74,9 +74,11 @@ 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,
protocol_version: None,
loader: modloader,
loader_version: loader.map(|x| x.id),
groups: Vec::new(),

View File

@@ -1,9 +1,9 @@
//! Theseus profile management interface
use crate::event::LoadingBarType;
use crate::event::emit::{
emit_loading, init_loading, loading_try_for_each_concurrent,
};
use crate::event::LoadingBarType;
use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
@@ -12,10 +12,10 @@ use crate::state::{
ProfileFile, ProfileInstallStage, ProjectType, SideType,
};
use crate::event::{emit::emit_profile, ProfilePayloadType};
use crate::event::{ProfilePayloadType, emit::emit_profile};
use crate::util::fetch;
use crate::util::io::{self, IOError};
pub use crate::{state::Profile, State};
pub use crate::{State, state::Profile};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use serde_json::json;
@@ -36,6 +36,13 @@ use tokio::{fs::File, process::Command, sync::RwLock};
pub mod create;
pub mod update;
#[derive(Debug, Clone)]
pub enum QuickPlayType {
None,
Singleplayer(String),
Server(String),
}
/// Remove a profile
#[tracing::instrument]
pub async fn remove(path: &str) -> crate::Result<()> {
@@ -463,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}!"
))
})?;
@@ -610,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()
@@ -623,14 +628,17 @@ fn pack_get_relative_path(
/// Run Minecraft using a profile and the default credentials, logged in credentials,
/// failing with an error if no credentials are available
#[tracing::instrument]
pub async fn run(path: &str) -> crate::Result<ProcessMetadata> {
pub async fn run(
path: &str,
quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
let default_account = Credentials::get_default_credential(&state.pool)
.await?
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?;
run_credentials(path, &default_account).await
run_credentials(path, &default_account, quick_play_type).await
}
/// Run Minecraft using a profile, and credentials for authentication
@@ -640,13 +648,13 @@ pub async fn run(path: &str) -> crate::Result<ProcessMetadata> {
pub async fn run_credentials(
path: &str,
credentials: &Credentials,
quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> {
let state = State::get().await?;
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}!"
))
})?;
@@ -719,6 +727,7 @@ pub async fn run_credentials(
credentials,
post_exit_hook,
&profile,
quick_play_type,
)
.await
}
@@ -741,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;
@@ -823,7 +831,7 @@ pub async fn create_mrpack_json(
return Err(crate::ErrorKind::OtherError(
"Loader version mismatch".to_string(),
)
.into())
.into());
}
};
dependencies

View File

@@ -1,13 +1,13 @@
use crate::state::CacheBehaviour;
use crate::{
LoadingBarType,
event::{
emit::{emit_profile, init_loading},
ProfilePayloadType,
emit::{emit_profile, init_loading},
},
pack::{self, install_from::generate_pack_from_version_id},
profile::get,
state::ProfileInstallStage,
LoadingBarType,
};
use futures::try_join;
@@ -24,9 +24,9 @@ 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),
)
crate::ErrorKind::InputError(format!(
"Profile at {profile_path} is not a managed modrinth pack, or has been disconnected."
))
};
// Extract modrinth pack information, if appropriate
@@ -58,9 +58,9 @@ 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),
)
crate::ErrorKind::InputError(format!(
"Profile at {profile_path} is not a managed modrinth pack, or has been disconnected."
))
};
// For repairing specifically, first we remove all installed projects (to ensure we do remove ones that aren't in the pack)

View File

@@ -1,8 +1,8 @@
//! Theseus profile management interface
pub use crate::{
state::{Hooks, MemorySettings, Profile, Settings, WindowSize},
State,
state::{Hooks, MemorySettings, Profile, Settings, WindowSize},
};
/// Gets entire settings

View File

@@ -1,8 +1,8 @@
//! Theseus tag management interface
use crate::state::CachedEntry;
pub use crate::{
state::{Category, DonationPlatform, GameVersion, Loader},
State,
state::{Category, DonationPlatform, GameVersion, Loader},
};
/// Get category tags

View File

@@ -0,0 +1,967 @@
use crate::data::ModLoader;
use crate::launcher::get_loader_version_from_profile;
use crate::profile::get_full_path;
use crate::state::attached_world_data::AttachedWorldData;
use crate::state::{
Profile, ProfileInstallStage, attached_world_data, server_join_log,
};
pub use crate::util::server_ping::{
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
};
use crate::util::{io, server_ping};
use crate::{Error, ErrorKind, Result, State, launcher};
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 quartz_nbt::{NbtCompound, NbtTag};
use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
use std::cmp::Reverse;
use std::io::Cursor;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tokio::io::AsyncWriteExt;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url;
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct WorldWithProfile {
pub profile: String,
#[serde(flatten)]
pub world: World,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct World {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_played: Option<DateTime<Utc>>,
#[serde(
skip_serializing_if = "Option::is_none",
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 {
Singleplayer {
path: String,
game_mode: SingleplayerGameMode,
hardcore: bool,
locked: bool,
},
Server {
index: usize,
address: String,
pack_status: ServerPackStatus,
},
}
#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum SingleplayerGameMode {
#[default]
Survival,
Creative,
Adventure,
Spectator,
}
#[derive(Deserialize, Serialize, Debug, Copy, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum ServerPackStatus {
Enabled,
Disabled,
#[default]
Prompt,
}
impl From<Option<bool>> for ServerPackStatus {
fn from(value: Option<bool>) -> Self {
match value {
Some(true) => ServerPackStatus::Enabled,
Some(false) => ServerPackStatus::Disabled,
None => ServerPackStatus::Prompt,
}
}
}
impl From<ServerPackStatus> for Option<bool> {
fn from(val: ServerPackStatus) -> Self {
match val {
ServerPackStatus::Enabled => Some(true),
ServerPackStatus::Disabled => Some(false),
ServerPackStatus::Prompt => None,
}
}
}
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();
let mut profiles = Profile::get_all(&state.pool).await?;
profiles.sort_by_key(|x| Reverse(x.last_played));
let mut result = Vec::with_capacity(limit);
let mut least_recent_time = None;
for profile in profiles {
if result.len() >= limit && profile.last_played < least_recent_time {
break;
}
let profile_path = &profile.path;
let profile_dir = profiles_dir.join(profile_path);
let profile_worlds =
get_all_worlds_in_profile(profile_path, &profile_dir).await;
if let Err(e) = profile_worlds {
tracing::error!(
"Failed to get worlds for profile {}: {}",
profile_path,
e
);
continue;
}
for world in profile_worlds? {
let is_older = least_recent_time.is_none()
|| world.last_played < least_recent_time;
if result.len() >= limit && is_older {
continue;
}
if !display_statuses.contains(world.display_status) {
continue;
}
if is_older {
least_recent_time = world.last_played;
}
result.push(WorldWithProfile {
profile: profile_path.clone(),
world,
});
}
if result.len() > limit {
result.sort_by_key(|x| Reverse(x.world.last_played));
result.truncate(limit);
}
}
if result.len() <= limit {
result.sort_by_key(|x| Reverse(x.world.last_played));
}
Ok(result)
}
pub async fn get_profile_worlds(profile_path: &str) -> Result<Vec<World>> {
get_all_worlds_in_profile(profile_path, &get_full_path(profile_path).await?)
.await
}
async fn get_all_worlds_in_profile(
profile_path: &str,
profile_dir: &Path,
) -> Result<Vec<World>> {
let mut worlds = vec![];
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)
}
async fn get_singleplayer_worlds_in_profile(
instance_dir: &Path,
worlds: &mut Vec<World>,
) -> Result<()> {
let saves_dir = instance_dir.join("saves");
if !saves_dir.exists() {
return Ok(());
}
let mut saves_dir = io::read_dir(saves_dir).await?;
while let Some(world_dir) = saves_dir.next_entry().await? {
let world_path = world_dir.path();
let level_dat_path = world_path.join("level.dat");
if !level_dat_path.exists() {
continue;
}
if let Ok(world) = read_singleplayer_world(world_path).await {
worlds.push(world);
}
}
Ok(())
}
pub async fn get_singleplayer_world(
instance: &str,
world: &str,
) -> Result<World> {
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> {
if let Some(_lock) = try_get_world_session_lock(&world_path).await? {
read_singleplayer_world_maybe_locked(world_path, false).await
} else {
read_singleplayer_world_maybe_locked(world_path, true).await
}
}
async fn read_singleplayer_world_maybe_locked(
world_path: PathBuf,
locked: bool,
) -> Result<World> {
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct LevelDataRoot {
data: LevelData,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct LevelData {
#[serde(default)]
level_name: String,
#[serde(default)]
last_played: i64,
#[serde(default)]
game_type: i32,
#[serde(default, rename = "hardcore")]
hardcore: bool,
}
let level_data = io::read(world_path.join("level.dat")).await?;
let level_data: LevelDataRoot = quartz_nbt::serde::deserialize(
&level_data,
quartz_nbt::io::Flavor::GzCompressed,
)?
.0;
let level_data = level_data.data;
let icon = Some(world_path.join("icon.png")).filter(|i| i.exists());
let game_mode = match level_data.game_type {
0 => SingleplayerGameMode::Survival,
1 => SingleplayerGameMode::Creative,
2 => SingleplayerGameMode::Adventure,
3 => SingleplayerGameMode::Spectator,
_ => SingleplayerGameMode::Survival,
};
Ok(World {
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()
.unwrap()
.to_string_lossy()
.to_string(),
game_mode,
hardcore: level_data.hardcore,
locked,
},
})
}
async fn get_server_worlds_in_profile(
profile_path: &str,
instance_dir: &Path,
worlds: &mut Vec<World>,
) -> Result<()> {
let servers = servers_data::read(instance_dir).await?;
if servers.is_empty() {
return Ok(());
}
let state = State::get().await?;
let join_log = server_join_log::get_joins(profile_path, &state.pool)
.await
.ok();
for (index, server) in servers.into_iter().enumerate() {
if server.hidden {
// TODO: Figure out whether we want to hide or show direct connect servers
continue;
}
let icon = server.icon.and_then(|icon| {
Url::parse(&format!("data:image/png;base64,{icon}")).ok()
});
let last_played = join_log
.as_ref()
.and_then(|log| {
let address = parse_server_address(&server.ip).ok()?;
log.get(&(address.0.to_owned(), address.1))
})
.copied();
let world = World {
name: server.name,
last_played,
icon: icon.map(Either::Right),
display_status: DisplayStatus::Normal,
details: WorldDetails::Server {
index,
address: server.ip,
pack_status: server.accept_textures.into(),
},
};
worlds.push(world);
}
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,
new_name: &str,
) -> Result<()> {
let world = get_world_dir(instance, world);
let level_dat_path = world.join("level.dat");
if !level_dat_path.exists() {
return Ok(());
}
let _lock = get_world_session_lock(&world).await?;
let level_data = io::read(&level_dat_path).await?;
let (mut root_data, _) = quartz_nbt::io::read_nbt(
&mut Cursor::new(level_data),
quartz_nbt::io::Flavor::GzCompressed,
)?;
let data = root_data.get_mut::<_, &mut NbtCompound>("Data")?;
data.insert(
"LevelName",
NbtTag::String(new_name.trim_ascii().to_string()),
);
let mut level_data = vec![];
quartz_nbt::io::write_nbt(
&mut level_data,
None,
&root_data,
quartz_nbt::io::Flavor::GzCompressed,
)?;
io::write(level_dat_path, level_data).await?;
Ok(())
}
pub async fn reset_world_icon(instance: &Path, world: &str) -> Result<()> {
let world = get_world_dir(instance, world);
let icon = world.join("icon.png");
if let Some(_lock) = try_get_world_session_lock(&world).await? {
let _ = io::remove_file(icon).await;
}
Ok(())
}
pub async fn backup_world(instance: &Path, world: &str) -> Result<u64> {
let world_dir = get_world_dir(instance, world);
let _lock = get_world_session_lock(&world_dir).await?;
let backups_dir = instance.join("backups");
io::create_dir_all(&backups_dir).await?;
let name_base = {
let now = Local::now();
let formatted_time = now.format("%Y-%m-%d_%H-%M-%S");
format!("{formatted_time}_{world}")
};
let output_path =
backups_dir.join(find_available_name(&backups_dir, &name_base, ".zip"));
let writer = tokio::fs::File::create(&output_path).await?;
let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(writer);
let mut walker = WalkDir::new(&world_dir);
while let Some(entry) = walker.next().await {
let entry = entry.map_err(|e| io::IOError::IOPathError {
path: e.path().unwrap().to_string_lossy().to_string(),
source: e.into_io().unwrap(),
})?;
if !entry.file_type().await?.is_file() {
continue;
}
if entry.file_name() == "session.lock" {
continue;
}
let zip_filename = format!(
"{world}/{}",
entry
.path()
.strip_prefix(&world_dir)?
.display()
.to_string()
.replace('\\', "/")
);
let mut stream = writer
.write_entry_stream(
ZipEntryBuilder::new(zip_filename.into(), Compression::Deflate)
.build(),
)
.await?
.compat_write();
let mut source = tokio::fs::File::open(entry.path()).await?;
tokio::io::copy(&mut source, &mut stream).await?;
stream.into_inner().close().await?;
}
writer.close().await?;
Ok(io::metadata(output_path).await?.len())
}
fn find_available_name(dir: &Path, file_name: &str, extension: &str) -> String {
static RESERVED_WINDOWS_FILENAMES: LazyLock<Regex> = LazyLock::new(|| {
RegexBuilder::new(r#"^.*\.|(?:COM|CLOCK\$|CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$"#)
.case_insensitive(true)
.build()
.unwrap()
});
static COPY_COUNTER_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
RegexBuilder::new(r#"^(?<name>.*) \((?<count>\d*)\)$"#)
.case_insensitive(true)
.unicode(true)
.build()
.unwrap()
});
let mut file_name = file_name.replace(
[
'/', '\n', '\r', '\t', '\0', '\x0c', '`', '?', '*', '\\', '<', '>',
'|', '"', ':', '.', '/', '"',
],
"_",
);
if RESERVED_WINDOWS_FILENAMES.is_match(&file_name) {
file_name.insert(0, '_');
file_name.push('_');
}
let mut count = 0;
if let Some(find) = COPY_COUNTER_PATTERN.captures(&file_name) {
count = find
.name("count")
.unwrap()
.as_str()
.parse::<i32>()
.unwrap_or(0);
let end = find.name("name").unwrap().end();
drop(find);
file_name.truncate(end);
}
if file_name.len() > 255 - extension.len() {
file_name.truncate(255 - extension.len());
}
let mut current_attempt = file_name.clone();
loop {
if count != 0 {
let with_count = format!(" ({count})");
if file_name.len() > 255 - with_count.len() {
current_attempt.truncate(255 - with_count.len());
}
current_attempt.push_str(&with_count);
}
current_attempt.push_str(extension);
let result = dir.join(&current_attempt);
if !result.exists() {
return current_attempt;
}
count += 1;
current_attempt.replace_range(..current_attempt.len(), &file_name);
}
}
pub async fn delete_world(instance: &Path, world: &str) -> Result<()> {
let world = get_world_dir(instance, world);
let lock = get_world_session_lock(&world).await?;
let lock_path = world.join("session.lock");
let mut dir = io::read_dir(&world).await?;
while let Some(entry) = dir.next_entry().await? {
let path = entry.path();
if entry.file_type().await?.is_dir() {
io::remove_dir_all(path).await?;
continue;
}
if path != lock_path {
io::remove_file(path).await?;
}
}
drop(lock);
io::remove_file(lock_path).await?;
io::remove_dir(world).await?;
Ok(())
}
fn get_world_dir(instance: &Path, world: &str) -> PathBuf {
instance.join("saves").join(world)
}
async fn get_world_session_lock(world: &Path) -> Result<tokio::fs::File> {
let lock_path = world.join("session.lock");
let mut file = tokio::fs::File::options()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.await?;
file.write_all("".as_bytes()).await?;
file.sync_all().await?;
let locked = file.try_lock_exclusive()?;
locked.then_some(file).ok_or_else(|| {
io::IOError::IOPathError {
source: std::io::Error::new(
std::io::ErrorKind::ResourceBusy,
"already locked by Minecraft",
),
path: lock_path.to_string_lossy().into_owned(),
}
.into()
})
}
async fn try_get_world_session_lock(
world: &Path,
) -> Result<Option<tokio::fs::File>> {
let file = tokio::fs::File::options()
.create(true)
.write(true)
.truncate(false)
.open(world.join("session.lock"))
.await?;
file.sync_all().await?;
let locked = file.try_lock_exclusive()?;
Ok(locked.then_some(file))
}
pub async fn add_server_to_profile(
profile_path: &Path,
name: String,
address: String,
pack_status: ServerPackStatus,
) -> Result<usize> {
let mut servers = servers_data::read(profile_path).await?;
let insert_index = servers
.iter()
.position(|x| x.hidden)
.unwrap_or(servers.len());
servers.insert(
insert_index,
servers_data::ServerData {
name,
ip: address,
accept_textures: pack_status.into(),
hidden: false,
icon: None,
},
);
servers_data::write(profile_path, &servers).await?;
Ok(insert_index)
}
pub async fn edit_server_in_profile(
profile_path: &Path,
index: usize,
name: String,
address: String,
pack_status: ServerPackStatus,
) -> Result<()> {
let mut servers = servers_data::read(profile_path).await?;
let server =
servers
.get_mut(index)
.filter(|x| !x.hidden)
.ok_or_else(|| {
ErrorKind::InputError(format!(
"No editable server at index {index}"
))
.as_error()
})?;
server.name = name;
server.ip = address;
server.accept_textures = pack_status.into();
servers_data::write(profile_path, &servers).await?;
Ok(())
}
pub async fn remove_server_from_profile(
profile_path: &Path,
index: usize,
) -> Result<()> {
let mut servers = servers_data::read(profile_path).await?;
if servers.get(index).filter(|x| !x.hidden).is_none() {
return Err(ErrorKind::InputError(format!(
"No removable server at index {index}"
))
.into());
}
servers.remove(index);
servers_data::write(profile_path, &servers).await?;
Ok(())
}
mod servers_data {
use crate::Result;
use crate::util::io;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ServerData {
#[serde(default)]
pub hidden: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub ip: String,
#[serde(default)]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub accept_textures: Option<bool>,
}
pub async fn read(instance_dir: &Path) -> Result<Vec<ServerData>> {
#[derive(Deserialize, Debug)]
struct ServersData {
#[serde(default)]
servers: Vec<ServerData>,
}
let servers_dat_path = instance_dir.join("servers.dat");
if !servers_dat_path.exists() {
return Ok(vec![]);
}
let servers_data = io::read(servers_dat_path).await?;
let servers_data: ServersData = quartz_nbt::serde::deserialize(
&servers_data,
quartz_nbt::io::Flavor::Uncompressed,
)?
.0;
Ok(servers_data.servers)
}
pub async fn write(
instance_dir: &Path,
servers: &[ServerData],
) -> Result<()> {
#[derive(Serialize, Debug)]
struct ServersData<'a> {
servers: &'a [ServerData],
}
let servers_dat_path = instance_dir.join("servers.dat");
let data = quartz_nbt::serde::serialize(
&ServersData { servers },
None,
quartz_nbt::io::Flavor::Uncompressed,
)?;
io::write(servers_dat_path, data).await?;
Ok(())
}
}
pub async fn get_profile_protocol_version(
profile: &str,
) -> Result<Option<i32>> {
let mut profile = super::profile::get(profile).await?.ok_or_else(|| {
ErrorKind::UnmanagedProfileError(format!(
"Could not find profile {profile}"
))
})?;
if profile.install_stage != ProfileInstallStage::Installed {
return Ok(None);
}
if let Some(protocol_version) = profile.protocol_version {
return Ok(Some(protocol_version));
}
let minecraft = crate::api::metadata::get_minecraft_versions().await?;
let version_index = minecraft
.versions
.iter()
.position(|it| it.id == profile.game_version)
.ok_or(crate::ErrorKind::LauncherError(format!(
"Invalid game version: {}",
profile.game_version
)))?;
let version = &minecraft.versions[version_index];
let loader_version = get_loader_version_from_profile(
&profile.game_version,
profile.loader,
profile.loader_version.as_deref(),
)
.await?;
if profile.loader != ModLoader::Vanilla && loader_version.is_none() {
return Ok(None);
}
let version_jar =
loader_version.as_ref().map_or(version.id.clone(), |it| {
format!("{}-{}", version.id.clone(), it.id.clone())
});
let state = State::get().await?;
let client_path = state
.directories
.version_dir(&version_jar)
.join(format!("{version_jar}.jar"));
if !client_path.exists() {
return Ok(None);
}
let version = launcher::read_protocol_version_from_jar(client_path).await?;
if version.is_some() {
profile.protocol_version = version;
profile.upsert(&state.pool).await?;
}
Ok(version)
}
pub async fn get_server_status(
address: &str,
protocol_version: Option<i32>,
) -> Result<ServerStatus> {
let (original_host, original_port) = parse_server_address(address)?;
let (host, port) =
resolve_server_address(original_host, original_port).await?;
server_ping::get_server_status(
&(&host as &str, port),
(original_host, original_port),
protocol_version,
)
.await
}
pub fn parse_server_address(address: &str) -> Result<(&str, u16)> {
parse_server_address_inner(address)
.map_err(|e| Error::from(ErrorKind::InputError(e)))
}
// Reimplementation of Guava's HostAndPort#fromString with a default port of 25565
fn parse_server_address_inner(
address: &str,
) -> std::result::Result<(&str, u16), String> {
let (host, port_str) = if address.starts_with("[") {
let colon_index = address.find(':');
let close_bracket_index = address.rfind(']');
if colon_index.is_none() || close_bracket_index.is_none() {
return Err(format!("Invalid bracketed host/port: {address}"));
}
let close_bracket_index = close_bracket_index.unwrap();
let host = &address[1..close_bracket_index];
if close_bracket_index + 1 == address.len() {
(host, "")
} else {
if address.as_bytes().get(close_bracket_index).copied()
!= Some(b':')
{
return Err(format!(
"Only a colon may follow a close bracket: {address}"
));
}
let port_str = &address[close_bracket_index + 2..];
for c in port_str.chars() {
if !c.is_ascii_digit() {
return Err(format!("Port must be numeric: {address}"));
}
}
(host, port_str)
}
} else {
let colon_pos = address.find(':');
if let Some(colon_pos) = colon_pos {
(&address[..colon_pos], &address[colon_pos + 1..])
} else {
(address, "")
}
};
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
}
}
Ok((host, port.unwrap_or(25565)))
}
async fn resolve_server_address(
host: &str,
port: u16,
) -> Result<(String, u16)> {
if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
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
}
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)),
)
}

View File

@@ -13,6 +13,12 @@ pub enum ErrorKind {
#[error("Serialization error (JSON): {0}")]
JSONError(#[from] serde_json::Error),
#[error("Serialization error (NBT): {0}")]
NBTError(#[from] quartz_nbt::io::NbtIoError),
#[error("NBT data structure error: {0}")]
NBTReprError(#[from] quartz_nbt::NbtReprError),
#[error("Serialization error (websocket): {0}")]
WebsocketSerializationError(
#[from] ariadne::networking::serialization::SerializationError,
@@ -116,6 +122,9 @@ pub enum ErrorKind {
#[error("Move directory error: {0}")]
DirectoryMoveError(String),
#[error("Error resolving DNS: {0}")]
DNSError(#[from] hickory_resolver::ResolveError),
}
#[derive(Debug)]

View File

@@ -161,7 +161,7 @@ pub fn emit_loading(
let display_frac = loading_bar.current / loading_bar.total;
let opt_display_frac = if display_frac >= 1.0 {
None // by convention, when its done, we submit None
// any further updates will be ignored (also sending None)
// any further updates will be ignored (also sending None)
} else {
Some(display_frac)
};

View File

@@ -1,5 +1,7 @@
//! Theseus state management system
use ariadne::users::{UserId, UserStatus};
use ariadne::ids::UserId;
use ariadne::users::UserStatus;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::{path::PathBuf, sync::Arc};
@@ -234,13 +236,23 @@ pub enum ProcessPayloadType {
#[derive(Serialize, Clone)]
pub struct ProfilePayload {
pub profile_path_id: String,
#[serde(flatten)]
pub event: ProfilePayloadType,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "snake_case")]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum ProfilePayloadType {
Created,
Synced,
ServersUpdated,
WorldUpdated {
world: String,
},
ServerJoined {
host: String,
port: u16,
timestamp: DateTime<Utc>,
},
Edited,
Removed,
}

View File

@@ -1,5 +1,6 @@
//! Minecraft CLI argument logic
use crate::launcher::parse_rules;
use crate::profile::QuickPlayType;
use crate::state::Credentials;
use crate::{
state::{MemorySettings, WindowSize},
@@ -31,7 +32,12 @@ pub fn get_class_paths(
.iter()
.filter_map(|library| {
if let Some(rules) = &library.rules {
if !parse_rules(rules, java_arch, minecraft_updated) {
if !parse_rules(
rules,
java_arch,
&QuickPlayType::None,
minecraft_updated,
) {
return None;
}
}
@@ -111,6 +117,7 @@ pub fn get_jvm_arguments(
memory: MemorySettings,
custom_args: Vec<String>,
java_arch: &str,
quick_play_type: &QuickPlayType,
log_config: Option<&LoggingConfiguration>,
) -> crate::Result<Vec<String>> {
let mut parsed_arguments = Vec::new();
@@ -130,6 +137,7 @@ pub fn get_jvm_arguments(
)
},
java_arch,
quick_play_type,
)?;
} else {
parsed_arguments.push(format!(
@@ -214,6 +222,7 @@ pub fn get_minecraft_arguments(
version_type: &VersionType,
resolution: WindowSize,
java_arch: &str,
quick_play_type: &QuickPlayType,
) -> crate::Result<Vec<String>> {
if let Some(arguments) = arguments {
let mut parsed_arguments = Vec::new();
@@ -233,9 +242,11 @@ pub fn get_minecraft_arguments(
assets_directory,
version_type,
resolution,
quick_play_type,
)
},
java_arch,
quick_play_type,
)?;
Ok(parsed_arguments)
@@ -253,6 +264,7 @@ pub fn get_minecraft_arguments(
assets_directory,
version_type,
resolution,
quick_play_type,
)?);
}
Ok(parsed_arguments)
@@ -273,6 +285,7 @@ fn parse_minecraft_argument(
assets_directory: &Path,
version_type: &VersionType,
resolution: WindowSize,
quick_play_type: &QuickPlayType,
) -> crate::Result<String> {
Ok(argument
.replace("${accessToken}", access_token)
@@ -326,7 +339,21 @@ fn parse_minecraft_argument(
)
.replace("${version_type}", version_type.as_str())
.replace("${resolution_width}", &resolution.0.to_string())
.replace("${resolution_height}", &resolution.1.to_string()))
.replace("${resolution_height}", &resolution.1.to_string())
.replace(
"${quickPlaySingleplayer}",
match quick_play_type {
QuickPlayType::Singleplayer(world) => world,
_ => "",
},
)
.replace(
"${quickPlayMultiplayer}",
match quick_play_type {
QuickPlayType::Server(address) => address,
_ => "",
},
))
}
fn parse_arguments<F>(
@@ -334,6 +361,7 @@ fn parse_arguments<F>(
parsed_arguments: &mut Vec<String>,
parse_function: F,
java_arch: &str,
quick_play_type: &QuickPlayType,
) -> crate::Result<()>
where
F: Fn(&str) -> crate::Result<String>,
@@ -348,7 +376,7 @@ where
}
}
Argument::Ruled { rules, value } => {
if parse_rules(rules, java_arch, true) {
if parse_rules(rules, java_arch, quick_play_type, true) {
match value {
ArgumentValue::Single(arg) => {
parsed_arguments.push(parse_function(
@@ -410,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

@@ -1,10 +1,11 @@
//! Downloader for Minecraft data
use crate::launcher::parse_rules;
use crate::profile::QuickPlayType;
use crate::{
event::{
emit::{emit_loading, loading_try_for_each_concurrent},
LoadingBarId,
emit::{emit_loading, loading_try_for_each_concurrent},
},
state::State,
util::{fetch::*, io, platform::OsExt},
@@ -295,7 +296,7 @@ pub async fn download_libraries(
stream::iter(libraries.iter())
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
if let Some(rules) = &library.rules {
if !parse_rules(rules, java_arch, minecraft_updated) {
if !parse_rules(rules, java_arch, &QuickPlayType::None, minecraft_updated) {
tracing::trace!("Skipped library {}", &library.name);
return Ok(());
}

View File

@@ -4,18 +4,21 @@ use crate::event::emit::{emit_loading, init_or_edit_loading};
use crate::event::{LoadingBarId, LoadingBarType};
use crate::launcher::download::download_log_config;
use crate::launcher::io::IOError;
use crate::profile::QuickPlayType;
use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
};
use crate::util::io;
use crate::{process, state as st, State};
use crate::{State, process, state as st};
use chrono::Utc;
use daedalus as d;
use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo};
use daedalus::modded::LoaderVersion;
use rand::seq::SliceRandom;
use rand::seq::SliceRandom; // AstralRinth
use serde::Deserialize;
use st::Profile;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::process::Command;
mod args;
@@ -31,11 +34,14 @@ use crate::state::ACTIVE_STATE;
pub fn parse_rules(
rules: &[d::minecraft::Rule],
java_version: &str,
quick_play_type: &QuickPlayType,
minecraft_updated: bool,
) -> bool {
let mut x = rules
.iter()
.map(|x| parse_rule(x, java_version, minecraft_updated))
.map(|x| {
parse_rule(x, java_version, quick_play_type, minecraft_updated)
})
.collect::<Vec<Option<bool>>>();
if rules
@@ -56,26 +62,30 @@ pub fn parse_rules(
pub fn parse_rule(
rule: &d::minecraft::Rule,
java_version: &str,
quick_play_type: &QuickPlayType,
minecraft_updated: bool,
) -> Option<bool> {
use d::minecraft::{Rule, RuleAction};
let res = match rule {
Rule {
os: Some(ref os), ..
} => {
Rule { os: Some(os), .. } => {
crate::util::platform::os_rule(os, java_version, minecraft_updated)
}
Rule {
features: Some(ref features),
features: Some(features),
..
} => {
!features.is_demo_user.unwrap_or(true)
|| features.has_custom_resolution.unwrap_or(false)
|| !features.has_quick_plays_support.unwrap_or(true)
|| !features.is_quick_play_multiplayer.unwrap_or(true)
|| (features.is_quick_play_singleplayer.unwrap_or(false)
&& matches!(
quick_play_type,
QuickPlayType::Singleplayer(_)
))
|| (features.is_quick_play_multiplayer.unwrap_or(false)
&& matches!(quick_play_type, QuickPlayType::Server(..)))
|| !features.is_quick_play_realms.unwrap_or(true)
|| !features.is_quick_play_singleplayer.unwrap_or(true)
}
_ => return Some(true),
};
@@ -288,8 +298,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:?}"
))
})?;
@@ -308,12 +317,11 @@ pub async fn install_minecraft(
)
.await?;
let client_path = state
.directories
.version_dir(&version_jar)
.join(format!("{version_jar}.jar"));
if let Some(processors) = &version_info.processors {
let client_path = state
.directories
.version_dir(&version_jar)
.join(format!("{version_jar}.jar"));
let libraries_dir = state.directories.libraries_dir();
if let Some(ref mut data) = version_info.data {
@@ -398,16 +406,18 @@ 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}"
)),
)?;
}
}
}
let protocol_version = read_protocol_version_from_jar(client_path).await?;
crate::api::profile::edit(&profile.path, |prof| {
prof.install_stage = ProfileInstallStage::Installed;
prof.protocol_version = protocol_version;
async { Ok(()) }
})
@@ -417,6 +427,34 @@ pub async fn install_minecraft(
Ok(())
}
pub async fn read_protocol_version_from_jar(
path: PathBuf,
) -> crate::Result<Option<i32>> {
let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?;
let Some(entry_index) = zip
.file()
.entries()
.iter()
.position(|x| matches!(x.filename().as_str(), Ok("version.json")))
else {
return Ok(None);
};
#[derive(Deserialize, Debug)]
struct VersionData {
protocol_version: Option<i32>,
}
let mut data = vec![];
zip.reader_with_entry(entry_index)
.await?
.read_to_end_checked(&mut data)
.await?;
let data: VersionData = serde_json::from_slice(&data)?;
Ok(data.protocol_version)
}
#[tracing::instrument(skip_all)]
#[allow(clippy::too_many_arguments)]
pub async fn launch_minecraft(
@@ -429,6 +467,7 @@ pub async fn launch_minecraft(
credentials: &Credentials,
post_exit_hook: Option<String>,
profile: &Profile,
quick_play_type: &QuickPlayType,
) -> crate::Result<ProcessMetadata> {
if profile.install_stage == ProfileInstallStage::PackInstalling
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
@@ -584,6 +623,7 @@ pub async fn launch_minecraft(
*memory,
Vec::from(java_args),
&java_version.architecture,
quick_play_type,
version_info
.logging
.as_ref()
@@ -606,6 +646,7 @@ pub async fn launch_minecraft(
&version.type_,
*resolution,
&java_version.architecture,
quick_play_type,
)?
.into_iter()
.collect::<Vec<_>>(),
@@ -637,10 +678,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;
}
@@ -658,12 +699,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(
@@ -712,6 +751,12 @@ pub async fn launch_minecraft(
// This also spawns the process and prepares the subsequent processes
state
.process_manager
.insert_new_process(&profile.path, command, post_exit_hook)
.insert_new_process(
&profile.path,
command,
post_exit_hook,
state.directories.profile_logs_dir(&profile.path),
version_info.logging.is_some(),
)
.await
}

View File

@@ -21,8 +21,8 @@ mod state;
pub use api::*;
pub use error::*;
pub use event::{
emit::emit_loading, emit::init_loading, EventState, LoadingBar,
LoadingBarType,
EventState, LoadingBar, LoadingBarType, emit::emit_loading,
emit::init_loading,
};
pub use logger::start_logger;
pub use state::State;

View File

@@ -0,0 +1,122 @@
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())
}
pub async fn remove_for_world(
instance: &str,
world_type: WorldType,
world_id: &str,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let world_type = world_type.as_str();
sqlx::query!(
"
DELETE FROM attached_world_data
WHERE profile_path = $1 and world_type = $2 and world_id = $3
",
instance,
world_type,
world_id
)
.execute(exec)
.await?;
Ok(())
}
}
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

@@ -1,6 +1,6 @@
use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::state::ProjectType;
use crate::util::fetch::{fetch_json, sha1_async, FetchSemaphore};
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
use chrono::{DateTime, Utc};
use dashmap::DashSet;
use reqwest::Method;
@@ -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",
@@ -1285,7 +1285,7 @@ impl CachedEntry {
if let Some(values) =
filtered_keys.iter_mut().find(|x| {
x.0 .0 == loaders_key && x.0 .1 == game_version
x.0.0 == loaders_key && x.0.1 == game_version
})
{
values.1.push(hash.to_string());
@@ -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

@@ -1,8 +1,8 @@
//! Theseus directory information
use crate::LoadingBarType;
use crate::event::emit::{emit_loading, init_loading};
use crate::state::{JavaVersion, Profile, Settings};
use crate::util::fetch::IoSemaphore;
use crate::LoadingBarType;
use dashmap::DashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;

View File

@@ -1,17 +1,16 @@
use std::{
sync::{atomic::AtomicBool, Arc},
time::{SystemTime, UNIX_EPOCH},
time::{SystemTime, UNIX_EPOCH}, // AstralRinth
};
use discord_rich_presence::{
activity::{Activity, Assets, Timestamps},
activity::{Activity, Assets, Timestamps}, // AstralRinth
DiscordIpc, DiscordIpcClient,
};
use rand::seq::SliceRandom;
use rand::seq::SliceRandom; // AstralRinth
use tokio::sync::RwLock;
// use crate::state::Profile;
use crate::util::utils;
use crate::util::utils; // AstralRinth
use crate::State;
pub struct DiscordGuard {
@@ -43,8 +42,7 @@ impl DiscordGuard {
let dipc =
DiscordIpcClient::new("1190718475832918136").map_err(|e| {
crate::ErrorKind::OtherError(format!(
"Could not create Discord client {}",
e,
"Could not create Discord client {e}",
))
})?;
@@ -133,8 +131,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}",
))
};
@@ -142,8 +139,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
@@ -174,8 +170,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}",
))
};
@@ -183,8 +178,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

@@ -1,32 +1,34 @@
use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
use crate::data::ModrinthCredentials;
use crate::event::emit::emit_friend;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
use crate::state::tunnel::InternalTunnelSocket;
use crate::state::{ProcessManager, Profile, TunnelSocket};
use crate::util::fetch::{fetch_advanced, fetch_json, FetchSemaphore};
use crate::util::fetch::{FetchSemaphore, fetch_advanced, fetch_json};
use ariadne::ids::UserId;
use ariadne::networking::message::{
ClientToServerMessage, ServerToClientMessage,
};
use ariadne::users::{UserId, UserStatus};
use async_tungstenite::tokio::{connect_async, ConnectStream};
use async_tungstenite::tungstenite::client::IntoClientRequest;
use async_tungstenite::tungstenite::Message;
use ariadne::users::UserStatus;
use async_tungstenite::WebSocketStream;
use async_tungstenite::tokio::{ConnectStream, connect_async};
use async_tungstenite::tungstenite::Message;
use async_tungstenite::tungstenite::client::IntoClientRequest;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use either::Either;
use futures::stream::SplitSink;
use futures::{SinkExt, StreamExt};
use reqwest::header::HeaderValue;
use reqwest::Method;
use reqwest::header::HeaderValue;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::ops::Deref;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::OwnedReadHalf;
use tokio::net::TcpStream;
use tokio::net::tcp::OwnedReadHalf;
use tokio::sync::{Mutex, RwLock};
use uuid::Uuid;
@@ -204,7 +206,10 @@ impl FriendsSocket {
}
}
Err(e) => {
tracing::error!("Error handling message from websocket server: {:?}", e);
tracing::error!(
"Error handling message from websocket server: {:?}",
e
);
}
}
}
@@ -258,7 +263,7 @@ impl FriendsSocket {
last_ping = Utc::now();
let mut write = state.friends_socket.write.write().await;
if let Some(write) = write.as_mut() {
let _ = write.send(Message::Ping(Vec::new())).await;
let _ = write.send(Message::Ping(Bytes::new())).await;
}
}

View File

@@ -1,30 +1,31 @@
use crate::event::emit::{emit_profile, emit_warning};
use crate::State;
use crate::event::ProfilePayloadType;
use crate::state::{DirectoryInfo, ProfileInstallStage, ProjectType};
use futures::{channel::mpsc::channel, SinkExt, StreamExt};
use crate::event::emit::{emit_profile, emit_warning};
use crate::state::{
DirectoryInfo, ProfileInstallStage, ProjectType, attached_world_data,
};
use crate::worlds::WorldType;
use notify::{RecommendedWatcher, RecursiveMode};
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::sync::{RwLock, mpsc::channel};
pub type FileWatcher = RwLock<Debouncer<RecommendedWatcher>>;
pub async fn init_watcher() -> crate::Result<FileWatcher> {
let (mut tx, mut rx) = channel(1);
let (tx, mut rx) = channel(1);
let file_watcher = new_debouncer(
Duration::from_secs_f32(1.0),
move |res: DebounceEventResult| {
futures::executor::block_on(async {
tx.send(res).await.unwrap();
})
tx.blocking_send(res).ok();
},
)?;
tokio::task::spawn(async move {
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
tracing::info!(parent: &span, "Initting watcher");
while let Some(res) = rx.next().await {
while let Some(res) = rx.recv().await {
let _span = span.enter();
match res {
@@ -37,9 +38,7 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
let mut found = false;
for component in e.path.components() {
if found {
profile_path = Some(
component.as_os_str().to_string_lossy(),
);
profile_path = Some(component.as_os_str());
break;
}
@@ -51,26 +50,87 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
}
if let Some(profile_path) = profile_path {
if e.path
let profile_path_str =
profile_path.to_string_lossy().to_string();
let first_file_name = e
.path
.components()
.any(|x| x.as_os_str() == "crash-reports")
.skip_while(|x| x.as_os_str() != profile_path)
.nth(1)
.map(|x| x.as_os_str());
if first_file_name
.filter(|x| *x == "crash-reports")
.is_some()
&& e.path
.extension()
.map(|x| x == "txt")
.unwrap_or(false)
.filter(|x| *x == "txt")
.is_some()
{
crash_task(profile_path.to_string());
crash_task(profile_path_str);
} else if !visited_profiles.contains(&profile_path)
{
let path = profile_path.to_string();
tokio::spawn(async move {
let _ = emit_profile(
&path,
ProfilePayloadType::Synced,
)
.await;
});
visited_profiles.push(profile_path);
let event = if first_file_name
.filter(|x| *x == "servers.dat")
.is_some()
{
Some(ProfilePayloadType::ServersUpdated)
} else if first_file_name
.filter(|x| {
*x == "saves"
&& e.path
.file_name()
.filter(|x| *x == "level.dat")
.is_some()
})
.is_some()
{
tracing::info!(
"World updated: {}",
e.path.display()
);
let world = e
.path
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
if !e.path.is_file() {
let profile_path_str = profile_path_str.clone();
let world = world.clone();
tokio::spawn(async move {
if let Ok(state) = State::get().await {
if let Err(e) = attached_world_data::AttachedWorldData::remove_for_world(
&profile_path_str,
WorldType::Singleplayer,
&world,
&state.pool
).await {
tracing::warn!("Failed to remove AttachedWorldData for '{world}': {e}")
}
}
});
}
Some(ProfilePayloadType::WorldUpdated { world })
} else if first_file_name
.filter(|x| *x == "saves")
.is_none()
{
Some(ProfilePayloadType::Synced)
} else {
None
};
if let Some(event) = event {
tokio::spawn(async move {
let _ = emit_profile(
&profile_path_str,
event,
)
.await;
});
visited_profiles.push(profile_path);
}
}
}
});
@@ -111,27 +171,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(["crash-reports"])
{
let path = profile_path.join(folder);
for sub_path in ProjectType::iterator().map(|x| x.get_folder()).chain([
"crash-reports",
"saves",
"servers.dat",
]) {
let full_path = profile_path.join(sub_path);
if !path.exists() && !path.is_symlink() {
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,11 +5,11 @@ 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 crate::util::fetch::{IoSemaphore, read_json};
use chrono::{DateTime, Utc};
use p256::ecdsa::SigningKey;
use p256::pkcs8::DecodePrivateKey;
@@ -250,9 +250,11 @@ where
.metadata
.game_version
.clone(),
loaders: vec![mod_loader
.as_str()
.to_string()],
loaders: vec![
mod_loader
.as_str()
.to_string(),
],
update_version_id:
update_version.id.clone(),
},
@@ -317,9 +319,11 @@ where
ProfileInstallStage::NotInstalled
}
},
launcher_feature_version: LauncherFeatureVersion::None,
name: profile.metadata.name,
icon_path: profile.metadata.icon,
game_version: profile.metadata.game_version,
protocol_version: None,
loader: profile.metadata.loader.into(),
loader_version: profile
.metadata

View File

@@ -1,18 +1,17 @@
use crate::util::fetch::REQWEST_CLIENT;
use crate::ErrorKind;
use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
use crate::util::fetch::REQWEST_CLIENT;
use base64::Engine;
use byteorder::BigEndian;
use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
use chrono::{DateTime, Duration, TimeZone, Utc};
use dashmap::DashMap;
use futures::TryStreamExt;
use p256::ecdsa::signature::Signer;
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
use rand::rngs::OsRng;
use rand::Rng;
use reqwest::header::HeaderMap;
use rand::rngs::OsRng;
use reqwest::Response;
use reqwest::header::HeaderMap;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -62,12 +61,6 @@ pub enum MinecraftAuthenticationError {
#[source]
source: reqwest::Error,
},
#[error("Error creating signed request buffer {step:?}: {source}")]
ConstructingSignedRequest {
step: MinecraftAuthStep,
#[source]
source: std::io::Error,
},
#[error("Error reading XBOX Session ID header")]
NoSessionId,
#[error("Error reading user hash")]
@@ -1110,56 +1103,25 @@ async fn send_signed_request<T: DeserializeOwned>(
let time: u128 =
{ ((current_date.timestamp() as u128) + 11644473600) * 10000000 };
use byteorder::WriteBytesExt;
let mut buffer = Vec::new();
buffer.write_u32::<BigEndian>(1).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
buffer.write_u8(0).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
buffer
.write_u64::<BigEndian>(time as u64)
.map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest {
source,
step,
}
})?;
buffer.write_u8(0).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
buffer.extend_from_slice(&1_u32.to_be_bytes()[..]);
buffer.push(0_u8);
buffer.extend_from_slice(&(time as u64).to_be_bytes()[..]);
buffer.push(0_u8);
buffer.extend_from_slice("POST".as_bytes());
buffer.write_u8(0).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
buffer.push(0_u8);
buffer.extend_from_slice(url_path.as_bytes());
buffer.write_u8(0).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
buffer.push(0_u8);
buffer.extend_from_slice(&auth);
buffer.write_u8(0).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
buffer.push(0_u8);
buffer.extend_from_slice(&body);
buffer.write_u8(0).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
buffer.push(0_u8);
let ecdsa_sig: Signature = key.key.sign(&buffer);
let mut sig_buffer = Vec::new();
sig_buffer.write_i32::<BigEndian>(1).map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
})?;
sig_buffer
.write_u64::<BigEndian>(time as u64)
.map_err(|source| {
MinecraftAuthenticationError::ConstructingSignedRequest {
source,
step,
}
})?;
sig_buffer.extend_from_slice(&1_i32.to_be_bytes()[..]);
sig_buffer.extend_from_slice(&(time as u64).to_be_bytes()[..]);
sig_buffer.extend_from_slice(&ecdsa_sig.r().to_bytes());
sig_buffer.extend_from_slice(&ecdsa_sig.s().to_bytes());
@@ -1224,6 +1186,6 @@ fn get_date_header(headers: &HeaderMap) -> DateTime<Utc> {
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()
let bytes: Vec<u8> = (0..64).map(|_| rng.r#gen::<u8>()).collect();
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

View File

@@ -45,6 +45,9 @@ pub use self::mr_auth::*;
mod legacy_converter;
pub mod attached_world_data;
pub mod server_join_log;
// Global state
// RwLock on state only has concurrent reads, except for config dir change which takes control of the State
static LAUNCHER_STATE: OnceCell<Arc<State>> = OnceCell::const_new();
@@ -108,7 +111,9 @@ impl State {
/// Get the current launcher state, waiting for initialization
pub async fn get() -> crate::Result<Arc<Self>> {
if !LAUNCHER_STATE.initialized() {
tracing::error!("Attempted to get state before it is initialized - this should never happen!");
tracing::error!(
"Attempted to get state before it is initialized - this should never happen!"
);
while !LAUNCHER_STATE.initialized() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}

View File

@@ -1,6 +1,6 @@
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
use crate::state::{CacheBehaviour, CachedEntry};
use crate::util::fetch::{fetch_advanced, FetchSemaphore};
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
use chrono::{DateTime, Duration, TimeZone, Utc};
use dashmap::DashMap;
use futures::TryStreamExt;

View File

@@ -1,15 +1,23 @@
use crate::event::emit::emit_process;
use crate::event::ProcessPayloadType;
use crate::event::emit::{emit_process, emit_profile};
use crate::event::{ProcessPayloadType, ProfilePayloadType};
use crate::profile;
use crate::util::io::IOError;
use chrono::{DateTime, Utc};
use chrono::{DateTime, TimeZone, Utc};
use dashmap::DashMap;
use quick_xml::Reader;
use quick_xml::events::Event;
use serde::Deserialize;
use serde::Serialize;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use uuid::Uuid;
const LAUNCHER_LOG_PATH: &str = "launcher_log.txt";
pub struct ProcessManager {
processes: DashMap<Uuid, Process>,
}
@@ -32,8 +40,16 @@ impl ProcessManager {
profile_path: &str,
mut mc_command: Command,
post_exit_command: Option<String>,
logs_folder: PathBuf,
xml_logging: bool,
) -> crate::Result<ProcessMetadata> {
let mc_proc = mc_command.spawn().map_err(IOError::from)?;
mc_command.stdout(std::process::Stdio::piped());
mc_command.stderr(std::process::Stdio::piped());
let mut mc_proc = mc_command.spawn().map_err(IOError::from)?;
let stdout = mc_proc.stdout.take();
let stderr = mc_proc.stderr.take();
let process = Process {
metadata: ProcessMetadata {
@@ -46,6 +62,65 @@ impl ProcessManager {
let metadata = process.metadata.clone();
if !logs_folder.exists() {
tokio::fs::create_dir_all(&logs_folder)
.await
.map_err(|e| IOError::with_path(e, &logs_folder))?;
}
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
{
let mut log_file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&log_path)
.map_err(|e| IOError::with_path(e, &log_path))?;
// Initialize with timestamp header
let now = chrono::Local::now();
writeln!(
log_file,
"# Minecraft launcher log started at {}",
now.format("%Y-%m-%d %H:%M:%S")
)
.map_err(|e| IOError::with_path(e, &log_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))?;
}
if let Some(stdout) = stdout {
let log_path_clone = log_path.clone();
let profile_path = metadata.profile_path.clone();
tokio::spawn(async move {
Process::process_output(
&profile_path,
stdout,
log_path_clone,
xml_logging,
)
.await;
});
}
if let Some(stderr) = stderr {
let log_path_clone = log_path.clone();
let profile_path = metadata.profile_path.clone();
tokio::spawn(async move {
Process::process_output(
&profile_path,
stderr,
log_path_clone,
xml_logging,
)
.await;
});
}
tokio::spawn(Process::sequential_process_manager(
profile_path.to_string(),
post_exit_command,
@@ -120,7 +195,384 @@ struct Process {
child: Child,
}
#[derive(Debug, Default)]
struct Log4jEvent {
timestamp: Option<String>,
logger: Option<String>,
level: Option<String>,
thread: Option<String>,
message: Option<String>,
}
impl Process {
async fn process_output<R>(
profile_path: &str,
reader: R,
log_path: impl AsRef<Path>,
xml_logging: bool,
) where
R: tokio::io::AsyncRead + Unpin,
{
let mut buf_reader = BufReader::new(reader);
if xml_logging {
let mut reader = Reader::from_reader(buf_reader);
reader.config_mut().enable_all_checks(false);
let mut buf = Vec::new();
let mut current_event = Log4jEvent::default();
let mut in_event = false;
let mut in_message = false;
let mut in_throwable = false;
let mut current_content = String::new();
loop {
match reader.read_event_into_async(&mut buf).await {
Err(e) => {
tracing::error!(
"Error at position {}: {:?}",
reader.buffer_position(),
e
);
break;
}
// exits the loop when reaching end of file
Ok(Event::Eof) => break,
Ok(Event::Start(e)) => {
match e.name().as_ref() {
b"log4j:Event" => {
// Reset for new event
current_event = Log4jEvent::default();
in_event = true;
// Extract attributes
for attr in e.attributes().flatten() {
let key = String::from_utf8_lossy(
attr.key.into_inner(),
)
.to_string();
let value =
String::from_utf8_lossy(&attr.value)
.to_string();
match key.as_str() {
"logger" => {
current_event.logger = Some(value)
}
"level" => {
current_event.level = Some(value)
}
"thread" => {
current_event.thread = Some(value)
}
"timestamp" => {
current_event.timestamp =
Some(value)
}
_ => {}
}
}
}
b"log4j:Message" => {
in_message = true;
current_content = String::new();
}
b"log4j:Throwable" => {
in_throwable = true;
current_content = String::new();
}
_ => {}
}
}
Ok(Event::End(e)) => {
match e.name().as_ref() {
b"log4j:Message" => {
in_message = false;
current_event.message =
Some(current_content.clone());
}
b"log4j:Throwable" => {
in_throwable = false;
// Process and write the log entry
let thread = current_event
.thread
.as_deref()
.unwrap_or("");
let level = current_event
.level
.as_deref()
.unwrap_or("");
let logger = current_event
.logger
.as_deref()
.unwrap_or("");
if let Some(message) = &current_event.message {
let formatted_time =
Process::format_timestamp(
current_event.timestamp.as_deref(),
);
let formatted_log = format!(
"{} [{}] [{}{}]: {}\n",
formatted_time,
thread,
if !logger.is_empty() {
format!("{logger}/")
} else {
String::new()
},
level,
message.trim()
);
// Write the log message
if let Err(e) = Process::append_to_log_file(
&log_path,
&formatted_log,
) {
tracing::error!(
"Failed to write to log file: {}",
e
);
}
// Write the throwable if present
if !current_content.is_empty() {
if let Err(e) =
Process::append_to_log_file(
&log_path,
&current_content,
)
{
tracing::error!(
"Failed to write throwable to log file: {}",
e
);
}
}
}
}
b"log4j:Event" => {
in_event = false;
// If no throwable was present, write the log entry at the end of the event
if current_event.message.is_some()
&& !in_throwable
{
let thread = current_event
.thread
.as_deref()
.unwrap_or("");
let level = current_event
.level
.as_deref()
.unwrap_or("");
let logger = current_event
.logger
.as_deref()
.unwrap_or("");
let message = current_event
.message
.as_deref()
.unwrap_or("")
.trim();
let formatted_time =
Process::format_timestamp(
current_event.timestamp.as_deref(),
);
let formatted_log = format!(
"{} [{}] [{}{}]: {}\n",
formatted_time,
thread,
if !logger.is_empty() {
format!("{logger}/")
} else {
String::new()
},
level,
message
);
// Write the log message
if let Err(e) = Process::append_to_log_file(
&log_path,
&formatted_log,
) {
tracing::error!(
"Failed to write to log file: {}",
e
);
}
if let Some(timestamp) =
current_event.timestamp.as_deref()
{
if let Err(e) = Self::maybe_handle_server_join_logging(
profile_path,
timestamp,
message
).await {
tracing::error!("Failed to handle server join logging: {e}");
}
}
}
}
_ => {}
}
}
Ok(Event::Text(mut e)) => {
if in_message || in_throwable {
if let Ok(text) = e.unescape() {
current_content.push_str(&text);
}
} else if !in_event
&& !e.inplace_trim_end()
&& !e.inplace_trim_start()
{
if let Ok(text) = e.unescape() {
if let Err(e) = Process::append_to_log_file(
&log_path,
&format!("{text}\n"),
) {
tracing::error!(
"Failed to write to log file: {}",
e
);
}
}
}
}
Ok(Event::CData(e)) => {
if in_message || in_throwable {
if let Ok(text) = e
.escape()
.map_err(|x| x.into())
.and_then(|x| x.unescape())
{
current_content.push_str(&text);
}
}
}
_ => (),
}
buf.clear();
}
} else {
let mut line = String::new();
while let Ok(bytes_read) = buf_reader.read_line(&mut line).await {
if bytes_read == 0 {
break; // End of stream
}
if !line.is_empty() {
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
tracing::warn!("Failed to write to log file: {}", e);
}
}
line.clear();
}
}
}
fn format_timestamp(timestamp: Option<&str>) -> String {
if let Some(timestamp_str) = timestamp {
if let Ok(timestamp_val) = timestamp_str.parse::<i64>() {
let datetime_utc = if timestamp_val > i32::MAX as i64 {
let secs = timestamp_val / 1000;
let nsecs = ((timestamp_val % 1000) * 1_000_000) as u32;
chrono::DateTime::<Utc>::from_timestamp(secs, nsecs)
.unwrap_or_default()
} else {
chrono::DateTime::<Utc>::from_timestamp(timestamp_val, 0)
.unwrap_or_default()
};
let datetime_local = datetime_utc.with_timezone(&chrono::Local);
format!("[{}]", datetime_local.format("%H:%M:%S"))
} else {
"[??:??:??]".to_string()
}
} else {
"[??:??:??]".to_string()
}
}
fn append_to_log_file(
path: impl AsRef<Path>,
line: &str,
) -> std::io::Result<()> {
let mut file =
OpenOptions::new().append(true).create(true).open(path)?;
file.write_all(line.as_bytes())?;
Ok(())
}
async fn maybe_handle_server_join_logging(
profile_path: &str,
timestamp: &str,
message: &str,
) -> crate::Result<()> {
let Some(host_port_string) = message.strip_prefix("Connecting to ")
else {
return Ok(());
};
let Some((host, port_string)) = host_port_string.rsplit_once(", ")
else {
return Ok(());
};
let Some(port) = port_string.parse::<u16>().ok() else {
return Ok(());
};
let timestamp = timestamp
.parse::<i64>()
.map(|x| x / 1000)
.map_err(|x| {
crate::ErrorKind::OtherError(format!(
"Failed to parse timestamp: {x}"
))
})
.and_then(|x| {
Utc.timestamp_opt(x, 0).single().ok_or_else(|| {
crate::ErrorKind::OtherError(
"Failed to convert timestamp to DateTime".to_string(),
)
})
})?;
let state = crate::State::get().await?;
crate::state::server_join_log::JoinLogEntry {
profile_path: profile_path.to_owned(),
host: host.to_string(),
port,
join_time: timestamp,
}
.upsert(&state.pool)
.await?;
{
let profile_path = profile_path.to_owned();
let host = host.to_owned();
tokio::spawn(async move {
let _ = emit_profile(
&profile_path,
ProfilePayloadType::ServerJoined {
host,
port,
timestamp,
},
)
.await;
});
}
Ok(())
}
// Spawns a new child process and inserts it into the hashmap
// Also, as the process ends, it spawns the follow-up process if it exists
// By convention, ExitStatus is last command's exit status, and we exit on the first non-zero exit status
@@ -204,6 +656,21 @@ impl Process {
}
});
let logs_folder = state.directories.profile_logs_dir(&profile_path);
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
if log_path.exists() {
if let Err(e) = Process::append_to_log_file(
&log_path,
&format!("\n# Process exited with status: {mc_exit_status}\n"),
) {
tracing::warn!(
"Failed to write exit status to log file: {}",
e
);
}
}
let _ = state.discord_rpc.clear_to_default(true).await;
let _ = state.friends_socket.update_status(None).await;

View File

@@ -1,28 +1,38 @@
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,
CacheBehaviour, CachedEntry, CachedFileHash, cache_file_hash,
};
use crate::util;
use crate::util::fetch::{write_cached_icon, FetchSemaphore, IoSemaphore};
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
use crate::util::io::{self};
use chrono::{DateTime, TimeZone, Utc};
use chrono::{DateTime, TimeDelta, TimeZone, Utc};
use dashmap::DashMap;
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 std::sync::LazyLock;
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>,
pub game_version: String,
pub protocol_version: Option<i32>,
pub loader: ModLoader,
pub loader_version: Option<String>,
@@ -86,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,
@@ -261,6 +303,8 @@ struct ProfileQueryResult {
override_hook_pre_launch: Option<String>,
override_hook_wrapper: Option<String>,
override_hook_post_exit: Option<String>,
protocol_version: Option<i64>,
launcher_feature_version: String,
}
impl TryFrom<ProfileQueryResult> for Profile {
@@ -270,9 +314,13 @@ 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,
protocol_version: x.protocol_version.map(|x| x as i32),
loader: ModLoader::from_string(&x.mod_loader),
loader_version: x.mod_loader_version,
groups: serde_json::from_value(x.groups).unwrap_or_default(),
@@ -336,8 +384,8 @@ macro_rules! select_profiles_with_predicate {
ProfileQueryResult,
r#"
SELECT
path, install_stage, name, icon_path,
game_version, mod_loader, mod_loader_version,
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,
created, modified, last_played,
@@ -399,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)?;
@@ -435,7 +485,8 @@ impl Profile {
submitted_time_played, recent_time_played,
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
override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,
protocol_version, launcher_feature_version
)
VALUES (
$1, $2, $3, $4,
@@ -446,7 +497,8 @@ impl Profile {
$15, $16,
$17, jsonb($18), jsonb($19),
$20, $21, $22, $23,
$24, $25, $26
$24, $25, $26,
$27, $28
)
ON CONFLICT (path) DO UPDATE SET
install_stage = $2,
@@ -480,7 +532,10 @@ impl Profile {
override_hook_pre_launch = $24,
override_hook_wrapper = $25,
override_hook_post_exit = $26
override_hook_post_exit = $26,
protocol_version = $27,
launcher_feature_version = $28
",
self.path,
install_stage,
@@ -508,6 +563,8 @@ impl Profile {
self.hooks.pre_launch,
self.hooks.wrapper,
self.hooks.post_exit,
self.protocol_version,
launcher_feature_version
)
.execute(exec)
.await?;
@@ -557,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();
@@ -602,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<_>>(),
@@ -643,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<()> {
static LOG_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
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

@@ -0,0 +1,65 @@
use std::collections::HashMap;
use chrono::{DateTime, TimeZone, Utc};
#[derive(Default)]
pub struct JoinLogEntry {
pub profile_path: String,
pub host: String,
pub port: u16,
pub join_time: DateTime<Utc>,
}
impl JoinLogEntry {
pub async fn upsert(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let join_time = self.join_time.timestamp();
sqlx::query!(
"
INSERT INTO join_log (profile_path, host, port, join_time)
VALUES ($1, $2, $3, $4)
ON CONFLICT (profile_path, host, port) DO UPDATE SET
join_time = $4
",
self.profile_path,
self.host,
self.port,
join_time
)
.execute(exec)
.await?;
Ok(())
}
}
pub async fn get_joins(
instance: &str,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<HashMap<(String, u16), DateTime<Utc>>> {
let joins = sqlx::query!(
"
SELECT profile_path, host, port, join_time
FROM join_log
WHERE profile_path = $1
",
instance
)
.fetch_all(exec)
.await?;
Ok(joins
.into_iter()
.map(|x| {
(
(x.host, x.port as u16),
Utc.timestamp_opt(x.join_time, 0)
.single()
.unwrap_or_else(Utc::now),
)
})
.collect())
}

View File

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

View File

@@ -1,5 +1,5 @@
use crate::state::friends::{TunnelSockets, WriteSocket};
use crate::state::FriendsSocket;
use crate::state::friends::{TunnelSockets, WriteSocket};
use ariadne::networking::message::ClientToServerMessage;
use std::net::SocketAddr;
use std::sync::Arc;

View File

@@ -1,14 +1,14 @@
//! Functions for fetching infromation from the Internet
//! Functions for fetching information from the Internet
use super::io::{self, IOError};
use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::event::emit::emit_loading;
use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
use lazy_static::lazy_static;
use reqwest::Method;
use serde::de::DeserializeOwned;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::{self};
use tokio::sync::Semaphore;
use tokio::{fs::File, io::AsyncWriteExt};
@@ -18,22 +18,20 @@ pub struct IoSemaphore(pub Semaphore);
#[derive(Debug)]
pub struct FetchSemaphore(pub Semaphore);
lazy_static! {
pub static ref REQWEST_CLIENT: reqwest::Client = {
let mut headers = reqwest::header::HeaderMap::new();
let header = reqwest::header::HeaderValue::from_str(&format!(
"modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
))
.unwrap();
headers.insert(reqwest::header::USER_AGENT, header);
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
.default_headers(headers)
.build()
.expect("Reqwest Client Building Failed")
};
}
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
let header = reqwest::header::HeaderValue::from_str(&format!(
"modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
))
.unwrap();
headers.insert(reqwest::header::USER_AGENT, header);
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
.default_headers(headers)
.build()
.expect("Reqwest Client Building Failed")
});
const FETCH_ATTEMPTS: usize = 3;
#[tracing::instrument(skip(semaphore))]

View File

@@ -255,3 +255,42 @@ pub async fn remove_file(
path: path.to_string_lossy().to_string(),
})
}
// 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>,
) -> Result<(), IOError> {
let path = path.as_ref();
tokio::fs::remove_dir(path)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: path.to_string_lossy().to_string(),
})
}
// metadata
pub async fn metadata(
path: impl AsRef<std::path::Path>,
) -> Result<std::fs::Metadata, IOError> {
let path = path.as_ref();
tokio::fs::metadata(path)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: path.to_string_lossy().to_string(),
})
}

View File

@@ -10,8 +10,8 @@ use tokio::task::JoinError;
use crate::State;
#[cfg(target_os = "windows")]
use winreg::{
enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY},
RegKey,
enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY},
};
// Entrypoint function (Windows)
@@ -276,11 +276,10 @@ pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
};
let bytes = include_bytes!("../../library/JavaInfo.class");
let tempdir: PathBuf = tempfile::tempdir().ok()?.into_path();
if !tempdir.exists() {
let Ok(tempdir) = tempfile::tempdir() else {
return None;
}
let file_path = tempdir.join("JavaInfo.class");
};
let file_path = tempdir.path().join("JavaInfo.class");
io::write(&file_path, bytes).await.ok()?;
let output = Command::new(&java)

View File

@@ -3,7 +3,8 @@ pub mod fetch;
pub mod io;
pub mod jre;
pub mod platform;
pub mod utils;
pub mod utils; // AstralRinth
pub mod server_ping;
/// Wrap a builder which uses a mut reference into one which outputs an owned value
macro_rules! wrap_ref_builder {

View File

@@ -1,6 +1,5 @@
//! Platform-related code
use daedalus::minecraft::{Os, OsRule};
use regex::Regex;
// OS detection
pub trait OsExt {
@@ -92,12 +91,16 @@ pub fn os_rule(
}
}
if let Some(version) = &rule.version {
if let Ok(regex) = Regex::new(version.as_str()) {
rule_match &=
regex.is_match(&sys_info::os_release().unwrap_or_default());
}
}
// `rule.version` is ignored because it's not usually seen on real recent
// Minecraft version manifests, its alleged regex syntax is undefined and is
// likely to not match `Regex`'s, and the way to get the value to match it
// against is allegedly calling `System.getProperty("os.version")`, which
// on Windows the OpenJDK implements by fetching the kernel32.dll version,
// an approach that no public Rust library implements. Moreover, launchers
// such as PrismLauncher also ignore this field. Code references:
// - https://github.com/openjdk/jdk/blob/948ade8e7003a41683600428c8e3155c7ed798db/src/java.base/windows/native/libjava/java_props_md.c#L556
// - https://github.com/PrismLauncher/PrismLauncher/blob/1c20faccf88999474af70db098a4c10e7a03af33/launcher/minecraft/Rule.h#L77
// - https://github.com/FillZpp/sys-info-rs/blob/60ecf1470a5b7c90242f429934a3bacb6023ec4d/c/windows.c#L23-L38
rule_match
}

View File

@@ -0,0 +1,223 @@
use crate::ErrorKind;
use crate::error::Result;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::time::Duration;
use tokio::net::ToSocketAddrs;
use tokio::select;
use url::Url;
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ServerStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Box<RawValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub players: Option<ServerPlayers>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<ServerVersion>,
#[serde(skip_serializing_if = "Option::is_none")]
pub favicon: Option<Url>,
#[serde(default)]
pub enforces_secure_chat: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub ping: Option<i64>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerPlayers {
pub max: i32,
pub online: i32,
#[serde(default)]
pub sample: Vec<ServerGameProfile>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerGameProfile {
pub id: String,
pub name: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerVersion {
pub name: String,
pub protocol: i32,
}
pub async fn get_server_status(
address: &impl ToSocketAddrs,
original_address: (&str, u16),
protocol_version: Option<i32>,
) -> Result<ServerStatus> {
select! {
res = modern::status(address, original_address, protocol_version) => res,
_ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError(
format!("Ping of {}:{} timed out", original_address.0, original_address.1)
).into())
}
}
mod modern {
use super::ServerStatus;
use crate::ErrorKind;
use chrono::Utc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, ToSocketAddrs};
pub async fn status(
address: &impl ToSocketAddrs,
original_address: (&str, u16),
protocol_version: Option<i32>,
) -> crate::Result<ServerStatus> {
let mut stream = TcpStream::connect(address).await?;
handshake(&mut stream, original_address, protocol_version).await?;
let mut result = status_body(&mut stream).await?;
result.ping = ping(&mut stream).await.ok();
Ok(result)
}
async fn handshake(
stream: &mut TcpStream,
original_address: (&str, u16),
protocol_version: Option<i32>,
) -> crate::Result<()> {
let (host, port) = original_address;
let protocol_version = protocol_version.unwrap_or(-1);
const PACKET_ID: i32 = 0;
const NEXT_STATE: i32 = 1;
let packet_size = varint::get_byte_size(PACKET_ID)
+ varint::get_byte_size(protocol_version)
+ varint::get_byte_size(host.len() as i32)
+ host.len()
+ size_of::<u16>()
+ varint::get_byte_size(NEXT_STATE);
let mut packet_buffer = Vec::with_capacity(
varint::get_byte_size(packet_size as i32) + packet_size,
);
varint::write(&mut packet_buffer, packet_size as i32);
varint::write(&mut packet_buffer, PACKET_ID);
varint::write(&mut packet_buffer, protocol_version);
varint::write(&mut packet_buffer, host.len() as i32);
packet_buffer.extend_from_slice(host.as_bytes());
packet_buffer.extend_from_slice(&port.to_be_bytes());
varint::write(&mut packet_buffer, NEXT_STATE);
stream.write_all(&packet_buffer).await?;
stream.flush().await?;
Ok(())
}
async fn status_body(
stream: &mut TcpStream,
) -> crate::Result<ServerStatus> {
stream.write_all(&[0x01, 0x00]).await?;
stream.flush().await?;
let packet_length = varint::read(stream).await?;
if packet_length < 0 {
return Err(ErrorKind::InputError(
"Invalid status response packet length".to_string(),
)
.into());
}
let mut packet_stream = stream.take(packet_length as u64);
let packet_id = varint::read(&mut packet_stream).await?;
if packet_id != 0x00 {
return Err(ErrorKind::InputError(
"Unexpected status response".to_string(),
)
.into());
}
let response_length = varint::read(&mut packet_stream).await?;
let mut json_response = vec![0_u8; response_length as usize];
packet_stream.read_exact(&mut json_response).await?;
if packet_stream.limit() > 0 {
tokio::io::copy(&mut packet_stream, &mut tokio::io::sink()).await?;
}
Ok(serde_json::from_slice(&json_response)?)
}
async fn ping(stream: &mut TcpStream) -> crate::Result<i64> {
let start_time = Utc::now();
let ping_magic = start_time.timestamp_millis();
stream.write_all(&[0x09, 0x01]).await?;
stream.write_i64(ping_magic).await?;
stream.flush().await?;
let mut response_prefix = [0_u8; 2];
stream.read_exact(&mut response_prefix).await?;
let response_magic = stream.read_i64().await?;
if response_prefix != [0x09, 0x01] || response_magic != ping_magic {
return Err(ErrorKind::InputError(
"Unexpected ping response".to_string(),
)
.into());
}
let response_time = Utc::now();
Ok((response_time - start_time).num_milliseconds())
}
mod varint {
use std::io;
use tokio::io::{AsyncRead, AsyncReadExt};
const MAX_VARINT_SIZE: usize = 5;
const DATA_BITS_MASK: u32 = 0x7f;
const CONT_BIT_MASK_U8: u8 = 0x80;
const CONT_BIT_MASK_U32: u32 = CONT_BIT_MASK_U8 as u32;
const DATA_BITS_PER_BYTE: usize = 7;
pub fn get_byte_size(x: i32) -> usize {
let x = x as u32;
for size in 1..MAX_VARINT_SIZE {
if (x & (u32::MAX << (size * DATA_BITS_PER_BYTE))) == 0 {
return size;
}
}
MAX_VARINT_SIZE
}
pub fn write(out: &mut Vec<u8>, value: i32) {
let mut value = value as u32;
while value >= CONT_BIT_MASK_U32 {
out.push(((value & DATA_BITS_MASK) | CONT_BIT_MASK_U32) as u8);
value >>= DATA_BITS_PER_BYTE;
}
out.push(value as u8);
}
pub async fn read<R: AsyncRead + Unpin>(
reader: &mut R,
) -> io::Result<i32> {
let mut result = 0;
let mut shift = 0;
loop {
let b = reader.read_u8().await?;
result |=
(b as u32 & DATA_BITS_MASK) << (shift * DATA_BITS_PER_BYTE);
shift += 1;
if shift > MAX_VARINT_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"VarInt too big",
));
}
if b & CONT_BIT_MASK_U8 == 0 {
return Ok(result as i32);
}
}
}
}
}

View File

@@ -1,6 +1,9 @@
use serde::{Deserialize, Serialize};
use tokio::io;
/*
AstralRinth Utils
*/
const PACKAGE_JSON_CONTENT: &str =
// include_str!("../../../../apps/app-frontend/package.json");
include_str!("../../../../apps/app/tauri.conf.json");