From f8df9f41a1eb6fab6fdff241dd2b9e4871b0af2b Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sun, 8 Mar 2026 16:06:20 -0500 Subject: Extract icons from .exe files for desktop shortcuts, add cache invalidation Parse PE resources to extract the largest embedded icon from Windows executables and save as PNG to ~/.local/share/icons/fluorine/. If a cached icon is just the Fluorine fallback (same file size), re-attempt extraction in case the executable changed or was previously unavailable. Co-Authored-By: Claude Opus 4.6 --- src/src/envshortcut.cpp | 321 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 295 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/src/src/envshortcut.cpp b/src/src/envshortcut.cpp index 55600fe..e78e904 100644 --- a/src/src/envshortcut.cpp +++ b/src/src/envshortcut.cpp @@ -353,9 +353,11 @@ QString toString(Shortcut::Locations loc) #else // Linux +#include #include #include #include +#include #include #include #include @@ -394,35 +396,305 @@ static QString sanitizeDesktopName(const QString& s) return result; } +// --------------------------------------------------------------------------- +// PE icon extractor — reads the largest icon embedded in a Windows .exe +// and returns it as a QImage. Returns a null QImage on failure. +// --------------------------------------------------------------------------- +static QImage extractIconFromExe(const QString& exePath) +{ + QFile f(exePath); + if (!f.open(QIODevice::ReadOnly)) + return {}; + + auto peek = [&](qint64 offset, qint64 size) -> QByteArray { + if (offset < 0 || offset + size > f.size()) + return {}; + f.seek(offset); + return f.read(size); + }; + + auto u16 = [](const char* p) -> quint16 { + return static_cast(static_cast(p[0])) | + (static_cast(static_cast(p[1])) << 8); + }; + auto u32 = [](const char* p) -> quint32 { + return static_cast(static_cast(p[0])) | + (static_cast(static_cast(p[1])) << 8) | + (static_cast(static_cast(p[2])) << 16) | + (static_cast(static_cast(p[3])) << 24); + }; + + // DOS header: check MZ magic, read PE offset at 0x3C. + QByteArray dosHdr = peek(0, 64); + if (dosHdr.size() < 64 || dosHdr[0] != 'M' || dosHdr[1] != 'Z') + return {}; + + quint32 peOff = u32(dosHdr.constData() + 0x3C); + + // PE signature. + QByteArray peSig = peek(peOff, 4); + if (peSig.size() < 4 || peSig[0] != 'P' || peSig[1] != 'E') + return {}; + + // COFF header (20 bytes) immediately after the 4-byte PE signature. + QByteArray coffHdr = peek(peOff + 4, 20); + if (coffHdr.size() < 20) + return {}; + quint16 numSections = u16(coffHdr.constData() + 2); + quint16 optionalHdrSize = u16(coffHdr.constData() + 16); + + // Optional header — we need the magic (PE32 vs PE32+) and the + // resource data directory entry (index 2). + qint64 optOff = peOff + 4 + 20; + QByteArray optHdr = peek(optOff, optionalHdrSize); + if (optHdr.size() < 4) + return {}; + + quint16 magic = u16(optHdr.constData()); + int ddOffset; // offset of DataDirectory[0] inside optional header + if (magic == 0x10b) // PE32 + ddOffset = 96; + else if (magic == 0x20b) // PE32+ + ddOffset = 112; + else + return {}; + + // DataDirectory[2] = resource table (each entry is 8 bytes: RVA + Size). + int rsrcDDOff = ddOffset + 2 * 8; + if (rsrcDDOff + 8 > optHdr.size()) + return {}; + quint32 rsrcRVA = u32(optHdr.constData() + rsrcDDOff); + if (rsrcRVA == 0) + return {}; + + // Section headers — find the section containing rsrcRVA. + qint64 secOff = optOff + optionalHdrSize; + quint32 rsrcFileOff = 0; + quint32 rsrcVA = 0; + for (int i = 0; i < numSections; ++i) { + QByteArray sec = peek(secOff + i * 40, 40); + if (sec.size() < 40) + return {}; + quint32 virtAddr = u32(sec.constData() + 12); + quint32 virtSize = u32(sec.constData() + 8); + quint32 rawOff = u32(sec.constData() + 20); + if (rsrcRVA >= virtAddr && rsrcRVA < virtAddr + virtSize) { + rsrcFileOff = rawOff + (rsrcRVA - virtAddr); + rsrcVA = virtAddr; + break; + } + } + if (rsrcFileOff == 0) + return {}; + + // Helper: convert an RVA inside the .rsrc section to a file offset. + auto rvaToFile = [&](quint32 rva) -> qint64 { + return static_cast(rsrcFileOff) + (rva - rsrcRVA); + }; + + // Parse a resource directory and return (id, offset, isDir) entries. + struct DirEntry { quint32 id; quint32 offset; bool isDir; }; + auto readDir = [&](quint32 dirFileOff) -> std::vector { + std::vector entries; + QByteArray dh = peek(dirFileOff, 16); + if (dh.size() < 16) return entries; + quint16 numNamed = u16(dh.constData() + 12); + quint16 numId = u16(dh.constData() + 14); + int total = numNamed + numId; + QByteArray ea = peek(dirFileOff + 16, total * 8); + if (ea.size() < total * 8) return entries; + for (int i = 0; i < total; ++i) { + quint32 nameOrId = u32(ea.constData() + i * 8); + quint32 off = u32(ea.constData() + i * 8 + 4); + bool isDir = (off & 0x80000000u) != 0; + off &= 0x7FFFFFFFu; + entries.push_back({nameOrId, off, isDir}); + } + return entries; + }; + + // Level 0: find RT_GROUP_ICON (14) and RT_ICON (3). + auto level0 = readDir(rsrcFileOff); + quint32 groupIconOff = 0, iconOff = 0; + for (auto& e : level0) { + if (e.id == 14 && e.isDir) groupIconOff = rsrcFileOff + e.offset; + if (e.id == 3 && e.isDir) iconOff = rsrcFileOff + e.offset; + } + if (groupIconOff == 0 || iconOff == 0) + return {}; + + // Level 1 of RT_GROUP_ICON: pick the first group. + auto groupL1 = readDir(groupIconOff); + if (groupL1.empty() || !groupL1[0].isDir) + return {}; + + // Level 2: pick the first language. + auto groupL2 = readDir(rsrcFileOff + groupL1[0].offset); + if (groupL2.empty() || groupL2[0].isDir) + return {}; + + // Read the data entry (RVA + size). + QByteArray dataEntry = peek(rsrcFileOff + groupL2[0].offset, 16); + if (dataEntry.size() < 16) + return {}; + quint32 grpDataRVA = u32(dataEntry.constData()); + quint32 grpDataSize = u32(dataEntry.constData() + 4); + QByteArray grpData = peek(rvaToFile(grpDataRVA), grpDataSize); + if (grpData.size() < 6) + return {}; + + // Parse the GRPICONDIR: pick the icon entry with the largest area. + quint16 iconCount = u16(grpData.constData() + 4); + if (grpData.size() < 6 + iconCount * 14) + return {}; + + int bestIdx = -1; + int bestArea = 0; + quint16 bestId = 0; + for (int i = 0; i < iconCount; ++i) { + const char* e = grpData.constData() + 6 + i * 14; + int w = static_cast(e[0]); + int h = static_cast(e[1]); + if (w == 0) w = 256; + if (h == 0) h = 256; + int area = w * h; + if (area > bestArea) { + bestArea = area; + bestIdx = i; + bestId = u16(e + 12); + } + } + if (bestIdx < 0) + return {}; + + // Find RT_ICON with matching ID. + auto iconL1 = readDir(iconOff); + quint32 iconEntryOff = 0; + for (auto& e : iconL1) { + if (e.id == bestId && e.isDir) { + auto iconL2 = readDir(rsrcFileOff + e.offset); + if (!iconL2.empty() && !iconL2[0].isDir) + iconEntryOff = rsrcFileOff + iconL2[0].offset; + break; + } + } + if (iconEntryOff == 0) + return {}; + + QByteArray iconDE = peek(iconEntryOff, 16); + if (iconDE.size() < 16) + return {}; + quint32 iconDataRVA = u32(iconDE.constData()); + quint32 iconDataSize = u32(iconDE.constData() + 4); + QByteArray iconData = peek(rvaToFile(iconDataRVA), iconDataSize); + if (iconData.isEmpty()) + return {}; + + // Try loading as PNG first (256x256 icons are typically stored as PNG). + QImage img; + img.loadFromData(iconData, "PNG"); + if (!img.isNull()) + return img; + + // Otherwise it's a raw BITMAPINFOHEADER (DIB). Wrap it in a minimal + // .ico so Qt's ICO reader can handle it. + QByteArray ico; + QBuffer buf(&ico); + buf.open(QIODevice::WriteOnly); + // ICONDIR header + const char icoHdr[6] = {0, 0, 1, 0, 1, 0}; // reserved=0, type=1, count=1 + buf.write(icoHdr, 6); + // ICONDIRENTRY (use the values from the group icon entry) + const char* grpE = grpData.constData() + 6 + bestIdx * 14; + char entry[16]; + memcpy(entry, grpE, 12); // copy w,h,colorCount,reserved,planes,bitCount,size + quint32 dataOff = 6 + 16; // offset to icon data = after header + 1 entry + entry[12] = dataOff & 0xFF; + entry[13] = (dataOff >> 8) & 0xFF; + entry[14] = (dataOff >> 16) & 0xFF; + entry[15] = (dataOff >> 24) & 0xFF; + // Fix the size field (bytes 8-11) to match actual data size + entry[8] = iconDataSize & 0xFF; + entry[9] = (iconDataSize >> 8) & 0xFF; + entry[10] = (iconDataSize >> 16) & 0xFF; + entry[11] = (iconDataSize >> 24) & 0xFF; + buf.write(entry, 16); + buf.write(iconData); + buf.close(); + + img.loadFromData(ico, "ICO"); + return img; +} + +// Return the path to the bundled Fluorine icon inside the AppImage, +// or the installed hicolor copy. Empty string if neither exists. +static QString bundledFluorineIcon() +{ + QString appDir = QProcessEnvironment::systemEnvironment().value("APPDIR"); + if (!appDir.isEmpty()) { + QString bundled = appDir + "/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; + if (QFile::exists(bundled)) + return bundled; + } + QString hicolor = QDir::homePath() + + "/.local/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; + if (QFile::exists(hicolor)) + return hicolor; + return {}; +} + +// Check whether an icon file is just the Fluorine fallback icon +// (same file size as the bundled one). +static bool isFallbackIcon(const QString& iconPath) +{ + QString bundled = bundledFluorineIcon(); + if (bundled.isEmpty()) + return false; + return QFileInfo(iconPath).size() == QFileInfo(bundled).size(); +} + // Install a game-specific icon to ~/.local/share/icons/fluorine/ and return -// the absolute path. Falls back to the bundled Fluorine icon. -static QString installIcon(const QString& iconBaseName) +// the absolute path. Tries to extract the icon from exePath (.exe) first, +// then falls back to the bundled Fluorine icon. +// +// If a cached icon exists but is just the fallback, re-attempts extraction +// in case the executable has changed or was previously unavailable. +static QString installIcon(const QString& iconBaseName, const QString& exePath = {}) { QString iconDir = QDir::homePath() + "/.local/share/icons/fluorine"; QString iconDest = iconDir + "/" + iconBaseName + ".png"; - if (QFile::exists(iconDest)) { + // If the icon already exists and is NOT the fallback, keep it. + if (QFile::exists(iconDest) && !isFallbackIcon(iconDest)) { return iconDest; } - // No game-specific icon available — install the bundled Fluorine icon - // under the game-specific name so each shortcut gets its own file. QDir().mkpath(iconDir); - QString appDir = QProcessEnvironment::systemEnvironment().value("APPDIR"); - if (!appDir.isEmpty()) { - QString bundled = appDir + "/usr/share/icons/hicolor/256x256/apps/com.fluorine.manager.png"; - if (QFile::exists(bundled)) { - QFile::copy(bundled, iconDest); - return iconDest; + // Try to extract the icon from the .exe file. + if (!exePath.isEmpty()) { + QImage icon = extractIconFromExe(exePath); + if (!icon.isNull()) { + if (icon.width() != 256 || icon.height() != 256) { + icon = icon.scaled(256, 256, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + // Remove stale fallback before saving the real icon. + QFile::remove(iconDest); + if (icon.save(iconDest, "PNG")) { + return iconDest; + } } } - // Also keep the hicolor copy for compatibility. - QString hicolorDir = QDir::homePath() + "/.local/share/icons/hicolor/256x256/apps"; - QString hicolorDest = hicolorDir + "/com.fluorine.manager.png"; - if (QFile::exists(hicolorDest)) { - QFile::copy(hicolorDest, iconDest); + // Already have a fallback cached — no need to copy again. + if (QFile::exists(iconDest)) { + return iconDest; + } + + // Install the bundled Fluorine icon as a fallback. + QString bundled = bundledFluorineIcon(); + if (!bundled.isEmpty()) { + QFile::copy(bundled, iconDest); return iconDest; } @@ -448,20 +720,17 @@ Shortcut::Shortcut(const Executable& exe) : Shortcut() m_description = QString("Run %1 with Fluorine").arg(exe.title()); - // .exe icons can't be used on Linux — only use native icon formats. - // Install a game-specific icon to ~/.local/share/icons/fluorine/. - if (exe.usesOwnIcon()) { - QString iconPath = exe.binaryInfo().absoluteFilePath(); - if (!iconPath.endsWith(".exe", Qt::CaseInsensitive)) { - m_icon = iconPath; - } + // Try to extract the icon from the executable (works for .exe files). + // For native Linux binaries with a custom icon, use that directly. + // Falls back to the bundled Fluorine icon. + QString exePath = exe.binaryInfo().absoluteFilePath(); + if (exe.usesOwnIcon() && !exePath.endsWith(".exe", Qt::CaseInsensitive)) { + m_icon = exePath; } if (m_icon.isEmpty()) { - // Build a unique icon name from the instance + executable title, - // e.g. "NewVegas-NVSE" → ~/.local/share/icons/fluorine/NewVegas-NVSE.png QString iconBase = sanitizeDesktopName(m_instanceName) + "-" + sanitizeDesktopName(m_name); - m_icon = installIcon(iconBase); + m_icon = installIcon(iconBase, exePath); } m_workingDirectory = QFileInfo(m_target).absolutePath(); -- cgit v1.3.1