Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added build/budget-file-icon.icns
Binary file not shown.
Binary file added build/budget-file-icon.ico
Binary file not shown.
Binary file added build/budget-file-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/budget-icon-source.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions build/entitlements.mac.inherit.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
Comment on lines +1 to +11
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This inherit entitlements file is added but (based on the current electron-builder config) nothing references it. If helpers need distinct entitlements, add mac.entitlementsInherit to package.json build config; otherwise consider removing this file to avoid dead configuration that can drift over time.

Copilot uses AI. Check for mistakes.
</plist>
12 changes: 12 additions & 0 deletions build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
Comment on lines +5 to +10
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These entitlements significantly relax macOS hardened runtime protections (allow-jit, allow-unsigned-executable-memory, disable-library-validation). If they’re strictly required for Electron/native modules, consider documenting the rationale and keeping the set as minimal as possible (in particular, disable-library-validation is a broad permission).

Suggested change
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Electron/V8 requires JIT; keep this minimal hardened runtime relaxation. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>

Copilot uses AI. Check for mistakes.
</dict>
</plist>
122 changes: 104 additions & 18 deletions scripts/generate-icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,75 @@ 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) {
console.error(`❌ ${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`);
Expand All @@ -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);
Comment on lines 80 to 99
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateIconSet now always does fs.copyFileSync(sourceFilePath, outputPng), but sourceFilePath is an optional field in the options parameter. If a future call site omits it, this will throw a non-obvious TypeError. Consider making sourceFilePath a required function parameter (or explicitly validate it and fail with a clear message).

Copilot uses AI. Check for mistakes.
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)}`);
Expand All @@ -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'));
Expand Down
Loading