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:
Calum H.
2026-08-13 14:55:35 +00:00
committed by GitHub
parent e087e571f3
commit 06b7c44dcc
126 changed files with 3654 additions and 3473 deletions
+3 -2
View File
@@ -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,
+89 -2
View File
@@ -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)]
+2 -2
View File
@@ -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(