Merge pull request #65 from AutoMaker-Org/fix-electron-build

feat: enhance Electron build process and server preparation
This commit is contained in:
Shirone
2025-12-14 02:16:00 +01:00
committed by GitHub
14 changed files with 1242 additions and 672 deletions

1
apps/app/.gitignore vendored
View File

@@ -48,3 +48,4 @@ next-env.d.ts
# Electron # Electron
/dist/ /dist/
/server-bundle/

View File

@@ -8,21 +8,114 @@
const path = require("path"); const path = require("path");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const fs = require("fs"); const fs = require("fs");
const http = require("http");
// Load environment variables from .env file
require("dotenv").config({ path: path.join(__dirname, "../.env") });
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron"); const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
// Load environment variables from .env file (development only)
if (!app.isPackaged) {
try {
require("dotenv").config({ path: path.join(__dirname, "../.env") });
} catch (error) {
console.warn("[Electron] dotenv not available:", error.message);
}
}
let mainWindow = null; let mainWindow = null;
let serverProcess = null; let serverProcess = null;
let staticServer = null;
const SERVER_PORT = 3008; const SERVER_PORT = 3008;
const STATIC_PORT = 3007;
// Get icon path - works in both dev and production // Get icon path - works in both dev and production, cross-platform
function getIconPath() { function getIconPath() {
return app.isPackaged // Different icon formats for different platforms
? path.join(process.resourcesPath, "app", "public", "logo.png") let iconFile;
: path.join(__dirname, "../public/logo.png"); if (process.platform === "win32") {
iconFile = "icon.ico";
} else if (process.platform === "darwin") {
iconFile = "logo_larger.png";
} else {
// Linux
iconFile = "logo_larger.png";
}
const iconPath = path.join(__dirname, "../public", iconFile);
// Verify the icon exists
if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
return null;
}
return iconPath;
}
/**
* Start static file server for production builds
*/
async function startStaticServer() {
const staticPath = path.join(__dirname, "../out");
staticServer = http.createServer((request, response) => {
// Parse the URL and remove query string
let filePath = path.join(staticPath, request.url.split("?")[0]);
// Default to index.html for directory requests
if (filePath.endsWith("/")) {
filePath = path.join(filePath, "index.html");
} else if (!path.extname(filePath)) {
filePath += ".html";
}
// Check if file exists
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
// Try index.html for SPA fallback
filePath = path.join(staticPath, "index.html");
}
// Read and serve the file
fs.readFile(filePath, (error, content) => {
if (error) {
response.writeHead(500);
response.end("Server Error");
return;
}
// Set content type based on file extension
const ext = path.extname(filePath);
const contentTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
};
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
response.end(content);
});
});
});
return new Promise((resolve, reject) => {
staticServer.listen(STATIC_PORT, (err) => {
if (err) {
reject(err);
} else {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
resolve();
}
});
});
} }
/** /**
@@ -63,18 +156,45 @@ async function startServer() {
command = "node"; command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js"); serverPath = path.join(process.resourcesPath, "server", "index.js");
args = [serverPath]; args = [serverPath];
// Verify server files exist
if (!fs.existsSync(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
} }
// Set environment variables for server // Set environment variables for server
const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, "server", "node_modules")
: path.join(__dirname, "../../server/node_modules");
// Set default workspace directory to user's Documents/Automaker
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
// Ensure workspace directory exists
if (!fs.existsSync(defaultWorkspaceDir)) {
try {
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
console.log("[Electron] Created workspace directory:", defaultWorkspaceDir);
} catch (error) {
console.error("[Electron] Failed to create workspace directory:", error);
}
}
const env = { const env = {
...process.env, ...process.env,
PORT: SERVER_PORT.toString(), PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"), DATA_DIR: app.getPath("userData"),
NODE_PATH: serverNodeModules,
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
}; };
console.log("[Electron] Starting backend server..."); console.log("[Electron] Starting backend server...");
console.log("[Electron] Server path:", serverPath);
console.log("[Electron] NODE_PATH:", serverNodeModules);
serverProcess = spawn(command, args, { serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
env, env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
@@ -92,6 +212,11 @@ async function startServer() {
serverProcess = null; serverProcess = null;
}); });
serverProcess.on("error", (err) => {
console.error(`[Server] Failed to start server process:`, err);
serverProcess = null;
});
// Wait for server to be ready // Wait for server to be ready
await waitForServer(); await waitForServer();
} }
@@ -132,12 +257,12 @@ async function waitForServer(maxAttempts = 30) {
* Create the main window * Create the main window
*/ */
function createWindow() { function createWindow() {
mainWindow = new BrowserWindow({ const iconPath = getIconPath();
const windowOptions = {
width: 1400, width: 1400,
height: 900, height: 900,
minWidth: 1024, minWidth: 1024,
minHeight: 700, minHeight: 700,
icon: getIconPath(),
webPreferences: { webPreferences: {
preload: path.join(__dirname, "preload.js"), preload: path.join(__dirname, "preload.js"),
contextIsolation: true, contextIsolation: true,
@@ -145,17 +270,20 @@ function createWindow() {
}, },
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
backgroundColor: "#0a0a0a", backgroundColor: "#0a0a0a",
}); };
// Load Next.js dev server in development or production build // Only set icon if it exists
if (iconPath) {
windowOptions.icon = iconPath;
}
mainWindow = new BrowserWindow(windowOptions);
// Load Next.js dev server in development or static server in production
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
if (isDev) { mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
mainWindow.loadURL("http://localhost:3007"); if (isDev && process.env.OPEN_DEVTOOLS === "true") {
if (process.env.OPEN_DEVTOOLS === "true") { mainWindow.webContents.openDevTools();
mainWindow.webContents.openDevTools();
}
} else {
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
} }
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
@@ -173,10 +301,22 @@ function createWindow() {
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Set app icon (dock icon on macOS) // Set app icon (dock icon on macOS)
if (process.platform === "darwin" && app.dock) { if (process.platform === "darwin" && app.dock) {
app.dock.setIcon(getIconPath()); const iconPath = getIconPath();
if (iconPath) {
try {
app.dock.setIcon(iconPath);
} catch (error) {
console.warn("[Electron] Failed to set dock icon:", error.message);
}
}
} }
try { try {
// Start static file server in production
if (app.isPackaged) {
await startStaticServer();
}
// Start backend server // Start backend server
await startServer(); await startServer();
@@ -207,6 +347,13 @@ app.on("before-quit", () => {
serverProcess.kill(); serverProcess.kill();
serverProcess = null; serverProcess = null;
} }
// Close static server
if (staticServer) {
console.log("[Electron] Stopping static server...");
staticServer.close();
staticServer = null;
}
}); });
// ============================================ // ============================================

View File

@@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "export",
env: { env: {
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "", CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
}, },

View File

@@ -20,7 +20,10 @@
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"", "dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"", "dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
"build": "next build", "build": "next build",
"build:electron": "next build && electron-builder", "build:electron": "node scripts/prepare-server.js && next build && electron-builder",
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "playwright test", "test": "playwright test",
@@ -79,35 +82,46 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electron": "^39.2.6", "electron": "39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.7", "eslint-config-next": "16.0.7",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5", "typescript": "5.9.3",
"wait-on": "^9.0.3" "wait-on": "^9.0.3"
}, },
"build": { "build": {
"appId": "com.automaker.app", "appId": "com.automaker.app",
"productName": "Automaker", "productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}", "artifactName": "${productName}-${version}-${arch}.${ext}",
"afterPack": "./scripts/rebuild-server-natives.js",
"directories": { "directories": {
"output": "dist" "output": "dist"
}, },
"files": [ "files": [
"electron/**/*", "electron/**/*",
".next/**/*", "out/**/*",
"public/**/*", "public/**/*",
"!node_modules/**/*" "!node_modules/**/*"
], ],
"extraResources": [ "extraResources": [
{ {
"from": ".env", "from": "server-bundle/dist",
"to": "server"
},
{
"from": "server-bundle/node_modules",
"to": "server/node_modules"
},
{
"from": "server-bundle/package.json",
"to": "server/package.json"
},
{
"from": "../../.env",
"to": ".env", "to": ".env",
"filter": [ "filter": ["**/*"]
"**/*"
]
} }
], ],
"mac": { "mac": {
@@ -139,7 +153,7 @@
] ]
} }
], ],
"icon": "public/logo_larger.png" "icon": "public/icon.ico"
}, },
"linux": { "linux": {
"target": [ "target": [

BIN
apps/app/public/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* This script prepares the server for bundling with Electron.
* It copies the server dist and installs production dependencies
* in a way that works with npm workspaces.
*/
import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const APP_DIR = join(__dirname, '..');
const SERVER_DIR = join(APP_DIR, '..', 'server');
const BUNDLE_DIR = join(APP_DIR, 'server-bundle');
console.log('🔧 Preparing server for Electron bundling...\n');
// Step 1: Clean up previous bundle
if (existsSync(BUNDLE_DIR)) {
console.log('🗑️ Cleaning previous server-bundle...');
rmSync(BUNDLE_DIR, { recursive: true });
}
mkdirSync(BUNDLE_DIR, { recursive: true });
// Step 2: Build the server TypeScript
console.log('📦 Building server TypeScript...');
execSync('npm run build', { cwd: SERVER_DIR, stdio: 'inherit' });
// Step 3: Copy server dist
console.log('📋 Copying server dist...');
cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true });
// Step 4: Create a minimal package.json for the server
console.log('📝 Creating server package.json...');
const serverPkg = JSON.parse(readFileSync(join(SERVER_DIR, 'package.json'), 'utf-8'));
const bundlePkg = {
name: '@automaker/server-bundle',
version: serverPkg.version,
type: 'module',
main: 'dist/index.js',
dependencies: serverPkg.dependencies
};
writeFileSync(
join(BUNDLE_DIR, 'package.json'),
JSON.stringify(bundlePkg, null, 2)
);
// Step 5: Install production dependencies
console.log('📥 Installing server production dependencies...');
execSync('npm install --omit=dev', {
cwd: BUNDLE_DIR,
stdio: 'inherit',
env: {
...process.env,
// Prevent npm from using workspace resolution
npm_config_workspace: ''
}
});
// Step 6: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...');
try {
execSync('npm rebuild', {
cwd: BUNDLE_DIR,
stdio: 'inherit'
});
console.log('✅ Native modules rebuilt successfully');
} catch (error) {
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
console.warn(' Error:', error.message);
}
console.log('\n✅ Server prepared for bundling at:', BUNDLE_DIR);

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env node
/**
* Electron-builder afterPack hook
* Rebuilds native modules in the server bundle for the target architecture
*/
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const execAsync = promisify(exec);
exports.default = async function(context) {
const { appOutDir, electronPlatformName, arch, packager } = context;
const electronVersion = packager.config.electronVersion;
// Convert arch to string if it's a number (electron-builder sometimes passes indices)
const archNames = ['ia32', 'x64', 'armv7l', 'arm64', 'universal'];
const archStr = typeof arch === 'number' ? archNames[arch] : arch;
console.log(`\n🔨 Rebuilding server native modules for ${electronPlatformName}-${archStr}...`);
// Path to server node_modules in the packaged app
let serverNodeModulesPath;
if (electronPlatformName === 'darwin') {
serverNodeModulesPath = path.join(
appOutDir,
`${packager.appInfo.productName}.app`,
'Contents',
'Resources',
'server',
'node_modules'
);
} else if (electronPlatformName === 'win32') {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
} else {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
}
try {
// Rebuild native modules for the target architecture
const rebuildCmd = `npx --yes @electron/rebuild --version=${electronVersion} --arch=${archStr} --force --module-dir="${serverNodeModulesPath}/.."`;
console.log(` Command: ${rebuildCmd}`);
const { stdout, stderr } = await execAsync(rebuildCmd);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
console.log(`✅ Server native modules rebuilt successfully for ${archStr}\n`);
} catch (error) {
console.error(`❌ Failed to rebuild server native modules:`, error.message);
// Don't fail the build, just warn
}
};

View File

@@ -113,8 +113,8 @@ export function FileBrowserDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]"> <DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader> <DialogHeader className="pb-2">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-brand-500" /> <FolderOpen className="w-5 h-5 text-brand-500" />
{title} {title}
@@ -124,7 +124,7 @@ export function FileBrowserDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px]"> <div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
{/* Drives selector (Windows only) */} {/* Drives selector (Windows only) */}
{drives.length > 0 && ( {drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border"> <div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
@@ -216,7 +216,7 @@ export function FileBrowserDialog({
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="border-t border-border pt-4 gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>

View File

@@ -239,6 +239,25 @@ export function Sidebar() {
// Ref for project search input // Ref for project search input
const projectSearchInputRef = useRef<HTMLInputElement>(null); const projectSearchInputRef = useRef<HTMLInputElement>(null);
// Auto-collapse sidebar on small screens
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint
const handleResize = () => {
if (mediaQuery.matches && sidebarOpen) {
// Auto-collapse on small screens
toggleSidebar();
}
};
// Check on mount
handleResize();
// Listen for changes
mediaQuery.addEventListener('change', handleResize);
return () => mediaQuery.removeEventListener('change', handleResize);
}, [sidebarOpen, toggleSidebar]);
// Filtered projects based on search query // Filtered projects based on search query
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
if (!projectSearchQuery.trim()) { if (!projectSearchQuery.trim()) {

View File

@@ -198,7 +198,10 @@ export function NewProjectModal({
} }
}; };
const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : ""; // Use platform-specific path separator
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : "";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -305,7 +305,10 @@ export function InterviewView() {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
const fullProjectPath = `${projectPath}/${projectName}`; // Use platform-specific path separator
const pathSep = typeof window !== 'undefined' && (window as any).electronAPI ?
(navigator.platform.indexOf('Win') !== -1 ? '\\' : '/') : '/';
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
// Create project directory // Create project directory
await api.mkdir(fullProjectPath); await api.mkdir(fullProjectPath);

View File

@@ -34,6 +34,8 @@ export function createTemplatesRoutes(): Router {
return; return;
} }
console.log(`[Templates] Clone request - Repo: ${repoUrl}, Project: ${projectName}, Parent: ${parentDir}`);
// Validate repo URL is a valid GitHub URL // Validate repo URL is a valid GitHub URL
const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/; const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/;
if (!githubUrlPattern.test(repoUrl)) { if (!githubUrlPattern.test(repoUrl)) {
@@ -79,12 +81,32 @@ export function createTemplatesRoutes(): Router {
// Ensure parent directory exists // Ensure parent directory exists
try { try {
await fs.mkdir(parentDir, { recursive: true }); // Check if parentDir is a root path (Windows: C:\, D:\, etc. or Unix: /)
const isWindowsRoot = /^[A-Za-z]:\\?$/.test(parentDir);
const isUnixRoot = parentDir === '/' || parentDir === '';
const isRoot = isWindowsRoot || isUnixRoot;
if (isRoot) {
// Root paths always exist, just verify access
console.log(`[Templates] Using root path: ${parentDir}`);
await fs.access(parentDir);
} else {
// Check if parent directory exists
const parentExists = await fs.access(parentDir).then(() => true).catch(() => false);
if (!parentExists) {
console.log(`[Templates] Creating parent directory: ${parentDir}`);
await fs.mkdir(parentDir, { recursive: true });
} else {
console.log(`[Templates] Parent directory exists: ${parentDir}`);
}
}
} catch (error) { } catch (error) {
console.error("[Templates] Failed to create parent directory:", error); const errorMessage = error instanceof Error ? error.message : String(error);
console.error("[Templates] Failed to access parent directory:", parentDir, error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: "Failed to create parent directory", error: `Failed to access parent directory: ${errorMessage}`,
}); });
return; return;
} }

1478
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,9 @@
"build": "npm run build --workspace=apps/app", "build": "npm run build --workspace=apps/app",
"build:server": "npm run build --workspace=apps/server", "build:server": "npm run build --workspace=apps/server",
"build:electron": "npm run build:electron --workspace=apps/app", "build:electron": "npm run build:electron --workspace=apps/app",
"build:electron:win": "npm run build:electron:win --workspace=apps/app",
"build:electron:mac": "npm run build:electron:mac --workspace=apps/app",
"build:electron:linux": "npm run build:electron:linux --workspace=apps/app",
"start": "npm run start --workspace=apps/app", "start": "npm run start --workspace=apps/app",
"start:server": "npm run start --workspace=apps/server", "start:server": "npm run start --workspace=apps/server",
"lint": "npm run lint --workspace=apps/app", "lint": "npm run lint --workspace=apps/app",