* Hide dotfiles from instance content scanning Prevent hidden files such as .DS_Store from being treated as valid instance content. This updates the profile scanning logic in [packages/app-lib/src/state/profiles.rs](/Users/froggy/Downloads/code-main/packages/app-lib/src/state/profiles.rs#L420) to ignore basenames that start with '.', and applies that filter consistently in both scan paths. Signed-off-by: Corsican Frog <49497194+acorsicanfrog@users.noreply.github.com> * Whitelist scannable instance content files Only scan supported content archives into instance content. Accept .jar files for mods and .zip files for datapacks, resourcepacks, and shaderpacks, after trimming the .disabled suffix. This prevents .DS_Store and other unsupported files from appearing in the Content tab. Signed-off-by: Corsican Frog <49497194+acorsicanfrog@users.noreply.github.com> * Fmt --------- Signed-off-by: Corsican Frog <49497194+acorsicanfrog@users.noreply.github.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: François-X. T. <fetch@ferrous.ch>
1313 lines
43 KiB
Rust
1313 lines
43 KiB
Rust
use super::settings::{Hooks, MemorySettings, WindowSize};
|
|
use crate::profile::get_full_path;
|
|
use crate::state::server_join_log::JoinLogEntry;
|
|
use crate::state::{
|
|
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, cache_file_hash,
|
|
};
|
|
use crate::util;
|
|
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
|
|
use crate::util::io::{self};
|
|
use chrono::{DateTime, TimeDelta, TimeZone, Utc};
|
|
use dashmap::DashMap;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::SqlitePool;
|
|
use std::collections::HashSet;
|
|
use std::convert::TryFrom;
|
|
use std::convert::TryInto;
|
|
use std::path::Path;
|
|
use std::sync::LazyLock;
|
|
use tokio::fs::DirEntry;
|
|
use tokio::io::{AsyncBufReadExt, AsyncRead};
|
|
use tokio::task::JoinSet;
|
|
|
|
// Represent a Minecraft instance.
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
pub struct Profile {
|
|
pub path: String,
|
|
pub install_stage: ProfileInstallStage,
|
|
pub launcher_feature_version: LauncherFeatureVersion,
|
|
|
|
pub name: String,
|
|
pub icon_path: Option<String>,
|
|
|
|
pub game_version: String,
|
|
pub protocol_version: Option<u32>,
|
|
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, Copy, Debug, Eq, PartialEq, Ord, PartialOrd,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LauncherFeatureVersion {
|
|
None,
|
|
MigratedServerLastPlayTime,
|
|
MigratedLaunchHooks,
|
|
}
|
|
|
|
impl LauncherFeatureVersion {
|
|
pub const MOST_RECENT: Self = Self::MigratedLaunchHooks;
|
|
|
|
pub fn as_str(&self) -> &'static str {
|
|
match *self {
|
|
Self::None => "none",
|
|
Self::MigratedServerLastPlayTime => {
|
|
"migrated_server_last_play_time"
|
|
}
|
|
Self::MigratedLaunchHooks => "migrated_launch_hooks",
|
|
}
|
|
}
|
|
|
|
pub fn from_str(val: &str) -> Self {
|
|
match val {
|
|
"none" => Self::None,
|
|
"migrated_server_last_play_time" => {
|
|
Self::MigratedServerLastPlayTime
|
|
}
|
|
"migrated_launch_hooks" => Self::MigratedLaunchHooks,
|
|
_ => Self::None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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: impl AsRef<Path>) -> Option<Self> {
|
|
match path
|
|
.as_ref()
|
|
.parent()?
|
|
.file_name()?
|
|
.to_str()
|
|
.unwrap_or_default()
|
|
{
|
|
"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>,
|
|
launcher_feature_version: String,
|
|
}
|
|
|
|
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),
|
|
launcher_feature_version: LauncherFeatureVersion::from_str(
|
|
&x.launcher_feature_version,
|
|
),
|
|
name: x.name,
|
|
icon_path: x.icon_path,
|
|
game_version: x.game_version,
|
|
protocol_version: x.protocol_version.map(|x| x as u32),
|
|
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, launcher_feature_version, name, icon_path,
|
|
game_version, protocol_version, mod_loader, mod_loader_version,
|
|
json(groups) as "groups!: serde_json::Value",
|
|
linked_project_id, linked_version_id, locked,
|
|
created, modified, last_played,
|
|
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
|
|
)
|
|
};
|
|
}
|
|
|
|
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,
|
|
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 launcher_feature_version = self.launcher_feature_version.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, launcher_feature_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, $28
|
|
)
|
|
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,
|
|
launcher_feature_version = $28
|
|
",
|
|
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,
|
|
launcher_feature_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(
|
|
&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![];
|
|
let mut migrations = JoinSet::new();
|
|
|
|
for profile in &mut all {
|
|
let path = 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()
|
|
&& 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()
|
|
.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?;
|
|
}
|
|
|
|
if profile.launcher_feature_version
|
|
< LauncherFeatureVersion::MOST_RECENT
|
|
{
|
|
let state = state.clone();
|
|
let profile_path = profile.path.clone();
|
|
migrations.spawn(async move {
|
|
let Ok(Some(mut profile)) = Self::get(&profile_path, &state.pool).await else {
|
|
tracing::error!("Failed to find instance '{}' for migration", profile_path);
|
|
return;
|
|
};
|
|
drop(profile_path);
|
|
|
|
tracing::info!(
|
|
"Migrating profile '{}' from launcher feature version {:?} to {:?}",
|
|
profile.path, profile.launcher_feature_version, LauncherFeatureVersion::MOST_RECENT
|
|
);
|
|
loop {
|
|
let result = profile.perform_launcher_feature_migration(&state).await;
|
|
if result.is_err() || profile.launcher_feature_version == LauncherFeatureVersion::MOST_RECENT {
|
|
if let Err(err) = result {
|
|
tracing::error!("Failed to migrate instance '{}': {}", profile.path, err);
|
|
return;
|
|
}
|
|
if let Err(err) = profile.upsert(&state.pool).await {
|
|
tracing::error!("Failed to update instance '{}' migration state: {}", profile.path, err);
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
tracing::info!("Finished migration for profile '{}'", profile.path);
|
|
});
|
|
}
|
|
}
|
|
migrations.join_all().await;
|
|
|
|
let file_hashes = CachedEntry::get_file_hash_many(
|
|
&keys.iter().map(|s| &**s).collect::<Vec<_>>(),
|
|
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(())
|
|
}
|
|
|
|
async fn perform_launcher_feature_migration(
|
|
&mut self,
|
|
state: &crate::State,
|
|
) -> crate::Result<()> {
|
|
match self.launcher_feature_version {
|
|
LauncherFeatureVersion::None => {
|
|
if self.last_played.is_none() {
|
|
self.launcher_feature_version =
|
|
LauncherFeatureVersion::MigratedServerLastPlayTime;
|
|
return Ok(());
|
|
}
|
|
let mut join_log_entry = JoinLogEntry {
|
|
profile_path: self.path.clone(),
|
|
..Default::default()
|
|
};
|
|
let logs_path = state.directories.profile_logs_dir(&self.path);
|
|
let Ok(mut directory) = io::read_dir(&logs_path).await else {
|
|
self.launcher_feature_version =
|
|
LauncherFeatureVersion::MigratedServerLastPlayTime;
|
|
return Ok(());
|
|
};
|
|
let existing_joins_map =
|
|
super::server_join_log::get_joins(&self.path, &state.pool)
|
|
.await?;
|
|
let existing_joins = existing_joins_map
|
|
.keys()
|
|
.map(|x| (&x.0 as &str, x.1))
|
|
.collect::<HashSet<_>>();
|
|
while let Some(log_file) = directory.next_entry().await? {
|
|
if let Err(err) = Self::parse_log_file(
|
|
&log_file,
|
|
|host, port| existing_joins.contains(&(host, port)),
|
|
state,
|
|
&mut join_log_entry,
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
"Failed to parse log file '{}': {}",
|
|
log_file.path().display(),
|
|
err
|
|
);
|
|
}
|
|
}
|
|
self.launcher_feature_version =
|
|
LauncherFeatureVersion::MigratedServerLastPlayTime;
|
|
}
|
|
LauncherFeatureVersion::MigratedServerLastPlayTime => {
|
|
let quoter = shlex::Quoter::new().allow_nul(true);
|
|
|
|
// Previously split by spaces
|
|
if let Some(pre_launch) = self.hooks.pre_launch.as_ref() {
|
|
self.hooks.pre_launch =
|
|
Some(quoter.join(pre_launch.split(' ')).unwrap())
|
|
}
|
|
|
|
// Previously treated as complete path to command
|
|
if let Some(wrapper) = self.hooks.wrapper.as_ref() {
|
|
self.hooks.wrapper =
|
|
Some(quoter.quote(wrapper).unwrap().to_string())
|
|
}
|
|
|
|
// Previously split by spaces
|
|
if let Some(post_exit) = self.hooks.post_exit.as_ref() {
|
|
self.hooks.post_exit =
|
|
Some(quoter.join(post_exit.split(' ')).unwrap())
|
|
}
|
|
|
|
self.launcher_feature_version =
|
|
LauncherFeatureVersion::MigratedLaunchHooks;
|
|
}
|
|
LauncherFeatureVersion::MOST_RECENT => unreachable!(
|
|
"LauncherFeatureVersion::MOST_RECENT was not updated"
|
|
),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Parses a log file on a best-effort basis, using the log's creation time, rather than the
|
|
// actual times mentioned in the log file, which are missing date information.
|
|
async fn parse_log_file(
|
|
log_file: &DirEntry,
|
|
should_skip: impl Fn(&str, u16) -> bool,
|
|
state: &crate::State,
|
|
join_entry: &mut JoinLogEntry,
|
|
) -> crate::Result<()> {
|
|
let file_name = log_file.file_name();
|
|
let Some(file_name) = file_name.to_str() else {
|
|
return Ok(());
|
|
};
|
|
let log_time = io::metadata(&log_file.path()).await?.created()?.into();
|
|
if file_name == "latest.log" {
|
|
let file = io::open_file(&log_file.path()).await?;
|
|
Self::parse_open_log_file(
|
|
file,
|
|
should_skip,
|
|
log_time,
|
|
state,
|
|
join_entry,
|
|
)
|
|
.await
|
|
} else if file_name.ends_with(".log.gz") {
|
|
let file = io::open_file(&log_file.path()).await?;
|
|
let file = tokio::io::BufReader::new(file);
|
|
let file =
|
|
async_compression::tokio::bufread::GzipDecoder::new(file);
|
|
Self::parse_open_log_file(
|
|
file,
|
|
should_skip,
|
|
log_time,
|
|
state,
|
|
join_entry,
|
|
)
|
|
.await
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
async fn parse_open_log_file(
|
|
reader: impl AsyncRead + Unpin,
|
|
should_skip: impl Fn(&str, u16) -> bool,
|
|
mut log_time: DateTime<Utc>,
|
|
state: &crate::State,
|
|
join_entry: &mut JoinLogEntry,
|
|
) -> crate::Result<()> {
|
|
static LOG_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"^\[[0-9]{2}(?::[0-9]{2}){2}] \[.+?/[A-Z]+?]: Connecting to (.+?), ([1-9][0-9]{0,4})$").unwrap()
|
|
});
|
|
let reader = tokio::io::BufReader::new(reader);
|
|
let mut lines = reader.lines();
|
|
while let Some(log_line) = lines.next_line().await? {
|
|
let Some(log_line) = LOG_LINE_REGEX.captures(&log_line) else {
|
|
continue;
|
|
};
|
|
|
|
let Some(host) = log_line.get(1) else {
|
|
continue;
|
|
};
|
|
let host = host.as_str();
|
|
|
|
let Some(port) = log_line.get(2) else {
|
|
continue;
|
|
};
|
|
let Ok(port) = port.as_str().parse::<u16>() else {
|
|
continue;
|
|
};
|
|
|
|
if should_skip(host, port) {
|
|
continue;
|
|
}
|
|
|
|
join_entry.host = host.to_string();
|
|
join_entry.port = port;
|
|
join_entry.join_time = log_time;
|
|
join_entry.upsert(&state.pool).await?;
|
|
|
|
log_time += TimeDelta::seconds(1);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_projects(
|
|
&self,
|
|
cache_behaviour: Option<CacheBehaviour>,
|
|
pool: &SqlitePool,
|
|
fetch_semaphore: &FetchSemaphore,
|
|
) -> crate::Result<DashMap<String, ProfileFile>> {
|
|
let (keys, file_hashes) =
|
|
self.scan_and_hash(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 (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 mut keys_by_path: std::collections::HashMap<
|
|
String,
|
|
InitialScanFile,
|
|
> = keys.into_iter().map(|k| (k.path.clone(), k)).collect();
|
|
|
|
let file_info_by_hash: std::collections::HashMap<String, CachedFile> =
|
|
file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
|
|
|
|
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: 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)
|
|
}
|
|
|
|
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() {
|
|
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()
|
|
&& 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()
|
|
.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?;
|
|
|
|
Ok((keys, file_hashes))
|
|
}
|
|
|
|
fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String {
|
|
format!(
|
|
"{}-{}-{}",
|
|
file.hash,
|
|
file.project_type
|
|
.filter(|x| *x != ProjectType::Mod)
|
|
.map_or_else(
|
|
|| profile.loader.as_str().to_string(),
|
|
|x| x.get_loaders().join("+")
|
|
),
|
|
profile.game_version
|
|
)
|
|
}
|
|
|
|
#[tracing::instrument(skip(pool))]
|
|
pub async fn add_project_version(
|
|
profile_path: &str,
|
|
version_id: &str,
|
|
reason: util::fetch::DownloadReason,
|
|
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(),
|
|
};
|
|
|
|
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),
|
|
Some(&download_meta),
|
|
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.
|
|
///
|
|
/// 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 base = crate::api::profile::get_full_path(profile_path).await?;
|
|
|
|
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 {
|
|
return Err(crate::ErrorKind::FSError(format!(
|
|
"Could not find project file for '{project_path}' in profile"
|
|
))
|
|
.into());
|
|
};
|
|
|
|
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)
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
}
|