diff --git a/Cargo.lock b/Cargo.lock index 726ebeeed2..aeff746ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -503,6 +503,12 @@ dependencies = [ "uuid 1.23.3", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -2439,6 +2445,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -3462,6 +3477,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -3630,6 +3654,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "flume" version = "0.11.1" @@ -3659,6 +3689,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -4083,6 +4136,16 @@ dependencies = [ "weezl", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -4806,7 +4869,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -4992,7 +5055,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.13.3", "image-webp", "moxcms", "num-traits", @@ -5002,8 +5065,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.4.12", + "zune-jpeg 0.4.21", ] [[package]] @@ -5016,6 +5079,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + [[package]] name = "imgref" version = "1.12.0" @@ -5441,6 +5510,18 @@ dependencies = [ "selectors", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "labrinth" version = "2.7.0" @@ -6003,6 +6084,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -7432,6 +7522,12 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.10" @@ -7555,6 +7651,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -8016,7 +8121,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.32", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.17", "tokio", "tracing", @@ -8053,7 +8158,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", "windows-sys 0.60.2", ] @@ -8580,6 +8685,23 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "194d8e591e405d1eecf28819740abed6d719d1a2db87fc0bcdedee9a26d55560" +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg 0.5.15", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -8667,6 +8789,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.9.8" @@ -9006,6 +9143,24 @@ dependencies = [ "rust_decimal_macros", ] +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.9.4", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "rxml" version = "0.11.1" @@ -9766,6 +9921,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -9784,6 +9948,15 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -10176,6 +10349,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fdc163db75f7b5ffa3daf0c5a7136fb0d4b2f35523cd1769da05e034159feb" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -10298,6 +10480,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher 1.0.1", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -11020,6 +11212,7 @@ dependencies = [ "heck 0.5.0", "hickory-resolver 0.25.2", "httpdate", + "image", "indicatif", "itertools 0.14.0", "modrinth-content-management", @@ -11036,6 +11229,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.12.24", + "resvg", "rgb", "serde", "serde_ini", @@ -11179,7 +11373,7 @@ dependencies = [ "half 2.7.0", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -11253,6 +11447,32 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.0", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -11736,6 +11956,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "tungstenite" version = "0.27.0" @@ -11856,6 +12085,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -11877,12 +12118,24 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.1.14" @@ -11972,6 +12225,34 @@ dependencies = [ "url", ] +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher 1.0.1", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -13434,6 +13715,12 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "xredis" version = "0.1.0" @@ -13749,6 +14036,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-inflate" version = "0.2.54" @@ -13764,7 +14057,16 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1529ce8910..2c2c57050a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,6 +159,7 @@ rdkafka = { version = "0.36.2", features = ["cmake-build"] } redis = "1.4.1" regex = "1.12.2" reqwest = { version = "0.12.24", default-features = false } +resvg = "0.47.0" rgb = "0.8.52" rust_decimal = { version = "1.39.0", features = [ "serde-with-float", diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue index 3c84d90668..8568ebefa6 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue @@ -109,9 +109,13 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => { }) async function resetIcon() { - icon.value = undefined - await edit_icon(instance.value.id, null).catch(handleError) - trackEvent('InstanceRemoveIcon') + try { + await edit_icon(instance.value.id, null) + icon.value = undefined + trackEvent('InstanceRemoveIcon') + } catch (error) { + handleError(error) + } } async function setIcon() { @@ -127,10 +131,13 @@ async function setIcon() { if (!value) return - icon.value = value - await edit_icon(instance.value.id, icon.value).catch(handleError) - - trackEvent('InstanceSetIcon') + try { + await edit_icon(instance.value.id, value) + icon.value = value + trackEvent('InstanceSetIcon') + } catch (error) { + handleError(error) + } } const editInstanceObject = computed(() => ({ diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 2ddccf999d..e918fd9757 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -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 } diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs index 09a4325f2e..43f84769ba 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance.rs @@ -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, diff --git a/packages/app-lib/src/api/instance/icon.rs b/packages/app-lib/src/api/instance/icon.rs new file mode 100644 index 0000000000..e56dd9fd0b --- /dev/null +++ b/packages/app-lib/src/api/instance/icon.rs @@ -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 { + 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 { + 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, + 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 { + 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(reader: R) -> crate::Result +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 { + 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 { + 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) -> crate::Result { + 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" 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() +} diff --git a/packages/app-lib/src/api/instance/lifecycle.rs b/packages/app-lib/src/api/instance/lifecycle.rs index 94b37e1f5b..f58f10eb8c 100644 --- a/packages/app-lib/src/api/instance/lifecycle.rs +++ b/packages/app-lib/src/api/instance/lifecycle.rs @@ -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?; diff --git a/packages/app-lib/src/api/pack/import/curseforge.rs b/packages/app-lib/src/api/pack/import/curseforge.rs index becc532407..e1f4630222 100644 --- a/packages/app-lib/src/api/pack/import/curseforge.rs +++ b/packages/app-lib/src/api/pack/import/curseforge.rs @@ -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 diff --git a/packages/app-lib/src/api/pack/import/mod.rs b/packages/app-lib/src/api/pack/import/mod.rs index 4a58cfcbf3..20b7f287ab 100644 --- a/packages/app-lib/src/api/pack/import/mod.rs +++ b/packages/app-lib/src/api/pack/import/mod.rs @@ -346,19 +346,10 @@ pub async fn recache_icon( ) -> crate::Result> { 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) diff --git a/packages/app-lib/src/api/pack/install_from.rs b/packages/app-lib/src/api/pack/install_from.rs index e4a86d79f1..c894b94ad2 100644 --- a/packages/app-lib/src/api/pack/install_from.rs +++ b/packages/app-lib/src/api/pack/install_from.rs @@ -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 } diff --git a/packages/app-lib/src/state/instances/commands/create_instance.rs b/packages/app-lib/src/state/instances/commands/create_instance.rs index 7567ec815c..21d1068b1d 100644 --- a/packages/app-lib/src/state/instances/commands/create_instance.rs +++ b/packages/app-lib/src/state/instances/commands/create_instance.rs @@ -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())) } diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index c848699726..246e183779 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -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(), diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs index fbca82414c..a7cb5215ae 100644 --- a/packages/app-lib/src/util/fetch.rs +++ b/packages/app-lib/src/util/fetch.rs @@ -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 { - 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 { let hash = tokio::task::spawn_blocking(move || { sha1_smol::Sha1::from(bytes).hexdigest()