feat: instance icon improvements

This commit is contained in:
Calum H. (IMB11)
2026-07-27 14:28:11 +01:00
parent 1c22503d01
commit c8b21fe120
13 changed files with 671 additions and 162 deletions
+2
View File
@@ -49,6 +49,7 @@ hickory-resolver = { workspace = true }
httpdate = { workspace = true }
indicatif = { workspace = true, optional = true }
itertools = { workspace = true }
image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
modrinth-content-management = { workspace = true }
notify = { workspace = true }
notify-debouncer-mini = { workspace = true }
@@ -74,6 +75,7 @@ reqwest = { workspace = true, features = [
"rustls-tls-webpki-roots",
"stream",
] }
resvg = { workspace = true }
rgb = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_ini = { workspace = true }
+6 -1
View File
@@ -4,6 +4,7 @@ mod content;
mod content_set_diff;
mod export_mrpack;
mod get;
mod icon;
mod install;
mod lifecycle;
mod paths;
@@ -21,9 +22,13 @@ pub use self::export_mrpack::{
create_mrpack_json, export_mrpack, get_pack_export_candidates,
};
pub use self::get::{get, get_many, list};
pub use self::icon::edit_icon;
pub(crate) use self::icon::{
cache_icon, cache_icon_from_path, migrate_legacy_icons,
};
pub use self::install::get_optimal_jre_key;
pub(crate) use self::lifecycle::create;
pub use self::lifecycle::{edit, edit_icon, remove};
pub use self::lifecycle::{edit, remove};
pub use self::paths::{get_full_path, get_mod_full_path};
pub use self::projects::{
InstallProjectWithDependenciesRequest, add_project_from_path,
+316
View File
@@ -0,0 +1,316 @@
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{EditInstance, State};
use crate::util::fetch::{sha1_async, write};
use crate::util::io;
use bytes::Bytes;
use std::fs::File as StdFile;
use std::io::{BufRead, BufReader, Cursor, Seek};
use std::path::{Path, PathBuf};
const INSTANCE_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
const INSTANCE_ICON_MAX_DIMENSION: u32 = 512;
const INSTANCE_ICON_MAX_SOURCE_DIMENSION: u32 = 8_192;
const INSTANCE_ICON_MAX_DECODE_BYTES: u64 = 64 * 1024 * 1024;
const INSTANCE_ICON_MAX_SVG_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
pub async fn edit_icon(
instance_id: &str,
icon_path: Option<&Path>,
) -> crate::Result<()> {
let state = State::get().await?;
let icon_path = if let Some(icon_path) = icon_path {
Some(
cache_icon_from_path(icon_path, &state)
.await?
.to_string_lossy()
.to_string(),
)
} else {
None
};
apply_instance_icon(instance_id, icon_path, &state).await
}
pub(crate) async fn cache_icon(
bytes: Bytes,
state: &State,
) -> crate::Result<PathBuf> {
let bytes = tokio::task::spawn_blocking(move || {
if looks_like_svg(&bytes) {
normalize_svg(&bytes, None)
} else {
normalize_raster(Cursor::new(bytes))
}
})
.await??;
write_cached_icon(bytes, state).await
}
pub(crate) async fn cache_icon_from_path(
icon_path: &Path,
state: &State,
) -> crate::Result<PathBuf> {
let icon_path = icon_path.to_path_buf();
let bytes = tokio::task::spawn_blocking(move || {
if has_svg_extension(&icon_path) {
normalize_svg_from_path(&icon_path)
} else {
let file = StdFile::open(&icon_path).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not open instance icon {}: {error}",
icon_path.display()
))
})?;
normalize_raster(BufReader::new(file))
}
})
.await??;
write_cached_icon(bytes, state).await
}
pub(crate) async fn migrate_legacy_icons() -> crate::Result<()> {
let state = State::get().await?;
let instances = instance_rows::list_instances(&state.pool).await?;
for instance in instances {
let Some(icon_path) = instance.icon_path.as_deref() else {
continue;
};
let metadata = match io::metadata(icon_path).await {
Ok(metadata) => metadata,
Err(error) => {
tracing::warn!(
instance_id = instance.id,
icon_path,
error = %error,
"Failed to inspect legacy instance icon"
);
continue;
}
};
if metadata.len() <= INSTANCE_ICON_MAX_BYTES as u64 {
continue;
}
if let Err(error) =
edit_icon(&instance.id, Some(Path::new(icon_path))).await
{
tracing::warn!(
instance_id = instance.id,
icon_path,
error = %error,
"Failed to normalize oversized legacy instance icon"
);
}
}
Ok(())
}
async fn apply_instance_icon(
instance_id: &str,
icon_path: Option<String>,
state: &State,
) -> crate::Result<()> {
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path.clone()),
..EditInstance::default()
},
&state.pool,
)
.await?;
if let Err(error) = super::shared::sync_shared_instance_icon(
instance_id,
icon_path.as_deref(),
state,
)
.await
{
tracing::warn!(
instance_id,
error = %error,
"Failed to sync shared instance icon"
);
}
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
}
async fn write_cached_icon(
bytes: Bytes,
state: &State,
) -> crate::Result<PathBuf> {
if bytes.len() > INSTANCE_ICON_MAX_BYTES {
return Err(icon_too_large_error());
}
let hash = sha1_async(bytes.clone()).await?;
let path = state
.directories
.caches_dir()
.join("icons")
.join(format!("{hash}.png"));
write(&path, &bytes, &state.io_semaphore).await?;
Ok(io::canonicalize(path)?)
}
fn normalize_raster<R>(reader: R) -> crate::Result<Bytes>
where
R: BufRead + Seek,
{
let mut reader = image::ImageReader::new(reader)
.with_guessed_format()
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not identify instance icon format: {error}"
))
})?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
limits.max_image_height = Some(INSTANCE_ICON_MAX_SOURCE_DIMENSION);
limits.max_alloc = Some(INSTANCE_ICON_MAX_DECODE_BYTES);
reader.limits(limits);
let image = reader.decode().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not decode instance icon: {error}"
))
})?;
let image = if image.width() > INSTANCE_ICON_MAX_DIMENSION
|| image.height() > INSTANCE_ICON_MAX_DIMENSION
{
image.resize(
INSTANCE_ICON_MAX_DIMENSION,
INSTANCE_ICON_MAX_DIMENSION,
image::imageops::FilterType::Lanczos3,
)
} else {
image
};
let mut normalized = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image.to_rgba8())
.write_to(&mut normalized, image::ImageFormat::Png)
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not encode instance icon as PNG: {error}"
))
})?;
validate_normalized_icon(normalized.into_inner())
}
fn normalize_svg_from_path(icon_path: &Path) -> crate::Result<Bytes> {
let metadata = std::fs::metadata(icon_path).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not inspect instance icon {}: {error}",
icon_path.display()
))
})?;
if metadata.len() > INSTANCE_ICON_MAX_SVG_SOURCE_BYTES {
return Err(crate::ErrorKind::InputError(format!(
"SVG instance icons cannot exceed {} bytes before normalization",
INSTANCE_ICON_MAX_SVG_SOURCE_BYTES
))
.into());
}
let bytes = std::fs::read(icon_path).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not read instance icon {}: {error}",
icon_path.display()
))
})?;
normalize_svg(&bytes, icon_path.parent())
}
fn normalize_svg(
bytes: &[u8],
resources_dir: Option<&Path>,
) -> crate::Result<Bytes> {
if bytes.len() as u64 > INSTANCE_ICON_MAX_SVG_SOURCE_BYTES {
return Err(crate::ErrorKind::InputError(format!(
"SVG instance icons cannot exceed {} bytes before normalization",
INSTANCE_ICON_MAX_SVG_SOURCE_BYTES
))
.into());
}
let mut options = resvg::usvg::Options::default();
options.resources_dir = resources_dir.map(Path::to_path_buf);
options.fontdb_mut().load_system_fonts();
let tree =
resvg::usvg::Tree::from_data(bytes, &options).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not decode SVG instance icon: {error}"
))
})?;
let size = tree.size();
let scale = (INSTANCE_ICON_MAX_DIMENSION as f32
/ size.width().max(size.height()))
.min(1.0);
let width = (size.width() * scale).ceil().max(1.0) as u32;
let height = (size.height() * scale).ceil().max(1.0) as u32;
let mut pixmap =
resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not allocate SVG instance icon output".to_string(),
)
})?;
resvg::render(
&tree,
resvg::tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let normalized = pixmap.encode_png().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not encode SVG instance icon as PNG: {error}"
))
})?;
validate_normalized_icon(normalized)
}
fn validate_normalized_icon(normalized: Vec<u8>) -> crate::Result<Bytes> {
if normalized.len() > INSTANCE_ICON_MAX_BYTES {
return Err(icon_too_large_error());
}
Ok(Bytes::from(normalized))
}
fn looks_like_svg(bytes: &[u8]) -> bool {
bytes[..bytes.len().min(1_024)]
.windows(4)
.any(|window| window.eq_ignore_ascii_case(b"<svg"))
}
fn has_svg_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("svg"))
}
fn icon_too_large_error() -> crate::Error {
crate::ErrorKind::InputError(format!(
"Instance icons cannot exceed {} bytes",
INSTANCE_ICON_MAX_BYTES
))
.into()
}
@@ -5,8 +5,6 @@ use crate::state::{
CreateInstance, EditInstance, InstanceLink, InstanceMetadata, ModLoader,
State,
};
use crate::util::io;
use std::path::Path;
#[tracing::instrument]
#[allow(clippy::too_many_arguments)]
@@ -73,60 +71,6 @@ pub async fn edit(
Ok(instance)
}
pub async fn edit_icon(
instance_id: &str,
icon_path: Option<&Path>,
) -> crate::Result<()> {
let state = State::get().await?;
let instance =
instance_rows::get_instance_display_info(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let icon_path = if let Some(icon) = icon_path {
let bytes = io::read(icon).await?;
let file = crate::util::fetch::write_cached_icon(
&icon.to_string_lossy(),
&state.directories.caches_dir(),
bytes::Bytes::from(bytes),
&state.io_semaphore,
)
.await?;
Some(file.to_string_lossy().to_string())
} else {
None
};
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path.clone()),
..EditInstance::default()
},
&state.pool,
)
.await?;
if let Err(error) = super::shared::sync_shared_instance_icon(
instance_id,
icon_path.as_deref(),
&state,
)
.await
{
tracing::warn!(
instance_id,
error = %error,
"Failed to sync shared instance icon"
);
}
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
}
#[tracing::instrument]
pub async fn remove(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?;
@@ -7,10 +7,7 @@ use crate::{
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::{
fetch::{fetch, write_cached_icon},
io,
},
util::{fetch::fetch, io},
};
use super::{finish_import, recache_icon};
@@ -90,18 +87,8 @@ pub async fn import_curseforge(
&state.pool,
)
.await?;
let filename = thumbnail_url.rsplit('/').next_back();
if let Some(filename) = filename {
icon = Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
);
}
icon =
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?);
}
// base mod loader is always None for vanilla
+3 -12
View File
@@ -346,19 +346,10 @@ pub async fn recache_icon(
) -> crate::Result<Option<PathBuf>> {
let state = crate::State::get().await?;
let bytes = tokio::fs::read(&icon_path).await;
if let Ok(bytes) = bytes {
let bytes = bytes::Bytes::from(bytes);
let cache_dir = &state.directories.caches_dir();
let semaphore = &state.io_semaphore;
if tokio::fs::try_exists(&icon_path).await.unwrap_or(false) {
Ok(Some(
fetch::write_cached_icon(
&icon_path.to_string_lossy(),
cache_dir,
bytes,
semaphore,
)
.await?,
crate::api::instance::cache_icon_from_path(&icon_path, &state)
.await?,
))
} else {
// could not find icon (for instance, prism default icon, etc)
+2 -16
View File
@@ -10,7 +10,7 @@ use crate::state::{
};
use crate::util::fetch::{
DownloadMeta, DownloadReason, FetchProgressFn, fetch,
fetch_advanced_with_progress, sha1_file_async, write_cached_icon,
fetch_advanced_with_progress, sha1_file_async,
};
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method;
@@ -415,21 +415,7 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
)
.await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
)
} else {
None
}
Some(crate::api::instance::cache_icon(icon_bytes, &state).await?)
} else {
None
}
@@ -8,7 +8,7 @@ use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
State,
};
use crate::util::fetch::{self, write_cached_icon};
use crate::util::fetch;
use crate::util::io;
use chrono::Utc;
use serde::{Deserialize, Serialize};
@@ -174,10 +174,8 @@ async fn resolve_icon_path(
return Ok(None);
};
let (bytes, file_name) = if icon.starts_with("https://")
|| icon.starts_with("http://")
{
let fetched = fetch::fetch(
let file = if icon.starts_with("https://") || icon.starts_with("http://") {
let bytes = fetch::fetch(
icon,
None,
None,
@@ -186,21 +184,15 @@ async fn resolve_icon_path(
&state.pool,
)
.await?;
let name = icon.rsplit('/').next().unwrap_or("icon").to_string();
(fetched, name)
crate::api::instance::cache_icon(bytes, state).await?
} else {
let data = io::read(state.directories.caches_dir().join(icon)).await?;
(bytes::Bytes::from(data), icon.to_string())
crate::api::instance::cache_icon_from_path(
&state.directories.caches_dir().join(icon),
state,
)
.await?
};
let file = write_cached_icon(
&file_name,
&state.directories.caches_dir(),
bytes,
&state.io_semaphore,
)
.await?;
Ok(Some(file.to_string_lossy().to_string()))
}
+4
View File
@@ -150,6 +150,10 @@ impl State {
)
.await;
if let Err(e) = crate::api::instance::migrate_legacy_icons().await {
tracing::error!("Error migrating legacy instance icons: {e}");
}
let res = tokio::try_join!(
state.discord_rpc.clear_to_default(true),
instances::refresh_all_instances(),
+1 -29
View File
@@ -14,10 +14,9 @@ use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::ffi::OsStr;
use std::future::Future;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, LazyLock};
use std::time::{self, Duration, Instant, SystemTime};
@@ -907,33 +906,6 @@ pub async fn copy(
Ok(())
}
// Writes a icon to the cache and returns the absolute path of the icon within the cache directory
#[tracing::instrument(skip(bytes, semaphore))]
pub async fn write_cached_icon(
icon_path: &str,
cache_dir: &Path,
bytes: Bytes,
semaphore: &IoSemaphore,
) -> crate::Result<PathBuf> {
let hash = sha1_async(bytes.clone()).await?;
let path = cache_dir
.join("icons")
.join(cached_icon_file_name(icon_path, &hash));
write(&path, &bytes, semaphore).await?;
let path = io::canonicalize(path)?;
Ok(path)
}
fn cached_icon_file_name(icon_path: &str, hash: &str) -> String {
let path = icon_path.split(['?', '#']).next().unwrap_or(icon_path);
match Path::new(path).extension().and_then(OsStr::to_str) {
Some(extension) => format!("{hash}.{extension}"),
None => hash.to_string(),
}
}
pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
let hash = tokio::task::spawn_blocking(move || {
sha1_smol::Sha1::from(bytes).hexdigest()