Files
moreminimore-vibe/src/ipc/handlers/app_handlers.ts

746 lines
22 KiB
TypeScript

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 } 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 {
copyDirectoryRecursive,
getFilesRecursively,
} from "../utils/file_utils";
import {
runningApps,
processCounter,
killProcess,
removeAppIfCurrentProcess,
} from "../utils/process_manager";
import { ALLOWED_ENV_VARS } from "../../constants/models";
import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings";
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";
import { createLoggedHandler } from "./safe_handle";
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
// 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<void> {
await executeAppLocalNode({ appPath, appId, event });
}
async function executeAppLocalNode({
appPath,
appId,
event,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
}): Promise<void> {
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<void> {
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();
// 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
const commitHash = await git.commit({
fs: fs,
dir: fullAppPath,
message: "Init from react vite template",
author: await getGitAuthor(),
});
// Update chat with initial commit hash
await db
.update(chats)
.set({
initialCommitHash: commitHash,
})
.where(eq(chats.id, chat.id));
} catch (error) {
logger.error("Error in background app initialization:", error);
}
return { app, chatId: chat.id };
},
);
handle("get-app", async (_, appId: number): Promise<App> => {
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<string, string | undefined> = {};
for (const key of ALLOWED_ENV_VARS) {
envVars[key] = getEnvVar(key);
}
return envVars;
});
ipcMain.handle(
"run-app",
async (
event: Electron.IpcMainInvokeEvent,
{ appId }: { appId: number },
): Promise<void> => {
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 {
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<void> => {
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<void> => {
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<void> => {
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;
} 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<void> => {
// 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<void> => {
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 {
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 {
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 (): Promise<void> => {
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 };
});
}