diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36d..fc5ae9f0c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.vercel diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d39ae07bc..381c91999 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,9 +2,14 @@ import { useEffect, useState } from "react"; import { StreamPage } from "./pages/StreamPage"; import { Toaster } from "./components/ui/sonner"; import { PipelinesProvider } from "./contexts/PipelinesContext"; -import { CloudProvider } from "./lib/cloudContext"; +import { CloudProvider } from "./lib/directCloudContext"; import { CloudStatusProvider } from "./hooks/useCloudStatus"; -import { handleOAuthCallback, initElectronAuthListener } from "./lib/auth"; +import { + handleOAuthCallback, + initElectronAuthListener, + getDaydreamUserId, + isAuthenticated, +} from "./lib/auth"; import { toast } from "sonner"; import "./index.css"; @@ -23,6 +28,22 @@ type AuthResult = function App() { const [isHandlingAuth, setIsHandlingAuth] = useState(true); const [authResult, setAuthResult] = useState(null); + // Track auth state for direct cloud mode - only connect when authenticated + const [isSignedIn, setIsSignedIn] = useState(isAuthenticated()); + + // Listen for auth changes to update cloud connection + useEffect(() => { + const handleAuthChange = () => { + setIsSignedIn(isAuthenticated()); + }; + + window.addEventListener("daydream-auth-change", handleAuthChange); + window.addEventListener("daydream-auth-success", handleAuthChange); + return () => { + window.removeEventListener("daydream-auth-change", handleAuthChange); + window.removeEventListener("daydream-auth-success", handleAuthChange); + }; + }, []); useEffect(() => { // Initialize Electron auth callback listener (if running in Electron) @@ -92,14 +113,22 @@ function App() { ); } + // Get user ID for direct cloud mode (sent to cloud for log correlation) + const userId = getDaydreamUserId() ?? undefined; + + // Only connect to cloud if authenticated (in direct cloud mode) + // This prevents connecting before login and ensures we see the "connecting" state + const shouldConnect = !CLOUD_WS_URL || isSignedIn; + const effectiveWsUrl = shouldConnect ? CLOUD_WS_URL : undefined; + return ( - - - - - + + + + + - + ); } diff --git a/frontend/src/components/ComplexFields.tsx b/frontend/src/components/ComplexFields.tsx index 77e162d32..058a303ee 100644 --- a/frontend/src/components/ComplexFields.tsx +++ b/frontend/src/components/ComplexFields.tsx @@ -71,7 +71,7 @@ export interface SchemaComplexFieldContext { supportsKvCacheBias?: boolean; isStreaming?: boolean; isLoading?: boolean; - isCloudMode?: boolean; + isDirectCloudMode?: boolean; /** Per-field overrides for schema-driven fields (e.g. image path). */ schemaFieldOverrides?: Record; onSchemaFieldOverrideChange?: ( @@ -210,7 +210,7 @@ export function SchemaComplexField({ ); } - if (component === "lora" && !rendered.has("lora") && !ctx.isCloudMode) { + if (component === "lora" && !rendered.has("lora") && !ctx.isDirectCloudMode) { rendered.add("lora"); return (
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 27941f9eb..bc8b00106 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -4,6 +4,7 @@ import { Button } from "./ui/button"; import { SettingsDialog } from "./SettingsDialog"; import { toast } from "sonner"; import { useCloudStatus } from "../hooks/useCloudStatus"; +import { useCloudContext } from "../lib/directCloudContext"; interface HeaderProps { className?: string; @@ -12,6 +13,8 @@ interface HeaderProps { // External settings tab control openSettingsTab?: string | null; onSettingsTabOpened?: () => void; + // Auth state for direct cloud mode + isSignedIn?: boolean; } export function Header({ @@ -20,17 +23,28 @@ export function Header({ cloudDisabled, openSettingsTab, onSettingsTabOpened, + isSignedIn = true, }: HeaderProps) { const [settingsOpen, setSettingsOpen] = useState(false); + const [initialTab, setInitialTab] = useState< "general" | "account" | "api-keys" | "plugins" >("general"); const [initialPluginPath, setInitialPluginPath] = useState(""); + // Get direct cloud mode from context (static, based on env var) + const { isDirectCloudMode } = useCloudContext(); + // Use shared cloud status hook - single source of truth const { isConnected, isConnecting, lastCloseCode, lastCloseReason } = useCloudStatus(); + // In direct cloud mode, determine if we need to force the settings dialog to stay open + // Require both signed in AND connected to cloud + const requiresAuth = isDirectCloudMode && !isSignedIn; + const requiresConnection = isDirectCloudMode && isSignedIn && !isConnected; + const preventClose = requiresAuth || requiresConnection; + // Track the last close code we've shown a toast for to avoid duplicates const lastNotifiedCloseCodeRef = useRef(null); @@ -103,7 +117,32 @@ export function Header({ } }, []); + // Force settings dialog open when in direct cloud mode and not authenticated/connected + useEffect(() => { + if (preventClose) { + setInitialTab("account"); + setSettingsOpen(true); + } + }, [preventClose]); + + // Track previous preventClose state to detect connection completion + const prevPreventCloseRef = useRef(preventClose); + + // Auto-close settings dialog when connection completes (preventClose goes from true to false) + useEffect(() => { + if (prevPreventCloseRef.current && !preventClose && settingsOpen) { + // Connection just completed - auto-close the dialog + setSettingsOpen(false); + setInitialTab("general"); + } + prevPreventCloseRef.current = preventClose; + }, [preventClose, settingsOpen]); + const handleClose = () => { + // Prevent closing if auth or connection is required + if (preventClose) { + return; + } setSettingsOpen(false); setInitialTab("general"); setInitialPluginPath(""); @@ -160,6 +199,8 @@ export function Header({ initialPluginPath={initialPluginPath} onPipelinesRefresh={onPipelinesRefresh} cloudDisabled={cloudDisabled} + preventClose={preventClose} + isConnecting={isConnecting} /> ); diff --git a/frontend/src/components/ImageManager.tsx b/frontend/src/components/ImageManager.tsx index bd0e406f5..f8f5e7326 100644 --- a/frontend/src/components/ImageManager.tsx +++ b/frontend/src/components/ImageManager.tsx @@ -1,9 +1,45 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Plus, X } from "lucide-react"; import { LabelWithTooltip } from "./ui/label-with-tooltip"; +import { useApi } from "../hooks/useApi"; import { getAssetUrl } from "../lib/api"; import { MediaPicker } from "./MediaPicker"; +/** Helper component that loads an asset image, using data URL in cloud mode */ +function AssetImage({ + assetPath, + alt, + isDirectCloudMode, + getAssetDataUrl, +}: { + assetPath: string; + alt: string; + isDirectCloudMode: boolean; + getAssetDataUrl: (path: string) => Promise; +}) { + const [src, setSrc] = useState(null); + + useEffect(() => { + if (isDirectCloudMode) { + getAssetDataUrl(assetPath) + .then(setSrc) + .catch(() => setSrc(null)); + } else { + setSrc(getAssetUrl(assetPath)); + } + }, [assetPath, isDirectCloudMode, getAssetDataUrl]); + + if (!src) { + return ( +
+
+
+ ); + } + + return {alt}; +} + interface ImageManagerProps { images: string[]; onImagesChange: (images: string[]) => void; @@ -30,6 +66,7 @@ export function ImageManager({ hideLabel = false, singleColumn, }: ImageManagerProps) { + const { isDirectCloudMode, getAssetDataUrl } = useApi(); const [isMediaPickerOpen, setIsMediaPickerOpen] = useState(false); const handleAddImage = (imagePath: string) => { @@ -80,10 +117,11 @@ export function ImageManager({ key={index} className="aspect-square border rounded-lg overflow-hidden relative group" > - {`${label} ))} diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 003550b64..e6030ab3a 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -7,6 +7,7 @@ import { GeneralTab } from "./settings/GeneralTab"; import { PluginsTab } from "./settings/PluginsTab"; import { ReportBugDialog } from "./ReportBugDialog"; import { usePipelinesContext } from "@/contexts/PipelinesContext"; +import { useCloudContext } from "@/lib/directCloudContext"; import type { InstalledPlugin } from "@/types/settings"; import { listPlugins, @@ -14,8 +15,8 @@ import { uninstallPlugin, restartServer, waitForServer, - getServerInfo, } from "@/lib/api"; +import { useApi } from "@/hooks/useApi"; import { toast } from "sonner"; interface SettingsDialogProps { @@ -25,6 +26,10 @@ interface SettingsDialogProps { initialPluginPath?: string; onPipelinesRefresh?: () => Promise; cloudDisabled?: boolean; + /** When true, the dialog cannot be closed (used for auth gating in direct cloud mode) */ + preventClose?: boolean; + /** When true, shows connecting state in the account tab */ + isConnecting?: boolean; } const isLocalPath = (spec: string): boolean => { @@ -61,8 +66,12 @@ export function SettingsDialog({ initialPluginPath = "", onPipelinesRefresh, cloudDisabled, + preventClose = false, + isConnecting = false, }: SettingsDialogProps) { const { refetch: refetchPipelines } = usePipelinesContext(); + const { isDirectCloudMode } = useCloudContext(); + const { getServerInfo } = useApi(); const [modelsDirectory, setModelsDirectory] = useState( "~/.daydream-scope/models" ); @@ -315,8 +324,27 @@ export function SettingsDialog({ }; return ( - !isOpen && onClose()}> - + { + // Prevent closing if preventClose is true + if (!isOpen && preventClose) { + return; + } + if (!isOpen) { + onClose(); + } + }} + > + Settings @@ -339,18 +367,23 @@ export function SettingsDialog({ > Account - - API Keys - - - Plugins - + {/* Hide API Keys and Plugins tabs in direct cloud mode - no local backend */} + {!isDirectCloudMode && ( + <> + + API Keys + + + Plugins + + + )}
@@ -363,31 +396,39 @@ export function SettingsDialog({ onModelsDirectoryChange={handleModelsDirectoryChange} onLogsDirectoryChange={handleLogsDirectoryChange} onReportBug={() => setReportBugOpen(true)} + isDirectCloudMode={isDirectCloudMode} /> - - - - - - + {/* Hide API Keys and Plugins content in direct cloud mode */} + {!isDirectCloudMode && ( + <> + + + + + + + + )}
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 3125e22ca..1e88c580d 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -112,7 +112,7 @@ interface SettingsPanelProps { value: unknown, isRuntimeParam?: boolean ) => void; - isCloudMode?: boolean; + isDirectCloudMode?: boolean; } export function SettingsPanel({ @@ -158,7 +158,7 @@ export function SettingsPanel({ onPostprocessorIdsChange, schemaFieldOverrides, onSchemaFieldOverrideChange, - isCloudMode = false, + isDirectCloudMode = false, }: SettingsPanelProps) { // Local slider state management hooks const noiseScaleSlider = useLocalSliderValue(noiseScale, onNoiseScaleChange); @@ -530,7 +530,7 @@ export function SettingsPanel({ supportsKvCacheBias: pipelines?.[pipelineId]?.supportsKvCacheBias, isStreaming, isLoading, - isCloudMode, + isDirectCloudMode, schemaFieldOverrides, onSchemaFieldOverrideChange, }; @@ -674,7 +674,7 @@ export function SettingsPanel({
)} - {currentPipeline?.supportsLoRA && !isCloudMode && ( + {currentPipeline?.supportsLoRA && !isDirectCloudMode && (
Promise; /** Disable the toggle (e.g., when streaming) */ cloudDisabled?: boolean; + /** Whether the cloud is currently connecting */ + isConnecting?: boolean; + /** Whether the dialog cannot be closed (auth gating mode) */ + preventClose?: boolean; } export function AccountTab({ onPipelinesRefresh, cloudDisabled, + isConnecting, + preventClose, }: AccountTabProps) { return (
); diff --git a/frontend/src/components/settings/DaydreamAccountSection.tsx b/frontend/src/components/settings/DaydreamAccountSection.tsx index 01df7ffb1..694e09112 100644 --- a/frontend/src/components/settings/DaydreamAccountSection.tsx +++ b/frontend/src/components/settings/DaydreamAccountSection.tsx @@ -21,17 +21,24 @@ import { shouldShowCloudMode, } from "../../lib/auth"; import { useCloudStatus } from "../../hooks/useCloudStatus"; +import { useCloudContext } from "../../lib/directCloudContext"; interface DaydreamAccountSectionProps { /** Callback to refresh pipeline list after cloud mode toggle */ onPipelinesRefresh?: () => Promise; /** Disable the toggle (e.g., when streaming) */ disabled?: boolean; + /** Whether the cloud is currently connecting */ + isConnecting?: boolean; + /** Whether the dialog cannot be closed (auth gating mode) */ + preventClose?: boolean; } export function DaydreamAccountSection({ onPipelinesRefresh, disabled = false, + isConnecting: isConnectingProp = false, + preventClose = false, }: DaydreamAccountSectionProps) { // Auth state const [isSignedIn, setIsSignedIn] = useState(false); @@ -41,6 +48,9 @@ export function DaydreamAccountSection({ // Use shared cloud status hook - avoids redundant polling with Header const { status, refresh: refreshStatus } = useCloudStatus(); + // Check if we're in direct cloud mode (no local backend, always cloud) + const { isDirectCloudMode, disconnect: disconnectCloud } = useCloudContext(); + // Local action state const [isDisconnecting, setIsDisconnecting] = useState(false); const [error, setError] = useState(null); @@ -174,7 +184,11 @@ export function DaydreamAccountSection({ const handleSignOut = async () => { // Disconnect from cloud if connected before signing out - if (status.connected) { + if (isDirectCloudMode) { + // In direct cloud mode, use the context's disconnect function + disconnectCloud(); + } else if (status.connected) { + // In backend cloud mode, call the backend API to disconnect await handleDisconnect(); } clearDaydreamAuth(); @@ -206,8 +220,8 @@ export function DaydreamAccountSection({ )}
- {/* Cloud Mode section - only visible for cohort participants or admins */} - {showCloudMode && ( + {/* Cloud Mode toggle section - only visible for cohort participants or admins, hidden in direct cloud mode */} + {showCloudMode && !isDirectCloudMode && (
@@ -256,6 +270,67 @@ export function DaydreamAccountSection({ )}
)} + + {/* Direct cloud mode - show connection info without toggle */} + {isDirectCloudMode && ( +
+
+ + Cloud Connection +
+ + {/* Auth gating message - shown when dialog cannot be closed */} + {preventClose && !isSignedIn && ( +

+ Please log in to your Daydream account to continue. +

+ )} + + {/* Connecting state - shown when waiting for cloud connection */} + {preventClose && isSignedIn && isConnectingProp && ( +
+
+

+ Connecting to cloud... +

+
+ )} + + {/* Connection ID when connected */} + {status.connected && status.connection_id && ( +
+ + Connection ID:{" "} + + {status.connection_id} + + + +
+ )} + + {/* Regular connecting state (not in preventClose mode) */} + {!preventClose && status.connecting && ( +

Connecting...

+ )} + + {(error || status.error) && ( +

{error || status.error}

+ )} +
+ )}
); } diff --git a/frontend/src/components/settings/GeneralTab.tsx b/frontend/src/components/settings/GeneralTab.tsx index ad5e1554f..6e82b7c11 100644 --- a/frontend/src/components/settings/GeneralTab.tsx +++ b/frontend/src/components/settings/GeneralTab.tsx @@ -9,6 +9,8 @@ interface GeneralTabProps { onModelsDirectoryChange: (value: string) => void; onLogsDirectoryChange: (value: string) => void; onReportBug: () => void; + /** Whether we're in direct cloud mode (hides local-only settings) */ + isDirectCloudMode?: boolean; } export function GeneralTab({ @@ -19,6 +21,7 @@ export function GeneralTab({ onModelsDirectoryChange, onLogsDirectoryChange, onReportBug, + isDirectCloudMode = false, }: GeneralTabProps) { const handleDocsClick = () => { console.log("Docs clicked"); @@ -49,7 +52,8 @@ export function GeneralTab({
{version} - {gitCommit && ` (${gitCommit})`} + {/* Hide git SHA in direct cloud mode */} + {!isDirectCloudMode && gitCommit && ` (${gitCommit})`}
@@ -111,41 +115,45 @@ export function GeneralTab({ />
- {/* Models Directory */} -
- - onModelsDirectoryChange(e.target.value)} - placeholder="~/.daydream-scope/models" - className="flex-1" - disabled - /> -
+ {/* Models Directory - hidden in direct cloud mode */} + {!isDirectCloudMode && ( +
+ + onModelsDirectoryChange(e.target.value)} + placeholder="~/.daydream-scope/models" + className="flex-1" + disabled + /> +
+ )} - {/* Logs Directory */} -
- - onLogsDirectoryChange(e.target.value)} - placeholder="~/.daydream-scope/logs" - className="flex-1" - disabled - /> -
+ {/* Logs Directory - hidden in direct cloud mode */} + {!isDirectCloudMode && ( +
+ + onLogsDirectoryChange(e.target.value)} + placeholder="~/.daydream-scope/logs" + className="flex-1" + disabled + /> +
+ )}
); diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 9efb9573b..fb61d0fcb 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -27,28 +27,49 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +interface DialogContentProps + extends React.ComponentPropsWithoutRef { + /** Optional tooltip text for the close button */ + closeButtonTitle?: string; + /** Whether the close button should appear disabled */ + closeButtonDisabled?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); + DialogContentProps +>( + ( + { className, children, closeButtonTitle, closeButtonDisabled, ...props }, + ref + ) => ( + + + + {children} + + + Close + + + + ) +); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 8918c2fc6..61f9c7370 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -4,7 +4,7 @@ */ import { useCallback } from "react"; -import { useCloudContext } from "../lib/cloudContext"; +import { useCloudContext } from "../lib/directCloudContext"; import * as api from "../lib/api"; import type { PipelineStatusResponse, @@ -16,6 +16,7 @@ import type { AssetFileInfo, WebRTCOfferRequest, WebRTCOfferResponse, + ServerInfo, } from "../lib/api"; import type { IceServersResponse, ModelStatusResponse } from "../types"; @@ -26,132 +27,205 @@ import type { IceServersResponse, ModelStatusResponse } from "../types"; * In local mode, requests go directly via HTTP fetch. */ export function useApi() { - const { adapter, isCloudMode, isReady } = useCloudContext(); + const { adapter, isDirectCloudMode, isReady } = useCloudContext(); // Pipeline APIs const getPipelineStatus = useCallback(async (): Promise => { - if (isCloudMode && adapter) { - return adapter.api.getPipelineStatus(); + if (isDirectCloudMode) { + // In direct cloud mode, must go through adapter - never fall back to direct HTTP + if (adapter) { + return adapter.api.getPipelineStatus(); + } + // Adapter not ready yet, return default status + return { status: "not_loaded" }; } return api.getPipelineStatus(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); const loadPipeline = useCallback( async (data: PipelineLoadRequest): Promise<{ message: string }> => { - if (isCloudMode && adapter) { - return adapter.api.loadPipeline(data); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.loadPipeline(data); + } + throw new Error("Cloud connection not ready"); } return api.loadPipeline(data); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); const getPipelineSchemas = useCallback(async (): Promise => { - if (isCloudMode && adapter) { - return adapter.api.getPipelineSchemas(); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.getPipelineSchemas(); + } + // Adapter not ready yet, return empty pipelines + return { pipelines: {} }; } return api.getPipelineSchemas(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); // Model APIs const checkModelStatus = useCallback( async (pipelineId: string): Promise => { - if (isCloudMode && adapter) { - return adapter.api.checkModelStatus(pipelineId); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.checkModelStatus(pipelineId); + } + throw new Error("Cloud connection not ready"); } return api.checkModelStatus(pipelineId); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); const downloadPipelineModels = useCallback( async (pipelineId: string): Promise<{ message: string }> => { - if (isCloudMode && adapter) { - return adapter.api.downloadPipelineModels(pipelineId); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.downloadPipelineModels(pipelineId); + } + throw new Error("Cloud connection not ready"); } return api.downloadPipelineModels(pipelineId); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); // Hardware APIs const getHardwareInfo = useCallback(async (): Promise => { - if (isCloudMode && adapter) { - return adapter.api.getHardwareInfo(); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.getHardwareInfo(); + } + // Adapter not ready yet, return default hardware info + return { vram_gb: null, spout_available: false }; } return api.getHardwareInfo(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); // LoRA APIs const listLoRAFiles = useCallback(async (): Promise => { - if (isCloudMode && adapter) { - return adapter.api.listLoRAFiles(); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.listLoRAFiles(); + } + // Adapter not ready yet, return empty list + return { lora_files: [] }; } return api.listLoRAFiles(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); // Asset APIs const listAssets = useCallback( async (type?: "image" | "video"): Promise => { - if (isCloudMode && adapter) { - return adapter.api.listAssets(type); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.listAssets(type); + } + // Adapter not ready yet, return empty list + return { assets: [] }; } return api.listAssets(type); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); const uploadAsset = useCallback( async (file: File): Promise => { - if (isCloudMode && adapter) { - return adapter.api.uploadAsset(file); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.uploadAsset(file); + } + throw new Error("Cloud connection not ready"); } return api.uploadAsset(file); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); // Logs const fetchCurrentLogs = useCallback(async (): Promise => { - if (isCloudMode && adapter) { - return adapter.api.fetchCurrentLogs(); + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.fetchCurrentLogs(); + } + throw new Error("Cloud connection not ready"); } return api.fetchCurrentLogs(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); - // Recording - note: in cloud mode, we still use direct HTTP for binary download + // Recording download const downloadRecording = useCallback( async (sessionId: string): Promise => { - // Always use direct HTTP for binary downloads - // In cloud mode, this may need the full URL + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.downloadRecording(sessionId); + } + throw new Error("Cloud connection not ready"); + } return api.downloadRecording(sessionId); }, - [] + [adapter, isDirectCloudMode] ); + // Get asset as data URL (for cloud mode where we can't serve files directly) + const getAssetDataUrl = useCallback( + async (assetPath: string): Promise => { + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.getAssetDataUrl(assetPath); + } + throw new Error("Cloud connection not ready"); + } + // In local mode, just return the regular URL + return api.getAssetUrl(assetPath); + }, + [adapter, isDirectCloudMode] + ); + + // Server info + const getServerInfo = useCallback(async (): Promise => { + if (isDirectCloudMode) { + if (adapter) { + return adapter.api.getServerInfo(); + } + // Return default info when not connected yet + return { version: "", gitCommit: "" }; + } + return api.getServerInfo(); + }, [adapter, isDirectCloudMode]); + // WebRTC signaling const getIceServers = useCallback(async (): Promise => { - if (isCloudMode && adapter) { - return adapter.getIceServers(); + if (isDirectCloudMode) { + if (adapter) { + return adapter.getIceServers(); + } + throw new Error("Cloud connection not ready"); } return api.getIceServers(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); const sendWebRTCOffer = useCallback( async (data: WebRTCOfferRequest): Promise => { - if (isCloudMode && adapter) { - return adapter.sendOffer( - data.sdp || "", - data.type || "offer", - data.initialParameters - ); + if (isDirectCloudMode) { + if (adapter) { + return adapter.sendOffer( + data.sdp || "", + data.type || "offer", + data.initialParameters + ); + } + throw new Error("Cloud connection not ready"); } return api.sendWebRTCOffer(data); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); const sendIceCandidates = useCallback( @@ -159,23 +233,26 @@ export function useApi() { sessionId: string, candidates: RTCIceCandidate | RTCIceCandidate[] ): Promise => { - if (isCloudMode && adapter) { - const candidateArray = Array.isArray(candidates) - ? candidates - : [candidates]; - for (const candidate of candidateArray) { - await adapter.sendIceCandidate(sessionId, candidate); + if (isDirectCloudMode) { + if (adapter) { + const candidateArray = Array.isArray(candidates) + ? candidates + : [candidates]; + for (const candidate of candidateArray) { + await adapter.sendIceCandidate(sessionId, candidate); + } + return; } - return; + throw new Error("Cloud connection not ready"); } return api.sendIceCandidates(sessionId, candidates); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); return { // State - isCloudMode, + isDirectCloudMode, isReady, // Pipeline @@ -196,7 +273,8 @@ export function useApi() { // Assets listAssets, uploadAsset, - getAssetUrl: api.getAssetUrl, // This is just a URL builder, no API call + getAssetUrl: api.getAssetUrl, // URL builder for local mode + getAssetDataUrl, // Async fetch for cloud mode (returns data URL) // Logs fetchCurrentLogs, @@ -204,6 +282,9 @@ export function useApi() { // Recording downloadRecording, + // Server info + getServerInfo, + // WebRTC signaling getIceServers, sendWebRTCOffer, diff --git a/frontend/src/hooks/useCloudStatus.tsx b/frontend/src/hooks/useCloudStatus.tsx index 82c5c6782..2b6ab3209 100644 --- a/frontend/src/hooks/useCloudStatus.tsx +++ b/frontend/src/hooks/useCloudStatus.tsx @@ -5,7 +5,13 @@ * Uses a React context to share state, so when one component refreshes the status, * all components see the updated value immediately. * - * Polling is smart and based on current status: + * Supports two cloud modes: + * - Direct mode (VITE_CLOUD_WS_URL): Frontend connects directly to cloud via WebSocket. + * Status comes from CloudContext instead of polling. + * - Proxy mode: Frontend talks to local backend which proxies to cloud. + * Status is polled from /api/v1/cloud/status. + * + * Polling (proxy mode only) is smart and based on current status: * - Disconnected: no polling (state changes only via explicit user actions) * - Connecting: poll every 1s to quickly detect when connection completes * - Connected: poll every 5s to detect unexpected disconnection @@ -20,6 +26,7 @@ import { useRef, type ReactNode, } from "react"; +import { useCloudContext } from "../lib/directCloudContext"; export interface CloudStatus { connected: boolean; @@ -30,6 +37,8 @@ export interface CloudStatus { credentials_configured: boolean; last_close_code: number | null; last_close_reason: string | null; + /** Whether this status is from direct cloud mode (VITE_CLOUD_WS_URL) */ + is_direct_mode?: boolean; } const DEFAULT_STATUS: CloudStatus = { @@ -41,6 +50,7 @@ const DEFAULT_STATUS: CloudStatus = { credentials_configured: false, last_close_code: null, last_close_reason: null, + is_direct_mode: false, }; // Polling intervals based on connection state @@ -57,18 +67,27 @@ const CloudStatusContext = createContext(null); interface CloudStatusProviderProps { children: ReactNode; + /** Skip backend polling - used in direct cloud mode where there's no local backend */ + skipPolling?: boolean; } /** * Provider component that manages cloud status and shares state across all consumers. * Polling is automatic and adapts based on connection state. */ -export function CloudStatusProvider({ children }: CloudStatusProviderProps) { +export function CloudStatusProvider({ + children, + skipPolling = false, +}: CloudStatusProviderProps) { const [status, setStatus] = useState(DEFAULT_STATUS); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(!skipPolling); const intervalRef = useRef(null); const fetchStatus = useCallback(async () => { + // Skip fetching in direct cloud mode - no local backend to poll + if (skipPolling) { + return; + } try { const response = await fetch("/api/v1/cloud/status"); if (response.ok) { @@ -80,14 +99,16 @@ export function CloudStatusProvider({ children }: CloudStatusProviderProps) { } finally { setIsLoading(false); } - }, []); + }, [skipPolling]); - // Initial fetch on mount + // Initial fetch on mount (skip in direct cloud mode) useEffect(() => { - fetchStatus(); - }, [fetchStatus]); + if (!skipPolling) { + fetchStatus(); + } + }, [fetchStatus, skipPolling]); - // Smart polling based on connection state + // Smart polling based on connection state (skip in direct cloud mode) useEffect(() => { // Clear any existing interval if (intervalRef.current) { @@ -95,6 +116,11 @@ export function CloudStatusProvider({ children }: CloudStatusProviderProps) { intervalRef.current = null; } + // Skip polling entirely in direct cloud mode + if (skipPolling) { + return; + } + // Determine polling interval based on status let pollInterval: number | null = null; @@ -117,7 +143,7 @@ export function CloudStatusProvider({ children }: CloudStatusProviderProps) { intervalRef.current = null; } }; - }, [status.connecting, status.connected, fetchStatus]); + }, [status.connecting, status.connected, fetchStatus, skipPolling]); // Manual refresh function - updates shared state immediately const refresh = useCallback(async () => { @@ -137,6 +163,9 @@ export function CloudStatusProvider({ children }: CloudStatusProviderProps) { * All components using this hook share the same status state. * When one component calls refresh(), all components see the updated value immediately. * + * Automatically uses direct cloud mode status when VITE_CLOUD_WS_URL is set, + * otherwise falls back to proxy mode status from the backend. + * * Must be used within a CloudStatusProvider. */ export function useCloudStatus() { @@ -146,12 +175,39 @@ export function useCloudStatus() { throw new Error("useCloudStatus must be used within a CloudStatusProvider"); } - const { status, isLoading, refresh } = context; + const { status: proxyStatus, isLoading, refresh } = context; + + // Check for direct cloud mode (VITE_CLOUD_WS_URL) + // This context is optional - if CloudProvider is not in the tree, we just use proxy status + let cloudContext: ReturnType | null = null; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks + cloudContext = useCloudContext(); + } catch { + // CloudProvider not in tree, use proxy status + } + + // If in direct cloud mode, use that status instead of proxy status + const isDirectMode = cloudContext?.isDirectCloudMode ?? false; + + const status: CloudStatus = isDirectMode + ? { + connected: cloudContext?.isReady ?? false, + connecting: cloudContext?.isConnecting ?? false, + error: cloudContext?.error?.message ?? null, + app_id: null, // Not available in direct mode + connection_id: cloudContext?.connectionId ?? null, + credentials_configured: true, // Credentials are in env vars for direct mode + last_close_code: cloudContext?.lastCloseCode ?? null, + last_close_reason: cloudContext?.lastCloseReason ?? null, + is_direct_mode: true, + } + : proxyStatus; return { status, - isLoading, - refresh, + isLoading: isDirectMode ? false : isLoading, + refresh: isDirectMode ? async () => {} : refresh, // No-op refresh in direct mode // Convenience accessors isConnected: status.connected, isConnecting: status.connecting, @@ -159,5 +215,6 @@ export function useCloudStatus() { error: status.error, lastCloseCode: status.last_close_code, lastCloseReason: status.last_close_reason, + isDirectMode, }; } diff --git a/frontend/src/hooks/usePipelines.ts b/frontend/src/hooks/usePipelines.ts index 096614461..26619fb93 100644 --- a/frontend/src/hooks/usePipelines.ts +++ b/frontend/src/hooks/usePipelines.ts @@ -1,10 +1,10 @@ import { useState, useEffect, useCallback } from "react"; import { getPipelineSchemas } from "../lib/api"; -import { useCloudContext } from "../lib/cloudContext"; +import { useCloudContext } from "../lib/directCloudContext"; import type { InputMode, PipelineInfo } from "../types"; export function usePipelines() { - const { adapter, isCloudMode, isReady } = useCloudContext(); + const { adapter, isDirectCloudMode, isReady } = useCloudContext(); const [pipelines, setPipelines] = useState { // In cloud mode, wait until adapter is ready - if (isCloudMode && !isReady) { + if (isDirectCloudMode && !isReady) { return; } @@ -103,7 +103,7 @@ export function usePipelines() { // Use adapter if in cloud mode, otherwise direct API const schemas = - isCloudMode && adapter + isDirectCloudMode && adapter ? await adapter.api.getPipelineSchemas() : await getPipelineSchemas(); @@ -129,7 +129,7 @@ export function usePipelines() { return () => { mounted = false; }; - }, [adapter, isCloudMode, isReady, transformSchemas]); + }, [adapter, isDirectCloudMode, isReady, transformSchemas]); return { pipelines, diff --git a/frontend/src/hooks/useStreamState.ts b/frontend/src/hooks/useStreamState.ts index 3f76c424d..4a9510983 100644 --- a/frontend/src/hooks/useStreamState.ts +++ b/frontend/src/hooks/useStreamState.ts @@ -13,7 +13,7 @@ import { type HardwareInfoResponse, type PipelineSchemasResponse, } from "../lib/api"; -import { useCloudContext } from "../lib/cloudContext"; +import { useCloudContext } from "../lib/directCloudContext"; // Generic fallback defaults used before schemas are loaded. // Resolution and denoising steps use conservative values. @@ -43,24 +43,24 @@ function getFallbackDefaults(mode?: InputMode) { } export function useStreamState() { - const { adapter, isCloudMode, isReady } = useCloudContext(); + const { adapter, isDirectCloudMode, isReady } = useCloudContext(); // Helper functions that use cloud adapter when available const getPipelineSchemas = useCallback(async (): Promise => { - if (isCloudMode && adapter) { + if (isDirectCloudMode && adapter) { return adapter.api.getPipelineSchemas(); } return getPipelineSchemasApi(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); const getHardwareInfo = useCallback(async (): Promise => { - if (isCloudMode && adapter) { + if (isDirectCloudMode && adapter) { return adapter.api.getHardwareInfo(); } return getHardwareInfoApi(); - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); const [systemMetrics, setSystemMetrics] = useState({ cpu: 0, @@ -235,7 +235,7 @@ export function useStreamState() { // Fetch pipeline schemas and hardware info on mount useEffect(() => { // In cloud mode, wait until adapter is ready - if (isCloudMode && !isReady) { + if (isDirectCloudMode && !isReady) { return; } @@ -288,7 +288,7 @@ export function useStreamState() { }; fetchInitialData(); - }, [isCloudMode, isReady, getPipelineSchemas, getHardwareInfo]); + }, [isDirectCloudMode, isReady, getPipelineSchemas, getHardwareInfo]); // Update inputMode when schemas load or pipeline changes // This sets the correct default mode for the pipeline diff --git a/frontend/src/hooks/useUnifiedWebRTC.ts b/frontend/src/hooks/useUnifiedWebRTC.ts index 0f8e5a6f5..7842e1de0 100644 --- a/frontend/src/hooks/useUnifiedWebRTC.ts +++ b/frontend/src/hooks/useUnifiedWebRTC.ts @@ -4,7 +4,7 @@ */ import { useState, useEffect, useRef, useCallback } from "react"; -import { useCloudContext } from "../lib/cloudContext"; +import { useCloudContext } from "../lib/directCloudContext"; import { sendWebRTCOffer, sendIceCandidates, @@ -43,7 +43,7 @@ interface UseUnifiedWebRTCOptions { * In cloud mode, uses the CloudAdapter WebSocket for signaling. */ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { - const { adapter, isCloudMode } = useCloudContext(); + const { adapter, isDirectCloudMode } = useCloudContext(); const [remoteStream, setRemoteStream] = useState(null); const [connectionState, setConnectionState] = @@ -63,7 +63,7 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { console.log("[UnifiedWebRTC] Fetching ICE servers..."); let iceServersResponse; - if (isCloudMode && adapter) { + if (isDirectCloudMode && adapter) { iceServersResponse = await adapter.getIceServers(); } else { iceServersResponse = await getIceServers(); @@ -80,7 +80,7 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { ); return { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; } - }, [adapter, isCloudMode]); + }, [adapter, isDirectCloudMode]); // Helper to send SDP offer const sendOffer = useCallback( @@ -89,7 +89,7 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { type: string, initialParameters?: InitialParameters ) => { - if (isCloudMode && adapter) { + if (isDirectCloudMode && adapter) { return adapter.sendOffer(sdp, type, initialParameters); } return sendWebRTCOffer({ @@ -98,19 +98,19 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { initialParameters, }); }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); // Helper to send ICE candidate const sendIceCandidate = useCallback( async (sessionId: string, candidate: RTCIceCandidate) => { - if (isCloudMode && adapter) { + if (isDirectCloudMode && adapter) { await adapter.sendIceCandidate(sessionId, candidate); } else { await sendIceCandidates(sessionId, candidate); } }, - [adapter, isCloudMode] + [adapter, isDirectCloudMode] ); const startStream = useCallback( @@ -130,7 +130,7 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { // Log peer connection configuration console.log("[UnifiedWebRTC] Created RTCPeerConnection with config:", { - mode: isCloudMode ? "CLOUD (frontend direct)" : "LOCAL (backend)", + mode: isDirectCloudMode ? "CLOUD (frontend direct)" : "LOCAL (backend)", iceServers: config.iceServers?.map((s: RTCIceServer) => ({ urls: s.urls, hasCredentials: !!(s.username && s.credential), @@ -237,7 +237,7 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { ); console.log( "[UnifiedWebRTC] Mode:", - isCloudMode + isDirectCloudMode ? "CLOUD (frontend → cloud)" : "LOCAL (frontend → backend)" ); @@ -371,7 +371,7 @@ export function useUnifiedWebRTC(options?: UseUnifiedWebRTCOptions) { console.log("[UnifiedWebRTC] Server SDP session:", sessionName); console.log( "[UnifiedWebRTC] Stream target:", - isCloudMode ? "cloud backend" : "local backend" + isDirectCloudMode ? "cloud backend" : "local backend" ); // Flush queued ICE candidates diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index e7e837353..cf34b8bb0 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -55,6 +55,7 @@ function verifyAuthState(state: string | null): boolean { const storedState = sessionStorage.getItem(AUTH_STATE_KEY); // Clear the stored state regardless of match (one-time use) sessionStorage.removeItem(AUTH_STATE_KEY); + console.log("verifyAuthState", state, storedState); if (!state || !storedState) { return false; @@ -277,6 +278,9 @@ export async function exchangeTokenForAPIKey(token: string): Promise { * Handle OAuth callback - extract token from URL and exchange it for API key * Returns true if callback was handled, false otherwise */ +// Track if we're already processing an OAuth callback (prevents double-processing in React StrictMode) +let isProcessingOAuthCallback = false; + export async function handleOAuthCallback(): Promise { const urlParams = new URLSearchParams(window.location.search); const token = urlParams.get("token"); @@ -287,15 +291,23 @@ export async function handleOAuthCallback(): Promise { return false; } + // Prevent double-processing (React StrictMode calls useEffect twice) + if (isProcessingOAuthCallback) { + console.log("[Auth] OAuth callback already being processed, skipping"); + return false; + } + isProcessingOAuthCallback = true; + + // Clean up the URL IMMEDIATELY to prevent double-processing on re-renders + const url = new URL(window.location.href); + url.searchParams.delete("token"); + url.searchParams.delete("state"); + url.searchParams.delete("userId"); + window.history.replaceState({}, document.title, url.toString()); + // Verify the state parameter to prevent CSRF attacks if (!verifyAuthState(state)) { - // Clean up URL even on state mismatch - const url = new URL(window.location.href); - url.searchParams.delete("token"); - url.searchParams.delete("state"); - url.searchParams.delete("userId"); - window.history.replaceState({}, document.title, url.toString()); - + isProcessingOAuthCallback = false; throw new Error( "Invalid auth state. This may be a CSRF attack or the auth session expired. Please try signing in again." ); @@ -308,17 +320,12 @@ export async function handleOAuthCallback(): Promise { // Save auth credentials and fetch profile in one operation await saveDaydreamAuth(apiKey, userId); - // Clean up the URL by removing the token parameter - const url = new URL(window.location.href); - url.searchParams.delete("token"); - url.searchParams.delete("state"); - url.searchParams.delete("userId"); - window.history.replaceState({}, document.title, url.toString()); - return true; } catch (error) { console.error("Failed to exchange token for API key:", error); throw error; + } finally { + isProcessingOAuthCallback = false; } } diff --git a/frontend/src/lib/cloudContext.tsx b/frontend/src/lib/cloudContext.tsx deleted file mode 100644 index b845fbfe2..000000000 --- a/frontend/src/lib/cloudContext.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Cloud Context Provider - * - * Provides a context for managing cloud deployment mode. - * When CLOUD_WS_URL is set, all API calls and WebRTC signaling - * go through the CloudAdapter WebSocket connection. - */ - -import React, { createContext, useContext, useEffect, useState } from "react"; -import { CloudAdapter } from "./cloudAdapter"; - -interface CloudContextValue { - /** Whether we're in cloud mode */ - isCloudMode: boolean; - /** The CloudAdapter instance (null if not in cloud mode) */ - adapter: CloudAdapter | null; - /** Whether the adapter is connected and ready */ - isReady: boolean; - /** Connection error if any */ - error: Error | null; -} - -const CloudContext = createContext({ - isCloudMode: false, - adapter: null, - isReady: false, - error: null, -}); - -interface CloudProviderProps { - /** WebSocket URL for cloud endpoint. If not set, local mode is used. */ - wsUrl?: string; - /** cloud API key for authentication */ - apiKey?: string; - children: React.ReactNode; -} - -export function CloudProvider({ wsUrl, apiKey, children }: CloudProviderProps) { - const [adapter, setAdapter] = useState(null); - const [isReady, setIsReady] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!wsUrl) { - setAdapter(null); - setIsReady(false); - setError(null); - return; - } - - console.log("[CloudProvider] Connecting to cloud:", wsUrl); - const cloudAdapter = new CloudAdapter(wsUrl, apiKey); - setAdapter(cloudAdapter); - - cloudAdapter - .connect() - .then(() => { - console.log("[CloudProvider] Connected to cloud"); - setIsReady(true); - setError(null); - }) - .catch(err => { - console.error("[CloudProvider] Connection failed:", err); - setError(err); - setIsReady(false); - }); - - return () => { - console.log("[CloudProvider] Disconnecting from cloud"); - cloudAdapter.disconnect(); - }; - }, [wsUrl, apiKey]); - - const value: CloudContextValue = { - isCloudMode: !!wsUrl, - adapter, - isReady: !!wsUrl && isReady, - error, - }; - - return ( - {children} - ); -} - -/** - * Hook to access the cloud context - */ -export function useCloudContext() { - return useContext(CloudContext); -} - -/** - * Hook that returns the adapter if in cloud mode - */ -export function useCloudAdapter() { - const { adapter, isCloudMode, isReady, error } = useCloudContext(); - return { adapter, isCloudMode, isReady, error }; -} diff --git a/frontend/src/lib/cloudAdapter.ts b/frontend/src/lib/directCloudAdapter.ts similarity index 68% rename from frontend/src/lib/cloudAdapter.ts rename to frontend/src/lib/directCloudAdapter.ts index 09a83dcf3..851516206 100644 --- a/frontend/src/lib/cloudAdapter.ts +++ b/frontend/src/lib/directCloudAdapter.ts @@ -33,6 +33,8 @@ import type { LoRAFilesResponse, AssetsResponse, AssetFileInfo, + HealthResponse, + ServerInfo, } from "./api"; type MessageHandler = (response: ApiResponse) => void; @@ -60,6 +62,7 @@ export class CloudAdapter { private ws: WebSocket | null = null; private wsUrl: string; private apiKey: string | null = null; + private userId: string | null = null; private pendingRequests: Map = new Map(); private requestCounter = 0; private isReady = false; @@ -68,18 +71,44 @@ export class CloudAdapter { private reconnectAttempts = 0; private maxReconnectAttempts = 5; private messageHandlers: Set = new Set(); + // Flag to prevent auto-reconnect when intentionally disconnected + private intentionalDisconnect = false; // Current WebRTC session ID (set after offer/answer exchange) private currentSessionId: string | null = null; + // Connection ID from cloud server (received in ready message) + private _connectionId: string | null = null; + + // Last close info for reporting + private _lastCloseCode: number | null = null; + private _lastCloseReason: string | null = null; + /** * Create a CloudAdapter instance. * @param wsUrl - WebSocket URL for the cloud endpoint * @param apiKey - Optional cloud API key for authentication + * @param userId - Optional user ID for log correlation (sent to cloud after connection) */ - constructor(wsUrl: string, apiKey?: string) { + constructor(wsUrl: string, apiKey?: string, userId?: string) { this.wsUrl = wsUrl; this.apiKey = apiKey || null; + this.userId = userId || null; + } + + /** Get the connection ID assigned by the cloud server */ + get connectionId(): string | null { + return this._connectionId; + } + + /** Get the last close code (if connection was closed unexpectedly) */ + get lastCloseCode(): number | null { + return this._lastCloseCode; + } + + /** Get the last close reason (if connection was closed unexpectedly) */ + get lastCloseReason(): string | null { + return this._lastCloseReason; } /** @@ -90,6 +119,12 @@ export class CloudAdapter { return; } + // Reset the intentional disconnect flag when connecting + this.intentionalDisconnect = false; + // Clear previous close info on new connection attempt + this._lastCloseCode = null; + this._lastCloseReason = null; + this.readyPromise = new Promise(resolve => { this.readyResolve = resolve; }); @@ -118,6 +153,21 @@ export class CloudAdapter { // Check for ready message if (message.type === "ready") { + // Extract connection_id from ready message (like Python backend does) + this._connectionId = (message as { connection_id?: string }) + .connection_id || null; + console.log( + `[CloudAdapter] Cloud server ready (connection_id: ${this._connectionId})` + ); + + // Send user_id to cloud for log correlation (like Python backend does) + if (this.userId && this.ws) { + this.ws.send( + JSON.stringify({ type: "set_user_id", user_id: this.userId }) + ); + console.log(`[CloudAdapter] Sent user_id to cloud: ${this.userId}`); + } + this.isReady = true; this.readyResolve?.(); resolve(); @@ -141,6 +191,15 @@ export class CloudAdapter { this.isReady = false; this.ws = null; + // Store close info for unexpected closes (not code 1000) + if (event.code !== 1000 && !this.intentionalDisconnect) { + this._lastCloseCode = event.code; + this._lastCloseReason = event.reason || null; + } + + // Clear connection ID on disconnect + this._connectionId = null; + // Reject all pending requests for (const [requestId, pending] of this.pendingRequests) { clearTimeout(pending.timeout); @@ -149,7 +208,10 @@ export class CloudAdapter { } // Attempt reconnect if not intentional close + // Check intentionalDisconnect flag in addition to close code, + // because closing during CONNECTING state may not use code 1000 if ( + !this.intentionalDisconnect && event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts ) { @@ -182,6 +244,8 @@ export class CloudAdapter { * Disconnect from the WebSocket */ disconnect(): void { + // Set flag before closing to prevent auto-reconnect + this.intentionalDisconnect = true; if (this.ws) { this.ws.close(1000, "Client disconnect"); this.ws = null; @@ -425,10 +489,96 @@ export class CloudAdapter { fetchCurrentLogs: (): Promise => this.apiRequest("GET", "/api/v1/logs/current"), - // Note: downloadRecording needs special handling for binary data - // For now, it will return the URL to download from - getRecordingUrl: (sessionId: string): string => - `/api/v1/recordings/${sessionId}`, + /** + * Download a recording. Returns the recording as a base64-encoded string. + * The caller is responsible for triggering the browser download. + */ + downloadRecording: async (sessionId: string): Promise => { + const response = await this.sendAndWait({ + type: "api", + method: "GET", + path: `/api/v1/recordings/${sessionId}`, + _binary: true, // Signal to cloud that we want base64 response + }); + + // Check both top-level and nested data for the base64 content + const base64Content = response._base64_content || response.data?._base64_content; + if (!base64Content) { + console.error("[CloudAdapter] Recording response:", response); + throw new Error("No recording data received"); + } + + // Convert base64 to blob and trigger download + const binaryString = atob(base64Content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const contentType = response._content_type || response.data?._content_type || "video/mp4"; + const blob = new Blob([bytes], { type: contentType }); + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `recording-${new Date().toISOString().split("T")[0]}.mp4`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, + + /** + * Get asset content as base64 data URL (for displaying images in cloud mode) + */ + getAssetDataUrl: async (assetPath: string): Promise => { + // Extract filename from path for the API call + const pathParts = assetPath.split(/[/\\]/); + const assetsIndex = pathParts.findIndex(part => part === "assets"); + let relativePath: string; + if (assetsIndex >= 0 && assetsIndex < pathParts.length - 1) { + relativePath = pathParts.slice(assetsIndex + 1).join("/"); + } else { + relativePath = pathParts[pathParts.length - 1]; + } + + const response = await this.sendAndWait({ + type: "api", + method: "GET", + path: `/api/v1/assets/${encodeURIComponent(relativePath)}`, + _binary: true, + }); + + // Check both top-level and nested data for the base64 content + const base64Content = response._base64_content || response.data?._base64_content; + if (!base64Content) { + console.error("[CloudAdapter] Asset response:", response); + throw new Error("No asset data received"); + } + + const contentType = response._content_type || response.data?._content_type || "image/png"; + return `data:${contentType};base64,${base64Content}`; + }, + + /** + * Get server health/info + */ + getServerInfo: async (): Promise => { + const response = await this.sendAndWait({ + type: "api", + method: "GET", + path: "/health", + }); + + const data = response.data; + if (!data) { + throw new Error("No health data received"); + } + + return { + version: data.version, + gitCommit: data.git_commit, + }; + }, }; } @@ -509,8 +659,8 @@ export function getCloudAdapter(): CloudAdapter | null { } /** - * Check if we're running in cloud mode (adapter is initialized) + * Check if we're running in direct cloud mode (adapter is initialized) */ -export function isCloudMode(): boolean { +export function isDirectCloudMode(): boolean { return globalAdapter !== null && globalAdapter !== undefined; } diff --git a/frontend/src/lib/directCloudContext.tsx b/frontend/src/lib/directCloudContext.tsx new file mode 100644 index 000000000..1d7588b1d --- /dev/null +++ b/frontend/src/lib/directCloudContext.tsx @@ -0,0 +1,226 @@ +/** + * Cloud Context Provider + * + * Provides a context for managing cloud deployment mode. + * When CLOUD_WS_URL is set, all API calls and WebRTC signaling + * go through the CloudAdapter WebSocket connection. + */ + +import React, { + createContext, + useContext, + useEffect, + useState, + useRef, +} from "react"; +import { CloudAdapter } from "./directCloudAdapter"; + +// Check if direct cloud mode is enabled (static, never changes) +const DIRECT_CLOUD_MODE = !!import.meta.env.VITE_CLOUD_WS_URL; + +interface CloudContextValue { + /** Whether we're in direct cloud mode (VITE_CLOUD_WS_URL is set) - STATIC, always true if env var is set */ + isDirectCloudMode: boolean; + /** The CloudAdapter instance (null if not in cloud mode or not connected) */ + adapter: CloudAdapter | null; + /** Whether we're currently connecting (waiting for ready message) */ + isConnecting: boolean; + /** Whether the adapter is connected and ready */ + isReady: boolean; + /** Connection error if any */ + error: Error | null; + /** Connection ID assigned by the cloud server */ + connectionId: string | null; + /** Last close code if connection was closed unexpectedly */ + lastCloseCode: number | null; + /** Last close reason if connection was closed unexpectedly */ + lastCloseReason: string | null; + /** Disconnect the cloud adapter (for logout) */ + disconnect: () => void; + /** Reconnect the cloud adapter (for login) */ + reconnect: () => void; +} + +const CloudContext = createContext({ + isDirectCloudMode: DIRECT_CLOUD_MODE, + adapter: null, + isConnecting: false, + isReady: false, + error: null, + connectionId: null, + lastCloseCode: null, + lastCloseReason: null, + disconnect: () => {}, + reconnect: () => {}, +}); + +interface CloudProviderProps { + /** WebSocket URL for cloud endpoint. If not set, local mode is used. */ + wsUrl?: string; + /** cloud API key for authentication */ + apiKey?: string; + /** User ID for log correlation (sent to cloud after connection) */ + userId?: string; + children: React.ReactNode; +} + +export function CloudProvider({ + wsUrl, + apiKey, + userId, + children, +}: CloudProviderProps) { + const [adapter, setAdapter] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(null); + const [connectionId, setConnectionId] = useState(null); + const [lastCloseCode, setLastCloseCode] = useState(null); + const [lastCloseReason, setLastCloseReason] = useState(null); + // Track if manually disconnected (e.g., user logged out) + const [manuallyDisconnected, setManuallyDisconnected] = useState(false); + // Track if the effect is still active to prevent state updates after cleanup + // This is important for React StrictMode which double-mounts components + const isActiveRef = useRef(true); + // Store adapter ref for disconnect/reconnect + const adapterRef = useRef(null); + + useEffect(() => { + // Reset active flag on each effect run + isActiveRef.current = true; + + if (!wsUrl || manuallyDisconnected) { + // Don't connect if no URL or manually disconnected + if (adapterRef.current) { + adapterRef.current.disconnect(); + adapterRef.current = null; + } + setAdapter(null); + setIsConnecting(false); + setIsReady(false); + setError(null); + setConnectionId(null); + setLastCloseCode(null); + setLastCloseReason(null); + return; + } + + console.log("[CloudProvider] Connecting to cloud:", wsUrl); + const cloudAdapter = new CloudAdapter(wsUrl, apiKey, userId); + adapterRef.current = cloudAdapter; + setAdapter(cloudAdapter); + // Set connecting state while waiting for ready message + setIsConnecting(true); + setIsReady(false); + setError(null); + + cloudAdapter + .connect() + .then(() => { + // Only update state if this effect is still active + if (isActiveRef.current) { + console.log("[CloudProvider] Connected to cloud"); + setIsConnecting(false); + setIsReady(true); + setError(null); + setConnectionId(cloudAdapter.connectionId); + // Clear close info on successful connection + setLastCloseCode(null); + setLastCloseReason(null); + } else { + // Effect was cleaned up while connecting, disconnect immediately + console.log( + "[CloudProvider] Connection completed but effect was cleaned up, disconnecting" + ); + cloudAdapter.disconnect(); + } + }) + .catch(err => { + // Only update state if this effect is still active + if (isActiveRef.current) { + console.error("[CloudProvider] Connection failed:", err); + setIsConnecting(false); + setError(err); + setIsReady(false); + setConnectionId(null); + // Update close info from adapter + setLastCloseCode(cloudAdapter.lastCloseCode); + setLastCloseReason(cloudAdapter.lastCloseReason); + } + }); + + return () => { + console.log("[CloudProvider] Disconnecting from cloud"); + // Mark effect as inactive before disconnecting + isActiveRef.current = false; + cloudAdapter.disconnect(); + adapterRef.current = null; + }; + }, [wsUrl, apiKey, userId, manuallyDisconnected]); + + // Disconnect function for logout + const disconnect = React.useCallback(() => { + console.log("[CloudProvider] Manual disconnect requested"); + setManuallyDisconnected(true); + }, []); + + // Reconnect function for login + const reconnect = React.useCallback(() => { + console.log("[CloudProvider] Reconnect requested"); + setManuallyDisconnected(false); + }, []); + + const value: CloudContextValue = { + isDirectCloudMode: DIRECT_CLOUD_MODE, + adapter, + isConnecting: !!wsUrl && !manuallyDisconnected && isConnecting, + isReady: !!wsUrl && !manuallyDisconnected && isReady, + error, + connectionId, + lastCloseCode, + lastCloseReason, + disconnect, + reconnect, + }; + + return ( + {children} + ); +} + +/** + * Hook to access the cloud context + */ +export function useCloudContext() { + return useContext(CloudContext); +} + +/** + * Hook that returns the adapter if in cloud mode + */ +export function useCloudAdapter() { + const { + adapter, + isDirectCloudMode, + isConnecting, + isReady, + error, + connectionId, + lastCloseCode, + lastCloseReason, + disconnect, + reconnect, + } = useCloudContext(); + return { + adapter, + isDirectCloudMode, + isConnecting, + isReady, + error, + connectionId, + lastCloseCode, + lastCloseReason, + disconnect, + reconnect, + }; +} diff --git a/frontend/src/pages/StreamPage.tsx b/frontend/src/pages/StreamPage.tsx index a35f5da09..c2f00c6ed 100644 --- a/frontend/src/pages/StreamPage.tsx +++ b/frontend/src/pages/StreamPage.tsx @@ -15,7 +15,7 @@ import { usePipeline } from "../hooks/usePipeline"; import { useStreamState } from "../hooks/useStreamState"; import { usePipelinesContext } from "../contexts/PipelinesContext"; import { useApi } from "../hooks/useApi"; -import { useCloudContext } from "../lib/cloudContext"; +import { useCloudContext } from "../lib/directCloudContext"; import { useCloudStatus } from "../hooks/useCloudStatus"; import { getDefaultPromptForMode } from "../data/pipelines"; import { adjustResolutionForPipeline } from "../lib/utils"; @@ -67,21 +67,21 @@ function getVaceParams( return {}; } -export function StreamPage() { +interface StreamPageProps { + isSignedIn?: boolean; +} + +export function StreamPage({ isSignedIn = true }: StreamPageProps) { // Get API functions that work in both local and cloud modes const api = useApi(); - const { isCloudMode: isDirectCloudMode, isReady: isCloudReady } = - useCloudContext(); + const { isDirectCloudMode, isReady: isCloudReady } = useCloudContext(); // Track backend cloud relay mode (local backend connected to cloud or connecting) const { - isConnected: isBackendCloudConnected, + // isConnected: isBackendCloudConnected, isConnecting: isBackendCloudConnecting, } = useCloudStatus(); - // Combined cloud mode: either frontend direct-to-cloud or backend relay to cloud - const isCloudMode = isDirectCloudMode || isBackendCloudConnected; - // Show loading state while connecting to cloud useEffect(() => { if (isDirectCloudMode) { @@ -1239,6 +1239,7 @@ export function StreamPage() { cloudDisabled={isStreaming} openSettingsTab={openSettingsTab} onSettingsTabOpened={() => setOpenSettingsTab(null)} + isSignedIn={isSignedIn} /> {/* Main Content Area */} @@ -1561,7 +1562,7 @@ export function StreamPage() { sendParameterUpdate({ [key]: value }); } }} - isCloudMode={isCloudMode} + isDirectCloudMode={isDirectCloudMode} /> diff --git a/src/scope/cloud/fal_app.py b/src/scope/cloud/fal_app.py index 60ab70406..925927ff4 100644 --- a/src/scope/cloud/fal_app.py +++ b/src/scope/cloud/fal_app.py @@ -22,10 +22,6 @@ from fal.container import ContainerImage from fastapi import WebSocket -# Daydream API configuration -DAYDREAM_API_BASE = os.getenv("DAYDREAM_API_BASE", "https://api.daydream.live") - - async def validate_user_access(user_id: str) -> tuple[bool, str]: """ Validate that a user has access to cloud mode. @@ -39,7 +35,7 @@ async def validate_user_access(user_id: str) -> tuple[bool, str]: if not user_id: return False, "No user ID provided" - url = f"{DAYDREAM_API_BASE}/v1/users/{user_id}" + url = f"{os.getenv("DAYDREAM_API_BASE", "https://api.daydream.live")}/v1/users/{user_id}" def fetch_user(): req = urllib.request.Request(url) @@ -207,6 +203,9 @@ def cleanup_session_data(): print(f"Warning: Session cleanup failed: {e}") +# TODO different client_source for kafka in direct cloud +# TODO missing user id and conn id for direct mode? + # To deploy: # 1. Ensure the docker image for your current git SHA has been built # (check https://github.com/daydreamlive/scope/actions for the docker-build workflow)