Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ next-env.d.ts

# Electron
/dist/
/server-bundle/
187 changes: 167 additions & 20 deletions apps/app/electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,114 @@
const path = require("path");
const { spawn } = require("child_process");
const fs = require("fs");

// Load environment variables from .env file
require("dotenv").config({ path: path.join(__dirname, "../.env") });

const http = require("http");
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 serverProcess = null;
let staticServer = null;
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() {
return app.isPackaged
? path.join(process.resourcesPath, "app", "public", "logo.png")
: path.join(__dirname, "../public/logo.png");
// Different icon formats for different platforms
let iconFile;
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();
}
});
});
}

/**
Expand Down Expand Up @@ -63,18 +156,45 @@ async function startServer() {
command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js");
args = [serverPath];

// Verify server files exist
if (!fs.existsSync(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
}

// 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 = {
...process.env,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"),
NODE_PATH: serverNodeModules,
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
};

console.log("[Electron] Starting backend server...");
console.log("[Electron] Server path:", serverPath);
console.log("[Electron] NODE_PATH:", serverNodeModules);

serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
env,
stdio: ["ignore", "pipe", "pipe"],
});
Expand All @@ -92,6 +212,11 @@ async function startServer() {
serverProcess = null;
});

serverProcess.on("error", (err) => {
console.error(`[Server] Failed to start server process:`, err);
serverProcess = null;
});

// Wait for server to be ready
await waitForServer();
}
Expand Down Expand Up @@ -132,30 +257,33 @@ async function waitForServer(maxAttempts = 30) {
* Create the main window
*/
function createWindow() {
mainWindow = new BrowserWindow({
const iconPath = getIconPath();
const windowOptions = {
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 700,
icon: getIconPath(),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
titleBarStyle: "hiddenInset",
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;
if (isDev) {
mainWindow.loadURL("http://localhost:3007");
if (process.env.OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools();
}
} else {
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools();
}

mainWindow.on("closed", () => {
Expand All @@ -173,10 +301,22 @@ function createWindow() {
app.whenReady().then(async () => {
// Set app icon (dock icon on macOS)
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 {
// Start static file server in production
if (app.isPackaged) {
await startStaticServer();
}

// Start backend server
await startServer();

Expand Down Expand Up @@ -207,6 +347,13 @@ app.on("before-quit", () => {
serverProcess.kill();
serverProcess = null;
}

// Close static server
if (staticServer) {
console.log("[Electron] Stopping static server...");
staticServer.close();
staticServer = null;
}
});

// ============================================
Expand Down
1 change: 1 addition & 0 deletions apps/app/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
output: "export",
env: {
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
},
Expand Down
32 changes: 23 additions & 9 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
"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 .\"",
"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",
Comment on lines +23 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's some duplication in the build:electron:* scripts. Each script repeats node scripts/prepare-server.js && next build. To improve maintainability and reduce repetition, you could extract the common preparation steps into a separate script. This way, if the preparation steps change, you only need to update it in one place.

    "prepare:electron:build": "node scripts/prepare-server.js && next build",
    "build:electron": "npm run prepare:electron:build && electron-builder",
    "build:electron:win": "npm run prepare:electron:build && electron-builder --win",
    "build:electron:mac": "npm run prepare:electron:build && electron-builder --mac",
    "build:electron:linux": "npm run prepare:electron:build && electron-builder --linux"

"start": "next start",
"lint": "eslint",
"test": "playwright test",
Expand Down Expand Up @@ -79,35 +82,46 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"electron": "^39.2.6",
"electron": "39.2.7",
"electron-builder": "^26.0.12",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"typescript": "5.9.3",
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"afterPack": "./scripts/rebuild-server-natives.js",
"directories": {
"output": "dist"
},
"files": [
"electron/**/*",
".next/**/*",
"out/**/*",
"public/**/*",
"!node_modules/**/*"
],
"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",
"filter": [
"**/*"
]
"filter": ["**/*"]
}
],
"mac": {
Expand Down Expand Up @@ -139,7 +153,7 @@
]
}
],
"icon": "public/logo_larger.png"
"icon": "public/icon.ico"
},
"linux": {
"target": [
Expand Down
Binary file added apps/app/public/icon.ico
Binary file not shown.
Loading