Skip to content
Open
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions opennow-stable/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion opennow-stable/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opennow-stable",
"version": "0.5.1",
"version": "0.5.2",
"description": "Electron-based OpenNOW stable client",
"author": {
"name": "zortos293",
Expand Down
158 changes: 158 additions & 0 deletions opennow-stable/src/main/gfn/cloudmatch.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -1959,3 +1960,160 @@ export async function claimSession(input: SessionClaimRequest): Promise<SessionI

throw new Error("Session did not become ready after claiming");
}

interface QueueNodeInfo {
QueuePosition: number;
Region: string;
eta?: number;
}

interface QueueApiResponse {
status: boolean;
data: Record<string, QueueNodeInfo>;
}

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 =
Comment on lines +1975 to +1979
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<boolean> {
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<string | null> {
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<string | null> {
const QUEUE_API_URL = "https://api.printedwaste.com/gfn/queue/";

try {
const queueRes = await fetchWithOptionalProxy(QUEUE_API_URL, { method: "GET" }, proxyUrl);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add a timeout around smart-join queue fetch

When api.printedwaste.com stalls or accepts the connection without responding during the 15-second prefetch/post-disconnect fallback, this fetch has no AbortSignal or timeout, unlike the existing fetchPrintedWasteQueue path. The IPC promise can remain pending, leaving prePingInFlightRef stuck and preventing auto-rejoin from falling back; wrap this request and JSON parse in the same timeout pattern used by the PrintedWaste service.

Useful? React with 👍 / 👎.

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<string, { prefix: string; pingMs: number }> = {};

// 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({
Comment on lines +2082 to +2086

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter unusable PrintedWaste zones before selecting

When the queue API includes zones that the existing flow excludes, such as nuked zones from the server mapping or non-standard/alliance entries, this loop still adds them and later treats a resolving hostname as eligible. The queue modal already requires standard non-nuked zones before routing; selecting a low-queue nuked zone here can send auto-rejoin to an unavailable CloudMatch LB and fail session creation, so fetch/use the mapping filter before adding candidates.

Useful? React with 👍 / 👎.

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;
}
4 changes: 2 additions & 2 deletions opennow-stable/src/main/gfn/proxyFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export async function fetchWithOptionalProxy(
): Promise<Response> {
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;
Expand All @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1164,6 +1169,13 @@ function registerIpcHandlers(): void {
},
);

ipcMain.handle(
IPC_CHANNELS.GET_SMART_AUTO_JOIN_BASE_URL,
async () => {
return await getSmartAutoJoinBaseUrl();
},
Comment on lines +1173 to +1176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the active session proxy into smart join

When a user has the session proxy enabled, the subsequent session create/poll calls still use activeSessionProxyUrl, but this IPC handler always calls the resolver with undefined, so queue fetching and latency selection are measured from the local network instead of the proxy egress. That can auto-rejoin proxy users to a region optimized for the wrong path; pass the enabled proxy URL through the IPC or read it from settings before calling the resolver.

Useful? React with 👍 / 👎.

);

// Logs export IPC handler
ipcMain.handle(
IPC_CHANNELS.LOGS_EXPORT,
Expand Down
57 changes: 57 additions & 0 deletions opennow-stable/src/main/services/dnsInterceptor.ts
Original file line number Diff line number Diff line change
@@ -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 = {};
}

Comment on lines +24 to +28
if (isNvidiaHostname(hostname)) {
const family = actualOptions.family;
if (family === 6) {
// Fall back to original for IPv6 lookup
originalLookup(hostname, actualOptions, actualCallback);
Comment on lines +29 to +33
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.");
}
3 changes: 3 additions & 0 deletions opennow-stable/src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +156 to +157
/** 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) */
Expand Down Expand Up @@ -259,6 +261,7 @@ const DEFAULT_SETTINGS: Settings = {
discordRichPresence: false,
autoCheckForUpdates: true,
allowEscapeToExitFullscreen: false,
enableFastQueueJoin: false,
lastSeenReleaseHighlightsVersion: "",
videoShader: { ...DEFAULT_VIDEO_SHADER_SETTINGS },
};
Expand Down
2 changes: 2 additions & 0 deletions opennow-stable/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ const api: OpenNowApi = {
ipcRenderer.invoke(IPC_CHANNELS.MEDIA_REGEN_THUMBNAIL, input),
deleteCache: (): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.CACHE_DELETE_ALL),
getSmartAutoJoinBaseUrl: (): Promise<string | null> =>
ipcRenderer.invoke(IPC_CHANNELS.GET_SMART_AUTO_JOIN_BASE_URL),
fetchPrintedWasteQueue: (): Promise<PrintedWasteQueueData> =>
ipcRenderer.invoke(IPC_CHANNELS.PRINTEDWASTE_QUEUE_FETCH),
fetchPrintedWasteServerMapping: (): Promise<PrintedWasteServerMapping> =>
Expand Down
Loading