diff --git a/opennow-stable/src/main/gfn/signaling.ts b/opennow-stable/src/main/gfn/signaling.ts index c15e2c4..dda3c3d 100644 --- a/opennow-stable/src/main/gfn/signaling.ts +++ b/opennow-stable/src/main/gfn/signaling.ts @@ -30,8 +30,14 @@ export class GfnSignalingClient { private peerId = 2; private peerName = `peer-${Math.floor(Math.random() * 10_000_000_000)}`; private ackCounter = 0; + private maxReceivedAckId = 0; private heartbeatTimer: NodeJS.Timeout | null = null; + private reconnectTimer: NodeJS.Timeout | null = null; + private reconnectAttempts = 0; + private manualDisconnect = false; private listeners = new Set<(event: MainToRendererSignalingEvent) => void>(); + private static readonly MAX_RECONNECT_ATTEMPTS = 6; + private static readonly RECONNECT_BASE_DELAY_MS = 750; constructor( private readonly signalingServer: string, @@ -39,7 +45,7 @@ export class GfnSignalingClient { private readonly signalingUrl?: string, ) {} - private buildSignInUrl(): string { + private buildSignInUrl(reconnect = false): string { // Match Rust behavior: extract host:port from signalingUrl if available, // since the signalingUrl contains the real server address (which may differ // from signalingServer when the resource path was an rtsps:// URL) @@ -58,7 +64,7 @@ export class GfnSignalingClient { : `${this.signalingServer}:443`; } - const url = `wss://${serverWithPort}/nvst/sign_in?peer_id=${this.peerName}&version=2`; + const url = `wss://${serverWithPort}/nvst/sign_in?peer_id=${this.peerName}&version=2&peer_role=1${reconnect ? "&reconnect=1" : ""}`; console.log("[Signaling] URL:", url, "(server:", this.signalingServer, ", signalingUrl:", this.signalingUrl, ")"); return url; } @@ -88,9 +94,7 @@ export class GfnSignalingClient { private setupHeartbeat(): void { this.clearHeartbeat(); - this.heartbeatTimer = setInterval(() => { - this.sendJson({ hb: 1 }); - }, 5000); + // Official client does not proactively send signaling hb packets. } private clearHeartbeat(): void { @@ -100,6 +104,47 @@ export class GfnSignalingClient { } } + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private scheduleReconnect(closeReason: string): void { + if (this.manualDisconnect) { + return; + } + if (this.reconnectAttempts >= GfnSignalingClient.MAX_RECONNECT_ATTEMPTS) { + this.emit({ type: "disconnected", reason: `${closeReason} (reconnect exhausted)` }); + return; + } + if (this.reconnectTimer) { + return; + } + + this.reconnectAttempts += 1; + const attempt = this.reconnectAttempts; + const delayMs = Math.min( + 5000, + GfnSignalingClient.RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt - 1), + ); + this.emit({ + type: "log", + message: `Signaling reconnect attempt ${attempt}/${GfnSignalingClient.MAX_RECONNECT_ATTEMPTS} in ${delayMs}ms`, + }); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.connectSocket(true).catch((error) => { + const errorMsg = `Signaling reconnect failed: ${String(error)}`; + console.error("[Signaling]", errorMsg); + this.emit({ type: "error", message: errorMsg }); + this.scheduleReconnect(errorMsg); + }); + }, delayMs); + } + private sendPeerInfo(): void { this.sendJson({ ackid: this.nextAckId(), @@ -117,20 +162,27 @@ export class GfnSignalingClient { } async connect(): Promise { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { + if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } + this.manualDisconnect = false; + this.clearReconnectTimer(); + this.reconnectAttempts = 0; + this.maxReceivedAckId = 0; + await this.connectSocket(false); + } - const url = this.buildSignInUrl(); + private async connectSocket(reconnect: boolean): Promise { + const url = this.buildSignInUrl(reconnect); const protocol = `x-nv-sessionid.${this.sessionId}`; - console.log("[Signaling] Connecting to:", url); + console.log("[Signaling] Connecting to:", url, reconnect ? "(reconnect)" : ""); console.log("[Signaling] Session ID:", this.sessionId); console.log("[Signaling] Protocol:", protocol); await new Promise((resolve, reject) => { - // Extract host:port for the Host header (matching Rust behavior) const urlHost = url.replace(/^wss?:\/\//, "").split("/")[0]; + let reconnectStabilized = !reconnect; const ws = new WebSocket(url, protocol, { rejectUnauthorized: false, @@ -143,28 +195,66 @@ export class GfnSignalingClient { }); this.ws = ws; + let opened = false; ws.once("error", (error) => { - this.emit({ type: "error", message: `Signaling connect failed: ${String(error)}` }); - reject(error); + if (!opened) { + this.emit({ type: "error", message: `Signaling connect failed: ${String(error)}` }); + reject(error); + return; + } + const errorMsg = String(error); + console.error("[Signaling] WebSocket error during session:", errorMsg); + this.emit({ type: "error", message: `Signaling session error: ${errorMsg}` }); }); ws.once("open", () => { - this.sendPeerInfo(); - this.setupHeartbeat(); + opened = true; + this.manualDisconnect = false; + this.clearReconnectTimer(); + if (!reconnect) { + this.sendPeerInfo(); + this.setupHeartbeat(); + } this.emit({ type: "connected" }); resolve(); }); ws.on("message", (raw) => { + if (!reconnectStabilized && this.reconnectAttempts > 0) { + reconnectStabilized = true; + this.reconnectAttempts = 0; + this.emit({ type: "log", message: "Signaling reconnect stabilized" }); + } const text = typeof raw === "string" ? raw : raw.toString("utf8"); this.handleMessage(text); }); - ws.on("close", (_code, reason) => { + ws.on("close", (code, reason) => { + if (this.ws === ws) { + this.ws = null; + } this.clearHeartbeat(); const reasonText = typeof reason === "string" ? reason : reason.toString("utf8"); - this.emit({ type: "disconnected", reason: reasonText || "socket closed" }); + const closeReason = reasonText || "socket closed"; + console.log(`[Signaling] WebSocket closed - code: ${code}, reason: "${closeReason}"`); + + if (!opened) { + reject(new Error(`Signaling socket closed before open: ${closeReason} (code: ${code})`)); + return; + } + + if (this.manualDisconnect) { + this.emit({ type: "disconnected", reason: `${closeReason} (code: ${code})` }); + return; + } + + if (code === 1006 || code === 1011 || code === 1001) { + this.scheduleReconnect(`${closeReason} (code: ${code})`); + return; + } + + this.emit({ type: "disconnected", reason: `${closeReason} (code: ${code})` }); }); }); } @@ -178,15 +268,22 @@ export class GfnSignalingClient { return; } + if (parsed.hb) { + // Official client ignores signaling hb payloads. + return; + } + + let shouldProcessPayload = true; if (typeof parsed.ackid === "number") { - const shouldAck = parsed.peer_info?.id !== this.peerId; - if (shouldAck) { - this.sendJson({ ack: parsed.ackid }); + if (parsed.ackid <= this.maxReceivedAckId) { + shouldProcessPayload = false; + } else { + this.maxReceivedAckId = parsed.ackid; } + this.sendJson({ ack: this.maxReceivedAckId }); } - if (parsed.hb) { - this.sendJson({ hb: 1 }); + if (!shouldProcessPayload) { return; } @@ -272,7 +369,10 @@ export class GfnSignalingClient { } disconnect(): void { + this.manualDisconnect = true; this.clearHeartbeat(); + this.clearReconnectTimer(); + this.reconnectAttempts = 0; if (this.ws) { this.ws.close(); this.ws = null; diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 8219783..f6ff175 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -183,6 +183,8 @@ async function createMainWindow(): Promise { contextIsolation: true, nodeIntegration: false, sandbox: false, + backgroundThrottling: false, + v8CacheOptions: "code", }, }); @@ -441,6 +443,17 @@ function registerIpcHandlers(): void { return signalingClient.sendIceCandidate(payload); }); + // Power save blocker handlers - DISABLED to fix Windows stream start issues + ipcMain.handle(IPC_CHANNELS.POWER_SAVE_BLOCKER_START, async (): Promise => { + // Disabled: power save blocker was causing stream start issues on Windows after sleep + console.log("[Main] Power save blocker start requested (disabled)"); + }); + + ipcMain.handle(IPC_CHANNELS.POWER_SAVE_BLOCKER_STOP, async (): Promise => { + // Disabled: power save blocker was causing stream start issues on Windows after sleep + console.log("[Main] Power save blocker stop requested (disabled)"); + }); + // Toggle fullscreen via IPC (for completeness) ipcMain.handle(IPC_CHANNELS.TOGGLE_FULLSCREEN, async () => { if (mainWindow && !mainWindow.isDestroyed()) { diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 638397e..0914fec 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -76,6 +76,8 @@ const api: PreloadApi = { resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET), exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format), pingRegions: (regions: StreamRegion[]) => ipcRenderer.invoke(IPC_CHANNELS.PING_REGIONS, regions), + startPowerSaveBlocker: () => ipcRenderer.invoke(IPC_CHANNELS.POWER_SAVE_BLOCKER_START), + stopPowerSaveBlocker: () => ipcRenderer.invoke(IPC_CHANNELS.POWER_SAVE_BLOCKER_STOP), }; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index c9388f1..d3adf10 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -154,6 +154,10 @@ function defaultDiagnostics(): StreamDiagnostics { serverRegion: "", micState: "uninitialized", micEnabled: false, + cursorVisible: false, + cursorX: 0, + cursorY: 0, + cursorType: 'arrow', }; } @@ -371,11 +375,13 @@ export function App(): JSX.Element { // Refs const videoRef = useRef(null); const audioRef = useRef(null); + const cursorRef = useRef(null); const clientRef = useRef(null); const sessionRef = useRef(null); const hasInitializedRef = useRef(false); const regionsRequestRef = useRef(0); const launchInFlightRef = useRef(false); + const streamStatusRef = useRef("idle"); const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const applyVariantSelections = useCallback((catalog: GameInfo[]): void => { @@ -410,6 +416,10 @@ export function App(): JSX.Element { sessionRef.current = session; }, [session]); + useEffect(() => { + streamStatusRef.current = streamStatus; + }, [streamStatus]); + useEffect(() => { document.body.classList.toggle("controller-mode", controllerConnected); return () => { @@ -754,7 +764,17 @@ export function App(): JSX.Element { // 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 : ""); + const eventDetail = + event.type === "offer" + ? `(SDP ${event.sdp.length} chars)` + : event.type === "remote-ice" + ? event.candidate + : event.type === "disconnected" + ? event.reason + : event.type === "error" || event.type === "log" + ? event.message + : ""; + console.log(`[App] Signaling event: ${event.type}`, eventDetail); try { if (event.type === "offer") { const activeSession = sessionRef.current; @@ -793,6 +813,22 @@ export function App(): JSX.Element { onMicStateChange: (state) => { console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`); }, + onStreamExit: (info) => { + console.warn("[App] Stream exit from control channel:", info); + void window.openNow.disconnectSignaling().catch(() => {}); + clientRef.current?.dispose(); + clientRef.current = null; + setLaunchError({ + stage: "connecting", + title: "Stream Ended by Server", + description: info.nvstResult + ? `The server ended the stream (nvstResult ${info.nvstResult}).` + : "The server ended the stream unexpectedly.", + codeLabel: toCodeLabel(info.gfnErrorCode), + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + launchInFlightRef.current = false; + }, }); // Auto-start microphone if mode is enabled if (settings.microphoneMode !== "disabled") { @@ -810,14 +846,45 @@ export function App(): JSX.Element { }); setLaunchError(null); setStreamStatus("streaming"); + // Power save blocker disabled - was causing stream start issues on Windows after sleep } } else if (event.type === "remote-ice") { await clientRef.current?.addRemoteCandidate(event.candidate); } else if (event.type === "disconnected") { console.warn("Signaling disconnected:", event.reason); + const currentStreamStatus = streamStatusRef.current; + const mediaObject = videoRef.current?.srcObject; + const hasLiveMediaTracks = + mediaObject instanceof MediaStream && + mediaObject.getTracks().some((track) => track.readyState === "live"); + console.warn( + "Signaling disconnected - streamStatus:", + currentStreamStatus, + "videoRef exists:", + !!videoRef.current, + "hasLiveMediaTracks:", + hasLiveMediaTracks, + ); + const reconnectExhausted = event.reason.includes("reconnect exhausted"); + if ((currentStreamStatus === "streaming" || currentStreamStatus === "connecting") && hasLiveMediaTracks && !reconnectExhausted) { + console.warn("[App] Ignoring signaling disconnect while WebRTC media is still live"); + return; + } + if (videoRef.current) { + console.warn("Video element state before dispose:", { + isConnected: videoRef.current.isConnected, + parentElement: videoRef.current.parentElement?.tagName, + paused: videoRef.current.paused, + readyState: videoRef.current.readyState, + videoWidth: videoRef.current.videoWidth, + videoHeight: videoRef.current.videoHeight, + srcObject: videoRef.current.srcObject ? "set" : "null", + }); + } clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime(); + // Power save blocker disabled - was causing stream start issues on Windows after sleep launchInFlightRef.current = false; } else if (event.type === "error") { console.error("Signaling error:", event.message); @@ -1258,9 +1325,11 @@ export function App(): JSX.Element { clientRef.current = null; setNavbarActiveSession(null); resetLaunchRuntime(); + // Power save blocker disabled - was causing stream start issues on Windows after sleep void refreshNavbarActiveSession(); } catch (error) { console.error("Stop failed:", error); + // Power save blocker disabled - was causing stream start issues on Windows after sleep } }, [authSession, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt]); @@ -1269,6 +1338,7 @@ export function App(): JSX.Element { clientRef.current?.dispose(); clientRef.current = null; resetLaunchRuntime(); + // Power save blocker disabled - was causing stream start issues on Windows after sleep void refreshNavbarActiveSession(); }, [refreshNavbarActiveSession, resetLaunchRuntime]); @@ -1503,6 +1573,7 @@ export function App(): JSX.Element { { + const videoEl = localVideoRef.current; + if (!videoEl) return { x: 0, y: 0 }; + + const videoRect = videoEl.getBoundingClientRect(); + const videoWidth = videoEl.videoWidth || videoRect.width || 1920; + const videoHeight = videoEl.videoHeight || videoRect.height || 1200; + + // Match object-fit: contain placement so cursor stays aligned in fullscreen letterbox/pillarbox. + const streamAspect = videoWidth / videoHeight; + const boxAspect = videoRect.width / videoRect.height; + + let renderWidth = videoRect.width; + let renderHeight = videoRect.height; + let offsetX = 0; + let offsetY = 0; + + if (streamAspect > boxAspect) { + renderHeight = videoRect.width / streamAspect; + offsetY = (videoRect.height - renderHeight) / 2; + } else { + renderWidth = videoRect.height * streamAspect; + offsetX = (videoRect.width - renderWidth) / 2; + } + + const scaleX = renderWidth / videoWidth; + const scaleY = renderHeight / videoHeight; + + return { + x: offsetX + stats.cursorX * scaleX - (stats.cursorHotspotX || 0) * scaleX, + y: offsetY + stats.cursorY * scaleY - (stats.cursorHotspotY || 0) * scaleY, + }; + }; + + const cursorPos = getCursorPosition(); + + useEffect(() => { + if (stats.cursorVisible && stats.cursorImageUrl) { + console.log( + `[StreamView] Custom cursor applied (hotspot=${stats.cursorHotspotX || 0},${stats.cursorHotspotY || 0})`, + ); + } + }, [stats.cursorVisible, stats.cursorImageUrl, stats.cursorHotspotX, stats.cursorHotspotY]); + // Focus video element when stream is ready (not connecting anymore) useEffect(() => { if (!isConnecting && localVideoRef.current && hasResolution) { @@ -250,6 +296,7 @@ export function StreamView({ muted tabIndex={0} className="sv-video" + style={{ cursor: stats.cursorVisible ? 'none' : 'default' }} onClick={() => { // Ensure video has focus when clicked for pointer lock to work if (localVideoRef.current && document.activeElement !== localVideoRef.current) { @@ -257,6 +304,27 @@ export function StreamView({ } }} /> + {/* Cursor overlay - displays server-rendered cursor image */} + {stats.cursorVisible && stats.cursorImageUrl && ( + { + console.log('[StreamView] Cursor image failed to load:', e); + }} + /> + )}