Skip to content

Commit f44dae5

Browse files
committed
fix(mac): inset nightly app icon
1 parent 81d5351 commit f44dae5

4 files changed

Lines changed: 33 additions & 97 deletions

File tree

build/icon-nightly-mac.png

44.9 KB
Loading

build/icon-nightly.icns

20.7 KB
Binary file not shown.

scripts/build-desktop-artifact.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ function buildElectronBuilderConfig() {
456456
const updaterChannel = channelTable.updaterChannelFor(channel);
457457
const prefix = channelTable.artifactPrefixFor(channel);
458458
const iconSuffix = channel === "nightly" ? "-nightly" : "";
459+
const runtimeIconSuffix = channel === "nightly" ? "-nightly-mac" : "";
459460
const publishChannelLine = updaterChannel ? `\n channel: ${updaterChannel}` : "";
460461
const macEntitlements = "build/entitlements.mac.plist";
461462
const macEntitlementsInherit = "build/entitlements.mac.plist";
@@ -492,7 +493,7 @@ extraResources:
492493
to: skills
493494
filter:
494495
- "**/*"
495-
- from: build/icon${iconSuffix}.png
496+
- from: build/icon${runtimeIconSuffix}.png
496497
to: app-icon.png
497498
498499
extraMetadata:

scripts/make-nightly-icon.mjs

Lines changed: 31 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
#!/usr/bin/env node
2-
// Generate the Nightly channel's build/icon-nightly.{png,icns,ico} from the
3-
// stable icns. The moon is composited over the cursor in the stable icon, and
4-
// the resulting 1024×1024 is downsized into platform-specific iconsets.
2+
// Normalize the Nightly channel's build/icon-nightly.png to the macOS optical
3+
// safe area, then generate the dedicated runtime PNG and ICNS variants.
54
//
6-
// Re-run after changing the stable icon (`build/icon.icns`) or tweaking the
7-
// crescent geometry below.
5+
// The full-bleed source stays unchanged for Windows and Linux. Trimming its
6+
// transparent canvas first also tolerates a pre-padded replacement source.
87
//
98
// macOS-only because it shells out to `sips` and `iconutil` for the ICNS
10-
// pipeline. The ICO is assembled inline (raw PNG payloads inside an ICONDIR
11-
// header) so no extra image-tooling is required.
9+
// pipeline.
1210

1311
import { execFileSync } from "node:child_process";
1412
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -22,77 +20,44 @@ const sharp = requireFromHere("sharp");
2220

2321
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
2422
const BUILD_DIR = join(repoRoot, "build");
25-
const STABLE_ICNS = join(BUILD_DIR, "icon.icns");
26-
const OUT_PNG = join(BUILD_DIR, "icon-nightly.png");
23+
const SOURCE_PNG = process.env.PORACODE_NIGHTLY_ICON_SOURCE || join(BUILD_DIR, "icon-nightly.png");
24+
const OUT_MAC_PNG = join(BUILD_DIR, "icon-nightly-mac.png");
2725
const OUT_ICNS = join(BUILD_DIR, "icon-nightly.icns");
28-
const OUT_ICO = join(BUILD_DIR, "icon-nightly.ico");
2926

3027
const SIZE = 1024;
28+
// Modern macOS icons keep the rounded-square body inside an optical safe area
29+
// instead of touching every edge of the 1024px canvas. A full-bleed body looks
30+
// materially larger than native and correctly padded third-party Dock icons.
31+
const ICON_BODY_SIZE = 824;
32+
const ICON_INSET = (SIZE - ICON_BODY_SIZE) / 2;
3133
const stage = mkdtempSync(join(tmpdir(), "poracode-nightly-icon-"));
3234
console.log(`stage: ${stage}`);
3335

3436
try {
35-
// 1. Extract the 1024×1024 variant from the stable .icns.
36-
const iconsetIn = join(stage, "stable.iconset");
37-
execFileSync("iconutil", ["-c", "iconset", STABLE_ICNS, "-o", iconsetIn], { stdio: "inherit" });
38-
const stableSource = join(iconsetIn, "icon_512x512@2x.png");
39-
40-
// 2. Composite the moon SVG over the stable source.
41-
const cx = SIZE / 2;
42-
const cy = SIZE / 2;
43-
const moonRadius = 215;
44-
const cutoutOffsetX = 110;
45-
const cutoutOffsetY = -50;
46-
const cutoutRadius = 180;
47-
const moonSvg = `
48-
<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
49-
<defs>
50-
<radialGradient id="moonFill" cx="50%" cy="45%" r="55%">
51-
<stop offset="0%" stop-color="#E9FBFC" />
52-
<stop offset="55%" stop-color="#9FEEF3" />
53-
<stop offset="100%" stop-color="#5CD4DC" />
54-
</radialGradient>
55-
<radialGradient id="moonGlow" cx="50%" cy="50%" r="50%">
56-
<stop offset="0%" stop-color="#6FE9F0" stop-opacity="0.55" />
57-
<stop offset="55%" stop-color="#3FB7C0" stop-opacity="0.25" />
58-
<stop offset="100%" stop-color="#1A6E76" stop-opacity="0" />
59-
</radialGradient>
60-
<radialGradient id="cursorMask" cx="50%" cy="50%" r="50%">
61-
<stop offset="0%" stop-color="#0E1620" stop-opacity="0.95" />
62-
<stop offset="70%" stop-color="#0E1620" stop-opacity="0.65" />
63-
<stop offset="100%" stop-color="#0E1620" stop-opacity="0" />
64-
</radialGradient>
65-
<mask id="crescent" maskUnits="userSpaceOnUse">
66-
<rect width="${SIZE}" height="${SIZE}" fill="black" />
67-
<circle cx="${cx}" cy="${cy}" r="${moonRadius}" fill="white" />
68-
<circle cx="${cx + cutoutOffsetX}" cy="${cy + cutoutOffsetY}" r="${cutoutRadius}" fill="black" />
69-
</mask>
70-
<filter id="softBlur" x="-50%" y="-50%" width="200%" height="200%">
71-
<feGaussianBlur stdDeviation="14" />
72-
</filter>
73-
<filter id="cursorBlur" x="-50%" y="-50%" width="200%" height="200%">
74-
<feGaussianBlur stdDeviation="22" />
75-
</filter>
76-
</defs>
77-
<ellipse cx="${cx}" cy="${cy}" rx="120" ry="240" fill="url(#cursorMask)" filter="url(#cursorBlur)" />
78-
<circle cx="${cx}" cy="${cy}" r="${moonRadius + 80}" fill="url(#moonGlow)" filter="url(#softBlur)" />
79-
<rect width="${SIZE}" height="${SIZE}" fill="url(#moonFill)" mask="url(#crescent)" />
80-
<g mask="url(#crescent)">
81-
<circle cx="${cx - 30}" cy="${cy - 30}" r="${moonRadius - 10}" fill="none" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="6" />
82-
</g>
83-
</svg>
84-
`;
37+
// 1. Normalize the visible icon body to the macOS safe area. `trim()` strips
38+
// either the original full-bleed canvas or padding from a previous run.
8539
const composed = join(stage, "icon-nightly-1024.png");
86-
await sharp(stableSource)
87-
.composite([{ input: Buffer.from(moonSvg), top: 0, left: 0 }])
40+
await sharp(SOURCE_PNG)
41+
.trim()
42+
.resize(ICON_BODY_SIZE, ICON_BODY_SIZE, {
43+
fit: "contain",
44+
background: { r: 0, g: 0, b: 0, alpha: 0 },
45+
})
46+
.extend({
47+
top: ICON_INSET,
48+
bottom: ICON_INSET,
49+
left: ICON_INSET,
50+
right: ICON_INSET,
51+
background: { r: 0, g: 0, b: 0, alpha: 0 },
52+
})
8853
.png()
8954
.toFile(composed);
9055

91-
// 3. PNG output.
92-
writeFileSync(OUT_PNG, readFileSync(composed));
93-
console.log(`wrote ${OUT_PNG}`);
56+
// 2. Runtime PNG output used by app.dock.setIcon().
57+
writeFileSync(OUT_MAC_PNG, readFileSync(composed));
58+
console.log(`wrote ${OUT_MAC_PNG}`);
9459

95-
// 4. ICNS output via sips + iconutil.
60+
// 3. ICNS output via sips + iconutil.
9661
const iconsetOut = join(stage, "nightly.iconset");
9762
mkdirSync(iconsetOut, { recursive: true });
9863
const ICNS_SIZES = [
@@ -118,36 +83,6 @@ try {
11883
}
11984
execFileSync("iconutil", ["-c", "icns", iconsetOut, "-o", OUT_ICNS], { stdio: "inherit" });
12085
console.log(`wrote ${OUT_ICNS}`);
121-
122-
// 5. ICO output. Modern .ico files embed PNG payloads directly; we just need
123-
// an ICONDIR header + one ICONDIRENTRY per size pointing at the PNG offsets.
124-
const ICO_SIZES = [16, 32, 48, 64, 128, 256];
125-
const pngBuffers = await Promise.all(
126-
ICO_SIZES.map((size) => sharp(composed).resize(size, size).png().toBuffer()),
127-
);
128-
const header = Buffer.alloc(6);
129-
header.writeUInt16LE(0, 0);
130-
header.writeUInt16LE(1, 2);
131-
header.writeUInt16LE(ICO_SIZES.length, 4);
132-
const ENTRY_SIZE = 16;
133-
const entries = Buffer.alloc(ENTRY_SIZE * ICO_SIZES.length);
134-
let offset = header.length + ENTRY_SIZE * ICO_SIZES.length;
135-
for (let i = 0; i < ICO_SIZES.length; i++) {
136-
const size = ICO_SIZES[i];
137-
const png = pngBuffers[i];
138-
const base = i * ENTRY_SIZE;
139-
entries.writeUInt8(size === 256 ? 0 : size, base);
140-
entries.writeUInt8(size === 256 ? 0 : size, base + 1);
141-
entries.writeUInt8(0, base + 2);
142-
entries.writeUInt8(0, base + 3);
143-
entries.writeUInt16LE(1, base + 4);
144-
entries.writeUInt16LE(32, base + 6);
145-
entries.writeUInt32LE(png.length, base + 8);
146-
entries.writeUInt32LE(offset, base + 12);
147-
offset += png.length;
148-
}
149-
writeFileSync(OUT_ICO, Buffer.concat([header, entries, ...pngBuffers]));
150-
console.log(`wrote ${OUT_ICO}`);
15186
} finally {
15287
rmSync(stage, { recursive: true, force: true });
15388
}

0 commit comments

Comments
 (0)