Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,10 @@ jobs:
- name: Package installers
run: npx electron-builder --publish never ${{ matrix.builder_args }}

- name: Verify macOS code signatures
if: runner.os == 'macOS'
run: codesign --verify --deep --strict --verbose=2 dist-release/mac*/OpenNOW.app

- name: Rename macOS update feed
if: matrix.arch == 'arm64'
shell: bash
Expand Down
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
Binary file added native/opennow-streamer/bin/opennow-streamer
Binary file not shown.
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.

4 changes: 2 additions & 2 deletions 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 All @@ -27,7 +27,7 @@
"lint": "oxlint src",
"locales:check": "node scripts/check-translations.mjs",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.json",
"test": "node scripts/run-tests.mjs"
"test": "node scripts/run-tests.mjs && node --test scripts/after-sign-mac.test.mjs"
},
"dependencies": {
"discord-rpc": "^4.0.1",
Expand Down
1 change: 0 additions & 1 deletion opennow-stable/scripts/after-sign-mac.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ export default async function afterSign({ appOutDir, packager }) {

execFileSync("codesign", [
"--force",
"--deep",
"--sign", "-",
"--requirements", `=designated => identifier "${bundleId}"`,
appPath,
Expand Down
42 changes: 42 additions & 0 deletions opennow-stable/scripts/after-sign-mac.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

import afterSign from "./after-sign-mac.mjs";

test("re-signs only the outer macOS app bundle", { skip: process.platform !== "darwin" }, async () => {
const root = await mkdtemp(join(tmpdir(), "opennow-after-sign-"));
const binDir = join(root, "bin");
const argsFile = join(root, "codesign-args");
const appOutDir = join(root, "output");
const originalEnv = { ...process.env };

try {
await mkdir(binDir);
await mkdir(join(appOutDir, "OpenNOW.app"), { recursive: true });
await writeFile(join(binDir, "codesign"), '#!/bin/sh\nprintf "%s\\n" "$@" >> "$CODESIGN_ARGS_FILE"\n');
await chmod(join(binDir, "codesign"), 0o755);
process.env.PATH = `${binDir}:${process.env.PATH}`;
process.env.CODESIGN_ARGS_FILE = argsFile;
process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false";

await afterSign({
appOutDir,
packager: {
appInfo: { id: "com.zortos.opennow.stable", productFilename: "OpenNOW" },
},
});

const args = (await readFile(argsFile, "utf8")).trim().split("\n");
assert.equal(args.length, 6);
assert.equal(args.includes("--deep"), false);
assert.deepEqual(args.slice(0, 4), ["--force", "--sign", "-", "--requirements"]);
assert.equal(args[4], '=designated => identifier "com.zortos.opennow.stable"');
assert.equal(args[5], join(appOutDir, "OpenNOW.app"));
} finally {
process.env = originalEnv;
await rm(root, { recursive: true, force: true });
}
});
104 changes: 104 additions & 0 deletions opennow-stable/src/main/gfn/cloudmatch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import crypto from "node:crypto";
import dns from "node:dns";
import { tcpPing } from "../services/regionPing";
import { fetchPrintedWasteQueue, fetchPrintedWasteServerMapping } from "../services/printedWaste";
import { app } from "electron";
import { createRequire } from "node:module";
import { createHash } from "node:crypto";

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

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

function clusterPrefix(key: string): string {
const parts = key.split("-");
return parts.length > 2 ? parts.slice(0, parts.length - 1).join("-") : key;
}

export async function getSmartAutoJoinBaseUrl(proxyUrl?: string): Promise<string | null> {
try {
const appVersion = app.getVersion();

// 1. Fetch server mapping to filter out nuked zones
const serverMapping = await fetchPrintedWasteServerMapping(appVersion).catch((e) => {
console.warn("[SmartAutoJoin] Failed to fetch server mapping:", e);
return null;
});

// 2. Fetch queue via shared service (includes User-Agent header + proxy support)
const queueData = await fetchPrintedWasteQueue(appVersion, proxyUrl);
if (!queueData || Object.keys(queueData).length === 0) return null;

// Get unique datacenter prefixes
const prefixes = Array.from(
new Set(
Object.keys(queueData).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(queueData).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(queueData)) {
// Filter out nuked zones using server mapping (case-insensitive check)
if (serverMapping) {
const mappingKey = Object.keys(serverMapping).find(
(k) => k.toLowerCase() === zoneId.toLowerCase()
);
if (mappingKey && serverMapping[mappingKey].nuked) {
console.log(`[SmartAutoJoin] Skipping nuked zone: ${zoneId}`);
continue;
}
}

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;
}
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
16 changes: 15 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,15 @@ function registerIpcHandlers(): void {
},
);

ipcMain.handle(
IPC_CHANNELS.GET_SMART_AUTO_JOIN_BASE_URL,
async () => {
const proxyEnabled = settingsManager?.get("sessionProxyEnabled");
const proxyUrl = proxyEnabled ? (settingsManager?.get("sessionProxyUrl") ?? undefined) : undefined;
return await getSmartAutoJoinBaseUrl(proxyUrl);
},
);

// Logs export IPC handler
ipcMain.handle(
IPC_CHANNELS.LOGS_EXPORT,
Expand Down
79 changes: 79 additions & 0 deletions opennow-stable/src/main/services/dnsInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 = {};
} else if (typeof options === "number") {
actualOptions = { family: options };
} else {
actualOptions = options ?? {};
}

if (isNvidiaHostname(hostname)) {
const family = actualOptions.family;
if (family === 6) {
// Fall back to original for IPv6 lookup
originalLookup(hostname, actualOptions, actualCallback);
return;
}

// Try original lookup first to respect local network configurations/hosts file
originalLookup(hostname, actualOptions, (err, address, resolvedFamily) => {
if (err && (err.code === "ENOTFOUND" || err.code === "EAI_AGAIN")) {
// If native lookup failed, attempt resolving via Cloudflare/Google public DNS
fallbackResolver.resolve4(hostname, (fallbackErr, addresses) => {
if (!fallbackErr && addresses && addresses.length > 0) {
const ip = addresses[0];
console.log(`[DNS Interceptor] Fallback resolved ${hostname} -> ${ip}`);
if (actualOptions.all) {
actualCallback(null, [{ address: ip, family: 4 }], 4);
} else {
actualCallback(null, ip, 4);
}
} else {
// Return original error if fallback also failed
actualCallback(err, address, resolvedFamily);
}
});
} else {
// Normal success or other errors
actualCallback(err, address, resolvedFamily);
}
});
return;
}

originalLookup(hostname, actualOptions, actualCallback);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Preserve util.promisify(dns.lookup) compatibility — copy the custom promisify
// symbol from the original so callers get the correct { address, family } shape.
const promisifyCustom = Symbol.for("nodejs.util.promisify.custom");
if (promisifyCustom in originalLookup) {
// @ts-ignore
dns.lookup[promisifyCustom] = originalLookup[promisifyCustom as keyof typeof originalLookup];
}

console.log("[DNS Interceptor] Interceptor initialized successfully with safe fallback resolver.");
}
Loading