diff --git a/locales/en.json b/locales/en.json index a9318f24..ffed720b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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." }, diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index f5d47eee..d92d6ee2 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -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, @@ -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"), diff --git a/opennow-stable/src/main/platforms/gfn/clientHeaders.ts b/opennow-stable/src/main/platforms/gfn/clientHeaders.ts index 32e89093..4314e588 100644 --- a/opennow-stable/src/main/platforms/gfn/clientHeaders.ts +++ b/opennow-stable/src/main/platforms/gfn/clientHeaders.ts @@ -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 = @@ -9,8 +17,6 @@ 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"; @@ -18,7 +24,7 @@ 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}`; @@ -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, + 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 { @@ -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 { + const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: options.identifyAsSteamDeck }); const headers: Record = { Accept: options.accept ?? "application/json", }; @@ -89,8 +104,10 @@ export function buildGfnLcarsHeaders(options: GfnLcarsHeadersOptions): Record { - return { +export function buildGfnGraphQlHeaders( + token?: string, + options?: { identifyAsSteamDeck?: boolean }, +): Record { + const identity = resolveGfnDeviceIdentity(options); + const headers: Record = { Accept: "application/json, text/plain, */*", "Content-Type": "application/json", Origin: GFN_PLAY_ORIGIN, @@ -110,13 +131,11 @@ export function buildGfnGraphQlHeaders(token?: string): Record { "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 { @@ -124,6 +143,7 @@ export interface GfnCloudMatchHeadersOptions { clientId?: string; deviceId?: string; includeOrigin?: boolean; + identifyAsSteamDeck?: boolean; } function resolveCloudMatchIdentity(options: GfnCloudMatchHeadersOptions): { clientId: string; deviceId: string } { @@ -135,6 +155,7 @@ function resolveCloudMatchIdentity(options: GfnCloudMatchHeadersOptions): { clie export function buildGfnCloudMatchHeaders(options: GfnCloudMatchHeadersOptions): Record { const { clientId, deviceId } = resolveCloudMatchIdentity(options); + const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: options.identifyAsSteamDeck }); const headers: Record = { "User-Agent": GFN_USER_AGENT, Authorization: gfnJwtAuthorization(options.token), @@ -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; @@ -161,8 +179,8 @@ export function buildGfnCloudMatchHeaders(options: GfnCloudMatchHeadersOptions): export function buildGfnCloudMatchClaimHeaders(options: GfnCloudMatchHeadersOptions): Record { const { clientId, deviceId } = resolveCloudMatchIdentity(options); - - return { + const identity = resolveGfnDeviceIdentity({ identifyAsSteamDeck: options.identifyAsSteamDeck }); + const headers: Record = { "User-Agent": GFN_USER_AGENT, Authorization: gfnJwtAuthorization(options.token), "Content-Type": "application/json", @@ -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; } diff --git a/opennow-stable/src/main/platforms/gfn/cloudmatchSessionRequest.ts b/opennow-stable/src/main/platforms/gfn/cloudmatchSessionRequest.ts index 5708e423..a00be970 100644 --- a/opennow-stable/src/main/platforms/gfn/cloudmatchSessionRequest.ts +++ b/opennow-stable/src/main/platforms/gfn/cloudmatchSessionRequest.ts @@ -9,6 +9,7 @@ import { import type { CloudMatchRequest } from "./types"; import { buildGfnCloudMatchHeaders } from "./clientHeaders"; +import { resolveGfnDeviceIdentity } from "./deviceIdentity"; import { getStableDeviceId } from "./deviceId"; import { appLaunchModeWireValue, @@ -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, @@ -220,7 +222,7 @@ export function buildSessionRequestBody( clientVersion: "30.0", sdkVersion: "1.0", streamerVersion: 1, - clientPlatformName: "windows", + clientPlatformName: resolveGfnDeviceIdentity().clientPlatformName, clientRequestMonitorSettings: [ { monitorId: 0, @@ -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" }, diff --git a/opennow-stable/src/main/platforms/gfn/deviceIdentity.test.ts b/opennow-stable/src/main/platforms/gfn/deviceIdentity.test.ts new file mode 100644 index 00000000..c4d0994d --- /dev/null +++ b/opennow-stable/src/main/platforms/gfn/deviceIdentity.test.ts @@ -0,0 +1,79 @@ +/// + +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"); +}); diff --git a/opennow-stable/src/main/platforms/gfn/deviceIdentity.ts b/opennow-stable/src/main/platforms/gfn/deviceIdentity.ts new file mode 100644 index 00000000..32ad34be --- /dev/null +++ b/opennow-stable/src/main/platforms/gfn/deviceIdentity.ts @@ -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 = 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); +} diff --git a/opennow-stable/src/main/platforms/gfn/index.ts b/opennow-stable/src/main/platforms/gfn/index.ts index d4018b66..aa0fdafa 100644 --- a/opennow-stable/src/main/platforms/gfn/index.ts +++ b/opennow-stable/src/main/platforms/gfn/index.ts @@ -29,6 +29,11 @@ export { export { initSessionProxyAuth } from "./proxyFetch"; export { normalizeSessionProxyUrl, sessionProxyHasCredentials } from "./proxyUrl"; export { getStableDeviceId } from "./deviceId"; +export { + STEAM_DECK_DEVICE_IDENTITY, + configureIdentifyAsSteamDeck, + resolveGfnDeviceIdentity, +} from "./deviceIdentity"; export { fetchSubscription, fetchDynamicRegions } from "./subscription"; export { fetchPersistentStorageLocations, diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index a6ce87ac..812376fe 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -150,6 +150,11 @@ export interface Settings { enablePersistingInGameSettings: boolean; /** Experimental request for Low Latency, Low Loss, Scalable throughput on new sessions */ enableL4S: boolean; + /** + * Advertise OpenNOW as the official Steam Deck GFN client via nv-device-* headers + * and clientPlatformName (does not switch OAuth client ID). + */ + identifyAsSteamDeck: boolean; /** Request Cloud G-Sync / Variable Refresh Rate on new sessions */ enableCloudGsync: boolean; /** Hidden diagnostics for native transition recovery and 240 FPS server-side stream changes */ @@ -269,6 +274,7 @@ const DEFAULT_SETTINGS: Settings = { gameLanguage: "en_US", enablePersistingInGameSettings: false, enableL4S: false, + identifyAsSteamDeck: false, enableCloudGsync: false, nativeTransitionDiagnostics: undefined, discordRichPresence: false, @@ -493,6 +499,10 @@ export class SettingsManager { settings.steamControllerCompatibilityMode = false; migrated = true; } + if (typeof settings.identifyAsSteamDeck !== "boolean") { + settings.identifyAsSteamDeck = false; + migrated = true; + } const videoShader = normalizeVideoShaderSettings(settings.videoShader); if (JSON.stringify(settings.videoShader) !== JSON.stringify(videoShader)) { diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 99299cb8..f27157ab 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -231,6 +231,7 @@ export function App(): JSX.Element { gameLanguage: "en_US", enablePersistingInGameSettings: false, enableL4S: false, + identifyAsSteamDeck: false, enableCloudGsync: false, discordRichPresence: false, autoCheckForUpdates: true, @@ -1621,7 +1622,14 @@ export function App(): JSX.Element { if (settingsLoaded) { await window.openNow.setSetting(key, value); } - }, [previewSetting, settingsLoaded]); + if (key === "identifyAsSteamDeck" && authSession) { + try { + await loadSubscriptionInfo(authSession); + } catch (error) { + console.warn("Failed to refresh subscription after Steam Deck identity change:", error); + } + } + }, [authSession, loadSubscriptionInfo, previewSetting, settingsLoaded]); useEffect(() => { if (!settingsLoaded || !subscriptionInfo) { diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index c3ed177b..3e698f31 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -68,6 +68,9 @@ export function SettingsPage({ const [entitledResolutions, setEntitledResolutions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); + const identifyAsSteamDeckRef = useRef(settings.identifyAsSteamDeck); + const subscriptionLoadIdRef = useRef(0); + identifyAsSteamDeckRef.current = settings.identifyAsSteamDeck; useEffect(() => { settingsContentRef.current?.scrollTo({ top: 0 }); @@ -93,25 +96,6 @@ export function SettingsPage({ }, 1500); }, []); - const handleChange = useCallback( - (key: K, value: Settings[K]): void => { - const requestId = ++saveRequestRef.current; - if (savedIndicatorTimerRef.current !== null) { - window.clearTimeout(savedIndicatorTimerRef.current); - savedIndicatorTimerRef.current = null; - } - setSavedIndicator(false); - void onSettingChange(key, value) - .then(() => showSavedIndicator(requestId)) - .catch((error) => { - if (requestId === saveRequestRef.current) { - console.warn(`[Settings] Failed to save ${String(key)}:`, error); - } - }); - }, - [onSettingChange, showSavedIndicator], - ); - const handlePreview = useCallback( (key: K, value: Settings[K]): void => { onSettingPreview(key, value); @@ -129,56 +113,101 @@ export function SettingsPage({ } }, []); - const loadSubscriptionData = useCallback(async (isCancelled: () => boolean = () => false): Promise => { + const loadSubscriptionData = useCallback(async ( + options?: { identifyAsSteamDeck?: boolean; preferCache?: boolean }, + ): Promise => { + const loadId = ++subscriptionLoadIdRef.current; + const isCurrentLoad = (): boolean => subscriptionLoadIdRef.current === loadId; setSubscriptionLoading(true); try { const sessionResult = await window.openNow.getAuthSession(); const session = sessionResult.session; - if (!session || isCancelled()) { + if (!isCurrentLoad()) { + return; + } + if (!session) { setEntitledResolutions([]); setSubscriptionInfo(null); return; } const userId = session.user.userId; - const cached = loadCachedEntitledResolutions(); - if ( - cached && - cached.userId === userId && - cached.membershipTier === session.user.membershipTier && - !isCancelled() - ) { - setEntitledResolutions(cached.entitledResolutions); + const identifyAsSteamDeck = options?.identifyAsSteamDeck ?? identifyAsSteamDeckRef.current; + if (options?.preferCache !== false) { + const cached = loadCachedEntitledResolutions(identifyAsSteamDeck); + if ( + cached && + cached.userId === userId && + cached.membershipTier === session.user.membershipTier && + isCurrentLoad() + ) { + setEntitledResolutions(cached.entitledResolutions); + } } const sub = await window.openNow.fetchSubscription({ userId, }); - if (!isCancelled()) { - setSubscriptionInfo(sub); - setEntitledResolutions(sub.entitledResolutions); - saveCachedEntitledResolutions({ - userId, - membershipTier: sub.membershipTier, - entitledResolutions: sub.entitledResolutions, - }); + if (!isCurrentLoad()) { + return; } + + setSubscriptionInfo(sub); + setEntitledResolutions(sub.entitledResolutions); + saveCachedEntitledResolutions({ + userId, + membershipTier: sub.membershipTier, + identifyAsSteamDeck, + entitledResolutions: sub.entitledResolutions, + }); } catch (err) { console.warn("Failed to fetch subscription for settings:", err); - if (!isCancelled()) { + if (isCurrentLoad()) { setSubscriptionInfo(null); } } finally { - if (!isCancelled()) setSubscriptionLoading(false); + if (isCurrentLoad()) { + setSubscriptionLoading(false); + } } }, []); + const handleChange = useCallback( + (key: K, value: Settings[K]): void => { + const requestId = ++saveRequestRef.current; + if (savedIndicatorTimerRef.current !== null) { + window.clearTimeout(savedIndicatorTimerRef.current); + savedIndicatorTimerRef.current = null; + } + setSavedIndicator(false); + void onSettingChange(key, value) + .then(async () => { + showSavedIndicator(requestId); + if (key === "identifyAsSteamDeck") { + // Refresh after the setting is persisted so MES sees the new identity. + // Keep the previous resolution list until the matching cache/MES result arrives. + await loadSubscriptionData({ + identifyAsSteamDeck: value as boolean, + preferCache: true, + }); + } + }) + .catch((error) => { + if (requestId === saveRequestRef.current) { + console.warn(`[Settings] Failed to save ${String(key)}:`, error); + } + }); + }, + [loadSubscriptionData, onSettingChange, showSavedIndicator], + ); + useEffect(() => { - let cancelled = false; - void loadSubscriptionData(() => cancelled); - return () => { cancelled = true; }; + void loadSubscriptionData(); + return () => { + subscriptionLoadIdRef.current += 1; + }; }, [loadSubscriptionData]); const normalizedSettingsSearch = settingsSearch.trim().toLowerCase(); diff --git a/opennow-stable/src/renderer/src/components/settings/sections/SettingsStreamSection.tsx b/opennow-stable/src/renderer/src/components/settings/sections/SettingsStreamSection.tsx index 42bf4186..b2afd21f 100644 --- a/opennow-stable/src/renderer/src/components/settings/sections/SettingsStreamSection.tsx +++ b/opennow-stable/src/renderer/src/components/settings/sections/SettingsStreamSection.tsx @@ -898,6 +898,29 @@ export function SettingsStreamSection({ +
+
+ + +
+ + {t("settings.video.identifyAsSteamDeckHint")} + +
+