diff --git a/images/defaultIcon.png b/images/defaultIcon.png new file mode 100644 index 0000000..65d9c01 Binary files /dev/null and b/images/defaultIcon.png differ diff --git a/package-lock.json b/package-lock.json index 0cc836e..46b8d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "edit-json-file": "^1.6.2", "follow-redirects": "^1.13.1", "fs-extra": "^9.0.1", + "png2icons": "^2.0.1", "recursive-readdir": "^2.2.2", + "resedit": "^2.0.2", "spawn-command": "^0.0.2-1", "tcp-port-used": "^1.0.2", "uuid": "^8.3.2", @@ -984,6 +986,19 @@ "node": ">=0.10.0" } }, + "node_modules/pe-library": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", + "integrity": "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==", + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -1027,6 +1042,14 @@ "node": ">=0.10.0" } }, + "node_modules/png2icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/png2icons/-/png2icons-2.0.1.tgz", + "integrity": "sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA==", + "bin": { + "png2icons": "png2icons-cli.js" + } + }, "node_modules/prettier": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", @@ -1074,6 +1097,22 @@ "node": ">=6.0.0" } }, + "node_modules/resedit": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-2.0.2.tgz", + "integrity": "sha512-UKTnq602iVe+W5SyRAQx/WdWMnlDiONfXBLFg/ur4QE4EQQ8eP7Jgm5mNXdK12kKawk1vvXPja2iXKqZiGDW6Q==", + "dependencies": { + "pe-library": "^1.0.1" + }, + "engines": { + "node": ">=14", + "npm": ">=7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -2097,6 +2136,11 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, + "pe-library": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", + "integrity": "sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==" + }, "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -2125,6 +2169,11 @@ "pinkie": "^2.0.0" } }, + "png2icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/png2icons/-/png2icons-2.0.1.tgz", + "integrity": "sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA==" + }, "prettier": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", @@ -2157,6 +2206,14 @@ "minimatch": "^3.0.5" } }, + "resedit": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-2.0.2.tgz", + "integrity": "sha512-UKTnq602iVe+W5SyRAQx/WdWMnlDiONfXBLFg/ur4QE4EQQ8eP7Jgm5mNXdK12kKawk1vvXPja2iXKqZiGDW6Q==", + "requires": { + "pe-library": "^1.0.1" + } + }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", diff --git a/package.json b/package.json index 2262143..29963b5 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "edit-json-file": "^1.6.2", "follow-redirects": "^1.13.1", "fs-extra": "^9.0.1", + "png2icons": "^2.0.1", "recursive-readdir": "^2.2.2", + "resedit": "^2.0.2", "spawn-command": "^0.0.2-1", "tcp-port-used": "^1.0.2", "uuid": "^8.3.2", diff --git a/src/modules/bundler.js b/src/modules/bundler.js index 9e33510..0a2fa51 100644 --- a/src/modules/bundler.js +++ b/src/modules/bundler.js @@ -7,6 +7,7 @@ const constants = require('../constants'); const frontendlib = require('./frontendlib'); const hostproject = require('./hostproject'); const utils = require('../utils'); +const {patchWindowsExecutable} = require('./exepatch'); const path = require('path'); async function createAsarFile() { @@ -87,6 +88,23 @@ module.exports.bundleApp = async (isRelease, copyStorage) => { } } + utils.log('Patching windows executables...'); + try { + await Promise.all(Object.keys(constants.files.binaries.win32).map(async (arch) => { + const origBinaryName = constants.files.binaries.win32[arch]; + const filepath = hostproject.hasHostProject() ? `bin/${origBinaryName}` : origBinaryName.replace('neutralino', binaryName); + const winPath = `${buildDir}/${binaryName}/${filepath}`; + if (await fse.exists(winPath)) { + await patchWindowsExecutable(winPath); + } + })) + } + catch (err) { + console.error(err); + utils.error('Could not patch windows executable'); + process.exit(1); + } + for (let dependency of constants.files.dependencies) { fse.copySync(`bin/${dependency}`, `${buildDir}/${binaryName}/${dependency}`); } diff --git a/src/modules/exepatch.js b/src/modules/exepatch.js new file mode 100644 index 0000000..f7d5813 --- /dev/null +++ b/src/modules/exepatch.js @@ -0,0 +1,125 @@ +const fse = require('fs-extra'); +const png2icons = require('png2icons'); +const path = require('path'); + +const config = require('./config'); +// 1033 means "English (United States)" locale in Windows +const EN_US = 1033; + +// works like `a ?? b ?? c ?? ...` +const pickNotNullish = (...values) => { + for (let i = 0; i < values.length - 1; i++) { + if (values[i] !== undefined && values[i] !== null) { + return values[i]; + } + } + return values[values.length - 1]; +} + +const getWindowsMetadata = (config) => { + const copyright = pickNotNullish( + config.copyright, + `Copyright © ${new Date().getFullYear()} ${pickNotNullish(config.author, 'All rights reserved.')}` + ); + const versionStrings = { + CompanyName: config.author, + FileDescription: pickNotNullish(config.description, 'A Neutralinojs application'), + ProductVersion: config.version, + LegalCopyright: copyright, + InternalName: config.cli.binaryName, + OriginalFilename: config.cli.binaryName, + ProductName: pickNotNullish(config.applicationName, config.cli.binaryName), + SpecialBuild: config.cli.binaryName, + }; + // Strip away any undefined values from the versionStrings object + // in case some configuration options are missing. + Object.keys(versionStrings).forEach((option) => { + if (versionStrings[option] === undefined) { + delete versionStrings[option]; + } + }); + return versionStrings; +}; + +/** + * Reads the specified png file and returns a buffer of a converted ICO file. + */ +const convertPngToIco = async (src) => { + const icon = await fse.readFile(src); + return png2icons.createICO(icon, png2icons.HERMITE, 0, true, true); +} + +/** + * Edits the Windows executable with an icon + * and changes its metadata to include the app's name and version. + * + * @param {string} src The path to the .exe file + */ +const patchWindowsExecutable = async (src) => { + const resedit = await import('resedit'); + // pe-library is a direct dependency of resedit + const peLibrary = await import('pe-library'); + const configObj = config.get(); + // Create an executable object representation of the .exe file and get its resource object + const exe = peLibrary.NtExecutable.from(await fse.readFile(src)); + const res = peLibrary.NtExecutableResource.from(exe); + + // If an icon was not set for the project, try to get the icon from the window's config + let windowIcon = null; + if (configObj.modes && configObj.modes.window && configObj.modes.window.icon) { + windowIcon = configObj.modes.window.icon; + // Do not use non-PNG window icons. + if (windowIcon.split('.').pop().toLowerCase() !== 'png') { + windowIcon = null; + } else { + // Remove the leading slash if it exists, we need a path relative to CWD. + if (windowIcon[0] === '/') { + windowIcon = windowIcon.slice(1); + } + // Get the path from the resources directory relative to CWD. + windowIcon = path.join(process.cwd(), windowIcon); + } + } + + const iconPath = pickNotNullish( + configObj.applicationIcon, + windowIcon, + `${__dirname}/../../images/defaultIcon.png` // Default to a neutralinojs icon + ); + const icoBuffer = await convertPngToIco(iconPath); + // Create an icon file that contains the icon + const iconFile = resedit.Data.IconFile.from(icoBuffer); + // Put the group into the resource + resedit.Resource.IconGroupEntry.replaceIconsForResource( + res.entries, + // 0 is the default icon group + 0, + EN_US, + iconFile.icons.map(i => i.data) + ); + + // Fill in version info + const vi = resedit.Resource.VersionInfo.createEmpty(); + const [major, minor, patch] = pickNotNullish(configObj.version, '1.0.0').split("."); + vi.setFileVersion( + pickNotNullish(major, 1), // Version number + pickNotNullish(minor, 0), + pickNotNullish(patch, 0), + 0, // Revision number, isn't used in most JS applications so use a 0. + EN_US + ); + vi.setStringValues({ + lang: EN_US, + codepage: 1200 + }, getWindowsMetadata(configObj)); + vi.outputToResourceEntries(res.entries); + // Write the modified resource to the executable object + res.outputResource(exe); + // Output the modified executable to the original file path + const outBuffer = Buffer.from(exe.generate()); + await fse.writeFile(src, outBuffer); +} + +module.exports = { + patchWindowsExecutable +}; \ No newline at end of file