import { ipcMain } from "electron"; import { db, getDatabasePath } from "../../db"; import { apps, chats } from "../../db/schema"; import { desc, eq } from "drizzle-orm"; import type { App, CreateAppParams, SandboxConfig, Version, } from "../ipc_types"; import fs from "node:fs"; import path from "node:path"; import { getDyadAppPath, getUserDataPath } from "../../paths/paths"; import { spawn } from "node:child_process"; import git from "isomorphic-git"; import { promises as fsPromises } from "node:fs"; import { extractCodebase } from "../../utils/codebase"; // Import our utility modules import { withLock } from "../utils/lock_utils"; import { copyDirectoryRecursive, getFilesRecursively, } from "../utils/file_utils"; import { runningApps, processCounter, killProcess, removeAppIfCurrentProcess, RunningAppInfo, } from "../utils/process_manager"; import { ALLOWED_ENV_VARS } from "../../constants/models"; import { getEnvVar } from "../utils/read_env"; import { readSettings } from "../../main/settings"; import { Worker } from "worker_threads"; import fixPath from "fix-path"; import { getGitAuthor } from "../utils/git_author"; import killPort from "kill-port"; import util from "util"; import log from "electron-log"; import { getSupabaseProjectName } from "../../supabase_admin/supabase_management_client"; const logger = log.scope("app_handlers"); // Needed, otherwise electron in MacOS/Linux will not be able // to find node/pnpm. fixPath(); async function executeApp({ appPath, appId, event, // Keep event for local-node case }: { appPath: string; appId: number; event: Electron.IpcMainInvokeEvent; }): Promise { await executeAppLocalNode({ appPath, appId, event }); } async function executeAppLocalNode({ appPath, appId, event, }: { appPath: string; appId: number; event: Electron.IpcMainInvokeEvent; }): Promise { const process = spawn( "(pnpm install && pnpm run dev --port 32100) || (npm install && npm run dev -- --port 32100)", [], { cwd: appPath, shell: true, stdio: "pipe", // Ensure stdio is piped so we can capture output/errors and detect close detached: false, // Ensure child process is attached to the main process lifecycle unless explicitly backgrounded } ); // Check if process spawned correctly if (!process.pid) { // Attempt to capture any immediate errors if possible let errorOutput = ""; process.stderr?.on("data", (data) => (errorOutput += data)); await new Promise((resolve) => process.on("error", resolve)); // Wait for error event throw new Error( `Failed to spawn process for app ${appId}. Error: ${ errorOutput || "Unknown spawn error" }` ); } // Increment the counter and store the process reference with its ID const currentProcessId = processCounter.increment(); runningApps.set(appId, { process, processId: currentProcessId }); // Log output process.stdout?.on("data", (data) => { const message = util.stripVTControlCharacters(data.toString()); logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`); event.sender.send("app:output", { type: "stdout", message, appId, }); }); process.stderr?.on("data", (data) => { const message = util.stripVTControlCharacters(data.toString()); logger.error(`App ${appId} (PID: ${process.pid}) stderr: ${message}`); event.sender.send("app:output", { type: "stderr", message, appId, }); }); // Handle process exit/close process.on("close", (code, signal) => { logger.log( `App ${appId} (PID: ${process.pid}) process closed with code ${code}, signal ${signal}.` ); removeAppIfCurrentProcess(appId, process); }); // Handle errors during process lifecycle (e.g., command not found) process.on("error", (err) => { logger.error( `Error in app ${appId} (PID: ${process.pid}) process: ${err.message}` ); removeAppIfCurrentProcess(appId, process); // Note: We don't throw here as the error is asynchronous. The caller got a success response already. // Consider adding ipcRenderer event emission to notify UI of the error. }); } // Helper to kill process on a specific port (cross-platform, using kill-port) async function killProcessOnPort(port: number): Promise { try { await killPort(port, "tcp"); } catch (err) { // Ignore if nothing was running on that port } } export function registerAppHandlers() { ipcMain.handle("create-app", async (_, params: CreateAppParams) => { const appPath = params.name; const fullAppPath = getDyadAppPath(appPath); if (fs.existsSync(fullAppPath)) { throw new Error(`App already exists at: ${fullAppPath}`); } // Create a new app const [app] = await db .insert(apps) .values({ name: params.name, // Use the name as the path for now path: appPath, }) .returning(); // Create an initial chat for this app const [chat] = await db .insert(chats) .values({ appId: app.id, }) .returning(); // Start async operations in background try { // Copy scaffold asynchronously await copyDirectoryRecursive( path.join(__dirname, "..", "..", "scaffold"), fullAppPath ); // Initialize git repo and create first commit await git.init({ fs: fs, dir: fullAppPath, defaultBranch: "main", }); // Stage all files await git.add({ fs: fs, dir: fullAppPath, filepath: ".", }); // Create initial commit await git.commit({ fs: fs, dir: fullAppPath, message: "Init from react vite template", author: await getGitAuthor(), }); } catch (error) { logger.error("Error in background app initialization:", error); } // })(); return { app, chatId: chat.id }; }); ipcMain.handle("get-app", async (_, appId: number): Promise => { const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } // Get app files const appPath = getDyadAppPath(app.path); let files: string[] = []; try { files = getFilesRecursively(appPath, appPath); } catch (error) { logger.error(`Error reading files for app ${appId}:`, error); // Return app even if files couldn't be read } let supabaseProjectName: string | null = null; if (app.supabaseProjectId) { supabaseProjectName = await getSupabaseProjectName(app.supabaseProjectId); } return { ...app, files, supabaseProjectName, }; }); ipcMain.handle("list-apps", async () => { const allApps = await db.query.apps.findMany({ orderBy: [desc(apps.createdAt)], }); return { apps: allApps, appBasePath: getDyadAppPath("$APP_BASE_PATH"), }; }); ipcMain.handle( "read-app-file", async (_, { appId, filePath }: { appId: number; filePath: string }) => { const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } const appPath = getDyadAppPath(app.path); const fullPath = path.join(appPath, filePath); // Check if the path is within the app directory (security check) if (!fullPath.startsWith(appPath)) { throw new Error("Invalid file path"); } if (!fs.existsSync(fullPath)) { throw new Error("File not found"); } try { const contents = fs.readFileSync(fullPath, "utf-8"); return contents; } catch (error) { logger.error(`Error reading file ${filePath} for app ${appId}:`, error); throw new Error("Failed to read file"); } } ); ipcMain.handle("get-env-vars", async () => { const envVars: Record = {}; for (const key of ALLOWED_ENV_VARS) { envVars[key] = getEnvVar(key); } return envVars; }); ipcMain.handle( "run-app", async ( event: Electron.IpcMainInvokeEvent, { appId }: { appId: number } ) => { return withLock(appId, async () => { // Check if app is already running if (runningApps.has(appId)) { logger.debug(`App ${appId} is already running.`); // Potentially return the existing process info or confirm status return { success: true, message: "App already running." }; } const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } logger.debug(`Starting app ${appId} in path ${app.path}`); const appPath = getDyadAppPath(app.path); try { const currentProcessId = await executeApp({ appPath, appId, event }); return { success: true, processId: currentProcessId }; } catch (error: any) { logger.error(`Error running app ${appId}:`, error); // Ensure cleanup if error happens during setup but before process events are handled if ( runningApps.has(appId) && runningApps.get(appId)?.processId === processCounter.value ) { runningApps.delete(appId); } throw new Error(`Failed to run app ${appId}: ${error.message}`); } }); } ); ipcMain.handle("stop-app", async (_, { appId }: { appId: number }) => { logger.log( `Attempting to stop app ${appId}. Current running apps: ${runningApps.size}` ); return withLock(appId, async () => { const appInfo = runningApps.get(appId); if (!appInfo) { logger.log( `App ${appId} not found in running apps map. Assuming already stopped.` ); return { success: true, message: "App not running.", }; } const { process, processId } = appInfo; logger.log( `Found running app ${appId} with processId ${processId} (PID: ${process.pid}). Attempting to stop.` ); // Check if the process is already exited or closed if (process.exitCode !== null || process.signalCode !== null) { logger.log( `Process for app ${appId} (PID: ${process.pid}) already exited (code: ${process.exitCode}, signal: ${process.signalCode}). Cleaning up map.` ); runningApps.delete(appId); // Ensure cleanup if somehow missed return { success: true, message: "Process already exited." }; } try { // Use the killProcess utility to stop the process await killProcess(process); // Now, safely remove the app from the map *after* confirming closure removeAppIfCurrentProcess(appId, process); return { success: true }; } catch (error: any) { logger.error( `Error stopping app ${appId} (PID: ${process.pid}, processId: ${processId}):`, error ); // Attempt cleanup even if an error occurred during the stop process removeAppIfCurrentProcess(appId, process); throw new Error(`Failed to stop app ${appId}: ${error.message}`); } }); }); ipcMain.handle( "restart-app", async ( event: Electron.IpcMainInvokeEvent, { appId, removeNodeModules, }: { appId: number; removeNodeModules?: boolean } ) => { logger.log(`Restarting app ${appId}`); return withLock(appId, async () => { try { // First stop the app if it's running const appInfo = runningApps.get(appId); if (appInfo) { const { process, processId } = appInfo; logger.log( `Stopping app ${appId} (processId ${processId}) before restart` ); await killProcess(process); runningApps.delete(appId); } else { logger.log(`App ${appId} not running. Proceeding to start.`); } // Kill any orphaned process on port 32100 (in case previous run left it) await killProcessOnPort(32100); // Now start the app again const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } const appPath = getDyadAppPath(app.path); // Remove node_modules if requested if (removeNodeModules) { const nodeModulesPath = path.join(appPath, "node_modules"); logger.log( `Removing node_modules for app ${appId} at ${nodeModulesPath}` ); if (fs.existsSync(nodeModulesPath)) { await fsPromises.rm(nodeModulesPath, { recursive: true, force: true, }); logger.log(`Successfully removed node_modules for app ${appId}`); } else { logger.log(`No node_modules directory found for app ${appId}`); } } logger.debug( `Executing app ${appId} in path ${app.path} after restart request` ); // Adjusted log await executeApp({ appPath, appId, event }); // This will handle starting either mode return { success: true }; } catch (error) { logger.error(`Error restarting app ${appId}:`, error); throw error; } }); } ); ipcMain.handle("list-versions", async (_, { appId }: { appId: number }) => { const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } const appPath = getDyadAppPath(app.path); // Just return an empty array if the app is not a git repo. if (!fs.existsSync(path.join(appPath, ".git"))) { return []; } try { const commits = await git.log({ fs, dir: appPath, depth: 1000, // Limit to last 1000 commits for performance }); return commits.map((commit) => ({ oid: commit.oid, message: commit.commit.message, timestamp: commit.commit.author.timestamp, })) satisfies Version[]; } catch (error: any) { logger.error(`Error listing versions for app ${appId}:`, error); throw new Error(`Failed to list versions: ${error.message}`); } }); ipcMain.handle( "revert-version", async ( _, { appId, previousVersionId }: { appId: number; previousVersionId: string } ) => { return withLock(appId, async () => { const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } const appPath = getDyadAppPath(app.path); try { await git.checkout({ fs, dir: appPath, ref: "main", force: true, }); // Get status matrix comparing the target commit (previousVersionId as HEAD) with current working directory const matrix = await git.statusMatrix({ fs, dir: appPath, ref: previousVersionId, }); // Process each file to revert to the state in previousVersionId for (const [ filepath, headStatus, workdirStatus, stageStatus, ] of matrix) { const fullPath = path.join(appPath, filepath); // If file exists in HEAD (previous version) if (headStatus === 1) { // If file doesn't exist or has changed in working directory, restore it from the target commit if (workdirStatus !== 1) { const { blob } = await git.readBlob({ fs, dir: appPath, oid: previousVersionId, filepath, }); await fsPromises.mkdir(path.dirname(fullPath), { recursive: true, }); await fsPromises.writeFile(fullPath, Buffer.from(blob)); } } // If file doesn't exist in HEAD but exists in working directory, delete it else if (headStatus === 0 && workdirStatus !== 0) { if (fs.existsSync(fullPath)) { await fsPromises.unlink(fullPath); await git.remove({ fs, dir: appPath, filepath: filepath, }); } } } // Stage all changes await git.add({ fs, dir: appPath, filepath: ".", }); // Create a revert commit await git.commit({ fs, dir: appPath, message: `Reverted all changes back to version ${previousVersionId}`, author: await getGitAuthor(), }); return { success: true }; } catch (error: any) { logger.error( `Error reverting to version ${previousVersionId} for app ${appId}:`, error ); throw new Error(`Failed to revert version: ${error.message}`); } }); } ); ipcMain.handle( "checkout-version", async (_, { appId, versionId }: { appId: number; versionId: string }) => { return withLock(appId, async () => { const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } const appPath = getDyadAppPath(app.path); try { // Checkout the target commit await git.checkout({ fs, dir: appPath, ref: versionId, force: true, }); return { success: true }; } catch (error: any) { logger.error( `Error checking out version ${versionId} for app ${appId}:`, error ); throw new Error(`Failed to checkout version: ${error.message}`); } }); } ); // Extract codebase information ipcMain.handle( "extract-codebase", async (_, { appId, maxFiles }: { appId: number; maxFiles?: number }) => { const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } const appPath = getDyadAppPath(app.path); try { return await extractCodebase(appPath, maxFiles); } catch (error) { logger.error(`Error extracting codebase for app ${appId}:`, error); throw new Error( `Failed to extract codebase: ${(error as any).message}` ); } } ); ipcMain.handle( "edit-app-file", async ( _, { appId, filePath, content, }: { appId: number; filePath: string; content: string } ) => { const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } const appPath = getDyadAppPath(app.path); const fullPath = path.join(appPath, filePath); // Check if the path is within the app directory (security check) if (!fullPath.startsWith(appPath)) { throw new Error("Invalid file path"); } // Ensure directory exists const dirPath = path.dirname(fullPath); await fsPromises.mkdir(dirPath, { recursive: true }); try { await fsPromises.writeFile(fullPath, content, "utf-8"); // Check if git repository exists and commit the change if (fs.existsSync(path.join(appPath, ".git"))) { await git.add({ fs, dir: appPath, filepath: filePath, }); await git.commit({ fs, dir: appPath, message: `Updated ${filePath}`, author: await getGitAuthor(), }); } return { success: true }; } catch (error: any) { logger.error(`Error writing file ${filePath} for app ${appId}:`, error); throw new Error(`Failed to write file: ${error.message}`); } } ); ipcMain.handle("delete-app", async (_, { appId }: { appId: number }) => { // Static server worker is NOT terminated here anymore return withLock(appId, async () => { // Check if app exists const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } // Stop the app if it's running if (runningApps.has(appId)) { const appInfo = runningApps.get(appId)!; try { logger.log(`Stopping app ${appId} before deletion.`); // Adjusted log await killProcess(appInfo.process); runningApps.delete(appId); } catch (error: any) { logger.error(`Error stopping app ${appId} before deletion:`, error); // Adjusted log // Continue with deletion even if stopping fails } } // Delete app files const appPath = getDyadAppPath(app.path); try { await fsPromises.rm(appPath, { recursive: true, force: true }); } catch (error: any) { logger.error(`Error deleting app files for app ${appId}:`, error); throw new Error(`Failed to delete app files: ${error.message}`); } // Delete app from database try { await db.delete(apps).where(eq(apps.id, appId)); // Note: Associated chats will cascade delete if that's set up in the schema return { success: true }; } catch (error: any) { logger.error(`Error deleting app ${appId} from database:`, error); throw new Error(`Failed to delete app from database: ${error.message}`); } }); }); ipcMain.handle( "rename-app", async ( _, { appId, appName, appPath, }: { appId: number; appName: string; appPath: string } ) => { return withLock(appId, async () => { // Check if app exists const app = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!app) { throw new Error("App not found"); } // Check for conflicts with existing apps const nameConflict = await db.query.apps.findFirst({ where: eq(apps.name, appName), }); const pathConflict = await db.query.apps.findFirst({ where: eq(apps.path, appPath), }); if (nameConflict && nameConflict.id !== appId) { throw new Error(`An app with the name '${appName}' already exists`); } if (pathConflict && pathConflict.id !== appId) { throw new Error(`An app with the path '${appPath}' already exists`); } // Stop the app if it's running if (runningApps.has(appId)) { const appInfo = runningApps.get(appId)!; try { await killProcess(appInfo.process); runningApps.delete(appId); } catch (error: any) { logger.error(`Error stopping app ${appId} before renaming:`, error); throw new Error( `Failed to stop app before renaming: ${error.message}` ); } } const oldAppPath = getDyadAppPath(app.path); const newAppPath = getDyadAppPath(appPath); // Only move files if needed if (newAppPath !== oldAppPath) { // Move app files try { // Check if destination directory already exists if (fs.existsSync(newAppPath)) { throw new Error( `Destination path '${newAppPath}' already exists` ); } // Create parent directory if it doesn't exist await fsPromises.mkdir(path.dirname(newAppPath), { recursive: true, }); // Move the files await fsPromises.rename(oldAppPath, newAppPath); } catch (error: any) { logger.error( `Error moving app files from ${oldAppPath} to ${newAppPath}:`, error ); throw new Error(`Failed to move app files: ${error.message}`); } } // Update app in database try { const [updatedApp] = await db .update(apps) .set({ name: appName, path: appPath, }) .where(eq(apps.id, appId)) .returning(); return { success: true, app: updatedApp }; } catch (error: any) { // Attempt to rollback the file move if (newAppPath !== oldAppPath) { try { await fsPromises.rename(newAppPath, oldAppPath); } catch (rollbackError) { logger.error( `Failed to rollback file move during rename error:`, rollbackError ); } } logger.error(`Error updating app ${appId} in database:`, error); throw new Error(`Failed to update app in database: ${error.message}`); } }); } ); ipcMain.handle("reset-all", async () => { logger.log("start: resetting all apps and settings."); // Stop all running apps first logger.log("stopping all running apps..."); const runningAppIds = Array.from(runningApps.keys()); for (const appId of runningAppIds) { try { const appInfo = runningApps.get(appId)!; await killProcess(appInfo.process); runningApps.delete(appId); } catch (error) { logger.error(`Error stopping app ${appId} during reset:`, error); // Continue with reset even if stopping fails } } logger.log("all running apps stopped."); logger.log("deleting database..."); // 1. Drop the database by deleting the SQLite file const dbPath = getDatabasePath(); if (fs.existsSync(dbPath)) { // Close database connections first if (db.$client) { db.$client.close(); } await fsPromises.unlink(dbPath); logger.log(`Database file deleted: ${dbPath}`); } logger.log("database deleted."); logger.log("deleting settings..."); // 2. Remove settings const userDataPath = getUserDataPath(); const settingsPath = path.join(userDataPath, "user-settings.json"); if (fs.existsSync(settingsPath)) { await fsPromises.unlink(settingsPath); logger.log(`Settings file deleted: ${settingsPath}`); } logger.log("settings deleted."); // 3. Remove all app files recursively // Doing this last because it's the most time-consuming and the least important // in terms of resetting the app state. logger.log("removing all app files..."); const dyadAppPath = getDyadAppPath("."); if (fs.existsSync(dyadAppPath)) { await fsPromises.rm(dyadAppPath, { recursive: true, force: true }); // Recreate the base directory await fsPromises.mkdir(dyadAppPath, { recursive: true }); } logger.log("all app files removed."); logger.log("reset all complete."); return { success: true, message: "Successfully reset everything" }; }); ipcMain.handle("get-app-version", async () => { // Read version from package.json at project root const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); return { version: packageJson.version }; }); }