super-productivity/tools/generate-mac-icon.js
Johannes Millan 681a6cd2ee fix(build): remove sharp from deps to fix F-Droid build
sharp is a native C++ module that fails to compile in F-Droid's
build environment. It is only used by manual icon generation scripts,
so auto-install it on demand instead.

Closes #6637
2026-03-29 19:57:10 +02:00

176 lines
6.4 KiB
JavaScript

#!/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 `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
<path d="${d}" fill="white"/>
</svg>`;
}
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);
});