Skip to content
Merged
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: 2 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,8 @@
},
"experimentalL4SRequest": "Experimental L4S Request",
"experimentalL4SRequestHint": "Request the GeForce NOW L4S streaming feature on newly created sessions. This does not change browser WebRTC behavior by itself and may be ignored by the service or network path.",
"identifyAsSteamDeck": "Identify as Steam Deck",
"identifyAsSteamDeckHint": "Identify as the Steam Deck GFN client to unlock Deck-specific resolutions and 90 FPS. Video options refresh automatically.",
"launchInConsoleMode": "Console Mode (TV / Big Picture)",
"launchInConsoleModeHint": "Start OpenNOW fullscreen with Controller Mode enabled, like GeForce NOW's TV mode. Sessions ask NVIDIA to launch games gamepad-friendly (big picture) while Console Mode or Controller Mode is active."
},
Expand Down
2 changes: 2 additions & 0 deletions opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { getSettingsManager, type SettingsManager } from "./settings";

import { getActiveSessions } from "./platforms/gfn/cloudmatch";
import { AuthService } from "./platforms/gfn/auth";
import { configureIdentifyAsSteamDeck } from "./platforms/gfn/deviceIdentity";
import { initSessionProxyAuth } from "./platforms/gfn/proxyFetch";
import {
connectDiscordRpc,
Expand Down Expand Up @@ -517,6 +518,7 @@ app.whenReady().then(async () => {
await authService.initialize();

settingsManager = getSettingsManager();
configureIdentifyAsSteamDeck(() => settingsManager.get("identifyAsSteamDeck"));
appUpdater = createAppUpdaterController({
onStateChanged: emitUpdaterStateToRenderer,
automaticChecksEnabled: settingsManager.get("autoCheckForUpdates"),
Expand Down
68 changes: 43 additions & 25 deletions opennow-stable/src/main/platforms/gfn/clientHeaders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import crypto from "node:crypto";

import { GFN_PLAY_ORIGIN as SHARED_GFN_PLAY_ORIGIN, GFN_PLAY_REFERER as SHARED_GFN_PLAY_REFERER } from "@shared/gfn/endpoints";

import {
resolveGfnDeviceIdentity,
type GfnDeviceIdentity,
type GfnDeviceOs,
} from "./deviceIdentity";

const GFN_WINDOWS_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 NVIDIACEFClient/HEAD/debb5919f6 GFN-PC/2.0.80.173";
const GFN_MACOS_USER_AGENT =
Expand All @@ -9,16 +17,14 @@ export const GFN_USER_AGENT = process.platform === "darwin" ? GFN_MACOS_USER_AGE
export const GFN_CLIENT_VERSION = "2.0.80.173";
export const LCARS_CLIENT_ID = "ec7e38d4-03af-4b58-b131-cfb0495903ab";

import { GFN_PLAY_ORIGIN as SHARED_GFN_PLAY_ORIGIN, GFN_PLAY_REFERER as SHARED_GFN_PLAY_REFERER } from "@shared/gfn/endpoints";

export const GFN_PLAY_ORIGIN = SHARED_GFN_PLAY_ORIGIN;
export const GFN_PLAY_REFERER = SHARED_GFN_PLAY_REFERER;
export const NVIDIA_FILE_ORIGIN = "https://nvfile";
export const NVIDIA_FILE_REFERER = "https://nvfile/";

export type GfnClientStreamer = "NVIDIA-CLASSIC" | "WEBRTC";
export type GfnClientType = "NATIVE" | "BROWSER";
export type GfnDeviceOs = "WINDOWS" | "MACOS" | "LINUX";
export type { GfnDeviceOs };

export function gfnJwtAuthorization(token: string): string {
return `GFNJWT ${token}`;
Expand All @@ -29,13 +35,20 @@ export function bearerAuthorization(token: string): string {
}

export function platformToGfnDeviceOs(platform: NodeJS.Platform = process.platform): GfnDeviceOs {
if (platform === "win32") {
return "WINDOWS";
}
if (platform === "darwin") {
return "MACOS";
return resolveGfnDeviceIdentity({ identifyAsSteamDeck: false, platform }).deviceOs;
}

function applyDeviceIdentityHeaders(
headers: Record<string, string>,
identity: GfnDeviceIdentity,
options?: { includeMakeModel?: boolean },
): void {
headers["nv-device-os"] = identity.deviceOs;
headers["nv-device-type"] = identity.deviceType;
if (options?.includeMakeModel !== false) {
headers["nv-device-make"] = identity.deviceMake;
headers["nv-device-model"] = identity.deviceModel;
}
return "LINUX";
}

export interface NvidiaAuthHeadersOptions {
Expand Down Expand Up @@ -72,11 +85,13 @@ export interface GfnLcarsHeadersOptions {
clientStreamer: GfnClientStreamer;
accept?: string;
deviceOs?: GfnDeviceOs;
identifyAsSteamDeck?: boolean;
includeUserAgent?: boolean;
includeEmptyTokenAuthorization?: boolean;
}

export function buildGfnLcarsHeaders(options: GfnLcarsHeadersOptions): Record<string, string> {
const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: options.identifyAsSteamDeck });
const headers: Record<string, string> = {
Accept: options.accept ?? "application/json",
};
Expand All @@ -89,8 +104,10 @@ export function buildGfnLcarsHeaders(options: GfnLcarsHeadersOptions): Record<st
headers["nv-client-type"] = options.clientType;
headers["nv-client-version"] = GFN_CLIENT_VERSION;
headers["nv-client-streamer"] = options.clientStreamer;
headers["nv-device-os"] = options.deviceOs ?? "WINDOWS";
headers["nv-device-type"] = "DESKTOP";
applyDeviceIdentityHeaders(headers, {
...identity,
deviceOs: options.deviceOs ?? identity.deviceOs,
});

if (options.includeUserAgent) {
headers["User-Agent"] = GFN_USER_AGENT;
Expand All @@ -99,8 +116,12 @@ export function buildGfnLcarsHeaders(options: GfnLcarsHeadersOptions): Record<st
return headers;
}

export function buildGfnGraphQlHeaders(token?: string): Record<string, string> {
return {
export function buildGfnGraphQlHeaders(
token?: string,
options?: { identifyAsSteamDeck?: boolean },
): Record<string, string> {
const identity = resolveGfnDeviceIdentity(options);
const headers: Record<string, string> = {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
Origin: GFN_PLAY_ORIGIN,
Expand All @@ -110,20 +131,19 @@ export function buildGfnGraphQlHeaders(token?: string): Record<string, string> {
"nv-client-type": "NATIVE",
"nv-client-version": GFN_CLIENT_VERSION,
"nv-client-streamer": "NVIDIA-CLASSIC",
"nv-device-os": platformToGfnDeviceOs(),
"nv-device-type": "DESKTOP",
"nv-device-make": "UNKNOWN",
"nv-device-model": "UNKNOWN",
"nv-browser-type": "CHROME",
"User-Agent": GFN_USER_AGENT,
};
applyDeviceIdentityHeaders(headers, identity);
return headers;
}

export interface GfnCloudMatchHeadersOptions {
token: string;
clientId?: string;
deviceId?: string;
includeOrigin?: boolean;
identifyAsSteamDeck?: boolean;
}

function resolveCloudMatchIdentity(options: GfnCloudMatchHeadersOptions): { clientId: string; deviceId: string } {
Expand All @@ -135,6 +155,7 @@ function resolveCloudMatchIdentity(options: GfnCloudMatchHeadersOptions): { clie

export function buildGfnCloudMatchHeaders(options: GfnCloudMatchHeadersOptions): Record<string, string> {
const { clientId, deviceId } = resolveCloudMatchIdentity(options);
const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: options.identifyAsSteamDeck });
const headers: Record<string, string> = {
"User-Agent": GFN_USER_AGENT,
Authorization: gfnJwtAuthorization(options.token),
Expand All @@ -144,12 +165,9 @@ export function buildGfnCloudMatchHeaders(options: GfnCloudMatchHeadersOptions):
"nv-client-streamer": "NVIDIA-CLASSIC",
"nv-client-type": "NATIVE",
"nv-client-version": GFN_CLIENT_VERSION,
"nv-device-make": "UNKNOWN",
"nv-device-model": "UNKNOWN",
"nv-device-os": platformToGfnDeviceOs(),
"nv-device-type": "DESKTOP",
"x-device-id": deviceId,
};
applyDeviceIdentityHeaders(headers, identity);

if (options.includeOrigin !== false) {
headers.Origin = GFN_PLAY_ORIGIN;
Expand All @@ -161,8 +179,8 @@ export function buildGfnCloudMatchHeaders(options: GfnCloudMatchHeadersOptions):

export function buildGfnCloudMatchClaimHeaders(options: GfnCloudMatchHeadersOptions): Record<string, string> {
const { clientId, deviceId } = resolveCloudMatchIdentity(options);

return {
const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: options.identifyAsSteamDeck });
const headers: Record<string, string> = {
"User-Agent": GFN_USER_AGENT,
Authorization: gfnJwtAuthorization(options.token),
"Content-Type": "application/json",
Expand All @@ -172,8 +190,8 @@ export function buildGfnCloudMatchClaimHeaders(options: GfnCloudMatchHeadersOpti
"nv-client-streamer": "NVIDIA-CLASSIC",
"nv-client-type": "NATIVE",
"nv-client-version": GFN_CLIENT_VERSION,
"nv-device-os": platformToGfnDeviceOs(),
"nv-device-type": "DESKTOP",
"x-device-id": deviceId,
};
applyDeviceIdentityHeaders(headers, identity);
return headers;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

import type { CloudMatchRequest } from "./types";
import { buildGfnCloudMatchHeaders } from "./clientHeaders";
import { resolveGfnDeviceIdentity } from "./deviceIdentity";
import { getStableDeviceId } from "./deviceId";
import {
appLaunchModeWireValue,
Expand Down Expand Up @@ -115,9 +116,10 @@ export async function createNetworkTestSession(input: {
}

const { width, height } = parseResolution(input.settings.resolution);
const { clientPlatformName } = resolveGfnDeviceIdentity();
const body = {
netTestRequestData: {
clientPlatformName: "windows",
clientPlatformName,
netTestProfile: {
widthInPixels: width,
heightInPixels: height,
Expand Down Expand Up @@ -220,7 +222,7 @@ export function buildSessionRequestBody(
clientVersion: "30.0",
sdkVersion: "1.0",
streamerVersion: 1,
clientPlatformName: "windows",
clientPlatformName: resolveGfnDeviceIdentity().clientPlatformName,
clientRequestMonitorSettings: [
{
monitorId: 0,
Expand Down Expand Up @@ -303,7 +305,7 @@ export function buildClaimRequestBody(
clientVersion: "30.0",
deviceHashId: deviceId,
internalTitle: null,
clientPlatformName: "windows",
clientPlatformName: resolveGfnDeviceIdentity().clientPlatformName,
metaData: [
{ key: "SubSessionId", value: subSessionId },
{ key: "wssignaling", value: "1" },
Expand Down
79 changes: 79 additions & 0 deletions opennow-stable/src/main/platforms/gfn/deviceIdentity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/// <reference types="node" />

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

import {
buildGfnCloudMatchHeaders,
buildGfnGraphQlHeaders,
buildGfnLcarsHeaders,
} from "./clientHeaders";
import {
STEAM_DECK_DEVICE_IDENTITY,
configureIdentifyAsSteamDeck,
resolveGfnDeviceIdentity,
} from "./deviceIdentity";

test("resolveGfnDeviceIdentity defaults to host desktop DESKTOP/UNKNOWN", () => {
configureIdentifyAsSteamDeck(() => false);
const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: false, platform: "win32" });
assert.equal(identity.deviceOs, "WINDOWS");
assert.equal(identity.deviceType, "DESKTOP");
assert.equal(identity.deviceMake, "UNKNOWN");
assert.equal(identity.deviceModel, "UNKNOWN");
assert.equal(identity.clientPlatformName, "windows");
});

test("resolveGfnDeviceIdentity Steam Deck profile matches official headers", () => {
const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: true });
assert.deepEqual(identity, STEAM_DECK_DEVICE_IDENTITY);
assert.equal(identity.deviceOs, "STEAMOS");
assert.equal(identity.deviceType, "CONSOLE");
assert.equal(identity.deviceMake, "VALVE");
assert.equal(identity.deviceModel, "STEAMDECK");
assert.equal(identity.clientPlatformName, "SteamOS");
});

test("CloudMatch/LCARS/GraphQL headers honor Steam Deck identity", () => {
configureIdentifyAsSteamDeck(() => true);

const cloudMatch = buildGfnCloudMatchHeaders({
token: "token",
clientId: "client",
deviceId: "device",
});
assert.equal(cloudMatch["nv-device-os"], "STEAMOS");
assert.equal(cloudMatch["nv-device-type"], "CONSOLE");
assert.equal(cloudMatch["nv-device-make"], "VALVE");
assert.equal(cloudMatch["nv-device-model"], "STEAMDECK");

const lcars = buildGfnLcarsHeaders({
token: "token",
clientType: "NATIVE",
clientStreamer: "NVIDIA-CLASSIC",
});
assert.equal(lcars["nv-device-os"], "STEAMOS");
assert.equal(lcars["nv-device-type"], "CONSOLE");
assert.equal(lcars["nv-device-make"], "VALVE");
assert.equal(lcars["nv-device-model"], "STEAMDECK");

const graphQl = buildGfnGraphQlHeaders("token");
assert.equal(graphQl["nv-device-os"], "STEAMOS");
assert.equal(graphQl["nv-device-type"], "CONSOLE");
assert.equal(graphQl["nv-device-make"], "VALVE");
assert.equal(graphQl["nv-device-model"], "STEAMDECK");

configureIdentifyAsSteamDeck(() => false);
});

test("explicit identifyAsSteamDeck option overrides settings reader", () => {
configureIdentifyAsSteamDeck(() => false);
const headers = buildGfnCloudMatchHeaders({
token: "token",
clientId: "client",
deviceId: "device",
identifyAsSteamDeck: true,
});
assert.equal(headers["nv-device-os"], "STEAMOS");
assert.equal(headers["nv-device-type"], "CONSOLE");
});
85 changes: 85 additions & 0 deletions opennow-stable/src/main/platforms/gfn/deviceIdentity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* GFN device identity profiles.
*
* Official Steam Deck CEF still uses clientIdentification/MES serviceName `gfn_pc`,
* but advertises itself via nv-device-* headers and clientPlatformName. OpenNOW only
* used Steam Deck headers for QR device-login; this module owns the stream/MES profile.
*/

export type GfnDeviceOs = "WINDOWS" | "MACOS" | "LINUX" | "STEAMOS";
export type GfnDeviceType = "DESKTOP" | "CONSOLE";

export interface GfnDeviceIdentity {
deviceOs: GfnDeviceOs;
deviceType: GfnDeviceType;
deviceMake: string;
deviceModel: string;
/** CloudMatch session / nettest `clientPlatformName` */
clientPlatformName: string;
}

// OpenNOW historically always sent clientPlatformName "windows" on the desktop
// CloudMatch path (even on macOS/Linux). Keep that unless Steam Deck spoof is on.
const DESKTOP_IDENTITY_BY_PLATFORM: Record<"win32" | "darwin" | "linux", GfnDeviceIdentity> = {
win32: {
deviceOs: "WINDOWS",
deviceType: "DESKTOP",
deviceMake: "UNKNOWN",
deviceModel: "UNKNOWN",
clientPlatformName: "windows",
},
darwin: {
deviceOs: "MACOS",
deviceType: "DESKTOP",
deviceMake: "UNKNOWN",
deviceModel: "UNKNOWN",
clientPlatformName: "windows",
},
linux: {
deviceOs: "LINUX",
deviceType: "DESKTOP",
deviceMake: "UNKNOWN",
deviceModel: "UNKNOWN",
clientPlatformName: "windows",
},
};

/** Matches official Steam Deck / SteamOS mall device headers (VALVE + STEAMDECK + CONSOLE). */
export const STEAM_DECK_DEVICE_IDENTITY: Readonly<GfnDeviceIdentity> = Object.freeze({
deviceOs: "STEAMOS",
deviceType: "CONSOLE",
deviceMake: "VALVE",
deviceModel: "STEAMDECK",
clientPlatformName: "SteamOS",
});

let identifyAsSteamDeckReader: (() => boolean) | null = null;

/** Wire from main after settings load so header builders stay free of settings imports. */
export function configureIdentifyAsSteamDeck(reader: () => boolean): void {
identifyAsSteamDeckReader = reader;
}

export function isIdentifyAsSteamDeckEnabled(): boolean {
return identifyAsSteamDeckReader?.() ?? false;
}

export function resolveHostDesktopIdentity(
platform: NodeJS.Platform = process.platform,
): GfnDeviceIdentity {
if (platform === "win32" || platform === "darwin" || platform === "linux") {
return DESKTOP_IDENTITY_BY_PLATFORM[platform];
}
return DESKTOP_IDENTITY_BY_PLATFORM.linux;
}

export function resolveGfnDeviceIdentity(options?: {
identifyAsSteamDeck?: boolean;
platform?: NodeJS.Platform;
}): GfnDeviceIdentity {
const identifyAsSteamDeck = options?.identifyAsSteamDeck ?? isIdentifyAsSteamDeckEnabled();
if (identifyAsSteamDeck) {
return STEAM_DECK_DEVICE_IDENTITY;
}
return resolveHostDesktopIdentity(options?.platform);
}
Loading
Loading