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
645 changes: 435 additions & 210 deletions opennow-stable/bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion opennow-stable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"dist": "npm run build && cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder",
"dist:signed": "npm run build && electron-builder",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.json",
"test": "tsx --test src/renderer/src/gfn/inputProtocol.test.ts"
"test": "tsx --test src/shared/gfn.test.ts src/renderer/src/lib/launchOwnership.test.ts src/renderer/src/components/GameCard.test.ts src/renderer/src/gfn/inputProtocol.test.ts"
},
"dependencies": {
"discord-rpc": "^4.0.1",
Expand Down
3 changes: 2 additions & 1 deletion opennow-stable/src/main/gfn/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
GameInfo,
GameVariant,
} from "@shared/gfn";
import { isOwnedLibraryStatus } from "@shared/gfn";
import { cacheManager } from "../services/cacheManager";

const GRAPHQL_URL = "https://games.geforce.com/graphql";
Expand Down Expand Up @@ -333,7 +334,7 @@ function resolveAppData(app: AppData): AppResolution {
const lastPlayed = variants
.map((variant) => variant.gfn?.library?.lastPlayedDate)
.find((value): value is string => typeof value === "string" && value.length > 0);
const isInLibrary = variants.some((variant) => variant.gfn?.library?.status === "IN_LIBRARY" || variant.gfn?.library?.selected === true);
const isInLibrary = variants.some((variant) => isOwnedLibraryStatus(variant.gfn?.library?.status));

return {
numericAppId,
Expand Down
39 changes: 18 additions & 21 deletions opennow-stable/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { useElapsedSeconds } from "./utils/useElapsedSeconds";
import { usePlaytime } from "./utils/usePlaytime";
import { createStreamDiagnosticsStore } from "./utils/streamDiagnosticsStore";
import { loadStoredCodecResults, saveStoredCodecResults, testCodecSupport, type CodecTestResult } from "./lib/codecDiagnostics";
import { chooseAccountLinked, getEpicOwnershipLaunchError } from "./lib/launchOwnership";

// UI Components
import { LoginScreen } from "./components/LoginScreen";
Expand Down Expand Up @@ -259,7 +260,7 @@ function parseNumericId(value: string | undefined): number | null {
}

function defaultVariantId(game: GameInfo): string {
return game.variants[0]?.id ?? game.id;
return game.variants[game.selectedVariantIndex]?.id ?? game.variants[0]?.id ?? game.id;
}

function getSelectedVariant(game: GameInfo, variantId: string): GameVariant | undefined {
Expand Down Expand Up @@ -305,22 +306,6 @@ function matchesGameSearch(game: GameInfo, query: string): boolean {
.some((value) => value.toLowerCase().includes(normalizedQuery));
}

function chooseAccountLinked(game: GameInfo, selectedVariant?: GameVariant): boolean {
if (game.playType === "INSTALL_TO_PLAY") {
return false;
}
if (selectedVariant?.librarySelected) {
return true;
}
if (selectedVariant?.libraryStatus === "IN_LIBRARY") {
return true;
}
if (game.isInLibrary) {
return true;
}
return false;
}

function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length != right.length) {
return false;
Expand Down Expand Up @@ -2555,6 +2540,20 @@ export function App(): JSX.Element {
return;
}

const selectedVariantId = variantByGameId[game.id] ?? defaultVariantId(game);
const selectedVariant = getSelectedVariant(game, selectedVariantId);
const epicOwnershipError = getEpicOwnershipLaunchError(selectedVariant);
if (epicOwnershipError) {
setStreamingGame(game);
setStreamingStore(selectedVariant?.store ?? null);
setLaunchError({
stage: "queue",
title: epicOwnershipError.title,
description: epicOwnershipError.description,
});
return;
}

launchInFlightRef.current = true;
launchAbortRef.current = false;
let loadingStep: StreamLoadingStatus = "queue";
Expand All @@ -2564,12 +2563,10 @@ export function App(): JSX.Element {
};

setSessionStartedAtMs(null);
setRemoteStreamWarning(null);
setLocalSessionTimerWarning(null);
setRemoteStreamWarning(null);
setLocalSessionTimerWarning(null);
setLaunchError(null);
resetStatsOverlayToPreference();
const selectedVariantId = variantByGameId[game.id] ?? defaultVariantId(game);
const selectedVariant = getSelectedVariant(game, selectedVariantId);
startPlaytimeSession(game.id);
updateLoadingStep("queue");
setQueuePosition(undefined);
Expand Down
88 changes: 88 additions & 0 deletions opennow-stable/src/renderer/src/components/GameCard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/// <reference types="node" />

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

import type { GameInfo, GameVariant } from "@shared/gfn";
import { getStoreOptions } from "../lib/gameCardStores";

function makeVariant(overrides: Partial<GameVariant> = {}): GameVariant {
return {
id: overrides.id ?? "variant-1",
store: overrides.store ?? "Steam",
supportedControls: overrides.supportedControls ?? [],
librarySelected: overrides.librarySelected,
libraryStatus: overrides.libraryStatus,
lastPlayedDate: overrides.lastPlayedDate,
gfnStatus: overrides.gfnStatus,
};
}

function makeGame(variants: GameVariant[]): GameInfo {
return {
id: "game-1",
title: "Test Game",
selectedVariantIndex: 0,
variants,
};
}

test("marks owned and unowned store chips independently", () => {
const options = getStoreOptions(
makeGame([
makeVariant({ id: "steam", store: "Steam", libraryStatus: "MANUAL" }),
makeVariant({ id: "epic", store: "Epic Games Store", libraryStatus: "NOT_OWNED" }),
]),
);

assert.deepEqual(
options.map((option) => ({
storeKey: option.storeKey,
isOwned: option.isOwned,
isActive: option.isActive,
})),
[
{ storeKey: "STEAM", isOwned: true, isActive: true },
{ storeKey: "EPIC_GAMES_STORE", isOwned: false, isActive: false },
],
);
});

test("collapses multiple variants from the same store into a single chip and prefers an owned variant", () => {
const options = getStoreOptions(
makeGame([
makeVariant({ id: "steam-unowned", store: "Steam", libraryStatus: "NOT_OWNED" }),
makeVariant({ id: "steam-owned", store: "Steam", libraryStatus: "PLATFORM_SYNC" }),
makeVariant({ id: "epic", store: "Epic", libraryStatus: "NOT_OWNED" }),
]),
"epic",
);

assert.equal(options.length, 2);

const steamOption = options.find((option) => option.storeKey === "STEAM");
assert.ok(steamOption);
assert.equal(steamOption.variantId, "steam-owned");
assert.equal(steamOption.isOwned, true);
assert.equal(steamOption.isActive, false);
});

test("keeps active and owned states separate for the selected store", () => {
const options = getStoreOptions(
makeGame([
makeVariant({ id: "epic", store: "EGS", libraryStatus: "NOT_OWNED" }),
makeVariant({ id: "steam", store: "Steam", libraryStatus: "IN_LIBRARY" }),
]),
"epic",
);

const epicOption = options.find((option) => option.storeKey === "EGS");
const steamOption = options.find((option) => option.storeKey === "STEAM");

assert.ok(epicOption);
assert.ok(steamOption);
assert.equal(epicOption.isActive, true);
assert.equal(epicOption.isOwned, false);
assert.equal(steamOption.isActive, false);
assert.equal(steamOption.isOwned, true);
});
67 changes: 26 additions & 41 deletions opennow-stable/src/renderer/src/components/GameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Play, Monitor } from "lucide-react";
import { memo, useCallback, useState } from "react";
import type { JSX } from "react";
import { normalizeGameStore } from "@shared/gfn";
import type { GameInfo } from "@shared/gfn";
import { getStoreOptions as getGameCardStoreOptions } from "../lib/gameCardStores";

interface GameCardProps {
game: GameInfo;
Expand All @@ -13,10 +15,13 @@ interface GameCardProps {
}

interface StoreOption {
store: string;
storeKey: string;
variantId: string;
displayName: string;
IconComponent: () => JSX.Element;
isOwned: boolean;
isActive: boolean;
}

/* ── Official store brand icons (Simple Icons / MDI, viewBox 0 0 24 24) ── */
Expand Down Expand Up @@ -131,7 +136,7 @@ const STORE_DISPLAY_NAME: Record<string, string> = {

/** Normalize an appStore value to the uppercase key used by the icon/name maps. */
export function normalizeStoreKey(raw: string): string {
return raw.toUpperCase().replace(/[\s-]+/g, "_");
return normalizeGameStore(raw);
}

function formatStoreFallbackName(storeKey: string): string {
Expand All @@ -151,39 +156,6 @@ export function getStoreIconComponent(store: string): () => JSX.Element {
return STORE_ICON_MAP[key] ?? DefaultStoreIcon;
}

function getStoreOptions(game: GameInfo): StoreOption[] {
const seen = new Set<string>();
const options: StoreOption[] = [];
for (const variant of game.variants) {
const key = normalizeStoreKey(variant.store);
if (key !== "UNKNOWN" && key !== "NONE" && !seen.has(key)) {
seen.add(key);
options.push({
storeKey: key,
variantId: variant.id,
displayName: getStoreDisplayName(variant.store),
IconComponent: getStoreIconComponent(variant.store),
});
}
}
return options;
}

function getActiveVariantId(storeOptions: StoreOption[], selectedVariantId?: string): string | undefined {
if (!selectedVariantId) {
return storeOptions[0]?.variantId;
}
const hasSelected = storeOptions.some((option) => option.variantId === selectedVariantId);
return hasSelected ? selectedVariantId : storeOptions[0]?.variantId;
}

function getActiveStoreOption(storeOptions: StoreOption[], activeVariantId?: string): StoreOption | undefined {
if (!activeVariantId) {
return storeOptions[0];
}
return storeOptions.find((option) => option.variantId === activeVariantId) ?? storeOptions[0];
}

export const GameCard = memo(function GameCard({
game,
isSelected = false,
Expand All @@ -192,9 +164,12 @@ export const GameCard = memo(function GameCard({
selectedVariantId,
onSelectStore,
}: GameCardProps): JSX.Element {
const storeOptions = getStoreOptions(game);
const activeVariantId = getActiveVariantId(storeOptions, selectedVariantId);
const activeStoreOption = getActiveStoreOption(storeOptions, activeVariantId);
const storeOptions: StoreOption[] = getGameCardStoreOptions(game, selectedVariantId).map((option) => ({
...option,
displayName: getStoreDisplayName(option.store),
IconComponent: getStoreIconComponent(option.store),
}));
const activeStoreOption = storeOptions.find((option) => option.isActive) ?? storeOptions[0];

const [aspectPct, setAspectPct] = useState<number | undefined>(undefined);

Expand Down Expand Up @@ -277,9 +252,19 @@ export const GameCard = memo(function GameCard({
{storeOptions.length > 0 && (
<div className="game-card-stores">
{storeOptions.map((store) => {
const isActive = store.variantId === activeVariantId;
const className = `game-card-store-chip ${isActive ? "active" : ""}`;
const title = `${store.displayName}${isActive ? " (selected)" : ""}`;
const className = [
"game-card-store-chip",
store.isActive ? "active" : "",
store.isOwned ? "owned" : "",
].filter(Boolean).join(" ");
const titleParts = [store.displayName];
if (store.isOwned) {
titleParts.push("owned");
}
if (store.isActive) {
titleParts.push("selected");
}
const title = titleParts.join(" · ");

if (onSelectStore) {
return (
Expand All @@ -290,7 +275,7 @@ export const GameCard = memo(function GameCard({
title={title}
onClick={(event) => handleStoreClick(event, store.variantId)}
aria-label={`${store.displayName} store`}
aria-pressed={isActive}
aria-pressed={store.isActive}
>
<store.IconComponent />
</button>
Expand Down
58 changes: 58 additions & 0 deletions opennow-stable/src/renderer/src/lib/gameCardStores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { GameInfo, GameVariant } from "@shared/gfn";
import { isOwnedVariant, normalizeGameStore } from "@shared/gfn";

export interface StoreOption {
storeKey: string;
store: string;
variantId: string;
isOwned: boolean;
isActive: boolean;
}

function getResolvedSelectedVariantId(game: GameInfo, selectedVariantId?: string): string | undefined {
if (selectedVariantId && game.variants.some((variant) => variant.id === selectedVariantId)) {
return selectedVariantId;
}

return game.variants[game.selectedVariantIndex]?.id ?? game.variants[0]?.id;
}

function getVariantForStore(variants: GameVariant[], activeVariantId?: string): GameVariant | undefined {
if (activeVariantId) {
return variants.find((variant) => variant.id === activeVariantId) ?? variants[0];
}

return variants.find((variant) => isOwnedVariant(variant)) ?? variants[0];
}

export function getStoreOptions(game: GameInfo, selectedVariantId?: string): StoreOption[] {
const resolvedSelectedVariantId = getResolvedSelectedVariantId(game, selectedVariantId);
const variantsByStore = new Map<string, GameVariant[]>();

for (const variant of game.variants) {
const storeKey = normalizeGameStore(variant.store);
if (storeKey === "UNKNOWN" || storeKey === "NONE") {
continue;
}

const existing = variantsByStore.get(storeKey);
if (existing) {
existing.push(variant);
} else {
variantsByStore.set(storeKey, [variant]);
}
}

return [...variantsByStore.entries()].map(([storeKey, variants]) => {
const activeVariantId = variants.find((variant) => variant.id === resolvedSelectedVariantId)?.id;
const preferredVariant = getVariantForStore(variants, activeVariantId);

return {
storeKey,
store: preferredVariant?.store ?? variants[0]?.store ?? storeKey,
variantId: preferredVariant?.id ?? variants[0]?.id ?? storeKey,
isOwned: variants.some((variant) => isOwnedVariant(variant)),
isActive: Boolean(activeVariantId),
};
});
}
Loading
Loading