'use strict' const fs = require('fs') const path = require('path') const { desktopFileFor, readDesktopEntry, findIconFile, pngOrSvg } = require('./linuxapp') function rvaToOffset(sections, rva) { for (const s of sections) { if (rva >= s.va && rva < s.va + (s.vs || s.rawSize)) return rva - s.va + s.raw } return null } function dirEntries(buf, dirOff) { const named = buf.readUInt16LE(dirOff + 12) const ids = buf.readUInt16LE(dirOff + 14) const out = [] for (let i = 0; i < named + ids; i++) { const e = dirOff + 16 + i * 8 const nameOrId = buf.readUInt32LE(e) const value = buf.readUInt32LE(e + 4) out.push({ id: nameOrId & 0x7fffffff, isName: (nameOrId & 0x80000000) !== 0, value, isDir: (value & 0x80000000) !== 0 }) } return out } function parseIcons(buf) { try { if (buf.length < 64 || buf.readUInt16LE(0) !== 0x5a4d) return null const peOff = buf.readUInt32LE(0x3c) if (peOff + 24 > buf.length || buf.readUInt32LE(peOff) !== 0x00004550) return null const numSections = buf.readUInt16LE(peOff + 6) const optSize = buf.readUInt16LE(peOff + 20) const optOff = peOff + 24 const magic = buf.readUInt16LE(optOff) const resRva = magic === 0x20b ? buf.readUInt32LE(optOff + 128) : buf.readUInt32LE(optOff + 112) if (!resRva) return [] const secOff = optOff + optSize const sections = [] for (let i = 0; i < numSections; i++) { const s = secOff + i * 40 sections.push({ va: buf.readUInt32LE(s + 12), vs: buf.readUInt32LE(s + 8), rawSize: buf.readUInt32LE(s + 16), raw: buf.readUInt32LE(s + 20) }) } const rootOff = rvaToOffset(sections, resRva) if (rootOff == null) return [] const typeEntry = dirEntries(buf, rootOff).find(e => e.id === 3 && e.isDir) if (!typeEntry) return [] const iconDirOff = rvaToOffset(sections, resRva + (typeEntry.value & 0x7fffffff)) if (iconDirOff == null) return [] const iconDatas = [] for (const nameEntry of dirEntries(buf, iconDirOff)) { if (!nameEntry.isDir) continue const langDirOff = rvaToOffset(sections, resRva + (nameEntry.value & 0x7fffffff)) if (langDirOff == null) continue const langEntry = dirEntries(buf, langDirOff)[0] if (!langEntry || langEntry.isDir) continue const dataEntryOff = rvaToOffset(sections, resRva + langEntry.value) if (dataEntryOff == null) continue const dataRva = buf.readUInt32LE(dataEntryOff) const dataSize = buf.readUInt32LE(dataEntryOff + 4) const dataOff = rvaToOffset(sections, dataRva) if (dataOff == null || !dataSize) continue iconDatas.push({ id: nameEntry.id, buf: Buffer.from(buf.subarray(dataOff, dataOff + dataSize)) }) } if (!iconDatas.length) return [] const groupEntry = dirEntries(buf, rootOff).find(e => e.id === 14 && e.isDir) if (groupEntry) { const grpDirOff = rvaToOffset(sections, resRva + (groupEntry.value & 0x7fffffff)) if (grpDirOff != null) { for (const nameEntry of dirEntries(buf, grpDirOff)) { if (!nameEntry.isDir) continue const langDirOff = rvaToOffset(sections, resRva + (nameEntry.value & 0x7fffffff)) if (langDirOff == null) continue const langEntry = dirEntries(buf, langDirOff)[0] if (!langEntry || langEntry.isDir) continue const dataEntryOff = rvaToOffset(sections, resRva + langEntry.value) if (dataEntryOff == null) continue const dataRva = buf.readUInt32LE(dataEntryOff) const dataSize = buf.readUInt32LE(dataEntryOff + 4) const dataOff = rvaToOffset(sections, dataRva) if (dataOff == null || !dataSize) continue const grp = buf.subarray(dataOff, dataOff + dataSize) if (grp.length < 6) continue const count = grp.readUInt16LE(4) const best = { area: -1, buf: null } for (let i = 0; i < count; i++) { const e = 6 + i * 14 const w = grp.readUInt8(e) || 256 const h = grp.readUInt8(e + 1) || 256 const id = grp.readUInt16LE(e + 12) const icon = iconDatas.find(ic => ic.id === id) if (icon && w * h > best.area) { best.area = w * h best.buf = icon.buf } } if (best.buf) return [best.buf] } } } return [iconDatas.reduce((a, b) => (b.buf.length > a.buf.length ? b : a)).buf] } catch { return null } } function pngSize(img) { if (img.length > 24 && img.readUInt32BE(0) === 0x89504e47) { return { w: img.readUInt32BE(16), h: img.readUInt32BE(20) } } return null } // RT_ICON payloads are BITMAPINFOHEADER bitmaps; read real dimensions from // there (ICONDIRENTRY-style first bytes yield garbage). function bmpDims(img) { if (img.length < 12) return null const biSize = img.readUInt32LE(0) if (biSize === 12) { // BITMAPCOREHEADER (old icons): WORD width/height/planes/bitcount const w = img.readUInt16LE(4) const h = Math.floor(img.readUInt16LE(6) / 2) const planes = img.readUInt16LE(8) || 1 const bpp = img.readUInt16LE(10) || 32 const colors = bpp <= 8 ? 1 << bpp : 0 if (w > 0 && h > 0 && w <= 1024 && h <= 1024) return { w, h, planes, bpp, colors } return null } if (biSize >= 40 && biSize <= 124 && img.length > 40) { const w = img.readUInt32LE(4) const h = Math.floor(img.readUInt32LE(8) / 2) const planes = img.readUInt16LE(12) || 1 const bpp = img.readUInt16LE(14) || 32 const colors = img.readUInt32LE(32) || (bpp <= 8 ? 1 << bpp : 0) if (w > 0 && h > 0 && w <= 1024 && h <= 1024) return { w, h, planes, bpp, colors } } return null } function buildIco(images) { const header = Buffer.alloc(6) header.writeUInt16LE(0, 0) header.writeUInt16LE(1, 2) header.writeUInt16LE(images.length, 4) const metas = [] const datas = [] let offset = 6 + images.length * 16 for (const img of images) { let w, h, colors = 0, planes = 1, bpp = 32 if (img.readUInt32BE(0) === 0x89504e47) { const dim = pngSize(img) || { w: 256, h: 256 } w = dim.w h = dim.h } else { const dims = bmpDims(img) if (dims) { w = dims.w h = dims.h colors = dims.colors planes = dims.planes bpp = dims.bpp } else { // legacy fallback: ICONDIRENTRY-style first bytes (PNG-era heuristic) w = img.readUInt8(0) || 256 h = img.readUInt8(1) || 256 colors = img.readUInt8(2) planes = img.length > 4 ? img.readUInt16LE(4) || 1 : 1 bpp = img.length > 6 ? img.readUInt16LE(6) || 32 : 32 } } const meta = Buffer.alloc(16) meta.writeUInt8(w >= 256 ? 0 : w, 0) meta.writeUInt8(h >= 256 ? 0 : h, 1) // 0 in the ICO dir entry means "256+ colors" (palette implied by bpp) meta.writeUInt8(colors >= 256 ? 0 : colors, 2) meta.writeUInt8(0, 3) meta.writeUInt16LE(planes, 4) meta.writeUInt16LE(bpp, 6) meta.writeUInt32LE(img.length, 8) meta.writeUInt32LE(offset, 12) metas.push(meta) datas.push(img) offset += img.length } return Buffer.concat([header, ...metas, ...datas]) } function extractIconFromExe(exePath, outPath) { try { const buf = fs.readFileSync(exePath) const icon = parseIcons(buf) if (!icon || !icon.length) return { ok: false, error: 'no-icon' } const ico = buildIco(icon) fs.mkdirSync(path.dirname(outPath), { recursive: true }) fs.writeFileSync(outPath, ico) return { ok: true, path: outPath } } catch (err) { return { ok: false, error: String(err && err.message || err) } } } // Native Linux apps carry no icon inside the binary: it comes from the // .desktop entry (Icon=), an image next to the program, or the theme. // `outBase` is a path without extension; the sniffed one is appended. function iconFromLinuxApp(exePath, outBase) { try { const desktop = desktopFileFor(exePath) const entry = desktop ? readDesktopEntry(desktop) : null const baseDir = desktop ? path.dirname(desktop) : path.dirname(exePath) let source = findIconFile(entry && entry.Icon, baseDir) if (!source) { // no .desktop: a same-named image (or the AppImage .DirIcon) next to it const dir = path.dirname(exePath) const stem = path.basename(exePath).replace(/\.(appimage|sh|bin)$/i, '') for (const name of [stem + '.png', stem + '.svg', '.DirIcon', 'icon.png', 'icon.svg']) { const candidate = path.join(dir, name) if (pngOrSvg(candidate)) { source = candidate break } } } if (!source) return { ok: false, error: 'no-icon' } const outPath = `${outBase}.${pngOrSvg(source)}` fs.mkdirSync(path.dirname(outPath), { recursive: true }) fs.copyFileSync(source, outPath) return { ok: true, path: outPath } } catch (err) { return { ok: false, error: String((err && err.message) || err) } } } module.exports = { extractIconFromExe, iconFromLinuxApp, parseIcons, buildIco }