mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
refactor: content tab styling (#7091)
* refactor: managed content card * fix: update filter pill styling to be chips-like * fix: modpack exporting * fixed: vanilla server projs + header * refactor: filters * refactor: detect external file icons + other metadata inside * refactor: support json5 metadata * fix: qa * fix: qa * fix: fmt + prepr * fix: fmt * fix: qa * fix: headers * feat: locking * fix: clippy * fix: qa * fix: frozen loc * fix: shared instances diff calc break * fix: env filter + remove categories
This commit is contained in:
@@ -50,6 +50,7 @@ httpdate = { workspace = true }
|
||||
image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
|
||||
indicatif = { workspace = true, optional = true }
|
||||
itertools = { workspace = true }
|
||||
json5 = { workspace = true }
|
||||
modrinth-content-management = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
notify-debouncer-mini = { workspace = true }
|
||||
@@ -105,6 +106,7 @@ tokio = { workspace = true, features = [
|
||||
"time",
|
||||
] }
|
||||
tokio-util = { workspace = true, features = ["compat", "io", "io-util", "time"] }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-error = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE instance_content_locks (
|
||||
file_id TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (file_id),
|
||||
FOREIGN KEY (file_id) REFERENCES instance_files(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -35,8 +35,9 @@ pub use self::projects::{
|
||||
InstallProjectWithDependenciesRequest, add_project_from_path,
|
||||
add_project_from_version, install_project_with_dependencies,
|
||||
is_file_on_modrinth, remove_project, repair_managed_modrinth,
|
||||
switch_project_version_with_dependencies, toggle_disable_project,
|
||||
update_all_projects, update_managed_modrinth_version, update_project,
|
||||
set_project_locked, switch_project_version_with_dependencies,
|
||||
toggle_disable_project, update_all_projects,
|
||||
update_managed_modrinth_version, update_project,
|
||||
};
|
||||
pub use self::run::{
|
||||
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::pack::install_from::{
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, InstanceMetadata, ModLoader, SideType, State,
|
||||
VersionEnvironment,
|
||||
};
|
||||
use crate::util::io::{self, IOError};
|
||||
use async_zip::tokio::write::ZipFileWriter;
|
||||
@@ -413,6 +414,39 @@ fn pack_get_relative_path(
|
||||
)?)
|
||||
}
|
||||
|
||||
fn get_mrpack_environment(
|
||||
environment: Option<VersionEnvironment>,
|
||||
) -> HashMap<EnvType, SideType> {
|
||||
let (client, server) = match environment
|
||||
.unwrap_or(VersionEnvironment::Unknown)
|
||||
{
|
||||
VersionEnvironment::ClientAndServer
|
||||
| VersionEnvironment::SingleplayerOnly => {
|
||||
(SideType::Required, SideType::Required)
|
||||
}
|
||||
VersionEnvironment::ClientOnly => {
|
||||
(SideType::Required, SideType::Unsupported)
|
||||
}
|
||||
VersionEnvironment::ClientOnlyServerOptional => {
|
||||
(SideType::Required, SideType::Optional)
|
||||
}
|
||||
VersionEnvironment::ServerOnly
|
||||
| VersionEnvironment::DedicatedServerOnly => {
|
||||
(SideType::Unsupported, SideType::Required)
|
||||
}
|
||||
VersionEnvironment::ServerOnlyClientOptional => {
|
||||
(SideType::Optional, SideType::Required)
|
||||
}
|
||||
VersionEnvironment::ClientOrServer
|
||||
| VersionEnvironment::ClientOrServerPrefersBoth => {
|
||||
(SideType::Optional, SideType::Optional)
|
||||
}
|
||||
VersionEnvironment::Unknown => (SideType::Optional, SideType::Optional),
|
||||
};
|
||||
|
||||
HashMap::from([(EnvType::Client, client), (EnvType::Server, server)])
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_mrpack_json(
|
||||
metadata: &InstanceMetadata,
|
||||
@@ -461,9 +495,10 @@ pub async fn create_mrpack_json(
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let versions = CachedEntry::get_version_many(
|
||||
&projects.iter().map(|x| &*x.1).collect::<Vec<_>>(),
|
||||
None,
|
||||
let version_ids = projects.iter().map(|x| &*x.1).collect::<Vec<_>>();
|
||||
let versions = CachedEntry::get_version_v3_many(
|
||||
&version_ids,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
@@ -473,9 +508,7 @@ pub async fn create_mrpack_json(
|
||||
.filter_map(|(path, version_id)| {
|
||||
if let Some(version) = versions.iter().find(|x| x.id == version_id)
|
||||
{
|
||||
let mut env = HashMap::new();
|
||||
env.insert(EnvType::Client, SideType::Required);
|
||||
env.insert(EnvType::Server, SideType::Required);
|
||||
let env = get_mrpack_environment(version.environment);
|
||||
let Some(primary_file) = version.files.first() else {
|
||||
return Some(Err(crate::ErrorKind::OtherError(format!(
|
||||
"No primary file found for mod at: {path}"
|
||||
|
||||
@@ -60,6 +60,7 @@ pub async fn update_project(
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
ensure_project_not_frozen(instance_id, project_path, &state).await?;
|
||||
let path = crate::state::instances::commands::update_project(
|
||||
instance_id,
|
||||
project_path,
|
||||
@@ -197,6 +198,7 @@ pub async fn switch_project_version_with_dependencies(
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
ensure_project_not_frozen(instance_id, project_path, &state).await?;
|
||||
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
@@ -289,6 +291,27 @@ pub async fn remove_project(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn set_project_locked(
|
||||
instance_id: &str,
|
||||
project: &str,
|
||||
locked: bool,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
ensure_shared_instance_can_modify_project(instance_id, project, &state)
|
||||
.await?;
|
||||
crate::state::instances::commands::set_project_locked(
|
||||
instance_id,
|
||||
project,
|
||||
locked,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
emit_instance(instance_id, InstancePayloadType::Edited).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_shared_instance_can_modify_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
@@ -328,6 +351,28 @@ async fn ensure_shared_instance_can_modify_project(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_project_not_frozen(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
if crate::state::instances::commands::is_project_locked(
|
||||
instance_id,
|
||||
project_path,
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Frozen content cannot change versions. Unfreeze it first."
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn update_managed_modrinth_version(
|
||||
instance_id: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::state::ProjectType;
|
||||
use crate::state::{EmbeddedContentMetadata, ProjectType};
|
||||
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashSet;
|
||||
@@ -21,6 +21,7 @@ pub enum CacheValueType {
|
||||
Project,
|
||||
ProjectV3,
|
||||
Version,
|
||||
VersionV3,
|
||||
User,
|
||||
Team,
|
||||
Organization,
|
||||
@@ -37,6 +38,7 @@ pub enum CacheValueType {
|
||||
SearchResults,
|
||||
SearchResultsV3,
|
||||
ModpackFiles,
|
||||
EmbeddedContentMetadata,
|
||||
/// Cached list of versions for a project (without changelogs for fast loading)
|
||||
ProjectVersions,
|
||||
}
|
||||
@@ -47,6 +49,7 @@ impl CacheValueType {
|
||||
CacheValueType::Project => "project",
|
||||
CacheValueType::ProjectV3 => "project_v3",
|
||||
CacheValueType::Version => "version",
|
||||
CacheValueType::VersionV3 => "version_v3",
|
||||
CacheValueType::User => "user",
|
||||
CacheValueType::Team => "team",
|
||||
CacheValueType::Organization => "organization",
|
||||
@@ -63,6 +66,9 @@ impl CacheValueType {
|
||||
CacheValueType::SearchResults => "search_results",
|
||||
CacheValueType::SearchResultsV3 => "search_results_v3",
|
||||
CacheValueType::ModpackFiles => "modpack_files",
|
||||
CacheValueType::EmbeddedContentMetadata => {
|
||||
"embedded_content_metadata"
|
||||
}
|
||||
CacheValueType::ProjectVersions => "project_versions",
|
||||
}
|
||||
}
|
||||
@@ -72,6 +78,7 @@ impl CacheValueType {
|
||||
"project" => CacheValueType::Project,
|
||||
"project_v3" => CacheValueType::ProjectV3,
|
||||
"version" => CacheValueType::Version,
|
||||
"version_v3" => CacheValueType::VersionV3,
|
||||
"user" => CacheValueType::User,
|
||||
"team" => CacheValueType::Team,
|
||||
"organization" => CacheValueType::Organization,
|
||||
@@ -88,6 +95,9 @@ impl CacheValueType {
|
||||
"search_results" => CacheValueType::SearchResults,
|
||||
"search_results_v3" => CacheValueType::SearchResultsV3,
|
||||
"modpack_files" => CacheValueType::ModpackFiles,
|
||||
"embedded_content_metadata" => {
|
||||
CacheValueType::EmbeddedContentMetadata
|
||||
}
|
||||
"project_versions" => CacheValueType::ProjectVersions,
|
||||
_ => CacheValueType::Project,
|
||||
}
|
||||
@@ -100,7 +110,10 @@ impl CacheValueType {
|
||||
CacheValueType::FileHash => 30 * 24 * 60 * 60, // 30 days
|
||||
// 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)
|
||||
CacheValueType::ModpackFiles
|
||||
| CacheValueType::EmbeddedContentMetadata => {
|
||||
100 * 365 * 24 * 60 * 60 // 100 years (effectively never)
|
||||
}
|
||||
CacheValueType::SearchResults | CacheValueType::SearchResultsV3 => {
|
||||
10 * 60 // 10 minutes
|
||||
}
|
||||
@@ -134,6 +147,7 @@ impl CacheValueType {
|
||||
| CacheValueType::GameVersions
|
||||
| CacheValueType::DonationPlatforms
|
||||
| CacheValueType::Version
|
||||
| CacheValueType::VersionV3
|
||||
| CacheValueType::Team
|
||||
| CacheValueType::File
|
||||
| CacheValueType::LoaderManifest
|
||||
@@ -141,6 +155,7 @@ impl CacheValueType {
|
||||
| CacheValueType::SearchResults
|
||||
| CacheValueType::SearchResultsV3
|
||||
| CacheValueType::ModpackFiles
|
||||
| CacheValueType::EmbeddedContentMetadata
|
||||
| CacheValueType::ProjectVersions => None,
|
||||
}
|
||||
}
|
||||
@@ -162,6 +177,13 @@ pub struct CachedProjectVersions {
|
||||
pub versions: Vec<Version>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CachedEmbeddedContentMetadata {
|
||||
pub cache_key: String,
|
||||
pub hash: String,
|
||||
pub metadata: Option<EmbeddedContentMetadata>,
|
||||
}
|
||||
|
||||
// De/serialization strategy:
|
||||
// - on serialize:
|
||||
// - in the `cache` table, save the `data_type` (variant of this value) alongside
|
||||
@@ -181,6 +203,7 @@ pub struct CachedProjectVersions {
|
||||
pub enum CacheValue {
|
||||
Project(Project),
|
||||
Version(Version),
|
||||
VersionV3(VersionV3),
|
||||
User(User),
|
||||
Team(Vec<TeamMember>),
|
||||
Organization(Organization),
|
||||
@@ -197,6 +220,7 @@ pub enum CacheValue {
|
||||
SearchResults(SearchResults),
|
||||
SearchResultsV3(SearchResultsV3),
|
||||
ModpackFiles(CachedModpackFiles),
|
||||
EmbeddedContentMetadata(CachedEmbeddedContentMetadata),
|
||||
ProjectVersions(CachedProjectVersions),
|
||||
ProjectV3(ProjectV3),
|
||||
}
|
||||
@@ -543,6 +567,30 @@ pub struct Version {
|
||||
pub loaders: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct VersionV3 {
|
||||
pub id: String,
|
||||
pub files: Vec<VersionFile>,
|
||||
#[serde(default)]
|
||||
pub environment: Option<VersionEnvironment>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VersionEnvironment {
|
||||
ClientAndServer,
|
||||
ClientOnly,
|
||||
ClientOnlyServerOptional,
|
||||
SingleplayerOnly,
|
||||
ServerOnly,
|
||||
ServerOnlyClientOptional,
|
||||
DedicatedServerOnly,
|
||||
ClientOrServer,
|
||||
ClientOrServerPrefersBoth,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct VersionFile {
|
||||
pub hashes: HashMap<String, String>,
|
||||
@@ -661,6 +709,7 @@ impl CacheValue {
|
||||
CacheValue::Project(_) => CacheValueType::Project,
|
||||
CacheValue::ProjectV3(_) => CacheValueType::ProjectV3,
|
||||
CacheValue::Version(_) => CacheValueType::Version,
|
||||
CacheValue::VersionV3(_) => CacheValueType::VersionV3,
|
||||
CacheValue::User(_) => CacheValueType::User,
|
||||
CacheValue::Team { .. } => CacheValueType::Team,
|
||||
CacheValue::Organization(_) => CacheValueType::Organization,
|
||||
@@ -681,6 +730,9 @@ impl CacheValue {
|
||||
CacheValue::SearchResults(_) => CacheValueType::SearchResults,
|
||||
CacheValue::SearchResultsV3(_) => CacheValueType::SearchResultsV3,
|
||||
CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles,
|
||||
CacheValue::EmbeddedContentMetadata(_) => {
|
||||
CacheValueType::EmbeddedContentMetadata
|
||||
}
|
||||
CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions,
|
||||
}
|
||||
}
|
||||
@@ -690,6 +742,7 @@ impl CacheValue {
|
||||
CacheValue::Project(project) => project.id.clone(),
|
||||
CacheValue::ProjectV3(project) => project.id.clone(),
|
||||
CacheValue::Version(version) => version.id.clone(),
|
||||
CacheValue::VersionV3(version) => version.id.clone(),
|
||||
CacheValue::User(user) => user.id.clone(),
|
||||
CacheValue::Team(members) => members
|
||||
.iter()
|
||||
@@ -724,6 +777,9 @@ impl CacheValue {
|
||||
CacheValue::SearchResults(search) => search.search.clone(),
|
||||
CacheValue::SearchResultsV3(search) => search.search.clone(),
|
||||
CacheValue::ModpackFiles(files) => files.version_id.clone(),
|
||||
CacheValue::EmbeddedContentMetadata(metadata) => {
|
||||
metadata.cache_key.clone()
|
||||
}
|
||||
CacheValue::ProjectVersions(pv) => pv.project_id.clone(),
|
||||
}
|
||||
}
|
||||
@@ -746,6 +802,7 @@ impl CacheValue {
|
||||
| CacheValue::GameVersions(_)
|
||||
| CacheValue::DonationPlatforms(_)
|
||||
| CacheValue::Version(_)
|
||||
| CacheValue::VersionV3(_)
|
||||
| CacheValue::Team { .. }
|
||||
| CacheValue::File { .. }
|
||||
| CacheValue::LoaderManifest { .. }
|
||||
@@ -753,6 +810,7 @@ impl CacheValue {
|
||||
| CacheValue::SearchResults(_)
|
||||
| CacheValue::SearchResultsV3(_)
|
||||
| CacheValue::ModpackFiles(_)
|
||||
| CacheValue::EmbeddedContentMetadata(_)
|
||||
| CacheValue::ProjectVersions(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -762,6 +820,7 @@ impl CacheValue {
|
||||
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::VersionV3(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),
|
||||
@@ -788,6 +847,9 @@ impl CacheValue {
|
||||
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::EmbeddedContentMetadata(metadata) => {
|
||||
serde_json::to_value(metadata)
|
||||
}
|
||||
CacheValue::ProjectVersions(pv) => serde_json::to_value(pv),
|
||||
}
|
||||
.map_err(|err| {
|
||||
@@ -898,6 +960,7 @@ impl_cache_methods!(
|
||||
(Project, Project),
|
||||
(ProjectV3, ProjectV3),
|
||||
(Version, Version),
|
||||
(VersionV3, VersionV3),
|
||||
(User, User),
|
||||
(Team, Vec<TeamMember>),
|
||||
(Organization, Organization),
|
||||
@@ -905,6 +968,7 @@ impl_cache_methods!(
|
||||
(LoaderManifest, CachedLoaderManifest),
|
||||
(FileHash, CachedFileHash),
|
||||
(FileUpdate, CachedFileUpdate),
|
||||
(EmbeddedContentMetadata, CachedEmbeddedContentMetadata),
|
||||
(SearchResults, SearchResults),
|
||||
(SearchResultsV3, SearchResultsV3)
|
||||
);
|
||||
@@ -1274,6 +1338,15 @@ impl CachedEntry {
|
||||
CacheValue::Version
|
||||
)
|
||||
}
|
||||
CacheValueType::VersionV3 => {
|
||||
fetch_original_values!(
|
||||
VersionV3,
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
"versions",
|
||||
Some("/v3/versions"),
|
||||
CacheValue::VersionV3
|
||||
)
|
||||
}
|
||||
CacheValueType::User => {
|
||||
fetch_original_values!(
|
||||
User,
|
||||
@@ -1855,6 +1928,10 @@ impl CachedEntry {
|
||||
// not fetched from an external API
|
||||
vec![]
|
||||
}
|
||||
CacheValueType::EmbeddedContentMetadata => {
|
||||
// Embedded content metadata is populated from local archives.
|
||||
vec![]
|
||||
}
|
||||
CacheValueType::ProjectVersions => {
|
||||
let mut values = vec![];
|
||||
|
||||
@@ -1976,6 +2053,9 @@ impl CachedEntry {
|
||||
CacheValueType::Version => {
|
||||
CacheValue::Version(parse(data, id, "version")?)
|
||||
}
|
||||
CacheValueType::VersionV3 => {
|
||||
CacheValue::VersionV3(parse(data, id, "version_v3")?)
|
||||
}
|
||||
CacheValueType::User => CacheValue::User(parse(data, id, "user")?),
|
||||
CacheValueType::Team => CacheValue::Team(parse(data, id, "team")?),
|
||||
CacheValueType::Organization => {
|
||||
@@ -2018,6 +2098,13 @@ impl CachedEntry {
|
||||
CacheValueType::ModpackFiles => {
|
||||
CacheValue::ModpackFiles(parse(data, id, "modpack_files")?)
|
||||
}
|
||||
CacheValueType::EmbeddedContentMetadata => {
|
||||
CacheValue::EmbeddedContentMetadata(parse(
|
||||
data,
|
||||
id,
|
||||
"embedded_content_metadata",
|
||||
)?)
|
||||
}
|
||||
CacheValueType::ProjectVersions => CacheValue::ProjectVersions(
|
||||
parse(data, id, "project_versions")?,
|
||||
),
|
||||
|
||||
@@ -120,6 +120,7 @@ pub struct ContentFile {
|
||||
pub hash: String,
|
||||
pub file_name: String,
|
||||
pub enabled: bool,
|
||||
pub locked: bool,
|
||||
pub size: u64,
|
||||
pub metadata: Option<FileMetadata>,
|
||||
pub update_version_id: Option<String>,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::state::instances::{
|
||||
use crate::state::{ModLoader, ProjectType, ReleaseChannel};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use sqlx::{Executor, Sqlite, SqlitePool, Transaction};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
@@ -529,6 +530,89 @@ where
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_locked_instance_file_ids(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<HashSet<String>> {
|
||||
let file_ids = sqlx::query_scalar::<_, String>(
|
||||
"
|
||||
SELECT content_lock.file_id
|
||||
FROM instance_content_locks content_lock
|
||||
INNER JOIN instance_files file ON file.id = content_lock.file_id
|
||||
WHERE file.instance_id = ?
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(file_ids.into_iter().collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_instance_file_locked(
|
||||
instance_id: &str,
|
||||
relative_path: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<bool> {
|
||||
let locked = sqlx::query_scalar::<_, i64>(
|
||||
"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM instance_content_locks content_lock
|
||||
INNER JOIN instance_files file ON file.id = content_lock.file_id
|
||||
WHERE file.instance_id = ? AND file.relative_path = ?
|
||||
)
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(relative_path)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(locked != 0)
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_file_locked(
|
||||
instance_id: &str,
|
||||
relative_path: &str,
|
||||
locked: bool,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let file =
|
||||
get_instance_file_by_relative_path(instance_id, relative_path, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unknown content file {relative_path}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if locked {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_content_locks (file_id)
|
||||
VALUES (?)
|
||||
ON CONFLICT (file_id) DO NOTHING
|
||||
",
|
||||
)
|
||||
.bind(&file.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"
|
||||
DELETE FROM instance_content_locks
|
||||
WHERE file_id = ?
|
||||
",
|
||||
)
|
||||
.bind(&file.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_file_missing(
|
||||
file_id: &str,
|
||||
missing: bool,
|
||||
|
||||
@@ -513,7 +513,9 @@ pub(crate) async fn add_project_bytes(
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let project_type = match project_type {
|
||||
Some(project_type) => project_type,
|
||||
None => infer_project_type(&bytes)?,
|
||||
None => {
|
||||
super::embedded_content_metadata::infer_project_type_bytes(&bytes)?
|
||||
}
|
||||
};
|
||||
let relative_path = format!("{}/{}", project_type.get_folder(), file_name);
|
||||
let full_path =
|
||||
@@ -793,6 +795,37 @@ pub(crate) async fn content_source_kind_for_project_path(
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn is_project_locked(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<bool> {
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
content_rows::is_instance_file_locked(
|
||||
&scope.instance.id,
|
||||
project_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_project_locked(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
locked: bool,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let _content_lock = state.lock_instance_content(instance_id).await;
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
content_rows::set_instance_file_locked(
|
||||
&scope.instance.id,
|
||||
project_path,
|
||||
locked,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn rename_project_companion_file(
|
||||
instance_id: &str,
|
||||
old_project_path: &str,
|
||||
@@ -940,37 +973,3 @@ async fn upsert_entry_for_file(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn infer_project_type(bytes: &Bytes) -> crate::Result<ProjectType> {
|
||||
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()
|
||||
{
|
||||
Ok(ProjectType::Mod)
|
||||
} else if archive.by_name("pack.mcmeta").is_ok() {
|
||||
if archive.file_names().any(|name| name.starts_with("data/")) {
|
||||
Ok(ProjectType::DataPack)
|
||||
} else {
|
||||
Ok(ProjectType::ResourcePack)
|
||||
}
|
||||
} else if archive
|
||||
.file_names()
|
||||
.any(|name| name.starts_with("shaders/"))
|
||||
{
|
||||
Ok(ProjectType::ShaderPack)
|
||||
} else {
|
||||
Err(crate::ErrorKind::InputError(
|
||||
"Unable to infer project type for input file".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,10 +461,11 @@ async fn bulk_updateable_project_paths(
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
!shared_instance_member
|
||||
|| !item
|
||||
.source_kind
|
||||
.is_some_and(ContentSourceKind::is_shared_instance_managed)
|
||||
!item.locked
|
||||
&& (!shared_instance_member
|
||||
|| !item.source_kind.is_some_and(
|
||||
ContentSourceKind::is_shared_instance_managed,
|
||||
))
|
||||
})
|
||||
.map(|item| item.file_path)
|
||||
.collect())
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
use crate::state::{
|
||||
CacheValue, CachedEmbeddedContentMetadata, CachedEntry, ContentFile,
|
||||
EmbeddedContentMetadata, Instance, ModLoader, ProjectType, State,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
use std::path::Path;
|
||||
use toml::Value as TomlValue;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const MAX_METADATA_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_ICON_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const PREFERRED_ICON_SIZE: u32 = 96;
|
||||
const MAX_NAME_CHARS: usize = 256;
|
||||
const MAX_VERSION_CHARS: usize = 128;
|
||||
|
||||
pub(crate) struct ArchiveInspection {
|
||||
pub metadata: Option<EmbeddedContentMetadata>,
|
||||
pub icon: Option<Bytes>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ModMetadataKind {
|
||||
Fabric,
|
||||
Quilt,
|
||||
NeoForge,
|
||||
Forge,
|
||||
LegacyForge,
|
||||
}
|
||||
|
||||
pub(crate) fn infer_project_type_bytes(
|
||||
bytes: &Bytes,
|
||||
) -> crate::Result<ProjectType> {
|
||||
let mut archive = ZipArchive::new(Cursor::new(&**bytes)).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Unable to infer project type for input file".to_string(),
|
||||
)
|
||||
})?;
|
||||
infer_project_type(&mut archive)
|
||||
}
|
||||
|
||||
fn inspect_content_file(
|
||||
path: &Path,
|
||||
loader: ModLoader,
|
||||
) -> crate::Result<ArchiveInspection> {
|
||||
let file = File::open(path).map_err(|error| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Could not open content archive {}: {error}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
inspect_content_archive(file, Some(loader))
|
||||
}
|
||||
|
||||
fn inspect_content_archive<R: Read + Seek>(
|
||||
reader: R,
|
||||
loader: Option<ModLoader>,
|
||||
) -> crate::Result<ArchiveInspection> {
|
||||
let mut archive = ZipArchive::new(reader).map_err(|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Unable to infer project type for input file".to_string(),
|
||||
)
|
||||
})?;
|
||||
let project_type = infer_project_type(&mut archive)?;
|
||||
let (metadata, icon) = match project_type {
|
||||
ProjectType::Mod => inspect_mod(&mut archive, loader),
|
||||
ProjectType::DataPack | ProjectType::ResourcePack => {
|
||||
inspect_pack(&mut archive)
|
||||
}
|
||||
ProjectType::ShaderPack => (None, None),
|
||||
};
|
||||
|
||||
Ok(ArchiveInspection { metadata, icon })
|
||||
}
|
||||
|
||||
fn infer_project_type<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
) -> crate::Result<ProjectType> {
|
||||
if has_entry(archive, "fabric.mod.json")
|
||||
|| has_entry(archive, "quilt.mod.json")
|
||||
|| has_entry(archive, "META-INF/neoforge.mods.toml")
|
||||
|| has_entry(archive, "META-INF/mods.toml")
|
||||
|| has_entry(archive, "mcmod.info")
|
||||
{
|
||||
Ok(ProjectType::Mod)
|
||||
} else if has_entry(archive, "pack.mcmeta") {
|
||||
if archive.file_names().any(|name| name.starts_with("data/")) {
|
||||
Ok(ProjectType::DataPack)
|
||||
} else {
|
||||
Ok(ProjectType::ResourcePack)
|
||||
}
|
||||
} else if archive
|
||||
.file_names()
|
||||
.any(|name| name.starts_with("shaders/"))
|
||||
{
|
||||
Ok(ProjectType::ShaderPack)
|
||||
} else {
|
||||
Err(crate::ErrorKind::InputError(
|
||||
"Unable to infer project type for input file".to_string(),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_pack<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
) -> (Option<EmbeddedContentMetadata>, Option<Bytes>) {
|
||||
let icon = read_icon_entry(archive, "pack.png");
|
||||
let metadata = EmbeddedContentMetadata::default();
|
||||
|
||||
if metadata.is_empty() && icon.is_none() {
|
||||
(None, None)
|
||||
} else {
|
||||
(Some(metadata), icon)
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_mod<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
loader: Option<ModLoader>,
|
||||
) -> (Option<EmbeddedContentMetadata>, Option<Bytes>) {
|
||||
let manifest = read_manifest(archive);
|
||||
let order = metadata_order(loader);
|
||||
let parsed = order.iter().find_map(|kind| match kind {
|
||||
ModMetadataKind::Fabric => parse_fabric_metadata(archive),
|
||||
ModMetadataKind::Quilt => parse_quilt_metadata(archive),
|
||||
ModMetadataKind::NeoForge => parse_toml_metadata(
|
||||
archive,
|
||||
"META-INF/neoforge.mods.toml",
|
||||
&manifest,
|
||||
),
|
||||
ModMetadataKind::Forge => {
|
||||
parse_toml_metadata(archive, "META-INF/mods.toml", &manifest)
|
||||
}
|
||||
ModMetadataKind::LegacyForge => parse_legacy_forge_metadata(archive),
|
||||
});
|
||||
let (mut metadata, icon_path) =
|
||||
parsed.unwrap_or_else(|| (EmbeddedContentMetadata::default(), None));
|
||||
|
||||
if metadata.name.is_none() {
|
||||
metadata.name = manifest
|
||||
.get("implementation-title")
|
||||
.and_then(|value| clean_string(value, MAX_NAME_CHARS));
|
||||
}
|
||||
if metadata.version.is_none() {
|
||||
metadata.version = manifest
|
||||
.get("implementation-version")
|
||||
.and_then(|value| clean_string(value, MAX_VERSION_CHARS));
|
||||
}
|
||||
|
||||
let icon = icon_path
|
||||
.as_deref()
|
||||
.and_then(|path| read_icon_entry(archive, path));
|
||||
if metadata.is_empty() && icon.is_none() {
|
||||
(None, None)
|
||||
} else {
|
||||
(Some(metadata), icon)
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_order(loader: Option<ModLoader>) -> [ModMetadataKind; 5] {
|
||||
match loader {
|
||||
Some(ModLoader::Fabric) => [
|
||||
ModMetadataKind::Fabric,
|
||||
ModMetadataKind::Quilt,
|
||||
ModMetadataKind::NeoForge,
|
||||
ModMetadataKind::Forge,
|
||||
ModMetadataKind::LegacyForge,
|
||||
],
|
||||
Some(ModLoader::Quilt) => [
|
||||
ModMetadataKind::Quilt,
|
||||
ModMetadataKind::Fabric,
|
||||
ModMetadataKind::NeoForge,
|
||||
ModMetadataKind::Forge,
|
||||
ModMetadataKind::LegacyForge,
|
||||
],
|
||||
Some(ModLoader::Forge) => [
|
||||
ModMetadataKind::Forge,
|
||||
ModMetadataKind::LegacyForge,
|
||||
ModMetadataKind::NeoForge,
|
||||
ModMetadataKind::Fabric,
|
||||
ModMetadataKind::Quilt,
|
||||
],
|
||||
Some(ModLoader::NeoForge) => [
|
||||
ModMetadataKind::NeoForge,
|
||||
ModMetadataKind::Forge,
|
||||
ModMetadataKind::LegacyForge,
|
||||
ModMetadataKind::Fabric,
|
||||
ModMetadataKind::Quilt,
|
||||
],
|
||||
Some(ModLoader::Vanilla) | None => [
|
||||
ModMetadataKind::Fabric,
|
||||
ModMetadataKind::Quilt,
|
||||
ModMetadataKind::NeoForge,
|
||||
ModMetadataKind::Forge,
|
||||
ModMetadataKind::LegacyForge,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_fabric_metadata<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
) -> Option<(EmbeddedContentMetadata, Option<String>)> {
|
||||
let root = read_json_entry(archive, "fabric.mod.json")?;
|
||||
let metadata = EmbeddedContentMetadata {
|
||||
name: root
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.or_else(|| root.get("id").and_then(JsonValue::as_str))
|
||||
.and_then(|value| clean_string(value, MAX_NAME_CHARS)),
|
||||
version: root
|
||||
.get("version")
|
||||
.and_then(json_scalar_string)
|
||||
.and_then(|value| clean_string(&value, MAX_VERSION_CHARS)),
|
||||
..Default::default()
|
||||
};
|
||||
let icon = root.get("icon").and_then(json_icon_path);
|
||||
Some((metadata, icon))
|
||||
}
|
||||
|
||||
fn parse_quilt_metadata<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
) -> Option<(EmbeddedContentMetadata, Option<String>)> {
|
||||
let text = read_text_entry(archive, "quilt.mod.json")?;
|
||||
let root = json5::from_str::<JsonValue>(&text).ok()?;
|
||||
let loader = root.get("quilt_loader").unwrap_or(&root);
|
||||
let display = loader.get("metadata").unwrap_or(loader);
|
||||
let metadata = EmbeddedContentMetadata {
|
||||
name: display
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.or_else(|| loader.get("id").and_then(JsonValue::as_str))
|
||||
.and_then(|value| clean_string(value, MAX_NAME_CHARS)),
|
||||
version: loader
|
||||
.get("version")
|
||||
.and_then(json_scalar_string)
|
||||
.and_then(|value| clean_string(&value, MAX_VERSION_CHARS)),
|
||||
..Default::default()
|
||||
};
|
||||
let icon = display
|
||||
.get("icon")
|
||||
.or_else(|| loader.get("icon"))
|
||||
.and_then(json_icon_path);
|
||||
Some((metadata, icon))
|
||||
}
|
||||
|
||||
fn parse_toml_metadata<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
path: &str,
|
||||
manifest: &HashMap<String, String>,
|
||||
) -> Option<(EmbeddedContentMetadata, Option<String>)> {
|
||||
let text = read_text_entry(archive, path)?;
|
||||
let root = toml::from_str::<TomlValue>(&text).ok()?;
|
||||
let mod_table = root.get("mods")?.as_array()?.first()?.as_table()?;
|
||||
let name = mod_table
|
||||
.get("displayName")
|
||||
.or_else(|| mod_table.get("modId"))
|
||||
.and_then(toml_scalar_string)
|
||||
.and_then(|value| clean_string(&value, MAX_NAME_CHARS));
|
||||
let version = mod_table
|
||||
.get("version")
|
||||
.and_then(toml_scalar_string)
|
||||
.and_then(|value| resolve_toml_value(&value, &root, manifest))
|
||||
.and_then(|value| clean_string(&value, MAX_VERSION_CHARS));
|
||||
let icon = mod_table
|
||||
.get("logoFile")
|
||||
.and_then(TomlValue::as_str)
|
||||
.and_then(safe_archive_path);
|
||||
|
||||
Some((
|
||||
EmbeddedContentMetadata {
|
||||
name,
|
||||
version,
|
||||
..Default::default()
|
||||
},
|
||||
icon,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_legacy_forge_metadata<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
) -> Option<(EmbeddedContentMetadata, Option<String>)> {
|
||||
let root = read_json_entry(archive, "mcmod.info")?;
|
||||
let entry = root
|
||||
.as_array()
|
||||
.and_then(|entries| entries.first())
|
||||
.or_else(|| {
|
||||
root.get("modList")
|
||||
.and_then(JsonValue::as_array)
|
||||
.and_then(|entries| entries.first())
|
||||
})?;
|
||||
let metadata = EmbeddedContentMetadata {
|
||||
name: entry
|
||||
.get("name")
|
||||
.and_then(JsonValue::as_str)
|
||||
.or_else(|| entry.get("modid").and_then(JsonValue::as_str))
|
||||
.and_then(|value| clean_string(value, MAX_NAME_CHARS)),
|
||||
version: entry
|
||||
.get("version")
|
||||
.and_then(json_scalar_string)
|
||||
.and_then(|value| clean_string(&value, MAX_VERSION_CHARS)),
|
||||
..Default::default()
|
||||
};
|
||||
let icon = entry
|
||||
.get("logoFile")
|
||||
.and_then(JsonValue::as_str)
|
||||
.and_then(safe_archive_path);
|
||||
Some((metadata, icon))
|
||||
}
|
||||
|
||||
fn resolve_toml_value(
|
||||
value: &str,
|
||||
root: &TomlValue,
|
||||
manifest: &HashMap<String, String>,
|
||||
) -> Option<String> {
|
||||
if value == "${file.jarVersion}" {
|
||||
return manifest.get("implementation-version").cloned();
|
||||
}
|
||||
if let Some(key) = value
|
||||
.strip_prefix("${file.")
|
||||
.and_then(|value| value.strip_suffix('}'))
|
||||
{
|
||||
return root
|
||||
.get("properties")
|
||||
.and_then(TomlValue::as_table)
|
||||
.and_then(|properties| properties.get(key))
|
||||
.and_then(toml_scalar_string);
|
||||
}
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
fn json_icon_path(value: &JsonValue) -> Option<String> {
|
||||
if let Some(path) = value.as_str() {
|
||||
return safe_archive_path(path);
|
||||
}
|
||||
let icons = value.as_object()?;
|
||||
let mut candidates = icons
|
||||
.iter()
|
||||
.filter_map(|(size, path)| {
|
||||
Some((
|
||||
size.parse::<u32>().ok()?,
|
||||
safe_archive_path(path.as_str()?)?,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort_by_key(|(size, _)| *size);
|
||||
candidates
|
||||
.iter()
|
||||
.find(|(size, _)| *size >= PREFERRED_ICON_SIZE)
|
||||
.or_else(|| candidates.last())
|
||||
.map(|(_, path)| path.clone())
|
||||
}
|
||||
|
||||
fn safe_archive_path(path: &str) -> Option<String> {
|
||||
let path = path.trim().trim_start_matches('/');
|
||||
if path.is_empty()
|
||||
|| path.contains('\0')
|
||||
|| path.contains('\\')
|
||||
|| path
|
||||
.split('/')
|
||||
.any(|part| part.is_empty() || part == "." || part == "..")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(path.to_string())
|
||||
}
|
||||
|
||||
fn clean_string(value: &str, max_chars: usize) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.contains("${") {
|
||||
return None;
|
||||
}
|
||||
let value = value
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(max_chars)
|
||||
.collect::<String>();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
fn json_scalar_string(value: &JsonValue) -> Option<String> {
|
||||
match value {
|
||||
JsonValue::String(value) => Some(value.clone()),
|
||||
JsonValue::Number(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_scalar_string(value: &TomlValue) -> Option<String> {
|
||||
match value {
|
||||
TomlValue::String(value) => Some(value.clone()),
|
||||
TomlValue::Integer(value) => Some(value.to_string()),
|
||||
TomlValue::Float(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_manifest<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
) -> HashMap<String, String> {
|
||||
let Some(text) = read_text_entry(archive, "META-INF/MANIFEST.MF") else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let mut attributes: HashMap<String, String> = HashMap::new();
|
||||
let mut current_key: Option<String> = None;
|
||||
for line in text.lines() {
|
||||
if let Some(continuation) = line.strip_prefix(' ') {
|
||||
if let Some(key) = current_key.as_ref()
|
||||
&& let Some(value) = attributes.get_mut(key)
|
||||
{
|
||||
value.push_str(continuation);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
current_key = None;
|
||||
continue;
|
||||
};
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
attributes.insert(key.clone(), value.trim_start().to_string());
|
||||
current_key = Some(key);
|
||||
}
|
||||
attributes
|
||||
}
|
||||
|
||||
fn has_entry<R: Read + Seek>(archive: &mut ZipArchive<R>, path: &str) -> bool {
|
||||
archive.by_name(path).is_ok()
|
||||
}
|
||||
|
||||
fn read_json_entry<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
path: &str,
|
||||
) -> Option<JsonValue> {
|
||||
let text = read_text_entry(archive, path)?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
fn read_text_entry<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
path: &str,
|
||||
) -> Option<String> {
|
||||
let bytes = read_entry(archive, path, MAX_METADATA_BYTES)?;
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
fn read_icon_entry<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
path: &str,
|
||||
) -> Option<Bytes> {
|
||||
let path = safe_archive_path(path)?;
|
||||
read_entry(archive, &path, MAX_ICON_BYTES).map(Bytes::from)
|
||||
}
|
||||
|
||||
fn read_entry<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
path: &str,
|
||||
max_bytes: u64,
|
||||
) -> Option<Vec<u8>> {
|
||||
let entry = archive.by_name(path).ok()?;
|
||||
if entry.size() > max_bytes {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(entry.size() as usize);
|
||||
entry
|
||||
.take(max_bytes.saturating_add(1))
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
(bytes.len() as u64 <= max_bytes).then_some(bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_embedded_content_metadata(
|
||||
instance: &Instance,
|
||||
loader: ModLoader,
|
||||
files: &[(String, ContentFile)],
|
||||
state: &State,
|
||||
) -> crate::Result<HashMap<String, EmbeddedContentMetadata>> {
|
||||
let candidates = files
|
||||
.iter()
|
||||
.filter(|(_, file)| {
|
||||
file.metadata.is_none()
|
||||
&& matches!(
|
||||
file.project_type,
|
||||
ProjectType::Mod
|
||||
| ProjectType::DataPack
|
||||
| ProjectType::ResourcePack
|
||||
)
|
||||
})
|
||||
.map(|(relative_path, file)| {
|
||||
(
|
||||
file.hash.clone(),
|
||||
state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&instance.path)
|
||||
.join(relative_path),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let cache_keys = candidates
|
||||
.keys()
|
||||
.map(|hash| metadata_cache_key(hash, loader))
|
||||
.collect::<Vec<_>>();
|
||||
let cache_key_refs =
|
||||
cache_keys.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let cached = CachedEntry::get_embedded_content_metadata_many(
|
||||
&cache_key_refs,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let mut resolved = HashMap::new();
|
||||
let mut resolved_hashes = HashSet::new();
|
||||
for cached in cached {
|
||||
let icon_exists = cached
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.icon_path.as_deref())
|
||||
.is_none_or(|path| Path::new(path).is_file());
|
||||
if icon_exists {
|
||||
resolved_hashes.insert(cached.hash.clone());
|
||||
if let Some(metadata) = cached.metadata {
|
||||
resolved.insert(cached.hash, metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pending = candidates
|
||||
.into_iter()
|
||||
.filter(|(hash, _)| !resolved_hashes.contains(hash))
|
||||
.collect::<Vec<_>>();
|
||||
let inspected_metadata = stream::iter(pending)
|
||||
.map(|(hash, path)| async move {
|
||||
let inspection = tokio::task::spawn_blocking(move || {
|
||||
inspect_content_file(&path, loader)
|
||||
})
|
||||
.await;
|
||||
let (mut metadata, icon) = match inspection {
|
||||
Ok(Ok(inspection)) => {
|
||||
(inspection.metadata.unwrap_or_default(), inspection.icon)
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::debug!(
|
||||
hash,
|
||||
error = %error,
|
||||
"Could not inspect content metadata"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
hash,
|
||||
error = %error,
|
||||
"Content metadata inspection task failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Some(icon) = icon {
|
||||
match crate::api::instance::cache_icon(icon, state).await {
|
||||
Ok(path) => {
|
||||
metadata.icon_path =
|
||||
Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
hash,
|
||||
error = %error,
|
||||
"Could not cache embedded content icon"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let metadata = (!metadata.is_empty()).then_some(metadata);
|
||||
Some((hash, metadata))
|
||||
})
|
||||
.buffer_unordered(8)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
let mut entries = Vec::with_capacity(inspected_metadata.len());
|
||||
for (hash, metadata) in inspected_metadata.into_iter().flatten() {
|
||||
if let Some(metadata) = metadata.as_ref() {
|
||||
resolved.insert(hash.clone(), metadata.clone());
|
||||
}
|
||||
entries.push(
|
||||
CacheValue::EmbeddedContentMetadata(
|
||||
CachedEmbeddedContentMetadata {
|
||||
cache_key: metadata_cache_key(&hash, loader),
|
||||
hash,
|
||||
metadata,
|
||||
},
|
||||
)
|
||||
.get_entry(),
|
||||
);
|
||||
}
|
||||
CachedEntry::upsert_many(&entries, &state.pool).await?;
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn metadata_cache_key(hash: &str, loader: ModLoader) -> String {
|
||||
format!("{hash}-{}", loader.as_str())
|
||||
}
|
||||
@@ -12,7 +12,8 @@ use crate::state::{
|
||||
CacheBehaviour, CachedEntry, CachedFile, ContentFile, ContentItem,
|
||||
ContentItemOwner, ContentItemProject, ContentItemVersion, Dependency,
|
||||
LinkedModpackInfo, ModLoader, Organization, OwnerType, Project,
|
||||
ProjectType, ReleaseChannel, TeamMember, Version,
|
||||
ProjectType, ReleaseChannel, TeamMember, Version, VersionEnvironment,
|
||||
VersionV3,
|
||||
};
|
||||
use crate::util::fetch::{
|
||||
DownloadMeta, DownloadReason, FetchSemaphore, fetch_mirrors, sha1_async,
|
||||
@@ -247,6 +248,7 @@ pub(crate) async fn list_content(
|
||||
|
||||
content_files_to_content_items(
|
||||
&resolved.instance,
|
||||
resolved.content_set.loader,
|
||||
&files,
|
||||
cache_behaviour,
|
||||
state,
|
||||
@@ -287,6 +289,7 @@ pub(crate) async fn list_linked_modpack_content(
|
||||
|
||||
return content_files_to_content_items(
|
||||
&resolved.instance,
|
||||
resolved.content_set.loader,
|
||||
&files,
|
||||
cache_behaviour,
|
||||
state,
|
||||
@@ -322,6 +325,7 @@ pub(crate) async fn list_linked_modpack_content(
|
||||
|
||||
content_files_to_content_items(
|
||||
&resolved.instance,
|
||||
resolved.content_set.loader,
|
||||
&files,
|
||||
cache_behaviour,
|
||||
state,
|
||||
@@ -507,13 +511,9 @@ pub(crate) async fn dependencies_to_content_items(
|
||||
.map(|file| file.size as u64)
|
||||
.unwrap_or(0),
|
||||
enabled: true,
|
||||
locked: false,
|
||||
project_type,
|
||||
project: Some(ContentItemProject {
|
||||
id: project.id.clone(),
|
||||
slug: project.slug.clone(),
|
||||
title: project.title.clone(),
|
||||
icon_url: project.icon_url.clone(),
|
||||
}),
|
||||
project: Some(content_item_project(project)),
|
||||
version: version.map(|version| ContentItemVersion {
|
||||
id: version.id.clone(),
|
||||
version_number: version.version_number.clone(),
|
||||
@@ -524,11 +524,16 @@ pub(crate) async fn dependencies_to_content_items(
|
||||
.unwrap_or_default(),
|
||||
date_published: Some(version.date_published.to_rfc3339()),
|
||||
}),
|
||||
environment: resolve_environment(
|
||||
dependency.version_id.as_deref(),
|
||||
&meta.versions_v3,
|
||||
),
|
||||
owner,
|
||||
has_update: false,
|
||||
update_version_id: None,
|
||||
date_added: None,
|
||||
source_kind: None,
|
||||
embedded_metadata: None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -604,6 +609,11 @@ async fn content_projects_for_scope(
|
||||
entry.file_id.as_deref().map(|file_id| (file_id, entry))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let locked_file_ids = sqlite::content_rows::get_locked_instance_file_ids(
|
||||
&resolved.instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let hashes = files
|
||||
.iter()
|
||||
.map(|file| file.sha1.as_str())
|
||||
@@ -736,6 +746,7 @@ async fn content_projects_for_scope(
|
||||
enabled: entry.map_or(file.enabled, |entry| {
|
||||
entry.enabled && file.enabled
|
||||
}),
|
||||
locked: locked_file_ids.contains(&file.id),
|
||||
size: file.size,
|
||||
metadata: file_metadata_from_entry_or_cache(entry, metadata),
|
||||
project_type,
|
||||
@@ -812,6 +823,7 @@ fn file_update_cache_key(
|
||||
|
||||
async fn content_files_to_content_items(
|
||||
instance: &Instance,
|
||||
loader: ModLoader,
|
||||
files: &[(String, ContentFile)],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
state: &State,
|
||||
@@ -840,6 +852,11 @@ async fn content_files_to_content_items(
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let embedded_metadata =
|
||||
super::embedded_content_metadata::resolve_embedded_content_metadata(
|
||||
instance, loader, files, state,
|
||||
)
|
||||
.await?;
|
||||
let instance_path = state.directories.instances_dir().join(&instance.path);
|
||||
let paths = files
|
||||
.iter()
|
||||
@@ -885,19 +902,21 @@ async fn content_files_to_content_items(
|
||||
id: file.hash.clone(),
|
||||
size: file.size,
|
||||
enabled: file.enabled,
|
||||
locked: file.locked,
|
||||
project_type: file.project_type,
|
||||
project: project.map(|project| ContentItemProject {
|
||||
id: project.id.clone(),
|
||||
slug: project.slug.clone(),
|
||||
title: project.title.clone(),
|
||||
icon_url: project.icon_url.clone(),
|
||||
}),
|
||||
project: project.map(content_item_project),
|
||||
version: version.map(|version| ContentItemVersion {
|
||||
id: version.id.clone(),
|
||||
version_number: version.version_number.clone(),
|
||||
file_name: file.file_name.clone(),
|
||||
date_published: Some(version.date_published.to_rfc3339()),
|
||||
}),
|
||||
environment: resolve_environment(
|
||||
file.metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.version_id.as_str()),
|
||||
&meta.versions_v3,
|
||||
),
|
||||
owner,
|
||||
has_update: file.update_version_id.is_some()
|
||||
&& !file.source_kind.is_some_and(
|
||||
@@ -906,6 +925,7 @@ async fn content_files_to_content_items(
|
||||
update_version_id: file.update_version_id.clone(),
|
||||
date_added: modification_times[index].clone(),
|
||||
source_kind: file.source_kind,
|
||||
embedded_metadata: embedded_metadata.get(&file.hash).cloned(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -917,6 +937,7 @@ async fn content_files_to_content_items(
|
||||
struct ResolvedMetadata {
|
||||
projects: Vec<Project>,
|
||||
versions: Vec<Version>,
|
||||
versions_v3: Vec<VersionV3>,
|
||||
teams: Vec<Vec<TeamMember>>,
|
||||
organizations: Vec<Organization>,
|
||||
}
|
||||
@@ -932,7 +953,7 @@ async fn resolve_metadata(
|
||||
project_ids.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let version_id_refs =
|
||||
version_ids.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let (projects, versions) =
|
||||
let (projects, versions, versions_v3) =
|
||||
if !project_ids.is_empty() || !version_ids.is_empty() {
|
||||
tokio::try_join!(
|
||||
async {
|
||||
@@ -960,10 +981,23 @@ async fn resolve_metadata(
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
async {
|
||||
if version_ids.is_empty() {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
CachedEntry::get_version_v3_many(
|
||||
&version_id_refs,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
)?
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
let team_ids = projects
|
||||
.iter()
|
||||
@@ -1012,11 +1046,23 @@ async fn resolve_metadata(
|
||||
Ok(ResolvedMetadata {
|
||||
projects,
|
||||
versions,
|
||||
versions_v3,
|
||||
teams,
|
||||
organizations,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_environment(
|
||||
version_id: Option<&str>,
|
||||
versions: &[VersionV3],
|
||||
) -> Option<VersionEnvironment> {
|
||||
let version_id = version_id?;
|
||||
versions
|
||||
.iter()
|
||||
.find(|version| version.id == version_id)
|
||||
.and_then(|version| version.environment)
|
||||
}
|
||||
|
||||
fn resolve_owner(
|
||||
project: &Project,
|
||||
teams: &[Vec<TeamMember>],
|
||||
@@ -1049,6 +1095,18 @@ fn resolve_owner(
|
||||
}
|
||||
}
|
||||
|
||||
fn content_item_project(project: &Project) -> ContentItemProject {
|
||||
ContentItemProject {
|
||||
id: project.id.clone(),
|
||||
slug: project.slug.clone(),
|
||||
title: project.title.clone(),
|
||||
icon_url: project.icon_url.clone(),
|
||||
license: project.license.clone(),
|
||||
categories: project.categories.clone(),
|
||||
additional_categories: project.additional_categories.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_metadata_from_entry_or_cache(
|
||||
entry: Option<&ContentEntry>,
|
||||
cached: Option<CachedFile>,
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(crate) use self::list_content::{
|
||||
list_linked_modpack_content,
|
||||
};
|
||||
|
||||
mod embedded_content_metadata;
|
||||
|
||||
mod remove_instance;
|
||||
pub(crate) use self::remove_instance::*;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::ContentSourceKind;
|
||||
use crate::state::{Project, ProjectType, Version};
|
||||
use crate::state::{
|
||||
License, Project, ProjectType, Version, VersionEnvironment,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -9,14 +11,32 @@ pub struct ContentItem {
|
||||
pub id: String,
|
||||
pub size: u64,
|
||||
pub enabled: bool,
|
||||
pub locked: bool,
|
||||
pub project_type: ProjectType,
|
||||
pub project: Option<ContentItemProject>,
|
||||
pub version: Option<ContentItemVersion>,
|
||||
pub environment: Option<VersionEnvironment>,
|
||||
pub owner: Option<ContentItemOwner>,
|
||||
pub has_update: bool,
|
||||
pub update_version_id: Option<String>,
|
||||
pub date_added: Option<String>,
|
||||
pub source_kind: Option<ContentSourceKind>,
|
||||
pub embedded_metadata: Option<EmbeddedContentMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct EmbeddedContentMetadata {
|
||||
pub name: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
}
|
||||
|
||||
impl EmbeddedContentMetadata {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.name.is_none()
|
||||
&& self.version.is_none()
|
||||
&& self.icon_path.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -25,6 +45,9 @@ pub struct ContentItemProject {
|
||||
pub slug: Option<String>,
|
||||
pub title: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub license: License,
|
||||
pub categories: Vec<String>,
|
||||
pub additional_categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
||||
@@ -11,8 +11,8 @@ pub use self::commands::{
|
||||
InstanceLaunchOverridesPatch, InstanceMetadata,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
|
||||
quarantine_shared_instance, set_shared_instance_sync_status,
|
||||
attach_shared_instance, clear_shared_instance, quarantine_shared_instance,
|
||||
set_shared_instance_sync_status,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
create_instance, edit_instance, get_instance, get_instances_metadata,
|
||||
|
||||
@@ -138,7 +138,7 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
};
|
||||
if let Some(event) = event {
|
||||
let emit_instance_id = instance_id.clone();
|
||||
let mark_shared_stale = first_file_name
|
||||
let sync_content = first_file_name
|
||||
.as_ref()
|
||||
.is_some_and(|name| {
|
||||
ProjectType::iterator().any(
|
||||
@@ -150,18 +150,18 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
)
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
if mark_shared_stale
|
||||
if sync_content
|
||||
&& let Ok(state) =
|
||||
State::get().await
|
||||
&& let Err(error) =
|
||||
crate::state::mark_shared_instance_stale(
|
||||
crate::state::sync_content_files(
|
||||
&emit_instance_id,
|
||||
&state.pool,
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to mark shared instance stale after filesystem sync: {error}"
|
||||
"Failed to sync instance content after filesystem change: {error}"
|
||||
);
|
||||
}
|
||||
let _ = emit_instance(
|
||||
|
||||
Reference in New Issue
Block a user