Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -1370,7 +1370,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
110 changes: 98 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 @@ -1151,6 +1154,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 @@ -2621,6 +2627,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 @@ -2644,31 +2712,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 @@ -2680,6 +2750,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 @@ -2780,20 +2856,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 @@ -2829,6 +2911,10 @@ 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");
Comment thread
Jayian1890 marked this conversation as resolved.
Outdated
},
});
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 @@ -1657,15 +1668,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 @@ -3783,6 +3838,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 @@ -3818,6 +3877,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 @@ -4060,6 +4123,10 @@ export class GfnWebRtcClient {
await this.pc.addIceCandidate(init);
}

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

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

Expand Down
23 changes: 23 additions & 0 deletions opennow-stable/src/renderer/src/lib/signalingRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// <reference types="node" />

import test from "node:test";
import assert from "node:assert/strict";

import { SIGNALING_RECOVERY_BASE_DELAYS_MS, signalingRecoveryDelayMs } from "./signalingRecovery";

test("signalingRecoveryDelayMs returns 0 for non-positive base", () => {
assert.equal(signalingRecoveryDelayMs(0), 0);
assert.equal(signalingRecoveryDelayMs(-100), 0);
});

test("signalingRecoveryDelayMs applies deterministic jitter from rand", () => {
const alwaysLow = () => 0;
const alwaysHigh = () => 0.999999;
assert.equal(signalingRecoveryDelayMs(1000, alwaysLow), Math.floor(1000 * 0.85));
assert.equal(signalingRecoveryDelayMs(1000, alwaysHigh), Math.floor(1000 * 1.1499997));
});

test("SIGNALING_RECOVERY_BASE_DELAYS_MS has expected length and first step zero", () => {
assert.ok(SIGNALING_RECOVERY_BASE_DELAYS_MS.length >= 3);
assert.equal(SIGNALING_RECOVERY_BASE_DELAYS_MS[0], 0);
});
Loading
Loading