102 lines
5.7 KiB
JavaScript
102 lines
5.7 KiB
JavaScript
// 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
|
<defs>
|
|
<linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
|
|
<stop offset="0" stop-color="#f3f8fc"/><stop offset=".52" stop-color="#e4edf5"/><stop offset="1" stop-color="#cadce9"/>
|
|
</linearGradient>
|
|
<linearGradient id="spark" x1=".18" y1="0" x2=".82" y2="1">
|
|
<stop offset="0" stop-color="#b4cddb"/><stop offset=".38" stop-color="#7fa6bf"/><stop offset=".78" stop-color="#517b9b"/><stop offset="1" stop-color="#628ba7"/>
|
|
</linearGradient>
|
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#ffffff" stop-opacity=".9"/><stop offset="1" stop-color="#9eb9cc" stop-opacity=".5"/></linearGradient>
|
|
<radialGradient id="glow" cx=".28" cy=".16" r=".9"><stop stop-color="#ffffff" stop-opacity=".7"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></radialGradient>
|
|
</defs>
|
|
<rect x="92" y="105" width="840" height="840" rx="190" fill="#5c7e97" opacity=".13"/>
|
|
<rect x="92" y="79" width="840" height="840" rx="190" fill="url(#tile)"/>
|
|
<rect x="94" y="81" width="836" height="836" rx="188" fill="url(#glow)" stroke="url(#rim)" stroke-width="4"/>
|
|
<g transform="translate(122 112) scale(3.2)">
|
|
<path fill="url(#spark)" d="${sparkPath('SPARK_BODY')}"/>
|
|
<path fill="url(#spark)" d="${sparkPath('SPARK_DROP')}"/>
|
|
<path fill="#edf5fa" fill-opacity=".34" d="M54 131C56 108 70 85 84 76C69 101 66 119 65 137C63 143 58 142 54 139Z"/>
|
|
<path fill="#f7fafc" d="${sparkPath('SPARK_FACE')}"/>
|
|
<ellipse fill="#c4d8e5" fill-opacity=".5" cx="82" cy="154" rx="9" ry="7"/>
|
|
<ellipse fill="#c4d8e5" fill-opacity=".5" cx="151" cy="154" rx="9" ry="7"/>
|
|
<ellipse fill="#365a75" cx="102" cy="140" rx="3.7" ry="4.4"/>
|
|
<ellipse fill="#365a75" cx="139" cy="140" rx="3.7" ry="4.4"/>
|
|
<path fill="none" d="${sparkPath('SPARK_SMILE')}" stroke="#426a87" stroke-width="3.2" stroke-linecap="round"/>
|
|
</g>
|
|
</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));
|