#!/usr/bin/env node /** * Generate macOS app icon with squircle mask from existing PNG source. * * macOS does not automatically apply a squircle mask to .icns icons — * the shape must be baked into the image. This script applies Apple's * continuous-corner rounded rectangle (the "squircle") to the square * source icon and generates build/icon.icns. * * The bezier curve constants are reverse-engineered from iOS * UIBezierPath(roundedRect:cornerRadius:) by Liam Rosenfeld. * See: https://liamrosenfeld.com/posts/apple_icon_quest/ * * The .icns file is assembled directly from PNG buffers (no native tools needed). */ let sharp; try { sharp = require('sharp'); } catch { console.log('sharp not found, installing...'); require('child_process').execSync('npm install --no-save --no-package-lock sharp', { stdio: 'inherit', }); sharp = require('sharp'); } const fs = require('fs'); const path = require('path'); const SOURCE_PNG = path.join(__dirname, '../build/icons/sq2160x2160.png'); const OUTPUT_ICNS = path.join(__dirname, '../build/icon.icns'); // ICNS OSType codes for PNG-based entries, mapped by pixel size // See: https://en.wikipedia.org/wiki/Apple_Icon_Image_format const ICNS_TYPES = [ { osType: 'ic10', size: 1024 }, { osType: 'ic09', size: 512 }, { osType: 'ic08', size: 256 }, { osType: 'ic07', size: 128 }, { osType: 'icp5', size: 32 }, { osType: 'icp4', size: 16 }, ]; // Apple's corner radius: 185.4px at 824x824, scaled to full canvas const CORNER_RADIUS_RATIO = 185.4 / 824; /** * Generate an SVG path for Apple's continuous-corner rounded rectangle. * * Unlike a standard SVG rounded rect (which uses circular arcs), this uses * cubic bezier curves with continuous curvature — the transition from * straight edge to curve is gradual, not abrupt. * * Constants from UIBezierPath(roundedRect:cornerRadius:) reverse-engineering. */ function continuousRoundedRectSvg(size) { const cr = Math.round(size * CORNER_RADIUS_RATIO); // Helper functions matching the Swift implementation const tl = (x, y) => [x * cr, y * cr]; const tr = (x, y) => [size - x * cr, y * cr]; const br = (x, y) => [size - x * cr, size - y * cr]; const bl = (x, y) => [x * cr, size - y * cr]; const p = (pt) => `${pt[0].toFixed(2)} ${pt[1].toFixed(2)}`; // Build SVG path using Apple's continuous-corner bezier curves const d = [ `M ${p(tl(1.528665, 0))}`, `L ${p(tr(1.528665, 0))}`, `C ${p(tr(1.08849296, 0))} ${p(tr(0.86840694, 0))} ${p(tr(0.63149379, 0.07491139))}`, `C ${p(tr(0.37282383, 0.16905956))} ${p(tr(0.16905956, 0.37282383))} ${p(tr(0.07491139, 0.63149379))}`, `C ${p(tr(0, 0.86840694))} ${p(tr(0, 1.08849296))} ${p(tr(0, 1.52866498))}`, `L ${p(br(0, 1.528665))}`, `C ${p(br(0, 1.08849296))} ${p(br(0, 0.86840694))} ${p(br(0.07491139, 0.63149379))}`, `C ${p(br(0.16905956, 0.37282383))} ${p(br(0.37282383, 0.16905956))} ${p(br(0.63149379, 0.07491139))}`, `C ${p(br(0.86840694, 0))} ${p(br(1.08849296, 0))} ${p(br(1.52866498, 0))}`, `L ${p(bl(1.528665, 0))}`, `C ${p(bl(1.08849296, 0))} ${p(bl(0.86840694, 0))} ${p(bl(0.63149379, 0.07491139))}`, `C ${p(bl(0.37282383, 0.16905956))} ${p(bl(0.16905956, 0.37282383))} ${p(bl(0.07491139, 0.63149379))}`, `C ${p(bl(0, 0.86840694))} ${p(bl(0, 1.08849296))} ${p(bl(0, 1.52866498))}`, `L ${p(tl(0, 1.528665))}`, `C ${p(tl(0, 1.08849296))} ${p(tl(0, 0.86840694))} ${p(tl(0.07491139, 0.63149379))}`, `C ${p(tl(0.16905956, 0.37282383))} ${p(tl(0.37282383, 0.16905956))} ${p(tl(0.63149379, 0.07491139))}`, `C ${p(tl(0.86840694, 0))} ${p(tl(1.08849296, 0))} ${p(tl(1.52866498, 0))}`, 'Z', ].join(' '); return ` `; } function buildIcns(entries) { // ICNS file format: // Header: 4 bytes magic ('icns') + 4 bytes total file size // Entries: 4 bytes OSType + 4 bytes entry size (including header) + PNG data const HEADER_SIZE = 8; const ENTRY_HEADER_SIZE = 8; let totalSize = HEADER_SIZE; for (const entry of entries) { totalSize += ENTRY_HEADER_SIZE + entry.data.length; } const buffer = Buffer.alloc(totalSize); let offset = 0; // File header buffer.write('icns', offset, 4, 'ascii'); offset += 4; buffer.writeUInt32BE(totalSize, offset); offset += 4; // Entries for (const entry of entries) { buffer.write(entry.osType, offset, 4, 'ascii'); offset += 4; buffer.writeUInt32BE(ENTRY_HEADER_SIZE + entry.data.length, offset); offset += 4; entry.data.copy(buffer, offset); offset += entry.data.length; } return buffer; } async function generateMacIcon() { console.log('Generating macOS app icon with continuous-corner squircle mask...\n'); if (!fs.existsSync(SOURCE_PNG)) { throw new Error(`Source PNG not found: ${SOURCE_PNG}`); } console.log('Source PNG found:', SOURCE_PNG); const entries = []; for (const { osType, size } of ICNS_TYPES) { const svg = continuousRoundedRectSvg(size); // Resize and apply squircle mask (preserving alpha for transparent edges) // macOS .icns natively supports PNG with alpha — no flatten needed. // See: https://github.com/super-productivity/super-productivity/issues/6323 const pngBuffer = await sharp(SOURCE_PNG) .resize(size, size, { fit: 'cover', position: 'center' }) .composite([{ input: Buffer.from(svg), blend: 'dest-in' }]) .png() .toBuffer(); entries.push({ osType, data: pngBuffer }); console.log(` Generated ${size}x${size} (${osType}, ${pngBuffer.length} bytes)`); } // Build and write .icns file console.log('\nAssembling .icns file...'); const icnsBuffer = buildIcns(entries); fs.writeFileSync(OUTPUT_ICNS, icnsBuffer); const stats = fs.statSync(OUTPUT_ICNS); console.log(`Generated: ${OUTPUT_ICNS} (${stats.size} bytes)`); // Verify the largest entry retains alpha (transparent edges) const metadata = await sharp(entries[0].data).metadata(); console.log(`\nVerification (1024px entry):`); console.log(` Dimensions: ${metadata.width}x${metadata.height}`); console.log(` Channels: ${metadata.channels} (${metadata.hasAlpha ? 'RGBA' : 'RGB'})`); console.log(` Has alpha: ${metadata.hasAlpha ? 'YES (correct)' : 'NO (unexpected)'}`); console.log('\nDone! macOS squircle icon generated at build/icon.icns'); } generateMacIcon().catch((error) => { console.error('\nError generating icon:', error.message); process.exit(1); });