Merge tag 'v0.14.6' into beta

v0.14.6
This commit is contained in:
2026-06-17 02:14:47 +03:00
2497 changed files with 357074 additions and 111947 deletions
+21 -2
View File
@@ -1,6 +1,6 @@
use crate::state::{
CacheBehaviour, CacheValueType, CachedEntry, Organization, Project,
SearchResults, TeamMember, User, Version,
ProjectV3, SearchResults, SearchResultsV3, TeamMember, User, Version,
};
macro_rules! impl_cache_methods {
@@ -36,11 +36,13 @@ macro_rules! impl_cache_methods {
impl_cache_methods!(
(Project, Project),
(ProjectV3, ProjectV3),
(Version, Version),
(User, User),
(Team, Vec<TeamMember>),
(Organization, Organization),
(SearchResults, SearchResults)
(SearchResults, SearchResults),
(SearchResultsV3, SearchResultsV3)
);
pub async fn purge_cache_types(
@@ -51,3 +53,20 @@ pub async fn purge_cache_types(
Ok(())
}
/// Get versions for a project (without changelogs for fast loading).
/// Uses the cache system with the ProjectVersions cache type.
#[tracing::instrument]
pub async fn get_project_versions(
project_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<Vec<Version>>> {
let state = crate::State::get().await?;
CachedEntry::get_project_versions(
project_id,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
+4
View File
@@ -24,6 +24,10 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
Some(("modpack", id)) => {
CommandPayload::InstallModpack { id: id.to_string() }
}
// /server/{id} - Opens a server project page and triggers play flow
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
_ => {
emit_warning(&format!(
"Invalid command, unrecognized path: {sublink}"
+22 -5
View File
@@ -90,6 +90,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
None,
None,
None,
None,
Some((&loading_bar, 80.0)),
&state.fetch_semaphore,
&state.pool,
@@ -135,7 +136,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
#[cfg(target_os = "macos")]
{
base_path = base_path
.join(format!("zulu-{java_version}.jre"))
.join("Contents")
.join("Home")
.join("bin")
@@ -181,12 +181,29 @@ pub async fn test_jre(
Ok(version == major_version)
}
// Gets maximum memory in KiB.
pub async fn get_max_memory() -> crate::Result<u64> {
Ok(sysinfo::System::new_with_specifics(
fn system_memory_bytes() -> u64 {
sysinfo::System::new_with_specifics(
RefreshKind::nothing()
.with_memory(MemoryRefreshKind::nothing().with_ram()),
)
.total_memory()
/ 1024)
}
/// Recommended default max heap (MiB) for new instances based on system RAM.
pub fn default_memory_max_mb() -> u32 {
const BYTES_PER_GIB: u64 = 1024 * 1024 * 1024;
let system_gib = system_memory_bytes() / BYTES_PER_GIB;
if system_gib < 8 {
1024 * 2
} else if system_gib >= 24 {
1024 * 6
} else {
1024 * 4
}
}
// Gets maximum memory in KiB.
pub async fn get_max_memory() -> crate::Result<u64> {
Ok(system_memory_bytes() / 1024)
}
+179 -32
View File
@@ -1,4 +1,5 @@
use std::io::{Read, SeekFrom};
use std::fmt::Write as _;
use std::io::{BufRead, SeekFrom};
use std::time::SystemTime;
use futures::TryFutureExt;
@@ -28,6 +29,8 @@ pub enum LogType {
CrashReport,
}
const LOG_COMPACTION_THRESHOLD: usize = 20;
#[derive(Serialize, Debug)]
pub struct LatestLogCursor {
pub cursor: u64,
@@ -68,6 +71,142 @@ impl CensoredString {
}
}
#[derive(Clone, Copy, Debug, Default)]
struct LogCompactionStats {
compacted_runs: usize,
compacted_lines: usize,
}
struct CompactedLog {
output: String,
stats: LogCompactionStats,
}
fn split_line_ending(line: &str) -> (&str, &str) {
if let Some(line) = line.strip_suffix("\r\n") {
(line, "\r\n")
} else if let Some(line) = line.strip_suffix('\n') {
(line, "\n")
} else if let Some(line) = line.strip_suffix('\r') {
(line, "\r")
} else {
(line, "")
}
}
fn push_compacted_log_run(
output: &mut String,
stats: &mut LogCompactionStats,
line: &str,
line_ending: &str,
count: usize,
) {
if count >= LOG_COMPACTION_THRESHOLD {
output.push_str(line);
let _ = write!(output, " (x{count} times - compacted by Modrinth App)");
output.push_str(line_ending);
stats.compacted_runs += 1;
stats.compacted_lines += count;
} else {
for _ in 0..count {
output.push_str(line);
output.push_str(line_ending);
}
}
}
fn read_compacted_log<R: BufRead>(
reader: &mut R,
) -> std::io::Result<CompactedLog> {
let mut output = String::new();
let mut stats = LogCompactionStats::default();
let mut buffer = Vec::new();
let mut current_line: Option<String> = None;
let mut current_line_ending = String::new();
let mut current_count = 0usize;
loop {
buffer.clear();
let bytes_read = reader.read_until(b'\n', &mut buffer)?;
if bytes_read == 0 {
break;
}
let line = String::from_utf8_lossy(&buffer);
let (line, line_ending) = split_line_ending(&line);
match current_line.as_deref() {
Some(current) if current == line => {
current_count += 1;
if current_line_ending.is_empty() && !line_ending.is_empty() {
current_line_ending = line_ending.to_string();
}
}
_ => {
if let Some(current) = current_line.take() {
push_compacted_log_run(
&mut output,
&mut stats,
&current,
&current_line_ending,
current_count,
);
}
current_line = Some(line.to_string());
current_line_ending = line_ending.to_string();
current_count = 1;
}
}
}
if let Some(current) = current_line {
push_compacted_log_run(
&mut output,
&mut stats,
&current,
&current_line_ending,
current_count,
);
}
Ok(CompactedLog { output, stats })
}
fn compact_duplicate_lines(input: &str) -> CompactedLog {
let mut reader = std::io::Cursor::new(input.as_bytes());
read_compacted_log(&mut reader)
.expect("compacting an in-memory log should not fail")
}
fn format_count(count: usize) -> String {
let raw = count.to_string();
let mut formatted = String::with_capacity(raw.len() + raw.len() / 3);
for (index, character) in raw.chars().enumerate() {
if index > 0 && (raw.len() - index).is_multiple_of(3) {
formatted.push(',');
}
formatted.push(character);
}
formatted
}
async fn maybe_emit_log_compaction_warning(
file_name: &str,
stats: LogCompactionStats,
) {
if stats.compacted_runs == 0 {
return;
}
let _ = crate::event::emit::emit_warning(&format!(
"Modrinth App has compacted {} repeated log lines in {} before displaying it for performance reasons.",
format_count(stats.compacted_lines),
file_name,
))
.await;
}
impl Logs {
async fn build(
log_type: LogType,
@@ -218,41 +357,25 @@ pub async fn get_output_by_filename(
.map(|x| x.1)
.collect::<Vec<_>>();
// Load .gz file into String
if let Some(ext) = path.extension() {
if ext == "gz" {
let file = std::fs::File::open(&path)
.map_err(|e| IOError::with_path(e, &path))?;
let mut contents = [0; 1024];
let mut result = String::new();
let mut gz =
let gz =
flate2::read::GzDecoder::new(std::io::BufReader::new(file));
while gz
.read(&mut contents)
.map_err(|e| IOError::with_path(e, &path))?
> 0
{
result.push_str(&String::from_utf8_lossy(&contents));
contents = [0; 1024];
}
return Ok(CensoredString::censor(result, &credentials));
} else if ext == "log" || ext == "txt" {
let mut result = String::new();
let mut contents = [0; 1024];
let mut file = std::fs::File::open(&path)
let mut reader = std::io::BufReader::new(gz);
let compacted = read_compacted_log(&mut reader)
.map_err(|e| IOError::with_path(e, &path))?;
// iteratively read the file to a String
while file
.read(&mut contents)
.map_err(|e| IOError::with_path(e, &path))?
> 0
{
result.push_str(&String::from_utf8_lossy(&contents));
contents = [0; 1024];
}
let result = CensoredString::censor(result, &credentials);
return Ok(result);
maybe_emit_log_compaction_warning(file_name, compacted.stats).await;
return Ok(CensoredString::censor(compacted.output, &credentials));
} else if ext == "log" || ext == "txt" {
let file = std::fs::File::open(&path)
.map_err(|e| IOError::with_path(e, &path))?;
let mut reader = std::io::BufReader::new(file);
let compacted = read_compacted_log(&mut reader)
.map_err(|e| IOError::with_path(e, &path))?;
maybe_emit_log_compaction_warning(file_name, compacted.stats).await;
return Ok(CensoredString::censor(compacted.output, &credentials));
}
}
Err(crate::ErrorKind::OtherError(format!(
@@ -299,6 +422,28 @@ pub async fn delete_logs_by_filename(
Ok(())
}
#[tracing::instrument]
pub async fn get_live_log_buffer(
profile_path: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let lines = crate::state::get_log_buffer(profile_path);
let joined = lines.join("\n");
let compacted = compact_duplicate_lines(&joined);
let credentials = Credentials::get_all(&state.pool)
.await?
.into_iter()
.map(|x| x.1)
.collect::<Vec<_>>();
maybe_emit_log_compaction_warning("live log", compacted.stats).await;
Ok(CensoredString::censor(compacted.output, &credentials))
}
pub fn clear_live_log_buffer(profile_path: &str) {
crate::state::remove_log_buffer(profile_path);
}
#[tracing::instrument]
pub async fn get_latest_log_cursor(
profile_path: &str,
@@ -349,7 +494,8 @@ pub async fn get_generic_live_log_cursor(
.read_to_end(&mut buffer)
.map_err(|e| IOError::with_path(e, &path))
.await?; // Read to end of file
let output = String::from_utf8_lossy(&buffer).to_string(); // Convert to String
let output = String::from_utf8_lossy(&buffer); // Convert to String
let compacted = compact_duplicate_lines(&output);
let cursor = cursor + bytes_read as u64; // Update cursor
let credentials = Credentials::get_all(&state.pool)
@@ -357,7 +503,8 @@ pub async fn get_generic_live_log_cursor(
.into_iter()
.map(|x| x.1)
.collect::<Vec<_>>();
let output = CensoredString::censor(output, &credentials);
maybe_emit_log_compaction_warning(log_file_name, compacted.stats).await;
let output = CensoredString::censor(compacted.output, &credentials);
Ok(LatestLogCursor {
cursor,
new_file,
+2 -2
View File
@@ -4,11 +4,11 @@ use reqwest::StatusCode;
use crate::State;
use crate::state::{Credentials, MinecraftLoginFlow};
use crate::util::fetch::REQWEST_CLIENT;
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
#[tracing::instrument]
pub async fn check_reachable() -> crate::Result<()> {
let resp = REQWEST_CLIENT
let resp = INSECURE_REQWEST_CLIENT
.get("https://sessionserver.mojang.com/session/minecraft/hasJoined")
.send()
.await?;
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@ use tokio_util::compat::FuturesAsyncReadCompatExt;
use url::Url;
use crate::{
ErrorKind, minecraft_skins::UrlOrBlob, util::fetch::REQWEST_CLIENT,
ErrorKind, minecraft_skins::UrlOrBlob, util::fetch::INSECURE_REQWEST_CLIENT,
};
pub async fn url_to_data_stream(
@@ -25,9 +25,10 @@ pub async fn url_to_data_stream(
Ok(Either::Left(stream::once(async { Ok(data) })))
} else {
let response = REQWEST_CLIENT
let response = INSECURE_REQWEST_CLIENT
.get(url.as_str())
.header("Accept", "image/png")
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.and_then(|response| response.error_for_status())?;
+7 -4
View File
@@ -19,10 +19,12 @@ pub mod worlds;
pub mod data {
pub use crate::state::{
CacheBehaviour, CacheValueType, Credentials, Dependency, DirectoryInfo,
Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader,
ModrinthCredentials, Organization, ProcessMetadata, ProfileFile,
Project, ProjectType, SearchResult, SearchResults, Settings,
CacheBehaviour, CacheValueType, ContentItem, ContentItemOwner,
ContentItemProject, ContentItemVersion, Credentials, Dependency,
DirectoryInfo, Hooks, JavaVersion, LinkedData, LinkedModpackInfo,
MemorySettings, ModLoader, ModrinthCredentials, Organization,
OwnerType, ProcessMetadata, ProfileFile, Project, ProjectType,
ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
TeamMember, Theme, User, UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
@@ -36,6 +38,7 @@ pub mod prelude {
jre, metadata, minecraft_auth, mr_auth, pack, process,
profile::{self, Profile, create},
settings,
state::ReleaseChannel,
util::{
io::{IOError, canonicalize},
network::{is_network_metered, tcp_listen_any_loopback},
@@ -78,9 +78,14 @@ pub async fn import_curseforge(
thumbnail_url: Some(thumbnail_url),
}) = minecraft_instance.installed_modpack.clone()
{
let icon_bytes =
fetch(&thumbnail_url, None, &state.fetch_semaphore, &state.pool)
.await?;
let icon_bytes = fetch(
&thumbnail_url,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let filename = thumbnail_url.rsplit('/').next_back();
if let Some(filename) = filename {
icon = Some(
@@ -1,604 +0,0 @@
use std::io::Cursor;
use async_zip::base::read::seek::ZipFileReader;
use serde::{Deserialize, Serialize};
use crate::{
State,
event::{LoadingBarType, ProfilePayloadType},
prelude::ModLoader,
state::{LinkedData, ProfileInstallStage},
util::fetch::fetch,
};
use super::copy_dotminecraft;
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeManifest {
pub minecraft: CurseForgeMinecraft,
pub manifest_type: String,
pub manifest_version: i32,
pub name: String,
pub version: String,
pub author: String,
pub files: Vec<CurseForgeFile>,
pub overrides: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeMinecraft {
pub version: String,
pub mod_loaders: Vec<CurseForgeModLoader>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeModLoader {
pub id: String,
pub primary: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CurseForgeFile {
#[serde(rename = "projectID")]
pub project_id: u32,
#[serde(rename = "fileID")]
pub file_id: u32,
pub required: bool,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CurseForgeProfileMetadata {
pub name: String,
pub download_url: String,
}
/// Fetch CurseForge profile metadata from profile code
pub async fn fetch_curseforge_profile_metadata(
profile_code: &str,
) -> crate::Result<CurseForgeProfileMetadata> {
let state = State::get().await?;
// Make initial request to get redirect URL
let url = format!(
"https://api.curseforge.com/v1/shared-profile/{}",
profile_code
);
// Try to fetch the profile - the CurseForge API should redirect to the ZIP file
let response = fetch(&url, None, &state.fetch_semaphore, &state.pool).await;
let download_url = match response {
Ok(_bytes) => {
// If we get bytes back, use the original URL
url
}
Err(e) => {
// If we get an error, it might contain redirect information
let error_msg = format!("{:?}", e);
if let Some(redirect_start) =
error_msg.find("https://shared-profile-media.forgecdn.net/")
{
let redirect_end = error_msg[redirect_start..]
.find(' ')
.unwrap_or(error_msg.len() - redirect_start);
error_msg[redirect_start..redirect_start + redirect_end]
.to_string()
} else {
return Err(crate::ErrorKind::InputError(format!(
"Failed to fetch CurseForge profile metadata: {}",
e
))
.into());
}
}
};
// Now fetch the ZIP file and extract the name from manifest.json
let zip_bytes =
fetch(&download_url, None, &state.fetch_semaphore, &state.pool).await?;
// Create a cursor for the ZIP data
let cursor = std::io::Cursor::new(zip_bytes);
let mut zip_reader =
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read profile ZIP: {}",
e
))
})?;
// Find and extract manifest.json
let manifest_index = zip_reader
.file()
.entries()
.iter()
.position(|f| {
f.filename().as_str().unwrap_or_default() == "manifest.json"
})
.ok_or_else(|| {
crate::ErrorKind::InputError(
"No manifest.json found in profile".to_string(),
)
})?;
let mut manifest_content = String::new();
let mut reader = zip_reader
.reader_with_entry(manifest_index)
.await
.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read manifest.json: {}",
e
))
})?;
reader.read_to_string_checked(&mut manifest_content).await?;
// Parse the manifest to get the actual name
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
let profile_name = if manifest.name.is_empty() {
format!("CurseForge Profile {}", profile_code)
} else {
manifest.name.clone()
};
Ok(CurseForgeProfileMetadata {
name: profile_name,
download_url,
})
}
/// Import a CurseForge profile from profile code
pub async fn import_curseforge_profile(
profile_code: &str,
profile_path: &str,
) -> crate::Result<()> {
let state = State::get().await?;
// Initialize loading bar
let loading_bar = crate::event::emit::init_loading(
LoadingBarType::CurseForgeProfileDownload {
profile_name: profile_path.to_string(),
},
100.0,
"Importing CurseForge profile...",
)
.await?;
// First, fetch the profile metadata to get the download URL
crate::event::emit::emit_loading(
&loading_bar,
10.0,
Some("Fetching profile metadata..."),
)?;
let metadata = fetch_curseforge_profile_metadata(profile_code).await?;
// Download the profile ZIP file
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Downloading profile ZIP..."),
)?;
let zip_bytes = fetch(
&metadata.download_url,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
// Create a cursor for the ZIP data
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Extracting ZIP contents..."),
)?;
let cursor = Cursor::new(zip_bytes);
let mut zip_reader =
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read profile ZIP: {}",
e
))
})?;
// Find and extract manifest.json
let manifest_index = zip_reader
.file()
.entries()
.iter()
.position(|f| {
f.filename().as_str().unwrap_or_default() == "manifest.json"
})
.ok_or_else(|| {
crate::ErrorKind::InputError(
"No manifest.json found in profile".to_string(),
)
})?;
let mut manifest_content = String::new();
let mut reader = zip_reader
.reader_with_entry(manifest_index)
.await
.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read manifest.json: {}",
e
))
})?;
reader.read_to_string_checked(&mut manifest_content).await?;
// Parse the manifest
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Parsing profile manifest..."),
)?;
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
// Determine modloader and version
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Configuring profile..."),
)?;
let (mod_loader, loader_version) = if let Some(primary_loader) =
manifest.minecraft.mod_loaders.iter().find(|l| l.primary)
{
parse_modloader(&primary_loader.id)
} else if let Some(first_loader) = manifest.minecraft.mod_loaders.first() {
parse_modloader(&first_loader.id)
} else {
(ModLoader::Vanilla, None)
};
let game_version = manifest.minecraft.version.clone();
// Get appropriate loader version if needed
let final_loader_version = if mod_loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
// Set profile data
crate::api::profile::edit(profile_path, |prof| {
prof.name = if manifest.name.is_empty() {
format!("CurseForge Profile {}", profile_code)
} else {
manifest.name.clone()
};
prof.install_stage = ProfileInstallStage::PackInstalling;
prof.game_version = game_version.clone();
prof.loader_version = final_loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
// Set linked data for modpack management
prof.linked_data = Some(LinkedData {
project_id: String::new(),
version_id: String::new(),
locked: false,
});
async { Ok(()) }
})
.await?;
// Create a temporary directory to extract overrides
let temp_dir = state
.directories
.caches_dir()
.join(format!("curseforge_profile_{}", profile_code));
tokio::fs::create_dir_all(&temp_dir).await?;
// Extract overrides directory if it exists
crate::event::emit::emit_loading(
&loading_bar,
10.0,
Some("Extracting profile files..."),
)?;
let overrides_dir = temp_dir.join(&manifest.overrides);
tokio::fs::create_dir_all(&overrides_dir).await?;
// Extract all files that are in the overrides directory
// First collect the entries we need to extract to avoid borrowing conflicts
let entries_to_extract: Vec<(usize, String)> = {
let zip_file = zip_reader.file();
zip_file
.entries()
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let file_path = entry.filename().as_str().unwrap_or_default();
if file_path.starts_with(&format!("{}/", manifest.overrides)) {
Some((index, file_path.to_string()))
} else {
None
}
})
.collect()
};
// Now extract each file
for (index, file_path) in entries_to_extract {
let relative_path = file_path
.strip_prefix(&format!("{}/", manifest.overrides))
.unwrap();
let output_path = overrides_dir.join(relative_path);
// Create parent directories
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
// Extract file
let mut reader =
zip_reader.reader_with_entry(index).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read file {}: {}",
file_path, e
))
})?;
let mut file_content = Vec::new();
reader.read_to_end_checked(&mut file_content).await?;
tokio::fs::write(&output_path, file_content).await?;
}
// Copy overrides to profile
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Copying profile files..."),
)?;
let _loading_bar = copy_dotminecraft(
profile_path,
overrides_dir,
&state.io_semaphore,
None,
)
.await?;
// Download and install mods from CurseForge
crate::event::emit::emit_loading(
&loading_bar,
10.0,
Some("Downloading mods..."),
)?;
install_curseforge_mods(
&manifest.files,
profile_path,
&state,
&loading_bar,
)
.await?;
// Clean up temporary directory
tokio::fs::remove_dir_all(&temp_dir).await.ok();
// Install Minecraft if needed
crate::event::emit::emit_loading(
&loading_bar,
20.0,
Some("Installing Minecraft..."),
)?;
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
crate::launcher::install_minecraft(
&profile_val,
Some(_loading_bar),
false,
)
.await?;
}
// Mark the profile as fully installed
crate::event::emit::emit_loading(
&loading_bar,
20.0,
Some("Finalizing profile..."),
)?;
crate::api::profile::edit(profile_path, |prof| {
prof.install_stage = ProfileInstallStage::Installed;
async { Ok(()) }
})
.await?;
// Emit profile sync event to trigger file system watcher refresh
crate::event::emit::emit_profile(profile_path, ProfilePayloadType::Synced)
.await?;
// Complete the loading bar
crate::event::emit::emit_loading(
&loading_bar,
5.0,
Some("Import completed!"),
)?;
Ok(())
}
/// Parse CurseForge modloader ID into ModLoader and version
fn parse_modloader(id: &str) -> (ModLoader, Option<String>) {
if id.starts_with("forge-") {
let version = id.strip_prefix("forge-").unwrap_or("").to_string();
(ModLoader::Forge, Some(version))
} else if id.starts_with("fabric-") {
let version = id.strip_prefix("fabric-").unwrap_or("").to_string();
(ModLoader::Fabric, Some(version))
} else if id.starts_with("quilt-") {
let version = id.strip_prefix("quilt-").unwrap_or("").to_string();
(ModLoader::Quilt, Some(version))
} else if id.starts_with("neoforge-") {
let version = id.strip_prefix("neoforge-").unwrap_or("").to_string();
(ModLoader::NeoForge, Some(version))
} else {
(ModLoader::Vanilla, None)
}
}
/// Install mods from CurseForge files list
async fn install_curseforge_mods(
files: &[CurseForgeFile],
profile_path: &str,
state: &State,
loading_bar: &crate::event::LoadingBarId,
) -> crate::Result<()> {
if files.is_empty() {
return Ok(());
}
let num_files = files.len();
tracing::info!("Installing {} CurseForge mods", num_files);
// Download mods sequentially to track progress properly
for (index, file) in files.iter().enumerate() {
// Update progress message with current mod
let progress_message =
format!("Downloading mod {} of {}", index + 1, num_files);
crate::event::emit::emit_loading(
loading_bar,
0.0, // Don't increment here, just update message
Some(&progress_message),
)?;
download_curseforge_mod(file, profile_path, state).await?;
// Emit progress for each downloaded mod (20% total for mods, divided by number of mods)
let mod_progress = 20.0 / num_files as f64;
crate::event::emit::emit_loading(
loading_bar,
mod_progress,
Some(&format!("Downloaded mod {} of {}", index + 1, num_files)),
)?;
}
Ok(())
}
/// Download a single mod from CurseForge
async fn download_curseforge_mod(
file: &CurseForgeFile,
profile_path: &str,
_state: &State,
) -> crate::Result<()> {
// Log the download attempt
tracing::info!(
"Downloading CurseForge mod: project_id={}, file_id={}",
file.project_id,
file.file_id
);
// Get profile path and create mods directory first
let profile_full_path =
crate::api::profile::get_full_path(profile_path).await?;
let mods_dir = profile_full_path.join("mods");
tokio::fs::create_dir_all(&mods_dir).await?;
// First, get the file metadata to get the correct filename
let metadata_url = format!(
"https://www.curseforge.com/api/v1/mods/{}/files/{}",
file.project_id, file.file_id
);
tracing::info!("Fetching metadata from: {}", metadata_url);
let client = reqwest::Client::new();
let metadata_response =
client.get(&metadata_url).send().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to fetch metadata for mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
if !metadata_response.status().is_success() {
return Err(crate::ErrorKind::InputError(format!(
"HTTP error fetching metadata for mod {}/{}: {}",
file.project_id,
file.file_id,
metadata_response.status()
))
.into());
}
// Parse the metadata JSON to get the filename
let metadata_json: serde_json::Value =
metadata_response.json().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to parse metadata JSON for mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
let original_filename = metadata_json
.get("data")
.and_then(|data| data.get("fileName"))
.and_then(|name| name.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
// Fallback to the old format if API response is unexpected
format!("mod_{}_{}.jar", file.project_id, file.file_id)
});
tracing::info!("Original filename: {}", original_filename);
// Now download the mod using the direct download URL
let download_url = format!(
"https://www.curseforge.com/api/v1/mods/{}/files/{}/download",
file.project_id, file.file_id
);
tracing::info!("Downloading from: {}", download_url);
let response = client.get(&download_url).send().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to download mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
if !response.status().is_success() {
return Err(crate::ErrorKind::InputError(format!(
"HTTP error downloading mod {}/{}: {}",
file.project_id,
file.file_id,
response.status()
))
.into());
}
// Write the file with its original name
let final_path = mods_dir.join(&original_filename);
let bytes = response.bytes().await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to read response bytes for mod {}/{}: {}",
file.project_id, file.file_id, e
))
})?;
tokio::fs::write(&final_path, &bytes).await.map_err(|e| {
crate::ErrorKind::InputError(format!(
"Failed to write mod file {:?}: {}",
final_path, e
))
})?;
tracing::info!(
"Successfully downloaded mod: {} ({} bytes)",
original_filename,
bytes.len()
);
Ok(())
}
+107 -11
View File
@@ -19,7 +19,6 @@ use crate::{
pub mod atlauncher;
pub mod curseforge;
pub mod curseforge_profile;
pub mod gdlauncher;
pub mod mmc;
@@ -69,10 +68,23 @@ pub async fn get_importable_instances(
.await
.unwrap_or_else(|| "instances".to_string()),
ImportLauncherType::Unknown => {
return Err(crate::ErrorKind::InputError(
"Launcher type Unknown".to_string(),
)
.into());
let types = [
ImportLauncherType::MultiMC,
ImportLauncherType::PrismLauncher,
ImportLauncherType::ATLauncher,
ImportLauncherType::GDLauncher,
ImportLauncherType::Curseforge,
];
for lt in types {
if let Ok(instances) =
Box::pin(get_importable_instances(lt, base_path.clone()))
.await
&& !instances.is_empty()
{
return Ok(instances);
}
}
return Ok(Vec::new());
}
};
@@ -145,10 +157,39 @@ pub async fn import_instance(
.await
}
ImportLauncherType::Unknown => {
return Err(crate::ErrorKind::InputError(
"Launcher type Unknown".to_string(),
)
.into());
let types = [
ImportLauncherType::MultiMC,
ImportLauncherType::PrismLauncher,
ImportLauncherType::ATLauncher,
ImportLauncherType::GDLauncher,
ImportLauncherType::Curseforge,
];
let mut matched = false;
for lt in types {
if let Ok(instances) =
Box::pin(get_importable_instances(lt, base_path.clone()))
.await
&& instances.contains(&instance_folder)
{
matched = true;
Box::pin(import_instance(
profile_path,
lt,
base_path,
instance_folder,
))
.await?;
break;
}
}
if !matched {
return Err(crate::ErrorKind::InputError(
"Could not determine launcher type for the given path"
.to_string(),
)
.into());
}
return Ok(());
}
};
@@ -172,7 +213,9 @@ pub fn get_default_launcher_path(
r#type: ImportLauncherType,
) -> Option<PathBuf> {
let path = match r#type {
ImportLauncherType::MultiMC => None, // multimc data is *in* app dir
ImportLauncherType::MultiMC => {
return find_multimc_path();
}
ImportLauncherType::PrismLauncher => {
Some(dirs::data_dir()?.join("PrismLauncher"))
}
@@ -183,7 +226,12 @@ pub fn get_default_launcher_path(
Some(dirs::data_dir()?.join("gdlauncher_next"))
}
ImportLauncherType::Curseforge => {
Some(dirs::home_dir()?.join("curseforge").join("minecraft"))
let home = dirs::home_dir()?;
let primary = home.join("curseforge").join("minecraft");
if primary.exists() {
return Some(primary);
}
Some(dirs::document_dir()?.join("curseforge").join("minecraft"))
}
ImportLauncherType::Unknown => None,
};
@@ -191,6 +239,54 @@ pub fn get_default_launcher_path(
if path.exists() { Some(path) } else { None }
}
/// Searches common locations for a MultiMC installation.
/// MultiMC stores data in its own application directory (not a standard data dir)
fn find_multimc_path() -> Option<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
// Linux/macOS: ~/.local/share/multimc is the typical location
if let Some(data_dir) = dirs::data_dir() {
candidates.push(data_dir.join("multimc"));
candidates.push(data_dir.join("MultiMC"));
}
// Windows: check common extraction locations
#[cfg(target_os = "windows")]
{
if let Some(home) = dirs::home_dir() {
candidates.push(home.join("MultiMC"));
candidates.push(home.join("Desktop").join("MultiMC"));
candidates.push(home.join("Downloads").join("MultiMC"));
}
candidates.push(PathBuf::from("C:\\MultiMC"));
if let Some(program_files) =
std::env::var_os("ProgramFiles").map(PathBuf::from)
{
candidates.push(program_files.join("MultiMC"));
}
if let Some(program_files_x86) =
std::env::var_os("ProgramFiles(x86)").map(PathBuf::from)
{
candidates.push(program_files_x86.join("MultiMC"));
}
}
// macOS: MultiMC is a .app bundle with data inside MultiMC.app/Data/
#[cfg(target_os = "macos")]
{
candidates.push(PathBuf::from("/Applications/MultiMC.app/Data"));
if let Some(home) = dirs::home_dir() {
candidates.push(
home.join("Applications").join("MultiMC.app").join("Data"),
);
}
}
candidates
.into_iter()
.find(|p| p.join("multimc.cfg").exists())
}
/// Checks if this PathBuf is a valid instance for the given launcher type
#[tracing::instrument]
+145 -42
View File
@@ -1,11 +1,16 @@
use crate::State;
use crate::api::profile;
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::{
CacheBehaviour, CachedEntry, LinkedData, Profile, ProfileInstallStage,
SideType,
};
use crate::util::fetch::{
DownloadMeta, DownloadReason, fetch, fetch_advanced, sha1_file_async,
write_cached_icon,
};
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method;
use serde::{Deserialize, Serialize};
@@ -105,6 +110,7 @@ pub struct CreatePackProfile {
pub icon: Option<PathBuf>, // the icon for the profile
pub icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
pub linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
pub unknown_file: bool, // true when pack file isn't found on Modrinth via hash lookup
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
@@ -120,18 +126,29 @@ impl Default for CreatePackProfile {
icon: None,
icon_url: None,
linked_data: None,
unknown_file: false,
skip_install_profile: Some(true),
no_watch: Some(false),
}
}
}
#[derive(Clone)]
pub enum CreatePackFile {
Bytes(bytes::Bytes),
// Local packs can be larger than available memory, so keep them file-backed.
Path(PathBuf),
}
#[derive(Clone)]
pub struct CreatePack {
pub file: bytes::Bytes,
pub file: CreatePackFile,
pub description: CreatePackDescription,
}
// The hash lookup only gates the unknown-pack warning, so avoid a long blocking scan for huge local packs.
const MAX_LOCAL_FILE_HASH_LOOKUP_SIZE: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct CreatePackDescription {
pub icon: Option<PathBuf>,
@@ -142,16 +159,16 @@ pub struct CreatePackDescription {
pub profile_path: String,
}
pub fn get_profile_from_pack(
pub async fn get_profile_from_pack(
location: CreatePackLocation,
) -> CreatePackProfile {
) -> crate::Result<CreatePackProfile> {
match location {
CreatePackLocation::FromVersionId {
project_id,
version_id,
title,
icon_url,
} => CreatePackProfile {
} => Ok(CreatePackProfile {
name: title,
icon_url,
linked_data: Some(LinkedData {
@@ -160,7 +177,7 @@ pub fn get_profile_from_pack(
locked: true,
}),
..Default::default()
},
}),
CreatePackLocation::FromFile { path } => {
let file_name = path
.file_stem()
@@ -168,10 +185,38 @@ pub fn get_profile_from_pack(
.to_string_lossy()
.to_string();
CreatePackProfile {
let is_known_file = if tokio::fs::metadata(&path).await?.len()
<= MAX_LOCAL_FILE_HASH_LOOKUP_SIZE
{
let state = State::get().await?;
let (_, hash) = sha1_file_async(&path).await?;
match CachedEntry::get_file_many(
&[&hash],
Some(CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(files) => !files.is_empty(),
Err(err) => {
tracing::warn!(
"Failed to check Modrinth file hash for {}: {}",
path.display(),
err
);
false
}
}
} else {
false
};
Ok(CreatePackProfile {
name: file_name,
unknown_file: !is_known_file,
..Default::default()
}
})
}
}
}
@@ -185,11 +230,11 @@ pub async fn generate_pack_from_version_id(
icon_url: Option<String>,
profile_path: String,
// Existing loading bar. Unlike when existing_loading_bar is used, this one is pre-initialized with PackFileDownload
// For example, you might use this if multiple packs are being downloaded at once and you want to use the same loading bar
initialized_loading_bar: Option<LoadingBarId>,
reason: DownloadReason,
) -> crate::Result<CreatePack> {
let state = State::get().await?;
let has_icon_url = icon_url.is_some();
let loading_bar = if let Some(bar) = initialized_loading_bar {
emit_loading(&bar, 0.0, Some("Downloading pack file"))?;
@@ -198,7 +243,7 @@ pub async fn generate_pack_from_version_id(
init_loading(
LoadingBarType::PackFileDownload {
profile_path: profile_path.clone(),
pack_name: title,
pack_name: title.clone(),
icon: icon_url,
pack_version: version_id.clone(),
},
@@ -211,7 +256,7 @@ pub async fn generate_pack_from_version_id(
emit_loading(&loading_bar, 0.0, Some("Fetching version"))?;
let version = CachedEntry::get_version(
&version_id,
None,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
@@ -223,6 +268,24 @@ pub async fn generate_pack_from_version_id(
})?;
emit_loading(&loading_bar, 10.0, None)?;
// Update profile with correct loader and game version from the API version metadata,
// so the UI shows accurate info while the pack file is still downloading.
if let Some(game_version) = version.game_versions.first() {
let loader = version
.loaders
.first()
.map(|l| ModLoader::from_string(l))
.unwrap_or(ModLoader::Vanilla);
let game_version = game_version.clone();
let profile_path_clone = profile_path.clone();
profile::edit(&profile_path_clone, |prof| {
prof.game_version.clone_from(&game_version);
prof.loader = loader;
async { Ok(()) }
})
.await?;
}
let (url, hash) =
if let Some(file) = version.files.iter().find(|x| x.primary) {
Some((file.url.clone(), file.hashes.get("sha1")))
@@ -238,12 +301,30 @@ pub async fn generate_pack_from_version_id(
)
})?;
let profile =
Profile::get(&profile_path, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(
profile_path.to_string(),
)
.as_error()
})?;
let download_meta = DownloadMeta {
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
dependent_on: Some(version_id.clone()),
};
let file = fetch_advanced(
Method::GET,
&url,
hash.map(|x| &**x),
None,
None,
Some(&download_meta),
Some((&loading_bar, 70.0)),
&state.fetch_semaphore,
&state.pool,
@@ -264,37 +345,58 @@ pub async fn generate_pack_from_version_id(
)
})?;
emit_loading(&loading_bar, 10.0, Some("Retrieving icon"))?;
let icon = if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
let icon_bytes =
fetch(&icon_url, None, &state.fetch_semaphore, &state.pool).await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
// Only fetch the pack icon when icon_url is provided (new profile).
// When installing to an existing profile (e.g. server projects),
// icon_url is None and we preserve the profile's existing icon.
let icon = if has_icon_url {
emit_loading(&loading_bar, 10.0, Some("Retrieving icon"))?;
let fetched = if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
let icon_bytes = fetch(
&icon_url,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
)
} else {
None
}
} else {
None
}
};
emit_loading(&loading_bar, 10.0, None)?;
fetched
} else {
emit_loading(&loading_bar, 20.0, None)?;
None
};
emit_loading(&loading_bar, 10.0, None)?;
// Set the icon immediately so the UI shows it during download.
if let Some(ref icon_path) = icon {
let _ =
profile::edit_icon(&profile_path, Some(icon_path.as_path())).await;
}
Ok(CreatePack {
file,
file: CreatePackFile::Bytes(file),
description: CreatePackDescription {
icon,
override_title: None,
override_title: Some(title),
project_id: Some(project_id),
version_id: Some(version_id),
existing_loading_bar: Some(loading_bar),
@@ -309,9 +411,8 @@ pub async fn generate_pack_from_file(
path: PathBuf,
profile_path: String,
) -> crate::Result<CreatePack> {
let file = io::read(&path).await?;
Ok(CreatePack {
file: bytes::Bytes::from(file),
file: CreatePackFile::Path(path),
description: CreatePackDescription {
icon: None,
override_title: None,
@@ -398,10 +499,12 @@ pub async fn set_profile_information(
})
}
prof.icon_path = description
.icon
.clone()
.map(|x| x.to_string_lossy().to_string());
// Only update the icon if the pack provides one.
// When installing to an existing profile, icon is None
// and we preserve the profile's existing icon.
if let Some(ref icon) = description.icon {
prof.icon_path = Some(icon.to_string_lossy().to_string());
}
prof.game_version.clone_from(game_version);
prof.loader_version = loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
+307 -52
View File
@@ -6,21 +6,215 @@ use crate::pack::install_from::{
EnvType, PackFile, PackFileHash, set_profile_information,
};
use crate::state::{
CacheBehaviour, CachedEntry, ProfileInstallStage, SideType, cache_file_hash,
CacheBehaviour, CachedEntry, Profile, ProfileInstallStage, SideType,
cache_file_hash,
};
use crate::util::fetch::{fetch_mirrors, write};
use crate::util::fetch::{DownloadMeta, DownloadReason, fetch_mirrors, write};
use crate::util::io;
use crate::{State, profile};
use async_zip::base::read::seek::ZipFileReader;
use async_zip::base::read::seek::ZipFileReader as SeekZipFileReader;
use async_zip::base::read::{WithEntry, ZipEntryReader};
use async_zip::tokio::read::fs::ZipFileReader as FsZipFileReader;
use futures::StreamExt;
use path_util::SafeRelativeUtf8UnixPathBuf;
use super::install_from::{
CreatePack, CreatePackLocation, PackFormat, generate_pack_from_file,
generate_pack_from_version_id,
CreatePack, CreatePackFile, CreatePackLocation, PackFormat,
generate_pack_from_file, generate_pack_from_version_id,
};
use crate::data::ProjectType;
use std::io::{Cursor, ErrorKind};
use std::path::Path;
use tokio::io::AsyncWriteExt;
enum MrpackZipReader {
Memory(async_zip::tokio::read::seek::ZipFileReader<Cursor<bytes::Bytes>>),
// Local imports stay on disk so large .mrpacks do not have to fit in memory.
File(FsZipFileReader),
}
impl MrpackZipReader {
async fn new(file: &CreatePackFile) -> crate::Result<Self> {
match file {
CreatePackFile::Bytes(file) => Ok(Self::Memory(
SeekZipFileReader::with_tokio(Cursor::new(file.clone()))
.await
.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?,
)),
CreatePackFile::Path(path) => Ok(Self::File(
FsZipFileReader::new(path).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?,
)),
}
}
fn file(&self) -> &async_zip::ZipFile {
match self {
Self::Memory(reader) => reader.file(),
Self::File(reader) => reader.file(),
}
}
async fn read_entry_to_string(
&mut self,
index: usize,
) -> crate::Result<String> {
let mut value = String::new();
match self {
Self::Memory(reader) => {
let mut reader = reader.reader_with_entry(index).await?;
reader.read_to_string_checked(&mut value).await?;
}
Self::File(reader) => {
let mut reader = reader.reader_with_entry(index).await?;
reader.read_to_string_checked(&mut value).await?;
}
}
Ok(value)
}
async fn hash_entry(
&mut self,
index: usize,
) -> crate::Result<(u64, String)> {
match self {
Self::Memory(reader) => {
hash_zip_entry(reader.reader_with_entry(index).await?).await
}
Self::File(reader) => {
hash_zip_entry(reader.reader_with_entry(index).await?).await
}
}
}
async fn extract_entry(
&mut self,
index: usize,
path: &Path,
semaphore: &crate::util::fetch::IoSemaphore,
) -> crate::Result<(u64, String)> {
match self {
Self::Memory(reader) => {
extract_zip_entry(
reader.reader_with_entry(index).await?,
path,
semaphore,
)
.await
}
Self::File(reader) => {
extract_zip_entry(
reader.reader_with_entry(index).await?,
path,
semaphore,
)
.await
}
}
}
}
async fn hash_zip_entry<R>(
mut reader: ZipEntryReader<'_, R, WithEntry<'_>>,
) -> crate::Result<(u64, String)>
where
R: futures_lite::io::AsyncBufRead + Unpin,
{
let expected_crc32 = reader.entry().crc32();
let mut hasher = sha1_smol::Sha1::new();
let mut size = 0;
let mut buffer = vec![0; 262144];
loop {
let bytes_read =
futures_lite::io::AsyncReadExt::read(&mut reader, &mut buffer)
.await?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
size += bytes_read as u64;
}
if reader.compute_hash() != expected_crc32 {
return Err(async_zip::error::ZipError::CRC32CheckError.into());
}
Ok((size, hasher.digest().to_string()))
}
async fn extract_zip_entry<R>(
mut reader: ZipEntryReader<'_, R, WithEntry<'_>>,
path: &Path,
semaphore: &crate::util::fetch::IoSemaphore,
) -> crate::Result<(u64, String)>
where
R: futures_lite::io::AsyncBufRead + Unpin,
{
let _permit = semaphore.0.acquire().await?;
if let Some(parent) = path.parent() {
io::create_dir_all(parent).await?;
}
let parent = path.parent().ok_or_else(|| {
io::IOError::from(std::io::Error::other(
"could not get parent directory for temporary file",
))
})?;
let temp_path = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| io::IOError::with_path(e, parent))?
.into_temp_path();
// Only replace the profile file after the ZIP entry has passed its CRC check.
let expected_crc32 = reader.entry().crc32();
let mut file = tokio::fs::File::create(&temp_path)
.await
.map_err(|e| io::IOError::with_path(e, &temp_path))?;
let mut hasher = sha1_smol::Sha1::new();
let mut size = 0;
let mut buffer = vec![0; 262144];
loop {
let bytes_read =
futures_lite::io::AsyncReadExt::read(&mut reader, &mut buffer)
.await?;
if bytes_read == 0 {
break;
}
file.write_all(&buffer[..bytes_read])
.await
.map_err(|e| io::IOError::with_path(e, &temp_path))?;
hasher.update(&buffer[..bytes_read]);
size += bytes_read as u64;
}
file.flush()
.await
.map_err(|e| io::IOError::with_path(e, &temp_path))?;
drop(file);
if reader.compute_hash() != expected_crc32 {
return Err(async_zip::error::ZipError::CRC32CheckError.into());
}
temp_path.persist(path).map_err(|e| {
let tempfile::PathPersistError { error, .. } = e;
io::IOError::with_path(error, path)
})?;
Ok((size, hasher.digest().to_string()))
}
/// Install a pack
/// Wrapper around install_pack_files that generates a pack creation description, and
@@ -45,6 +239,7 @@ pub async fn install_zipped_mrpack(
icon_url,
profile_path.clone(),
None,
DownloadReason::Modpack,
)
.await?
}
@@ -54,7 +249,12 @@ pub async fn install_zipped_mrpack(
};
// Install pack files, and if it fails, fail safely by removing the profile
let result = install_zipped_mrpack_files(create_pack, false).await;
let result = install_zipped_mrpack_files(
create_pack,
false,
DownloadReason::Modpack,
)
.await;
match result {
Ok(profile) => Ok(profile),
@@ -71,6 +271,7 @@ pub async fn install_zipped_mrpack(
pub async fn install_zipped_mrpack_files(
create_pack: CreatePack,
ignore_lock: bool,
reason: DownloadReason,
) -> crate::Result<String> {
let state = &State::get().await?;
@@ -83,15 +284,7 @@ pub async fn install_zipped_mrpack_files(
let profile_path = create_pack.description.profile_path;
let icon_exists = icon.is_some();
let reader: Cursor<&bytes::Bytes> = Cursor::new(&file);
// Create zip reader around file
let mut zip_reader =
ZipFileReader::with_tokio(reader).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?;
let mut zip_reader = MrpackZipReader::new(&file).await?;
// Extract index of modrinth.index.json
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
@@ -103,8 +296,7 @@ pub async fn install_zipped_mrpack_files(
};
let mut manifest = String::new();
let mut reader = zip_reader.reader_with_entry(manifest_idx).await?;
reader.read_to_string_checked(&mut manifest).await?;
manifest.push_str(&zip_reader.read_entry_to_string(manifest_idx).await?);
let pack: PackFormat = serde_json::from_str(&manifest)?;
@@ -115,6 +307,69 @@ pub async fn install_zipped_mrpack_files(
.into());
}
// Cache the modpack file hashes for later filtering of user-added content
// Includes both manifest file hashes and computed hashes for override files
if let Some(ref version_id) = version_id {
let mut file_hashes: Vec<String> = pack
.files
.iter()
.filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
.collect();
// Also hash files from overrides folders (these aren't in modrinth.index.json)
let override_entries: Vec<usize> = zip_reader
.file()
.entries()
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let filename = entry.filename().as_str().ok()?;
let is_override = (filename.starts_with("overrides/")
|| filename.starts_with("client-overrides/")
|| filename.starts_with("server-overrides/"))
&& !filename.ends_with('/');
is_override.then_some(index)
})
.collect();
for index in override_entries {
let (_, hash) = zip_reader.hash_entry(index).await?;
file_hashes.push(hash);
}
let project_ids: Vec<String> = pack
.files
.iter()
.filter_map(|f| {
f.downloads.iter().find_map(|url| {
let parts: Vec<&str> = url.split('/').collect();
let data_idx = parts.iter().position(|&p| p == "data")?;
parts.get(data_idx + 1).map(|s| s.to_string())
})
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
tracing::info!(
"Caching {} modpack file hashes and {} project IDs for version {}",
file_hashes.len(),
project_ids.len(),
version_id
);
CachedEntry::cache_modpack_files(
version_id,
file_hashes,
project_ids,
&state.pool,
)
.await?;
} else {
tracing::warn!(
"No version_id available, skipping modpack file hash caching"
);
}
// Sets generated profile attributes to the pack ones (using profile::edit)
set_profile_information(
profile_path.clone(),
@@ -132,18 +387,34 @@ pub async fn install_zipped_mrpack_files(
profile_path: profile_path.clone(),
pack_name: pack.name.clone(),
icon,
pack_id: project_id,
pack_version: version_id,
pack_id: project_id.clone(),
pack_version: version_id.clone(),
},
100.0,
"Downloading modpack",
)
.await?;
let profile =
Profile::get(&profile_path, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(
profile_path.to_string(),
)
.as_error()
})?;
let download_meta = DownloadMeta {
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
dependent_on: version_id.clone(),
};
let num_files = pack.files.len();
loading_try_for_each_concurrent(
futures::stream::iter(pack.files.into_iter())
.map(Ok::<PackFile, crate::Error>),
futures::stream::iter(pack.files).map(Ok::<PackFile, crate::Error>),
None,
Some(&loading_bar),
70.0,
@@ -151,6 +422,7 @@ pub async fn install_zipped_mrpack_files(
None,
|project| {
let profile_path = profile_path.clone();
let download_meta = download_meta.clone();
async move {
//TODO: Future update: prompt user for optional files in a modpack
if let Some(env) = project.env
@@ -168,6 +440,7 @@ pub async fn install_zipped_mrpack_files(
.map(|x| &**x)
.collect::<Vec<&str>>(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
Some(&download_meta),
&state.fetch_semaphore,
&state.pool,
)
@@ -226,17 +499,18 @@ pub async fn install_zipped_mrpack_files(
))
})?;
let mut file_bytes = vec![];
let mut reader = zip_reader.reader_with_entry(index).await?;
reader.read_to_end_checked(&mut file_bytes).await?;
let path = profile::get_full_path(&profile_path)
.await?
.join(relative_override_file_path.as_str());
let (size, hash) = zip_reader
.extract_entry(index, &path, &state.io_semaphore)
.await?;
let file_bytes = bytes::Bytes::from(file_bytes);
cache_file_hash(
file_bytes.clone(),
crate::state::cache_file_hash_metadata(
&profile_path,
relative_override_file_path.as_str(),
None,
size,
hash,
ProjectType::get_from_parent_folder(
relative_override_file_path.as_str(),
),
@@ -244,15 +518,6 @@ pub async fn install_zipped_mrpack_files(
)
.await?;
write(
&profile::get_full_path(&profile_path)
.await?
.join(relative_override_file_path.as_str()),
&file_bytes,
&state.io_semaphore,
)
.await?;
emit_loading(
&loading_bar,
30.0 / override_file_entries_count as f64,
@@ -288,17 +553,10 @@ pub async fn install_zipped_mrpack_files(
pub async fn remove_all_related_files(
profile_path: String,
mrpack_file: bytes::Bytes,
mrpack_file: CreatePackFile,
) -> crate::Result<()> {
let reader: Cursor<&bytes::Bytes> = Cursor::new(&mrpack_file);
// Create zip reader around file
let mut zip_reader =
ZipFileReader::with_tokio(reader).await.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(
"Failed to read input modpack zip".to_string(),
))
})?;
// Updates can remove files from a locally imported or downloaded pack, so share the same reader path.
let mut zip_reader = MrpackZipReader::new(&mrpack_file).await?;
// Extract index of modrinth.index.json
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
@@ -309,10 +567,7 @@ pub async fn remove_all_related_files(
)));
};
let mut manifest = String::new();
let mut reader = zip_reader.reader_with_entry(manifest_idx).await?;
reader.read_to_string_checked(&mut manifest).await?;
let manifest = zip_reader.read_entry_to_string(manifest_idx).await?;
let pack: PackFormat = serde_json::from_str(&manifest)?;
+25 -5
View File
@@ -1,7 +1,9 @@
//! Theseus profile management interface
use crate::launcher::get_loader_version_from_profile;
use crate::settings::Hooks;
use crate::state::{LauncherFeatureVersion, LinkedData, ProfileInstallStage};
use crate::state::{
LauncherFeatureVersion, LinkedData, ProfileInstallStage, ReleaseChannel,
};
use crate::util::io::{self, canonicalize};
use crate::{ErrorKind, pack, profile};
pub use crate::{State, state::Profile};
@@ -83,6 +85,7 @@ pub async fn profile_create(
loader_version: loader.map(|x| x.id),
groups: Vec::new(),
linked_data,
preferred_update_channel: ReleaseChannel::Release,
created: Utc::now(),
modified: Utc::now(),
last_played: None,
@@ -103,14 +106,31 @@ pub async fn profile_create(
let result = async {
if let Some(ref icon) = icon_path {
let bytes =
io::read(state.directories.caches_dir().join(icon)).await?;
let (bytes, file_name) = if icon.starts_with("https://")
|| icon.starts_with("http://")
{
let fetched = crate::util::fetch::fetch(
icon,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let name =
icon.rsplit('/').next().unwrap_or("icon").to_string();
(fetched, name)
} else {
let data =
io::read(state.directories.caches_dir().join(icon)).await?;
(bytes::Bytes::from(data), icon.clone())
};
profile
.set_icon(
&state.directories.caches_dir(),
&state.io_semaphore,
bytes::Bytes::from(bytes),
icon,
bytes,
&file_name,
)
.await?;
}
+213 -25
View File
@@ -8,8 +8,9 @@ use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
use crate::state::{
CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata,
ProfileFile, ProfileInstallStage, ProjectType, SideType,
CacheBehaviour, CachedEntry, ContentItem, Credentials, Dependency,
JavaVersion, LinkedModpackInfo, ProcessMetadata, ProfileFile,
ProfileInstallStage, ProjectType, SideType,
};
use crate::event::{ProfilePayloadType, emit::emit_profile};
@@ -20,8 +21,10 @@ use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde_json::json;
use tracing::{info, warn};
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use crate::data::Settings;
use crate::server_address::ServerAddress;
@@ -91,6 +94,119 @@ pub async fn get_projects(
}
}
#[tracing::instrument]
pub async fn get_installed_project_ids(
path: &str,
) -> crate::Result<Vec<String>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let ids = profile
.get_installed_project_ids(&state.pool, &state.api_semaphore)
.await?;
Ok(ids)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Get content items with rich metadata for a profile
///
/// Returns content items filtered to exclude modpack files (if linked),
/// sorted alphabetically by project name.
#[tracing::instrument]
pub async fn get_content_items(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let items = crate::state::get_content_items(
&profile,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(items)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Get content items that are part of the linked modpack
///
/// Returns the modpack's dependencies as ContentItem list.
/// Returns empty vec if the profile is not linked to a modpack.
#[tracing::instrument]
pub async fn get_linked_modpack_content(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let items = crate::state::get_linked_modpack_content(
&profile,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(items)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Convert a list of dependencies into ContentItems with rich metadata
#[tracing::instrument]
pub async fn get_dependencies_as_content_items(
dependencies: Vec<Dependency>,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<ContentItem>> {
let state = State::get().await?;
let items = crate::state::dependencies_to_content_items(
&dependencies,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(items)
}
/// Get linked modpack info for a profile
///
/// Returns project, version, and owner information for the linked modpack,
/// or None if the profile is not linked to a modpack.
#[tracing::instrument]
pub async fn get_linked_modpack_info(
path: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Option<LinkedModpackInfo>> {
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let info = crate::state::get_linked_modpack_info(
&profile,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await?;
Ok(info)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error())
}
}
/// Get profile's full path in the filesystem
#[tracing::instrument]
pub async fn get_full_path(path: &str) -> crate::Result<PathBuf> {
@@ -178,19 +294,13 @@ pub async fn get_optimal_jre_key(
let state = State::get().await?;
if let Some(profile) = get(path).await? {
let minecraft = crate::api::metadata::get_minecraft_versions().await?;
// Fetch version info from stored profile game_version
let version = minecraft
.versions
.iter()
.find(|it| it.id == profile.game_version)
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Invalid or unknown Minecraft version: {}",
profile.game_version
))
})?;
let (minecraft, version_index) =
crate::launcher::resolve_minecraft_manifest(
&profile.game_version,
&state,
)
.await?;
let version = &minecraft.versions[version_index];
let loader_version = crate::launcher::get_loader_version_from_profile(
&profile.game_version,
@@ -236,14 +346,22 @@ pub async fn install(path: &str, force: bool) -> crate::Result<()> {
if let Some(profile) = get(path).await? {
let result =
crate::launcher::install_minecraft(&profile, None, force).await;
if result.is_err()
&& profile.install_stage != ProfileInstallStage::Installed
{
edit(path, |prof| {
prof.install_stage = ProfileInstallStage::NotInstalled;
async { Ok(()) }
})
.await?;
if result.is_err() {
// Re-read the profile to get the current install_stage, as
// install_minecraft may have changed it (e.g. to MinecraftInstalling)
let current_stage = get(path)
.await
.ok()
.flatten()
.map(|p| p.install_stage)
.unwrap_or(ProfileInstallStage::NotInstalled);
if current_stage != ProfileInstallStage::Installed {
edit(path, |prof| {
prof.install_stage = ProfileInstallStage::NotInstalled;
async { Ok(()) }
})
.await?;
}
}
result?;
} else {
@@ -340,15 +458,22 @@ pub async fn update_project(
.remove(project_path)
&& let Some(update_version) = &file.update_version_id
{
let path = Profile::add_project_version(
let mut path = Profile::add_project_version(
profile_path,
update_version,
fetch::DownloadReason::Update,
None,
&state.pool,
&state.fetch_semaphore,
&state.io_semaphore,
)
.await?;
if project_path.ends_with(".disabled") {
path = Profile::toggle_disable_project(profile_path, &path)
.await?;
}
if path != project_path {
Profile::remove_project(profile_path, project_path).await?;
}
@@ -378,11 +503,16 @@ pub async fn update_project(
pub async fn add_project_from_version(
profile_path: &str,
version_id: &str,
reason: fetch::DownloadReason,
dependent_on_version_id: Option<String>,
) -> crate::Result<String> {
let state = State::get().await?;
let project_path = Profile::add_project_version(
profile_path,
version_id,
reason,
dependent_on_version_id,
&state.pool,
&state.fetch_semaphore,
&state.io_semaphore,
@@ -734,6 +864,64 @@ async fn run_credentials(
mc_set_options.push(("fullscreen".to_string(), "true".to_string()));
}
// For server projects: track this play in analytics
if let Some(linked_data) = &profile.linked_data {
let project_id = &linked_data.project_id;
if !project_id.trim().is_empty() {
let server_id = uuid::Uuid::new_v4().to_string();
let join_result = fetch::INSECURE_REQWEST_CLIENT
.post("https://sessionserver.mojang.com/session/minecraft/join")
.json(&json!({
"accessToken": &credentials.access_token,
"selectedProfile": credentials.offline_profile.id.simple().to_string(),
"serverId": &server_id,
}))
.timeout(Duration::from_secs(5))
.send()
.await;
match join_result {
Ok(resp) if resp.status().is_success() => {
let result = fetch::post_json(
concat!(
env!("MODRINTH_API_BASE_URL"),
"analytics/minecraft-server-play"
),
json!({
"project_id": &linked_data.project_id,
"username": &credentials.offline_profile.name,
"server_id": &server_id,
}),
&state.api_semaphore,
&state.pool,
)
.await;
match result {
Ok(()) => {
info!(
"Tracked server play for '{project_id}' in analytics"
)
}
Err(err) => {
warn!("Failed to report server play: {err:?}")
}
}
}
Ok(resp) => warn!(
"Failed to join Mojang session server: HTTP {}",
resp.status()
),
Err(err) => {
warn!("Failed to join Mojang session server: {err:?}")
}
}
}
}
crate::minecraft_skins::flush_pending_skin_change().await?;
crate::launcher::launch_minecraft(
&java_args,
&env_args,
@@ -796,7 +984,7 @@ pub async fn try_update_playtime(path: &str) -> crate::Result<()> {
}
fetch::post_json(
"https://api.modrinth.com/analytics/playtime",
concat!(env!("MODRINTH_API_BASE_URL"), "analytics/playtime"),
serde_json::to_value(hashmap)?,
&state.api_semaphore,
&state.pool,
+49 -2
View File
@@ -1,4 +1,5 @@
use crate::state::CacheBehaviour;
use crate::util::fetch::DownloadReason;
use crate::{
LoadingBarType,
event::{
@@ -10,6 +11,7 @@ use crate::{
state::ProfileInstallStage,
};
use futures::try_join;
use std::collections::HashSet;
/// Updates a managed modrinth pack to the version specified by new_version_id
#[tracing::instrument]
@@ -110,6 +112,22 @@ async fn replace_managed_modrinth(
new_version_id: Option<&String>,
ignore_lock: bool,
) -> crate::Result<()> {
// get disabled project ids to re-disable after update
let state = crate::State::get().await?;
let disabled_project_ids = profile
.get_projects(
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?
.into_iter()
.filter_map(|(file_path, project)| {
(file_path.ends_with(".disabled"))
.then_some(project.metadata?.project_id)
})
.collect::<HashSet<_>>();
crate::profile::edit(profile_path, |profile| {
profile.install_stage = ProfileInstallStage::MinecraftInstalling;
async { Ok(()) }
@@ -145,7 +163,8 @@ async fn replace_managed_modrinth(
profile.name.clone(),
None,
profile_path.to_string(),
Some(shared_loading_bar.clone())
Some(shared_loading_bar.clone()),
DownloadReason::Update,
),
generate_pack_from_version_id(
project_id.clone(),
@@ -153,7 +172,8 @@ async fn replace_managed_modrinth(
profile.name.clone(),
None,
profile_path.to_string(),
Some(shared_loading_bar)
Some(shared_loading_bar),
DownloadReason::Update,
)
)?
} else {
@@ -165,6 +185,7 @@ async fn replace_managed_modrinth(
None,
profile_path.to_string(),
None,
DownloadReason::Update,
)
.await?;
old_pack_creator.description.existing_loading_bar = None;
@@ -188,8 +209,34 @@ async fn replace_managed_modrinth(
pack::install_mrpack::install_zipped_mrpack_files(
new_pack_creator,
ignore_lock,
DownloadReason::Update,
)
.await?;
// re-enable previously disabled project
if !disabled_project_ids.is_empty()
&& let Some(updated_profile) = get(profile_path).await?
{
for (file_path, project) in updated_profile
.get_projects(
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?
{
if !file_path.ends_with(".disabled")
&& let Some(metadata) = &project.metadata
&& disabled_project_ids.contains(&metadata.project_id)
{
crate::state::Profile::toggle_disable_project(
profile_path,
&file_path,
)
.await?;
}
}
}
Ok(())
}
+2 -2
View File
@@ -150,8 +150,8 @@ pub async fn resolve_server_address(
match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await {
Err(e)
if e.proto()
.filter(|x| x.kind().is_no_records_found())
.is_some() =>
.as_ref()
.is_some_and(|x| x.kind().is_no_records_found()) =>
{
None
}
+4 -2
View File
@@ -23,10 +23,12 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
}
#[tracing::instrument]
pub async fn cancel_directory_change() -> crate::Result<()> {
pub async fn cancel_directory_change(
app_identifier: &str,
) -> crate::Result<()> {
// This is called to handle state initialization errors due to folder migrations
// failing, so fetching a DB connection pool from `State::get` is not reliable here
let pool = crate::state::db::connect().await?;
let pool = crate::state::db::connect(app_identifier).await?;
let mut settings = Settings::get(&pool).await?;
if let Some(prev_custom_dir) = settings.prev_custom_dir {
+198 -75
View File
@@ -12,7 +12,8 @@ pub use crate::util::server_ping::{
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
};
use crate::util::{io, server_ping};
use crate::{ErrorKind, Result, State, launcher};
use crate::{Error, ErrorKind, Result, State, launcher};
use async_minecraft_ping::ServerDescription;
use async_walkdir::WalkDir;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Local, TimeZone, Utc};
@@ -23,10 +24,12 @@ use futures::StreamExt;
use quartz_nbt::{NbtCompound, NbtTag};
use regex::{Regex, RegexBuilder};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::cmp::Reverse;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use tokio::task::JoinSet;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
@@ -139,6 +142,10 @@ pub enum WorldDetails {
index: usize,
address: String,
pack_status: ServerPackStatus,
#[serde(skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_kind: Option<String>,
},
}
@@ -278,15 +285,24 @@ async fn get_singleplayer_worlds_in_profile(
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 mut entries = io::read_dir(&saves_dir).await?;
let mut tasks = JoinSet::new();
while let Some(world_dir) = entries.next_entry().await? {
let world_path = world_dir.path();
let level_dat_path = world_path.join("level.dat");
if !level_dat_path.exists() {
if !world_path.join("level.dat").exists() {
continue;
}
if let Ok(world) = read_singleplayer_world(world_path).await {
worlds.push(world);
tasks.spawn(read_singleplayer_world(world_path));
}
while let Some(result) = tasks.join_next().await {
match result {
Ok(Ok(world)) => worlds.push(world),
Ok(Err(e)) => {
tracing::warn!("Skipping unreadable world: {e}");
}
Err(e) => {
tracing::warn!("World read task panicked: {e}");
}
}
}
@@ -327,36 +343,36 @@ 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,
let raw = io::read(world_path.join("level.dat")).await?;
let (root, _) = quartz_nbt::io::read_nbt(
&mut Cursor::new(raw),
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 data = root.get::<_, &NbtCompound>("Data").map_err(|_| {
Error::from(ErrorKind::InputError(
"Missing Data tag in level.dat".into(),
))
})?;
let game_mode = match level_data.game_type {
let level_name = data
.get::<_, &str>("LevelName")
.unwrap_or_default()
.to_string();
let last_played = data.get::<_, i64>("LastPlayed").unwrap_or(0);
let game_type = data.get::<_, i32>("GameType").unwrap_or(0);
let hardcore = data.get::<_, i8>("hardcore").unwrap_or(0) != 0;
let icon = if tokio::fs::try_exists(world_path.join("icon.png"))
.await
.unwrap_or(false)
{
Some(Either::Left(world_path.join("icon.png")))
} else {
None
};
let game_mode = match game_type {
0 => SingleplayerGameMode::Survival,
1 => SingleplayerGameMode::Creative,
2 => SingleplayerGameMode::Adventure,
@@ -365,9 +381,9 @@ async fn read_singleplayer_world_maybe_locked(
};
Ok(World {
name: level_data.level_name,
last_played: Utc.timestamp_millis_opt(level_data.last_played).single(),
icon: icon.map(Either::Left),
name: level_name,
last_played: Utc.timestamp_millis_opt(last_played).single(),
icon,
display_status: DisplayStatus::Normal,
details: WorldDetails::Singleplayer {
path: world_path
@@ -376,7 +392,7 @@ async fn read_singleplayer_world_maybe_locked(
.to_string_lossy()
.to_string(),
game_mode,
hardcore: level_data.hardcore,
hardcore,
locked,
},
})
@@ -397,7 +413,6 @@ async fn get_server_worlds_in_profile(
.await
.ok();
let first_server_index = worlds.len();
for (index, server) in servers.into_iter().enumerate() {
if server.hidden {
// TODO: Figure out whether we want to hide or show direct connect servers
@@ -423,40 +438,26 @@ async fn get_server_worlds_in_profile(
index,
address: server.ip,
pack_status: server.accept_textures.into(),
project_id: None,
content_kind: None,
},
};
worlds.push(world);
}
if let Some(join_log) = join_log {
let mut futures = JoinSet::new();
for (index, world) in worlds.iter().enumerate().skip(first_server_index)
{
// We can't check for the profile already having a last_played, in case the user joined
// the target address directly more recently. This is often the case when using
// quick-play before 1.20.
if let WorldDetails::Server { address, .. } = &world.details
&& let Ok((host, port)) = parse_server_address(address)
{
let host = host.to_owned();
futures.spawn(async move {
resolve_server_address(&host, port)
.await
.ok()
.map(|x| (index, x))
});
}
}
for (index, address) in futures.join_all().await.into_iter().flatten() {
worlds[index].last_played = join_log.get(&address).copied();
}
}
Ok(())
}
fn attach_world_data_to_world(world: &mut World, data: &AttachedWorldData) {
world.display_status = data.display_status;
if let WorldDetails::Server {
project_id,
content_kind,
..
} = &mut world.details
{
*project_id = data.project_id.clone();
*content_kind = data.content_kind.clone();
}
}
pub async fn set_world_display_status(
@@ -709,9 +710,12 @@ async fn try_get_world_session_lock(
pub async fn add_server_to_profile(
profile_path: &Path,
profile_path_id: &str,
name: String,
address: String,
pack_status: ServerPackStatus,
project_id: Option<String>,
content_kind: Option<String>,
) -> Result<usize> {
let mut servers = servers_data::read(profile_path).await?;
let insert_index = servers
@@ -722,13 +726,38 @@ pub async fn add_server_to_profile(
insert_index,
servers_data::ServerData {
name,
ip: address,
ip: address.clone(),
accept_textures: pack_status.into(),
hidden: false,
icon: None,
},
);
servers_data::write(profile_path, &servers).await?;
if project_id.is_some() || content_kind.is_some() {
let state = State::get().await?;
if let Some(project_id) = &project_id {
attached_world_data::set_project_id(
profile_path_id,
WorldType::Server,
&address,
project_id,
&state.pool,
)
.await?;
}
if let Some(content_kind) = &content_kind {
attached_world_data::set_content_kind(
profile_path_id,
WorldType::Server,
&address,
content_kind,
&state.pool,
)
.await?;
}
}
Ok(insert_index)
}
@@ -762,7 +791,7 @@ pub async fn remove_server_from_profile(
index: usize,
) -> Result<()> {
let mut servers = servers_data::read(profile_path).await?;
if servers.get(index).filter(|x| !x.hidden).is_none() {
if servers.get(index).as_ref().is_none_or(|x| x.hidden) {
return Err(ErrorKind::InputError(format!(
"No removable server at index {index}"
))
@@ -855,15 +884,13 @@ pub async fn get_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(ErrorKind::LauncherError(format!(
"Invalid game version: {}",
profile.game_version
)))?;
let state = State::get().await?;
let (minecraft, version_index) =
crate::launcher::resolve_minecraft_manifest(
&profile.game_version,
&state,
)
.await?;
let version = &minecraft.versions[version_index];
let loader_version = get_loader_version_from_profile(
@@ -902,6 +929,18 @@ pub async fn get_profile_protocol_version(
pub async fn get_server_status(
address: &str,
protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> {
tracing::debug!(
"Pinging {address} with protocol version {protocol_version:?}"
);
get_server_status_old(address, protocol_version).await
// get_server_status_new(address, protocol_version).await
}
async fn get_server_status_old(
address: &str,
protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> {
let (original_host, original_port) = parse_server_address(address)?;
let (host, port) =
@@ -916,3 +955,87 @@ pub async fn get_server_status(
)
.await
}
async fn _get_server_status_new(
address: &str,
protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> {
let (address, port) = match address.rsplit_once(':') {
Some((addr, port)) => {
let port = port.parse::<u16>().map_err(|_err| {
Error::from(ErrorKind::InputError("invalid port number".into()))
})?;
(addr, port)
}
None => (address, 25565),
};
let mut builder = async_minecraft_ping::ConnectionConfig::build(address)
.with_port(port)
.with_srv_lookup();
if let Some(version) = protocol_version {
builder = builder.with_protocol_version(version.version as usize)
}
let conn = builder.connect().await.map_err(|_err| {
Error::from(ErrorKind::InputError("failed to connect to server".into()))
})?;
let ping_conn = conn.status().await.map_err(|_err| {
Error::from(ErrorKind::InputError("failed to get server status".into()))
})?;
let status = &ping_conn.status;
let description = match &status.description {
ServerDescription::Plain(text) => {
serde_json::value::to_raw_value(&text).ok()
}
ServerDescription::Object { text } => {
// TODO: `text` always seems to be empty?
RawValue::from_string(text.clone()).ok()
}
};
let players = ServerPlayers {
max: status.players.max,
online: status.players.online,
sample: status
.players
.sample
.as_ref()
.map(|sample| {
sample
.iter()
.map(|player| ServerGameProfile {
id: player.id.clone(),
name: player.name.clone(),
})
.collect()
})
.unwrap_or_default(),
};
let version = ServerVersion {
name: status.version.name.clone(),
protocol: status.version.protocol,
legacy: false,
};
let favicon = status.favicon.as_ref().and_then(|url| url.parse().ok());
let latency = {
let start = Instant::now();
let ping_magic = Utc::now().timestamp_millis().cast_unsigned();
ping_conn.ping(ping_magic).await.map_err(|_err| {
Error::from(ErrorKind::InputError("failed to do ping".into()))
})?;
start.elapsed().as_millis() as i64
};
Ok(ServerStatus {
description,
players: Some(players),
version: Some(version),
favicon,
enforces_secure_chat: false,
ping: Some(latency),
})
}
+14
View File
@@ -16,6 +16,9 @@ pub struct LabrinthError {
#[derive(thiserror::Error, Debug)]
pub enum ErrorKind {
#[error("{0:?}")]
Any(eyre::Report),
#[error("Filesystem error: {0}")]
FSError(String),
@@ -248,6 +251,17 @@ impl<E: Into<ErrorKind>> From<E> for Error {
}
}
impl From<eyre::Report> for Error {
fn from(value: eyre::Report) -> Self {
let error = Arc::new(ErrorKind::Any(value));
Self {
raw: error.clone(),
source: error.in_current_span(),
}
}
}
impl ErrorKind {
pub fn as_error(self) -> Error {
self.into()
+15
View File
@@ -8,6 +8,7 @@ use crate::event::{
LoadingPayload, ProcessPayload, ProfilePayload, WarningPayload, InfoPayload
};
use futures::prelude::*;
use serde_json::Value;
#[cfg(feature = "tauri")]
use tauri::{Emitter, Manager};
use uuid::Uuid;
@@ -323,6 +324,20 @@ pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
Ok(())
}
#[allow(unused_variables)]
pub async fn emit_notification(payload: Value) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let event_state = crate::EventState::get()?;
event_state
.app
.emit("notification", payload)
.map_err(EventError::from)?;
}
Ok(())
}
// loading_join! macro
// loading_join!(key: Option<&LoadingBarId>, total: f64, message: Option<&str>; task1, task2, task3...)
// This will submit a loading event with the given message for each task as they complete
+26
View File
@@ -219,6 +219,9 @@ pub enum CommandPayload {
InstallModpack {
id: String,
},
InstallServer {
id: String,
},
RunMRPack {
// run or install .mrpack
path: PathBuf,
@@ -277,6 +280,29 @@ pub enum FriendPayload {
StatusSync,
}
#[cfg(feature = "tauri")]
pub use self::log_types::*;
#[cfg(feature = "tauri")]
mod log_types {
use crate::state::Log4jEvent;
use serde::Serialize;
#[derive(Serialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LogEvent {
Log4j(Log4jEvent),
Legacy { message: String },
}
#[derive(Serialize, Clone)]
pub struct LogPayload {
pub profile_path_id: String,
#[serde(flatten)]
pub event: LogEvent,
}
}
#[derive(Debug, thiserror::Error)]
pub enum EventError {
#[error("Event state was not properly initialized")]
+8 -3
View File
@@ -148,6 +148,7 @@ pub async fn download_client(
let bytes = fetch(
&client_download.url,
Some(&client_download.sha1),
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -238,7 +239,7 @@ pub async fn download_assets(
async {
if !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &st.pool))
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
.await?;
write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched asset with hash {hash}");
@@ -252,7 +253,7 @@ pub async fn download_assets(
if with_legacy && !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &st.pool))
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
.await?;
write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched legacy asset with hash {hash}");
@@ -326,6 +327,7 @@ pub async fn download_libraries(
let data = fetch(
&native.url,
Some(&native.sha1),
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -370,6 +372,7 @@ pub async fn download_libraries(
let bytes = fetch(
&artifact.url,
Some(&artifact.sha1),
None,
&st.fetch_semaphore,
&st.pool,
)
@@ -406,7 +409,8 @@ pub async fn download_libraries(
// failed download here is not a fatal condition.
//
// See DEV-479.
match fetch(&url, None, &st.fetch_semaphore, &st.pool).await
match fetch(&url, None, None, &st.fetch_semaphore, &st.pool)
.await
{
Ok(bytes) => {
write(&path, &bytes, &st.io_semaphore).await?;
@@ -465,6 +469,7 @@ pub async fn download_log_config(
let bytes = fetch(
&log_download.url,
Some(&log_download.sha1),
None,
&st.fetch_semaphore,
&st.pool,
)
+71 -19
View File
@@ -8,6 +8,8 @@ use crate::launcher::quick_play_version::{
QuickPlayServerVersion, QuickPlayVersion,
};
use crate::profile::QuickPlayType;
use crate::server_address::{ServerAddress, parse_server_address};
use crate::state::server_join_log::JoinLogEntry;
use crate::state::{
AccountType, Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
};
@@ -192,6 +194,46 @@ pub async fn get_loader_version_from_profile(
}
}
/// Resolves the Minecraft version manifest and finds the index for the given
/// game version. If the version isn't found in the cache, forces a manifest
/// refresh to pick up newly-released versions.
pub async fn resolve_minecraft_manifest(
game_version: &str,
state: &State,
) -> crate::Result<(d::minecraft::VersionManifest, usize)> {
let minecraft = crate::api::metadata::get_minecraft_versions().await?;
if let Some(idx) = minecraft
.versions
.iter()
.position(|it| it.id == game_version)
{
return Ok((minecraft, idx));
}
// Version not found in cache — force a manifest refresh in case it was
// released after the cache was populated.
let refreshed = crate::state::CachedEntry::get_minecraft_manifest(
Some(crate::state::CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::NoValueFor("minecraft versions".to_string())
})?;
let idx = refreshed
.versions
.iter()
.position(|it| it.id == game_version)
.ok_or(crate::ErrorKind::LauncherError(format!(
"Invalid game version: {game_version}"
)))?;
Ok((refreshed, idx))
}
#[tracing::instrument(skip(profile))]
pub async fn install_minecraft(
@@ -222,16 +264,8 @@ pub async fn install_minecraft(
let instance_path =
crate::api::profile::get_full_path(&profile.path).await?;
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 (minecraft, version_index) =
resolve_minecraft_manifest(&profile.game_version, &state).await?;
let version = &minecraft.versions[version_index];
let minecraft_updated = version_index
<= minecraft
@@ -484,15 +518,8 @@ pub async fn launch_minecraft(
let instance_path =
crate::api::profile::get_full_path(&profile.path).await?;
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 (minecraft, version_index) =
resolve_minecraft_manifest(&profile.game_version, &state).await?;
let version = &minecraft.versions[version_index];
let minecraft_updated = version_index
<= minecraft
@@ -617,6 +644,31 @@ pub async fn launch_minecraft(
if let QuickPlayType::Server(address) = &mut quick_play_type
&& quick_play_version.server >= QuickPlayServerVersion::BuiltinLegacy
{
// Record last-played for the original server address immediately so
// recent-worlds can match without DNS/SRV resolution.
let original = match address {
ServerAddress::Unresolved(address) => parse_server_address(address)
.ok()
.map(|(h, p)| (h.to_owned(), p)),
ServerAddress::Resolved {
original_host,
original_port,
..
} => Some((original_host.clone(), *original_port)),
};
if let Some((host, port)) = original
&& let Err(e) = (JoinLogEntry {
profile_path: profile.path.clone(),
host,
port,
join_time: Utc::now(),
})
.upsert(&state.pool)
.await
{
tracing::warn!("Failed to write server join log entry: {e}");
}
address.resolve().await?;
}
+1
View File
@@ -25,6 +25,7 @@ pub use event::{
};
pub use logger::start_logger;
pub use state::State;
pub use util::fetch::DownloadReason;
pub fn launcher_user_agent() -> String {
const LAUNCHER_BASE_USER_AGENT: &str =
+5 -3
View File
@@ -18,7 +18,7 @@
// Handling for the live development logging
// This will log to the console, and will not log to a file
#[cfg(debug_assertions)]
pub fn start_logger() -> Option<()> {
pub fn start_logger(_app_identifier: &str) -> Option<()> {
use tracing_subscriber::prelude::*;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
@@ -36,7 +36,7 @@ pub fn start_logger() -> Option<()> {
// Handling for the live production logging
// This will log to a file in the logs directory, and will not show any logs in the console
#[cfg(not(debug_assertions))]
pub fn start_logger() -> Option<()> {
pub fn start_logger(app_identifier: &str) -> Option<()> {
use crate::prelude::DirectoryInfo;
use chrono::Local;
use std::fs::OpenOptions;
@@ -44,7 +44,9 @@ pub fn start_logger() -> Option<()> {
use tracing_subscriber::prelude::*;
// Initialize and get logs directory path
let logs_dir = if let Some(d) = DirectoryInfo::launcher_logs_dir() {
let logs_dir = if let Some(d) =
DirectoryInfo::launcher_logs_dir_path(app_identifier)
{
d
} else {
eprintln!("Could not start logger");
@@ -5,6 +5,8 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct AttachedWorldData {
pub display_status: DisplayStatus,
pub project_id: Option<String>,
pub content_kind: Option<String>,
}
impl AttachedWorldData {
@@ -18,7 +20,7 @@ impl AttachedWorldData {
let attached_data = sqlx::query!(
"
SELECT display_status
SELECT display_status, project_id, content_kind
FROM attached_world_data
WHERE profile_path = $1 and world_type = $2 and world_id = $3
",
@@ -31,6 +33,8 @@ impl AttachedWorldData {
Ok(attached_data.map(|x| AttachedWorldData {
display_status: DisplayStatus::from_string(&x.display_status),
project_id: x.project_id,
content_kind: x.content_kind,
}))
}
@@ -40,7 +44,7 @@ impl AttachedWorldData {
) -> crate::Result<HashMap<(WorldType, String), Self>> {
let attached_data = sqlx::query!(
"
SELECT world_type, world_id, display_status
SELECT world_type, world_id, display_status, project_id, content_kind
FROM attached_world_data
WHERE profile_path = $1
",
@@ -57,7 +61,11 @@ impl AttachedWorldData {
DisplayStatus::from_string(&x.display_status);
(
(world_type, x.world_id),
AttachedWorldData { display_status },
AttachedWorldData {
display_status,
project_id: x.project_id,
content_kind: x.content_kind,
},
)
})
.collect())
@@ -120,3 +128,5 @@ macro_rules! attached_data_setter {
}
attached_data_setter!(display_status: DisplayStatus, "display_status" => display_status.as_str());
attached_data_setter!(project_id: &str, "project_id");
attached_data_setter!(content_kind: &str, "content_kind");
+667 -57
View File
@@ -15,10 +15,11 @@ use std::path::{Path, PathBuf};
// 1 day
const DEFAULT_ID: &str = "0";
#[derive(Serialize, Deserialize, Copy, Clone, Debug)]
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CacheValueType {
Project,
ProjectV3,
Version,
User,
Team,
@@ -34,12 +35,17 @@ pub enum CacheValueType {
FileHash,
FileUpdate,
SearchResults,
SearchResultsV3,
ModpackFiles,
/// Cached list of versions for a project (without changelogs for fast loading)
ProjectVersions,
}
impl CacheValueType {
pub fn as_str(&self) -> &'static str {
match self {
CacheValueType::Project => "project",
CacheValueType::ProjectV3 => "project_v3",
CacheValueType::Version => "version",
CacheValueType::User => "user",
CacheValueType::Team => "team",
@@ -55,12 +61,16 @@ impl CacheValueType {
CacheValueType::FileHash => "file_hash",
CacheValueType::FileUpdate => "file_update",
CacheValueType::SearchResults => "search_results",
CacheValueType::SearchResultsV3 => "search_results_v3",
CacheValueType::ModpackFiles => "modpack_files",
CacheValueType::ProjectVersions => "project_versions",
}
}
pub fn from_string(val: &str) -> CacheValueType {
match val {
"project" => CacheValueType::Project,
"project_v3" => CacheValueType::ProjectV3,
"version" => CacheValueType::Version,
"user" => CacheValueType::User,
"team" => CacheValueType::Team,
@@ -76,6 +86,9 @@ impl CacheValueType {
"file_hash" => CacheValueType::FileHash,
"file_update" => CacheValueType::FileUpdate,
"search_results" => CacheValueType::SearchResults,
"search_results_v3" => CacheValueType::SearchResultsV3,
"modpack_files" => CacheValueType::ModpackFiles,
"project_versions" => CacheValueType::ProjectVersions,
_ => CacheValueType::Project,
}
}
@@ -85,7 +98,10 @@ impl CacheValueType {
match self {
CacheValueType::File => 30 * 24 * 60 * 60, // 30 days
CacheValueType::FileHash => 30 * 24 * 60 * 60, // 30 days
_ => 30 * 60, // 30 minutes
// ModpackFiles never expire - version_id is immutable so hashes never change
// TODO: There has to be a way to exclude this from the "Purge cache" stuff?
CacheValueType::ModpackFiles => 100 * 365 * 24 * 60 * 60, // 100 years (effectively never)
_ => 30 * 60, // 30 minutes
}
}
@@ -102,6 +118,7 @@ impl CacheValueType {
pub fn case_sensitive_alias(&self) -> Option<bool> {
match self {
CacheValueType::Project
| CacheValueType::ProjectV3
| CacheValueType::User
| CacheValueType::Organization => Some(false),
@@ -118,39 +135,67 @@ impl CacheValueType {
| CacheValueType::File
| CacheValueType::LoaderManifest
| CacheValueType::FileUpdate
| CacheValueType::SearchResults => None,
| CacheValueType::SearchResults
| CacheValueType::SearchResultsV3
| CacheValueType::ModpackFiles
| CacheValueType::ProjectVersions => None,
}
}
}
/// Cached modpack file hashes for filtering content
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedModpackFiles {
pub version_id: String,
pub file_hashes: Vec<String>,
#[serde(default)]
pub project_ids: Vec<String>,
}
/// Cached list of versions for a project (without changelogs for fast loading)
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedProjectVersions {
pub project_id: String,
pub versions: Vec<Version>,
}
// De/serialization strategy:
// - on serialize:
// - in the `cache` table, save the `data_type` (variant of this value) alongside
// the data
// - data column contains the serialized form of the INNER value (i.e. for a
// `CacheValue::Project`, we serialize it as a `Project,` NOT as a `CacheValue`)
// - this way, we do not tag the data using serde in any way
// - on deserialize:
// - use the `data_type` to figure out what type of value to deser as
// - then wrap that in a `CacheValue`
//
// do NOT use `#[serde(untagged)]` here, since then a value of one variant can be
// deser'd as a value of another variant, if it comes before it in the enum
// definition list.
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum CacheValue {
Project(Project),
Version(Version),
User(User),
Team(Vec<TeamMember>),
Organization(Organization),
File(CachedFile),
LoaderManifest(CachedLoaderManifest),
MinecraftManifest(daedalus::minecraft::VersionManifest),
Categories(Vec<Category>),
ReportTypes(Vec<String>),
Loaders(Vec<Loader>),
GameVersions(Vec<GameVersion>),
DonationPlatforms(Vec<DonationPlatform>),
FileHash(CachedFileHash),
FileUpdate(CachedFileUpdate),
SearchResults(SearchResults),
SearchResultsV3(SearchResultsV3),
ModpackFiles(CachedModpackFiles),
ProjectVersions(CachedProjectVersions),
ProjectV3(ProjectV3),
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -193,13 +238,134 @@ pub struct SearchEntry {
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchResultsV3 {
pub search: String,
pub result: SearchResultV3,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchResultV3 {
pub hits: Vec<serde_json::Value>,
#[serde(default)]
pub offset: u32,
#[serde(default)]
pub limit: u32,
#[serde(default)]
pub total_hits: u32,
}
#[derive(Serialize, Clone, Debug)]
pub struct CachedFileUpdate {
pub hash: String,
pub game_version: String,
pub loaders: Vec<String>,
pub channel_policy: String,
pub update_version_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReleaseChannel {
Release,
Beta,
Alpha,
}
impl ReleaseChannel {
pub fn key(self) -> &'static str {
match self {
Self::Release => "release",
Self::Beta => "beta",
Self::Alpha => "alpha",
}
}
pub fn from_key(key: &str) -> Self {
match key {
"alpha" => Self::Alpha,
"all" => Self::Alpha,
"beta" => Self::Beta,
_ => Self::Release,
}
}
pub fn from_version_type(version_type: &str) -> Self {
match version_type {
"alpha" => Self::Alpha,
"beta" => Self::Beta,
_ => Self::Release,
}
}
pub fn least_stable(self, other: Self) -> Self {
if self.instability_rank() >= other.instability_rank() {
self
} else {
other
}
}
fn instability_rank(self) -> u8 {
match self {
Self::Release => 0,
Self::Beta => 1,
Self::Alpha => 2,
}
}
pub fn version_type_fallbacks(self) -> Vec<Vec<&'static str>> {
match self {
Self::Release => {
vec![vec!["release"], vec!["beta"], vec!["alpha"]]
}
Self::Beta => {
vec![vec!["release", "beta"], vec!["alpha"]]
}
Self::Alpha => vec![vec!["release", "beta", "alpha"]],
}
}
}
fn default_file_update_channel_policy() -> String {
ReleaseChannel::Alpha.key().to_string()
}
/// Migrates old cache entries that stored `"loader": "forge"` (singular string)
/// to the current `"loaders": ["forge"]` (array) format.
/// SEE: https://github.com/modrinth/code/issues/5562
impl<'de> serde::Deserialize<'de> for CachedFileUpdate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Helper {
hash: String,
game_version: String,
#[serde(default)]
loaders: Option<Vec<String>>,
#[serde(default)]
loader: Option<String>,
#[serde(default = "default_file_update_channel_policy")]
channel_policy: String,
update_version_id: String,
}
let helper = Helper::deserialize(deserializer)?;
let loaders = helper.loaders.unwrap_or_else(|| {
helper.loader.map(|l| vec![l]).unwrap_or_default()
});
Ok(CachedFileUpdate {
hash: helper.hash,
game_version: helper.game_version,
loaders,
channel_policy: helper.channel_policy,
update_version_id: helper.update_version_id,
})
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedFileHash {
pub path: String,
@@ -264,6 +430,15 @@ pub struct Project {
pub color: Option<u32>,
}
/// Uses serde_json::Value for flexibility since the v3. properly typed in frontend
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ProjectV3 {
pub id: String,
pub slug: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct License {
pub id: String,
@@ -308,7 +483,8 @@ pub struct Version {
pub name: String,
pub version_number: String,
pub changelog: String,
#[serde(default)]
pub changelog: Option<String>,
pub changelog_url: Option<String>,
pub date_published: DateTime<Utc>,
@@ -437,6 +613,7 @@ impl CacheValue {
pub fn get_type(&self) -> CacheValueType {
match self {
CacheValue::Project(_) => CacheValueType::Project,
CacheValue::ProjectV3(_) => CacheValueType::ProjectV3,
CacheValue::Version(_) => CacheValueType::Version,
CacheValue::User(_) => CacheValueType::User,
CacheValue::Team { .. } => CacheValueType::Team,
@@ -456,12 +633,16 @@ impl CacheValue {
CacheValue::FileHash(_) => CacheValueType::FileHash,
CacheValue::FileUpdate(_) => CacheValueType::FileUpdate,
CacheValue::SearchResults(_) => CacheValueType::SearchResults,
CacheValue::SearchResultsV3(_) => CacheValueType::SearchResultsV3,
CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles,
CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions,
}
}
fn get_key(&self) -> String {
match self {
CacheValue::Project(project) => project.id.clone(),
CacheValue::ProjectV3(project) => project.id.clone(),
CacheValue::Version(version) => version.id.clone(),
CacheValue::User(user) => user.id.clone(),
CacheValue::Team(members) => members
@@ -489,19 +670,24 @@ impl CacheValue {
}
CacheValue::FileUpdate(hash) => {
format!(
"{}-{}-{}",
"{}-{}-{}-{}",
hash.hash,
hash.loaders.join("+"),
hash.channel_policy,
hash.game_version
)
}
CacheValue::SearchResults(search) => search.search.clone(),
CacheValue::SearchResultsV3(search) => search.search.clone(),
CacheValue::ModpackFiles(files) => files.version_id.clone(),
CacheValue::ProjectVersions(pv) => pv.project_id.clone(),
}
}
fn get_alias(&self) -> Option<String> {
match self {
CacheValue::Project(project) => project.slug.clone(),
CacheValue::ProjectV3(project) => project.slug.clone(),
CacheValue::User(user) => Some(user.username.clone()),
CacheValue::Organization(org) => Some(org.slug.clone()),
@@ -520,9 +706,55 @@ impl CacheValue {
| CacheValue::File { .. }
| CacheValue::LoaderManifest { .. }
| CacheValue::FileUpdate(_)
| CacheValue::SearchResults(_) => None,
| CacheValue::SearchResults(_)
| CacheValue::SearchResultsV3(_)
| CacheValue::ModpackFiles(_)
| CacheValue::ProjectVersions(_) => None,
}
}
fn to_json_value(&self) -> crate::Result<serde_json::Value> {
let value = match self {
CacheValue::Project(project) => serde_json::to_value(project),
CacheValue::ProjectV3(project) => serde_json::to_value(project),
CacheValue::Version(version) => serde_json::to_value(version),
CacheValue::User(user) => serde_json::to_value(user),
CacheValue::Team(members) => serde_json::to_value(members),
CacheValue::Organization(org) => serde_json::to_value(org),
CacheValue::File(file) => serde_json::to_value(file),
CacheValue::LoaderManifest(loader) => serde_json::to_value(loader),
CacheValue::MinecraftManifest(manifest) => {
serde_json::to_value(manifest)
}
CacheValue::Categories(categories) => {
serde_json::to_value(categories)
}
CacheValue::ReportTypes(report_types) => {
serde_json::to_value(report_types)
}
CacheValue::Loaders(loaders) => serde_json::to_value(loaders),
CacheValue::GameVersions(versions) => {
serde_json::to_value(versions)
}
CacheValue::DonationPlatforms(platforms) => {
serde_json::to_value(platforms)
}
CacheValue::FileHash(hash) => serde_json::to_value(hash),
CacheValue::FileUpdate(update) => serde_json::to_value(update),
CacheValue::SearchResults(search) => serde_json::to_value(search),
CacheValue::SearchResultsV3(search) => serde_json::to_value(search),
CacheValue::ModpackFiles(files) => serde_json::to_value(files),
CacheValue::ProjectVersions(pv) => serde_json::to_value(pv),
}
.map_err(|err| {
crate::ErrorKind::OtherError(format!(
"Failed to serialize cache value: {err}"
))
.as_error()
})?;
Ok(value)
}
}
#[derive(
@@ -620,6 +852,7 @@ macro_rules! impl_cache_method_singular {
impl_cache_methods!(
(Project, Project),
(ProjectV3, ProjectV3),
(Version, Version),
(User, User),
(Team, Vec<TeamMember>),
@@ -628,7 +861,8 @@ impl_cache_methods!(
(LoaderManifest, CachedLoaderManifest),
(FileHash, CachedFileHash),
(FileUpdate, CachedFileUpdate),
(SearchResults, SearchResults)
(SearchResults, SearchResults),
(SearchResultsV3, SearchResultsV3)
);
impl_cache_method_singular!(
@@ -713,18 +947,15 @@ impl CachedEntry {
.fetch_all(pool)
.await?;
let now = Utc::now().timestamp();
for row in query {
let row_exists = row.data.is_some();
let parsed_data = row
.data
.and_then(|x| serde_json::from_value::<CacheValue>(x).ok());
let parsed_data = if let Some(data) = row.data.clone() {
Some(Self::deserialize_cache_value(type_, data, &row.id)?)
} else {
None
};
// If data is corrupted/failed to parse ignore it
if row_exists && parsed_data.is_none() {
continue;
}
if row.expires <= Utc::now().timestamp() {
if row.expires <= now {
if cache_behaviour == CacheBehaviour::MustRevalidate {
continue;
} else {
@@ -732,18 +963,32 @@ impl CachedEntry {
}
}
remaining_keys.retain(|x| {
x != &&*row.id
&& !row.alias.as_ref().is_some_and(|y| {
let row_id = row.id.clone();
let row_alias = row.alias.clone();
let remove_matching_key = |x: &&str| {
x != &&*row_id
&& !row_alias.as_ref().is_some_and(|y| {
if type_.case_sensitive_alias().unwrap_or(true) {
x == y
} else {
y.to_lowercase() == x.to_lowercase()
}
})
});
};
if let Some(data) = parsed_data {
if data.get_type() != type_ {
return Err(crate::ErrorKind::OtherError(format!(
"Cache type mismatch for id {}: expected {:?}, got {:?}",
row.id,
type_,
data.get_type()
))
.as_error());
}
remaining_keys.retain(remove_matching_key);
return_vals.push(Self {
id: row.id,
alias: row.alias,
@@ -751,6 +996,8 @@ impl CachedEntry {
data: Some(data),
expires: row.expires,
});
} else {
remaining_keys.retain(remove_matching_key);
}
}
}
@@ -953,6 +1200,14 @@ impl CachedEntry {
CacheValue::Project
)
}
CacheValueType::ProjectV3 => {
fetch_original_values!(
ProjectV3,
env!("MODRINTH_API_URL_V3"),
"projects",
CacheValue::ProjectV3
)
}
CacheValueType::Version => {
fetch_original_values!(
Version,
@@ -1270,20 +1525,46 @@ impl CachedEntry {
let mut vals = Vec::new();
// TODO: switch to update individual once back-end route exists
let mut filtered_keys: Vec<((String, String), Vec<String>)> =
Vec::new();
let mut filtered_keys: Vec<(
(String, String, String),
Vec<String>,
)> = Vec::new();
keys.iter().for_each(|x| {
let string = x.key().to_string();
let key = string.splitn(3, '-').collect::<Vec<_>>();
let key = string.splitn(4, '-').collect::<Vec<_>>();
if key.len() == 3 {
let hash = key[0];
let loaders_key = key[1];
let game_version = key[2];
let parsed_key = if key.len() == 4
&& matches!(
key[2],
"release" | "beta" | "alpha" | "all"
) {
Some((key[0], key[1], key[2], key[3]))
} else {
let key = string.splitn(3, '-').collect::<Vec<_>>();
if key.len() == 3 {
Some((
key[0],
key[1],
ReleaseChannel::Alpha.key(),
key[2],
))
} else {
None
}
};
if let Some((
hash,
loaders_key,
channel_policy_key,
game_version,
)) = parsed_key
{
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 == channel_policy_key
&& x.0.2 == game_version
})
{
values.1.push(hash.to_string());
@@ -1291,6 +1572,7 @@ impl CachedEntry {
filtered_keys.push((
(
loaders_key.to_string(),
channel_policy_key.to_string(),
game_version.to_string(),
),
vec![hash.to_string()],
@@ -1306,19 +1588,56 @@ impl CachedEntry {
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Vec<Version>>>(
Method::POST,
concat!(env!("MODRINTH_API_URL"), "version_files/update_many"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
"hashes": hashes,
"loaders": loaders_key.split('+').collect::<Vec<_>>(),
"game_versions": [game_version]
})),
fetch_semaphore,
pool,
|((loaders_key, channel_policy_key, game_version), hashes)| async move {
let channel_policy =
ReleaseChannel::from_key(channel_policy_key);
let mut remaining_hashes = hashes.clone();
let mut found_versions = HashMap::new();
for version_types in
channel_policy.version_type_fallbacks()
{
if remaining_hashes.is_empty() {
break;
}
let variation = fetch_json::<
HashMap<String, Vec<Version>>,
>(
Method::POST,
concat!(
env!("MODRINTH_API_URL"),
"version_files/update_many"
),
None,
Some(serde_json::json!({
"algorithm": "sha1",
"hashes": remaining_hashes.clone(),
"loaders": loaders_key.split('+').collect::<Vec<_>>(),
"game_versions": [game_version],
"version_types": version_types
})),
fetch_semaphore,
pool,
)
.await?;
for (hash, versions) in variation {
found_versions.insert(hash, versions);
}
remaining_hashes = hashes
.iter()
.filter(|hash| {
!found_versions
.contains_key(hash.as_str())
})
.cloned()
.collect();
}
Ok::<HashMap<String, Vec<Version>>, crate::Error>(
found_versions,
)
},
))
@@ -1326,9 +1645,10 @@ impl CachedEntry {
for (index, mut variation) in variations.into_iter().enumerate()
{
let ((loaders_key, game_version), hashes) =
&filtered_keys[index];
let (
(loaders_key, channel_policy_key, game_version),
hashes,
) = &filtered_keys[index];
for hash in hashes {
let versions = variation.remove(hash);
@@ -1348,6 +1668,8 @@ impl CachedEntry {
.split('+')
.map(|x| x.to_string())
.collect(),
channel_policy: channel_policy_key
.to_string(),
update_version_id: version_id,
})
.get_entry(),
@@ -1358,7 +1680,7 @@ impl CachedEntry {
vals.push((
CacheValueType::FileUpdate.get_empty_entry(
format!(
"{hash}-{loaders_key}-{game_version}"
"{hash}-{loaders_key}-{channel_policy_key}-{game_version}"
),
),
true,
@@ -1411,14 +1733,203 @@ impl CachedEntry {
})
.collect()
}
CacheValueType::ModpackFiles => {
// ModpackFiles are only stored locally during modpack installation,
// not fetched from an external API
vec![]
}
CacheValueType::ProjectVersions => {
let mut values = vec![];
for key in keys {
let project_id = key.to_string();
let url = format!(
"{}project/{}/version?include_changelog=false",
env!("MODRINTH_API_URL"),
project_id
);
match fetch_json::<Vec<Version>>(
Method::GET,
&url,
None,
None,
fetch_semaphore,
pool,
)
.await
{
Ok(versions) => {
values.push((
CacheValue::ProjectVersions(
CachedProjectVersions {
project_id,
versions,
},
)
.get_entry(),
true,
));
}
Err(e) => {
tracing::warn!(
"Failed to fetch versions for project {}: {:?}",
project_id,
e
);
}
}
}
values
}
CacheValueType::SearchResultsV3 => {
let fetch_urls = keys
.iter()
.map(|x| {
(
x.key().to_string(),
format!(
"{}search{}",
env!("MODRINTH_API_URL_V3"),
x.key()
),
)
})
.collect::<Vec<_>>();
futures::future::try_join_all(fetch_urls.iter().map(
|(_, url)| {
fetch_json(
Method::GET,
url,
None,
None,
fetch_semaphore,
pool,
)
},
))
.await?
.into_iter()
.enumerate()
.map(|(index, result)| {
(
CacheValue::SearchResultsV3(SearchResultsV3 {
search: fetch_urls[index].0.to_string(),
result,
})
.get_entry(),
true,
)
})
.collect()
}
})
}
fn deserialize_cache_value(
type_: CacheValueType,
data: serde_json::Value,
id: &str,
) -> crate::Result<CacheValue> {
fn parse<T: DeserializeOwned>(
data: serde_json::Value,
id: &str,
label: &str,
) -> crate::Result<T> {
serde_json::from_value::<T>(data.clone()).map_err(|err| {
crate::ErrorKind::OtherError(format!(
"Failed to deserialize cache {label} for id {id}: {err}\n\ndata:\n{}",
serde_json::to_string_pretty(&data).unwrap(),
))
.as_error()
})
}
let value = match type_ {
CacheValueType::Project => {
CacheValue::Project(parse(data, id, "project")?)
}
CacheValueType::ProjectV3 => {
CacheValue::ProjectV3(parse(data, id, "project_v3")?)
}
CacheValueType::Version => {
CacheValue::Version(parse(data, id, "version")?)
}
CacheValueType::User => CacheValue::User(parse(data, id, "user")?),
CacheValueType::Team => CacheValue::Team(parse(data, id, "team")?),
CacheValueType::Organization => {
CacheValue::Organization(parse(data, id, "organization")?)
}
CacheValueType::File => CacheValue::File(parse(data, id, "file")?),
CacheValueType::LoaderManifest => {
CacheValue::LoaderManifest(parse(data, id, "loader_manifest")?)
}
CacheValueType::MinecraftManifest => CacheValue::MinecraftManifest(
parse(data, id, "minecraft_manifest")?,
),
CacheValueType::Categories => {
CacheValue::Categories(parse(data, id, "categories")?)
}
CacheValueType::ReportTypes => {
CacheValue::ReportTypes(parse(data, id, "report_types")?)
}
CacheValueType::Loaders => {
CacheValue::Loaders(parse(data, id, "loaders")?)
}
CacheValueType::GameVersions => {
CacheValue::GameVersions(parse(data, id, "game_versions")?)
}
CacheValueType::DonationPlatforms => CacheValue::DonationPlatforms(
parse(data, id, "donation_platforms")?,
),
CacheValueType::FileHash => {
CacheValue::FileHash(parse(data, id, "file_hash")?)
}
CacheValueType::FileUpdate => {
CacheValue::FileUpdate(parse(data, id, "file_update")?)
}
CacheValueType::SearchResults => {
CacheValue::SearchResults(parse(data, id, "search_results")?)
}
CacheValueType::SearchResultsV3 => CacheValue::SearchResultsV3(
parse(data, id, "search_results_v3")?,
),
CacheValueType::ModpackFiles => {
CacheValue::ModpackFiles(parse(data, id, "modpack_files")?)
}
CacheValueType::ProjectVersions => CacheValue::ProjectVersions(
parse(data, id, "project_versions")?,
),
};
Ok(value)
}
pub(crate) async fn upsert_many(
items: &[Self],
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let items = serde_json::to_string(items)?;
let items = items
.iter()
.map(|item| {
let data = item
.data
.as_ref()
.map(|value| value.to_json_value())
.transpose()?;
Ok(serde_json::json!({
"id": item.id,
"data_type": item.type_.as_str(),
"alias": item.alias,
"data": data,
"expires": item.expires,
}))
})
.collect::<crate::Result<Vec<_>>>()?;
let items = serde_json::to_string(&items)?;
sqlx::query!(
"
@@ -1463,6 +1974,85 @@ impl CachedEntry {
Ok(())
}
/// Store modpack file hashes in cache
pub async fn cache_modpack_files(
version_id: &str,
file_hashes: Vec<String>,
project_ids: Vec<String>,
pool: &SqlitePool,
) -> crate::Result<()> {
let data = CachedModpackFiles {
version_id: version_id.to_string(),
file_hashes,
project_ids,
};
let entry = CachedEntry {
id: version_id.to_string(),
alias: None,
expires: Utc::now().timestamp()
+ CacheValueType::ModpackFiles.expiry(),
type_: CacheValueType::ModpackFiles,
data: Some(CacheValue::ModpackFiles(data)),
};
Self::upsert_many(&[entry], pool).await
}
/// Get modpack file hashes from cache
pub async fn get_modpack_files(
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<CachedModpackFiles>> {
let entry = Self::get(
CacheValueType::ModpackFiles,
version_id,
None,
pool,
fetch_semaphore,
)
.await?;
if let Some(CachedEntry {
data: Some(CacheValue::ModpackFiles(files)),
..
}) = entry
{
return Ok(Some(files));
}
Ok(None)
}
/// Get versions for a project (without changelogs for fast loading)
#[tracing::instrument(skip(pool, fetch_semaphore))]
pub async fn get_project_versions(
project_id: &str,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<Vec<Version>>> {
let entry = Self::get(
CacheValueType::ProjectVersions,
project_id,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
if let Some(CachedEntry {
data: Some(CacheValue::ProjectVersions(pv)),
..
}) = entry
{
return Ok(Some(pv.versions));
}
Ok(None)
}
}
pub async fn cache_file_hash(
@@ -1481,10 +2071,30 @@ pub async fn cache_file_hash(
sha1_async(bytes).await?
};
cache_file_hash_metadata(
profile_path,
path,
size as u64,
hash,
project_type,
exec,
)
.await
}
pub async fn cache_file_hash_metadata(
profile_path: &str,
path: &str,
size: u64,
hash: String,
project_type: Option<ProjectType>,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
// Streamed extraction already computed these values, so avoid buffering the file just to cache them.
CachedEntry::upsert_many(
&[CacheValue::FileHash(CachedFileHash {
path: format!("{profile_path}/{path}"),
size: size as u64,
size,
hash,
project_type,
})
+11 -128
View File
@@ -1,34 +1,20 @@
use crate::ErrorKind;
use crate::state::DirectoryInfo;
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions,
};
use sqlx::{Pool, Sqlite};
use std::collections::HashMap;
use std::str::FromStr;
use std::time::Duration;
use tokio::time::Instant;
pub(crate) async fn connect() -> crate::Result<Pool<Sqlite>> {
let pool = connect_without_migrate().await?;
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
sqlx::migrate!().run(&pool).await?;
if let Err(err) = stale_data_cleanup(&pool).await {
tracing::warn!(
"Failed to clean up stale data from state database: {err}"
);
}
Ok(pool)
}
// This code is modified by AstralRinth
// Implement SQLite3 connection without SQLx migrations.
async fn connect_without_migrate() -> crate::Result<Pool<Sqlite>> {
let settings_dir = DirectoryInfo::get_initial_settings_dir().ok_or(
ErrorKind::FSError("Could not find valid config dir".to_string()),
)?;
pub(crate) async fn connect(
app_identifier: &str,
) -> crate::Result<Pool<Sqlite>> {
let settings_dir = DirectoryInfo::initial_settings_dir_path(app_identifier)
.ok_or(crate::ErrorKind::FSError(
"Could not find valid config dir".to_string(),
))?;
if !settings_dir.exists() {
crate::util::io::create_dir_all(&settings_dir).await?;
@@ -48,6 +34,9 @@ async fn connect_without_migrate() -> crate::Result<Pool<Sqlite>> {
.connect_with(conn_options)
.await?;
MIGRATOR.run(&pool).await?;
stale_data_cleanup(&pool).await?;
Ok(pool)
}
@@ -57,11 +46,6 @@ async fn connect_without_migrate() -> crate::Result<Pool<Sqlite>> {
async fn stale_data_cleanup(pool: &Pool<Sqlite>) -> crate::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query!(
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
)
@@ -72,104 +56,3 @@ async fn stale_data_cleanup(pool: &Pool<Sqlite>) -> crate::Result<()> {
Ok(())
}
/*
// This code is modified by AstralRinth
Problem files, view detailed information in .gitattributes:
/packages/app-lib/migrations/20240711194701_init.sql !eol
CRLF -> 4c47e326f16f2b1efca548076ce638d4c90dd610172fe48c47d6de9bc46ef1c5abeadfdea05041ddd72c3819fa10c040
LF -> e973512979feac07e415405291eefafc1ef0bd89454958ad66f5452c381db8679c20ffadab55194ecf6ba8ec4ca2db21
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
CRLF -> C8FD2EFE72E66E394732599EA8D93CE1ED337F098697B3ADAD40DD37CC6367893E199A8D7113B44A3D0FFB537692F91D
LF -> 5b53534a7ffd74eebede234222be47e1d37bd0cc5fee4475212491b0c0379c16e3079e08eee0af959b1fa20835eeb206
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
CRLF -> C0DE804F171B5530010EDAE087A6E75645C0E90177E28365F935C9FDD9A5C68E24850B8C1498E386A379D525D520BC57
LF -> c0de804f171b5530010edae087a6e75645c0e90177e28365f935c9fdd9a5c68e24850b8c1498e386a379d525d520bc57
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol
CRLF -> 6B6F097E5BB45A397C96C3F1DC9C2A18433564E81DB264FE08A4775198CCEAC03C9E63C3605994ECB19C281C37D8F6AE
LF -> c17542cb989a0466153e695bfa4717f8970feee185ca186a2caa1f2f6c5d4adb990ab97c26cacfbbe09c39ac81551704
*/
pub(crate) async fn apply_migration_fix(eol: &str) -> crate::Result<bool> {
let started = Instant::now();
// Create connection to the database without migrations
let pool = connect_without_migrate().await?;
tracing::info!(
"⚙️ Patching Modrinth corrupted migration checksums using EOL standard: {eol}"
);
// validate EOL input
if eol != "lf" && eol != "crlf" {
return Ok(false);
}
// [eol][version] -> checksum
let checksums: HashMap<(&str, &str), &str> = HashMap::from([
(
("lf", "20240711194701"),
"e973512979feac07e415405291eefafc1ef0bd89454958ad66f5452c381db8679c20ffadab55194ecf6ba8ec4ca2db21",
),
(
("crlf", "20240711194701"),
"4c47e326f16f2b1efca548076ce638d4c90dd610172fe48c47d6de9bc46ef1c5abeadfdea05041ddd72c3819fa10c040",
),
(
("lf", "20240813205023"),
"5b53534a7ffd74eebede234222be47e1d37bd0cc5fee4475212491b0c0379c16e3079e08eee0af959b1fa20835eeb206",
),
(
("crlf", "20240813205023"),
"C8FD2EFE72E66E394732599EA8D93CE1ED337F098697B3ADAD40DD37CC6367893E199A8D7113B44A3D0FFB537692F91D",
),
(
("lf", "20240930001852"),
"c0de804f171b5530010edae087a6e75645c0e90177e28365f935c9fdd9a5c68e24850b8c1498e386a379d525d520bc57",
),
(
("crlf", "20240930001852"),
"C0DE804F171B5530010EDAE087A6E75645C0E90177E28365F935C9FDD9A5C68E24850B8C1498E386A379D525D520BC57",
),
(
("lf", "20241222013857"),
"c17542cb989a0466153e695bfa4717f8970feee185ca186a2caa1f2f6c5d4adb990ab97c26cacfbbe09c39ac81551704",
),
(
("crlf", "20241222013857"),
"6B6F097E5BB45A397C96C3F1DC9C2A18433564E81DB264FE08A4775198CCEAC03C9E63C3605994ECB19C281C37D8F6AE",
),
]);
let mut changed = false;
for ((eol_key, version), checksum) in checksums.iter() {
if *eol_key != eol {
continue;
}
tracing::info!(
"⏳ Patching checksum for migration {version} ({})",
eol.to_uppercase()
);
let result = sqlx::query(&format!(
r#"
UPDATE "_sqlx_migrations"
SET checksum = X'{checksum}'
WHERE version = '{version}';
"#
))
.execute(&pool)
.await?;
if result.rows_affected() > 0 {
changed = true;
}
}
tracing::info!(
"✅ Checksum patching completed in {:.2?} (changes: {})",
started.elapsed(),
changed
);
Ok(changed)
}
+38 -26
View File
@@ -1,6 +1,7 @@
//! Theseus directory information
use crate::LoadingBarType;
use crate::event::emit::{emit_loading, init_loading};
use crate::state::LAUNCHER_STATE;
use crate::state::{JavaVersion, Profile, Settings};
use crate::util::fetch::IoSemaphore;
use dashmap::DashSet;
@@ -17,30 +18,35 @@ pub const METADATA_FOLDER_NAME: &str = "meta";
pub struct DirectoryInfo {
pub settings_dir: PathBuf, // Base settings directory- app database
pub config_dir: PathBuf, // Base config directory- instances, minecraft downloads, etc. Changeable as a setting.
pub app_identifier: String,
}
impl DirectoryInfo {
pub fn global_handle_if_ready() -> Option<&'static Self> {
LAUNCHER_STATE.get().map(|x| &x.directories)
}
pub fn get_initial_settings_dir(&self) -> Option<PathBuf> {
Self::initial_settings_dir_path(&self.app_identifier)
}
// Get the settings directory
// init() is not needed for this function
// This code is modified by AstralRinth
pub fn get_initial_settings_dir() -> Option<PathBuf> {
Self::env_path("THESEUS_CONFIG_DIR").or_else(|| {
if std::env::current_dir().ok()?.join("portable.txt").exists() {
Some(std::path::Path::new("UserData").to_path_buf())
} else {
Some(dirs::data_dir()?.join("AstralRinthApp"))
}
})
pub fn initial_settings_dir_path(app_identifier: &str) -> Option<PathBuf> {
Self::env_path("THESEUS_CONFIG_DIR")
.or_else(|| Some(dirs::data_dir()?.join(app_identifier)))
}
/// Get all paths needed for Theseus to operate properly
#[tracing::instrument]
pub async fn init(config_dir: Option<String>) -> crate::Result<Self> {
let settings_dir = Self::get_initial_settings_dir().ok_or(
crate::ErrorKind::FSError(
pub async fn init(
config_dir: Option<String>,
app_identifier: &str,
) -> crate::Result<Self> {
let settings_dir = Self::initial_settings_dir_path(app_identifier)
.ok_or(crate::ErrorKind::FSError(
"Could not find valid settings dir".to_string(),
),
)?;
))?;
fs::create_dir_all(&settings_dir).await.map_err(|err| {
crate::ErrorKind::FSError(format!(
@@ -54,6 +60,7 @@ impl DirectoryInfo {
Ok(Self {
settings_dir,
config_dir,
app_identifier: app_identifier.to_owned(),
})
}
@@ -160,8 +167,14 @@ impl DirectoryInfo {
}
#[inline]
pub fn launcher_logs_dir() -> Option<PathBuf> {
Self::get_initial_settings_dir()
pub fn launcher_logs_dir(&self) -> Option<PathBuf> {
self.get_initial_settings_dir()
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
}
#[inline]
pub fn launcher_logs_dir_path(app_identifier: &str) -> Option<PathBuf> {
Self::initial_settings_dir_path(app_identifier)
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
}
@@ -182,15 +195,15 @@ impl DirectoryInfo {
settings: &mut Settings,
exec: E,
io_semaphore: &IoSemaphore,
app_identifier: &str,
) -> crate::Result<()>
where
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
{
let app_dir = DirectoryInfo::get_initial_settings_dir().ok_or(
crate::ErrorKind::FSError(
let app_dir = DirectoryInfo::initial_settings_dir_path(app_identifier)
.ok_or(crate::ErrorKind::FSError(
"Could not find valid config dir".to_string(),
),
)?;
))?;
if let Some(ref prev_custom_dir) = settings.prev_custom_dir {
let prev_dir = PathBuf::from(prev_custom_dir);
@@ -200,7 +213,7 @@ impl DirectoryInfo {
.as_ref()
.map_or_else(|| app_dir.clone(), PathBuf::from);
async fn is_dir_writeable(
async fn is_dir_writable(
new_config_dir: &Path,
) -> crate::Result<bool> {
let temp_path = new_config_dir.join(".tmp");
@@ -246,8 +259,8 @@ impl DirectoryInfo {
)
.await?;
if !is_dir_writeable(&move_dir).await? {
return Err(crate::ErrorKind::DirectoryMoveError(format!("Cannot move directory to {}: directory is not writeable", move_dir.display())).into());
if !is_dir_writable(&move_dir).await? {
return Err(crate::ErrorKind::DirectoryMoveError(format!("Cannot move directory to {}: directory is not writable", move_dir.display())).into());
}
const MOVE_DIRS: &[&str] = &[
@@ -351,10 +364,9 @@ impl DirectoryInfo {
.map_err(|e| {
crate::Error::from(crate::ErrorKind::DirectoryMoveError(
format!(
"Failed to move directory from {} to {}: {}",
"Failed to move directory from {} to {}: {e:?}",
x.old.display(),
x.new.display(),
e
),
))
})?;
@@ -408,7 +420,7 @@ impl DirectoryInfo {
io_semaphore,
)
.await.map_err(|e| { crate::Error::from(
crate::ErrorKind::DirectoryMoveError(format!("Failed to move directory from {} to {}: {}", x.old.display(), x.new.display(), e)))
crate::ErrorKind::DirectoryMoveError(format!("Failed to move directory from {} to {}: {e:?}", x.old.display(), x.new.display())))
})?;
let _ = emit_loading(
+41 -7
View File
@@ -1,7 +1,7 @@
use crate::ErrorKind;
use crate::data::ModrinthCredentials;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
use crate::event::emit::{emit_friend, emit_notification};
use crate::state::tunnel::InternalTunnelSocket;
use crate::state::{ProcessManager, Profile, TunnelSocket};
use crate::util::fetch::{FetchSemaphore, fetch_advanced, fetch_json};
@@ -22,6 +22,7 @@ use futures::{SinkExt, StreamExt};
use reqwest::Method;
use reqwest::header::HeaderValue;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::net::SocketAddr;
use std::ops::Deref;
use std::sync::Arc;
@@ -120,16 +121,34 @@ impl FriendsSocket {
Ok(msg) => {
let server_message = match msg {
Message::Text(text) => {
ServerToClientMessage::deserialize(
match ServerToClientMessage::deserialize(
Either::Left(&text),
)
.ok()
) {
Ok(message) => Some(message),
Err(_) => {
if let Ok(notification) =
serde_json::from_str::<Value>(&text)
{
let _ = Self::handle_notification(notification).await;
}
None
}
}
}
Message::Binary(bytes) => {
ServerToClientMessage::deserialize(
match ServerToClientMessage::deserialize(
Either::Right(&bytes),
)
.ok()
) {
Ok(message) => Some(message),
Err(_) => {
if let Ok(notification) =
serde_json::from_slice::<Value>(&bytes)
{
let _ = Self::handle_notification(notification).await;
}
None
}
}
}
Message::Ping(bytes) => {
if let Some(write) = write_handle
@@ -224,6 +243,19 @@ impl FriendsSocket {
Ok(())
}
async fn handle_notification(notification: Value) -> crate::Result<()> {
if notification
.get("body")
.and_then(|body| body.get("type"))
.and_then(Value::as_str)
.is_some()
{
emit_notification(notification).await?;
}
Ok(())
}
#[tracing::instrument(skip_all)]
pub async fn socket_loop() -> crate::Result<()> {
let state = crate::State::get().await?;
@@ -326,6 +358,7 @@ impl FriendsSocket {
None,
None,
None,
None,
semaphore,
exec,
)
@@ -358,6 +391,7 @@ impl FriendsSocket {
None,
None,
None,
None,
semaphore,
exec,
)
+71 -61
View File
@@ -59,31 +59,28 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
.nth(1)
.map(|x| x.as_os_str());
if first_file_name
.filter(|x| *x == "crash-reports")
.is_some()
.as_ref()
.is_some_and(|x| *x == "crash-reports")
&& e.path
.extension()
.filter(|x| *x == "txt")
.is_some()
.as_ref()
.is_some_and(|x| *x == "txt")
{
crash_task(profile_path_str);
} else if !visited_profiles.contains(&profile_path)
{
let event = if first_file_name
.filter(|x| *x == "servers.dat")
.is_some()
.as_ref()
.is_some_and(|x| *x == "servers.dat")
{
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()
{
} else if first_file_name.as_ref().is_some_and(|x| {
*x == "saves"
&& e.path
.file_name()
.as_ref()
.is_some_and(|x| *x == "level.dat")
}) {
tracing::info!(
"World updated: {}",
e.path.display()
@@ -113,8 +110,8 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
}
Some(ProfilePayloadType::WorldUpdated { world })
} else if first_file_name
.filter(|x| *x == "saves")
.is_none()
.as_ref()
.is_none_or(|x| *x != "saves")
{
Some(ProfilePayloadType::Synced)
} else {
@@ -147,18 +144,19 @@ pub(crate) async fn watch_profiles_init(
watcher: &FileWatcher,
dirs: &DirectoryInfo,
) {
if let Ok(profiles_dir) = std::fs::read_dir(dirs.profiles_dir()) {
for profile_dir in profiles_dir {
if let Ok(file_name) = profile_dir.map(|x| x.file_name())
&& let Some(file_name) = file_name.to_str()
{
if file_name.starts_with(".DS_Store") {
continue;
};
let Ok(mut profiles_dir) = tokio::fs::read_dir(dirs.profiles_dir()).await
else {
return;
};
watch_profile(file_name, watcher, dirs).await;
}
while let Ok(Some(profile_dir)) = profiles_dir.next_entry().await {
let file_name = profile_dir.file_name();
let file_name = file_name.to_string_lossy();
if file_name.starts_with(".DS_Store") {
continue;
}
watch_profile(&file_name, watcher, dirs).await;
}
}
@@ -169,46 +167,58 @@ pub(crate) async fn watch_profile(
) {
let profile_path = dirs.profiles_dir().join(profile_path);
if profile_path.exists() && profile_path.is_dir() {
for sub_path in ProjectType::iterator()
.map(|x| x.get_folder())
.chain(["crash-reports", "saves"])
{
let full_path = profile_path.join(sub_path);
let Ok(metadata) = tokio::fs::metadata(&profile_path).await else {
return;
};
if !full_path.exists()
&& !full_path.is_symlink()
&& !sub_path.contains(".")
&& let Err(e) =
crate::util::io::create_dir_all(&full_path).await
{
tracing::error!(
"Failed to create directory for watcher {full_path:?}: {e}"
);
return;
}
if !metadata.is_dir() {
return;
}
let mut watcher = watcher.write().await;
if let Err(e) = watcher
.watcher()
.watch(&full_path, RecursiveMode::Recursive)
{
tracing::error!(
"Failed to watch directory for watcher {full_path:?}: {e}"
);
return;
}
}
let mut to_watch = Vec::new();
for sub_path in ProjectType::iterator()
.map(|x| x.get_folder())
.chain(["crash-reports", "saves"])
{
let full_path = profile_path.join(sub_path);
let mut watcher = watcher.write().await;
if let Err(e) = watcher
.watcher()
.watch(&profile_path, RecursiveMode::NonRecursive)
let meta = tokio::fs::symlink_metadata(&full_path).await;
let exists = meta.is_ok();
let is_symlink = meta.ok().is_some_and(|m| m.file_type().is_symlink());
if !exists
&& !is_symlink
&& !sub_path.contains(".")
&& let Err(e) = crate::util::io::create_dir_all(&full_path).await
{
tracing::error!(
"Failed to watch root profile directory for watcher {profile_path:?}: {e}"
"Failed to create directory for watcher {full_path:?}: {e}"
);
return;
}
to_watch.push(full_path);
}
let mut watcher = watcher.write().await;
for full_path in &to_watch {
if let Err(e) =
watcher.watcher().watch(full_path, RecursiveMode::Recursive)
{
tracing::error!(
"Failed to watch directory for watcher {full_path:?}: {e}"
);
return;
}
}
if let Err(e) = watcher
.watcher()
.watch(&profile_path, RecursiveMode::NonRecursive)
{
tracing::error!(
"Failed to watch root profile directory for watcher {profile_path:?}: {e}"
);
}
}
@@ -0,0 +1,987 @@
//! # Content API
//!
//! ## Data Flow
//!
//! 1. Frontend calls `get_content_items(profile_path)`
//! 2. If profile is linked to a modpack:
//! - Fetch modpack file hashes from cache (populated during installation)
//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
//! - Filter out files that belong to the modpack before update lookup
//! 3. For remaining files, fetch project/version/owner metadata in parallel
//! 4. Return sorted `ContentItem` list
//!
//! ## Caching
//!
//! Modpack file hashes are cached in `CacheValueType::ModpackFiles`
//! during modpack installation. The cache never expires (version_id is
//! immutable), so re-download is only needed if cache was cleared or
//! profile predates this caching mechanism.
use crate::pack::install_from::{PackFileHash, PackFormat};
use crate::state::profiles::{Profile, ProfileFile, ProjectType};
use crate::state::{CacheBehaviour, CachedEntry, ReleaseChannel};
use crate::util::fetch::{
DownloadMeta, DownloadReason, FetchSemaphore, fetch_mirrors, sha1_async,
};
use async_zip::base::read::seek::ZipFileReader;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashSet;
use std::io::Cursor;
/// Content item with rich metadata for frontend display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItem {
/// Display file name.
pub file_name: String,
/// Relative path to the file within the profile
pub file_path: String,
/// SHA1 hash of file content. Stable across renames, but not unique when
/// duplicate files have identical contents.
pub id: String,
/// File size in bytes
pub size: u64,
/// Whether the file is enabled (not .disabled)
pub enabled: bool,
/// Type of project (mod, resourcepack, etc.)
pub project_type: ProjectType,
/// Modrinth project info if recognized
pub project: Option<ContentItemProject>,
/// Version info if recognized
pub version: Option<ContentItemVersion>,
/// Owner info (organization or user)
pub owner: Option<ContentItemOwner>,
/// Whether an update is available
pub has_update: bool,
/// The recommended version ID to update to (if has_update is true)
pub update_version_id: Option<String>,
/// When the file was added to the instance (file modification time)
pub date_added: Option<String>,
}
/// Project information for content item display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItemProject {
pub id: String,
pub slug: Option<String>,
pub title: String,
pub icon_url: Option<String>,
}
/// Version information for content item display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItemVersion {
pub id: String,
pub version_number: String,
pub file_name: String,
pub date_published: Option<String>,
}
/// Owner information for content item display
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContentItemOwner {
pub id: String,
pub name: String,
pub avatar_url: Option<String>,
#[serde(rename = "type")]
pub owner_type: OwnerType,
}
/// Type of content owner
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OwnerType {
User,
Organization,
}
use crate::state::cache::{Dependency, Organization, TeamMember};
use crate::state::{Project, Version};
/// Full linked modpack information including owner and update status
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LinkedModpackInfo {
pub project: Project,
pub version: Version,
pub owner: Option<ContentItemOwner>,
/// Whether an update is available for this modpack
pub has_update: bool,
/// The version ID to update to (if has_update is true)
pub update_version_id: Option<String>,
/// The full version info for the update (if has_update is true)
pub update_version: Option<Version>,
}
/// Get linked modpack info including project, version, owner, and update status.
/// Returns None if the profile is not linked to a modpack.
pub async fn get_linked_modpack_info(
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<LinkedModpackInfo>> {
let Some(linked_data) = &profile.linked_data else {
return Ok(None);
};
// Vanilla server projects have linked_data with an empty version_id
if linked_data.version_id.is_empty() {
return Ok(None);
}
// Fetch project, version, and all project versions in parallel
let (project, version, all_versions) = tokio::try_join!(
CachedEntry::get_project(
&linked_data.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_version(
&linked_data.version_id,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_project_versions(
&linked_data.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
)?;
let version = version.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Linked modpack version {} not found",
linked_data.version_id
))
})?;
// For server instances, linked_data.project_id is the server project,
// but the version may belong to a different (modpack) project.
// If so, fetch the actual modpack project for display and update checking.
let (project, all_versions) =
if version.project_id != linked_data.project_id {
let (modpack_project, modpack_versions) = tokio::try_join!(
CachedEntry::get_project(
&version.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_project_versions(
&version.project_id,
cache_behaviour,
pool,
fetch_semaphore,
),
)?;
(modpack_project.or(project), modpack_versions)
} else {
(project, all_versions)
};
let project = project.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Linked modpack project {} not found",
linked_data.project_id
))
})?;
// Resolve owner - prefer organization, fall back to team owner
let owner = if let Some(org_id) = &project.organization {
let org = CachedEntry::get_organization(
org_id,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
org.map(|o| ContentItemOwner {
id: o.id,
name: o.name,
avatar_url: o.icon_url,
owner_type: OwnerType::Organization,
})
} else {
let team = CachedEntry::get_team(
&project.team,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
team.and_then(|t| {
t.into_iter()
.find(|m| m.is_owner)
.map(|m| ContentItemOwner {
id: m.user.id,
name: m.user.username,
avatar_url: m.user.avatar_url,
owner_type: OwnerType::User,
})
})
};
// Check for updates
let (has_update, update_version_id, update_version) = check_modpack_update(
&linked_data.version_id,
&version,
all_versions,
profile.preferred_update_channel,
);
Ok(Some(LinkedModpackInfo {
project,
version,
owner,
has_update,
update_version_id,
update_version,
}))
}
/// Check if a newer version exists for the linked modpack.
/// Returns (has_update, update_version_id, update_version).
fn check_modpack_update(
installed_version_id: &str,
installed_version: &Version,
all_versions: Option<Vec<Version>>,
preferred_update_channel: ReleaseChannel,
) -> (bool, Option<String>, Option<Version>) {
let Some(versions) = all_versions else {
return (false, None, None);
};
let installed_channel =
ReleaseChannel::from_version_type(&installed_version.version_type);
let effective_channel =
preferred_update_channel.least_stable(installed_channel);
for version_types in effective_channel.version_type_fallbacks() {
if !versions
.iter()
.any(|v| version_types.contains(&v.version_type.as_str()))
{
continue;
}
let mut newer_versions: Vec<&Version> = versions
.iter()
.filter(|v| {
v.id != installed_version_id
&& v.date_published > installed_version.date_published
&& version_types.contains(&v.version_type.as_str())
})
.collect();
// Sort by date_published descending (newest first)
newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
if let Some(newest) = newer_versions.first() {
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
return (false, None, None);
}
(false, None, None)
}
/// Get content items with rich metadata, filtered to exclude modpack content.
/// Returns only user-added content (not part of the linked modpack).
pub async fn get_content_items(
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let modpack_ids = if let Some(ref linked_data) = profile.linked_data {
if linked_data.version_id.is_empty() {
None
} else {
tracing::info!(
"Fetching modpack identifiers for version_id={}, project_id={}",
linked_data.version_id,
linked_data.project_id
);
match get_modpack_identifiers(
&linked_data.version_id,
profile,
pool,
fetch_semaphore,
)
.await
{
Ok(ids) => {
tracing::info!(
"Got {} modpack file hashes, {} project IDs for version {}",
ids.hashes.len(),
ids.project_ids.len(),
linked_data.version_id
);
Some(ids)
}
Err(e) => {
tracing::error!(
"Failed to fetch modpack identifiers for version {}: {}",
linked_data.version_id,
e
);
None
}
}
}
} else {
None
};
let user_files: Vec<(String, ProfileFile)> = if let Some(ids) = &modpack_ids
{
let filtered_files = profile
.get_projects_excluding_modpack_files(
&ids.hashes,
&ids.project_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
filtered_files.into_iter().collect()
} else {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
all_files.into_iter().collect()
};
let content_items = profile_files_to_content_items(
&profile.path,
&user_files,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
Ok(content_items)
}
/// Pre-fetched metadata for projects, versions, teams, and organizations.
struct ResolvedMetadata {
projects: Vec<Project>,
versions: Vec<Version>,
teams: Vec<Vec<TeamMember>>,
organizations: Vec<Organization>,
}
/// Fetch project, version, team, and organization metadata in parallel batches.
async fn resolve_metadata(
project_ids: &HashSet<String>,
version_ids: &HashSet<String>,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<ResolvedMetadata> {
let project_ids_vec: Vec<&str> =
project_ids.iter().map(|s| s.as_str()).collect();
let version_ids_vec: Vec<&str> =
version_ids.iter().map(|s| s.as_str()).collect();
let (projects, versions) =
if !project_ids.is_empty() || !version_ids.is_empty() {
tokio::try_join!(
async {
if project_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_project_many(
&project_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
},
async {
if version_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_version_many(
&version_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
}
)?
} else {
(Vec::new(), Vec::new())
};
let team_ids: HashSet<String> =
projects.iter().map(|p| p.team.clone()).collect();
let org_ids: HashSet<String> = projects
.iter()
.filter_map(|p| p.organization.clone())
.collect();
let team_ids_vec: Vec<&str> = team_ids.iter().map(|s| s.as_str()).collect();
let org_ids_vec: Vec<&str> = org_ids.iter().map(|s| s.as_str()).collect();
let (teams, organizations) = if !team_ids.is_empty() || !org_ids.is_empty()
{
tokio::try_join!(
async {
if team_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_team_many(
&team_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
},
async {
if org_ids.is_empty() {
Ok(Vec::new())
} else {
CachedEntry::get_organization_many(
&org_ids_vec,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
}
)?
} else {
(Vec::new(), Vec::new())
};
Ok(ResolvedMetadata {
projects,
versions,
teams,
organizations,
})
}
/// Shared helper: convert profile files to ContentItems with rich metadata.
/// Used by both `get_content_items` (user-added files) and
/// `get_linked_modpack_content` (modpack-bundled files).
async fn profile_files_to_content_items(
profile_path: &str,
files: &[(String, ProfileFile)],
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let project_ids: HashSet<String> = files
.iter()
.filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.project_id.clone()))
.collect();
let version_ids: HashSet<String> = files
.iter()
.filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.version_id.clone()))
.collect();
let meta = resolve_metadata(
&project_ids,
&version_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let profile_base_path =
crate::api::profile::get_full_path(profile_path).await?;
// Batch-read file modification times off the main async runtime
let paths: Vec<std::path::PathBuf> = files
.iter()
.map(|(path, _)| profile_base_path.join(path))
.collect();
let modification_times: Vec<Option<String>> =
tokio::task::spawn_blocking(move || {
paths
.iter()
.map(|path| {
std::fs::metadata(path).and_then(|m| m.modified()).ok().map(
|t| {
chrono::DateTime::<chrono::Utc>::from(t)
.to_rfc3339()
},
)
})
.collect()
})
.await?;
let mut items: Vec<ContentItem> = files
.iter()
.enumerate()
.map(|(i, (path, file))| {
let project = file.metadata.as_ref().and_then(|m| {
meta.projects.iter().find(|p| p.id == m.project_id)
});
let version = file.metadata.as_ref().and_then(|m| {
meta.versions.iter().find(|v| v.id == m.version_id)
});
let owner = project.and_then(|p| {
resolve_owner(p, &meta.teams, &meta.organizations)
});
ContentItem {
file_name: file.file_name.clone(),
file_path: path.clone(),
id: file.hash.clone(),
size: file.size,
enabled: !file.file_name.ends_with(".disabled"),
project_type: file.project_type,
project: project.map(|p| ContentItemProject {
id: p.id.clone(),
slug: p.slug.clone(),
title: p.title.clone(),
icon_url: p.icon_url.clone(),
}),
version: version.map(|v| ContentItemVersion {
id: v.id.clone(),
version_number: v.version_number.clone(),
file_name: file.file_name.clone(),
date_published: Some(v.date_published.to_rfc3339()),
}),
owner,
has_update: file.update_version_id.is_some(),
update_version_id: file.update_version_id.clone(),
date_added: modification_times[i].clone(),
}
})
.collect();
items.sort_by(|a, b| {
let name_a = a
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&a.file_name);
let name_b = b
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&b.file_name);
name_a
.to_lowercase()
.cmp(&name_b.to_lowercase())
.then_with(|| a.file_name.cmp(&b.file_name))
});
Ok(items)
}
/// Resolve the owner of a project from pre-fetched teams and organizations.
fn resolve_owner(
project: &Project,
teams: &[Vec<TeamMember>],
organizations: &[Organization],
) -> Option<ContentItemOwner> {
if let Some(org_id) = &project.organization {
organizations.iter().find(|o| &o.id == org_id).map(|o| {
ContentItemOwner {
id: o.id.clone(),
name: o.name.clone(),
avatar_url: o.icon_url.clone(),
owner_type: OwnerType::Organization,
}
})
} else {
teams
.iter()
.find(|t| t.first().is_some_and(|m| m.team_id == project.team))
.and_then(|t| t.iter().find(|m| m.is_owner))
.map(|m| ContentItemOwner {
id: m.user.id.clone(),
name: m.user.username.clone(),
avatar_url: m.user.avatar_url.clone(),
owner_type: OwnerType::User,
})
}
}
/// Get content items that are part of the linked modpack (not user-added).
/// Returns modpack-bundled files with full on-disk metadata (file_path, enabled, etc).
/// Returns empty vec if the profile is not linked to a modpack.
pub async fn get_linked_modpack_content(
profile: &Profile,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let Some(linked_data) = &profile.linked_data else {
return Ok(Vec::new());
};
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
let modpack_ids = match get_modpack_identifiers(
&linked_data.version_id,
profile,
pool,
fetch_semaphore,
)
.await
{
Ok(ids) => ids,
Err(e) => {
tracing::warn!("Failed to fetch modpack identifiers: {}", e);
return Ok(Vec::new());
}
};
// Inverse of get_content_items: keep only modpack-bundled files
let modpack_files: Vec<(String, ProfileFile)> = all_files
.into_iter()
.filter(|(_, file)| modpack_ids.is_modpack_file(file))
.collect();
profile_files_to_content_items(
&profile.path,
&modpack_files,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
}
/// Convert a list of dependencies into ContentItems with rich metadata.
/// Fetches project, version, and owner info for each dependency.
pub async fn dependencies_to_content_items(
dependencies: &[Dependency],
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let project_ids: HashSet<String> = dependencies
.iter()
.filter_map(|d| d.project_id.clone())
.collect();
if project_ids.is_empty() {
return Ok(Vec::new());
}
let version_ids: HashSet<String> = dependencies
.iter()
.filter_map(|d| d.version_id.clone())
.collect();
let meta = resolve_metadata(
&project_ids,
&version_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let mut items: Vec<ContentItem> = dependencies
.iter()
.filter_map(|dep| {
let project_id = dep.project_id.as_ref()?;
let project = meta.projects.iter().find(|p| &p.id == project_id)?;
let version = dep
.version_id
.as_ref()
.and_then(|vid| meta.versions.iter().find(|v| &v.id == vid));
let owner =
resolve_owner(project, &meta.teams, &meta.organizations);
let project_type = match project.project_type.as_str() {
"mod" => ProjectType::Mod,
"resourcepack" => ProjectType::ResourcePack,
"shader" => ProjectType::ShaderPack,
"datapack" => ProjectType::DataPack,
_ => ProjectType::Mod,
};
Some(ContentItem {
file_name: version
.and_then(|v| v.files.first())
.map(|f| f.filename.clone())
.unwrap_or_else(|| {
format!(
"{}.jar",
project.slug.as_deref().unwrap_or(&project.id)
)
}),
file_path: String::new(),
id: String::new(),
size: version
.and_then(|v| v.files.first())
.map(|f| f.size as u64)
.unwrap_or(0),
enabled: true,
project_type,
project: Some(ContentItemProject {
id: project.id.clone(),
slug: project.slug.clone(),
title: project.title.clone(),
icon_url: project.icon_url.clone(),
}),
version: version.map(|v| ContentItemVersion {
id: v.id.clone(),
version_number: v.version_number.clone(),
file_name: v
.files
.first()
.map(|f| f.filename.clone())
.unwrap_or_default(),
date_published: Some(v.date_published.to_rfc3339()),
}),
owner,
has_update: false,
update_version_id: None,
date_added: None,
})
})
.collect();
items.sort_by(|a, b| {
let name_a = a
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&a.file_name);
let name_b = b
.project
.as_ref()
.map(|p| p.title.as_str())
.unwrap_or(&b.file_name);
name_a
.to_lowercase()
.cmp(&name_b.to_lowercase())
.then_with(|| a.file_name.cmp(&b.file_name))
});
Ok(items)
}
/// Modpack file identifiers: hashes for exact matching and project IDs for
/// matching files whose version was switched by the user.
struct ModpackIdentifiers {
hashes: HashSet<String>,
project_ids: HashSet<String>,
}
impl ModpackIdentifiers {
fn is_modpack_file(&self, file: &ProfileFile) -> bool {
self.hashes.contains(&file.hash)
|| file
.metadata
.as_ref()
.is_some_and(|m| self.project_ids.contains(&m.project_id))
}
}
/// Gets SHA1 hashes and project IDs of all files in a modpack version.
/// Checks cache first, falls back to downloading mrpack if not cached.
async fn get_modpack_identifiers(
version_id: &str,
profile: &crate::state::Profile,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<ModpackIdentifiers> {
if let Some(cached) =
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
.await?
{
if !cached.project_ids.is_empty() {
tracing::info!(
"Cache hit: {} modpack file hashes, {} project IDs for version {}",
cached.file_hashes.len(),
cached.project_ids.len(),
version_id
);
return Ok(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: cached.project_ids.into_iter().collect(),
});
}
// Legacy cache entry without project_ids — resolve via hash lookup API
tracing::info!(
"Legacy cache entry without project IDs, resolving via API for version {}",
version_id
);
let hash_refs: Vec<&str> =
cached.file_hashes.iter().map(|s| s.as_str()).collect();
let files =
CachedEntry::get_file_many(&hash_refs, None, pool, fetch_semaphore)
.await?;
let project_ids: Vec<String> = files
.iter()
.map(|f| f.project_id.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
// Update cache with project_ids for next time
CachedEntry::cache_modpack_files(
version_id,
cached.file_hashes.clone(),
project_ids.clone(),
pool,
)
.await?;
return Ok(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: project_ids.into_iter().collect(),
});
}
tracing::warn!(
"Cache miss: modpack files not cached, downloading mrpack for version {}",
version_id
);
let version =
CachedEntry::get_version(version_id, None, pool, fetch_semaphore)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack version {version_id} not found"
))
})?;
let primary_file = version
.files
.iter()
.find(|f| f.primary)
.or_else(|| version.files.first())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"No files found for modpack version {version_id}"
))
})?;
let download_meta = DownloadMeta {
reason: DownloadReason::Modpack,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
dependent_on: Some(version_id.to_string()),
};
let mrpack_bytes = fetch_mirrors(
&[&primary_file.url],
primary_file.hashes.get("sha1").map(|s| s.as_str()),
Some(&download_meta),
fetch_semaphore,
pool,
)
.await?;
let reader = Cursor::new(&mrpack_bytes);
let mut zip_reader =
ZipFileReader::with_tokio(reader).await.map_err(|_| {
crate::ErrorKind::InputError(
"Failed to read modpack zip".to_string(),
)
})?;
let manifest_idx = zip_reader
.file()
.entries()
.iter()
.position(|f| {
matches!(f.filename().as_str(), Ok("modrinth.index.json"))
})
.ok_or_else(|| {
crate::ErrorKind::InputError(
"No modrinth.index.json found in mrpack".to_string(),
)
})?;
let mut manifest = String::new();
let mut entry_reader = zip_reader.reader_with_entry(manifest_idx).await?;
entry_reader.read_to_string_checked(&mut manifest).await?;
let pack: PackFormat = serde_json::from_str(&manifest)?;
let mut hashes: Vec<String> = pack
.files
.iter()
.filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
.collect();
let project_ids: Vec<String> = pack
.files
.iter()
.filter_map(|f| {
f.downloads.iter().find_map(|url| {
let parts: Vec<&str> = url.split('/').collect();
let data_idx = parts.iter().position(|&p| p == "data")?;
parts.get(data_idx + 1).map(|s| s.to_string())
})
})
.collect::<HashSet<_>>()
.into_iter()
.collect();
// Also hash files from overrides folders (these aren't in modrinth.index.json)
let override_entries: Vec<usize> = zip_reader
.file()
.entries()
.iter()
.enumerate()
.filter_map(|(index, entry)| {
let filename = entry.filename().as_str().ok()?;
let is_override = (filename.starts_with("overrides/")
|| filename.starts_with("client-overrides/")
|| filename.starts_with("server-overrides/"))
&& !filename.ends_with('/');
is_override.then_some(index)
})
.collect();
for index in override_entries {
let mut file_bytes = Vec::new();
let mut entry_reader = zip_reader.reader_with_entry(index).await?;
entry_reader.read_to_end_checked(&mut file_bytes).await?;
let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?;
hashes.push(hash);
}
CachedEntry::cache_modpack_files(
version_id,
hashes.clone(),
project_ids.clone(),
pool,
)
.await?;
Ok(ModpackIdentifiers {
hashes: hashes.into_iter().collect(),
project_ids: project_ids.into_iter().collect(),
})
}
@@ -0,0 +1,4 @@
//! Instance-related modules for profile/instance management.
mod content;
pub use self::content::*;
@@ -7,7 +7,7 @@ use crate::state::{
Credentials, DefaultPage, DependencyType, DeviceToken, DeviceTokenKey,
DeviceTokenPair, FileType, Hooks, LauncherFeatureVersion, LinkedData,
MemorySettings, ModrinthCredentials, Profile, ProfileInstallStage,
TeamMember, Theme, VersionFile, WindowSize,
ReleaseChannel, TeamMember, Theme, VersionFile, WindowSize,
};
use crate::util::fetch::{IoSemaphore, read_json};
use chrono::{DateTime, Utc};
@@ -249,6 +249,9 @@ where
loaders: vec![
mod_loader.as_str().to_string(),
],
channel_policy: ReleaseChannel::Alpha
.key()
.to_string(),
update_version_id: update_version
.id
.clone(),
@@ -334,6 +337,7 @@ where
None
}),
preferred_update_channel: ReleaseChannel::Release,
created: profile.metadata.date_created,
modified: profile.metadata.date_modified,
last_played: profile.metadata.last_played,
@@ -624,7 +628,7 @@ impl From<LegacyModrinthVersion> for Version {
featured: value.featured,
name: value.name,
version_number: value.version_number,
changelog: value.changelog,
changelog: Some(value.changelog),
changelog_url: value.changelog_url,
date_published: value.date_published,
downloads: value.downloads,
+164 -91
View File
@@ -1,5 +1,5 @@
use crate::ErrorKind;
use crate::util::fetch::REQWEST_CLIENT;
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
use base64::Engine;
use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
use chrono::{DateTime, Duration, TimeZone, Utc};
@@ -20,7 +20,6 @@ use serde_json::json;
use sha2::Digest;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::future::Future;
use std::hash::{BuildHasherDefault, DefaultHasher};
use std::io;
@@ -301,6 +300,34 @@ pub(super) static PROFILE_CACHE: Mutex<
HashMap<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
const ONLINE_PROFILE_CACHE_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(60);
const ONLINE_PROFILE_LIVE_STATE_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(5);
const ONLINE_PROFILE_AUTH_ERROR_BACKOFF: std::time::Duration =
std::time::Duration::from_secs(60);
#[derive(Debug, Clone, Copy)]
enum OnlineProfileCacheIntent {
NormalRead,
LiveStateRead,
RefreshFromMojang,
}
impl OnlineProfileCacheIntent {
fn max_age(self) -> std::time::Duration {
match self {
Self::NormalRead => ONLINE_PROFILE_CACHE_MAX_AGE,
Self::LiveStateRead => ONLINE_PROFILE_LIVE_STATE_MAX_AGE,
Self::RefreshFromMojang => std::time::Duration::ZERO,
}
}
fn can_use_stale_on_fetch_error(self) -> bool {
matches!(self, Self::LiveStateRead)
}
}
impl Credentials {
/// Refreshes the authentication tokens for this user if they are expired, or
/// very close to expiration.
@@ -352,92 +379,133 @@ impl Credentials {
Ok(())
}
/// Returns online profile data when the cached copy is still recent enough.
#[tracing::instrument(skip(self))]
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
let mut profile_cache = PROFILE_CACHE.lock().await;
self.online_profile_with_cache_intent(
OnlineProfileCacheIntent::NormalRead,
)
.await
}
loop {
match profile_cache.entry(self.offline_profile.id) {
Entry::Occupied(entry) => {
match entry.get() {
ProfileCacheEntry::Hit(profile)
if profile.is_fresh() =>
{
return Some(Arc::clone(profile));
}
ProfileCacheEntry::Hit(_) => {
// The profile is stale, so remove it and try again
entry.remove();
continue;
}
// Auth errors must be handled with a backoff strategy because it
// has been experimentally found that Mojang quickly rate limits
// the profile data endpoint on repeated attempts with bad auth
ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token,
last_attempt,
} if &self.access_token != likely_expired_token
|| Instant::now()
.saturating_duration_since(*last_attempt)
> std::time::Duration::from_secs(60) =>
{
entry.remove();
continue;
}
ProfileCacheEntry::AuthErrorBackoff { .. } => {
return None;
}
/// Returns profile data recent enough for skin and cape state.
///
/// Reuses a profile read from the last few seconds so opening the skins page
/// does not send several identical Mojang requests.
#[tracing::instrument(skip(self))]
pub async fn online_profile_fresh(&self) -> Option<Arc<MinecraftProfile>> {
self.online_profile_with_cache_intent(
OnlineProfileCacheIntent::LiveStateRead,
)
.await
}
/// Fetches the online profile from Mojang after a skin or cape change.
#[tracing::instrument(skip(self))]
pub async fn refresh_online_profile(
&self,
) -> Option<Arc<MinecraftProfile>> {
self.online_profile_with_cache_intent(
OnlineProfileCacheIntent::RefreshFromMojang,
)
.await
}
async fn online_profile_with_cache_intent(
&self,
cache_intent: OnlineProfileCacheIntent,
) -> Option<Arc<MinecraftProfile>> {
let max_age = cache_intent.max_age();
let stale_profile = {
let mut profile_cache = PROFILE_CACHE.lock().await;
let mut remove_cached_entry = false;
let stale_profile = if let Some(cache_entry) =
profile_cache.get(&self.offline_profile.id)
{
match cache_entry {
ProfileCacheEntry::Hit(profile)
if profile.is_fresh(max_age) =>
{
return Some(Arc::clone(profile));
}
ProfileCacheEntry::Hit(profile) => {
Some(Arc::clone(profile))
}
// Auth errors must be handled with a backoff strategy because it
// has been experimentally found that Mojang quickly rate limits
// the profile data endpoint on repeated attempts with bad auth
ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token,
last_attempt,
} if &self.access_token != likely_expired_token
|| Instant::now()
.saturating_duration_since(*last_attempt)
> ONLINE_PROFILE_AUTH_ERROR_BACKOFF =>
{
remove_cached_entry = true;
None
}
ProfileCacheEntry::AuthErrorBackoff { .. } => {
return None;
}
}
Entry::Vacant(entry) => {
match minecraft_profile(&self.access_token).await {
Ok(profile) => {
let profile = Arc::new(profile);
let cache_entry =
ProfileCacheEntry::Hit(Arc::clone(&profile));
} else {
None
};
// When fetching a profile for the first time, the player UUID may
// be unknown (i.e., set to a dummy value), so make sure we don't
// cache it in the wrong place
if entry.key() != &profile.id {
profile_cache.insert(profile.id, cache_entry);
} else {
entry.insert(cache_entry);
}
if remove_cached_entry {
profile_cache.remove(&self.offline_profile.id);
}
return Some(profile);
}
Err(
err @ MinecraftAuthenticationError::DeserializeResponse {
status_code: StatusCode::UNAUTHORIZED,
..
},
) => {
tracing::warn!(
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
self.offline_profile.id
);
stale_profile
};
// We have to assume the player UUID key we have is correct here, which
// should always be the case assuming a non-adversarial server. In any
// case, any cache poisoning is inconsequential due to the entry expiration
// and the fact that we use at most one single dummy UUID
entry.insert(ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token: self.access_token.clone(),
last_attempt: Instant::now(),
});
match minecraft_profile(&self.access_token).await {
Ok(profile) => {
let profile = Arc::new(profile);
let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile));
return None;
}
Err(err) => {
tracing::warn!(
"Failed to fetch online profile for UUID {}: {err}",
self.offline_profile.id
);
let mut profile_cache = PROFILE_CACHE.lock().await;
if self.offline_profile.id != profile.id {
profile_cache.remove(&self.offline_profile.id);
}
profile_cache.insert(profile.id, cache_entry);
return None;
}
}
Some(profile)
}
Err(
err @ MinecraftAuthenticationError::DeserializeResponse {
status_code: StatusCode::UNAUTHORIZED,
..
},
) => {
tracing::warn!(
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
self.offline_profile.id
);
let mut profile_cache = PROFILE_CACHE.lock().await;
profile_cache.insert(
self.offline_profile.id,
ProfileCacheEntry::AuthErrorBackoff {
likely_expired_token: self.access_token.clone(),
last_attempt: Instant::now(),
},
);
None
}
Err(err) => {
tracing::warn!(
"Failed to fetch online profile for UUID {}: {err}",
self.offline_profile.id
);
if cache_intent.can_use_stale_on_fetch_error() {
stale_profile
} else {
None
}
}
}
@@ -806,6 +874,8 @@ impl DeviceTokenPair {
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
pub const MINECRAFT_SERVICES_USER_AGENT: &str =
"Modrinth App (support@modrinth.com; https://modrinth.com/app)";
pub struct RequestWithDate<T> {
pub date: DateTime<Utc>,
@@ -944,7 +1014,7 @@ async fn oauth_token(
query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| {
REQWEST_CLIENT
INSECURE_REQWEST_CLIENT
.post("https://login.live.com/oauth20_token.srf")
.header("Accept", "application/json")
.form(&query)
@@ -992,7 +1062,7 @@ async fn oauth_refresh(
query.insert("scope", REQUESTED_SCOPE);
let res = auth_retry(|| {
REQWEST_CLIENT
INSECURE_REQWEST_CLIENT
.post("https://login.live.com/oauth20_token.srf")
.header("Accept", "application/json")
.form(&query)
@@ -1137,9 +1207,10 @@ async fn minecraft_token(
let token = token.token;
let res = auth_retry(|| {
REQWEST_CLIENT
INSECURE_REQWEST_CLIENT
.post("https://api.minecraftservices.com/launcher/login")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.json(&json!({
"platform": "PC_LAUNCHER",
"xtoken": format!("XBL3.0 x={uhs};{token}"),
@@ -1313,10 +1384,10 @@ impl MinecraftProfile {
/// from the Mojang API: the vanilla launcher was seen refreshing profile
/// data every 60 seconds when re-entering the skin selection screen, and
/// external applications may change this data at any time.
fn is_fresh(&self) -> bool {
fn is_fresh(&self, max_age: std::time::Duration) -> bool {
self.fetch_time.is_some_and(|last_profile_fetch_time| {
Instant::now().saturating_duration_since(last_profile_fetch_time)
< std::time::Duration::from_secs(60)
< max_age
})
}
@@ -1365,9 +1436,10 @@ async fn minecraft_profile(
token: &str,
) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
let res = auth_retry(|| {
REQWEST_CLIENT
INSECURE_REQWEST_CLIENT
.get("https://api.minecraftservices.com/minecraft/profile")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(token)
// Profiles may be refreshed periodically in response to user actions,
// so we want each refresh to be fast
@@ -1416,12 +1488,13 @@ async fn minecraft_entitlements(
token: &str,
) -> Result<MinecraftEntitlements, MinecraftAuthenticationError> {
let res = auth_retry(|| {
REQWEST_CLIENT
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
.header("Accept", "application/json")
.bearer_auth(token)
.send()
})
INSECURE_REQWEST_CLIENT
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(token)
.send()
})
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
let status = res.status();
@@ -1560,7 +1633,7 @@ async fn send_signed_request<T: DeserializeOwned>(
let signature = BASE64_STANDARD.encode(&sig_buffer);
let res = auth_retry(|| {
let mut request = REQWEST_CLIENT
let mut request = INSECURE_REQWEST_CLIENT
.post(url)
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
+178 -76
View File
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use futures::{Stream, StreamExt, stream};
use uuid::{Uuid, fmt::Hyphenated};
@@ -5,79 +7,39 @@ use super::MinecraftSkinVariant;
pub mod mojang_api;
/// Represents the default cape for a Minecraft player.
#[derive(Debug, Clone)]
pub struct DefaultMinecraftCape {
/// The UUID of a cape for a Minecraft player, which comes from its profile.
///
/// This UUID may or may not be different for every player, even if they refer to the same cape.
pub id: Uuid,
}
impl DefaultMinecraftCape {
pub async fn set(
minecraft_user_id: Uuid,
cape_id: Uuid,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
let cape_id = cape_id.as_hyphenated();
sqlx::query!(
"INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
minecraft_user_id, cape_id
)
.execute(&mut *db.acquire().await?)
.await?;
Ok(())
}
pub async fn get(
minecraft_user_id: Uuid,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Option<Self>> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
Ok(sqlx::query_as!(
Self,
"SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.fetch_optional(&mut *db.acquire().await?)
.await?)
}
pub async fn remove(
minecraft_user_id: Uuid,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
sqlx::query!(
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.execute(&mut *db.acquire().await?)
.await?;
Ok(())
}
}
/// Represents a custom skin for a Minecraft player.
/// Represents a saved skin row for a Minecraft player.
///
/// The same player and `texture_key` always point to the same saved skin.
/// Changing the model variant or cape updates that saved skin instead of
/// creating a second copy. Bundled default skins with a cape are also stored
/// here so the cape can stay associated with the default skin card.
#[derive(Debug, Clone)]
pub struct CustomMinecraftSkin {
/// The key for the texture skin, which is akin to a hash that identifies it.
/// The key for the skin texture, which is akin to a hash that identifies it.
pub texture_key: String,
/// The variant of the skin model.
pub variant: MinecraftSkinVariant,
/// The UUID of the cape that this skin uses, which should match one of the
/// cape UUIDs the player has in its profile.
///
/// If `None`, the skin does not have an explicit cape set, and the default
/// cape for this player, if any, should be used.
/// If `None`, the skin is saved without a cape.
pub cape_id: Option<Uuid>,
/// The saved skin display order within this player's saved skins.
pub display_order: i64,
}
#[derive(Debug, Clone, Copy)]
pub enum CustomMinecraftSkinInsertPosition {
Top,
Bottom,
At(i64),
}
struct CustomMinecraftSkinRow {
texture_key: String,
variant: MinecraftSkinVariant,
cape_id: Option<Hyphenated>,
display_order: i64,
}
impl CustomMinecraftSkin {
@@ -87,6 +49,7 @@ impl CustomMinecraftSkin {
texture: &[u8],
variant: MinecraftSkinVariant,
cape_id: Option<Uuid>,
insert_position: CustomMinecraftSkinInsertPosition,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
@@ -94,25 +57,106 @@ impl CustomMinecraftSkin {
let mut transaction = db.begin().await?;
let existing_order = sqlx::query_scalar!(
"SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
texture_key
)
.fetch_optional(&mut *transaction)
.await?;
let display_order = match existing_order {
Some(display_order) => display_order,
None => match insert_position {
CustomMinecraftSkinInsertPosition::Top => {
sqlx::query!(
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.execute(&mut *transaction)
.await?;
0
}
CustomMinecraftSkinInsertPosition::Bottom => {
sqlx::query_scalar!(
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' \
FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
minecraft_user_id
)
.fetch_one(&mut *transaction)
.await?
}
CustomMinecraftSkinInsertPosition::At(display_order) => {
sqlx::query!(
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 \
WHERE minecraft_user_uuid = ? AND display_order >= ?",
minecraft_user_id,
display_order
)
.execute(&mut *transaction)
.await?;
display_order
}
},
};
sqlx::query!(
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
texture_key, texture
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
texture_key
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
minecraft_user_id, texture_key, variant, cape_id
)
.execute(&mut *transaction)
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
texture_key, texture
)
.execute(&mut *transaction)
.await?;
sqlx::query!(
"INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
minecraft_user_id, texture_key, variant, cape_id, display_order
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
pub async fn get_by_texture(
minecraft_user_id: Uuid,
texture_key: &str,
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Option<Self>> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
sqlx::query_as!(
CustomMinecraftSkinRow,
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
texture_key
)
.fetch_optional(&mut *db.acquire().await?)
.await?
.map(|row| {
Ok(Self {
texture_key: row.texture_key,
variant: row.variant,
cape_id: row.cape_id.map(Uuid::from),
display_order: row.display_order,
})
})
.transpose()
}
pub async fn get_many(
minecraft_user_id: Uuid,
offset: u32,
@@ -122,10 +166,10 @@ impl CustomMinecraftSkin {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
Ok(stream::iter(sqlx::query!(
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? \
ORDER BY rowid ASC \
ORDER BY display_order ASC, rowid ASC \
LIMIT ? OFFSET ?",
minecraft_user_id, count, offset
)
@@ -135,6 +179,7 @@ impl CustomMinecraftSkin {
texture_key: row.texture_key,
variant: row.variant,
cape_id: row.cape_id.map(Uuid::from),
display_order: row.display_order,
}))
}
@@ -165,16 +210,73 @@ impl CustomMinecraftSkin {
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
let cape_id = self.cape_id.map(|id| id.hyphenated());
sqlx::query!(
"DELETE FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
minecraft_user_id, self.texture_key, self.variant, cape_id
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
minecraft_user_id,
self.texture_key
)
.execute(&mut *db.acquire().await?)
.await?;
Ok(())
}
pub async fn set_order(
minecraft_user_id: Uuid,
texture_keys: &[String],
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let minecraft_user_id = minecraft_user_id.as_hyphenated();
let mut transaction = db.begin().await?;
let existing_rows = sqlx::query!(
"SELECT texture_key FROM custom_minecraft_skins \
WHERE minecraft_user_uuid = ? \
ORDER BY display_order ASC, rowid ASC",
minecraft_user_id
)
.fetch_all(&mut *transaction)
.await?;
let existing_keys = existing_rows
.iter()
.map(|row| row.texture_key.as_str())
.collect::<HashSet<_>>();
let mut seen_keys = HashSet::new();
let mut ordered_keys = Vec::with_capacity(existing_rows.len());
for texture_key in texture_keys {
if seen_keys.insert(texture_key.as_str())
&& existing_keys.contains(texture_key.as_str())
{
ordered_keys.push(texture_key.as_str());
}
}
for row in &existing_rows {
if seen_keys.insert(row.texture_key.as_str()) {
ordered_keys.push(row.texture_key.as_str());
}
}
for (display_order, texture_key) in ordered_keys.into_iter().enumerate()
{
let display_order = display_order as i64;
sqlx::query!(
"UPDATE custom_minecraft_skins SET display_order = ? \
WHERE minecraft_user_uuid = ? AND texture_key = ?",
display_order,
minecraft_user_id,
texture_key
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
}
@@ -10,8 +10,11 @@ use super::MinecraftSkinVariant;
use crate::{
ErrorKind,
data::Credentials,
state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry},
util::fetch::REQWEST_CLIENT,
state::{
MINECRAFT_SERVICES_USER_AGENT, MinecraftProfile, PROFILE_CACHE,
ProfileCacheEntry,
},
util::fetch::INSECURE_REQWEST_CLIENT,
};
/// Provides operations for interacting with capes on a Minecraft player profile.
@@ -23,13 +26,14 @@ impl MinecraftCapeOperation {
cape_id: Uuid,
) -> crate::Result<()> {
update_profile_cache_from_response(
REQWEST_CLIENT
.put("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
.bearer_auth(&credentials.access_token)
.json(&json!({
"capeId": cape_id.hyphenated(),
INSECURE_REQWEST_CLIENT
.put("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Content-Type", "application/json; charset=utf-8")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.json(&json!({
"capeId": cape_id.hyphenated(),
}))
.send()
.await
@@ -42,12 +46,13 @@ impl MinecraftCapeOperation {
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
update_profile_cache_from_response(
REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Accept", "application/json")
.bearer_auth(&credentials.access_token)
.send()
.await
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/capes/active")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.send()
.await
.and_then(|response| response.error_for_status())?
)
.await;
@@ -64,7 +69,7 @@ impl MinecraftSkinOperation {
credentials: &Credentials,
texture: TextureStream,
variant: MinecraftSkinVariant,
) -> crate::Result<()>
) -> crate::Result<Option<Arc<MinecraftProfile>>>
where
TextureStream: TryStream + Send + 'static,
TextureStream::Error: Into<Box<dyn Error + Send + Sync>>,
@@ -91,12 +96,13 @@ impl MinecraftSkinOperation {
.file_name("skin.png"),
);
update_profile_cache_from_response(
REQWEST_CLIENT
let profile = update_profile_cache_from_response(
INSECURE_REQWEST_CLIENT
.post(
"https://api.minecraftservices.com/minecraft/profile/skins",
)
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.multipart(form)
.send()
@@ -105,17 +111,18 @@ impl MinecraftSkinOperation {
)
.await;
Ok(())
Ok(profile)
}
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
update_profile_cache_from_response(
REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/skins/active")
.header("Accept", "application/json")
.bearer_auth(&credentials.access_token)
.send()
.await
INSECURE_REQWEST_CLIENT
.delete("https://api.minecraftservices.com/minecraft/profile/skins/active")
.header("Accept", "application/json")
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(&credentials.access_token)
.send()
.await
.and_then(|response| response.error_for_status())?
)
.await;
@@ -124,19 +131,24 @@ impl MinecraftSkinOperation {
}
}
async fn update_profile_cache_from_response(response: reqwest::Response) {
async fn update_profile_cache_from_response(
response: reqwest::Response,
) -> Option<Arc<MinecraftProfile>> {
let Some(mut profile) = response.json::<MinecraftProfile>().await.ok()
else {
tracing::warn!(
"Failed to parse player profile from skin or cape operation response, not updating profile cache"
);
return;
return None;
};
profile.fetch_time = Some(Instant::now());
let profile = Arc::new(profile);
PROFILE_CACHE
.lock()
.await
.insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile)));
.insert(profile.id, ProfileCacheEntry::Hit(Arc::clone(&profile)));
Some(profile)
}
+34 -6
View File
@@ -1,6 +1,7 @@
//! Theseus state management system
use crate::util::fetch::{FetchSemaphore, IoSemaphore};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio::sync::{OnceCell, Semaphore};
use crate::state::fs_watcher::FileWatcher;
@@ -13,6 +14,9 @@ pub use self::dirs::*;
mod profiles;
pub use self::profiles::*;
mod instances;
pub use self::instances::*;
mod settings;
pub use self::settings::*;
@@ -71,21 +75,35 @@ pub struct State {
/// Process manager
pub process_manager: ProcessManager,
// NOTE: we explicitly must NOT store the app identifier in the state object,
// because creating the state object is fallible (e.g. database missing),
// but we rely on the app identifier to create the state (data dir).
//
// /// App identifier string (like com.modrinth.ModrinthApp)
// pub app_identifier: String,
/// Friends socket
pub friends_socket: FriendsSocket,
pub restart_after_pending_update: AtomicBool,
pub(crate) pool: SqlitePool,
pub(crate) file_watcher: FileWatcher,
}
impl State {
pub async fn init() -> crate::Result<()> {
pub async fn init(app_identifier: String) -> crate::Result<()> {
let state = LAUNCHER_STATE
.get_or_try_init(Self::initialize_state)
.get_or_try_init(move || Self::initialize_state(app_identifier))
.await?;
tokio::task::spawn(async move {
fs_watcher::watch_profiles_init(
&state.file_watcher,
&state.directories,
)
.await;
let res = tokio::try_join!(
state.discord_rpc.clear_to_default(true),
Profile::refresh_all(),
@@ -131,10 +149,16 @@ impl State {
LAUNCHER_STATE.initialized()
}
pub fn get_if_initialized() -> Option<Arc<Self>> {
LAUNCHER_STATE.get().map(Arc::clone)
}
#[tracing::instrument]
async fn initialize_state() -> crate::Result<Arc<Self>> {
async fn initialize_state(
app_identifier: String,
) -> crate::Result<Arc<Self>> {
tracing::info!("Connecting to app database");
let pool = db::connect().await?;
let pool = db::connect(&app_identifier).await?;
legacy_converter::migrate_legacy_data(&pool).await?;
@@ -153,15 +177,17 @@ impl State {
&mut settings,
&pool,
&io_semaphore,
&app_identifier,
)
.await?;
let directories = DirectoryInfo::init(settings.custom_dir).await?;
let directories =
DirectoryInfo::init(settings.custom_dir, &app_identifier).await?;
let discord_rpc = DiscordGuard::init()?;
tracing::info!("Initializing file watcher");
let file_watcher = fs_watcher::init_watcher().await?;
fs_watcher::watch_profiles_init(&file_watcher, &directories).await;
let process_manager = ProcessManager::new();
@@ -175,8 +201,10 @@ impl State {
discord_rpc,
process_manager,
friends_socket,
restart_after_pending_update: AtomicBool::new(false),
pool,
file_watcher,
// app_identifier,
}))
}
}
+2
View File
@@ -35,6 +35,7 @@ impl ModrinthCredentials {
None,
Some(("Authorization", &*creds.session)),
None,
None,
semaphore,
exec,
)
@@ -226,6 +227,7 @@ async fn fetch_info(
None,
Some(("Authorization", token)),
None,
None,
semaphore,
exec,
)
+218 -117
View File
@@ -1,4 +1,6 @@
use crate::event::emit::{emit_process, emit_profile};
#[cfg(feature = "tauri")]
use crate::event::{LogEvent, LogPayload};
use crate::event::{ProcessPayloadType, ProfilePayloadType};
use crate::profile;
use crate::util::io::IOError;
@@ -9,17 +11,76 @@ use quick_xml::Reader;
use quick_xml::events::Event;
use serde::Deserialize;
use serde::Serialize;
use std::collections::VecDeque;
use std::fmt::Debug;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::sync::LazyLock;
#[cfg(feature = "tauri")]
use tauri::Emitter;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use uuid::Uuid;
const LAUNCHER_LOG_PATH: &str = "launcher_log.txt";
const LOG_BUFFER_CAPACITY: usize = 50_000;
struct LogRingBuffer {
lines: VecDeque<String>,
}
impl LogRingBuffer {
fn new() -> Self {
Self {
lines: VecDeque::new(),
}
}
fn push(&mut self, line: String) {
if self.lines.len() >= LOG_BUFFER_CAPACITY {
self.lines.pop_front();
}
self.lines.push_back(line);
}
fn get_all(&self) -> Vec<String> {
self.lines.iter().cloned().collect()
}
fn clear(&mut self) {
self.lines.clear();
}
}
static LOG_BUFFERS: LazyLock<DashMap<String, LogRingBuffer>> =
LazyLock::new(DashMap::new);
pub fn push_log_line(profile_path: &str, line: String) {
LOG_BUFFERS
.entry(profile_path.to_string())
.or_insert_with(LogRingBuffer::new)
.push(line);
}
pub fn get_log_buffer(profile_path: &str) -> Vec<String> {
LOG_BUFFERS
.get(profile_path)
.map(|buf| buf.get_all())
.unwrap_or_default()
}
pub fn clear_log_buffer(profile_path: &str) {
if let Some(mut buf) = LOG_BUFFERS.get_mut(profile_path) {
buf.clear();
}
}
pub fn remove_log_buffer(profile_path: &str) {
LOG_BUFFERS.remove(profile_path);
}
pub struct ProcessManager {
processes: DashMap<Uuid, Process>,
@@ -91,6 +152,8 @@ impl ProcessManager {
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
clear_log_buffer(profile_path);
{
let mut log_file = OpenOptions::new()
.write(true)
@@ -222,13 +285,14 @@ struct Process {
rpc_server: RpcServer,
}
#[derive(Debug, Default)]
struct Log4jEvent {
timestamp: Option<String>,
logger: Option<String>,
level: Option<String>,
thread: Option<String>,
message: Option<String>,
#[derive(Debug, Default, Serialize, Clone)]
pub struct Log4jEvent {
pub timestamp_millis: Option<i64>,
pub logger_name: Option<String>,
pub level: Option<String>,
pub thread_name: Option<String>,
pub message: Option<String>,
pub throwable: Option<String>,
}
impl Process {
@@ -285,17 +349,19 @@ impl Process {
match key.as_str() {
"logger" => {
current_event.logger = Some(value)
current_event.logger_name =
Some(value)
}
"level" => {
current_event.level = Some(value)
}
"thread" => {
current_event.thread = Some(value)
current_event.thread_name =
Some(value)
}
"timestamp" => {
current_event.timestamp =
Some(value)
current_event.timestamp_millis =
value.parse::<i64>().ok()
}
_ => {}
}
@@ -321,39 +387,17 @@ impl Process {
}
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("");
current_event.throwable =
if current_content.is_empty() {
None
} else {
Some(current_content.clone())
};
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
// Write log entry + throwable to file
if let Some(formatted_log) =
Self::format_log4j_entry(&current_event)
{
if let Err(e) = Process::append_to_log_file(
&log_path,
&formatted_log,
@@ -364,12 +408,11 @@ impl Process {
);
}
// Write the throwable if present
if !current_content.is_empty()
if let Some(ref throwable) =
current_event.throwable
&& let Err(e) =
Process::append_to_log_file(
&log_path,
&current_content,
&log_path, throwable,
)
{
tracing::error!(
@@ -378,68 +421,55 @@ impl Process {
);
}
}
Self::emit_log4j_event(
profile_path,
&current_event,
);
}
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
&& current_event.throwable.is_none()
{
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,
) {
if let Some(formatted_log) =
Self::format_log4j_entry(&current_event)
&& 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()
&& let Err(e) = Self::maybe_handle_server_join_logging(
if let Some(timestamp_millis) =
current_event.timestamp_millis
{
let timestamp =
timestamp_millis.to_string();
let message = current_event
.message
.as_deref()
.unwrap_or("")
.trim();
if let Err(e) = Self::maybe_handle_server_join_logging(
profile_path,
timestamp,
message
&timestamp,
message,
).await {
tracing::error!("Failed to handle server join logging: {e}");
}
}
Self::emit_log4j_event(
profile_path,
&current_event,
);
}
}
_ => {}
@@ -454,15 +484,17 @@ impl Process {
&& !e.inplace_trim_end()
&& !e.inplace_trim_start()
&& let Ok(text) = e.xml_content()
&& let Err(e) = Process::append_to_log_file(
{
if let Err(e) = Process::append_to_log_file(
&log_path,
&format!("{text}\n"),
)
{
tracing::error!(
"Failed to write to log file: {}",
e
);
) {
tracing::error!(
"Failed to write to log file: {}",
e
);
}
Self::emit_legacy_log(profile_path, &text);
}
}
Ok(Event::CData(e)) => {
@@ -489,6 +521,7 @@ impl Process {
if let Err(e) = Self::append_to_log_file(&log_path, &line) {
tracing::warn!("Failed to write to log file: {}", e);
}
Self::emit_legacy_log(profile_path, line.trim_ascii_end());
if let Err(e) = Self::maybe_handle_old_server_join_logging(
profile_path,
line.trim_ascii_end(),
@@ -506,30 +539,98 @@ impl Process {
}
}
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;
fn format_timestamp(timestamp_millis: Option<i64>) -> String {
if let Some(timestamp_val) = timestamp_millis {
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_secs(timestamp_val)
.unwrap_or_default()
};
let datetime_local = datetime_utc.with_timezone(&chrono::Local);
format!("[{}]", datetime_local.format("%H:%M:%S"))
chrono::DateTime::<Utc>::from_timestamp(secs, nsecs)
.unwrap_or_default()
} else {
"[??:??:??]".to_string()
}
chrono::DateTime::<Utc>::from_timestamp_secs(timestamp_val)
.unwrap_or_default()
};
let datetime_local = datetime_utc.with_timezone(&chrono::Local);
format!("[{}]", datetime_local.format("%H:%M:%S"))
} else {
"[??:??:??]".to_string()
}
}
fn format_log4j_entry(event: &Log4jEvent) -> Option<String> {
let message = event.message.as_ref()?;
let thread = event.thread_name.as_deref().unwrap_or("");
let level = event.level.as_deref().unwrap_or("");
let logger = event.logger_name.as_deref().unwrap_or("");
let formatted_time = Self::format_timestamp(event.timestamp_millis);
Some(format!(
"{} [{}] [{}{}]: {}\n",
formatted_time,
thread,
if !logger.is_empty() {
format!("{logger}/")
} else {
String::new()
},
level,
message.trim()
))
}
fn emit_log4j_event(profile_path: &str, event: &Log4jEvent) {
if let Some(formatted) = Self::format_log4j_entry(event) {
push_log_line(profile_path, formatted.trim_end().to_string());
}
if let Some(ref throwable) = event.throwable {
for line in throwable.lines().filter(|l| !l.is_empty()) {
push_log_line(profile_path, line.to_string());
}
}
#[cfg(feature = "tauri")]
{
if let Ok(event_state) = crate::EventState::get() {
let _ = event_state.app.emit(
"log",
LogPayload {
profile_path_id: profile_path.to_string(),
event: LogEvent::Log4j(event.clone()),
},
);
}
}
#[cfg(not(feature = "tauri"))]
{
let _ = (profile_path, event);
}
}
fn emit_legacy_log(profile_path: &str, message: &str) {
push_log_line(profile_path, message.to_string());
#[cfg(feature = "tauri")]
{
if let Ok(event_state) = crate::EventState::get() {
let _ = event_state.app.emit(
"log",
LogPayload {
profile_path_id: profile_path.to_string(),
event: LogEvent::Legacy {
message: message.to_string(),
},
},
);
}
}
#[cfg(not(feature = "tauri"))]
{
let _ = (profile_path, message);
}
}
fn append_to_log_file(
path: impl AsRef<Path>,
line: &str,
+432 -119
View File
@@ -2,7 +2,8 @@ use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::profile::get_full_path;
use crate::state::server_join_log::JoinLogEntry;
use crate::state::{
CacheBehaviour, CachedEntry, CachedFileHash, cache_file_hash,
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, ReleaseChannel,
cache_file_hash,
};
use crate::util;
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
@@ -12,7 +13,7 @@ use dashmap::DashMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::convert::TryInto;
use std::path::Path;
@@ -39,6 +40,7 @@ pub struct Profile {
pub groups: Vec<String>,
pub linked_data: Option<LinkedData>,
pub preferred_update_channel: ReleaseChannel,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
@@ -295,6 +297,7 @@ struct ProfileQueryResult {
linked_project_id: Option<String>,
linked_version_id: Option<String>,
locked: Option<i64>,
preferred_update_channel: String,
created: i64,
modified: i64,
last_played: Option<i64>,
@@ -344,6 +347,9 @@ impl TryFrom<ProfileQueryResult> for Profile {
} else {
None
},
preferred_update_channel: ReleaseChannel::from_key(
&x.preferred_update_channel,
),
created: Utc
.timestamp_opt(x.created, 0)
.single()
@@ -394,7 +400,7 @@ macro_rules! select_profiles_with_predicate {
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,
linked_project_id, linked_version_id, locked, preferred_update_channel,
created, modified, last_played,
submitted_time_played, recent_time_played,
override_java_path,
@@ -409,6 +415,33 @@ macro_rules! select_profiles_with_predicate {
};
}
struct InitialScanFile {
path: String,
file_name: String,
project_type: ProjectType,
size: u64,
cache_key: String,
}
fn is_scannable_project_file(
project_type: ProjectType,
file_name: &str,
) -> bool {
let Some(extension) = Path::new(file_name.trim_end_matches(".disabled"))
.extension()
.and_then(|ext| ext.to_str())
else {
return false;
};
match project_type {
ProjectType::Mod => extension.eq_ignore_ascii_case("jar"),
ProjectType::DataPack
| ProjectType::ResourcePack
| ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"),
}
}
impl Profile {
pub async fn get(
path: &str,
@@ -465,6 +498,7 @@ impl Profile {
let linked_data_version_id =
self.linked_data.as_ref().map(|x| x.version_id.clone());
let linked_data_locked = self.linked_data.as_ref().map(|x| x.locked);
let preferred_update_channel = self.preferred_update_channel.key();
let created = self.created.timestamp();
let modified = self.modified.timestamp();
@@ -487,7 +521,7 @@ impl Profile {
path, install_stage, name, icon_path,
game_version, mod_loader, mod_loader_version,
groups,
linked_project_id, linked_version_id, locked,
linked_project_id, linked_version_id, locked, preferred_update_channel,
created, modified, last_played,
submitted_time_played, recent_time_played,
override_java_path, override_extra_launch_args, override_custom_env_vars,
@@ -499,13 +533,13 @@ impl Profile {
$1, $2, $3, $4,
$5, $6, $7,
jsonb($8),
$9, $10, $11,
$12, $13, $14,
$15, $16,
$17, jsonb($18), jsonb($19),
$20, $21, $22, $23,
$24, $25, $26,
$27, $28
$9, $10, $11, $12,
$13, $14, $15,
$16, $17,
$18, jsonb($19), jsonb($20),
$21, $22, $23, $24,
$25, $26, $27,
$28, $29
)
ON CONFLICT (path) DO UPDATE SET
install_stage = $2,
@@ -521,28 +555,29 @@ impl Profile {
linked_project_id = $9,
linked_version_id = $10,
locked = $11,
preferred_update_channel = $12,
created = $12,
modified = $13,
last_played = $14,
created = $13,
modified = $14,
last_played = $15,
submitted_time_played = $15,
recent_time_played = $16,
submitted_time_played = $16,
recent_time_played = $17,
override_java_path = $17,
override_extra_launch_args = jsonb($18),
override_custom_env_vars = jsonb($19),
override_mc_memory_max = $20,
override_mc_force_fullscreen = $21,
override_mc_game_resolution_x = $22,
override_mc_game_resolution_y = $23,
override_java_path = $18,
override_extra_launch_args = jsonb($19),
override_custom_env_vars = jsonb($20),
override_mc_memory_max = $21,
override_mc_force_fullscreen = $22,
override_mc_game_resolution_x = $23,
override_mc_game_resolution_y = $24,
override_hook_pre_launch = $24,
override_hook_wrapper = $25,
override_hook_post_exit = $26,
override_hook_pre_launch = $25,
override_hook_wrapper = $26,
override_hook_post_exit = $27,
protocol_version = $27,
launcher_feature_version = $28
protocol_version = $28,
launcher_feature_version = $29
",
self.path,
install_stage,
@@ -555,6 +590,7 @@ impl Profile {
linked_data_project_id,
linked_data_version_id,
linked_data_locked,
preferred_update_channel,
created,
modified,
last_played,
@@ -640,6 +676,10 @@ impl Profile {
&& let Some(file_name) = subdirectory
.file_name()
.and_then(|x| x.to_str())
&& is_scannable_project_file(
project_type,
file_name,
)
{
let file_size = subdirectory
.metadata()
@@ -713,9 +753,15 @@ impl Profile {
let file_updates = file_hashes
.iter()
.filter_map(|file| {
all.iter()
.find(|prof| file.path.contains(&prof.path))
.map(|profile| Self::get_cache_key(file, profile))
all.iter().find(|prof| file.path.contains(&prof.path)).map(
|profile| {
Self::get_cache_key(
file,
profile,
profile.preferred_update_channel,
)
},
)
})
.collect::<Vec<_>>();
@@ -909,16 +955,311 @@ impl Profile {
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
let path = crate::api::profile::get_full_path(&self.path).await?;
self.get_projects_inner(
cache_behaviour,
pool,
fetch_semaphore,
None,
None,
)
.await
}
struct InitialScanFile {
path: String,
file_name: String,
project_type: ProjectType,
size: u64,
cache_key: String,
pub async fn get_projects_excluding_modpack_files(
&self,
excluded_hashes: &HashSet<String>,
excluded_project_ids: &HashSet<String>,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
self.get_projects_inner(
cache_behaviour,
pool,
fetch_semaphore,
Some(excluded_hashes),
Some(excluded_project_ids),
)
.await
}
async fn get_projects_inner(
&self,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
excluded_hashes: Option<&HashSet<String>>,
excluded_project_ids: Option<&HashSet<String>>,
) -> crate::Result<DashMap<String, ProfileFile>> {
let (keys, file_hashes) =
self.scan_and_hash(pool, fetch_semaphore).await?;
let excluded_hashes = excluded_hashes.filter(|ids| !ids.is_empty());
let excluded_project_ids =
excluded_project_ids.filter(|ids| !ids.is_empty());
let file_hashes = file_hashes
.into_iter()
.filter(|hash| {
excluded_hashes
.is_none_or(|excluded| !excluded.contains(&hash.hash))
})
.collect::<Vec<_>>();
let (file_hashes, file_info_by_hash, file_updates) =
if let Some(excluded_project_ids) = excluded_project_ids {
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_info = CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let file_info_by_hash: HashMap<String, CachedFile> = file_info
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let file_hashes = file_hashes
.into_iter()
.filter(|hash| {
file_info_by_hash.get(&hash.hash).is_none_or(|file| {
!excluded_project_ids.contains(&file.project_id)
})
})
.collect::<Vec<_>>();
let installed_channels = Self::get_installed_update_channels(
&file_info_by_hash,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let file_updates = file_hashes
.iter()
.filter(|x| file_info_by_hash.contains_key(&x.hash))
.map(|x| {
Self::get_cache_key(
x,
self,
self.effective_update_channel(
installed_channels.get(&x.hash).copied(),
),
)
})
.collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let file_updates = CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
(file_hashes, file_info_by_hash, file_updates)
} else {
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_info = CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let file_info_by_hash: HashMap<String, CachedFile> = file_info
.into_iter()
.map(|f| (f.hash.clone(), f))
.collect();
let installed_channels = Self::get_installed_update_channels(
&file_info_by_hash,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let file_updates = file_hashes
.iter()
.filter(|x| file_info_by_hash.contains_key(&x.hash))
.map(|x| {
Self::get_cache_key(
x,
self,
self.effective_update_channel(
installed_channels.get(&x.hash).copied(),
),
)
})
.collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let file_updates = CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
(file_hashes, file_info_by_hash, file_updates)
};
let mut keys_by_path: HashMap<String, InitialScanFile> =
keys.into_iter().map(|k| (k.path.clone(), k)).collect();
let mut updates_by_hash: HashMap<String, Vec<String>> = HashMap::new();
for update in file_updates {
updates_by_hash
.entry(update.hash)
.or_default()
.push(update.update_version_id);
}
let files = DashMap::new();
for hash in file_hashes {
let file = file_info_by_hash.get(&hash.hash).cloned();
let trimmed = hash.path.trim_end_matches(".disabled");
if let Some(initial_file) = keys_by_path.remove(trimmed) {
let path = format!(
"{}/{}",
initial_file.project_type.get_folder(),
initial_file.file_name
);
let update_version_id = if let Some(metadata) = &file {
let update_ids =
updates_by_hash.remove(&hash.hash).unwrap_or_default();
if !update_ids.contains(&metadata.version_id) {
update_ids.into_iter().next()
} else {
None
}
} else {
None
};
let file = ProfileFile {
update_version_id,
hash: hash.hash,
file_name: initial_file.file_name,
size: initial_file.size,
metadata: file.map(|x| FileMetadata {
project_id: x.project_id,
version_id: x.version_id,
}),
project_type: initial_file.project_type,
};
files.insert(path, file);
}
}
Ok(files)
}
async fn get_installed_update_channels(
file_info_by_hash: &HashMap<String, CachedFile>,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<HashMap<String, ReleaseChannel>> {
let version_ids = file_info_by_hash
.values()
.map(|file| file.version_id.as_str())
.collect::<HashSet<_>>();
if version_ids.is_empty() {
return Ok(HashMap::new());
}
let version_ids_ref = version_ids.iter().copied().collect::<Vec<_>>();
let versions = CachedEntry::get_version_many(
&version_ids_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
let channels_by_version_id = versions
.into_iter()
.map(|version| {
(
version.id,
ReleaseChannel::from_version_type(&version.version_type),
)
})
.collect::<HashMap<_, _>>();
Ok(file_info_by_hash
.iter()
.filter_map(|(hash, file)| {
channels_by_version_id
.get(&file.version_id)
.copied()
.map(|channel| (hash.clone(), channel))
})
.collect())
}
fn effective_update_channel(
&self,
installed_channel: Option<ReleaseChannel>,
) -> ReleaseChannel {
installed_channel.map_or(self.preferred_update_channel, |channel| {
self.preferred_update_channel.least_stable(channel)
})
}
pub async fn get_installed_project_ids(
&self,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<String>> {
let (_keys, file_hashes) =
self.scan_and_hash(pool, fetch_semaphore).await?;
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_info = CachedEntry::get_file_many(
&file_hashes_ref,
None,
pool,
fetch_semaphore,
)
.await?;
let project_ids: Vec<String> = file_info
.into_iter()
.map(|f| f.project_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
Ok(project_ids)
}
async fn scan_and_hash(
&self,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<(Vec<InitialScanFile>, Vec<CachedFileHash>)> {
let path = crate::api::profile::get_full_path(&self.path).await?;
let mut keys = vec![];
for project_type in ProjectType::iterator() {
@@ -934,6 +1275,7 @@ impl Profile {
if subdirectory.is_file()
&& let Some(file_name) =
subdirectory.file_name().and_then(|x| x.to_str())
&& is_scannable_project_file(project_type, file_name)
{
let file_size = subdirectory
.metadata()
@@ -967,85 +1309,16 @@ impl Profile {
)
.await?;
let file_updates = file_hashes
.iter()
.map(|x| Self::get_cache_key(x, self))
.collect::<Vec<_>>();
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
let (mut file_info, file_updates) = tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
cache_behaviour,
pool,
fetch_semaphore,
),
CachedEntry::get_file_update_many(
&file_updates_ref,
cache_behaviour,
pool,
fetch_semaphore,
)
)?;
let files = DashMap::new();
for hash in file_hashes {
let info_index = file_info.iter().position(|x| x.hash == hash.hash);
let file = info_index.map(|x| file_info.remove(x));
if let Some(initial_file_index) = keys
.iter()
.position(|x| x.path == hash.path.trim_end_matches(".disabled"))
{
let initial_file = keys.remove(initial_file_index);
let path = format!(
"{}/{}",
initial_file.project_type.get_folder(),
initial_file.file_name
);
let update_version_id = if let Some(metadata) = &file {
let update_ids: Vec<String> = file_updates
.iter()
.filter(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
.collect();
if !update_ids.contains(&metadata.version_id) {
update_ids.into_iter().next()
} else {
None
}
} else {
None
};
let file = ProfileFile {
update_version_id,
hash: hash.hash,
file_name: initial_file.file_name,
size: initial_file.size,
metadata: file.map(|x| FileMetadata {
project_id: x.project_id,
version_id: x.version_id,
}),
project_type: initial_file.project_type,
};
files.insert(path, file);
}
}
Ok(files)
Ok((keys, file_hashes))
}
fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String {
fn get_cache_key(
file: &CachedFileHash,
profile: &Profile,
channel: ReleaseChannel,
) -> String {
format!(
"{}-{}-{}",
"{}-{}-{}-{}",
file.hash,
file.project_type
.filter(|x| *x != ProjectType::Mod)
@@ -1053,6 +1326,7 @@ impl Profile {
|| profile.loader.as_str().to_string(),
|x| x.get_loaders().join("+")
),
channel.key(),
profile.game_version
)
}
@@ -1061,10 +1335,27 @@ impl Profile {
pub async fn add_project_version(
profile_path: &str,
version_id: &str,
reason: util::fetch::DownloadReason,
dependent_on_version_id: Option<String>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
io_semaphore: &IoSemaphore,
) -> crate::Result<String> {
let profile =
Self::get(profile_path, pool).await?.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(
profile_path.to_string(),
)
.as_error()
})?;
let download_meta = util::fetch::DownloadMeta {
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
dependent_on: dependent_on_version_id,
};
let version =
CachedEntry::get_version(version_id, None, pool, fetch_semaphore)
.await?
@@ -1090,6 +1381,7 @@ impl Profile {
let bytes = util::fetch::fetch(
&file.url,
file.hashes.get("sha1").map(|x| &**x),
Some(&download_meta),
fetch_semaphore,
pool,
)
@@ -1174,20 +1466,41 @@ impl Profile {
}
/// Toggle a project's disabled state.
///
/// Accepts either a bare file name (e.g. `mymod.jar`) or a relative
/// path (`mods/mymod.jar`). The function resolves the current on-disk
/// path (enabled or disabled) before renaming, so callers don't need
/// to track the `.disabled` suffix.
#[tracing::instrument]
pub async fn toggle_disable_project(
profile_path: &str,
project_path: &str,
) -> crate::Result<String> {
let path = crate::api::profile::get_full_path(profile_path).await?;
let base = crate::api::profile::get_full_path(profile_path).await?;
let new_path = if project_path.ends_with(".disabled") {
project_path.trim_end_matches(".disabled").to_string()
let trimmed = project_path.trim_end_matches(".disabled");
// Resolve the actual current path on disk
let current_path = if base.join(project_path).exists() {
project_path.to_string()
} else if base.join(format!("{trimmed}.disabled")).exists() {
format!("{trimmed}.disabled")
} else if base.join(trimmed).exists() {
trimmed.to_string()
} else {
format!("{project_path}.disabled")
return Err(crate::ErrorKind::FSError(format!(
"Could not find project file for '{project_path}' in profile"
))
.into());
};
io::rename_or_move(&path.join(project_path), &path.join(&new_path))
let new_path = if current_path.ends_with(".disabled") {
current_path.trim_end_matches(".disabled").to_string()
} else {
format!("{current_path}.disabled")
};
io::rename_or_move(&base.join(&current_path), &base.join(&new_path))
.await?;
Ok(new_path)
+19 -1
View File
@@ -55,10 +55,18 @@ pub enum FeatureFlag {
ProjectBackground,
WorldsTab,
WorldsInHome,
ServerRamAsBytesAlwaysOn,
AlwaysShowAppControls,
SkipUnknownPackWarning,
PrideFundraiser,
ServersInApp,
ServerProjectQa,
I18nDebug,
ShowInstancePlayTime,
}
impl Settings {
const CURRENT_VERSION: usize = 2;
const CURRENT_VERSION: usize = 3;
pub async fn get(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
@@ -289,6 +297,16 @@ impl Settings {
self.version = 2;
}
2 => {
// Update old default memory setting from 2GB to 4GB (depending on system memory)
const LEGACY_DEFAULT_MEMORY_MB: u32 = 2048;
if self.memory.maximum == LEGACY_DEFAULT_MEMORY_MB {
self.memory.maximum =
crate::api::jre::default_memory_max_mb();
}
self.version = 3;
}
version => {
return Err(crate::ErrorKind::OtherError(format!(
"Invalid settings version: {version}"
+166 -26
View File
@@ -9,13 +9,40 @@ use parking_lot::Mutex;
use rand::Rng;
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
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};
use tokio::{fs::File, io::AsyncReadExt, io::AsyncWriteExt};
pub const DOWNLOAD_META_HEADER: &str = "modrinth-download-meta";
#[derive(Debug, derive_more::Display, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[display(rename_all = "snake_case")]
pub enum DownloadReason {
Standalone,
Dependency,
Modpack,
Update,
}
#[derive(Debug, Clone, Serialize)]
pub struct DownloadMeta {
pub reason: DownloadReason,
pub game_version: String,
pub loader: String,
pub dependent_on: Option<String>,
}
impl DownloadMeta {
pub fn to_header_value(&self) -> String {
serde_json::to_string(self).unwrap_or_default()
}
}
#[derive(Debug)]
pub struct IoSemaphore(pub Semaphore);
@@ -130,18 +157,24 @@ static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
inner: Mutex::new(FenceInner::new()),
});
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
let header =
reqwest::header::HeaderValue::from_str(&crate::launcher_user_agent())
.unwrap();
headers.insert(reqwest::header::USER_AGENT, header);
fn reqwest_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
.default_headers(headers)
.user_agent(crate::launcher_user_agent())
}
pub static INSECURE_REQWEST_CLIENT: LazyLock<reqwest::Client> =
LazyLock::new(|| {
reqwest_client_builder()
.build()
.expect("client configuration should be valid")
});
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest_client_builder()
.https_only(true)
.build()
.expect("Reqwest Client Building Failed")
.expect("client configuration should be valid")
});
const FETCH_ATTEMPTS: usize = 2;
@@ -150,11 +183,46 @@ const FETCH_ATTEMPTS: usize = 2;
pub async fn fetch(
url: &str,
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
fetch_advanced(Method::GET, url, sha1, None, None, None, semaphore, exec)
.await
fetch_advanced(
Method::GET,
url,
sha1,
None,
None,
download_meta,
None,
semaphore,
exec,
)
.await
}
#[tracing::instrument(skip(semaphore))]
pub async fn fetch_with_client(
url: &str,
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
client: &reqwest::Client,
) -> crate::Result<Bytes> {
fetch_advanced_with_client(
Method::GET,
url,
sha1,
None,
None,
download_meta,
None,
semaphore,
exec,
client,
)
.await
}
#[tracing::instrument(skip(json_body, semaphore))]
@@ -170,14 +238,15 @@ where
T: DeserializeOwned,
{
let result = fetch_advanced(
method, url, sha1, json_body, None, None, semaphore, exec,
method, url, sha1, json_body, None, None, None, semaphore, exec,
)
.await?;
let value = serde_json::from_slice(&result)?;
Ok(value)
}
/// Downloads a file with retry and checksum functionality
/// Downloads a file with retry and checksum functionality, and a specific
/// [`reqwest::Client`].
#[tracing::instrument(skip(json_body, semaphore))]
#[allow(clippy::too_many_arguments)]
pub async fn fetch_advanced(
@@ -186,9 +255,40 @@ pub async fn fetch_advanced(
sha1: Option<&str>,
json_body: Option<serde_json::Value>,
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Bytes> {
fetch_advanced_with_client(
method,
url,
sha1,
json_body,
header,
download_meta,
loading_bar,
semaphore,
exec,
&INSECURE_REQWEST_CLIENT,
)
.await
}
/// Downloads a file with retry and checksum functionality
#[tracing::instrument(skip(json_body, semaphore))]
#[allow(clippy::too_many_arguments)]
pub async fn fetch_advanced_with_client(
method: Method,
url: &str,
sha1: Option<&str>,
json_body: Option<serde_json::Value>,
header: Option<(&str, &str)>,
download_meta: Option<&DownloadMeta>,
loading_bar: Option<(&LoadingBarId, f64)>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
client: &reqwest::Client,
) -> crate::Result<Bytes> {
let _permit = semaphore.0.acquire().await?;
@@ -205,12 +305,15 @@ pub async fn fetch_advanced(
None
};
let download_meta_header = download_meta
.map(|m| (DOWNLOAD_META_HEADER.to_string(), m.to_header_value()));
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
if is_api_url && GLOBAL_FETCH_FENCE.is_blocked() {
return Err(ErrorKind::ApiIsDownError.into());
}
let mut req = REQWEST_CLIENT.request(method.clone(), url);
let mut req = client.request(method.clone(), url);
if let Some(body) = json_body.clone() {
req = req.json(&body);
@@ -224,6 +327,11 @@ pub async fn fetch_advanced(
req = req.header("Authorization", &creds.session);
}
if let Some((name, value)) = &download_meta_header {
tracing::info!("Sending download analytics: {value}");
req = req.header(name.as_str(), value.as_str());
}
let result = req.send().await;
match result {
Ok(resp) => {
@@ -318,6 +426,7 @@ pub async fn fetch_advanced(
pub async fn fetch_mirrors(
mirrors: &[&str],
sha1: Option<&str>,
download_meta: Option<&DownloadMeta>,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Bytes> {
@@ -328,7 +437,15 @@ pub async fn fetch_mirrors(
}
for (index, mirror) in mirrors.iter().enumerate() {
let result = fetch(mirror, sha1, semaphore, exec).await;
let result = fetch_with_client(
mirror,
sha1,
download_meta,
semaphore,
exec,
&REQWEST_CLIENT,
)
.await;
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
return result;
@@ -340,18 +457,15 @@ pub async fn fetch_mirrors(
/// Posts a JSON to a URL
#[tracing::instrument(skip(json_body, semaphore))]
pub async fn post_json<T>(
pub async fn post_json(
url: &str,
json_body: serde_json::Value,
semaphore: &FetchSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<T>
where
T: DeserializeOwned,
{
) -> crate::Result<()> {
let _permit = semaphore.0.acquire().await?;
let mut req = REQWEST_CLIENT.post(url).json(&json_body);
let mut req = INSECURE_REQWEST_CLIENT.post(url).json(&json_body);
if let Some(creds) =
crate::state::ModrinthCredentials::get_active(exec).await?
@@ -359,10 +473,8 @@ where
req = req.header("Authorization", &creds.session);
}
let result = req.send().await?.error_for_status()?;
let value = result.json().await?;
Ok(value)
req.send().await?.error_for_status()?;
Ok(())
}
pub async fn read_json<T>(
@@ -456,6 +568,34 @@ pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
Ok(hash)
}
pub async fn sha1_file_async(
path: impl AsRef<Path>,
) -> crate::Result<(u64, String)> {
let path = path.as_ref();
// Local files can be multi-gigabyte .mrpacks, so hash them without materializing bytes.
let mut file = File::open(path)
.await
.map_err(|e| IOError::with_path(e, path))?;
let mut hasher = sha1_smol::Sha1::new();
let mut size = 0;
let mut buffer = vec![0; 262144];
loop {
let bytes_read = file
.read(&mut buffer)
.await
.map_err(|e| IOError::with_path(e, path))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
size += bytes_read as u64;
}
Ok((size, hasher.digest().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
+57 -16
View File
@@ -1,6 +1,7 @@
// IO error
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
use eyre::{Context, ContextCompat, Result, eyre};
use std::{
io::{ErrorKind, Write},
path::Path,
@@ -181,17 +182,34 @@ fn sync_write(
std::io::Result::Ok(())
}
pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool, IOError> {
pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Ok(old_dir.metadata()?.dev() == new_dir.metadata()?.dev())
use eyre::eyre;
// we need to use `symlink_metadata` instead of `metadata`, because
// if this file is a symlink, we need to query the symlink file itself,
// rather than the target.
// downloaded JREs use symlinks to point to certain stuff like LICENSE
// files.
// this fixes moving JRE dirs.
let old_meta = std::fs::symlink_metadata(old_dir)
.wrap_err_with(|| eyre!("getting meta of old dir {old_dir:?}"))?;
let new_meta = std::fs::symlink_metadata(new_dir)
.wrap_err_with(|| eyre!("getting meta of new dir {new_dir:?}"))?;
Ok(old_meta.dev() == new_meta.dev())
}
#[cfg(windows)]
{
let old_dir = canonicalize(old_dir)?;
let new_dir = canonicalize(new_dir)?;
let old_dir = canonicalize(old_dir)
.wrap_err_with(|| eyre!("canonicalizing {old_dir:?}"))?;
let new_dir = canonicalize(new_dir)
.wrap_err_with(|| eyre!("canonicalizing {new_dir:?}"))?;
let old_component = old_dir.components().next();
let new_component = new_dir.components().next();
@@ -209,39 +227,62 @@ pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool, IOError> {
pub async fn rename_or_move(
from: impl AsRef<std::path::Path>,
to: impl AsRef<std::path::Path>,
) -> Result<(), IOError> {
) -> Result<()> {
let from = from.as_ref();
let to = to.as_ref();
if to
let to_parent = to
.parent()
.map_or(Ok(false), |to_dir| is_same_disk(from, to_dir))?
{
.wrap_err_with(|| eyre!("getting parent of `to` dir {to:?}"))?;
let same_disk = is_same_disk(from, to_parent).wrap_err_with(|| {
eyre!("checking if `to_parent` ({to_parent:?}) and `from` ({from:?}) are on the same disk")
})?;
if same_disk {
tokio::fs::rename(from, to)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: from.to_string_lossy().to_string(),
})
.wrap_err_with(|| eyre!("moving {from:?} to {to:?} on same disk"))
} else {
move_recursive(from, to).await
move_recursive(from, to).await.with_context(|| {
eyre!("moving {from:?} to {to:?} on different disks")
})
}
}
#[async_recursion::async_recursion]
async fn move_recursive(from: &Path, to: &Path) -> Result<(), IOError> {
async fn move_recursive(from: &Path, to: &Path) -> Result<()> {
if from.is_file() {
copy(from, to).await?;
remove_file(from).await?;
copy(from, to)
.await
.wrap_err_with(|| eyre!("copying {from:?} to {to:?}"))?;
remove_file(from).await.wrap_err_with(|| {
eyre!("removing {from:?} after copying to {to:?}")
})?;
return Ok(());
}
create_dir(to).await?;
create_dir(to)
.await
.wrap_err_with(|| eyre!("creating dir for {to:?}"))?;
let mut dir = read_dir(from).await?;
while let Some(entry) = dir.next_entry().await? {
let mut dir = read_dir(from)
.await
.wrap_err_with(|| eyre!("reading dir {from:?}"))?;
while let Some(entry) = dir
.next_entry()
.await
.wrap_err_with(|| eyre!("reading dir entry in {from:?}"))?
{
let new_path = to.join(entry.file_name());
move_recursive(&entry.path(), &new_path).await?;
move_recursive(&entry.path(), &new_path)
.await
.with_context(|| {
eyre!("moving {:?} to {new_path:?}", entry.path())
})?;
}
Ok(())
+77 -18
View File
@@ -8,6 +8,33 @@ use tokio::net::ToSocketAddrs;
use tokio::select;
use url::Url;
const MAX_MINECRAFT_STATUS_STRING_LENGTH: usize = 32_767;
const MAX_MODERN_STATUS_PACKET_LENGTH: usize =
MAX_MINECRAFT_STATUS_STRING_LENGTH + 4;
const MAX_LEGACY_STATUS_UTF16_LENGTH: usize =
MAX_MINECRAFT_STATUS_STRING_LENGTH;
/// Ensures the length of a packet as stated by a server is not longer than a
/// hard-coded limit.
///
/// For example, if we ping a server that says its status packet is 2 billion
/// bytes long, we don't try to allocate a 2 billion byte buffer, since that
/// will OOM our machine.
///
/// Implemented as a function so that you can easily find callsites and see
/// where we accept unvalidated input from servers.
fn cap_length(
length: usize,
max_length: usize,
context: &'static str,
) -> Result<usize> {
if length > max_length {
return Err(ErrorKind::InputError(context.to_string()).into());
}
Ok(length)
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ServerStatus {
@@ -43,7 +70,7 @@ pub struct ServerGameProfile {
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ServerVersion {
pub name: String,
pub protocol: u32,
pub protocol: i32,
#[serde(skip_deserializing)]
pub legacy: bool,
}
@@ -69,7 +96,7 @@ pub async fn get_server_status(
mod modern {
use super::ServerStatus;
use crate::ErrorKind;
use chrono::Utc;
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, ToSocketAddrs};
@@ -79,6 +106,7 @@ mod modern {
protocol_version: Option<u32>,
) -> crate::Result<ServerStatus> {
let mut stream = TcpStream::connect(address).await?;
stream.set_nodelay(true)?;
handshake(&mut stream, original_address, protocol_version).await?;
let mut result = status_body(&mut stream).await?;
result.ping = ping(&mut stream).await.ok();
@@ -127,13 +155,11 @@ mod modern {
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 packet_length = cap_varint_length(
varint::read(stream).await?,
super::MAX_MODERN_STATUS_PACKET_LENGTH,
"invalid status response packet length",
)?;
let mut packet_stream = stream.take(packet_length as u64);
let packet_id = varint::read(&mut packet_stream).await?;
@@ -143,8 +169,12 @@ mod modern {
)
.into());
}
let response_length = varint::read(&mut packet_stream).await?;
let mut json_response = vec![0_u8; response_length as usize];
let response_length = cap_varint_length(
varint::read(&mut packet_stream).await?,
super::MAX_MINECRAFT_STATUS_STRING_LENGTH,
"invalid status response length",
)?;
let mut json_response = vec![0_u8; response_length];
packet_stream.read_exact(&mut json_response).await?;
if packet_stream.limit() > 0 {
@@ -154,10 +184,31 @@ mod modern {
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();
/// Ensures the length of a varint as stated by a server is not longer than a
/// hard-coded limit.
///
/// For example, if we ping a server that says its status packet is 2 billion
/// bytes long, we don't try to allocate a 2 billion byte buffer, since that
/// will OOM our machine.
///
/// Implemented as a function so that you can easily find callsites and see
/// where we accept unvalidated input from servers.
fn cap_varint_length(
length: i32,
max_length: usize,
context: &'static str,
) -> crate::Result<usize> {
if length < 0 {
return Err(ErrorKind::InputError(context.to_string()).into());
}
super::cap_length(length as usize, max_length, context)
}
async fn ping(stream: &mut TcpStream) -> crate::Result<i64> {
let ping_magic = chrono::Utc::now().timestamp_millis();
let start_time = Instant::now();
stream.write_all(&[0x09, 0x01]).await?;
stream.write_i64(ping_magic).await?;
stream.flush().await?;
@@ -172,8 +223,7 @@ mod modern {
.into());
}
let response_time = Utc::now();
Ok((response_time - start_time).num_milliseconds())
Ok(start_time.elapsed().as_millis() as i64)
}
mod varint {
@@ -275,8 +325,17 @@ mod legacy {
)));
}
let data_length = stream.read_u16().await?;
let mut data = vec![0u8; data_length as usize * 2];
let data_length = super::cap_length(
stream.read_u16().await? as usize,
super::MAX_LEGACY_STATUS_UTF16_LENGTH,
"invalid legacy status response length",
)?;
let data_byte_length = data_length.checked_mul(2).ok_or_else(|| {
ErrorKind::InputError(
"invalid legacy status response length".to_string(),
)
})?;
let mut data = vec![0u8; data_byte_length];
stream.read_exact(&mut data).await?;
drop(stream);
-13
View File
@@ -6,7 +6,6 @@
///
use crate::api::update;
use crate::event::emit::emit_info;
use crate::state::db;
use crate::{Result, State};
use serde::{Deserialize, Serialize};
@@ -214,18 +213,6 @@ async fn extract_metadata_from_elyby_file(
Ok((asset_name, download_url))
}
/// Applying migration fix for SQLite database.
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
tracing::info!("[AR] • Attempting to apply migration fix");
let patched = db::apply_migration_fix(eol).await?;
if patched {
tracing::info!("[AR] • Successfully applied migration fix");
} else {
tracing::error!("[AR] • Failed to apply migration fix");
}
Ok(patched)
}
/// Initialize the update launcher.
pub async fn init_update_launcher(
download_url: &str,