diff --git a/src/components/RuntimeModeSelector.tsx b/src/components/RuntimeModeSelector.tsx
new file mode 100644
index 0000000..11eefc0
--- /dev/null
+++ b/src/components/RuntimeModeSelector.tsx
@@ -0,0 +1,74 @@
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useSettings } from "@/hooks/useSettings";
+import { showError } from "@/lib/toast";
+import { IpcClient } from "@/ipc/ipc_client";
+
+export function RuntimeModeSelector() {
+ const { settings, updateSettings } = useSettings();
+
+ if (!settings) {
+ return null;
+ }
+
+ const isDockerMode = settings?.runtimeMode2 === "docker";
+
+ const handleRuntimeModeChange = async (value: "host" | "docker") => {
+ try {
+ await updateSettings({ runtimeMode2: value });
+ } catch (error: any) {
+ showError(`Failed to update runtime mode: ${error.message}`);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ Choose whether to run apps directly on the local machine or in Docker
+ containers
+
+
+ {isDockerMode && (
+
+ ⚠️ Docker mode is experimental and requires{" "}
+ {" "}
+ to be installed and running
+
+ )}
+
+ );
+}
diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx
index 4238a07..9f3eb4a 100644
--- a/src/components/preview_panel/PreviewIframe.tsx
+++ b/src/components/preview_panel/PreviewIframe.tsx
@@ -51,10 +51,11 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const { isStreaming } = useStreamChat();
if (!error) return null;
+ const isDockerError = error.includes("Cannot connect to the Docker");
const getTruncatedError = () => {
const firstLine = error.split("\n")[0];
- const snippetLength = 200;
+ const snippetLength = 250;
const snippet = error.substring(0, snippetLength);
return firstLine.length < snippet.length
? firstLine
@@ -97,23 +98,27 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
- Tip: Check if restarting the
- app fixes the error.
+ Tip:
+ {isDockerError
+ ? "Make sure Docker Desktop is running and try restarting the app."
+ : "Check if restarting the app fixes the error."}
{/* AI Fix button at the bottom */}
-
-
-
+ {!isDockerError && (
+
+
+
+ )}
);
};
diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts
index a6fc78c..4f03e9c 100644
--- a/src/ipc/handlers/app_handlers.ts
+++ b/src/ipc/handlers/app_handlers.ts
@@ -13,7 +13,7 @@ import type {
import fs from "node:fs";
import path from "node:path";
import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
-import { spawn } from "node:child_process";
+import { ChildProcess, spawn } from "node:child_process";
import git from "isomorphic-git";
import { promises as fsPromises } from "node:fs";
@@ -23,8 +23,9 @@ import { getFilesRecursively } from "../utils/file_utils";
import {
runningApps,
processCounter,
- killProcess,
removeAppIfCurrentProcess,
+ stopAppByInfo,
+ removeDockerVolumesForApp,
} from "../utils/process_manager";
import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings";
@@ -50,6 +51,8 @@ import { isServerFunction } from "@/supabase_admin/supabase_utils";
import { getVercelTeamSlug } from "../utils/vercel_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
+const DEFAULT_COMMAND =
+ "(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
async function copyDir(
source: string,
destination: string,
@@ -97,14 +100,28 @@ async function executeApp({
proxyWorker.terminate();
proxyWorker = null;
}
- await executeAppLocalNode({
- appPath,
- appId,
- event,
- isNeon,
- installCommand,
- startCommand,
- });
+ const settings = readSettings();
+ const runtimeMode = settings.runtimeMode2 ?? "host";
+
+ if (runtimeMode === "docker") {
+ await executeAppInDocker({
+ appPath,
+ appId,
+ event,
+ isNeon,
+ installCommand,
+ startCommand,
+ });
+ } else {
+ await executeAppLocalNode({
+ appPath,
+ appId,
+ event,
+ isNeon,
+ installCommand,
+ startCommand,
+ });
+ }
}
async function executeAppLocalNode({
@@ -122,12 +139,7 @@ async function executeAppLocalNode({
installCommand?: string | null;
startCommand?: string | null;
}): Promise {
- const defaultCommand =
- "(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
- const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim();
- const command = hasCustomCommands
- ? `${installCommand!.trim()} && ${startCommand!.trim()}`
- : defaultCommand;
+ const command = getCommand({ installCommand, startCommand });
const spawnedProcess = spawn(command, [], {
cwd: appPath,
shell: true,
@@ -153,8 +165,28 @@ async function executeAppLocalNode({
runningApps.set(appId, {
process: spawnedProcess,
processId: currentProcessId,
+ isDocker: false,
});
+ listenToProcess({
+ process: spawnedProcess,
+ appId,
+ isNeon,
+ event,
+ });
+}
+
+function listenToProcess({
+ process: spawnedProcess,
+ appId,
+ isNeon,
+ event,
+}: {
+ process: ChildProcess;
+ appId: number;
+ isNeon: boolean;
+ event: Electron.IpcMainInvokeEvent;
+}) {
// Log output
spawnedProcess.stdout?.on("data", async (data) => {
const message = util.stripVTControlCharacters(data.toString());
@@ -169,7 +201,7 @@ async function executeAppLocalNode({
// get this template and 2) it's safer to do this with Neon apps because
// their databases have point in time restore built-in.
if (isNeon && message.includes("created or renamed from another")) {
- spawnedProcess.stdin.write(`\r\n`);
+ spawnedProcess.stdin?.write(`\r\n`);
logger.info(
`App ${appId} (PID: ${spawnedProcess.pid}) wrote enter to stdin to automatically respond to drizzle push input`,
);
@@ -239,6 +271,169 @@ async function executeAppLocalNode({
});
}
+async function executeAppInDocker({
+ appPath,
+ appId,
+ event,
+ isNeon,
+ installCommand,
+ startCommand,
+}: {
+ appPath: string;
+ appId: number;
+ event: Electron.IpcMainInvokeEvent;
+ isNeon: boolean;
+ installCommand?: string | null;
+ startCommand?: string | null;
+}): Promise {
+ const containerName = `dyad-app-${appId}`;
+
+ // First, check if Docker is available
+ try {
+ await new Promise((resolve, reject) => {
+ const checkDocker = spawn("docker", ["--version"], { stdio: "pipe" });
+ checkDocker.on("close", (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error("Docker is not available"));
+ }
+ });
+ checkDocker.on("error", () => {
+ reject(new Error("Docker is not available"));
+ });
+ });
+ } catch {
+ throw new Error(
+ "Docker is required but not available. Please install Docker Desktop and ensure it's running.",
+ );
+ }
+
+ // Stop and remove any existing container with the same name
+ try {
+ await new Promise((resolve) => {
+ const stopContainer = spawn("docker", ["stop", containerName], {
+ stdio: "pipe",
+ });
+ stopContainer.on("close", () => {
+ const removeContainer = spawn("docker", ["rm", containerName], {
+ stdio: "pipe",
+ });
+ removeContainer.on("close", () => resolve());
+ removeContainer.on("error", () => resolve()); // Container might not exist
+ });
+ stopContainer.on("error", () => resolve()); // Container might not exist
+ });
+ } catch (error) {
+ logger.info(
+ `Docker container ${containerName} not found. Ignoring error: ${error}`,
+ );
+ }
+
+ // Create a Dockerfile in the app directory if it doesn't exist
+ const dockerfilePath = path.join(appPath, "Dockerfile.dyad");
+ if (!fs.existsSync(dockerfilePath)) {
+ const dockerfileContent = `FROM node:22-alpine
+
+# Install pnpm
+RUN npm install -g pnpm
+`;
+
+ try {
+ await fsPromises.writeFile(dockerfilePath, dockerfileContent, "utf-8");
+ } catch (error) {
+ logger.error(`Failed to create Dockerfile for app ${appId}:`, error);
+ throw new Error(`Failed to create Dockerfile: ${error}`);
+ }
+ }
+
+ // Build the Docker image
+ const buildProcess = spawn(
+ "docker",
+ ["build", "-f", "Dockerfile.dyad", "-t", `dyad-app-${appId}`, "."],
+ {
+ cwd: appPath,
+ stdio: "pipe",
+ },
+ );
+
+ let buildError = "";
+ buildProcess.stderr?.on("data", (data) => {
+ buildError += data.toString();
+ });
+
+ await new Promise((resolve, reject) => {
+ buildProcess.on("close", (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`Docker build failed: ${buildError}`));
+ }
+ });
+ buildProcess.on("error", (err) => {
+ reject(new Error(`Docker build process error: ${err.message}`));
+ });
+ });
+
+ // Run the Docker container
+ const process = spawn(
+ "docker",
+ [
+ "run",
+ "--rm",
+ "--name",
+ containerName,
+ "-p",
+ "32100:32100",
+ "-v",
+ `${appPath}:/app`,
+ "-v",
+ `dyad-pnpm-${appId}:/app/.pnpm-store`,
+ "-e",
+ "PNPM_STORE_PATH=/app/.pnpm-store",
+ "-w",
+ "/app",
+ `dyad-app-${appId}`,
+ "sh",
+ "-c",
+ getCommand({ installCommand, startCommand }),
+ ],
+ {
+ stdio: "pipe",
+ detached: false,
+ },
+ );
+
+ // 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 Docker container 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,
+ isDocker: true,
+ containerName,
+ });
+
+ listenToProcess({
+ process,
+ appId,
+ isNeon,
+ event,
+ });
+}
+
// Helper to kill process on a specific port (cross-platform, using kill-port)
async function killProcessOnPort(port: number): Promise {
try {
@@ -248,6 +443,49 @@ async function killProcessOnPort(port: number): Promise {
}
}
+// Helper to stop any Docker containers publishing a given host port
+async function stopDockerContainersOnPort(port: number): Promise {
+ try {
+ // List container IDs that publish the given port
+ const list = spawn("docker", ["ps", "--filter", `publish=${port}`, "-q"], {
+ stdio: "pipe",
+ });
+
+ let stdout = "";
+ list.stdout?.on("data", (data) => {
+ stdout += data.toString();
+ });
+
+ await new Promise((resolve) => {
+ list.on("close", () => resolve());
+ list.on("error", () => resolve());
+ });
+
+ const containerIds = stdout
+ .split("\n")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ if (containerIds.length === 0) {
+ return;
+ }
+
+ // Stop each container best-effort
+ await Promise.all(
+ containerIds.map(
+ (id) =>
+ new Promise((resolve) => {
+ const stop = spawn("docker", ["stop", id], { stdio: "pipe" });
+ stop.on("close", () => resolve());
+ stop.on("error", () => resolve());
+ }),
+ ),
+ );
+ } catch (e) {
+ logger.warn(`Failed stopping Docker containers on port ${port}: ${e}`);
+ }
+}
+
export function registerAppHandlers() {
handle("restart-dyad", async () => {
app.relaunch();
@@ -523,8 +761,8 @@ export function registerAppHandlers() {
const appPath = getDyadAppPath(app.path);
try {
- // Kill any orphaned process on port 32100 (in case previous run left it)
- await killProcessOnPort(32100);
+ // There may have been a previous run that left a process on port 32100.
+ await cleanUpPort(32100);
await executeApp({
appPath,
appId,
@@ -581,8 +819,7 @@ export function registerAppHandlers() {
}
try {
- // Use the killProcess utility to stop the process
- await killProcess(process);
+ await stopAppByInfo(appId, appInfo);
// Now, safely remove the app from the map *after* confirming closure
removeAppIfCurrentProcess(appId, process);
@@ -616,19 +853,17 @@ export function registerAppHandlers() {
// First stop the app if it's running
const appInfo = runningApps.get(appId);
if (appInfo) {
- const { process, processId } = appInfo;
+ const { processId } = appInfo;
logger.log(
`Stopping app ${appId} (processId ${processId}) before restart`,
);
-
- await killProcess(process);
- runningApps.delete(appId);
+ await stopAppByInfo(appId, appInfo);
} 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);
+ // There may have been a previous run that left a process on port 32100.
+ await cleanUpPort(32100);
// Now start the app again
const app = await db.query.apps.findFirst({
@@ -643,6 +878,9 @@ export function registerAppHandlers() {
// Remove node_modules if requested
if (removeNodeModules) {
+ const settings = readSettings();
+ const runtimeMode = settings.runtimeMode2 ?? "host";
+
const nodeModulesPath = path.join(appPath, "node_modules");
logger.log(
`Removing node_modules for app ${appId} at ${nodeModulesPath}`,
@@ -656,6 +894,24 @@ export function registerAppHandlers() {
} else {
logger.log(`No node_modules directory found for app ${appId}`);
}
+
+ // If running in Docker mode, also remove container volumes so deps reinstall freshly
+ if (runtimeMode === "docker") {
+ logger.log(
+ `Docker mode detected for app ${appId}. Removing Docker volumes dyad-pnpm-${appId}...`,
+ );
+ try {
+ await removeDockerVolumesForApp(appId);
+ logger.log(
+ `Removed Docker volumes for app ${appId} (dyad-pnpm-${appId}).`,
+ );
+ } catch (e) {
+ // Best-effort cleanup; log and continue
+ logger.warn(
+ `Failed to remove Docker volumes for app ${appId}. Continuing: ${e}`,
+ );
+ }
+ }
}
logger.debug(
@@ -786,8 +1042,7 @@ export function registerAppHandlers() {
const appInfo = runningApps.get(appId)!;
try {
logger.log(`Stopping app ${appId} before deletion.`); // Adjusted log
- await killProcess(appInfo.process);
- runningApps.delete(appId);
+ await stopAppByInfo(appId, appInfo);
} catch (error: any) {
logger.error(`Error stopping app ${appId} before deletion:`, error); // Adjusted log
// Continue with deletion even if stopping fails
@@ -860,8 +1115,7 @@ export function registerAppHandlers() {
if (runningApps.has(appId)) {
const appInfo = runningApps.get(appId)!;
try {
- await killProcess(appInfo.process);
- runningApps.delete(appId);
+ await stopAppByInfo(appId, appInfo);
} catch (error: any) {
logger.error(`Error stopping app ${appId} before renaming:`, error);
throw new Error(
@@ -957,8 +1211,7 @@ export function registerAppHandlers() {
for (const appId of runningAppIds) {
try {
const appInfo = runningApps.get(appId)!;
- await killProcess(appInfo.process);
- runningApps.delete(appId);
+ await stopAppByInfo(appId, appInfo);
} catch (error) {
logger.error(`Error stopping app ${appId} during reset:`, error);
// Continue with reset even if stopping fails
@@ -1088,3 +1341,25 @@ export function registerAppHandlers() {
},
);
}
+
+function getCommand({
+ installCommand,
+ startCommand,
+}: {
+ installCommand?: string | null;
+ startCommand?: string | null;
+}) {
+ const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim();
+ return hasCustomCommands
+ ? `${installCommand!.trim()} && ${startCommand!.trim()}`
+ : DEFAULT_COMMAND;
+}
+
+async function cleanUpPort(port: number) {
+ const settings = readSettings();
+ if (settings.runtimeMode2 === "docker") {
+ await stopDockerContainersOnPort(port);
+ } else {
+ await killProcessOnPort(port);
+ }
+}
diff --git a/src/ipc/utils/process_manager.ts b/src/ipc/utils/process_manager.ts
index 6b4233b..3dff2f8 100644
--- a/src/ipc/utils/process_manager.ts
+++ b/src/ipc/utils/process_manager.ts
@@ -1,10 +1,12 @@
-import { ChildProcess } from "node:child_process";
+import { ChildProcess, spawn } from "node:child_process";
import treeKill from "tree-kill";
// Define a type for the value stored in runningApps
export interface RunningAppInfo {
process: ChildProcess;
processId: number;
+ isDocker: boolean;
+ containerName?: string;
}
// Store running app processes
@@ -81,6 +83,49 @@ export function killProcess(process: ChildProcess): Promise {
});
}
+/**
+ * Gracefully stops a Docker container by name. Resolves even if the container doesn't exist.
+ */
+export function stopDockerContainer(containerName: string): Promise {
+ return new Promise((resolve) => {
+ const stop = spawn("docker", ["stop", containerName], { stdio: "pipe" });
+ stop.on("close", () => resolve());
+ stop.on("error", () => resolve());
+ });
+}
+
+/**
+ * Removes Docker named volumes used for an app's dependencies.
+ * Best-effort: resolves even if volumes don't exist.
+ */
+export function removeDockerVolumesForApp(appId: number): Promise {
+ return new Promise((resolve) => {
+ const pnpmVolume = `dyad-pnpm-${appId}`;
+
+ const rm = spawn("docker", ["volume", "rm", "-f", pnpmVolume], {
+ stdio: "pipe",
+ });
+ rm.on("close", () => resolve());
+ rm.on("error", () => resolve());
+ });
+}
+
+/**
+ * Stops an app based on its RunningAppInfo (container vs host) and removes it from the running map.
+ */
+export async function stopAppByInfo(
+ appId: number,
+ appInfo: RunningAppInfo,
+): Promise {
+ if (appInfo.isDocker) {
+ const containerName = appInfo.containerName || `dyad-app-${appId}`;
+ await stopDockerContainer(containerName);
+ } else {
+ await killProcess(appInfo.process);
+ }
+ runningApps.delete(appId);
+}
+
/**
* Removes an app from the running apps map if it's the current process
* @param appId The app ID
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index e0b6aab..37bd236 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -69,6 +69,9 @@ export type ProviderSetting = z.infer;
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
export type RuntimeMode = z.infer;
+export const RuntimeMode2Schema = z.enum(["host", "docker"]);
+export type RuntimeMode2 = z.infer;
+
export const ChatModeSchema = z.enum(["build", "ask"]);
export type ChatMode = z.infer;
@@ -170,6 +173,7 @@ export const UserSettingsSchema = z.object({
enableNativeGit: z.boolean().optional(),
enableAutoUpdate: z.boolean(),
releaseChannel: ReleaseChannelSchema,
+ runtimeMode2: RuntimeMode2Schema.optional(),
////////////////////////////////
// E2E TESTING ONLY.
diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx
index d278abf..5303fe4 100644
--- a/src/pages/settings.tsx
+++ b/src/pages/settings.tsx
@@ -23,6 +23,7 @@ import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch";
import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch";
import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector";
import { NeonIntegration } from "@/components/NeonIntegration";
+import { RuntimeModeSelector } from "@/components/RuntimeModeSelector";
export default function SettingsPage() {
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
@@ -256,6 +257,10 @@ export function GeneralSettings({ appVersion }: { appVersion: string | null }) {
+
+
+
+
App Version: