diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index a943aef2..2dbd19bb 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -175,7 +175,17 @@ type QueueAdReportOptions = { errorInfo?: QueueAdErrorInfo; }; +type SignalingRecoveryState = { + attemptCount: number; + inFlight: Promise | null; + explicitShutdown: boolean; + appId: number | null; + generation: number; +}; + const APP_PAGE_ORDER: AppPage[] = ["home", "library", "settings"]; +const RECOVERABLE_STREAM_STATUSES: readonly StreamStatus[] = ["queue", "setup", "starting", "connecting", "streaming"]; +const SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS = [0, 3000] as const; const isMac = navigator.platform.toLowerCase().includes("mac"); @@ -756,6 +766,13 @@ function toLaunchErrorState(error: unknown, stage: StreamLoadingStatus): LaunchE }; } +function streamStatusToLoadingStage(status: StreamStatus): StreamLoadingStatus { + if (status === "queue" || status === "setup" || status === "starting" || status === "connecting") { + return status; + } + return "connecting"; +} + export function App(): JSX.Element { // Auth State @@ -1122,6 +1139,13 @@ export function App(): JSX.Element { const launchInFlightRef = useRef(false); const launchAbortRef = useRef(false); const streamStatusRef = useRef(streamStatus); + const signalingRecoveryRef = useRef({ + attemptCount: 0, + inFlight: null, + explicitShutdown: false, + appId: null, + generation: 0, + }); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const adReportQueueRef = useRef>(Promise.resolve()); const adReportStateRef = useRef>({}); @@ -1340,6 +1364,9 @@ export function App(): JSX.Element { keepLaunchError?: boolean; keepStreamingContext?: boolean; }): void => { + signalingRecoveryRef.current.attemptCount = 0; + signalingRecoveryRef.current.inFlight = null; + signalingRecoveryRef.current.appId = null; setSession(null); setStreamStatus("idle"); setQueuePosition(undefined); @@ -1365,6 +1392,29 @@ export function App(): JSX.Element { } }, [diagnosticsStore, resetStatsOverlayToPreference, settings.discordRichPresence]); + const resetSignalingRecoveryState = useCallback((options?: { + keepExplicitShutdown?: boolean; + }): void => { + signalingRecoveryRef.current.generation += 1; + signalingRecoveryRef.current.attemptCount = 0; + signalingRecoveryRef.current.inFlight = null; + signalingRecoveryRef.current.appId = null; + if (!options?.keepExplicitShutdown) { + signalingRecoveryRef.current.explicitShutdown = false; + } + }, []); + + const markExplicitSignalingShutdown = useCallback((): void => { + signalingRecoveryRef.current.generation += 1; + signalingRecoveryRef.current.explicitShutdown = true; + signalingRecoveryRef.current.inFlight = null; + }, []); + + const isRecoveryGenerationCurrent = useCallback((generation: number): boolean => { + const state = signalingRecoveryRef.current; + return state.generation === generation && !state.explicitShutdown; + }, []); + // Session ref sync useEffect(() => { sessionRef.current = session; @@ -2167,87 +2217,6 @@ export function App(): JSX.Element { return () => window.clearTimeout(timer); }, [remoteStreamWarning]); - // Signaling events - useEffect(() => { - const unsubscribe = window.openNow.onSignalingEvent(async (event: MainToRendererSignalingEvent) => { - console.log(`[App] Signaling event: ${event.type}`, event.type === "offer" ? `(SDP ${event.sdp.length} chars)` : "", event.type === "remote-ice" ? event.candidate : ""); - try { - if (event.type === "offer") { - const activeSession = sessionRef.current; - if (!activeSession) { - console.warn("[App] Received offer but no active session in sessionRef!"); - return; - } - console.log("[App] Active session for offer:", JSON.stringify({ - sessionId: activeSession.sessionId, - serverIp: activeSession.serverIp, - signalingServer: activeSession.signalingServer, - mediaConnectionInfo: activeSession.mediaConnectionInfo, - iceServersCount: activeSession.iceServers?.length, - })); - - if (!clientRef.current && videoRef.current && audioRef.current) { - clientRef.current = new GfnWebRtcClient({ - videoElement: videoRef.current, - audioElement: audioRef.current, - autoFullScreen: settings.autoFullScreen, - microphoneMode: settings.microphoneMode, - microphoneDeviceId: settings.microphoneDeviceId || undefined, - mouseSensitivity: settings.mouseSensitivity, - mouseAcceleration: settings.mouseAcceleration, - onLog: (line: string) => console.log(`[WebRTC] ${line}`), - onStats: (stats) => diagnosticsStore.set(stats), - onEscHoldProgress: (visible, progress) => { - setEscHoldReleaseIndicator({ visible, progress }); - }, - onTimeWarning: (warning) => { - setRemoteStreamWarning({ - code: warning.code, - message: warningMessage(warning.code), - tone: warningTone(warning.code), - secondsLeft: warning.secondsLeft, - }); - }, - onMicStateChange: (state) => { - console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); - }, - }); - // Auto-start microphone if mode is enabled - if (settings.microphoneMode !== "disabled") { - void clientRef.current.startMicrophone(); - } - } - - if (clientRef.current) { - await clientRef.current.handleOffer(event.sdp, activeSession, { - codec: settings.codec, - colorQuality: settings.colorQuality, - resolution: settings.resolution, - fps: settings.fps, - maxBitrateKbps: settings.maxBitrateMbps * 1000, - }); - setLaunchError(null); - setStreamStatus("streaming"); - } - } else if (event.type === "remote-ice") { - await clientRef.current?.addRemoteCandidate(event.candidate); - } else if (event.type === "disconnected") { - console.warn("Signaling disconnected:", event.reason); - clientRef.current?.dispose(); - clientRef.current = null; - resetLaunchRuntime(); - launchInFlightRef.current = false; - } else if (event.type === "error") { - console.error("Signaling error:", event.message); - } - } catch (error) { - console.error("Signaling event error:", error); - } - }); - - return () => unsubscribe(); - }, [resetLaunchRuntime, settings]); - // Save settings when changed const updateSetting = useCallback(async (key: K, value: Settings[K]) => { setSettings((prev) => ({ ...prev, [key]: value })); @@ -2470,6 +2439,74 @@ export function App(): JSX.Element { }); }, []); + const resolveSessionClaimAppId = useCallback((existingSession: ActiveSessionInfo): string => { + const trackedAppId = signalingRecoveryRef.current.appId; + if (Number.isFinite(existingSession.appId) && existingSession.appId > 0) { + return String(existingSession.appId); + } + if (trackedAppId && Number.isFinite(trackedAppId)) { + return String(trackedAppId); + } + throw new Error("Active session is missing app metadata required for resume."); + }, []); + + const applyClaimedSessionAndConnect = useCallback(async ( + claimed: SessionInfo, + expectedRecoveryGeneration?: number, + ): Promise => { + const canProceedWithClaimedReconnect = (): boolean => { + if ( + expectedRecoveryGeneration !== undefined + && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) + ) { + return false; + } + if (signalingRecoveryRef.current.explicitShutdown) { + return false; + } + return true; + }; + + if ( + expectedRecoveryGeneration !== undefined + && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) + ) { + console.log("[Recovery] Skipping claimed session apply due to stale recovery generation"); + return; + } + + console.log("Claimed session:", { + sessionId: claimed.sessionId, + signalingServer: claimed.signalingServer, + signalingUrl: claimed.signalingUrl, + status: claimed.status, + }); + + await sleep(1000); + if ( + expectedRecoveryGeneration !== undefined + && !isRecoveryGenerationCurrent(expectedRecoveryGeneration) + ) { + console.log("[Recovery] Skipping reconnect due to stale recovery generation after delay"); + return; + } + if (!canProceedWithClaimedReconnect()) { + console.log("[Recovery] Skipping claimed session apply due to explicit shutdown"); + return; + } + + setSession(claimed); + sessionRef.current = claimed; + setQueuePosition(undefined); + setLaunchError(null); + setStreamStatus("connecting"); + await window.openNow.connectSignaling({ + sessionId: claimed.sessionId, + signalingServer: claimed.signalingServer, + signalingUrl: claimed.signalingUrl, + }); + }, [isRecoveryGenerationCurrent]); + const claimAndConnectSession = useCallback(async (existingSession: ActiveSessionInfo): Promise => { const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; if (!token) { @@ -2492,7 +2529,7 @@ export function App(): JSX.Element { streamingBaseUrl: effectiveStreamingBaseUrl, serverIp: existingSession.serverIp, sessionId: existingSession.sessionId, - appId: String(existingSession.appId), + appId: resolveSessionClaimAppId(existingSession), settings: { resolution: settings.resolution, fps: settings.fps, @@ -2506,25 +2543,293 @@ export function App(): JSX.Element { }, }); - console.log("Claimed session:", { - sessionId: claimed.sessionId, - signalingServer: claimed.signalingServer, - signalingUrl: claimed.signalingUrl, - status: claimed.status, - }); + await applyClaimedSessionAndConnect(claimed); + }, [applyClaimedSessionAndConnect, authSession, effectiveStreamingBaseUrl, findGameContextForSession, resolveSessionClaimAppId, settings]); - await sleep(1000); + const attemptSessionRecovery = useCallback(async (reason: string): Promise => { + const recoveryState = signalingRecoveryRef.current; + const recoveryGeneration = recoveryState.generation; + const currentStatus = streamStatusRef.current; + const currentSession = sessionRef.current; - setSession(claimed); - sessionRef.current = claimed; - setQueuePosition(undefined); - setStreamStatus("connecting"); - await window.openNow.connectSignaling({ - sessionId: claimed.sessionId, - signalingServer: claimed.signalingServer, - signalingUrl: claimed.signalingUrl, + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Skipping signaling recovery after explicit shutdown"); + return false; + } + if (!RECOVERABLE_STREAM_STATUSES.includes(currentStatus)) { + console.log("[Recovery] Stream status is not recoverable:", currentStatus); + return false; + } + if (!currentSession) { + console.warn("[Recovery] No active session available for signaling recovery"); + return false; + } + if (recoveryState.inFlight) { + console.log("[Recovery] Reusing in-flight signaling recovery attempt"); + return recoveryState.inFlight; + } + + const token = authSession?.tokens.idToken ?? authSession?.tokens.accessToken; + if (!token) { + throw new Error("Connection to the running session was lost and your login token is no longer available for resume."); + } + + if (recoveryState.attemptCount >= SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length) { + console.warn("[Recovery] Recovery budget exhausted"); + return false; + } + + const attemptPromise = (async (): Promise => { + clientRef.current?.dispose(); + clientRef.current = null; + setStreamStatus("connecting"); + await window.openNow.disconnectSignaling().catch(() => {}); + + let lastError: Error | null = null; + while (recoveryState.attemptCount < SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length) { + const attemptIndex = recoveryState.attemptCount; + recoveryState.attemptCount += 1; + const attemptNumber = recoveryState.attemptCount; + const attemptDelayMs = SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS[attemptIndex] ?? 0; + + console.warn( + `[Recovery] Attempt ${attemptNumber}/${SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length} after signaling disconnect: ${reason}`, + ); + + if (attemptDelayMs > 0) { + await sleep(attemptDelayMs); + } + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Aborting attempt after explicit shutdown"); + return false; + } + + try { + const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl); + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Aborting attempt after active session lookup due to stale generation"); + return false; + } + const previousAppId = recoveryState.appId; + const currentSessionId = currentSession.sessionId; + const sameSessionCandidate = + activeSessions.find((entry) => entry.sessionId === currentSessionId && entry.serverIp && isSessionReadyForConnect(entry.status)) ?? + null; + + let candidate = sameSessionCandidate; + if (!candidate && previousAppId !== null) { + candidate = + activeSessions.find((entry) => ( + entry.appId === previousAppId && + entry.serverIp && + isSessionReadyForConnect(entry.status) && + entry.sessionId === currentSessionId + )) ?? + activeSessions.find((entry) => ( + entry.appId === previousAppId && + entry.serverIp && + isSessionReadyForConnect(entry.status) + )) ?? + null; + } + + if (!candidate) { + const hasQueueOnlyMatch = activeSessions.some((entry) => entry.sessionId === currentSessionId && entry.status === 1); + if (hasQueueOnlyMatch) { + throw new Error("The session is still queued and cannot be reclaimed until the server marks it ready again."); + } + throw new Error("The running session could not be found anymore, so resume was not possible."); + } + + if (!candidate.serverIp) { + throw new Error("The running session is missing a server address, so resume was not possible."); + } + + const claimed = await window.openNow.claimSession({ + token, + streamingBaseUrl: effectiveStreamingBaseUrl, + serverIp: candidate.serverIp, + sessionId: candidate.sessionId, + appId: resolveSessionClaimAppId(candidate), + settings: { + resolution: settings.resolution, + fps: settings.fps, + maxBitrateMbps: settings.maxBitrateMbps, + codec: settings.codec, + colorQuality: settings.colorQuality, + keyboardLayout: settings.keyboardLayout, + gameLanguage: settings.gameLanguage, + enableL4S: settings.enableL4S, + enableCloudGsync: settings.enableCloudGsync, + }, + }); + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Discarding claimed session due to stale recovery generation"); + return false; + } + + const matchedContext = findGameContextForSession(candidate); + if (matchedContext) { + setStreamingGame(matchedContext.game); + setStreamingStore(matchedContext.variant?.store ?? null); + } else { + setStreamingStore(null); + } + + await applyClaimedSessionAndConnect(claimed, recoveryGeneration); + if (!isRecoveryGenerationCurrent(recoveryGeneration)) { + console.log("[Recovery] Recovery generation changed before connect completed"); + return false; + } + recoveryState.attemptCount = 0; + return true; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.warn(`[Recovery] Attempt ${attemptNumber} failed:`, lastError.message); + } + } + + if (lastError) { + throw lastError; + } + return false; + })(); + + recoveryState.inFlight = attemptPromise; + try { + return await attemptPromise; + } finally { + if (signalingRecoveryRef.current.inFlight === attemptPromise) { + signalingRecoveryRef.current.inFlight = null; + } + } + }, [ + applyClaimedSessionAndConnect, + authSession, + effectiveStreamingBaseUrl, + findGameContextForSession, + isRecoveryGenerationCurrent, + resolveSessionClaimAppId, + settings, + ]); + + // Signaling events + useEffect(() => { + const unsubscribe = window.openNow.onSignalingEvent(async (event: MainToRendererSignalingEvent) => { + console.log(`[App] Signaling event: ${event.type}`, event.type === "offer" ? `(SDP ${event.sdp.length} chars)` : "", event.type === "remote-ice" ? event.candidate : ""); + try { + if (event.type === "offer") { + const activeSession = sessionRef.current; + if (!activeSession) { + console.warn("[App] Received offer but no active session in sessionRef!"); + return; + } + console.log("[App] Active session for offer:", JSON.stringify({ + sessionId: activeSession.sessionId, + serverIp: activeSession.serverIp, + signalingServer: activeSession.signalingServer, + mediaConnectionInfo: activeSession.mediaConnectionInfo, + iceServersCount: activeSession.iceServers?.length, + })); + + if (!clientRef.current && videoRef.current && audioRef.current) { + clientRef.current = new GfnWebRtcClient({ + videoElement: videoRef.current, + audioElement: audioRef.current, + autoFullScreen: settings.autoFullScreen, + microphoneMode: settings.microphoneMode, + microphoneDeviceId: settings.microphoneDeviceId || undefined, + mouseSensitivity: settings.mouseSensitivity, + mouseAcceleration: settings.mouseAcceleration, + onLog: (line: string) => console.log(`[WebRTC] ${line}`), + onStats: (stats) => diagnosticsStore.set(stats), + onEscHoldProgress: (visible, progress) => { + setEscHoldReleaseIndicator({ visible, progress }); + }, + onTimeWarning: (warning) => { + setRemoteStreamWarning({ + code: warning.code, + message: warningMessage(warning.code), + tone: warningTone(warning.code), + secondsLeft: warning.secondsLeft, + }); + }, + onMicStateChange: (state) => { + console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); + }, + }); + if (settings.microphoneMode !== "disabled") { + void clientRef.current.startMicrophone(); + } + } + + if (clientRef.current) { + await clientRef.current.handleOffer(event.sdp, activeSession, { + codec: settings.codec, + colorQuality: settings.colorQuality, + resolution: settings.resolution, + fps: settings.fps, + maxBitrateKbps: settings.maxBitrateMbps * 1000, + }); + setLaunchError(null); + setStreamStatus("streaming"); + resetSignalingRecoveryState({ keepExplicitShutdown: true }); + } + } else if (event.type === "remote-ice") { + await clientRef.current?.addRemoteCandidate(event.candidate); + } else if (event.type === "disconnected") { + console.warn("Signaling disconnected:", event.reason); + const recovered = await attemptSessionRecovery(event.reason).catch((error) => { + console.error("[Recovery] Signaling recovery failed:", error); + throw error; + }); + if (!recovered) { + if ( + signalingRecoveryRef.current.explicitShutdown + || !RECOVERABLE_STREAM_STATUSES.includes(streamStatusRef.current) + ) { + console.log("[Recovery] Ignoring disconnect after explicit shutdown or non-recoverable status"); + return; + } + clientRef.current?.dispose(); + clientRef.current = null; + setLaunchError({ + stage: streamStatusToLoadingStage(streamStatusRef.current), + title: "Session Connection Lost", + description: "The connection to your running session was lost and could not be restored automatically. Try resuming it again from the app.", + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + } + } else if (event.type === "error") { + console.error("Signaling error:", event.message); + } + } catch (error) { + if ( + signalingRecoveryRef.current.explicitShutdown + || !RECOVERABLE_STREAM_STATUSES.includes(streamStatusRef.current) + ) { + console.log("[Recovery] Suppressing signaling error after explicit shutdown or non-recoverable status"); + return; + } + console.error("Signaling event error:", error); + clientRef.current?.dispose(); + clientRef.current = null; + const message = error instanceof Error ? error.message : "The connection to the running session was lost and resume failed."; + setLaunchError({ + stage: streamStatusToLoadingStage(streamStatusRef.current), + title: "Session Connection Lost", + description: message, + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + } }); - }, [authSession, effectiveStreamingBaseUrl, findGameContextForSession, resetStatsOverlayToPreference, settings]); + + return () => unsubscribe(); + }, [attemptSessionRecovery, diagnosticsStore, refreshNavbarActiveSession, resetLaunchRuntime, resetSignalingRecoveryState, settings]); // Play game handler const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string }) => { @@ -2562,6 +2867,7 @@ export function App(): JSX.Element { launchInFlightRef.current = true; launchAbortRef.current = false; + resetSignalingRecoveryState(); let loadingStep: StreamLoadingStatus = "queue"; const updateLoadingStep = (next: StreamLoadingStatus): void => { loadingStep = next; @@ -2608,6 +2914,7 @@ export function App(): JSX.Element { } const numericAppId = Number(appId); + signalingRecoveryRef.current.appId = numericAppId; const matchedGameContext = findSessionContextForAppId(allKnownGames, variantByGameId, numericAppId) ?? { game, variant: selectedVariant, @@ -2824,6 +3131,7 @@ export function App(): JSX.Element { claimAndConnectSession, effectiveStreamingBaseUrl, refreshNavbarActiveSession, + resetSignalingRecoveryState, resetLaunchRuntime, resetStatsOverlayToPreference, selectedProvider, @@ -2995,6 +3303,7 @@ export function App(): JSX.Element { navbarSessionActionInFlightRef.current = "resume"; launchInFlightRef.current = true; + resetSignalingRecoveryState(); setIsResumingNavbarSession(true); let loadingStep: StreamLoadingStatus = "setup"; const updateLoadingStep = (next: StreamLoadingStatus): void => { @@ -3018,6 +3327,7 @@ export function App(): JSX.Element { updateLoadingStep("setup"); try { + signalingRecoveryRef.current.appId = navbarActiveSession.appId; await claimAndConnectSession(navbarActiveSession); setNavbarActiveSession(null); } catch (error) { @@ -3040,6 +3350,7 @@ export function App(): JSX.Element { navbarActiveSession, findGameContextForSession, refreshNavbarActiveSession, + resetSignalingRecoveryState, resetLaunchRuntime, resetStatsOverlayToPreference, selectedProvider, @@ -3100,6 +3411,7 @@ export function App(): JSX.Element { if (status !== "idle" && status !== "streaming") { launchAbortRef.current = true; } + markExplicitSignalingShutdown(); await window.openNow.disconnectSignaling(); const current = sessionRef.current; @@ -3123,7 +3435,7 @@ export function App(): JSX.Element { } catch (error) { console.error("Stop failed:", error); } - }, [endPlaytimeSession, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt, stopSessionByTarget, streamingGame]); + }, [endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt, stopSessionByTarget, streamingGame]); const handleSwitchGame = useCallback(async (game: GameInfo) => { setControllerOverlayOpen(false); @@ -3174,12 +3486,13 @@ export function App(): JSX.Element { }, [handleStopStream, handlePlayGame]); const handleDismissLaunchError = useCallback(async () => { + markExplicitSignalingShutdown(); await window.openNow.disconnectSignaling().catch(() => {}); clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime(); void refreshNavbarActiveSession(); - }, [refreshNavbarActiveSession, resetLaunchRuntime]); + }, [markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime]); const releasePointerLockIfNeeded = useCallback(async () => { if (document.pointerLockElement) {