Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion opennow-stable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"dist": "npm run build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder",
"dist:signed": "npm run build && electron-builder",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.json",
"test": "tsx --test src/shared/gfn.test.ts src/renderer/src/lib/launchOwnership.test.ts src/renderer/src/components/GameCard.test.ts src/renderer/src/gfn/inputProtocol.test.ts src/renderer/src/gfn/webrtcClient.test.ts"
"test": "tsx --test src/shared/gfn.test.ts src/renderer/src/lib/launchOwnership.test.ts src/renderer/src/lib/signalingRecovery.test.ts src/renderer/src/components/GameCard.test.ts src/renderer/src/gfn/inputProtocol.test.ts src/renderer/src/gfn/webrtcClient.test.ts"
},
"dependencies": {
"discord-rpc": "^4.0.1",
Expand Down
3 changes: 3 additions & 0 deletions opennow-stable/src/main/gfn/signaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export class GfnSignalingClient {

async connect(): Promise<void> {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
// Renderer recovery waits for this event after connectSignaling; emit even when
// the socket was already open so Stage A can complete without a timeout.
this.emit({ type: "connected" });
return;
}

Expand Down
5 changes: 4 additions & 1 deletion opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1406,7 +1406,10 @@ function registerIpcHandlers(): void {
async (_event, payload: SignalingConnectRequest): Promise<void> => {
const nextKey = `${payload.sessionId}|${payload.signalingServer}|${payload.signalingUrl ?? ""}`;
if (signalingClient && signalingClientKey === nextKey) {
console.log("[Signaling] Reuse existing signaling connection (duplicate connect request ignored)");
// WebSocket may have closed while the session key is unchanged; always
// run connect() so a dead socket is reopened (connect() no-ops if OPEN).
console.log("[Signaling] Same session key — ensuring signaling WebSocket is connected");
await signalingClient.connect();
return;
}

Expand Down
123 changes: 111 additions & 12 deletions opennow-stable/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ import { usePlaytime } from "./utils/usePlaytime";
import { createStreamDiagnosticsStore } from "./utils/streamDiagnosticsStore";
import { loadStoredCodecResults, saveStoredCodecResults, testCodecSupport, type CodecTestResult } from "./lib/codecDiagnostics";
import { chooseAccountLinked, getEpicOwnershipLaunchError } from "./lib/launchOwnership";
import {
SIGNALING_RECOVERY_BASE_DELAYS_MS,
signalingRecoveryDelayMs,
} from "./lib/signalingRecovery";

// UI Components
import { LoginScreen } from "./components/LoginScreen";
Expand Down Expand Up @@ -186,7 +190,6 @@ type SignalingRecoveryState = {

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");

Expand Down Expand Up @@ -1152,6 +1155,9 @@ export function App(): JSX.Element {
appId: null,
generation: 0,
});
/** Incremented on each signaling `connected` event (generation wait for Stage A). */
const signalingConnectedGenerationRef = useRef(0);
const attemptSessionRecoveryRef = useRef<(reason: string) => Promise<boolean>>(async () => false);
const exitPromptResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const adReportQueueRef = useRef<Promise<void>>(Promise.resolve());
const adReportStateRef = useRef<Record<string, SessionAdAction>>({});
Expand Down Expand Up @@ -2654,6 +2660,68 @@ export function App(): JSX.Element {
await applyClaimedSessionAndConnect(claimed);
}, [applyClaimedSessionAndConnect, authSession, effectiveStreamingBaseUrl, findGameContextForSession, resolveSessionClaimAppId, settings]);

const waitForNextSignalingGeneration = useCallback(async (startGen: number, timeoutMs: number): Promise<boolean> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (signalingConnectedGenerationRef.current > startGen) {
return true;
}
// eslint-disable-next-line no-await-in-loop
await sleep(50);
}
return signalingConnectedGenerationRef.current > startGen;
}, []);

const attemptSignalingOnlyRecovery = useCallback(async (recoveryGeneration: number, reason: string): Promise<boolean> => {
if (!isRecoveryGenerationCurrent(recoveryGeneration)) {
return false;
}
const session = sessionRef.current;
const client = clientRef.current;
if (!session || !client) {
console.log("[Recovery] Stage A skipped: no session or WebRTC client");
return false;
}
const cs = client.getConnectionState();
if (cs !== "connected" && cs !== "connecting") {
console.log("[Recovery] Stage A skipped: peer connection not healthy:", cs);
return false;
}

console.log(`[Recovery] Stage A: signaling-only reconnect (${reason})`);
try {
const startGen = signalingConnectedGenerationRef.current;
await window.openNow.connectSignaling({
sessionId: session.sessionId,
signalingServer: session.signalingServer,
signalingUrl: session.signalingUrl,
});
if (!isRecoveryGenerationCurrent(recoveryGeneration)) {
return false;
}
const signalingOk = await waitForNextSignalingGeneration(startGen, 8000);
if (!signalingOk) {
console.warn("[Recovery] Stage A: timed out waiting for signaling connected");
return false;
}
if (!isRecoveryGenerationCurrent(recoveryGeneration)) {
return false;
}
const cs2 = client.getConnectionState();
if (cs2 !== "connected" && cs2 !== "connecting") {
console.warn("[Recovery] Stage A: peer connection unhealthy after signaling reconnect:", cs2);
return false;
}
resetSignalingRecoveryState({ keepExplicitShutdown: true });
setStreamStatus("streaming");
console.log("[Recovery] Stage A succeeded");
return true;
} catch (error) {
console.warn("[Recovery] Stage A failed:", error);
return false;
}
}, [isRecoveryGenerationCurrent, resetSignalingRecoveryState, waitForNextSignalingGeneration]);

const attemptSessionRecovery = useCallback(async (reason: string): Promise<boolean> => {
const recoveryState = signalingRecoveryRef.current;
const recoveryGeneration = recoveryState.generation;
Expand All @@ -2677,31 +2745,33 @@ export function App(): JSX.Element {
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) {
if (recoveryState.attemptCount >= SIGNALING_RECOVERY_BASE_DELAYS_MS.length) {
console.warn("[Recovery] Recovery budget exhausted");
return false;
}

const attemptPromise = (async (): Promise<boolean> => {
const stageA = await attemptSignalingOnlyRecovery(recoveryGeneration, reason);
if (stageA) {
recoveryState.attemptCount = 0;
return true;
}

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) {
while (recoveryState.attemptCount < SIGNALING_RECOVERY_BASE_DELAYS_MS.length) {
const attemptIndex = recoveryState.attemptCount;
recoveryState.attemptCount += 1;
const attemptNumber = recoveryState.attemptCount;
const attemptDelayMs = SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS[attemptIndex] ?? 0;
const baseDelayMs = SIGNALING_RECOVERY_BASE_DELAYS_MS[attemptIndex] ?? 0;
const attemptDelayMs = signalingRecoveryDelayMs(baseDelayMs);

console.warn(
`[Recovery] Attempt ${attemptNumber}/${SIGNALING_RECOVERY_ATTEMPT_DELAYS_MS.length} after signaling disconnect: ${reason}`,
`[Recovery] Stage B attempt ${attemptNumber}/${SIGNALING_RECOVERY_BASE_DELAYS_MS.length} (full reclaim): ${reason}`,
);

if (attemptDelayMs > 0) {
Expand All @@ -2713,6 +2783,12 @@ export function App(): JSX.Element {
}

try {
const authResult = await window.openNow.getAuthSession({ forceRefresh: true });
const token = authResult.session?.tokens.idToken ?? authResult.session?.tokens.accessToken;
if (!token) {
throw new Error("Connection to the running session was lost and your login token is no longer available for resume.");
}

const activeSessions = await window.openNow.getActiveSessions(token, effectiveStreamingBaseUrl);
if (!isRecoveryGenerationCurrent(recoveryGeneration)) {
console.log("[Recovery] Aborting attempt after active session lookup due to stale generation");
Expand Down Expand Up @@ -2813,20 +2889,26 @@ export function App(): JSX.Element {
}
}, [
applyClaimedSessionAndConnect,
authSession,
attemptSignalingOnlyRecovery,
effectiveStreamingBaseUrl,
findGameContextForSession,
isRecoveryGenerationCurrent,
resolveSessionClaimAppId,
settings,
]);

useEffect(() => {
attemptSessionRecoveryRef.current = attemptSessionRecovery;
}, [attemptSessionRecovery]);

// 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") {
if (event.type === "connected") {
signalingConnectedGenerationRef.current += 1;
} else if (event.type === "offer") {
const activeSession = sessionRef.current;
if (!activeSession) {
console.warn("[App] Received offer but no active session in sessionRef!");
Expand Down Expand Up @@ -2862,6 +2944,23 @@ export function App(): JSX.Element {
onMicStateChange: (state) => {
console.log(`[App] Mic state: ${state.state}${state.deviceLabel ? ` (${state.deviceLabel})` : ""}`);
},
onTransportDegraded: (detail) => {
console.warn("[App] Transport degraded:", detail);
void attemptSessionRecoveryRef.current("peer-connection-degraded").catch((error) => {
console.error("[Recovery] Transport recovery failed:", error);
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;
});
},
});
if (settings.microphoneMode !== "disabled") {
void clientRef.current.startMicrophone();
Expand Down
8 changes: 4 additions & 4 deletions opennow-stable/src/renderer/src/gfn/inputProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,10 +703,10 @@ export class InputEncoder {
const bytes = new Uint8Array(GAMEPAD_PACKET_SIZE);
const view = new DataView(bytes.buffer);

// Match official GFN client's gl() function exactly (vendor_beautified.js line 13469-13470):
// gl(i, u, m, w, P, L, $=0, ae=0) where:
// i=DataView, u=base offset (0), m=gamepad index, w=buttons,
// P=triggers, L=axes[4], $=timestamp, ae=bitmap
// Match official GFN web client gamepad encoder: webpackChunkgfn_mall
// vendor.48caacc87d5222af.js minifies it as bl(i,d,m,C,x,F,K=0,ae=0) with the same layout
// (type 12 LE, outer 26, index, bitmap, inner 20, buttons, packed LT/RT, four i16 axes, 0, 85, 0, u64 ts LE).
// Older decompiles call this gl() (vendor_beautified.js ~13469): same field order/endianness.

// Offset 0x00: Type (u32 LE) - event type 12
view.setUint32(0, INPUT_GAMEPAD, true);
Expand Down
67 changes: 67 additions & 0 deletions opennow-stable/src/renderer/src/gfn/webrtcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ export interface StreamTimeWarning {
secondsLeft?: number;
}

export interface TransportDegradedDetail {
reason: string;
iceConnectionState: string;
connectionState: string;
}

interface ClientOptions {
videoElement: HTMLVideoElement;
audioElement: HTMLAudioElement;
Expand All @@ -183,6 +189,8 @@ interface ClientOptions {
onStats?: (stats: StreamDiagnostics) => void;
onTimeWarning?: (warning: StreamTimeWarning) => void;
onMicStateChange?: (state: MicStateChange) => void;
/** Fired once per episode after ICE/PC stays in a bad state (debounced). */
onTransportDegraded?: (detail: TransportDegradedDetail) => void;
}

function timestampUs(sourceTimestampMs?: number): bigint {
Expand Down Expand Up @@ -551,6 +559,8 @@ export class GfnWebRtcClient {
private pendingMouseDyFloat = 0;
private inputCleanup: Array<() => void> = [];
private queuedCandidates: RTCIceCandidateInit[] = [];
private transportDegradeTimer: number | null = null;
private transportDegradeNotified = false;

// Input mode: all input types (mouse, keyboard, gamepad) work simultaneously
// Removed exclusive mode switching to allow concurrent input
Expand Down Expand Up @@ -579,6 +589,7 @@ export class GfnWebRtcClient {
private static readonly RUMBLE_EFFECT_MS = 500;
private static readonly RUMBLE_THROTTLE_MS = 500;
private static readonly HAPTICS_LOG_INTERVAL_MS = 5000;
private static readonly TRANSPORT_DEGRADE_DEBOUNCE_MS = 2500;

// Gamepad bitmap sent at packet offset 8, matching official client's this.nu field:
// bit i (0-3) = connected, bit i+8 = Xbox/xinput style device.
Expand Down Expand Up @@ -1660,15 +1671,59 @@ export class GfnWebRtcClient {
});
}

private resetTransportDegradeEpisode(): void {
if (this.transportDegradeTimer !== null) {
window.clearTimeout(this.transportDegradeTimer);
this.transportDegradeTimer = null;
}
this.transportDegradeNotified = false;
}

private scheduleTransportDegradeCheck(pc: RTCPeerConnection): void {
if (!this.options.onTransportDegraded) {
return;
}
const ice = pc.iceConnectionState;
const cs = pc.connectionState;
const suspicious =
ice === "disconnected" || ice === "failed" || cs === "disconnected" || cs === "failed";
if (!suspicious) {
return;
}
if (this.transportDegradeTimer !== null) {
window.clearTimeout(this.transportDegradeTimer);
}
this.transportDegradeTimer = window.setTimeout(() => {
this.transportDegradeTimer = null;
if (!this.pc || this.pc !== pc) {
return;
}
const ice2 = pc.iceConnectionState;
const cs2 = pc.connectionState;
const stillBad =
ice2 === "disconnected" || ice2 === "failed" || cs2 === "disconnected" || cs2 === "failed";
if (stillBad && !this.transportDegradeNotified) {
this.transportDegradeNotified = true;
this.options.onTransportDegraded?.({
reason: "peer-connection-degraded-sustained",
iceConnectionState: ice2,
connectionState: cs2,
});
}
}, GfnWebRtcClient.TRANSPORT_DEGRADE_DEBOUNCE_MS);
}

private cleanupPeerConnection(): void {
this.clearTimers();
this.resetTransportDegradeEpisode();
this.detachInputCapture();
this.closeDataChannels();
this.cleanupAudioRouting();
if (this.pc) {
this.pc.onicecandidate = null;
this.pc.ontrack = null;
this.pc.onconnectionstatechange = null;
this.pc.oniceconnectionstatechange = null;
this.pc.ondatachannel = null;
this.pc.close();
this.pc = null;
Expand Down Expand Up @@ -3900,6 +3955,10 @@ export class GfnWebRtcClient {
this.diagnostics.connectionState = pc.connectionState;
this.emitStats();
this.log(`Peer connection state: ${pc.connectionState}`);
if (pc.iceConnectionState === "connected" && pc.connectionState === "connected") {
this.resetTransportDegradeEpisode();
}
this.scheduleTransportDegradeCheck(pc);
};

pc.ondatachannel = (event) => {
Expand Down Expand Up @@ -3935,6 +3994,10 @@ export class GfnWebRtcClient {

pc.oniceconnectionstatechange = () => {
this.log(`ICE connection state: ${pc.iceConnectionState}`);
if (pc.iceConnectionState === "connected" && pc.connectionState === "connected") {
this.resetTransportDegradeEpisode();
}
this.scheduleTransportDegradeCheck(pc);
};

pc.onicegatheringstatechange = () => {
Expand Down Expand Up @@ -4177,6 +4240,10 @@ export class GfnWebRtcClient {
await this.pc.addIceCandidate(init);
}

getConnectionState(): RTCPeerConnectionState | null {
return this.pc?.connectionState ?? null;
}

dispose(): void {
this.cleanupPeerConnection();

Expand Down
Loading
Loading