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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ OpenNOW is a community-built desktop client for [NVIDIA GeForce NOW](https://www
| Up to 4K | ✅ | ✅ | Configurable in settings |
| 5K Resolution | ✅ | ✅ | Up to 5K@120fps |
| 120+ FPS | ✅ | ✅ | Configurable: 30/60/120/144/240 |
| HDR Streaming | 📋 | ✅ | 10-bit color supported, full HDR pipeline planned |
| HDR Streaming | | ✅ | HDR10 (PQ/BT.2020) on Windows; best-effort macOS; SDR fallback Linux |
| AI-Enhanced Stream Mode | ❌ | ✅ | NVIDIA Cinematic Quality — not available |
| Adjustable Bitrate | ✅ | ✅ | Up to 200 Mbps in OpenNOW |
| Color Quality (8/10-bit, 4:2:0/4:4:4) | ✅ | ✅ | Full chroma/bit-depth control |
Expand Down Expand Up @@ -117,7 +117,7 @@ OpenNOW is a community-built desktop client for [NVIDIA GeForce NOW](https://www
| 🔴 | ~~Microphone support~~ | ✅ Completed | Voice chat with mute/unmute toggle |
| 🟡 | Instant replay | 📋 Planned | Clip and save gameplay moments |
| 🟡 | Screenshots | 📋 Planned | Capture in-stream screenshots |
| 🟡 | HDR streaming pipeline | 📋 Planned | Full HDR end-to-end support |
| 🟡 | ~~HDR streaming pipeline~~ | ✅ Completed | HDR10 PQ/BT.2020 on Windows; best-effort macOS; auto-fallback |
| 🟢 | Latency optimizations | 🚧 Ongoing | Input and render path improvements |
| 🟢 | Platform stability | 🚧 Ongoing | Cross-platform bug fixes |

Expand All @@ -126,7 +126,7 @@ OpenNOW is a community-built desktop client for [NVIDIA GeForce NOW](https://www
## Features

**Streaming**
`H.264` `AV1` `H.265 (WIP)` · Up to 4K@240fps · Adjustable bitrate · 8/10-bit color · 4:2:0/4:4:4 chroma
`H.264` `AV1` `H.265 (WIP)` · Up to 4K@240fps · Adjustable bitrate · 8/10-bit color · 4:2:0/4:4:4 chroma · HDR10 (PQ/BT.2020)

**Input**
`Keyboard` `Mouse` `Gamepad ×4` · Mouse sensitivity · Clipboard paste
Expand Down
17 changes: 7 additions & 10 deletions opennow-stable/src/main/gfn/cloudmatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,7 @@ function timezoneOffsetMs(): number {
function buildSessionRequestBody(input: SessionCreateRequest): CloudMatchRequest {
const { width, height } = parseResolution(input.settings.resolution);
const cq = input.settings.colorQuality;
// IMPORTANT: hdrEnabled is a SEPARATE toggle from color quality.
// The Rust reference (cloudmatch.rs) uses settings.hdr_enabled independently.
// 10-bit color depth does NOT mean HDR — you can have 10-bit SDR.
// Conflating them caused the server to set up an HDR pipeline, which
// dynamically downscaled resolution to ~540p.
const hdrEnabled = false; // No HDR toggle implemented yet; hardcode off like claim body
const hdrEnabled = input.settings.hdrEnabled === true && cq.startsWith("10bit");
const bitDepth = colorQualityBitDepth(cq);
const chromaFormat = colorQualityChromaFormat(cq);
const accountLinked = input.accountLinked ?? true;
Expand Down Expand Up @@ -744,9 +739,7 @@ function buildClaimRequestBody(sessionId: string, appId: string, settings: Strea
const { width, height } = parseResolution(settings.resolution);
const cq = settings.colorQuality;
const chromaFormat = colorQualityChromaFormat(cq);
// Claim/resume uses SDR mode (matching Rust: hdr_enabled defaults false for claims).
// HDR is only negotiated on the initial session create.
const hdrEnabled = false;
const hdrEnabled = settings.hdrEnabled === true && cq.startsWith("10bit");
const deviceId = crypto.randomUUID();
const subSessionId = crypto.randomUUID();
const timezoneMs = timezoneOffsetMs();
Expand Down Expand Up @@ -822,14 +815,18 @@ function buildClaimRequestBody(sessionId: string, appId: string, settings: Strea
userAge: 26,
requestedStreamingFeatures: {
reflex: settings.fps >= 120,
bitDepth: 0,
bitDepth: hdrEnabled ? colorQualityBitDepth(cq) : 0,
cloudGsync: false,
enabledL4S: false,
mouseMovementFlags: 0,
trueHdr: hdrEnabled,
profile: 0,
fallbackToLogicalResolution: false,
chromaFormat,
prefilterMode: 0,
hudStreamingMode: 0,
sdrColorSpace: 2,
hdrColorSpace: hdrEnabled ? 4 : 0,
},
},
metaData: [],
Expand Down
86 changes: 86 additions & 0 deletions opennow-stable/src/main/hdr/hdrDetect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { screen } from "electron";
import { execSync } from "node:child_process";

export interface OsHdrInfo {
osHdrEnabled: boolean;
platform: string;
}

function detectWindows(): OsHdrInfo {
try {
const result = execSync(
'powershell -NoProfile -Command "Get-ItemPropertyValue -Path \'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\AdvancedColor\' -Name AdvancedColorEnabled 2>$null"',
{ encoding: "utf-8", timeout: 3000 },
).trim();

if (result === "1") {
return { osHdrEnabled: true, platform: "windows" };
}

const displays = screen.getAllDisplays();
for (const display of displays) {
const colorSpace = (display as unknown as Record<string, unknown>).colorSpace;
if (typeof colorSpace === "string" && colorSpace.toLowerCase().includes("hdr")) {
return { osHdrEnabled: true, platform: "windows" };
}
if (display.colorDepth > 24) {
return { osHdrEnabled: true, platform: "windows" };
}
}

return { osHdrEnabled: false, platform: "windows" };
} catch {
const displays = screen.getAllDisplays();
const hasHighDepth = displays.some((d) => d.colorDepth > 24);
return { osHdrEnabled: hasHighDepth, platform: "windows" };
}
}

function detectMacOS(): OsHdrInfo {
try {
const result = execSync(
"system_profiler SPDisplaysDataType 2>/dev/null | grep -i 'HDR\\|EDR\\|XDR'",
{ encoding: "utf-8", timeout: 3000 },
).trim();

if (result.length > 0) {
return { osHdrEnabled: true, platform: "macos" };
}
return { osHdrEnabled: false, platform: "macos" };
} catch {
return { osHdrEnabled: false, platform: "macos" };
}
}

function detectLinux(): OsHdrInfo {
try {
const sessionType = process.env.XDG_SESSION_TYPE ?? "";
const isWayland = sessionType.toLowerCase() === "wayland";

if (!isWayland) {
return { osHdrEnabled: false, platform: "linux" };
}

const result = execSync(
"kscreen-doctor --outputs 2>/dev/null | grep -i 'hdr'",
{ encoding: "utf-8", timeout: 3000 },
).trim();

return { osHdrEnabled: result.length > 0, platform: "linux" };
} catch {
return { osHdrEnabled: false, platform: "linux" };
}
}

export function getOsHdrInfo(): OsHdrInfo {
switch (process.platform) {
case "win32":
return detectWindows();
case "darwin":
return detectMacOS();
case "linux":
return detectLinux();
default:
return { osHdrEnabled: false, platform: "unknown" };
}
}
87 changes: 83 additions & 4 deletions opennow-stable/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ import {
import { fetchSubscription, fetchDynamicRegions } from "./gfn/subscription";
import { GfnSignalingClient } from "./gfn/signaling";
import { isSessionError, SessionError, GfnErrorCode } from "./gfn/errorCodes";

import { isSessionError, SessionError } from "./gfn/errorCodes";
import { DiscordPresenceService } from "./discord/DiscordPresenceService";
import { FlightProfileManager } from "./flight/FlightProfiles";
import { getOsHdrInfo } from "./hdr/hdrDetect";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

Expand Down Expand Up @@ -191,7 +194,74 @@ function emitToRenderer(event: MainToRendererSignalingEvent): void {
}
}

async function createMainWindow(): Promise<void> {
function emitSessionExpired(reason: string): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IPC_CHANNELS.AUTH_SESSION_EXPIRED, reason);
}
}

async function withRetryOn401<T>(
fn: (token: string) => Promise<T>,
explicitToken?: string,
): Promise<T> {
const token = await resolveJwt(explicitToken);
try {
return await fn(token);
} catch (error) {
const { shouldRetry, token: newToken } = await authService.handleApiError(error);
if (shouldRetry && newToken) {
return fn(newToken);
}
throw error;
}
}

function setupWebHidPermissions(): void {
const ses = session.defaultSession;

ses.setDevicePermissionHandler((details) => {
if (details.deviceType === "hid") {
return true;
}
return true;
});

ses.setPermissionCheckHandler((_webContents, permission) => {
const granted: ReadonlySet<string> = new Set(["hid", "media", "keyboardLock"]);
if (granted.has(permission)) {
return true;
}
return true;
});

ses.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === "media" || permission === "keyboardLock") {
callback(true);
return;
}
callback(true);
});

ses.on("select-hid-device", (event, details, callback) => {
event.preventDefault();
const ungranted = details.deviceList.find((d) => !grantedHidDeviceIds.has(d.deviceId));
const selected = ungranted ?? details.deviceList[0];
if (selected) {
grantedHidDeviceIds.add(selected.deviceId);
callback(selected.deviceId);
} else {
callback("");
}
});

ses.on("hid-device-added", (_event, _details) => {
// WebHID connect event handled in renderer via navigator.hid
});

ses.on("hid-device-removed", (_event, _details) => {
// WebHID disconnect event handled in renderer via navigator.hid
});
}async function createMainWindow(): Promise<void> {
const preloadMjsPath = join(__dirname, "../preload/index.mjs");
const preloadJsPath = join(__dirname, "../preload/index.js");
const preloadPath = existsSync(preloadMjsPath) ? preloadMjsPath : preloadJsPath;
Expand Down Expand Up @@ -501,8 +571,17 @@ function registerIpcHandlers(): void {
});

// Save window size when it changes
mainWindow?.on("resize", () => {
if (mainWindow && !mainWindow.isDestroyed()) {
ipcMain.handle(IPC_CHANNELS.HDR_GET_OS_INFO, () => {
return getOsHdrInfo();
}); mainWindow?.on("resize", () => {
});

ipcMain.handle(IPC_CHANNELS.APP_RELAUNCH, () => {
app.relaunch();
app.exit(0);
});

mainWindow?.on("resize", () => { if (mainWindow && !mainWindow.isDestroyed()) {
const [width, height] = mainWindow.getSize();
settingsManager.set("windowWidth", width);
settingsManager.set("windowHeight", height);
Expand Down
23 changes: 20 additions & 3 deletions opennow-stable/src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { app } from "electron";
import { join } from "node:path";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode } from "@shared/gfn";

import type { VideoCodec, ColorQuality, VideoAccelerationPreference, FlightSlotConfig, HdrStreamingMode } from "@shared/gfn";
import { defaultFlightSlots } from "@shared/gfn";
export interface Settings {
/** Video resolution (e.g., "1920x1080") */
resolution: string;
Expand Down Expand Up @@ -48,7 +49,18 @@ export interface Settings {
windowWidth: number;
/** Window height */
windowHeight: number;
}
/** Enable Discord Rich Presence */
discordPresenceEnabled: boolean;
/** Discord Application Client ID */
discordClientId: string;
/** Enable flight controls (HOTAS/joystick) */
flightControlsEnabled: boolean;
/** Controller slot for flight controls (0-3) — legacy, kept for compat */
flightControlsSlot: number;
/** Per-slot flight configurations */
flightSlots: FlightSlotConfig[];
/** HDR streaming mode: off, auto, on */
hdrStreaming: HdrStreamingMode;}

const defaultStopShortcut = "Ctrl+Shift+Q";
const defaultAntiAfkShortcut = "Ctrl+Shift+K";
Expand Down Expand Up @@ -79,7 +91,12 @@ const DEFAULT_SETTINGS: Settings = {
sessionClockShowDurationSeconds: 30,
windowWidth: 1400,
windowHeight: 900,
};
discordPresenceEnabled: false,
discordClientId: "",
flightControlsEnabled: false,
flightControlsSlot: 3,
flightSlots: defaultFlightSlots(),
hdrStreaming: "off",};

export class SettingsManager {
private settings: Settings;
Expand Down
15 changes: 14 additions & 1 deletion opennow-stable/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ const api: PreloadApi = {
ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SET, key, value),
resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET),
exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format),
updateDiscordPresence: (state: DiscordPresencePayload) =>
ipcRenderer.invoke(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, state),
clearDiscordPresence: () => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE),
flightGetProfile: (vidPid: string, gameId?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_PROFILE, vidPid, gameId),
flightSetProfile: (profile: FlightProfile) =>
ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_SET_PROFILE, profile),
flightDeleteProfile: (vidPid: string, gameId?: string) =>
ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, vidPid, gameId),
flightGetAllProfiles: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES),
flightResetProfile: (vidPid: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_RESET_PROFILE, vidPid),
getOsHdrInfo: () => ipcRenderer.invoke(IPC_CHANNELS.HDR_GET_OS_INFO),};
getOsHdrInfo: () => ipcRenderer.invoke(IPC_CHANNELS.HDR_GET_OS_INFO),
relaunchApp: () => ipcRenderer.invoke(IPC_CHANNELS.APP_RELAUNCH),
};

contextBridge.exposeInMainWorld("openNow", api);
Loading