Monorepo for Aesthetic.Computer
aesthetic.computer
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}