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, RenameBranchParams, CopyAppParams, } 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 our utility modules import { withLock } from "../utils/lock_utils"; import { getFilesRecursively } from "../utils/file_utils"; import { runningApps, processCounter, killProcess, removeAppIfCurrentProcess, } from "../utils/process_manager"; import { getEnvVar } from "../utils/read_env"; import { readSettings } from "../../main/settings"; import fixPath from "fix-path"; import killPort from "kill-port"; import util from "util"; import log from "electron-log"; import { getSupabaseProjectName } from "../../supabase_admin/supabase_management_client"; import { createLoggedHandler } from "./safe_handle"; import { getLanguageModelProviders } from "../shared/language_model_helpers"; import { startProxy } from "../utils/start_proxy_server"; import { Worker } from "worker_threads"; import { createFromTemplate } from "./createFromTemplate"; import { gitCommit } from "../utils/git_utils"; import { safeSend } from "../utils/safe_sender"; async function copyDir( source: string, destination: string, filter?: (source: string) => boolean, ) { await fsPromises.cp(source, destination, { recursive: true, filter: (src: string) => { if (path.basename(src) === "node_modules") { return false; } if (filter) { return filter(src); } return true; }, }); } const logger = log.scope("app_handlers"); const handle = createLoggedHandler(logger); let proxyWorker: Worker | null = null; // 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 { if (proxyWorker) { proxyWorker.terminate(); proxyWorker = null; } 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 --legacy-peer-deps && 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", async (data) => { const message = util.stripVTControlCharacters(data.toString()); logger.debug(`App ${appId} (PID: ${process.pid}) stdout: ${message}`); safeSend(event.sender, "app:output", { type: "stdout", message, appId, }); const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/); if (urlMatch) { proxyWorker = await startProxy(urlMatch[1], { onStarted: (proxyUrl) => { safeSend(event.sender, "app:output", { type: "stdout", message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${urlMatch[1]}]`, appId, }); }, }); } }); process.stderr?.on("data", (data) => { const message = util.stripVTControlCharacters(data.toString()); logger.error(`App ${appId} (PID: ${process.pid}) stderr: ${message}`); safeSend(event.sender, "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 { // Ignore if nothing was running on that port } } export function registerAppHandlers() { handle( "create-app", async ( _, params: CreateAppParams, ): Promise<{ app: any; chatId: number }> => { 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(); await createFromTemplate({ 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 const commitHash = await gitCommit({ path: fullAppPath, message: "Init Dyad app", }); // Update chat with initial commit hash await db .update(chats) .set({ initialCommitHash: commitHash, }) .where(eq(chats.id, chat.id)); return { app, chatId: chat.id }; }, ); handle( "copy-app", async (_, params: CopyAppParams): Promise<{ app: any }> => { const { appId, newAppName, withHistory } = params; // 1. Check if an app with the new name already exists const existingApp = await db.query.apps.findFirst({ where: eq(apps.name, newAppName), }); if (existingApp) { throw new Error(`An app named "${newAppName}" already exists.`); } // 2. Find the original app const originalApp = await db.query.apps.findFirst({ where: eq(apps.id, appId), }); if (!originalApp) { throw new Error("Original app not found."); } const originalAppPath = getDyadAppPath(originalApp.path); const newAppPath = getDyadAppPath(newAppName); // 3. Copy the app folder try { await copyDir(originalAppPath, newAppPath, (source: string) => { if (!withHistory && path.basename(source) === ".git") { return false; } return true; }); } catch (error) { logger.error("Failed to copy app directory:", error); throw new Error("Failed to copy app directory."); } if (!withHistory) { // Initialize git repo and create first commit await git.init({ fs: fs, dir: newAppPath, defaultBranch: "main", }); // Stage all files await git.add({ fs: fs, dir: newAppPath, filepath: ".", }); // Create initial commit await gitCommit({ path: newAppPath, message: "Init Dyad app", }); } // 4. Create a new app entry in the database const [newDbApp] = await db .insert(apps) .values({ name: newAppName, path: newAppName, // Use the new name for the path // Explicitly set these to null because we don't want to copy them over. // Note: we could just leave them out since they're nullable field, but this // is to make it explicit we intentionally don't want to copy them over. supabaseProjectId: null, githubOrg: null, githubRepo: null, }) .returning(); return { app: newDbApp }; }, ); 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; const settings = readSettings(); if (app.supabaseProjectId && settings.supabase?.accessToken?.value) { 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"); } }, ); // Do NOT use handle for this, it contains sensitive information. ipcMain.handle("get-env-vars", async () => { const envVars: Record = {}; const providers = await getLanguageModelProviders(); for (const provider of providers) { if (provider.envVarName) { envVars[provider.envVarName] = getEnvVar(provider.envVarName); } } return envVars; }); ipcMain.handle( "run-app", async ( event: Electron.IpcMainInvokeEvent, { appId }: { appId: number }, ): Promise => { return withLock(appId, async () => { // Check if app is already running if (runningApps.has(appId)) { logger.debug(`App ${appId} is already running.`); return; } 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 { // Kill any orphaned process on port 32100 (in case previous run left it) await killProcessOnPort(32100); await executeApp({ appPath, appId, event }); return; } 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 }): Promise => { 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; } 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; } 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; } 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 }, ): Promise => { 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; } catch (error) { logger.error(`Error restarting app ${appId}:`, error); throw error; } }); }, ); ipcMain.handle( "edit-app-file", async ( _, { appId, filePath, content, }: { appId: number; filePath: string; content: string }, ): Promise => { 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 gitCommit({ path: appPath, message: `Updated ${filePath}`, }); } return; } 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 }): Promise => { // 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; } 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 }, ): Promise => { 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, }); // Copy the directory without node_modules await copyDir(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}`); } try { // Delete the old directory await fsPromises.rm(oldAppPath, { recursive: true, force: true }); } catch (error: any) { // Why is this just a warning? This happens quite often on Windows // because it has an aggressive file lock. // // Not deleting the old directory is annoying, but not a big deal // since the user can do it themselves if they need to. logger.warn( `Error deleting old app directory ${oldAppPath}:`, error, ); } } // Update app in database try { await db .update(apps) .set({ name: appName, path: appPath, }) .where(eq(apps.id, appId)) .returning(); return; } catch (error: any) { // Attempt to rollback the file move if (newAppPath !== oldAppPath) { try { // Copy back from new to old await copyDir(newAppPath, oldAppPath); // Delete the new directory await fsPromises.rm(newAppPath, { recursive: true, force: true }); } 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 (): Promise => { 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."); }); ipcMain.handle("get-app-version", async (): Promise<{ version: string }> => { // 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 }; }); handle("rename-branch", async (_, params: RenameBranchParams) => { const { appId, oldBranchName, newBranchName } = params; 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); return withLock(appId, async () => { try { // Check if the old branch exists const branches = await git.listBranches({ fs, dir: appPath }); if (!branches.includes(oldBranchName)) { throw new Error(`Branch '${oldBranchName}' not found.`); } // Check if the new branch name already exists if (branches.includes(newBranchName)) { // If newBranchName is 'main' and oldBranchName is 'master', // and 'main' already exists, we might want to allow this if 'main' is the current branch // and just switch to it, or delete 'master'. // For now, let's keep it simple and throw an error. throw new Error( `Branch '${newBranchName}' already exists. Cannot rename.`, ); } await git.renameBranch({ fs: fs, dir: appPath, oldref: oldBranchName, ref: newBranchName, }); logger.info( `Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`, ); } catch (error: any) { logger.error( `Failed to rename branch for app ${appId}: ${error.message}`, ); throw new Error( `Failed to rename branch '${oldBranchName}' to '${newBranchName}': ${error.message}`, ); } }); }); }