You've already forked AstralRinth
Merge tag 'v0.14.6' into beta
v0.14.6
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¤t,
|
||||
¤t_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,
|
||||
¤t,
|
||||
¤t_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,
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) = ¤t_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(¤t_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,
|
||||
¤t_content,
|
||||
&log_path, throwable,
|
||||
)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -378,68 +421,55 @@ impl Process {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Self::emit_log4j_event(
|
||||
profile_path,
|
||||
¤t_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(¤t_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
|
||||
×tamp,
|
||||
message,
|
||||
).await {
|
||||
tracing::error!("Failed to handle server join logging: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Self::emit_log4j_event(
|
||||
profile_path,
|
||||
¤t_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,
|
||||
|
||||
@@ -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(¤t_path), &base.join(&new_path))
|
||||
.await?;
|
||||
|
||||
Ok(new_path)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user