diff --git a/README.md b/README.md index 4ca0d799f..6c582fe06 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ OpenNOW is a community-built Electron app for playing GeForce NOW from an open-s Grab the latest desktop build from [GitHub Releases](https://github.com/OpenCloudGaming/OpenNOW/releases). - iOS beta: [join TestFlight](https://testflight.apple.com/join/u1XPJKH2). The SwiftUI prototype currently lives on the [`kief5555/ios` branch](https://github.com/OpenCloudGaming/OpenNOW/tree/kief5555/ios/ios/OpenNOWiOS) under `ios/OpenNOWiOS/`; that folder is not present on this branch. -- Android builds: download current test builds from [Discord](https://discord.gg/8EJYaJcNfD). iOS and Android are being migrated into their own repositories soon so this desktop repo can stay focused. +- Android: download from [Google Play](https://play.google.com/store/apps/details?id=com.opencloudgaming.opennow). - Nintendo Switch: a homebrew port is coming soon in [OpenCloudGaming/Opennow-homebrew](https://github.com/OpenCloudGaming/Opennow-homebrew), built on top of Moonlight. For macOS users looking for a more performant OpenNOW version, Jayian1890 maintains the separate [OpenNOW-Mac](https://github.com/OpenCloudGaming/OpenNOW-Mac) repository. diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index 94398cd46..55f7e6b4d 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -1,12 +1,12 @@ { "name": "opennow-stable", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opennow-stable", - "version": "0.5.1", + "version": "0.5.2", "hasInstallScript": true, "dependencies": { "discord-rpc": "^4.0.1", diff --git a/opennow-stable/package.json b/opennow-stable/package.json index 6f29aacec..31e3ae83e 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -1,6 +1,6 @@ { "name": "opennow-stable", - "version": "0.5.1", + "version": "0.5.2", "description": "Electron-based OpenNOW stable client", "author": { "name": "zortos293", diff --git a/opennow-stable/src/main/gfn/cloudmatch.ts b/opennow-stable/src/main/gfn/cloudmatch.ts index 87ca228b8..05fe8517d 100644 --- a/opennow-stable/src/main/gfn/cloudmatch.ts +++ b/opennow-stable/src/main/gfn/cloudmatch.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import dns from "node:dns"; +import { tcpPing } from "../services/regionPing"; import { createRequire } from "node:module"; import { createHash } from "node:crypto"; @@ -1959,3 +1960,160 @@ export async function claimSession(input: SessionClaimRequest): Promise; +} + +function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLng = (lng2 - lng1) * Math.PI / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function clusterPrefix(key: string): string { + const parts = key.split("-"); + return parts.length > 2 ? parts.slice(0, parts.length - 1).join("-") : key; +} + +async function isDnsResolvable(url: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + await fetch(url, { method: "HEAD", signal: controller.signal }) + .finally(() => clearTimeout(timeoutId)); + return true; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("ENOTFOUND") || msg.includes("getaddrinfo")) return false; + return true; + } +} + +async function resolveIP(domain: string, proxyUrl?: string): Promise { + try { + const url = `https://cloudflare-dns.com/dns-query?name=${domain}&type=A`; + const response = await fetchWithOptionalProxy(url, { + method: "GET", + headers: { "accept": "application/dns-json" } + }, proxyUrl); + if (!response.ok) return null; + const data = await response.json() as { Answer?: Array<{ data: string }> }; + if (data.Answer && data.Answer.length > 0) { + return data.Answer[0].data; + } + } catch (e) { + console.warn(`[SmartAutoJoin] DNS lookup failed for ${domain}:`, e); + } + return null; +} + +async function getGeoLocation(ip: string, proxyUrl?: string): Promise<{ lat: number; lon: number; city: string; country: string } | null> { + try { + const url = `https://freeipapi.com/api/json/${ip}`; + const response = await fetchWithOptionalProxy(url, { method: "GET" }, proxyUrl); + if (!response.ok) return null; + const data = await response.json() as { latitude?: number; longitude?: number; cityName?: string; regionName?: string; countryName?: string }; + return { + lat: parseFloat(String(data.latitude)), + lon: parseFloat(String(data.longitude)), + city: data.cityName || data.regionName || "", + country: data.countryName || "" + }; + } catch (e) { + console.warn(`[SmartAutoJoin] Geo-IP lookup failed for IP ${ip}:`, e); + } + return null; +} + +export async function getSmartAutoJoinBaseUrl(proxyUrl?: string): Promise { + const QUEUE_API_URL = "https://api.printedwaste.com/gfn/queue/"; + + try { + const queueRes = await fetchWithOptionalProxy(QUEUE_API_URL, { method: "GET" }, proxyUrl); + if (!queueRes.ok) return null; + const queueBody = (await queueRes.json()) as QueueApiResponse; + if (!queueBody.status || !queueBody.data) return null; + + // Get unique datacenter prefixes + const prefixes = Array.from( + new Set( + Object.keys(queueBody.data).map((zoneId) => clusterPrefix(zoneId).toLowerCase()) + ) + ); + + const datacenters: Record = {}; + + // Ping all datacenters in parallel (takes only ~200-300ms total) + await Promise.all( + prefixes.map(async (prefix) => { + const matchingZone = Object.keys(queueBody.data).find( + (zoneId) => clusterPrefix(zoneId).toLowerCase() === prefix + ); + if (!matchingZone) return; + + const hostname = `${matchingZone.toLowerCase()}.cloudmatchbeta.nvidiagrid.net`; + try { + const ping = await tcpPing(hostname, 443, 1200); + if (ping !== null) { + datacenters[prefix] = { prefix, pingMs: ping }; + } else { + datacenters[prefix] = { prefix, pingMs: 999 }; + } + } catch { + datacenters[prefix] = { prefix, pingMs: 999 }; + } + }) + ); + + const candidateZones: Array<{ zoneId: string; queue: number; pingMs: number }> = []; + for (const [zoneId, zoneInfo] of Object.entries(queueBody.data)) { + const prefix = clusterPrefix(zoneId).toLowerCase(); + const dc = datacenters[prefix]; + if (dc) { + candidateZones.push({ + zoneId, + queue: zoneInfo.QueuePosition ?? 0, + pingMs: dc.pingMs, + }); + } + } + + // Sort by ping bucket (rounded to nearest 15ms to group similar low pings) first, then by queue + candidateZones.sort((a, b) => Math.round(a.pingMs / 15) - Math.round(b.pingMs / 15) || a.queue - b.queue); + + for (const cand of candidateZones) { + const hostname = `${cand.zoneId.toLowerCase()}.cloudmatchbeta.nvidiagrid.net`; + const ip = await resolveHostnameWithFallback(hostname); + if (ip) { + const url = `https://${hostname}`; + console.log(`[SmartAutoJoin] Selected candidate zone: ${cand.zoneId} (Ping: ${cand.pingMs}ms, Queue: ${cand.queue}) -> resolves to ${ip}`); + return url; + } else { + console.warn(`[SmartAutoJoin] Candidate zone ${cand.zoneId} (${hostname}) is offline or nxdomain, skipping.`); + } + } + + // Final fallback if absolutely nothing resolved, try the first candidate anyway + if (candidateZones[0]) { + const hostname = `${candidateZones[0].zoneId.toLowerCase()}.cloudmatchbeta.nvidiagrid.net`; + console.warn(`[SmartAutoJoin] No candidates resolved, falling back to: ${hostname}`); + return `https://${hostname}`; + } + } catch (err) { + console.warn(`[SmartAutoJoin] Selection failed:`, err); + } + return null; +} diff --git a/opennow-stable/src/main/gfn/proxyFetch.ts b/opennow-stable/src/main/gfn/proxyFetch.ts index 51efa9b28..79076e9de 100644 --- a/opennow-stable/src/main/gfn/proxyFetch.ts +++ b/opennow-stable/src/main/gfn/proxyFetch.ts @@ -98,7 +98,7 @@ export async function fetchWithOptionalProxy( ): Promise { const normalizedProxyUrl = normalizeSessionProxyUrl(proxyUrl); if (!normalizedProxyUrl) { - return fetch(input, init); + return undiciFetch(input, init as any) as unknown as Response; } const protocol = new URL(normalizedProxyUrl).protocol; @@ -112,7 +112,7 @@ export async function fetchWithOptionalProxy( const proxyConfig = parseSessionProxyForElectron(normalizedProxyUrl); if (!proxyConfig) { - return fetch(input, init); + return undiciFetch(input, init as any) as unknown as Response; } return fetchWithElectronSessionProxy(input, init, proxyConfig); diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index cf762111c..ed14646d1 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -47,7 +47,7 @@ import type { import { getSettingsManager, type SettingsManager } from "./settings"; -import { getActiveSessions } from "./gfn/cloudmatch"; +import { getActiveSessions, getSmartAutoJoinBaseUrl } from "./gfn/cloudmatch"; import { AuthService } from "./gfn/auth"; import { initSessionProxyAuth } from "./gfn/proxyFetch"; import { @@ -96,9 +96,14 @@ import { import { parseDirectLaunchArgs, type DirectLaunchArgs } from "@shared/directLaunch"; import { getReleaseHighlightsPayload, normalizeReleaseVersion, shouldShowReleaseHighlights } from "./releaseHighlights"; +import { initDnsInterceptor } from "./services/dnsInterceptor"; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// Initialize the DNS interceptor before any connection is made +initDnsInterceptor(); + // Configure Chromium video, WebRTC, and input behavior before app.whenReady(). function loadBootstrapChromiumPreferences(): BootstrapChromiumPreferences { @@ -1164,6 +1169,13 @@ function registerIpcHandlers(): void { }, ); + ipcMain.handle( + IPC_CHANNELS.GET_SMART_AUTO_JOIN_BASE_URL, + async () => { + return await getSmartAutoJoinBaseUrl(); + }, + ); + // Logs export IPC handler ipcMain.handle( IPC_CHANNELS.LOGS_EXPORT, diff --git a/opennow-stable/src/main/services/dnsInterceptor.ts b/opennow-stable/src/main/services/dnsInterceptor.ts new file mode 100644 index 000000000..c0a8ebaa0 --- /dev/null +++ b/opennow-stable/src/main/services/dnsInterceptor.ts @@ -0,0 +1,57 @@ +import dns from "node:dns"; + +const originalLookup = dns.lookup; + +const fallbackResolver = new dns.Resolver(); +fallbackResolver.setServers(["1.1.1.1", "8.8.8.8"]); + +function isNvidiaHostname(hostname: string): boolean { + if (!hostname) return false; + const lower = hostname.toLowerCase(); + return lower.endsWith(".nvidiagrid.net") || lower.endsWith(".nvidia.com"); +} + +export function initDnsInterceptor(): void { + // @ts-ignore + dns.lookup = function ( + hostname: string, + options: any, + callback: (err: NodeJS.ErrnoException | null, address: string | any[], family?: number) => void + ) { + let actualOptions = options; + let actualCallback = callback; + + if (typeof options === "function") { + actualCallback = options; + actualOptions = {}; + } + + if (isNvidiaHostname(hostname)) { + const family = actualOptions.family; + if (family === 6) { + // Fall back to original for IPv6 lookup + originalLookup(hostname, actualOptions, actualCallback); + return; + } + + fallbackResolver.resolve4(hostname, (err, addresses) => { + if (!err && addresses && addresses.length > 0) { + const ip = addresses[0]; + console.log(`[DNS Interceptor] Intercepted lookup for ${hostname} -> resolved to ${ip}`); + if (actualOptions.all) { + actualCallback(null, [{ address: ip, family: 4 }], 4); + } else { + actualCallback(null, ip, 4); + } + } else { + originalLookup(hostname, actualOptions, actualCallback); + } + }); + return; + } + + originalLookup(hostname, actualOptions, actualCallback); + }; + + console.log("[DNS Interceptor] Interceptor initialized successfully."); +} diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 9cc4b8537..1c875ffdb 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -153,6 +153,8 @@ export interface Settings { autoCheckForUpdates: boolean; /** When true, pressing Escape will exit fullscreen; when false Escape is sent to the game while pointer-locked */ allowEscapeToExitFullscreen?: boolean; + /** Automatically select a server and rejoin after a free-tier session ends. */ + enableFastQueueJoin: boolean; /** Last version for which the release highlights modal was acknowledged (empty = never) */ lastSeenReleaseHighlightsVersion: string; /** Client-side GPU post-processing shaders applied to the stream (web client mode) */ @@ -259,6 +261,7 @@ const DEFAULT_SETTINGS: Settings = { discordRichPresence: false, autoCheckForUpdates: true, allowEscapeToExitFullscreen: false, + enableFastQueueJoin: false, lastSeenReleaseHighlightsVersion: "", videoShader: { ...DEFAULT_VIDEO_SHADER_SETTINGS }, }; diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index bc9954c45..b74cef1a3 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -253,6 +253,8 @@ const api: OpenNowApi = { ipcRenderer.invoke(IPC_CHANNELS.MEDIA_REGEN_THUMBNAIL, input), deleteCache: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CACHE_DELETE_ALL), + getSmartAutoJoinBaseUrl: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.GET_SMART_AUTO_JOIN_BASE_URL), fetchPrintedWasteQueue: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PRINTEDWASTE_QUEUE_FETCH), fetchPrintedWasteServerMapping: (): Promise => diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 77a78403b..5d82576db 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -548,6 +548,7 @@ export function App(): JSX.Element { autoCheckForUpdates: true, lastSeenReleaseHighlightsVersion: "", videoShader: { ...DEFAULT_VIDEO_SHADER_SETTINGS }, + enableFastQueueJoin: false, }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [releaseHighlightsPayload, setReleaseHighlightsPayload] = useState(null); @@ -637,6 +638,10 @@ export function App(): JSX.Element { streamingGameRef.current = streamingGame; }, [streamingGame]); + const handlePlayGameRef = useRef<((game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string; variantId?: string }) => Promise) | null>(null); + const prePingSmartUrlRef = useRef(null); + const prePingInFlightRef = useRef(false); + const resetStatsOverlayToPreference = useCallback((): void => { setShowStatsOverlay(settings.showStatsOnLaunch); }, [settings.showStatsOnLaunch]); @@ -1673,6 +1678,46 @@ export function App(): JSX.Element { previousFreeTierRemainingSecondsRef.current = freeTierSessionRemainingSeconds; }, [freeTierSessionRemainingSeconds]); + // Auto-Rejoin: pre-ping best server using sessionTimeRemainingSeconds (works for ALL tiers, not just FREE) + useEffect(() => { + if (!settings.enableFastQueueJoin) return; + + // Only log at key thresholds to avoid console spam + if (sessionTimeRemainingSeconds !== null) { + if (sessionTimeRemainingSeconds <= 60 && (sessionTimeRemainingSeconds % 5 === 0 || sessionTimeRemainingSeconds <= 15)) { + console.log("[AutoRejoin] Session time remaining:", sessionTimeRemainingSeconds, "s | cachedUrl:", prePingSmartUrlRef.current, "| inFlight:", prePingInFlightRef.current); + } else if (sessionTimeRemainingSeconds % 300 === 0) { + // Log every 5 minutes during normal play so user can verify it's alive + console.log("[AutoRejoin] ⏱ Session time remaining:", Math.round(sessionTimeRemainingSeconds / 60), "min"); + } + } else { + // Only log this once — when it first becomes null + console.log("[AutoRejoin] sessionTimeRemainingSeconds is null (not streaming or tier unknown)"); + } + + if ( + sessionTimeRemainingSeconds !== null && + sessionTimeRemainingSeconds <= 15 && + prePingSmartUrlRef.current === null && + !prePingInFlightRef.current + ) { + prePingInFlightRef.current = true; + console.log("[AutoRejoin] *** TRIGGERING pre-fetch at", sessionTimeRemainingSeconds, "s remaining ***"); + window.openNow.getSmartAutoJoinBaseUrl().then((smartUrl) => { + if (smartUrl) { + prePingSmartUrlRef.current = smartUrl; + console.log("[AutoRejoin] ✅ Cached best server URL:", smartUrl); + } else { + console.warn("[AutoRejoin] ⚠️ Pre-fetch returned no server URL"); + } + prePingInFlightRef.current = false; + }).catch((err) => { + console.error("[AutoRejoin] ❌ Pre-ping failed:", err); + prePingInFlightRef.current = false; + }); + } + }, [sessionTimeRemainingSeconds, settings.enableFastQueueJoin]); + useEffect(() => { if (!localSessionTimerWarning) return; @@ -2956,6 +3001,71 @@ export function App(): JSX.Element { resolveSubscriptionInfoForLaunch, ]); + const startAutoRejoin = useCallback((reason: string, testGameOverride?: GameInfo | null): boolean => { + const game = testGameOverride !== undefined ? testGameOverride : streamingGameRef.current; + const playGame = handlePlayGameRef.current; + const cachedUrl = prePingSmartUrlRef.current; + + console.log("[AutoRejoin] *** startAutoRejoin called ***", { + reason, + enabled: settings.enableFastQueueJoin, + cachedUrl, + prefetchInFlight: prePingInFlightRef.current, + game: game?.title ?? null, + hasLaunchHandler: Boolean(playGame), + }); + + if (!settings.enableFastQueueJoin) { + console.log("[AutoRejoin] Skipping: enableFastQueueJoin is OFF"); + return false; + } + if (!game) { + console.error("[AutoRejoin] Skipping: streamingGameRef is null (no active game)"); + return false; + } + if (!playGame) { + console.error("[AutoRejoin] Skipping: handlePlayGameRef is null (ref not synced yet)"); + return false; + } + + const launch = (streamingBaseUrl: string | null): void => { + if (!streamingBaseUrl) { + console.error("[AutoRejoin] Cannot rejoin: getSmartAutoJoinBaseUrl returned null"); + resetLaunchRuntime(); + void refreshNavbarActiveSession(); + return; + } + console.log("[AutoRejoin] ✅ Launching rejoin to:", streamingBaseUrl); + prePingSmartUrlRef.current = null; + prePingInFlightRef.current = false; + resetLaunchRuntime(); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + window.setTimeout(() => { + void playGame(game, { bypassGuards: true, streamingBaseUrl }).catch((error) => { + console.error("[AutoRejoin] Rejoin launch failed:", error); + }); + }, 500); + }; + + if (cachedUrl) { + console.log("[AutoRejoin] Using pre-fetched URL:", cachedUrl); + launch(cachedUrl); + } else { + console.warn("[AutoRejoin] No cached URL — fetching best server now (might be slower)"); + void window.openNow.getSmartAutoJoinBaseUrl() + .then((url) => { + console.log("[AutoRejoin] Post-session fetch returned:", url); + launch(url); + }) + .catch((error) => { + console.error("[AutoRejoin] Fallback fetch failed:", error); + launch(null); + }); + } + return true; + }, [refreshNavbarActiveSession, resetLaunchRuntime, settings.enableFastQueueJoin]); + const handleExpectedNativeSessionClose = useCallback((reason: string): void => { console.log("[Recovery] Treating signaling close as ended session:", reason); const activeGameId = streamingGameRef.current?.id; @@ -2966,9 +3076,12 @@ export function App(): JSX.Element { clientRef.current?.dispose(); clientRef.current = null; launchInFlightRef.current = false; - resetLaunchRuntime(); - void refreshNavbarActiveSession(); - }, [endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime]); + const rejoining = startAutoRejoin(reason); + if (!rejoining) { + resetLaunchRuntime(); + void refreshNavbarActiveSession(); + } + }, [endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime, startAutoRejoin]); // Signaling events useEffect(() => { @@ -3316,14 +3429,16 @@ export function App(): JSX.Element { } clientRef.current?.dispose(); clientRef.current = null; - setLaunchError({ - stage: streamStatusToLoadingStage(streamStatusRef.current), - title: t("errors.sessionConnectionLostTitle"), - description: t("errors.sessionConnectionLostDescription"), - }); - resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); - void refreshNavbarActiveSession(); - launchInFlightRef.current = false; + if (!startAutoRejoin(event.reason)) { + setLaunchError({ + stage: streamStatusToLoadingStage(streamStatusRef.current), + title: t("errors.sessionConnectionLostTitle"), + description: t("errors.sessionConnectionLostDescription"), + }); + resetLaunchRuntime({ keepLaunchError: true, keepStreamingContext: true }); + void refreshNavbarActiveSession(); + launchInFlightRef.current = false; + } } } else if (event.type === "error") { console.error("Signaling error:", event.message); @@ -3356,7 +3471,7 @@ export function App(): JSX.Element { }); return () => unsubscribe(); - }, [attemptSessionRecovery, diagnosticsStore, handleExpectedNativeSessionClose, markDiscordStreamStarted, nativeInputBridgeReady, refreshNavbarActiveSession, resetLaunchRuntime, scheduleStableRecoveryReset, settings, streamMicLevel, streamVolume, t]); + }, [attemptSessionRecovery, diagnosticsStore, handleExpectedNativeSessionClose, markDiscordStreamStarted, nativeInputBridgeReady, refreshNavbarActiveSession, resetLaunchRuntime, scheduleStableRecoveryReset, settings, startAutoRejoin, streamMicLevel, streamVolume, t]); // Play game handler const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string; variantId?: string }) => { @@ -3679,6 +3794,10 @@ export function App(): JSX.Element { warmNativeStreamerForLaunch, ]); + useEffect(() => { + handlePlayGameRef.current = handlePlayGame; + }, [handlePlayGame]); + useEffect(() => { const request = pendingDirectLaunchRequest; if (!request || handledDirectLaunchIdsRef.current.has(request.id)) return; @@ -4178,6 +4297,65 @@ export function App(): JSX.Element { } }, [endPlaytimeSession, markExplicitSignalingShutdown, refreshNavbarActiveSession, resetLaunchRuntime, resolveExitPrompt, stopSessionByTarget, streamingGame]); + // DEV ONLY: expose auto-rejoin test function so you can call window.__testAutoRejoin() in DevTools + useEffect(() => { + if (!import.meta.env.DEV) return; + (window as unknown as Record).__testAutoRejoin = async () => { + console.log("[AutoRejoin] 🧪 Manual test triggered from DevTools console"); + if (!settings.enableFastQueueJoin) { + console.warn("[AutoRejoin] ⚠️ enableFastQueueJoin is OFF — enable Auto-Rejoin in Settings first!"); + return false; + } + const activeGame = streamingGameRef.current; + if (!activeGame) { + console.warn("[AutoRejoin] ⚠️ No active game is running right now to rejoin!"); + return false; + } + + // Step 1: Show "finding best server" notification while still streaming + setRemoteStreamWarning({ code: 1, message: "⏳ Finding best server...", tone: "warn" }); + console.log("[AutoRejoin] 🧪 Fetching best server URL..."); + + // Step 2: Fetch best server URL BEFORE stopping the stream + let bestUrl: string | null = null; + try { + bestUrl = await window.openNow.getSmartAutoJoinBaseUrl(); + } catch (e) { + console.warn("[AutoRejoin] ⚠️ Failed to pre-fetch best server:", e); + } + + const serverName = bestUrl + ? new URL(bestUrl).hostname.split(".")[0].toUpperCase() + : "best server"; + + // Step 3: Show 5s countdown notification with the server name + for (let i = 5; i >= 1; i--) { + setRemoteStreamWarning({ + code: 1, + message: `🔄 Reconnecting to ${serverName} in ${i}s...`, + tone: "warn", + }); + console.log(`[AutoRejoin] 🧪 Reconnecting to ${serverName} in ${i}s...`); + await new Promise((resolve) => window.setTimeout(resolve, 1000)); + } + + setRemoteStreamWarning(null); + + // Step 4: Stop session and launch rejoin + console.log("[AutoRejoin] 🧪 Stopping existing stream session..."); + await handleStopStream(); + console.log("[AutoRejoin] 🧪 Triggering startAutoRejoin for game:", activeGame.title); + // Pass pre-fetched URL directly if available + if (bestUrl) { + prePingSmartUrlRef.current = bestUrl; + } + return startAutoRejoin("test", activeGame); + }; + return () => { + delete (window as unknown as Record).__testAutoRejoin; + }; + }, [startAutoRejoin, settings.enableFastQueueJoin, handleStopStream]); + const handleDismissLaunchError = useCallback(async () => { markExplicitSignalingShutdown(); await disconnectSignalingControlled(); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index e4c1e8082..a77726af0 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -2821,6 +2821,22 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults, )} +
+ + +
diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 5bacac1f9..3487ba52e 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -406,6 +406,8 @@ export interface Settings { autoCheckForUpdates: boolean; /** When true, pressing Escape will exit fullscreen; when false Escape is sent to the game while pointer-locked */ allowEscapeToExitFullscreen?: boolean; + /** Automatically select the GFN region with the shortest queue. */ + enableFastQueueJoin: boolean; /** Last version for which the release highlights modal was acknowledged (empty = never) */ lastSeenReleaseHighlightsVersion: string; /** Client-side GPU post-processing shaders applied to the stream (web client mode) */ @@ -1067,6 +1069,8 @@ export interface StreamSettings { nativeTransitionDiagnostics?: NativeTransitionDiagnostics; /** Requested session app launch mode; "gamepadFriendly" asks NVIDIA to launch games big-picture style. */ appLaunchMode?: AppLaunchMode; + /** Automatically select the GFN region with the shortest queue. */ + enableFastQueueJoin?: boolean; } export interface SessionCreateRequest { @@ -1674,6 +1678,7 @@ export interface OpenNowApi { deleteCache(): Promise; + getSmartAutoJoinBaseUrl(): Promise; /** Fetch current GFN queue wait times from the PrintedWaste API */ fetchPrintedWasteQueue(): Promise; /** Fetch PrintedWaste server mapping metadata (includes nuked status) */ diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index b7dd85418..e5f97bbb5 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -94,6 +94,7 @@ export const IPC_CHANNELS = { MEDIA_DELETE_FILE: "media:delete-file", MEDIA_REGEN_THUMBNAIL: "media:regen-thumbnail", // PrintedWaste queue integration + GET_SMART_AUTO_JOIN_BASE_URL: "app:get-smart-auto-join-base-url", PRINTEDWASTE_QUEUE_FETCH: "printedwaste:queue-fetch", PRINTEDWASTE_SERVER_MAPPING_FETCH: "printedwaste:server-mapping-fetch", // Discord Rich Presence