mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
fix: add cross-platform Node.js executable finder for desktop launches
When the Electron app is launched from desktop environments (macOS Finder, Windows Explorer, Linux desktop launchers), the PATH environment variable is often limited and doesn't include Node.js installation paths. This adds a new `findNodeExecutable()` utility to @automaker/platform that: - Searches common installation paths (Homebrew, system, Program Files) - Supports version managers: NVM, fnm, nvm-windows, Scoop, Chocolatey - Falls back to shell resolution (which/where) when available - Enhances PATH for child processes via `buildEnhancedPath()` - Works cross-platform: macOS, Windows, and Linux 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,12 @@
|
||||
* Only native features (dialogs, shell) use IPC.
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import fs from "fs";
|
||||
import http, { Server } from "http";
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
|
||||
import path from 'path';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import http, { Server } from 'http';
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron';
|
||||
import { findNodeExecutable, buildEnhancedPath } from '@automaker/platform';
|
||||
|
||||
// Development environment
|
||||
const isDev = !app.isPackaged;
|
||||
@@ -19,9 +20,9 @@ const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||
if (isDev) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||
} catch (error) {
|
||||
console.warn("[Electron] dotenv not available:", (error as Error).message);
|
||||
console.warn('[Electron] dotenv not available:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +37,17 @@ const STATIC_PORT = 3007;
|
||||
*/
|
||||
function getIconPath(): string | null {
|
||||
let iconFile: string;
|
||||
if (process.platform === "win32") {
|
||||
iconFile = "icon.ico";
|
||||
} else if (process.platform === "darwin") {
|
||||
iconFile = "logo_larger.png";
|
||||
if (process.platform === 'win32') {
|
||||
iconFile = 'icon.ico';
|
||||
} else if (process.platform === 'darwin') {
|
||||
iconFile = 'logo_larger.png';
|
||||
} else {
|
||||
iconFile = "logo_larger.png";
|
||||
iconFile = 'logo_larger.png';
|
||||
}
|
||||
|
||||
const iconPath = isDev
|
||||
? path.join(__dirname, "../public", iconFile)
|
||||
: path.join(__dirname, "../dist/public", iconFile);
|
||||
? path.join(__dirname, '../public', iconFile)
|
||||
: path.join(__dirname, '../dist/public', iconFile);
|
||||
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||
@@ -60,18 +61,18 @@ function getIconPath(): string | null {
|
||||
* Start static file server for production builds
|
||||
*/
|
||||
async function startStaticServer(): Promise<void> {
|
||||
const staticPath = path.join(__dirname, "../dist");
|
||||
const staticPath = path.join(__dirname, '../dist');
|
||||
|
||||
staticServer = http.createServer((request, response) => {
|
||||
let filePath = path.join(staticPath, request.url?.split("?")[0] || "/");
|
||||
let filePath = path.join(staticPath, request.url?.split('?')[0] || '/');
|
||||
|
||||
if (filePath.endsWith("/")) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
if (filePath.endsWith('/')) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
} else if (!path.extname(filePath)) {
|
||||
// For client-side routing, serve index.html for paths without extensions
|
||||
const possibleFile = filePath + ".html";
|
||||
const possibleFile = filePath + '.html';
|
||||
if (!fs.existsSync(filePath) && !fs.existsSync(possibleFile)) {
|
||||
filePath = path.join(staticPath, "index.html");
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
} else if (fs.existsSync(possibleFile)) {
|
||||
filePath = possibleFile;
|
||||
}
|
||||
@@ -79,35 +80,35 @@ async function startStaticServer(): Promise<void> {
|
||||
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err || !stats?.isFile()) {
|
||||
filePath = path.join(staticPath, "index.html");
|
||||
filePath = path.join(staticPath, 'index.html');
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
response.writeHead(500);
|
||||
response.end("Server Error");
|
||||
response.end('Server Error');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
const contentTypes: Record<string, string> = {
|
||||
".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",
|
||||
'.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",
|
||||
'Content-Type': contentTypes[ext] || 'application/octet-stream',
|
||||
});
|
||||
response.end(content);
|
||||
});
|
||||
@@ -116,12 +117,10 @@ async function startStaticServer(): Promise<void> {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
staticServer!.listen(STATIC_PORT, () => {
|
||||
console.log(
|
||||
`[Electron] Static server running at http://localhost:${STATIC_PORT}`
|
||||
);
|
||||
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
||||
resolve();
|
||||
});
|
||||
staticServer!.on("error", reject);
|
||||
staticServer!.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,41 +128,39 @@ async function startStaticServer(): Promise<void> {
|
||||
* Start the backend server
|
||||
*/
|
||||
async function startServer(): Promise<void> {
|
||||
let command: string;
|
||||
// Find Node.js executable (handles desktop launcher scenarios)
|
||||
const nodeResult = findNodeExecutable({
|
||||
skipSearch: isDev,
|
||||
logger: (msg) => console.log(`[Electron] ${msg}`),
|
||||
});
|
||||
const command = nodeResult.nodePath;
|
||||
let args: string[];
|
||||
let serverPath: string;
|
||||
|
||||
if (isDev) {
|
||||
command = "node";
|
||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
||||
serverPath = path.join(__dirname, '../../server/src/index.ts');
|
||||
|
||||
const serverNodeModules = path.join(
|
||||
__dirname,
|
||||
"../../server/node_modules/tsx"
|
||||
);
|
||||
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
||||
const serverNodeModules = path.join(__dirname, '../../server/node_modules/tsx');
|
||||
const rootNodeModules = path.join(__dirname, '../../../node_modules/tsx');
|
||||
|
||||
let tsxCliPath: string;
|
||||
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
||||
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
||||
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
|
||||
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
|
||||
if (fs.existsSync(path.join(serverNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(serverNodeModules, 'dist/cli.mjs');
|
||||
} else if (fs.existsSync(path.join(rootNodeModules, 'dist/cli.mjs'))) {
|
||||
tsxCliPath = path.join(rootNodeModules, 'dist/cli.mjs');
|
||||
} else {
|
||||
try {
|
||||
tsxCliPath = require.resolve("tsx/cli.mjs", {
|
||||
paths: [path.join(__dirname, "../../server")],
|
||||
tsxCliPath = require.resolve('tsx/cli.mjs', {
|
||||
paths: [path.join(__dirname, '../../server')],
|
||||
});
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Could not find tsx. Please run 'npm install' in the server directory."
|
||||
);
|
||||
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
|
||||
}
|
||||
}
|
||||
|
||||
args = [tsxCliPath, "watch", serverPath];
|
||||
args = [tsxCliPath, 'watch', serverPath];
|
||||
} else {
|
||||
command = "node";
|
||||
serverPath = path.join(process.resourcesPath, "server", "index.js");
|
||||
serverPath = path.join(process.resourcesPath, 'server', 'index.js');
|
||||
args = [serverPath];
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
@@ -172,13 +169,20 @@ async function startServer(): Promise<void> {
|
||||
}
|
||||
|
||||
const serverNodeModules = app.isPackaged
|
||||
? path.join(process.resourcesPath, "server", "node_modules")
|
||||
: path.join(__dirname, "../../server/node_modules");
|
||||
? path.join(process.resourcesPath, 'server', 'node_modules')
|
||||
: path.join(__dirname, '../../server/node_modules');
|
||||
|
||||
// Build enhanced PATH that includes Node.js directory (cross-platform)
|
||||
const enhancedPath = buildEnhancedPath(command, process.env.PATH || '');
|
||||
if (enhancedPath !== process.env.PATH) {
|
||||
console.log(`[Electron] Enhanced PATH with Node directory: ${path.dirname(command)}`);
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: enhancedPath,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath("userData"),
|
||||
DATA_DIR: app.getPath('userData'),
|
||||
NODE_PATH: serverNodeModules,
|
||||
// Only set ALLOWED_ROOT_DIRECTORY if explicitly provided in environment
|
||||
// If not set, server will allow access to all paths
|
||||
@@ -187,30 +191,30 @@ async function startServer(): Promise<void> {
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("[Electron] Starting backend server...");
|
||||
console.log("[Electron] Server path:", serverPath);
|
||||
console.log("[Electron] NODE_PATH:", serverNodeModules);
|
||||
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"],
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
serverProcess.stdout?.on("data", (data) => {
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
console.log(`[Server] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on("data", (data) => {
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
console.error(`[Server Error] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.on("close", (code) => {
|
||||
serverProcess.on('close', (code) => {
|
||||
console.log(`[Server] Process exited with code ${code}`);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
serverProcess.on("error", (err) => {
|
||||
serverProcess.on('error', (err) => {
|
||||
console.error(`[Server] Failed to start server process:`, err);
|
||||
serverProcess = null;
|
||||
});
|
||||
@@ -225,30 +229,27 @@ async function waitForServer(maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = http.get(
|
||||
`http://localhost:${SERVER_PORT}/api/health`,
|
||||
(res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Timeout"));
|
||||
reject(new Error('Timeout'));
|
||||
});
|
||||
});
|
||||
console.log("[Electron] Server is ready");
|
||||
console.log('[Electron] Server is ready');
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Server failed to start");
|
||||
throw new Error('Server failed to start');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,12 +263,12 @@ function createWindow(): void {
|
||||
minWidth: 1280,
|
||||
minHeight: 768,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: "hiddenInset",
|
||||
backgroundColor: "#0a0a0a",
|
||||
titleBarStyle: 'hiddenInset',
|
||||
backgroundColor: '#0a0a0a',
|
||||
};
|
||||
|
||||
if (iconPath) {
|
||||
@@ -286,17 +287,17 @@ function createWindow(): void {
|
||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
||||
}
|
||||
|
||||
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
|
||||
if (isDev && process.env.OPEN_DEVTOOLS === 'true') {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,28 +305,22 @@ function createWindow(): void {
|
||||
app.whenReady().then(async () => {
|
||||
// Ensure userData path is consistent across dev/prod so files land in Automaker dir
|
||||
try {
|
||||
const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker");
|
||||
if (app.getPath("userData") !== desiredUserDataPath) {
|
||||
app.setPath("userData", desiredUserDataPath);
|
||||
console.log("[Electron] userData path set to:", desiredUserDataPath);
|
||||
const desiredUserDataPath = path.join(app.getPath('appData'), 'Automaker');
|
||||
if (app.getPath('userData') !== desiredUserDataPath) {
|
||||
app.setPath('userData', desiredUserDataPath);
|
||||
console.log('[Electron] userData path set to:', desiredUserDataPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[Electron] Failed to set userData path:",
|
||||
(error as Error).message
|
||||
);
|
||||
console.warn('[Electron] Failed to set userData path:', (error as Error).message);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
if (process.platform === 'darwin' && app.dock) {
|
||||
const iconPath = getIconPath();
|
||||
if (iconPath) {
|
||||
try {
|
||||
app.dock.setIcon(iconPath);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[Electron] Failed to set dock icon:",
|
||||
(error as Error).message
|
||||
);
|
||||
console.warn('[Electron] Failed to set dock icon:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,32 +337,36 @@ app.whenReady().then(async () => {
|
||||
// Create window
|
||||
createWindow();
|
||||
} catch (error) {
|
||||
console.error("[Electron] Failed to start:", error);
|
||||
console.error('[Electron] Failed to start:', error);
|
||||
dialog.showErrorBox(
|
||||
'Automaker Failed to Start',
|
||||
`The application failed to start.\n\n${(error as Error).message}\n\nPlease ensure Node.js is installed and accessible.`
|
||||
);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.on("activate", () => {
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
app.on('before-quit', () => {
|
||||
if (serverProcess) {
|
||||
console.log("[Electron] Stopping server...");
|
||||
console.log('[Electron] Stopping server...');
|
||||
serverProcess.kill();
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
if (staticServer) {
|
||||
console.log("[Electron] Stopping static server...");
|
||||
console.log('[Electron] Stopping static server...');
|
||||
staticServer.close();
|
||||
staticServer = null;
|
||||
}
|
||||
@@ -378,28 +377,28 @@ app.on("before-quit", () => {
|
||||
// ============================================
|
||||
|
||||
// Native file dialogs
|
||||
ipcMain.handle("dialog:openDirectory", async () => {
|
||||
ipcMain.handle('dialog:openDirectory', async () => {
|
||||
if (!mainWindow) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
||||
ipcMain.handle('dialog:openFile', async (_, options = {}) => {
|
||||
if (!mainWindow) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
properties: ['openFile'],
|
||||
...options,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
||||
ipcMain.handle('dialog:saveFile', async (_, options = {}) => {
|
||||
if (!mainWindow) {
|
||||
return { canceled: true, filePath: undefined };
|
||||
}
|
||||
@@ -408,7 +407,7 @@ ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
||||
});
|
||||
|
||||
// Shell operations
|
||||
ipcMain.handle("shell:openExternal", async (_, url: string) => {
|
||||
ipcMain.handle('shell:openExternal', async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
@@ -417,7 +416,7 @@ ipcMain.handle("shell:openExternal", async (_, url: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:openPath", async (_, filePath: string) => {
|
||||
ipcMain.handle('shell:openPath', async (_, filePath: string) => {
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
return { success: true };
|
||||
@@ -427,27 +426,24 @@ ipcMain.handle("shell:openPath", async (_, filePath: string) => {
|
||||
});
|
||||
|
||||
// App info
|
||||
ipcMain.handle(
|
||||
"app:getPath",
|
||||
async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
}
|
||||
);
|
||||
ipcMain.handle('app:getPath', async (_, name: Parameters<typeof app.getPath>[0]) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getVersion", async () => {
|
||||
ipcMain.handle('app:getVersion', async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isPackaged", async () => {
|
||||
ipcMain.handle('app:isPackaged', async () => {
|
||||
return app.isPackaged;
|
||||
});
|
||||
|
||||
// Ping - for connection check
|
||||
ipcMain.handle("ping", async () => {
|
||||
return "pong";
|
||||
ipcMain.handle('ping', async () => {
|
||||
return 'pong';
|
||||
});
|
||||
|
||||
// Get server URL for HTTP client
|
||||
ipcMain.handle("server:getUrl", async () => {
|
||||
ipcMain.handle('server:getUrl', async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user