// Offline brand assets for 橘子拓客. Normal builds use the checked-in files. // Requires sharp from a local installation, or an existing module path: // ICON_SHARP_MODULE=/absolute/path/to/node_modules/sharp node scripts/generate-app-icons.mjs // No downloads, browser sessions, or platform calls are used. import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const require = createRequire(import.meta.url); let sharp; try { sharp = require(process.env.ICON_SHARP_MODULE || 'sharp'); } catch { throw new Error('Icon generation needs the existing sharp module. Set ICON_SHARP_MODULE to its absolute path; normal builds do not regenerate icons.'); } const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const directory = resolve(root, 'resources/brand'); const component = await readFile(resolve(root, 'renderer/components/ParticleCompanion.tsx'), 'utf8'); function sparkPath(name) { const match = component.match(new RegExp(`const ${name} = '([^']+)';`)); if (!match) throw new Error(`The shared brand path ${name} is missing.`); return match[1]; } // The same rounded spark, detached drop and smile as SparkMark. The silver-blue // rounded tile adds a clear desktop silhouette without introducing another logo. const svg = ` `; await mkdir(directory, { recursive: true }); await writeFile(resolve(directory, 'app-icon.svg'), svg); const sizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024]; const pngs = new Map(await Promise.all(sizes.map(async size => [size, await sharp(Buffer.from(svg)).resize(size, size).png({ compressionLevel: 9 }).toBuffer(), ]))); await writeFile(resolve(directory, 'app-icon.png'), pngs.get(1024)); // ICO directory entries may contain PNG payloads (Windows Vista and newer). const icoSizes = [16, 24, 32, 48, 64, 128, 256]; const icoHeader = Buffer.alloc(6 + 16 * icoSizes.length); icoHeader.writeUInt16LE(1, 2); icoHeader.writeUInt16LE(icoSizes.length, 4); let icoOffset = icoHeader.length; icoSizes.forEach((size, index) => { const position = 6 + 16 * index; const png = pngs.get(size); icoHeader[position] = icoHeader[position + 1] = size === 256 ? 0 : size; icoHeader.writeUInt16LE(1, position + 4); icoHeader.writeUInt16LE(32, position + 6); icoHeader.writeUInt32LE(png.length, position + 8); icoHeader.writeUInt32LE(icoOffset, position + 12); icoOffset += png.length; }); await writeFile(resolve(directory, 'app-icon.ico'), Buffer.concat([icoHeader, ...icoSizes.map(size => pngs.get(size))])); // Modern ICNS uses PNG payloads. Include standard and Retina representations. const icnsTypes = [['icp4', 16], ['icp5', 32], ['icp6', 64], ['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024], ['ic11', 32], ['ic12', 64], ['ic13', 256], ['ic14', 512]]; const icnsChunks = icnsTypes.map(([type, size]) => { const png = pngs.get(size); const header = Buffer.alloc(8); header.write(type, 0, 4, 'ascii'); header.writeUInt32BE(8 + png.length, 4); return Buffer.concat([header, png]); }); const icnsHeader = Buffer.alloc(8); icnsHeader.write('icns', 0, 4, 'ascii'); icnsHeader.writeUInt32BE(8 + icnsChunks.reduce((total, chunk) => total + chunk.length, 0), 4); await writeFile(resolve(directory, 'app-icon.icns'), Buffer.concat([icnsHeader, ...icnsChunks])); const metadata = await sharp(pngs.get(1024)).metadata(); if (metadata.width !== 1024 || metadata.height !== 1024 || !metadata.hasAlpha) throw new Error('Invalid application icon dimensions or transparency.'); console.log(JSON.stringify({ generated: ['app-icon.svg', 'app-icon.png', 'app-icon.ico', 'app-icon.icns'], directory, size: 1024, icoSizes, icnsRepresentations: icnsTypes.length, externalCalls: 0 }, null, 2));