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
1311import { execFileSync } from "node:child_process" ;
1412import { mkdirSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
@@ -22,77 +20,44 @@ const sharp = requireFromHere("sharp");
2220
2321const repoRoot = resolve ( dirname ( fileURLToPath ( import . meta. url ) ) , ".." ) ;
2422const 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" ) ;
2725const OUT_ICNS = join ( BUILD_DIR , "icon-nightly.icns" ) ;
28- const OUT_ICO = join ( BUILD_DIR , "icon-nightly.ico" ) ;
2926
3027const 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 ;
3133const stage = mkdtempSync ( join ( tmpdir ( ) , "poracode-nightly-icon-" ) ) ;
3234console . log ( `stage: ${ stage } ` ) ;
3335
3436try {
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