//! Extract icon artwork from inside mod JARs and resource pack ZIPs so //! unmatched content files can display real icons. use bytes::Bytes; use std::io::{Read, Seek}; use std::path::Path; use zip::ZipArchive; /// Entries larger than this are almost certainly not pack icons. const MAX_ICON_BYTES: u64 = 2 * 1024 * 1024; /// Returns the matched entry name and validated image bytes for a mod JAR. /// /// Prefers the icon declared by the embedded mod metadata, then falls back /// to the in-game `pack.png` convention at the archive root. pub fn extract_mod_icon( bytes: &Bytes, metadata: Option<&crate::mod_metadata::LocalModMetadata>, ) -> Option<(String, Vec)> { let cursor = std::io::Cursor::new(&**bytes); let mut archive = ZipArchive::new(cursor).ok()?; if let Some(icon_path) = metadata.and_then(|meta| meta.icon_path.as_deref()) && let Some(icon) = read_entry(&mut archive, icon_path) { return Some(icon); } read_entry(&mut archive, "pack.png") } /// Returns the matched entry name and validated image bytes for a resource /// pack ZIP. Resource packs declare their icon as `pack.png` at the root. pub fn extract_resource_pack_icon(path: &Path) -> Option<(String, Vec)> { let file = std::fs::File::open(path).ok()?; let mut archive = ZipArchive::new(file).ok()?; read_entry(&mut archive, "pack.png") } fn read_entry( archive: &mut ZipArchive, entry_name: &str, ) -> Option<(String, Vec)> { let normalized = entry_name.trim_start_matches('/').replace('\\', "/"); let entry = open_entry(archive, &normalized)?; if !entry.is_file() || entry.size() > MAX_ICON_BYTES { return None; } let entry_name = entry.name().to_string(); let mut data = Vec::with_capacity(entry.size() as usize); entry.take(MAX_ICON_BYTES + 1).read_to_end(&mut data).ok()?; is_supported_image(&data).then_some((entry_name, data)) } fn open_entry<'a, R: Read + Seek>( archive: &'a mut ZipArchive, normalized: &str, ) -> Option> { if archive.by_name(normalized).is_ok() { archive.by_name(normalized).ok() } else { let names: Vec = archive.file_names().map(ToOwned::to_owned).collect(); let matched = names .iter() .find(|name| name.eq_ignore_ascii_case(normalized))?; archive.by_name(matched).ok() } } fn is_supported_image(data: &[u8]) -> bool { data.starts_with(&[0x89, b'P', b'N', b'G']) || data.starts_with(&[0xFF, 0xD8, 0xFF]) }