diff --git a/src/app/TitleBar.tsx b/src/app/TitleBar.tsx index 98e50de..16bf37f 100644 --- a/src/app/TitleBar.tsx +++ b/src/app/TitleBar.tsx @@ -8,9 +8,9 @@ import { RuntimeMode } from "@/lib/schemas"; function formatRuntimeMode(runtimeMode: RuntimeMode | undefined) { switch (runtimeMode) { case "web-sandbox": - return "Sandbox"; + return "Sandboxed"; case "local-node": - return "Local"; + return "Full Access"; default: return runtimeMode; } @@ -33,7 +33,7 @@ export const TitleBar = () => {
{displayText}
- {formatRuntimeMode(settings?.runtimeMode)} runtime + {formatRuntimeMode(settings?.runtimeMode)} mode
Dyad
diff --git a/src/components/SetupRuntimeFlow.tsx b/src/components/SetupRuntimeFlow.tsx index 1703af3..02f790b 100644 --- a/src/components/SetupRuntimeFlow.tsx +++ b/src/components/SetupRuntimeFlow.tsx @@ -1,21 +1,48 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { IpcClient } from "@/ipc/ipc_client"; import { useSettings } from "@/hooks/useSettings"; // Assuming useSettings provides a refresh function import { RuntimeMode } from "@/lib/schemas"; +import { ExternalLink } from "lucide-react"; interface SetupRuntimeFlowProps { - onRuntimeSelected: (mode: RuntimeMode) => Promise; + hideIntroText?: boolean; } -export function SetupRuntimeFlow({ onRuntimeSelected }: SetupRuntimeFlowProps) { - const [isLoading, setIsLoading] = useState(null); +export function SetupRuntimeFlow({ hideIntroText }: SetupRuntimeFlowProps) { + const [isLoading, setIsLoading] = useState( + null + ); + const [showNodeInstallPrompt, setShowNodeInstallPrompt] = useState(false); + const [nodeVersion, setNodeVersion] = useState(null); + const [npmVersion, setNpmVersion] = useState(null); + const [downloadClicked, setDownloadClicked] = useState(false); + const { updateSettings } = useSettings(); + + // Pre-check Node.js status on component mount (optional but good UX) + useEffect(() => { + const checkNode = async () => { + try { + const status = await IpcClient.getInstance().getNodejsStatus(); + setNodeVersion(status.nodeVersion); + setNpmVersion(status.npmVersion); + } catch (error) { + console.error("Failed to check Node.js status:", error); + // Assume not installed if check fails + setNodeVersion(null); + setNpmVersion(null); + } + }; + checkNode(); + }, []); const handleSelect = async (mode: RuntimeMode) => { + if (isLoading) return; // Prevent double clicks + setIsLoading(mode); try { - await onRuntimeSelected(mode); - // No need to setIsLoading(null) as the component will unmount on success + await updateSettings({ runtimeMode: mode }); + // Component likely unmounts on success } catch (error) { console.error("Failed to set runtime mode:", error); alert( @@ -27,18 +54,45 @@ export function SetupRuntimeFlow({ onRuntimeSelected }: SetupRuntimeFlowProps) { } }; + const handleLocalNodeClick = async () => { + if (isLoading) return; + + setIsLoading("check"); + try { + if (nodeVersion && npmVersion) { + // Node and npm found, proceed directly + handleSelect("local-node"); + } else { + // Node or npm not found, show prompt + setShowNodeInstallPrompt(true); + setIsLoading(null); + } + } catch (error) { + console.error("Failed to check Node.js status on click:", error); + // Show prompt if check fails + setShowNodeInstallPrompt(true); + setIsLoading(null); + } + }; + return ( -
-

Welcome to Dyad

-

- You can start building apps with AI in a moment, but first pick how you - want to run these apps. You can always change your mind later. -

+
+ {!hideIntroText && ( + <> +

+ Welcome to Dyad +

+

+ Before you start building, choose how your apps will run.
+ Don’t worry — you can change this later anytime. +

+ + )}
+ ) : ( + + )} +
+ )} + +

Warning: this will run AI-generated code directly on your - computer, which could put your system at risk. + computer, which could put your computer at risk.

diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 95218a0..ef27065 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -209,7 +209,59 @@ async function executeAppLocalNode({ }); } +function checkCommandExists(command: string): Promise { + return new Promise((resolve) => { + let output = ""; + const process = spawn(command, ["--version"], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], // ignore stdin, pipe stdout/stderr + }); + + process.stdout?.on("data", (data) => { + output += data.toString(); + }); + + process.stderr?.on("data", (data) => { + // Log stderr but don't treat it as a failure unless the exit code is non-zero + console.warn( + `Stderr from "${command} --version": ${data.toString().trim()}` + ); + }); + + process.on("error", (error) => { + console.error(`Error executing command "${command}":`, error.message); + resolve(null); // Command execution failed + }); + + process.on("close", (code) => { + if (code === 0) { + resolve(output.trim()); // Command succeeded, return trimmed output + } else { + console.error( + `Command "${command} --version" failed with code ${code}` + ); + resolve(null); // Command failed + } + }); + }); +} + export function registerAppHandlers() { + ipcMain.handle( + "nodejs-status", + async (): Promise<{ + nodeVersion: string | null; + npmVersion: string | null; + }> => { + // Run checks in parallel + const [nodeVersion, npmVersion] = await Promise.all([ + checkCommandExists("node"), + checkCommandExists("npm"), + ]); + return { nodeVersion, npmVersion }; + } + ); + ipcMain.handle( "get-app-sandbox-config", async (_, { appId }: { appId: number }): Promise => { diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 48221ea..5187b39 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -488,4 +488,21 @@ export class IpcClient { throw error; } } + + // Check Node.js and npm status + public async getNodejsStatus(): Promise<{ + nodeVersion: string | null; + npmVersion: string | null; + }> { + try { + const result = await this.ipcRenderer.invoke("nodejs-status"); + return result as { + nodeVersion: string | null; + npmVersion: string | null; + }; + } catch (error) { + showError(error); + throw error; + } + } } diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 24421d6..4bc8908 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -20,7 +20,7 @@ export default function HomePage() { const search = useSearch({ from: "/" }); const setSelectedAppId = useSetAtom(selectedAppIdAtom); const { refreshApps } = useLoadApps(); - const { settings, isAnyProviderSetup, updateSettings } = useSettings(); + const { settings, isAnyProviderSetup } = useSettings(); const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const [isLoading, setIsLoading] = useState(false); const { streamMessage } = useStreamChat(); @@ -35,10 +35,6 @@ export default function HomePage() { } }, [appId, navigate]); - const handleSetRuntimeMode = async (mode: RuntimeMode) => { - await updateSettings({ runtimeMode: mode }); - }; - const handleSubmit = async () => { if (!inputValue.trim()) return; @@ -90,7 +86,7 @@ export default function HomePage() { // Runtime Setup Flow // Render this only if runtimeMode is not set in settings if (settings?.runtimeMode === "unset") { - return ; + return ; } // Main Home Page Content (Rendered only if runtimeMode is set) diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 568671c..e03b99a 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useTheme } from "../contexts/ThemeContext"; import { ProviderSettingsGrid } from "@/components/ProviderSettings"; import ConfirmationDialog from "@/components/ConfirmationDialog"; @@ -6,96 +6,8 @@ import { IpcClient } from "@/ipc/ipc_client"; import { showSuccess, showError } from "@/lib/toast"; import { useSettings } from "@/hooks/useSettings"; import { RuntimeMode } from "@/lib/schemas"; - -// Helper component for runtime option buttons -function RuntimeOptionButton({ - title, - description, - badge, - warning, - isSelected, - isLoading, - onClick, - disabled, -}: { - title: string; - description: string; - badge?: string; - warning?: string; - isSelected: boolean; - isLoading: boolean; - onClick: () => void; - disabled: boolean; -}) { - return ( - - ); -} +import { Button } from "@/components/ui/button"; +import { ExternalLink } from "lucide-react"; export default function SettingsPage() { const { theme, setTheme } = useTheme(); @@ -107,7 +19,28 @@ export default function SettingsPage() { } = useSettings(); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); - const [isUpdatingRuntime, setIsUpdatingRuntime] = useState(false); + const [isUpdatingRuntime, setIsUpdatingRuntime] = useState< + RuntimeMode | "check" | null + >(null); + const [showNodeInstallPrompt, setShowNodeInstallPrompt] = useState(false); + const [nodeVersion, setNodeVersion] = useState(null); + const [npmVersion, setNpmVersion] = useState(null); + const [downloadClicked, setDownloadClicked] = useState(false); + + useEffect(() => { + const checkNode = async () => { + try { + const status = await IpcClient.getInstance().getNodejsStatus(); + setNodeVersion(status.nodeVersion); + setNpmVersion(status.npmVersion); + } catch (error) { + console.error("Failed to check Node.js status:", error); + setNodeVersion(null); + setNpmVersion(null); + } + }; + checkNode(); + }, []); const handleResetEverything = async () => { setIsResetting(true); @@ -132,7 +65,10 @@ export default function SettingsPage() { const handleRuntimeChange = async (newMode: RuntimeMode) => { if (newMode === settings?.runtimeMode || isUpdatingRuntime) return; - setIsUpdatingRuntime(true); + setIsUpdatingRuntime(newMode); + setShowNodeInstallPrompt(false); + setDownloadClicked(false); + try { await updateSettings({ runtimeMode: newMode }); showSuccess("Runtime mode updated successfully."); @@ -144,7 +80,34 @@ export default function SettingsPage() { : "Failed to update runtime mode." ); } finally { - setIsUpdatingRuntime(false); + setIsUpdatingRuntime(null); + } + }; + + const handleLocalNodeClick = async () => { + if (isUpdatingRuntime) return; + + if (nodeVersion && npmVersion) { + handleRuntimeChange("local-node"); + return; + } + + setIsUpdatingRuntime("check"); + try { + const status = await IpcClient.getInstance().getNodejsStatus(); + setNodeVersion(status.nodeVersion); + setNpmVersion(status.npmVersion); + if (status.nodeVersion && status.npmVersion) { + handleRuntimeChange("local-node"); + } else { + setShowNodeInstallPrompt(true); + setIsUpdatingRuntime(null); + } + } catch (error) { + console.error("Failed to check Node.js status on click:", error); + setShowNodeInstallPrompt(true); + setIsUpdatingRuntime(null); + showError("Could not verify Node.js installation."); } }; @@ -201,8 +164,8 @@ export default function SettingsPage() { Runtime Environment

- Choose how app code is executed. This affects performance and - security. + Choose how app code is executed. This affects performance, + security, and capabilities.

{settingsLoading ? ( @@ -235,28 +198,207 @@ export default function SettingsPage() {

) : (
- handleRuntimeChange("web-sandbox")} - disabled={isUpdatingRuntime} - /> - handleRuntimeChange("local-node")} - disabled={isUpdatingRuntime} - /> + > + {isUpdatingRuntime === "web-sandbox" && ( + + + + + )} + + Recommended for beginners + +
+

+ Sandboxed Mode +

+
+

+ Apps run in a protected environment within your browser. +

+

Does not support advanced apps.

+
+
+ + + + ) : ( +

+ Node.js download page opened. +

+ )} + +
+ )} + +

+ Warning: This mode runs AI-generated code directly on + your computer, which can be risky. Only use code from + trusted sources or review it carefully. +

+ + + )} diff --git a/src/preload.ts b/src/preload.ts index 1670ed2..6f2151a 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -31,6 +31,7 @@ const validInvokeChannels = [ "get-env-vars", "open-external-url", "reset-all", + "nodejs-status", ] as const; // Add valid receive channels