aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-08 16:06:20 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-08 16:06:20 -0500
commitf8df9f41a1eb6fab6fdff241dd2b9e4871b0af2b (patch)
tree869ac7436b8b0349d421afc5e5f7730ac3415077
parent630c3221bf8a6a6d3072ca7fef7d47210e9c9205 (diff)
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 <noreply@anthropic.com>
-rw-r--r--src/src/envshortcut.cpp321
1 files changed, 295 insertions, 26 deletions
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 <QBuffer>
#include <QDir>
#include <QFile>
#include <QFileInfo>
+#include <QImage>
#include <QProcessEnvironment>
#include <QStandardPaths>
#include <QString>
@@ -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<quint16>(static_cast<unsigned char>(p[0])) |
+ (static_cast<quint16>(static_cast<unsigned char>(p[1])) << 8);
+ };
+ auto u32 = [](const char* p) -> quint32 {
+ return static_cast<quint32>(static_cast<unsigned char>(p[0])) |
+ (static_cast<quint32>(static_cast<unsigned char>(p[1])) << 8) |
+ (static_cast<quint32>(static_cast<unsigned char>(p[2])) << 16) |
+ (static_cast<quint32>(static_cast<unsigned char>(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<qint64>(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<DirEntry> {
+ std::vector<DirEntry> 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<unsigned char>(e[0]);
+ int h = static_cast<unsigned char>(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();