Skip to content
Merged
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
18 changes: 17 additions & 1 deletion opennow-stable/src/main/discordPresence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ test("discordActivityFromSession includes queue position and only timestamps str
appId: 1001,
status: 1,
queuePosition: 12,
}, "Test Game");
}, "Test Game", "https://example.com/test-game.jpg");

assert.deepEqual(queued, {
gameName: "Test Game",
gameImageUrl: "https://example.com/test-game.jpg",
kind: "queued",
appId: "1001",
queuePosition: 12,
Expand Down Expand Up @@ -94,6 +95,7 @@ test("discordMonitorActivityDecision does not downgrade active streaming presenc
test("discordMonitorActivityDecision preserves current game title on queue updates", () => {
const decision = discordMonitorActivityDecision({
gameName: "Human Game Title",
gameImageUrl: "https://example.com/game.jpg",
kind: "queued",
appId: "1001",
queuePosition: 12,
Expand All @@ -107,10 +109,24 @@ test("discordMonitorActivityDecision preserves current game title on queue updat
assert.equal(decision.action, "set");
if (decision.action === "set") {
assert.equal(decision.activity.gameName, "Human Game Title");
assert.equal(decision.activity.gameImageUrl, "https://example.com/game.jpg");
assert.equal(decision.activity.queuePosition, 11);
}
});

test("isSameDiscordActivity detects newly available game artwork", () => {
assert.equal(isSameDiscordActivity({
gameName: "Game",
kind: "streaming",
appId: "1001",
}, {
gameName: "Game",
gameImageUrl: "https://example.com/game.jpg",
kind: "streaming",
appId: "1001",
}), false);
});

test("discordMonitorActivityDecision does not reset active streaming timer", () => {
const startedAt = new Date("2026-01-01T00:00:00.000Z");
const decision = discordMonitorActivityDecision({
Expand Down
7 changes: 6 additions & 1 deletion opennow-stable/src/main/discordPresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function discordActivityKindForSession(session: DiscordPresenceSessionSta
export function discordActivityFromSession(
session: (SessionInfo | ActiveSessionInfo) & DiscordPresenceSessionState,
gameName: string,
gameImageUrl?: string,
): DiscordActivityUpdate | null {
const kind = discordActivityKindForSession(session);
if (!kind) {
Expand All @@ -40,6 +41,7 @@ export function discordActivityFromSession(
const appId = typeof session.appId === "number" ? session.appId.toString() : session.appId;
return {
gameName,
gameImageUrl,
kind,
appId,
queuePosition: session.queuePosition,
Expand All @@ -54,6 +56,9 @@ export function isSameDiscordActivity(
if (!current || current.kind !== next.kind || current.queuePosition !== next.queuePosition) {
return false;
}
if (current.gameImageUrl !== next.gameImageUrl) {
return false;
}
if (current.appId || next.appId) {
return current.appId === next.appId;
}
Expand All @@ -74,7 +79,7 @@ export function discordMonitorActivityDecision(
}

const gameName = current?.appId === sessionAppId ? current.gameName : sessionAppId;
const nextActivity = discordActivityFromSession(activeSession, gameName);
const nextActivity = discordActivityFromSession(activeSession, gameName, current?.gameImageUrl);
if (!nextActivity) {
return current?.appId === sessionAppId ? { action: "clear" } : { action: "none" };
}
Expand Down
36 changes: 36 additions & 0 deletions opennow-stable/src/main/discordRpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/// <reference types="node" />

import test from "node:test";
import assert from "node:assert/strict";

import { discordRpcActivityPayload, discordRpcImageUrl } from "./discordRpc";

test("discordRpcImageUrl accepts public HTTPS artwork", () => {
assert.equal(
discordRpcImageUrl("https://img.nvidiagrid.net/game.jpg;f=webp;w=1200"),
"https://img.nvidiagrid.net/game.jpg;f=webp;w=1200",
);
});

test("discordRpcImageUrl rejects non-HTTPS and malformed artwork URLs", () => {
assert.equal(discordRpcImageUrl("http://example.com/game.jpg"), undefined);
assert.equal(discordRpcImageUrl("not a URL"), undefined);
});

test("discordRpcActivityPayload sends game artwork as the large presence image", () => {
const startedAt = new Date("2026-01-01T00:00:00.000Z");
assert.deepEqual(discordRpcActivityPayload({
gameName: "7 Days to Die",
gameImageUrl: "https://example.com/7-days-to-die.jpg",
kind: "streaming",
appId: "1001",
startTimestamp: startedAt,
}), {
details: "7 Days to Die",
state: "Streaming via OpenNow",
startTimestamp: startedAt,
largeImageKey: "https://example.com/7-days-to-die.jpg",
largeImageText: "7 Days to Die",
instance: false,
});
});
38 changes: 28 additions & 10 deletions opennow-stable/src/main/discordRpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const DISCORD_CLIENT_ID = "1479944467112001669";

let rpcClient: Client | null = null;
let connected = false;
type DiscordRpcActivity = Omit<DiscordActivityUpdate, "startTimestampMs"> & {
export type DiscordRpcActivity = Omit<DiscordActivityUpdate, "startTimestampMs"> & {
startTimestamp?: Date;
};

Expand Down Expand Up @@ -85,6 +85,32 @@ function activityState(activity: DiscordRpcActivity): string {
}
}

export function discordRpcImageUrl(imageUrl?: string): string | undefined {
const candidate = imageUrl?.trim();
if (!candidate) {
return undefined;
}
try {
return new URL(candidate).protocol === "https:" ? candidate : undefined;
} catch {
return undefined;
}
}

export function discordRpcActivityPayload(activity: DiscordRpcActivity) {
const gameImageUrl = discordRpcImageUrl(activity.gameImageUrl);
return {
details: activity.gameName,
state: activityState(activity),
...(activity.startTimestamp ? { startTimestamp: activity.startTimestamp } : {}),
...(gameImageUrl ? {
largeImageKey: gameImageUrl,
largeImageText: activity.gameName,
} : {}),
instance: false,
};
}

export async function setActivity(activity: DiscordRpcActivity): Promise<void> {
pendingActivity = activity;

Expand All @@ -93,15 +119,7 @@ export async function setActivity(activity: DiscordRpcActivity): Promise<void> {
}

try {
const rpcActivity = {
details: activity.gameName,
state: activityState(activity),
...(activity.startTimestamp ? { startTimestamp: activity.startTimestamp } : {}),
instance: false,
};
await rpcClient.setActivity({
...rpcActivity,
});
await rpcClient.setActivity(discordRpcActivityPayload(activity));
lastActivity = pendingActivity;
pendingActivity = null;
} catch (err) {
Expand Down
10 changes: 5 additions & 5 deletions opennow-stable/src/main/ipc/sessionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export function registerSessionIpcHandlers(deps: SessionIpcHandlerDeps): void {
clearActivity,
} = deps;

const setLaunchPresence = (session: SessionInfo, gameName: string): void => {
const activity = discordActivityFromSession(session, gameName);
const setLaunchPresence = (session: SessionInfo, gameName: string, gameImageUrl?: string): void => {
const activity = discordActivityFromSession(session, gameName, gameImageUrl);
if (!settingsManager.get("discordRichPresence") || !activity) {
return;
}
Expand Down Expand Up @@ -187,7 +187,7 @@ export function registerSessionIpcHandlers(deps: SessionIpcHandlerDeps): void {
if (!forceNewSession) {
const preChecked = await tryClaimExisting();
if (preChecked) {
setLaunchPresence(preChecked, payload.internalTitle || payload.appId);
setLaunchPresence(preChecked, payload.internalTitle || payload.appId, payload.discordGameImageUrl);
return preChecked;
}
}
Expand All @@ -206,7 +206,7 @@ export function registerSessionIpcHandlers(deps: SessionIpcHandlerDeps): void {
token,
streamingBaseUrl,
});
setLaunchPresence(sessionResult, payload.internalTitle || payload.appId);
setLaunchPresence(sessionResult, payload.internalTitle || payload.appId, payload.discordGameImageUrl);
return sessionResult;
} catch (error) {
if (
Expand All @@ -219,7 +219,7 @@ export function registerSessionIpcHandlers(deps: SessionIpcHandlerDeps): void {
);
const fallback = await tryClaimExisting();
if (fallback) {
setLaunchPresence(fallback, payload.internalTitle || payload.appId);
setLaunchPresence(fallback, payload.internalTitle || payload.appId, payload.discordGameImageUrl);
return fallback;
}
}
Expand Down
5 changes: 5 additions & 0 deletions opennow-stable/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
StreamRegion,
VideoShaderSettings,
} from "@shared/gfn";
import { discordGameImageUrl } from "@shared/discord";
import {
buildNativeStreamerSessionContext,
createDefaultSettings,
Expand Down Expand Up @@ -613,9 +614,13 @@ export function App(): JSX.Element {
}

const gameName = (streamingGameRef.current?.title || activeSession.appId || "Game").trim();
const gameImageUrl = streamingGameRef.current
? discordGameImageUrl(streamingGameRef.current)
: undefined;
discordStreamingActivitySessionRef.current = activeSession.sessionId;
void window.openNow.setDiscordActivity({
gameName,
gameImageUrl,
kind: "streaming",
appId: activeSession.appId,
startTimestampMs: Date.now(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
SubscriptionInfo,
} from "@shared/gfn";
import { isSessionAdsRequired } from "@shared/gfn";
import { discordGameImageUrl } from "@shared/discord";

import { chooseAccountLinked, getEpicOwnershipLaunchError } from "../../lib/launchOwnership";
import {
Expand Down Expand Up @@ -266,6 +267,7 @@ export function useGameLaunch({
streamingBaseUrl: launchStreamingBaseUrl,
appId,
internalTitle: game.title,
discordGameImageUrl: discordGameImageUrl(game),
accountLinked: chooseAccountLinked(game, selectedVariant),
enablePersistingInGameSettings: settings.enablePersistingInGameSettings,
supportsInGameSettingsPersistence: launchVariant?.supportsInGameSettingsPersistence === true,
Expand Down
21 changes: 21 additions & 0 deletions opennow-stable/src/shared/discord.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// <reference types="node" />

import test from "node:test";
import assert from "node:assert/strict";

import { discordGameImageUrl } from "./discord";

test("discordGameImageUrl prefers box artwork over landscape fallbacks", () => {
assert.equal(discordGameImageUrl({
imageUrl: "https://example.com/hero.jpg",
imageUrlsByType: {
GAME_BOX_ART: ["https://example.com/box-art.jpg"],
},
}), "https://example.com/box-art.jpg");
});

test("discordGameImageUrl falls back to the catalog image", () => {
assert.equal(discordGameImageUrl({
imageUrl: "https://example.com/catalog.jpg",
}), "https://example.com/catalog.jpg");
});
19 changes: 19 additions & 0 deletions opennow-stable/src/shared/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,27 @@ export type DiscordActivityKind = "queued" | "starting" | "streaming";

export interface DiscordActivityUpdate {
gameName: string;
gameImageUrl?: string;
kind: DiscordActivityKind;
appId?: string;
queuePosition?: number;
startTimestampMs?: number;
}

export interface DiscordGameArtwork {
imageUrl?: string;
heroImageUrl?: string;
imageUrlsByType?: Record<string, string[]>;
}

const DISCORD_GAME_IMAGE_TYPES = ["GAME_BOX_ART", "KEY_IMAGE", "KEY_ART"] as const;

export function discordGameImageUrl(game: DiscordGameArtwork): string | undefined {
for (const imageType of DISCORD_GAME_IMAGE_TYPES) {
const imageUrl = game.imageUrlsByType?.[imageType]?.find((candidate) => candidate.trim().length > 0);
if (imageUrl) {
return imageUrl;
}
}
return game.imageUrl || game.heroImageUrl;
}
2 changes: 2 additions & 0 deletions opennow-stable/src/shared/gfn/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface SessionCreateRequest {
streamingBaseUrl?: string;
appId: string;
internalTitle: string;
/** Public game artwork used only for the local Discord Rich Presence. */
discordGameImageUrl?: string;
accountLinked?: boolean;
/**
* Official clients only enable server-side in-game graphics/settings persistence
Expand Down
Loading