feat(build): add icon generation and external binary bundling

- Add scripts to generate application icons in multiple formats (ICO, ICNS, PNG)
- Implement download scripts for uv and Node.js binaries for cross-platform support
- Update build configuration to use new icon resources and bundled binaries
- Remove old loading screen and unused build configurations
- Fix application icon path resolution to use app resources directory
This commit is contained in:
duanshuwen
2026-04-08 07:25:25 +08:00
parent 49a8c0c1d6
commit eb9acae071
132 changed files with 1289 additions and 817920 deletions

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env zx
import 'zx/globals';
const ROOT_DIR = path.resolve(__dirname, '..');
const NODE_VERSION = '22.16.0';
const BASE_URL = `https://nodejs.org/dist/v${NODE_VERSION}`;
const OUTPUT_BASE = path.join(ROOT_DIR, 'resources', 'bin');
const TARGETS = {
'win32-x64': {
filename: `node-v${NODE_VERSION}-win-x64.zip`,
sourceDir: `node-v${NODE_VERSION}-win-x64`,
},
'win32-arm64': {
filename: `node-v${NODE_VERSION}-win-arm64.zip`,
sourceDir: `node-v${NODE_VERSION}-win-arm64`,
},
};
const PLATFORM_GROUPS = {
win: ['win32-x64', 'win32-arm64'],
};
async function setupTarget(id) {
const target = TARGETS[id];
if (!target) {
echo(chalk.yellow`⚠️ Target ${id} is not supported by this script.`);
return;
}
const targetDir = path.join(OUTPUT_BASE, id);
const tempDir = path.join(ROOT_DIR, 'temp_node_extract');
const archivePath = path.join(ROOT_DIR, target.filename);
const downloadUrl = `${BASE_URL}/${target.filename}`;
echo(chalk.blue`\n📦 Setting up Node.js for ${id}...`);
// Only remove the target binary, not the entire directory,
// to avoid deleting other binaries placed by other download scripts.
const outputNode = path.join(targetDir, 'node.exe');
if (await fs.pathExists(outputNode)) {
await fs.remove(outputNode);
}
await fs.remove(tempDir);
await fs.ensureDir(targetDir);
await fs.ensureDir(tempDir);
try {
echo`⬇️ Downloading: ${downloadUrl}`;
let response;
try {
response = await fetch(downloadUrl);
if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`);
} catch (err) {
echo(chalk.yellow`⚠️ Network error downloading Node.js for ${id}: ${err.message}`);
echo(chalk.yellow` Skipping Node.js download for ${id}. Packaging will continue without external Node.js binary.`);
return;
}
const buffer = await response.arrayBuffer();
await fs.writeFile(archivePath, Buffer.from(buffer));
echo`📂 Extracting...`;
if (os.platform() === 'win32') {
const { execFileSync } = await import('child_process');
const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath.replace(/'/g, "''")}', '${tempDir.replace(/'/g, "''")}')`;
execFileSync('powershell.exe', ['-NoProfile', '-Command', psCommand], { stdio: 'inherit' });
} else {
await $`unzip -q -o ${archivePath} -d ${tempDir}`;
}
const expectedNode = path.join(tempDir, target.sourceDir, 'node.exe');
if (await fs.pathExists(expectedNode)) {
await fs.move(expectedNode, outputNode, { overwrite: true });
} else {
echo(chalk.yellow`🔍 node.exe not found in expected directory, searching...`);
const files = await glob('**/node.exe', { cwd: tempDir, absolute: true });
if (files.length > 0) {
await fs.move(files[0], outputNode, { overwrite: true });
} else {
echo(chalk.yellow`⚠️ Could not find node.exe in extracted files. Skipping.`);
return;
}
}
echo(chalk.green`✅ Success: ${outputNode}`);
} finally {
await fs.remove(archivePath).catch(() => {});
await fs.remove(tempDir).catch(() => {});
}
}
const downloadAll = argv.all;
const platform = argv.platform;
try {
if (downloadAll) {
echo(chalk.cyan`🌐 Downloading Node.js binaries for all Windows targets...`);
for (const id of Object.keys(TARGETS)) {
await setupTarget(id);
}
} else if (platform) {
const targets = PLATFORM_GROUPS[platform];
if (!targets) {
echo(chalk.red`❌ Unknown platform: ${platform}`);
echo(`Available platforms: ${Object.keys(PLATFORM_GROUPS).join(', ')}`);
process.exit(1);
}
echo(chalk.cyan`🎯 Downloading Node.js binaries for platform: ${platform}`);
for (const id of targets) {
await setupTarget(id);
}
} else {
const currentId = `${os.platform()}-${os.arch()}`;
if (TARGETS[currentId]) {
echo(chalk.cyan`💻 Detected Windows system: ${currentId}`);
await setupTarget(currentId);
} else {
echo(chalk.cyan`🎯 Defaulting to Windows multi-arch Node.js download`);
for (const id of PLATFORM_GROUPS.win) {
await setupTarget(id);
}
}
}
echo(chalk.green`\n🎉 Done!`);
} catch (err) {
echo(chalk.yellow`⚠️ Unexpected error in Node.js download script: ${err.message}`);
echo(chalk.yellow` Packaging will continue without external Node.js binary.`);
// Exit with code 0 to allow packaging to continue
process.exit(0);
}

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env zx
import 'zx/globals';
const ROOT_DIR = path.resolve(__dirname, '..');
const UV_VERSION = '0.10.0';
const BASE_URL = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}`;
const OUTPUT_BASE = path.join(ROOT_DIR, 'resources', 'bin');
// Mapping Node platforms/archs to uv release naming
const TARGETS = {
'darwin-arm64': {
filename: 'uv-aarch64-apple-darwin.tar.gz',
binName: 'uv',
},
'darwin-x64': {
filename: 'uv-x86_64-apple-darwin.tar.gz',
binName: 'uv',
},
'win32-arm64': {
filename: 'uv-aarch64-pc-windows-msvc.zip',
binName: 'uv.exe',
},
'win32-x64': {
filename: 'uv-x86_64-pc-windows-msvc.zip',
binName: 'uv.exe',
},
'linux-arm64': {
filename: 'uv-aarch64-unknown-linux-gnu.tar.gz',
binName: 'uv',
},
'linux-x64': {
filename: 'uv-x86_64-unknown-linux-gnu.tar.gz',
binName: 'uv',
}
};
// Platform groups for building multi-arch packages
const PLATFORM_GROUPS = {
'mac': ['darwin-x64', 'darwin-arm64'],
'win': ['win32-x64', 'win32-arm64'],
'linux': ['linux-x64', 'linux-arm64']
};
async function setupTarget(id) {
const target = TARGETS[id];
if (!target) {
echo(chalk.yellow`⚠️ Target ${id} is not supported by this script.`);
return;
}
const targetDir = path.join(OUTPUT_BASE, id);
const tempDir = path.join(ROOT_DIR, 'temp_uv_extract');
const archivePath = path.join(ROOT_DIR, target.filename);
const downloadUrl = `${BASE_URL}/${target.filename}`;
echo(chalk.blue`\n📦 Setting up uv for ${id}...`);
// Cleanup & Prep
await fs.remove(targetDir);
await fs.remove(tempDir);
await fs.ensureDir(targetDir);
await fs.ensureDir(tempDir);
try {
// Download
echo`⬇️ Downloading: ${downloadUrl}`;
let response;
try {
response = await fetch(downloadUrl);
if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`);
} catch (err) {
echo(chalk.yellow`⚠️ Network error downloading uv for ${id}: ${err.message}`);
echo(chalk.yellow` Skipping uv download for ${id}. Packaging will continue without uv binary.`);
return;
}
const buffer = await response.arrayBuffer();
await fs.writeFile(archivePath, Buffer.from(buffer));
// Extract
echo`📂 Extracting...`;
if (target.filename.endsWith('.zip')) {
if (os.platform() === 'win32') {
const { execFileSync } = await import('child_process');
const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath.replace(/'/g, "''")}', '${tempDir.replace(/'/g, "''")}')`;
execFileSync('powershell.exe', ['-NoProfile', '-Command', psCommand], { stdio: 'inherit' });
} else {
await $`unzip -q -o ${archivePath} -d ${tempDir}`;
}
} else {
await $`tar -xzf ${archivePath} -C ${tempDir}`;
}
// Move binary
// uv archives usually contain a folder named after the target
const folderName = target.filename.replace('.tar.gz', '').replace('.zip', '');
const sourceBin = path.join(tempDir, folderName, target.binName);
const destBin = path.join(targetDir, target.binName);
if (await fs.pathExists(sourceBin)) {
await fs.move(sourceBin, destBin, { overwrite: true });
} else {
echo(chalk.yellow`🔍 Binary not found in expected subfolder, searching...`);
const files = await glob(`**/${target.binName}`, { cwd: tempDir, absolute: true });
if (files.length > 0) {
await fs.move(files[0], destBin, { overwrite: true });
} else {
echo(chalk.yellow`⚠️ Could not find ${target.binName} in extracted files. Skipping.`);
return;
}
}
// Permission fix
if (os.platform() !== 'win32') {
await fs.chmod(destBin, 0o755);
}
echo(chalk.green`✅ Success: ${destBin}`);
} finally {
// Cleanup
await fs.remove(archivePath).catch(() => {});
await fs.remove(tempDir).catch(() => {});
}
}
// Main logic
const downloadAll = argv.all;
const platform = argv.platform;
try {
if (downloadAll) {
// Download for all platforms
echo(chalk.cyan`🌐 Downloading uv binaries for ALL supported platforms...`);
for (const id of Object.keys(TARGETS)) {
await setupTarget(id);
}
} else if (platform) {
// Download for a specific platform (e.g., --platform=mac)
const targets = PLATFORM_GROUPS[platform];
if (!targets) {
echo(chalk.red`❌ Unknown platform: ${platform}`);
echo(`Available platforms: ${Object.keys(PLATFORM_GROUPS).join(', ')}`);
process.exit(1);
}
echo(chalk.cyan`🎯 Downloading uv binaries for platform: ${platform}`);
echo(` Architectures: ${targets.join(', ')}`);
for (const id of targets) {
await setupTarget(id);
}
} else {
// Download for current system only (default for local dev)
const currentId = `${os.platform()}-${os.arch()}`;
echo(chalk.cyan`💻 Detected system: ${currentId}`);
if (TARGETS[currentId]) {
await setupTarget(currentId);
} else {
echo(chalk.red`❌ Current system ${currentId} is not in the supported download list.`);
echo(`Supported targets: ${Object.keys(TARGETS).join(', ')}`);
echo(`\nTip: Use --platform=<platform> to download for a specific platform`);
echo(` Use --all to download for all platforms`);
process.exit(1);
}
}
echo(chalk.green`\n🎉 Done!`);
} catch (err) {
echo(chalk.yellow`⚠️ Unexpected error in uv download script: ${err.message}`);
echo(chalk.yellow` Packaging will continue without uv binary.`);
// Exit with code 0 to allow packaging to continue
process.exit(0);
}

169
scripts/generate-icons.mjs Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env zx
import 'zx/globals';
import sharp from 'sharp';
import png2icons from 'png2icons';
import { fileURLToPath } from 'url';
// Calculate paths
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '..');
const ICONS_DIR = path.join(PROJECT_ROOT, 'resources', 'icons');
// Possible source files (in order of preference)
const SOURCE_PATHS = [
path.join(ICONS_DIR, 'icon.svg'), // Primary: SVG in icons directory
path.join(PROJECT_ROOT, 'icon.svg'), // SVG in project root
path.join(PROJECT_ROOT, 'src', 'assets', 'images', 'login', 'blue_logo.png'), // Login logo
];
echo`🎨 Generating ZN-AI icons...`;
// Find the first existing source file
let sourceFile = null;
let sourceType = null;
for (const candidate of SOURCE_PATHS) {
if (fs.existsSync(candidate)) {
sourceFile = candidate;
sourceType = path.extname(candidate).toLowerCase();
echo`📁 Found source: ${sourceFile} (${sourceType})`;
break;
}
}
if (!sourceFile) {
echo(chalk.red`❌ No icon source file found!`);
echo`
Please provide one of the following source files:
1. ${path.join(ICONS_DIR, 'icon.svg')} (recommended)
2. ${path.join(PROJECT_ROOT, 'icon.svg')}
4. ${path.join(PROJECT_ROOT, 'src', 'assets', 'images', 'login', 'logo.png')}
Or create an SVG file at ${path.join(ICONS_DIR, 'icon.svg')} for best results.
`;
process.exit(1);
}
// Ensure icons directory exists
await fs.ensureDir(ICONS_DIR);
try {
let masterPngBuffer;
// Process source based on file type
if (sourceType === '.svg') {
echo` Processing SVG source...`;
masterPngBuffer = await sharp(sourceFile)
.resize(1024, 1024)
.png()
.toBuffer();
} else if (sourceType === '.png') {
echo` Processing PNG source...`;
const metadata = await sharp(sourceFile).metadata();
echo` Original size: ${metadata.width}x${metadata.height}`;
// Resize to 1024x1024 if smaller
if (metadata.width < 1024 || metadata.height < 1024) {
echo` ⚠️ Source is smaller than 1024x1024, quality may be affected`;
}
masterPngBuffer = await sharp(sourceFile)
.resize(1024, 1024, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toBuffer();
} else if (sourceType === '.ico') {
echo` Extracting largest image from ICO file...`;
// ICO files can contain multiple sizes, sharp can extract them
const image = sharp(sourceFile);
const metadata = await image.metadata();
echo` Extracted size: ${metadata.width}x${metadata.height}`;
masterPngBuffer = await image
.resize(1024, 1024, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 }
})
.png()
.toBuffer();
} else {
echo(chalk.red`❌ Unsupported source file type: ${sourceType}`);
process.exit(1);
}
// Save the main icon.png (typically 512x512 for Electron root icon)
await sharp(masterPngBuffer)
.resize(512, 512)
.toFile(path.join(ICONS_DIR, 'icon.png'));
echo` ✅ Created icon.png (512x512)`;
// 2. Generate Windows .ico
echo`🪟 Generating Windows .ico...`;
const icoBuffer = png2icons.createICO(masterPngBuffer, png2icons.HERMITE, 0, false);
if (icoBuffer) {
fs.writeFileSync(path.join(ICONS_DIR, 'icon.ico'), icoBuffer);
echo` ✅ Created icon.ico`;
} else {
echo(chalk.red` ❌ Failed to create icon.ico`);
// detailed error might not be available from png2icons simple API, often returns null on failure
}
// 3. Generate macOS .icns
echo`🍎 Generating macOS .icns...`;
const icnsBuffer = png2icons.createICNS(masterPngBuffer, png2icons.HERMITE, 0);
if (icnsBuffer) {
fs.writeFileSync(path.join(ICONS_DIR, 'icon.icns'), icnsBuffer);
echo` ✅ Created icon.icns`;
} else {
echo(chalk.red` ❌ Failed to create icon.icns`);
}
// 4. Generate Linux PNGs (various sizes)
echo`🐧 Generating Linux PNG icons...`;
const linuxSizes = [16, 32, 48, 64, 128, 256, 512];
let generatedCount = 0;
for (const size of linuxSizes) {
await sharp(masterPngBuffer)
.resize(size, size)
.toFile(path.join(ICONS_DIR, `${size}x${size}.png`));
generatedCount++;
}
echo` ✅ Created ${generatedCount} Linux PNG icons`;
// 5. Generate macOS Tray Icon Template
echo`📍 Generating macOS tray icon template...`;
const TRAY_SVG_SOURCE = path.join(ICONS_DIR, 'tray-icon-template.svg');
if (fs.existsSync(TRAY_SVG_SOURCE)) {
await sharp(TRAY_SVG_SOURCE)
.resize(22, 22)
.png()
.toFile(path.join(ICONS_DIR, 'tray-icon-Template.png'));
echo` ✅ Created tray-icon-Template.png (22x22)`;
} else {
echo` ⚠️ tray-icon-template.svg not found, skipping tray icon generation`;
}
echo`\n✨ Icon generation complete! Files located in: ${ICONS_DIR}`;
echo`
Next steps:
1. Update electron-builder.yml to use the generated icons:
win:
icon: resources/icons/icon.ico
mac:
icon: resources/icons/icon.icns
2. For best results, create an SVG source at ${path.join(ICONS_DIR, 'icon.svg')}
and run this script again.
`;
} catch (error) {
echo(chalk.red`\n❌ Fatal Error: ${error.message}`);
process.exit(1);
}