diff --git a/.gitignore b/.gitignore
index 033cbf9..ac3b077 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,14 +10,6 @@ coverage/
dist/
dist-electron/
release/
-build/
-!build/
-build/*
-!build/ICON_FILES_REQUIRED.txt
-!build/icon-source.png
-!build/icon.png
-!build/icon.ico
-!build/icon.icns
# Environment variables
.env
diff --git a/build/budget-file-icon.icns b/build/budget-file-icon.icns
new file mode 100644
index 0000000..560d305
Binary files /dev/null and b/build/budget-file-icon.icns differ
diff --git a/build/budget-file-icon.ico b/build/budget-file-icon.ico
new file mode 100644
index 0000000..216066c
Binary files /dev/null and b/build/budget-file-icon.ico differ
diff --git a/build/budget-file-icon.png b/build/budget-file-icon.png
new file mode 100644
index 0000000..521899a
Binary files /dev/null and b/build/budget-file-icon.png differ
diff --git a/build/budget-icon-source.png b/build/budget-icon-source.png
new file mode 100644
index 0000000..521899a
Binary files /dev/null and b/build/budget-icon-source.png differ
diff --git a/build/entitlements.mac.inherit.plist b/build/entitlements.mac.inherit.plist
new file mode 100644
index 0000000..48f7bf5
--- /dev/null
+++ b/build/entitlements.mac.inherit.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist
new file mode 100644
index 0000000..48f7bf5
--- /dev/null
+++ b/build/entitlements.mac.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js
index 7116ec2..92ab2ca 100644
--- a/scripts/generate-icons.js
+++ b/scripts/generate-icons.js
@@ -10,7 +10,9 @@ const rootDir = path.resolve(__dirname, '..');
const buildDir = path.join(rootDir, 'build');
const sourcePng = path.join(buildDir, 'icon-source.png');
+const sourceWinPng = path.join(buildDir, 'icon-source-win.png');
const budgetSourcePng = path.join(buildDir, 'budget-icon-source.png');
+const budgetSourceWinPng = path.join(buildDir, 'budget-icon-source-win.png');
const skipIfMissing = process.argv.includes('--if-present');
function fail(message) {
@@ -18,7 +20,65 @@ function fail(message) {
process.exit(1);
}
-function generateIconSet(inputBuffer, outputBaseName) {
+function getPngDimensions(buffer, label) {
+ if (!buffer || buffer.length < 24) {
+ fail(`Invalid PNG data for ${label}: file is too small.`);
+ }
+
+ // PNG signature (8 bytes)
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+ const hasValidSignature = signature.every((value, index) => buffer[index] === value);
+ if (!hasValidSignature) {
+ fail(`Invalid PNG data for ${label}: missing PNG signature.`);
+ }
+
+ const width = buffer.readUInt32BE(16);
+ const height = buffer.readUInt32BE(20);
+
+ if (!width || !height) {
+ fail(`Invalid PNG dimensions for ${label}: width=${width}, height=${height}.`);
+ }
+
+ return { width, height };
+}
+
+function assertSquareIcon(buffer, label) {
+ const { width, height } = getPngDimensions(buffer, label);
+ if (width !== height) {
+ fail(
+ [
+ `${label} must be a square PNG for reliable .ico/.icns output.`,
+ `Found ${width}x${height}.`,
+ 'Please export a square image (recommended 1024x1024) and try again.',
+ ].join(' ')
+ );
+ }
+
+ if (width < 256) {
+ fail(
+ [
+ `${label} is too small (${width}x${height}).`,
+ 'Use at least 256x256 (recommended 1024x1024) for crisp icon variants.',
+ ].join(' ')
+ );
+ }
+
+ return { width, height };
+}
+
+function readValidatedSquarePng(filePath, label) {
+ const buffer = fs.readFileSync(filePath);
+
+ if (!buffer || buffer.length === 0) {
+ fail(`Source icon file is empty: ${label}`);
+ }
+
+ assertSquareIcon(buffer, label);
+ return buffer;
+}
+
+function generateIconSet(inputBuffer, outputBaseName, options = {}) {
+ const { icoInputBuffer = inputBuffer, sourceFilePath, icoSourceLabel } = options;
const outputPng = path.join(buildDir, `${outputBaseName}.png`);
const outputIco = path.join(buildDir, `${outputBaseName}.ico`);
const outputIcns = path.join(buildDir, `${outputBaseName}.icns`);
@@ -28,19 +88,21 @@ function generateIconSet(inputBuffer, outputBaseName) {
fail(`Failed to generate ICNS file for ${outputBaseName}`);
}
- const icoBuffer = png2icons.createICO(inputBuffer, png2icons.BICUBIC2, 0, false, true);
+ // Use BMP payloads for all ICO entries to maximize compatibility across
+ // shell previews and older Windows icon decoders.
+ const icoBuffer = png2icons.createICO(icoInputBuffer, png2icons.BICUBIC2, 0, false, false);
if (!icoBuffer) {
fail(`Failed to generate ICO file for ${outputBaseName}`);
}
- fs.copyFileSync(
- outputBaseName === 'icon' ? sourcePng : budgetSourcePng,
- outputPng
- );
+ fs.copyFileSync(sourceFilePath, outputPng);
fs.writeFileSync(outputIcns, icnsBuffer);
fs.writeFileSync(outputIco, icoBuffer);
console.log(`✓ Generated icon files for ${outputBaseName}:`);
+ if (icoSourceLabel) {
+ console.log(` - Windows ICO source: ${icoSourceLabel}`);
+ }
console.log(` - ${path.relative(rootDir, outputPng)}`);
console.log(` - ${path.relative(rootDir, outputIcns)}`);
console.log(` - ${path.relative(rootDir, outputIco)}`);
@@ -60,24 +122,48 @@ if (!fs.existsSync(sourcePng)) {
);
}
-const inputBuffer = fs.readFileSync(sourcePng);
-
-if (!inputBuffer || inputBuffer.length === 0) {
- fail('Source icon file is empty: build/icon-source.png');
-}
+const inputBuffer = readValidatedSquarePng(sourcePng, 'build/icon-source.png');
+const inputWinBuffer = fs.existsSync(sourceWinPng)
+ ? readValidatedSquarePng(sourceWinPng, 'build/icon-source-win.png')
+ : inputBuffer;
fs.mkdirSync(buildDir, { recursive: true });
-generateIconSet(inputBuffer, 'icon');
+generateIconSet(inputBuffer, 'icon', {
+ icoInputBuffer: inputWinBuffer,
+ sourceFilePath: sourcePng,
+ icoSourceLabel: fs.existsSync(sourceWinPng) ? 'build/icon-source-win.png' : 'build/icon-source.png',
+});
if (fs.existsSync(budgetSourcePng)) {
- const budgetInputBuffer = fs.readFileSync(budgetSourcePng);
-
- if (!budgetInputBuffer || budgetInputBuffer.length === 0) {
- fail('Source icon file is empty: build/budget-icon-source.png');
+ const budgetInputBuffer = readValidatedSquarePng(budgetSourcePng, 'build/budget-icon-source.png');
+ const budgetWinInputBuffer = fs.existsSync(budgetSourceWinPng)
+ ? readValidatedSquarePng(budgetSourceWinPng, 'build/budget-icon-source-win.png')
+ : budgetInputBuffer;
+
+ const { width, height } = getPngDimensions(budgetInputBuffer, 'build/budget-icon-source.png');
+
+ if (width !== height) {
+ console.warn(
+ [
+ '⚠️ build/budget-icon-source.png is not square',
+ `(${width}x${height}).`,
+ 'Using app icon for .budget file associations to avoid corrupted icon output.',
+ ].join(' ')
+ );
+
+ fs.copyFileSync(path.join(buildDir, 'icon.png'), path.join(buildDir, 'budget-file-icon.png'));
+ fs.copyFileSync(path.join(buildDir, 'icon.icns'), path.join(buildDir, 'budget-file-icon.icns'));
+ fs.copyFileSync(path.join(buildDir, 'icon.ico'), path.join(buildDir, 'budget-file-icon.ico'));
+ } else {
+ generateIconSet(budgetInputBuffer, 'budget-file-icon', {
+ icoInputBuffer: budgetWinInputBuffer,
+ sourceFilePath: budgetSourcePng,
+ icoSourceLabel: fs.existsSync(budgetSourceWinPng)
+ ? 'build/budget-icon-source-win.png'
+ : 'build/budget-icon-source.png',
+ });
}
-
- generateIconSet(budgetInputBuffer, 'budget-file-icon');
} else {
// Fallback so file association always has a valid icon set.
fs.copyFileSync(path.join(buildDir, 'icon.png'), path.join(buildDir, 'budget-file-icon.png'));