11import crypto from "node:crypto" ;
22import dns from "node:dns" ;
3+ import { existsSync , readFileSync , writeFileSync } from "node:fs" ;
4+ import { join } from "node:path" ;
5+ import { app } from "electron" ;
36
47import type {
58 ActiveSessionInfo ,
@@ -33,6 +36,9 @@ const GFN_USER_AGENT =
3336const GFN_CLIENT_VERSION = "2.0.80.173" ;
3437const SESSION_MODIFY_ACTION_AD_UPDATE = 6 ;
3538const READY_SESSION_STATUSES = new Set ( [ 2 , 3 ] ) ;
39+ const GFN_DEVICE_ID_FILENAME = "gfn-device-id.json" ;
40+
41+ let cachedStableDeviceId : string | null = null ;
3642
3743const AD_ACTION_CODES : Record < SessionAdAction , number > = {
3844 start : 1 ,
@@ -52,6 +58,34 @@ function isReadySessionStatus(status: number): boolean {
5258 return READY_SESSION_STATUSES . has ( status ) ;
5359}
5460
61+ function getStableDeviceId ( ) : string {
62+ if ( cachedStableDeviceId ) {
63+ return cachedStableDeviceId ;
64+ }
65+
66+ try {
67+ const path = join ( app . getPath ( "userData" ) , GFN_DEVICE_ID_FILENAME ) ;
68+ if ( existsSync ( path ) ) {
69+ const parsed = JSON . parse ( readFileSync ( path , "utf-8" ) ) as { deviceId ?: unknown } ;
70+ if ( typeof parsed . deviceId === "string" && parsed . deviceId . length > 0 ) {
71+ cachedStableDeviceId = parsed . deviceId ;
72+ return parsed . deviceId ;
73+ }
74+ }
75+
76+ const deviceId = crypto . randomUUID ( ) ;
77+ writeFileSync ( path , JSON . stringify ( { deviceId } , null , 2 ) , "utf-8" ) ;
78+ cachedStableDeviceId = deviceId ;
79+ return deviceId ;
80+ } catch ( error ) {
81+ // Fallback to in-memory UUID if disk read/write fails.
82+ const fallback = crypto . randomUUID ( ) ;
83+ cachedStableDeviceId = fallback ;
84+ console . warn ( "[CloudMatch] Failed to load persisted device ID, using in-memory fallback:" , error ) ;
85+ return fallback ;
86+ }
87+ }
88+
5589async function resolveHostnameWithFallback ( hostname : string ) : Promise < string | null > {
5690 // Try system resolver first, then fall back to Cloudflare (1.1.1.1) and Google (8.8.8.8)
5791 try {
@@ -1196,7 +1230,7 @@ function buildClaimRequestBody(sessionId: string, appId: string, settings: Strea
11961230 // The session is already configured on the server side. Sending different fps, resolution,
11971231 // codec, etc. causes HTTP 400 from the server because those parameters are immutable for
11981232 // an already-streaming session. Only send the action and minimal required fields.
1199- const deviceId = crypto . randomUUID ( ) ;
1233+ const deviceId = getStableDeviceId ( ) ;
12001234 const subSessionId = crypto . randomUUID ( ) ;
12011235 const timezoneMs = timezoneOffsetMs ( ) ;
12021236
@@ -1262,7 +1296,7 @@ export async function claimSession(input: SessionClaimRequest): Promise<SessionI
12621296 throw new Error ( "Missing token for session claim" ) ;
12631297 }
12641298
1265- const deviceId = crypto . randomUUID ( ) ;
1299+ const deviceId = getStableDeviceId ( ) ;
12661300 const clientId = crypto . randomUUID ( ) ;
12671301
12681302 // Provide default values for optional parameters
0 commit comments