Monorepo for Aesthetic.Computer aesthetic.computer
at main 642 lines 20 kB view raw
1#!/usr/bin/env node 2 3// ac-ship.mjs - Package OBJKT packages as Electron desktop apps 4// Usage: node ac-ship.mjs [zip-file] [--platforms mac,windows,linux] 5 6import fs from 'fs/promises'; 7import path from 'path'; 8import { spawn } from 'child_process'; 9import { fileURLToPath } from 'url'; 10 11const __filename = fileURLToPath(import.meta.url); 12const __dirname = path.dirname(__filename); 13 14class ElectronShipper { 15 constructor() { 16 this.outputDir = path.join(__dirname, 'output'); 17 this.tempDir = null; // Will be set based on zip file name 18 this.electronDir = null; // Electron project directory 19 this.zipBaseName = null; 20 } 21 22 async findLatestZip() { 23 try { 24 // Look in current working directory first, then fallback to output directory 25 const searchDirs = [process.cwd(), this.outputDir]; 26 let allZipFiles = []; 27 28 for (const searchDir of searchDirs) { 29 try { 30 const files = await fs.readdir(searchDir); 31 const zipFiles = files 32 .filter(file => file.endsWith('.zip')) 33 .map(file => ({ 34 name: file, 35 path: path.join(searchDir, file), 36 stats: null, 37 source: searchDir === process.cwd() ? 'current' : 'output' 38 })); 39 allZipFiles.push(...zipFiles); 40 } catch (error) { 41 console.log(`📁 Could not read directory ${searchDir}: ${error.message}`); 42 } 43 } 44 45 // Get file stats to sort by modification time 46 for (const file of allZipFiles) { 47 try { 48 file.stats = await fs.stat(file.path); 49 } catch (error) { 50 console.warn(`⚠️ Could not stat ${file.name}:`, error.message); 51 } 52 } 53 54 // Sort by modification time (newest first), preferring current directory 55 const validFiles = allZipFiles.filter(f => f.stats); 56 if (validFiles.length === 0) { 57 throw new Error('No valid zip files found in current directory or output directory'); 58 } 59 60 validFiles.sort((a, b) => { 61 // Prefer files from current directory 62 if (a.source === 'current' && b.source !== 'current') return -1; 63 if (b.source === 'current' && a.source !== 'current') return 1; 64 // Then sort by modification time (newest first) 65 return b.stats.mtime - a.stats.mtime; 66 }); 67 68 const selectedFile = validFiles[0]; 69 console.log(`📍 Found in ${selectedFile.source} directory: ${selectedFile.name}`); 70 return selectedFile.path; 71 } catch (error) { 72 throw new Error(`Failed to find zip files: ${error.message}`); 73 } 74 } 75 76 async extractZip(zipPath) { 77 console.log(`📦 Extracting ${path.basename(zipPath)}...`); 78 79 // Set directories based on zip file name 80 this.zipBaseName = path.basename(zipPath, '.zip'); 81 this.tempDir = path.join(this.outputDir, `${this.zipBaseName}-temp`); 82 this.electronDir = path.join(this.outputDir, `${this.zipBaseName}-electron`); 83 84 console.log(`📁 Extract directory: ${this.tempDir}`); 85 86 // Clean up existing directories 87 try { 88 await fs.rm(this.tempDir, { recursive: true, force: true }); 89 await fs.rm(this.electronDir, { recursive: true, force: true }); 90 } catch (error) { 91 // Directories might not exist, that's okay 92 } 93 94 // Create temp directory 95 await fs.mkdir(this.tempDir, { recursive: true }); 96 97 // Extract using unzip command 98 return new Promise((resolve, reject) => { 99 const unzip = spawn('unzip', ['-q', zipPath, '-d', this.tempDir], { 100 stdio: 'pipe' 101 }); 102 103 unzip.on('close', (code) => { 104 if (code === 0) { 105 console.log(`✅ Extracted to ${this.tempDir}`); 106 resolve(); 107 } else { 108 reject(new Error(`Unzip failed with code ${code}`)); 109 } 110 }); 111 112 unzip.on('error', (error) => { 113 reject(new Error(`Unzip command failed: ${error.message}`)); 114 }); 115 }); 116 } 117 118 async createElectronProject() { 119 console.log(`🛠️ Creating Electron project structure...`); 120 121 // Create electron project directory 122 await fs.mkdir(this.electronDir, { recursive: true }); 123 124 // Create app subdirectory and copy extracted contents 125 const appDir = path.join(this.electronDir, 'app'); 126 await fs.mkdir(appDir, { recursive: true }); 127 128 // Copy all extracted files to app directory 129 const files = await fs.readdir(this.tempDir); 130 for (const file of files) { 131 const srcPath = path.join(this.tempDir, file); 132 const destPath = path.join(appDir, file); 133 await this.copyRecursive(srcPath, destPath); 134 } 135 136 console.log(`📋 Copied package contents to app directory`); 137 138 // Extract piece name from zip file name (format: @author-piecename-timestamp.zip) 139 const matches = this.zipBaseName.match(/^(.+?)-([^-]+)-\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3}$/); 140 const author = matches ? matches[1] : '@jeffrey'; 141 const rawPieceName = matches ? matches[2] : this.zipBaseName.split('-')[0] || 'aesthetic-piece'; 142 const pieceName = rawPieceName.replace(/[^a-zA-Z0-9-]/g, ''); // Sanitize for package name 143 144 // Create package.json 145 const packageJson = { 146 name: `aesthetic-${pieceName}`, 147 version: '1.0.0', 148 description: `${pieceName} - An interactive piece from aesthetic.computer`, 149 main: 'main.js', 150 author: author, 151 license: 'MIT', 152 repository: { 153 type: 'git', 154 url: 'https://github.com/digitpain/aesthetic.computer.git' 155 }, 156 scripts: { 157 start: 'electron .', 158 build: 'electron-builder', 159 'build:mac': 'electron-builder --mac', 160 'build:win': 'electron-builder --win', 161 'build:linux': 'electron-builder --linux' 162 }, 163 devDependencies: { 164 electron: 'latest', 165 'electron-builder': 'latest' 166 }, 167 build: { 168 appId: `computer.aesthetic.${pieceName}`, 169 productName: `${rawPieceName}`, 170 directories: { 171 output: 'dist' 172 }, 173 files: [ 174 'main.js', 175 'app/**/*' 176 ], 177 publish: null, // Disable publishing 178 mac: { 179 category: 'public.app-category.entertainment', 180 target: [ 181 { 182 target: 'dmg', 183 arch: ['x64', 'arm64'] 184 } 185 ] 186 }, 187 win: { 188 target: [ 189 { 190 target: 'nsis', 191 arch: ['x64'] 192 } 193 ] 194 }, 195 linux: { 196 icon: 'build/icon.png', 197 category: 'Game', 198 target: [ 199 { 200 target: 'AppImage', 201 arch: ['x64'] 202 } 203 ] 204 }, 205 nsis: { 206 oneClick: false, 207 allowToChangeInstallationDirectory: true 208 } 209 } 210 }; 211 212 await fs.writeFile( 213 path.join(this.electronDir, 'package.json'), 214 JSON.stringify(packageJson, null, 2) 215 ); 216 217 // Create main.js (Electron main process) 218 const mainJs = `const { app, BrowserWindow, Menu } = require('electron'); 219const path = require('path'); 220 221// Suppress common Node.js warnings in Electron 222process.removeAllListeners('warning'); 223process.on('warning', (warning) => { 224 // Suppress specific warnings that are common and harmless in Electron 225 if (warning.name === 'DeprecationWarning' || 226 warning.message.includes('child process with shell') || 227 warning.message.includes('experimental')) { 228 return; // Suppress these warnings 229 } 230 console.warn(warning.name + ': ' + warning.message); 231}); 232 233function createWindow() { 234 // Create the browser window 235 const mainWindow = new BrowserWindow({ 236 width: 1024, 237 height: 768, 238 webPreferences: { 239 nodeIntegration: false, 240 contextIsolation: true, 241 webSecurity: true 242 }, 243 icon: path.join(__dirname, 'build/icon.png'), 244 show: false, // Don't show until ready-to-show 245 titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default' 246 }); 247 248 // Suppress protocol errors and other console warnings 249 mainWindow.webContents.on('console-message', (event, level, message) => { 250 // Suppress specific protocol and CORS errors that are common in Electron 251 if (message.includes('ERR_UNKNOWN_URL_SCHEME') || 252 message.includes('protocol') || 253 message.includes('CORS') || 254 message.includes('Mixed Content') || 255 message.includes('net::')) { 256 return; // Suppress these messages 257 } 258 // Let other console messages through 259 console.log('Renderer:', message); 260 }); 261 262 // Load the ac-pack HTML directly 263 mainWindow.loadFile('app/index.html'); 264 265 // Show window when ready to prevent visual flash 266 mainWindow.once('ready-to-show', () => { 267 mainWindow.show(); 268 }); 269 270 // Handle window closed 271 mainWindow.on('closed', () => { 272 // Dereference the window object 273 app.quit(); 274 }); 275 276 // Set up menu 277 if (process.platform === 'darwin') { 278 // macOS menu 279 const template = [ 280 { 281 label: app.getName(), 282 submenu: [ 283 { role: 'about' }, 284 { type: 'separator' }, 285 { role: 'hide' }, 286 { role: 'hideothers' }, 287 { role: 'unhide' }, 288 { type: 'separator' }, 289 { role: 'quit' } 290 ] 291 }, 292 { 293 label: 'View', 294 submenu: [ 295 { role: 'reload' }, 296 { role: 'forceReload' }, 297 { role: 'toggleDevTools' }, 298 { type: 'separator' }, 299 { role: 'resetZoom' }, 300 { role: 'zoomIn' }, 301 { role: 'zoomOut' }, 302 { type: 'separator' }, 303 { role: 'togglefullscreen' } 304 ] 305 }, 306 { 307 label: 'Window', 308 submenu: [ 309 { role: 'minimize' }, 310 { role: 'close' } 311 ] 312 } 313 ]; 314 315 const menu = Menu.buildFromTemplate(template); 316 Menu.setApplicationMenu(menu); 317 } else { 318 // Windows/Linux menu 319 Menu.setApplicationMenu(null); 320 } 321} 322 323// This method will be called when Electron has finished initialization 324app.whenReady().then(createWindow); 325 326// Quit when all windows are closed, except on macOS 327app.on('window-all-closed', () => { 328 if (process.platform !== 'darwin') { 329 app.quit(); 330 } 331}); 332 333app.on('activate', () => { 334 // On macOS, re-create window when dock icon is clicked 335 if (BrowserWindow.getAllWindows().length === 0) { 336 createWindow(); 337 } 338}); 339 340// Security: Prevent new window creation 341app.on('web-contents-created', (event, contents) => { 342 contents.on('new-window', (navigationEvent, navigationURL) => { 343 navigationEvent.preventDefault(); 344 require('electron').shell.openExternal(navigationURL); 345 }); 346}); 347`; 348 349 await fs.writeFile(path.join(this.electronDir, 'main.js'), mainJs); 350 351 // Create build directory for icons 352 const buildDir = path.join(this.electronDir, 'build'); 353 await fs.mkdir(buildDir, { recursive: true }); 354 355 // Create simple icon files (placeholder - could be enhanced with actual icon generation) 356 await this.createIcons(buildDir, pieceName); 357 358 console.log(`✅ Created Electron project structure`); 359 } 360 361 async createIcons(buildDir, pieceName) { 362 // Look for existing icons in the app directory 363 const appDir = path.join(this.electronDir, 'app'); 364 const pieceIcon512 = path.join(appDir, 'icon', '512x512', `${pieceName}.png`); // Preferred piece-named 512x512 icon 365 const pieceIcon256 = path.join(appDir, 'icon', '256x256', `${pieceName}.png`); // Fallback piece-named 256x256 icon 366 const pieceIcon128 = path.join(appDir, 'icon', '128x128', `${pieceName}.png`); // Legacy piece-named 128x128 icon 367 const faviconIcon = path.join(appDir, 'aesthetic.computer', 'favicon.png'); 368 369 let sourceIcon = null; 370 let needsUpscaling = false; 371 372 // Try to find an existing icon to use (prioritize piece-named 512x512 icon) 373 try { 374 await fs.access(pieceIcon512); 375 sourceIcon = pieceIcon512; 376 console.log(`🎨 Found 512x512 piece icon: ${path.basename(pieceIcon512)}`); 377 } catch { 378 try { 379 await fs.access(pieceIcon256); 380 sourceIcon = pieceIcon256; 381 needsUpscaling = true; 382 console.log(`🎨 Found 256x256 piece icon: ${path.basename(pieceIcon256)}`); 383 } catch { 384 try { 385 await fs.access(pieceIcon128); 386 sourceIcon = pieceIcon128; 387 needsUpscaling = true; 388 console.log(`🎨 Found 128x128 piece icon: ${path.basename(pieceIcon128)}`); 389 } catch { 390 try { 391 await fs.access(faviconIcon); 392 sourceIcon = faviconIcon; 393 needsUpscaling = true; 394 console.log(`📎 Using favicon as fallback icon: ${path.basename(faviconIcon)}`); 395 } catch { 396 console.log(`⚠️ No existing icons found, skipping icon creation`); 397 return; 398 } 399 } 400 } 401 } 402 403 if (sourceIcon) { 404 const outputIcon = path.join(buildDir, 'icon.png'); 405 406 if (needsUpscaling) { 407 // Upscale to 512x512 for Mac compatibility using ffmpeg 408 try { 409 const { execSync } = await import('child_process'); 410 execSync(`ffmpeg -i "${sourceIcon}" -vf scale=512:512:flags=neighbor -y "${outputIcon}"`, { stdio: 'pipe' }); 411 console.log(`✅ Icon upscaled to 512x512 for Mac compatibility`); 412 } catch (error) { 413 console.log(`⚠️ Failed to upscale icon with ffmpeg, using original: ${error.message}`); 414 await fs.copyFile(sourceIcon, outputIcon); 415 console.log(`✅ Icon prepared for building (original size)`); 416 } 417 } else { 418 // Use 512x512 icon directly - no upscaling needed 419 await fs.copyFile(sourceIcon, outputIcon); 420 console.log(`✅ Using 512x512 icon directly - perfect for Mac compatibility`); 421 } 422 } 423 } 424 425 async copyRecursive(src, dest) { 426 const stat = await fs.stat(src); 427 if (stat.isDirectory()) { 428 await fs.mkdir(dest, { recursive: true }); 429 const files = await fs.readdir(src); 430 for (const file of files) { 431 await this.copyRecursive(path.join(src, file), path.join(dest, file)); 432 } 433 } else { 434 await fs.copyFile(src, dest); 435 } 436 } 437 438 async installDependencies() { 439 console.log(`📦 Installing Electron dependencies...`); 440 441 return new Promise((resolve, reject) => { 442 const npm = spawn('npm', ['install'], { 443 cwd: this.electronDir, 444 stdio: 'inherit' 445 }); 446 447 npm.on('close', (code) => { 448 if (code === 0) { 449 console.log(`✅ Dependencies installed`); 450 resolve(); 451 } else { 452 reject(new Error(`npm install failed with code ${code}`)); 453 } 454 }); 455 456 npm.on('error', (error) => { 457 reject(new Error(`npm install failed: ${error.message}`)); 458 }); 459 }); 460 } 461 462 async buildElectronApps(platforms = ['mac', 'windows', 'linux']) { 463 console.log(`🔧 Building Electron apps for: ${platforms.join(', ')}...`); 464 465 const distDir = path.join(this.electronDir, 'dist'); 466 467 // Map platform names to electron-builder targets 468 const targetMap = { 469 'mac': 'MAC', 470 'windows': 'WINDOWS', 471 'linux': 'LINUX' 472 }; 473 474 const results = []; 475 476 for (const platform of platforms) { 477 const target = targetMap[platform]; 478 if (!target) { 479 console.warn(`⚠️ Unknown platform: ${platform}`); 480 continue; 481 } 482 483 console.log(`🏗️ Building for ${platform}...`); 484 485 try { 486 // Dynamic import of electron-builder to handle ESM compatibility 487 const { build } = await import('electron-builder'); 488 const { Platform } = await import('electron-builder'); 489 490 const buildResult = await build({ 491 targets: Platform[target.toUpperCase()].createTarget(), 492 projectDir: this.electronDir, 493 config: { 494 directories: { 495 output: distDir 496 } 497 } 498 }); 499 500 console.log(`✅ Built ${platform} app successfully`); 501 results.push({ platform, success: true, files: buildResult }); 502 } catch (error) { 503 console.error(`❌ Failed to build ${platform} app:`, error.message); 504 results.push({ platform, success: false, error: error.message }); 505 } 506 } 507 508 return results; 509 } 510 511 async cleanup() { 512 console.log('🧹 Cleaning up temporary files...'); 513 514 try { 515 if (this.tempDir) { 516 await fs.rm(this.tempDir, { recursive: true, force: true }); 517 console.log('✅ Temporary extraction directory removed'); 518 } 519 } catch (error) { 520 console.warn('⚠️ Could not remove temporary directory:', error.message); 521 } 522 } 523 524 async listResults() { 525 const distDir = path.join(this.electronDir, 'dist'); 526 527 try { 528 const files = await fs.readdir(distDir); 529 const appFiles = files.filter(file => 530 file.endsWith('.dmg') || 531 file.endsWith('.exe') || 532 file.endsWith('.AppImage') || 533 file.endsWith('.deb') || 534 file.endsWith('.rpm') 535 ); 536 537 if (appFiles.length > 0) { 538 console.log('\n🎉 Generated Electron apps:'); 539 for (const file of appFiles) { 540 const filePath = path.join(distDir, file); 541 const stats = await fs.stat(filePath); 542 const sizeInMB = (stats.size / (1024 * 1024)).toFixed(1); 543 console.log(` 📱 ${file} (${sizeInMB} MB)`); 544 } 545 console.log(`\n📁 Apps saved to: ${distDir}`); 546 } else { 547 console.log('\n⚠️ No app files found in dist directory'); 548 } 549 } catch (error) { 550 console.warn('⚠️ Could not list results:', error.message); 551 } 552 } 553 554 async run(zipPath, platforms = ['mac', 'windows', 'linux']) { 555 try { 556 console.log('🚢 Aesthetic Computer Electron Shipper\n'); 557 558 // Find zip file if not provided 559 if (!zipPath) { 560 console.log('🔍 Finding latest zip file...'); 561 zipPath = await this.findLatestZip(); 562 console.log(`📦 Using: ${path.basename(zipPath)}`); 563 } 564 565 // Check if zip file exists 566 try { 567 await fs.access(zipPath); 568 } catch (error) { 569 throw new Error(`Zip file not found: ${zipPath}`); 570 } 571 572 // Extract the zip file 573 await this.extractZip(zipPath); 574 575 // Create Electron project structure 576 await this.createElectronProject(); 577 578 // Install dependencies 579 await this.installDependencies(); 580 581 // Build Electron apps for specified platforms 582 const results = await this.buildElectronApps(platforms); 583 584 // List the results 585 await this.listResults(); 586 587 // Clean up temporary files 588 await this.cleanup(); 589 590 // Summary 591 const successful = results.filter(r => r.success); 592 const failed = results.filter(r => !r.success); 593 594 console.log('\n📊 Build Summary:'); 595 console.log(` ✅ Successful: ${successful.map(r => r.platform).join(', ')}`); 596 if (failed.length > 0) { 597 console.log(` ❌ Failed: ${failed.map(r => r.platform).join(', ')}`); 598 failed.forEach(f => console.log(` ${f.platform}: ${f.error}`)); 599 } 600 601 console.log('\n🎯 Next Steps:'); 602 console.log('1. Test the generated apps on their respective platforms'); 603 console.log('2. Consider code signing for distribution'); 604 console.log('3. Upload to GitHub releases or your preferred distribution method'); 605 606 } catch (error) { 607 console.error('❌ Error:', error.message); 608 await this.cleanup(); 609 process.exit(1); 610 } 611 } 612} 613 614// Parse command line arguments 615function parseArgs() { 616 const args = process.argv.slice(2); 617 let zipPath = null; 618 let platforms = ['mac', 'windows', 'linux']; 619 620 for (let i = 0; i < args.length; i++) { 621 const arg = args[i]; 622 623 if (arg === '--platforms' && i + 1 < args.length) { 624 platforms = args[i + 1].split(',').map(p => p.trim()); 625 i++; // Skip next argument 626 } else if (!arg.startsWith('--') && !zipPath) { 627 zipPath = arg; 628 } 629 } 630 631 return { zipPath, platforms }; 632} 633 634// Export the class for use as a module 635export { ElectronShipper }; 636 637// Run the shipper if executed directly 638if (import.meta.url === `file://${process.argv[1]}`) { 639 const { zipPath, platforms } = parseArgs(); 640 const shipper = new ElectronShipper(); 641 shipper.run(zipPath, platforms); 642}