|
| 1 | +import { spawnSync } from 'child_process' |
| 2 | +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs' |
| 3 | +import { tmpdir } from 'os' |
| 4 | +import { dirname, join, resolve } from 'path' |
| 5 | +import { fileURLToPath } from 'url' |
| 6 | + |
| 7 | +const __dirname = dirname(fileURLToPath(import.meta.url)) |
| 8 | +const rootDir = resolve(__dirname, '..') |
| 9 | +const buildDir = join(rootDir, 'build') |
| 10 | +const sourceIcon = join(buildDir, 'icon.png') |
| 11 | +const outputIco = join(buildDir, 'icon.ico') |
| 12 | +const outputIcns = join(buildDir, 'icon.icns') |
| 13 | +const iconSizes = [16, 32, 48, 64, 128, 256, 512, 1024] |
| 14 | + |
| 15 | +if (!existsSync(sourceIcon)) { |
| 16 | + throw new Error(`Source icon not found: ${sourceIcon}`) |
| 17 | +} |
| 18 | + |
| 19 | +const tempDir = mkdtempSync(join(tmpdir(), 'neocode-icons-')) |
| 20 | + |
| 21 | +try { |
| 22 | + const resizedPngs = resizeImages(sourceIcon, tempDir, iconSizes) |
| 23 | + if (resizedPngs === null) { |
| 24 | + console.log(`Existing ${outputIco} and ${outputIcns} are up to date.`) |
| 25 | + } else { |
| 26 | + writeFileSync(outputIco, buildIco(resizedPngs)) |
| 27 | + writeFileSync(outputIcns, buildIcns(resizedPngs)) |
| 28 | + console.log(`Generated ${outputIco}`) |
| 29 | + console.log(`Generated ${outputIcns}`) |
| 30 | + } |
| 31 | +} finally { |
| 32 | + rmSync(tempDir, { recursive: true, force: true }) |
| 33 | +} |
| 34 | + |
| 35 | +/** resizeImages 根据当前系统选择可用的本地图像缩放工具。 */ |
| 36 | +function resizeImages(inputPath, outputDir, sizes) { |
| 37 | + if (process.platform === 'win32') { |
| 38 | + return resizeWithPowerShell(inputPath, outputDir, sizes) |
| 39 | + } |
| 40 | + if (process.platform === 'darwin' && commandExists('sips')) { |
| 41 | + return resizeWithSips(inputPath, outputDir, sizes) |
| 42 | + } |
| 43 | + if (commandExists('magick')) { |
| 44 | + return resizeWithMagick(inputPath, outputDir, sizes) |
| 45 | + } |
| 46 | + if (outputsAreFresh()) { |
| 47 | + console.warn('No local image resizer found; existing icon.ico and icon.icns are up to date.') |
| 48 | + return null |
| 49 | + } |
| 50 | + throw new Error('No local image resizer found. Install ImageMagick or run this script on Windows/macOS.') |
| 51 | +} |
| 52 | + |
| 53 | +/** resizeWithPowerShell 使用系统图像能力生成多尺寸 PNG,避免引入额外 npm 依赖。 */ |
| 54 | +function resizeWithPowerShell(inputPath, outputDir, sizes) { |
| 55 | + const script = ` |
| 56 | +Add-Type -AssemblyName System.Drawing |
| 57 | +$source = [System.Drawing.Image]::FromFile('${escapePowerShell(inputPath)}') |
| 58 | +try { |
| 59 | + $sizes = @(${sizes.join(',')}) |
| 60 | + foreach ($size in $sizes) { |
| 61 | + $bitmap = New-Object System.Drawing.Bitmap $size, $size |
| 62 | + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) |
| 63 | + try { |
| 64 | + $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality |
| 65 | + $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic |
| 66 | + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality |
| 67 | + $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality |
| 68 | + $graphics.Clear([System.Drawing.Color]::Transparent) |
| 69 | + $graphics.DrawImage($source, 0, 0, $size, $size) |
| 70 | + $bitmap.Save((Join-Path '${escapePowerShell(outputDir)}' "$size.png"), [System.Drawing.Imaging.ImageFormat]::Png) |
| 71 | + } finally { |
| 72 | + $graphics.Dispose() |
| 73 | + $bitmap.Dispose() |
| 74 | + } |
| 75 | + } |
| 76 | +} finally { |
| 77 | + $source.Dispose() |
| 78 | +} |
| 79 | +` |
| 80 | + const result = spawnSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { |
| 81 | + encoding: 'utf8', |
| 82 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 83 | + }) |
| 84 | + if (result.status !== 0) { |
| 85 | + throw new Error(`Icon resize failed:\n${result.stderr || result.stdout}`) |
| 86 | + } |
| 87 | + return new Map(sizes.map((size) => [size, readFileSync(join(outputDir, `${size}.png`))])) |
| 88 | +} |
| 89 | + |
| 90 | +/** resizeWithSips 使用 macOS 内置 sips 生成多尺寸 PNG。 */ |
| 91 | +function resizeWithSips(inputPath, outputDir, sizes) { |
| 92 | + for (const size of sizes) { |
| 93 | + const result = spawnSync('sips', ['-z', String(size), String(size), inputPath, '--out', join(outputDir, `${size}.png`)], { |
| 94 | + encoding: 'utf8', |
| 95 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 96 | + }) |
| 97 | + if (result.status !== 0) { |
| 98 | + throw new Error(`Icon resize failed:\n${result.stderr || result.stdout}`) |
| 99 | + } |
| 100 | + } |
| 101 | + return new Map(sizes.map((size) => [size, readFileSync(join(outputDir, `${size}.png`))])) |
| 102 | +} |
| 103 | + |
| 104 | +/** resizeWithMagick 使用 ImageMagick 作为 Linux 等环境的可选后备方案。 */ |
| 105 | +function resizeWithMagick(inputPath, outputDir, sizes) { |
| 106 | + for (const size of sizes) { |
| 107 | + const result = spawnSync('magick', [inputPath, '-resize', `${size}x${size}`, join(outputDir, `${size}.png`)], { |
| 108 | + encoding: 'utf8', |
| 109 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 110 | + }) |
| 111 | + if (result.status !== 0) { |
| 112 | + throw new Error(`Icon resize failed:\n${result.stderr || result.stdout}`) |
| 113 | + } |
| 114 | + } |
| 115 | + return new Map(sizes.map((size) => [size, readFileSync(join(outputDir, `${size}.png`))])) |
| 116 | +} |
| 117 | + |
| 118 | +/** buildIco 将多尺寸 PNG 封装为 Windows ICO 文件。 */ |
| 119 | +function buildIco(pngs) { |
| 120 | + const entries = iconSizes.filter((size) => size <= 256) |
| 121 | + const headerSize = 6 + entries.length * 16 |
| 122 | + let offset = headerSize |
| 123 | + const header = Buffer.alloc(headerSize) |
| 124 | + |
| 125 | + header.writeUInt16LE(0, 0) |
| 126 | + header.writeUInt16LE(1, 2) |
| 127 | + header.writeUInt16LE(entries.length, 4) |
| 128 | + |
| 129 | + entries.forEach((size, index) => { |
| 130 | + const image = pngs.get(size) |
| 131 | + const entryOffset = 6 + index * 16 |
| 132 | + header.writeUInt8(size === 256 ? 0 : size, entryOffset) |
| 133 | + header.writeUInt8(size === 256 ? 0 : size, entryOffset + 1) |
| 134 | + header.writeUInt8(0, entryOffset + 2) |
| 135 | + header.writeUInt8(0, entryOffset + 3) |
| 136 | + header.writeUInt16LE(1, entryOffset + 4) |
| 137 | + header.writeUInt16LE(32, entryOffset + 6) |
| 138 | + header.writeUInt32LE(image.length, entryOffset + 8) |
| 139 | + header.writeUInt32LE(offset, entryOffset + 12) |
| 140 | + offset += image.length |
| 141 | + }) |
| 142 | + |
| 143 | + return Buffer.concat([header, ...entries.map((size) => pngs.get(size))]) |
| 144 | +} |
| 145 | + |
| 146 | +/** buildIcns 将 PNG 数据封装为 macOS ICNS 文件,供 electron-builder 直接使用。 */ |
| 147 | +function buildIcns(pngs) { |
| 148 | + const chunks = [ |
| 149 | + ['icp4', 16], |
| 150 | + ['icp5', 32], |
| 151 | + ['icp6', 64], |
| 152 | + ['ic07', 128], |
| 153 | + ['ic08', 256], |
| 154 | + ['ic09', 512], |
| 155 | + ['ic10', 1024], |
| 156 | + ['ic11', 32], |
| 157 | + ['ic12', 64], |
| 158 | + ['ic13', 256], |
| 159 | + ['ic14', 512], |
| 160 | + ] |
| 161 | + const body = chunks.map(([type, size]) => { |
| 162 | + const image = pngs.get(size) |
| 163 | + const header = Buffer.alloc(8) |
| 164 | + header.write(type, 0, 4, 'ascii') |
| 165 | + header.writeUInt32BE(image.length + 8, 4) |
| 166 | + return Buffer.concat([header, image]) |
| 167 | + }) |
| 168 | + const totalLength = 8 + body.reduce((sum, chunk) => sum + chunk.length, 0) |
| 169 | + const header = Buffer.alloc(8) |
| 170 | + header.write('icns', 0, 4, 'ascii') |
| 171 | + header.writeUInt32BE(totalLength, 4) |
| 172 | + return Buffer.concat([header, ...body]) |
| 173 | +} |
| 174 | + |
| 175 | +/** escapePowerShell 转义路径中的单引号,保证脚本按字面值读取文件。 */ |
| 176 | +function escapePowerShell(value) { |
| 177 | + return value.replace(/'/g, "''") |
| 178 | +} |
| 179 | + |
| 180 | +/** commandExists 检查命令是否存在,避免直接执行时报出不清晰的错误。 */ |
| 181 | +function commandExists(command) { |
| 182 | + const probe = process.platform === 'win32' |
| 183 | + ? spawnSync('where.exe', [command], { stdio: 'ignore' }) |
| 184 | + : spawnSync('which', [command], { stdio: 'ignore' }) |
| 185 | + return probe.status === 0 |
| 186 | +} |
| 187 | + |
| 188 | +/** outputsAreFresh 判断已提交图标是否不旧于源图,可在缺少缩放工具时复用。 */ |
| 189 | +function outputsAreFresh() { |
| 190 | + if (!existsSync(outputIco) || !existsSync(outputIcns)) return false |
| 191 | + const sourceMtime = statSync(sourceIcon).mtimeMs |
| 192 | + return statSync(outputIco).mtimeMs >= sourceMtime && statSync(outputIcns).mtimeMs >= sourceMtime |
| 193 | +} |
0 commit comments