Files
AstralRinth/packages/app-lib/src/state/profiles.rs
Prospector ff4c7f47b2 Direct World Joining (#3457)
* Begin work on worlds backend

* Finish implementing get_profile_worlds and get_server_status (except pinning)

* Create TS types and manually copy unparsed chat components

* Clippy fix

* Update types.d.ts

* Initial worlds UI work

* Fix api::get_profile_worlds to take in a relative path

* sanitize & security update

* Fix sanitizePotentialFileUrl

* Fix sanitizePotentialFileUrl (for real)

* Fix empty motd causing error

* Finally actually fix world icons

* Fix world icon not being visible on non-Windows

* Use the correct generics to take in AppHandle

* Implement start_join_singleplayer_world and start_join_server for modern versions

* Don't error if server has no cached icon

* Migrate to own server pinging

* Ignore missing server hidden field and missing saves dir

* Update world list frontend

* More frontend work

* Server status player sample can be absent

* Fix refresh state

* Add get_profile_protocol_version

* Add protocol_version column to database

* SQL INTEGER is i64 in sqlx

* sqlx prepare

* Cache protocol version in database

* Continue worlds UI work

* Fix motds being bold

* Remove legacy pinging and add a 30-second timeout

* Remove pinned for now and match world (and server) parsing closer to spec

* Move type ServerStatus to worlds.ts

* Implement add_server_to_profile

* Fix pack_status being ignored when joining from launcher

* Make World path field be relative

* Implement rename_world and reset_world_icon

* Clippy fix

* Fix rename_world

* UI enhancements

* Implement backup_world, which returns the backup size in bytes

* Clippy fix

* Return index when adding servers to profile

* Fix backup

* Implement delete_world

* Implement edit_server_in_profile and remove_server_from_profile

* Clippy fix

* Log server joins

* Add edit and delete support

* Fix ts errors

* Fix minecraft font

* Switch font out for non-monospaced.

* Fix font proper

* Some more world cleanup, handle play state, check quickplay compatibility

* Clear the cached protocol version when a profile's game version is changed

* Fix tint colors in navbar

* Fix server protocol version pinging

* UI fixes

* Fix protocol version handler

* Fix MOTD parsing

* Add worlds_updated profile event

* fix pkg

* Functional home screen with worlds

* lint

* Fix incorrect folder creation

* Make items clickable

* Add locked field to SingleplayerWorld indicating whether the world is locked by the game

* Implement locking frontend

* Fix locking condition

* Split worlds_updated profile event into servers_updated and world_updated

* Fix compile error

* Use port from resolve SRV record

* Fix serialization of ProfilePayload and ProfilePayloadType

* Individual singleplayer world refreshing

* Log when worlds are perceived to be updated

* Push logging + total refresh lock

* Unlisten fixes

* Highlight current world when clicked

* Launcher logs refactor (#3444)

* Switch live log to use STDOUT

* fix clippy, legacy logs support

* Fix lint

* Handle non-XML log messages in XML logging, and don't escape log messages into XML

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>

* Update incompatibility text

* Home page fixes, and unlock after close

* Remove logging

* Add join log database migration

* Switch server join timing to being in the database instead of in a separate log file

* Create optimized get_recent_worlds function that takes in a limit

* Update dependencies and fix Cargo.lock

* temp disable overflow menus

* revert home page changes

* Enable overflow menus again

* Remove list

* Revert

* Push dev tools

* Remove default filter

* Disable debug renderer

* Fix random app errors

* Refactor

* Fix missing computed import

* Fix light mode issues

* Fix TS errors

* Lint

* Fix bad link in change modpack version modal

* fix lint

* fix intl

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
2025-04-26 18:09:58 -07:00

958 lines
31 KiB
Rust

use super::settings::{Hooks, MemorySettings, WindowSize};
use crate::state::{
cache_file_hash, CacheBehaviour, CachedEntry, CachedFileHash,
};
use crate::util;
use crate::util::fetch::{write_cached_icon, FetchSemaphore, IoSemaphore};
use crate::util::io::{self};
use chrono::{DateTime, TimeZone, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::convert::TryFrom;
use std::convert::TryInto;
use std::path::Path;
// Represent a Minecraft instance.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Profile {
pub path: String,
pub install_stage: ProfileInstallStage,
pub name: String,
pub icon_path: Option<String>,
pub game_version: String,
pub protocol_version: Option<i32>,
pub loader: ModLoader,
pub loader_version: Option<String>,
pub groups: Vec<String>,
pub linked_data: Option<LinkedData>,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
pub last_played: Option<DateTime<Utc>>,
pub submitted_time_played: u64,
pub recent_time_played: u64,
pub java_path: Option<String>,
pub extra_launch_args: Option<Vec<String>>,
pub custom_env_vars: Option<Vec<(String, String)>>,
pub memory: Option<MemorySettings>,
pub force_fullscreen: Option<bool>,
pub game_resolution: Option<WindowSize>,
pub hooks: Hooks,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ProfileInstallStage {
/// Profile is installed
Installed,
/// Profile's minecraft game is still installing
MinecraftInstalling,
/// Pack is installed, but Minecraft installation has not begun
PackInstalled,
/// Profile created for pack, but the pack hasn't been fully installed yet
PackInstalling,
/// Profile is not installed
NotInstalled,
}
impl ProfileInstallStage {
pub fn as_str(&self) -> &'static str {
match *self {
Self::Installed => "installed",
Self::MinecraftInstalling => "minecraft_installing",
Self::PackInstalled => "pack_installed",
Self::PackInstalling => "pack_installing",
Self::NotInstalled => "not_installed",
}
}
pub fn from_str(val: &str) -> Self {
match val {
"installed" => Self::Installed,
"minecraft_installing" => Self::MinecraftInstalling,
"installing" => Self::MinecraftInstalling, // Backwards compatibility
"pack_installed" => Self::PackInstalled,
"pack_installing" => Self::PackInstalling,
"not_installed" => Self::NotInstalled,
_ => Self::NotInstalled,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LinkedData {
pub project_id: String,
pub version_id: String,
pub locked: bool,
}
#[derive(Debug, Eq, PartialEq, Clone, Copy, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ModLoader {
Vanilla,
Forge,
Fabric,
Quilt,
NeoForge,
}
impl ModLoader {
pub fn as_str(&self) -> &'static str {
match *self {
Self::Vanilla => "vanilla",
Self::Forge => "forge",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::NeoForge => "neoforge",
}
}
pub fn as_meta_str(&self) -> &'static str {
match *self {
Self::Vanilla => "vanilla",
Self::Forge => "forge",
Self::Fabric => "fabric",
Self::Quilt => "quilt",
Self::NeoForge => "neo",
}
}
pub fn from_string(val: &str) -> Self {
match val {
"vanilla" => Self::Vanilla,
"forge" => Self::Forge,
"fabric" => Self::Fabric,
"quilt" => Self::Quilt,
"neoforge" => Self::NeoForge,
_ => Self::Vanilla,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProfileFile {
pub hash: String,
pub file_name: String,
pub size: u64,
pub metadata: Option<FileMetadata>,
pub update_version_id: Option<String>,
pub project_type: ProjectType,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileMetadata {
pub project_id: String,
pub version_id: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProjectType {
Mod,
DataPack,
ResourcePack,
ShaderPack,
}
impl ProjectType {
pub fn get_from_loaders(loaders: Vec<String>) -> Option<Self> {
if loaders
.iter()
.any(|x| ["fabric", "forge", "quilt", "neoforge"].contains(&&**x))
{
Some(ProjectType::Mod)
} else if loaders.iter().any(|x| x == "datapack") {
Some(ProjectType::DataPack)
} else if loaders.iter().any(|x| ["iris", "optifine"].contains(&&**x)) {
Some(ProjectType::ShaderPack)
} else if loaders
.iter()
.any(|x| ["vanilla", "canvas", "minecraft"].contains(&&**x))
{
Some(ProjectType::ResourcePack)
} else {
None
}
}
pub fn get_from_parent_folder(path: &Path) -> Option<Self> {
// Get parent folder
let path = path.parent()?.file_name()?;
match path.to_str()? {
"mods" => Some(ProjectType::Mod),
"datapacks" => Some(ProjectType::DataPack),
"resourcepacks" => Some(ProjectType::ResourcePack),
"shaderpacks" => Some(ProjectType::ShaderPack),
_ => None,
}
}
pub fn get_name(&self) -> &'static str {
match self {
ProjectType::Mod => "mod",
ProjectType::DataPack => "datapack",
ProjectType::ResourcePack => "resourcepack",
ProjectType::ShaderPack => "shader",
}
}
pub fn get_folder(&self) -> &'static str {
match self {
ProjectType::Mod => "mods",
ProjectType::DataPack => "datapacks",
ProjectType::ResourcePack => "resourcepacks",
ProjectType::ShaderPack => "shaderpacks",
}
}
pub fn get_loaders(&self) -> &'static [&'static str] {
match self {
ProjectType::Mod => &["fabric", "forge", "quilt", "neoforge"],
ProjectType::DataPack => &["datapack"],
ProjectType::ResourcePack => &["vanilla", "canvas", "minecraft"],
ProjectType::ShaderPack => &["iris", "optifine"],
}
}
pub fn iterator() -> impl Iterator<Item = ProjectType> {
[
ProjectType::Mod,
ProjectType::DataPack,
ProjectType::ResourcePack,
ProjectType::ShaderPack,
]
.iter()
.copied()
}
}
struct ProfileQueryResult {
path: String,
install_stage: String,
name: String,
icon_path: Option<String>,
game_version: String,
mod_loader: String,
mod_loader_version: Option<String>,
groups: serde_json::Value,
linked_project_id: Option<String>,
linked_version_id: Option<String>,
locked: Option<i64>,
created: i64,
modified: i64,
last_played: Option<i64>,
submitted_time_played: i64,
recent_time_played: i64,
override_java_path: Option<String>,
override_extra_launch_args: serde_json::Value,
override_custom_env_vars: serde_json::Value,
override_mc_memory_max: Option<i64>,
override_mc_force_fullscreen: Option<i64>,
override_mc_game_resolution_x: Option<i64>,
override_mc_game_resolution_y: Option<i64>,
override_hook_pre_launch: Option<String>,
override_hook_wrapper: Option<String>,
override_hook_post_exit: Option<String>,
protocol_version: Option<i64>,
}
impl TryFrom<ProfileQueryResult> for Profile {
type Error = crate::Error;
fn try_from(x: ProfileQueryResult) -> Result<Self, Self::Error> {
Ok(Profile {
path: x.path,
install_stage: ProfileInstallStage::from_str(&x.install_stage),
name: x.name,
icon_path: x.icon_path,
game_version: x.game_version,
protocol_version: x.protocol_version.map(|x| x as i32),
loader: ModLoader::from_string(&x.mod_loader),
loader_version: x.mod_loader_version,
groups: serde_json::from_value(x.groups).unwrap_or_default(),
linked_data: if let Some(project_id) = x.linked_project_id {
if let Some(version_id) = x.linked_version_id {
x.locked.map(|locked| LinkedData {
project_id,
version_id,
locked: locked == 1,
})
} else {
None
}
} else {
None
},
created: Utc
.timestamp_opt(x.created, 0)
.single()
.unwrap_or_else(Utc::now),
modified: Utc
.timestamp_opt(x.modified, 0)
.single()
.unwrap_or_else(Utc::now),
last_played: x
.last_played
.and_then(|x| Utc.timestamp_opt(x, 0).single()),
submitted_time_played: x.submitted_time_played as u64,
recent_time_played: x.recent_time_played as u64,
java_path: x.override_java_path,
extra_launch_args: serde_json::from_value(
x.override_extra_launch_args,
)
.ok(),
custom_env_vars: serde_json::from_value(x.override_custom_env_vars)
.ok(),
memory: x
.override_mc_memory_max
.map(|x| MemorySettings { maximum: x as u32 }),
force_fullscreen: x.override_mc_force_fullscreen.map(|x| x == 1),
game_resolution: if let Some(x_res) =
x.override_mc_game_resolution_x
{
x.override_mc_game_resolution_y
.map(|y_res| WindowSize(x_res as u16, y_res as u16))
} else {
None
},
hooks: Hooks {
pre_launch: x.override_hook_pre_launch,
wrapper: x.override_hook_wrapper,
post_exit: x.override_hook_post_exit,
},
})
}
}
macro_rules! select_profiles_with_predicate {
($predicate:tt, $param:ident) => {
sqlx::query_as!(
ProfileQueryResult,
r#"
SELECT
path, install_stage, name, icon_path,
game_version, protocol_version, mod_loader, mod_loader_version,
json(groups) as "groups!: serde_json::Value",
linked_project_id, linked_version_id, locked,
created, modified, last_played,
submitted_time_played, recent_time_played,
override_java_path,
json(override_extra_launch_args) as "override_extra_launch_args!: serde_json::Value", json(override_custom_env_vars) as "override_custom_env_vars!: serde_json::Value",
override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,
override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit
FROM profiles
"#
+ $predicate,
$param
)
};
}
impl Profile {
pub async fn get(
path: &str,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Option<Self>> {
Ok(Self::get_many(&[path], exec).await?.into_iter().next())
}
pub async fn get_many(
paths: &[&str],
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Vec<Self>> {
let ids = serde_json::to_string(&paths)?;
let results = select_profiles_with_predicate!(
"WHERE path IN (SELECT value FROM json_each($1))",
ids
)
.fetch_all(exec)
.await?;
results
.into_iter()
.map(|r| r.try_into())
.collect::<crate::Result<Vec<_>>>()
}
pub async fn get_all(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<Vec<Self>> {
let true_val = 1;
let results = select_profiles_with_predicate!("WHERE 1=$1", true_val)
.fetch_all(exec)
.await?;
results
.into_iter()
.map(|r| r.try_into())
.collect::<crate::Result<Vec<_>>>()
}
pub async fn upsert(
&self,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<()> {
let install_stage = self.install_stage.as_str();
let mod_loader = self.loader.as_str();
let groups = serde_json::to_string(&self.groups)?;
let linked_data_project_id =
self.linked_data.as_ref().map(|x| x.project_id.clone());
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 created = self.created.timestamp();
let modified = self.modified.timestamp();
let last_played = self.last_played.map(|x| x.timestamp());
let submitted_time_played = self.submitted_time_played as i64;
let recent_time_played = self.recent_time_played as i64;
let memory_max = self.memory.map(|x| x.maximum);
let game_resolution_x = self.game_resolution.map(|x| x.0);
let game_resolution_y = self.game_resolution.map(|x| x.1);
let extra_launch_args = serde_json::to_string(&self.extra_launch_args)?;
let custom_env_vars = serde_json::to_string(&self.custom_env_vars)?;
sqlx::query!(
"
INSERT INTO profiles (
path, install_stage, name, icon_path,
game_version, mod_loader, mod_loader_version,
groups,
linked_project_id, linked_version_id, locked,
created, modified, last_played,
submitted_time_played, recent_time_played,
override_java_path, override_extra_launch_args, override_custom_env_vars,
override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,
override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,
protocol_version
)
VALUES (
$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
)
ON CONFLICT (path) DO UPDATE SET
install_stage = $2,
name = $3,
icon_path = $4,
game_version = $5,
mod_loader = $6,
mod_loader_version = $7,
groups = jsonb($8),
linked_project_id = $9,
linked_version_id = $10,
locked = $11,
created = $12,
modified = $13,
last_played = $14,
submitted_time_played = $15,
recent_time_played = $16,
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_hook_pre_launch = $24,
override_hook_wrapper = $25,
override_hook_post_exit = $26,
protocol_version = $27
",
self.path,
install_stage,
self.name,
self.icon_path,
self.game_version,
mod_loader,
self.loader_version,
groups,
linked_data_project_id,
linked_data_version_id,
linked_data_locked,
created,
modified,
last_played,
submitted_time_played,
recent_time_played,
self.java_path,
extra_launch_args,
custom_env_vars,
memory_max,
self.force_fullscreen,
game_resolution_x,
game_resolution_y,
self.hooks.pre_launch,
self.hooks.wrapper,
self.hooks.post_exit,
self.protocol_version,
)
.execute(exec)
.await?;
Ok(())
}
pub async fn remove(
profile_path: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
sqlx::query!(
"
DELETE FROM profiles
WHERE path = $1
",
profile_path
)
.execute(pool)
.await?;
if let Ok(path) = crate::api::profile::get_full_path(profile_path).await
{
io::remove_dir_all(&path).await?;
}
Ok(())
}
#[tracing::instrument(skip(self, semaphore, icon))]
pub async fn set_icon<'a>(
&'a mut self,
cache_dir: &Path,
semaphore: &IoSemaphore,
icon: bytes::Bytes,
file_name: &str,
) -> crate::Result<()> {
let file =
write_cached_icon(file_name, cache_dir, icon, semaphore).await?;
self.icon_path = Some(file.to_string_lossy().to_string());
self.modified = Utc::now();
Ok(())
}
pub(crate) async fn refresh_all() -> crate::Result<()> {
let state = crate::State::get().await?;
let mut all = Self::get_all(&state.pool).await?;
let mut keys = vec![];
for profile in &mut all {
let path =
crate::api::profile::get_full_path(&profile.path).await?;
for project_type in ProjectType::iterator() {
let folder = project_type.get_folder();
let path = path.join(folder);
if path.exists() {
for subdirectory in std::fs::read_dir(&path)
.map_err(|e| io::IOError::with_path(e, &path))?
{
let subdirectory =
subdirectory.map_err(io::IOError::from)?.path();
if subdirectory.is_file() {
if let Some(file_name) = subdirectory
.file_name()
.and_then(|x| x.to_str())
{
let file_size = subdirectory
.metadata()
.map_err(io::IOError::from)?
.len();
keys.push(format!(
"{file_size}-{}/{folder}/{file_name}",
profile.path
));
}
}
}
}
}
if profile.install_stage == ProfileInstallStage::MinecraftInstalling
{
profile.install_stage = ProfileInstallStage::PackInstalled;
profile.upsert(&state.pool).await?;
} else if profile.install_stage
== ProfileInstallStage::PackInstalling
{
profile.install_stage = ProfileInstallStage::NotInstalled;
profile.upsert(&state.pool).await?;
}
}
let file_hashes = CachedEntry::get_file_hash_many(
&keys.iter().map(|s| &**s).collect::<Vec<_>>(),
None,
&state.pool,
&state.fetch_semaphore,
)
.await?;
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))
})
.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<_>>();
tokio::try_join!(
CachedEntry::get_file_many(
&file_hashes_ref,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.fetch_semaphore,
),
CachedEntry::get_file_update_many(
&file_updates_ref,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.fetch_semaphore,
)
)?;
Ok(())
}
pub async fn get_projects(
&self,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<DashMap<String, ProfileFile>> {
let path = crate::api::profile::get_full_path(&self.path).await?;
struct InitialScanFile {
path: String,
file_name: String,
project_type: ProjectType,
size: u64,
cache_key: String,
}
let mut keys = vec![];
for project_type in ProjectType::iterator() {
let folder = project_type.get_folder();
let path = path.join(folder);
if path.exists() {
for subdirectory in std::fs::read_dir(&path)
.map_err(|e| io::IOError::with_path(e, &path))?
{
let subdirectory =
subdirectory.map_err(io::IOError::from)?.path();
if subdirectory.is_file() {
if let Some(file_name) =
subdirectory.file_name().and_then(|x| x.to_str())
{
let file_size = subdirectory
.metadata()
.map_err(io::IOError::from)?
.len();
keys.push(InitialScanFile {
path: format!(
"{}/{folder}/{}",
self.path,
file_name.trim_end_matches(".disabled")
),
file_name: file_name.to_string(),
project_type,
size: file_size,
cache_key: format!(
"{file_size}-{}/{folder}/{file_name}",
self.path
),
});
}
}
}
}
}
let file_hashes = CachedEntry::get_file_hash_many(
&keys.iter().map(|s| &*s.cache_key).collect::<Vec<_>>(),
None,
pool,
fetch_semaphore,
)
.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(update) = file_updates
.iter()
.find(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
{
if let Some(metadata) = &file {
if metadata.version_id != update {
Some(update)
} else {
None
}
} 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)
}
fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String {
format!(
"{}-{}-{}",
file.hash,
file.project_type
.filter(|x| *x != ProjectType::Mod)
.map(|x| x.get_loaders().join("+"))
.unwrap_or_else(|| profile.loader.as_str().to_string()),
profile.game_version
)
}
#[tracing::instrument(skip(pool))]
pub async fn add_project_version(
profile_path: &str,
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
io_semaphore: &IoSemaphore,
) -> crate::Result<String> {
let version =
CachedEntry::get_version(version_id, None, pool, fetch_semaphore)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unable to install version id {version_id}. Not found."
))
.as_error()
})?;
let file = if let Some(file) = version.files.iter().find(|x| x.primary)
{
file
} else if let Some(file) = version.files.first() {
file
} else {
return Err(crate::ErrorKind::InputError(
"No files for input version present!".to_string(),
)
.into());
};
let bytes = util::fetch::fetch(
&file.url,
file.hashes.get("sha1").map(|x| &**x),
fetch_semaphore,
pool,
)
.await?;
let path = Self::add_project_bytes(
profile_path,
&file.filename,
bytes,
file.hashes.get("sha1").map(|x| &**x),
ProjectType::get_from_loaders(version.loaders.clone()),
io_semaphore,
pool,
)
.await?;
Ok(path)
}
#[tracing::instrument(skip(bytes))]
pub async fn add_project_bytes(
profile_path: &str,
file_name: &str,
bytes: bytes::Bytes,
hash: Option<&str>,
project_type: Option<ProjectType>,
io_semaphore: &IoSemaphore,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
) -> crate::Result<String> {
let project_type = if let Some(project_type) = project_type {
project_type
} else {
let cursor = std::io::Cursor::new(&*bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| {
crate::ErrorKind::InputError(
"Unable to infer project type for input file".to_string(),
)
})?;
if archive.by_name("fabric.mod.json").is_ok()
|| archive.by_name("quilt.mod.json").is_ok()
|| archive.by_name("META-INF/neoforge.mods.toml").is_ok()
|| archive.by_name("META-INF/mods.toml").is_ok()
|| archive.by_name("mcmod.info").is_ok()
{
ProjectType::Mod
} else if archive.by_name("pack.mcmeta").is_ok() {
if archive.file_names().any(|x| x.starts_with("data/")) {
ProjectType::DataPack
} else {
ProjectType::ResourcePack
}
} else if archive.file_names().any(|x| x.starts_with("shaders/")) {
ProjectType::ShaderPack
} else {
return Err(crate::ErrorKind::InputError(
"Unable to infer project type for input file".to_string(),
)
.into());
}
};
let path = crate::api::profile::get_full_path(profile_path).await?;
let project_path =
format!("{}/{}", project_type.get_folder(), file_name);
cache_file_hash(
bytes.clone(),
profile_path,
&project_path,
hash,
Some(project_type),
exec,
)
.await?;
util::fetch::write(&path.join(&project_path), &bytes, io_semaphore)
.await?;
Ok(project_path)
}
/// Toggle a project's disabled state.
#[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 new_path = if project_path.ends_with(".disabled") {
project_path.trim_end_matches(".disabled").to_string()
} else {
format!("{project_path}.disabled")
};
io::rename_or_move(&path.join(project_path), &path.join(&new_path))
.await?;
Ok(new_path)
}
#[tracing::instrument]
pub async fn remove_project(
profile_path: &str,
project_path: &str,
) -> crate::Result<()> {
if let Ok(path) = crate::api::profile::get_full_path(profile_path).await
{
io::remove_file(path.join(project_path)).await?;
}
Ok(())
}
}