From 84a43a2e6afbdfab92c610d47aa75e3876ffacd6 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Thu, 12 Mar 2026 13:48:25 -0500 Subject: Replace wrestool with native Rust PE icon extraction (pelite) - Add icons.rs to nak crate: parses PE resource sections, extracts RT_GROUP_ICON/RT_ICON and builds ICO files in memory - Expose nak_extract_exe_icon() FFI for C++ consumption - Update iconForExecutable() to use Rust FFI instead of shelling out to wrestool; reads all ICO entries for correct color depth - Fix BasicGamePlugin::gameIcon() to call iconForExecutable() (was returning empty QIcon, causing placeholder icons for BG3/Cyberpunk) - Move app icon setup: setDesktopFileName + setWindowIcon to MOApplication constructor (fixes Wayland taskbar/decoration icon) - Move data/com.fluorine.* to data/icons/, install to XDG paths at launch for Wayland compositor icon resolution - Remove icoutils dependency from Docker image and build output Co-Authored-By: Claude Opus 4.6 --- libs/nak/src/icons.rs | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++ libs/nak/src/lib.rs | 1 + 2 files changed, 196 insertions(+) create mode 100644 libs/nak/src/icons.rs (limited to 'libs/nak/src') diff --git a/libs/nak/src/icons.rs b/libs/nak/src/icons.rs new file mode 100644 index 0000000..940d0d1 --- /dev/null +++ b/libs/nak/src/icons.rs @@ -0,0 +1,195 @@ +//! Extract icons from Windows PE (.exe/.dll) files using pelite. +//! +//! Returns raw ICO file bytes that can be loaded directly by Qt's QIcon/QImage. + +use std::path::Path; + +use pelite::resources::{Entry, Name, Resources}; + +/// Extract the best (largest) icon from a PE executable as raw ICO bytes. +/// +/// Returns `None` if the file can't be read, isn't a valid PE, or has no icons. +pub fn extract_icon(exe_path: &Path) -> Option> { + let data = std::fs::read(exe_path).ok()?; + + // Try PE32+ (64-bit) first, fall back to PE32 (32-bit) + if let Ok(icon) = extract_from_pe64(&data) { + return Some(icon); + } + if let Ok(icon) = extract_from_pe32(&data) { + return Some(icon); + } + + None +} + +fn extract_from_pe64(data: &[u8]) -> Result, Box> { + use pelite::pe64::{Pe, PeFile}; + let pe = PeFile::from_bytes(data)?; + let resources = pe.resources()?; + build_ico_from_resources(&resources) +} + +fn extract_from_pe32(data: &[u8]) -> Result, Box> { + use pelite::pe32::{Pe, PeFile}; + let pe = PeFile::from_bytes(data)?; + let resources = pe.resources()?; + build_ico_from_resources(&resources) +} + +/// Build an ICO file from PE resources, picking the group icon with the +/// largest total pixel area. +fn build_ico_from_resources( + resources: &Resources, +) -> Result, Box> { + let root = resources.root()?; + + // Find RT_GROUP_ICON directory (type 14) + let group_icon_dir = root + .get_dir(Name::Id(14)) + .map_err(|_| "no RT_GROUP_ICON")?; + + let mut best_group: Option<(i32, Vec)> = None; + + for dir_entry in group_icon_dir.entries() { + // Each entry is a group; get its subdirectory (language variants) + let sub_dir = match dir_entry.entry() { + Ok(Entry::Directory(d)) => d, + _ => continue, + }; + + // Get the first language variant's data + let data_entry = match sub_dir.entries().next() { + Some(e) => match e.entry() { + Ok(Entry::DataEntry(d)) => d, + _ => continue, + }, + None => continue, + }; + + let grp_data = match data_entry.bytes() { + Ok(d) => d, + Err(_) => continue, + }; + + // Parse GRPICONDIR header: reserved(2) + type(2) + count(2) = 6 bytes + if grp_data.len() < 6 { + continue; + } + let count = u16::from_le_bytes([grp_data[4], grp_data[5]]) as usize; + // Each GRPICONDIRENTRY is 14 bytes + if grp_data.len() < 6 + count * 14 { + continue; + } + + // Calculate total pixel area for ranking + let mut total_area: i32 = 0; + for i in 0..count { + let offset = 6 + i * 14; + let w = if grp_data[offset] == 0 { + 256i32 + } else { + grp_data[offset] as i32 + }; + let h = if grp_data[offset + 1] == 0 { + 256i32 + } else { + grp_data[offset + 1] as i32 + }; + total_area += w * h; + } + + if let Ok(ico_bytes) = build_ico_file(resources, grp_data, count) { + if best_group.is_none() || total_area > best_group.as_ref().unwrap().0 { + best_group = Some((total_area, ico_bytes)); + } + } + } + + best_group + .map(|(_, bytes)| bytes) + .ok_or_else(|| "no valid icon group found".into()) +} + +/// Build a complete ICO file from a GRPICONDIR and the corresponding RT_ICON resources. +fn build_ico_file( + resources: &Resources, + grp_data: &[u8], + count: usize, +) -> Result, Box> { + let root = resources.root()?; + + // RT_ICON = type 3 + let icon_dir = root.get_dir(Name::Id(3)).map_err(|_| "no RT_ICON")?; + + // Collect individual icon image data + struct IconEntry<'a> { + header: [u8; 8], // first 8 bytes of GRPICONDIRENTRY (w, h, colors, reserved, planes, bpp) + data: &'a [u8], + } + let mut entries: Vec = Vec::new(); + + for i in 0..count { + let grp_offset = 6 + i * 14; + let icon_id = + u16::from_le_bytes([grp_data[grp_offset + 12], grp_data[grp_offset + 13]]) as u32; + + // Find the RT_ICON with this ID + let icon_sub = match icon_dir.get_dir(Name::Id(icon_id)) { + Ok(d) => d, + Err(_) => continue, + }; + + let data_entry = match icon_sub.entries().next() { + Some(e) => match e.entry() { + Ok(Entry::DataEntry(d)) => d, + _ => continue, + }, + None => continue, + }; + + let image_data = match data_entry.bytes() { + Ok(d) => d, + Err(_) => continue, + }; + + let mut header = [0u8; 8]; + header.copy_from_slice(&grp_data[grp_offset..grp_offset + 8]); + + entries.push(IconEntry { + header, + data: image_data, + }); + } + + if entries.is_empty() { + return Err("no icon entries found".into()); + } + + let entry_count = entries.len() as u16; + let header_size = 6 + (entry_count as usize) * 16; // ICONDIR + ICONDIRENTRYs + + let mut ico = Vec::new(); + + // ICONDIR header + ico.extend_from_slice(&0u16.to_le_bytes()); // reserved + ico.extend_from_slice(&1u16.to_le_bytes()); // type = ICO + ico.extend_from_slice(&entry_count.to_le_bytes()); + + // Write ICONDIRENTRY records (16 bytes each) + let mut data_offset = header_size as u32; + for entry in &entries { + ico.extend_from_slice(&entry.header); // width, height, colors, reserved, planes, bpp (8 bytes) + let size = entry.data.len() as u32; + ico.extend_from_slice(&size.to_le_bytes()); // dwBytesInRes (4 bytes) + ico.extend_from_slice(&data_offset.to_le_bytes()); // dwImageOffset (4 bytes) + data_offset += size; + } + + // Write image data + for entry in &entries { + ico.extend_from_slice(entry.data); + } + + Ok(ico) +} diff --git a/libs/nak/src/lib.rs b/libs/nak/src/lib.rs index 59f41c6..13bc90e 100644 --- a/libs/nak/src/lib.rs +++ b/libs/nak/src/lib.rs @@ -13,4 +13,5 @@ pub mod steam; pub mod utils; pub mod deps; +pub mod icons; pub mod installers; -- cgit v1.3.1