Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7db28ae
Track the installed version of a game
claude Oct 5, 2026
3450885
Remove PR screenshots from the tree
claude Oct 5, 2026
f68c34f
Share the PATCH handler of per-user game fields
claude Oct 5, 2026
6553acb
Address SonarCloud findings on the version helpers
claude Oct 5, 2026
a62337d
Record the installed version once the files are really in place
claude Oct 5, 2026
88e1c2c
Re-read the game before recording its version, refresh games app-wide
claude Oct 5, 2026
ae48e18
Take an update's target version, including build targets
claude Oct 5, 2026
6e975c2
Record edition releases' versions, keep suggestion clicks, write vers…
claude Oct 5, 2026
710b6ae
Add a 1.5.1-rc1 changelog entry for the installed version
claude Oct 5, 2026
499fc90
Skip pre-release versions, take build-range targets, serialize versio…
claude Oct 5, 2026
7ab4130
Queue a typed-back value during a save, and file the changelog entry …
claude Oct 5, 2026
17779cd
Keep the category picked when claiming a download, and trust it for t…
claude Oct 5, 2026
f5e5d48
Skip early-access and hotfix versions, take the highest build of any …
claude Oct 5, 2026
8c69408
Store the release category of searched and auto-grabbed downloads, ig…
claude Oct 5, 2026
dd29ac8
Read versions after Patch markers, store full-game editions as main
claude Oct 5, 2026
5f2b8c3
Ignore an Enter that confirms an IME composition in the version field
claude Oct 5, 2026
c8a5fa5
Keep an edited version draft across refreshes, skip versions after a …
claude Oct 5, 2026
76225b1
Suggest versions only from completed downloads, reject letter-suffixe…
claude Oct 5, 2026
497d4af
Read unprefixed update ranges, split DLC names on underscores
claude Oct 5, 2026
e31371b
Keep the version save pending until the games refetch lands
claude Oct 5, 2026
5ae8ed7
Broadcast manual version edits, read mixed-prefix update ranges
claude Oct 5, 2026
4b4f20d
Read unprefixed targets in ranges before a trailing patch marker
claude Oct 5, 2026
421b894
Take an unprefixed number as an update target only after "to"
claude Oct 5, 2026
837cde2
Read bare build range targets, normalize all separators before catego…
claude Oct 5, 2026
ab36a72
Keep hyphenated markers like Add-On when categorizing release names
claude Oct 5, 2026
e2822c9
Compare again after losing the version write to a concurrent import
claude Oct 5, 2026
e6b5e20
Merge the installed-version migrations into one per dialect
claude Oct 5, 2026
c257078
Fix SonarCloud findings on the installed-version code
claude Oct 5, 2026
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
138 changes: 138 additions & 0 deletions client/__tests__/InstalledVersionField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/** @vitest-environment jsdom */
import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createTestQueryClient } from "./test-utils";
import InstalledVersionField, { getVersionSuggestions } from "@/components/InstalledVersionField";

const mockToast = vi.fn();
vi.mock("@/hooks/use-toast", () => ({
useToast: () => ({ toast: mockToast }),
}));

const apiRequest = vi.fn();
vi.mock("@/lib/queryClient", () => ({
apiRequest: (...args: unknown[]) => apiRequest(...args),
}));

const gameId = "game-1";

function renderField(installedVersion: string | null, releaseNames: string[] = []) {
render(
<QueryClientProvider client={createTestQueryClient()}>
<InstalledVersionField
gameId={gameId}
installedVersion={installedVersion}
releaseNames={releaseNames}
/>
</QueryClientProvider>
);
return screen.getByLabelText("Installed version");
}

describe("getVersionSuggestions", () => {
it("lists distinct versions newest first, without the current one", () => {
expect(
getVersionSuggestions(
[
"Game.v1.2-RUNE",
"Game.Update.v1.10-RUNE",
"Game.v1.2-GOG",
"Game.Update.v1.3-RUNE",
"Game-NoVersion",
],
"v1.3"
)
).toEqual(["v1.10", "v1.2"]);
});
});

describe("InstalledVersionField", () => {
beforeEach(() => {
vi.clearAllMocks();
apiRequest.mockResolvedValue({ ok: true, json: async () => ({}) });
});

afterEach(() => {
cleanup();
});

it("shows the stored version", () => {
expect(renderField("v1.2.3")).toHaveValue("v1.2.3");
});

it("saves the typed version on Enter", async () => {
const input = renderField(null);
fireEvent.change(input, { target: { value: " v2.0 " } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v2.0",
})
);
});

it("clears the version when emptied and blurred", async () => {
const input = renderField("v1.0");
fireEvent.change(input, { target: { value: "" } });
fireEvent.blur(input);
await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: null,
})
);
});

it("does not save when the value did not change", () => {
const input = renderField("v1.0");
fireEvent.blur(input);
expect(apiRequest).not.toHaveBeenCalled();
});

it("applies a suggestion from the game's downloads", async () => {
renderField(null, ["Game.Update.v1.4-RUNE"]);
fireEvent.click(screen.getByRole("button", { name: "Set installed version to v1.4" }));
await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v1.4",
})
);
});

it("keeps focus in the input when a suggestion is pressed", () => {
renderField(null, ["Game.Update.v1.4-RUNE"]);
const button = screen.getByRole("button", { name: "Set installed version to v1.4" });
// A cancelled mousedown means the input never blurs (and never saves its draft) first.
expect(fireEvent.mouseDown(button)).toBe(false);
});

it("sends a value typed during a save only after that save settles", async () => {
let finishFirst: (value: unknown) => void = () => {};
apiRequest.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
const input = renderField(null);
fireEvent.change(input, { target: { value: "v2" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1));
fireEvent.change(input, { target: { value: "v3" } });
fireEvent.blur(input);
expect(apiRequest).toHaveBeenCalledTimes(1);

finishFirst({ ok: true, json: async () => ({}) });
await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2));
expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v3",
});
});

it("restores the stored value and warns when saving fails", async () => {
apiRequest.mockRejectedValue(new Error("boom"));
const input = renderField("v1.0");
fireEvent.change(input, { target: { value: "v9" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(mockToast).toHaveBeenCalled());
expect(input).toHaveValue("v1.0");
});
});
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Header from "@/components/Header";
import MobileBottomNav from "@/components/MobileBottomNav";
import { getPageTitle } from "@/components/navigation-items";
import { useBackgroundNotifications } from "@/hooks/use-background-notifications";
import { useGameUpdatedSocket } from "@/hooks/use-game-updated-socket";
import { AuthProvider } from "@/lib/auth";
import { Suspense, lazy, useEffect, useLayoutEffect, useRef } from "react";
import LoadingFallback from "@/components/LoadingFallback";
Expand Down Expand Up @@ -145,6 +146,7 @@ function Router() {
function AppContent() {
// Enable background notifications for downloads
useBackgroundNotifications();
useGameUpdatedSocket();

return <Router />;
}
Expand Down
14 changes: 14 additions & 0 deletions client/src/components/GameDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
import { NexusModsIcon } from "./NexusModsIcon";
import GameJournalTab from "./GameJournalTab";
import FileCategorySelect from "./FileCategorySelect";
import InstalledVersionField from "./InstalledVersionField";
import { getSocket } from "@/lib/socket";
import { useToast } from "@/hooks/use-toast";
import { useHiddenMutation } from "@/hooks/use-hidden-mutation";
Expand Down Expand Up @@ -635,6 +636,11 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
refetchInterval: 5000,
});

const downloadReleaseNames = useMemo(
() => gameDownloads.map((download) => download.downloadTitle),
[gameDownloads]
);

const {
data: xrelStatus,
isLoading: xrelStatusLoading,
Expand Down Expand Up @@ -1241,6 +1247,14 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
</p>
</div>
)}
{!isDiscoveryId(game.id) && (
<InstalledVersionField
key={game.id}
gameId={game.id}
installedVersion={game.installedVersion ?? null}
releaseNames={downloadReleaseNames}
/>
)}
</div>
</div>
</ScrollArea>
Expand Down
163 changes: 163 additions & 0 deletions client/src/components/InstalledVersionField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Tag } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { compareVersions, extractVersionFromReleaseName } from "@shared/version-utils";

interface InstalledVersionFieldProps {
readonly gameId: string;
readonly installedVersion: string | null;
/** Release names of the game's downloads, mined for version suggestions. */
readonly releaseNames: readonly string[];
}

const MAX_SUGGESTIONS = 6;

/** Distinct versions found in release names, newest first when they can be compared. */
export function getVersionSuggestions(
releaseNames: readonly string[],
current: string | null
): string[] {
const seen = new Map<string, string>();
for (const name of releaseNames) {
const version = extractVersionFromReleaseName(name);
if (version && !seen.has(version.toLowerCase())) seen.set(version.toLowerCase(), version);
Comment thread
Doezer marked this conversation as resolved.
}
const currentKey = current?.trim().toLowerCase();
return (
Array.from(seen.values())
.filter((version) => version.toLowerCase() !== currentKey)
// Newest first: compare the right-hand version against the left-hand one.
.sort((left, right) => compareVersions(right, left) ?? 0)
.slice(0, MAX_SUGGESTIONS)
);
}

export default function InstalledVersionField({
gameId,
installedVersion,
releaseNames,
}: InstalledVersionFieldProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [draft, setDraft] = useState(installedVersion ?? "");
// A value entered while a save is in flight; sent once that save settles, so saves never
// race and the latest value always lands last.
const queued = useRef<{ value: string | null } | null>(null);

// Follow the server value (another tab, a finished download detecting a newer version).
useEffect(() => {
setDraft(installedVersion ?? "");
}, [gameId, installedVersion]);
Comment thread
Doezer marked this conversation as resolved.

const mutation = useMutation<void, Error, string | null>({
mutationFn: async (value) => {
await apiRequest("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: value,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/games"] });
Comment thread
Doezer marked this conversation as resolved.
Outdated
},
onError: () => {
if (queued.current) return; // a newer value is about to be sent
setDraft(installedVersion ?? "");
toast({ description: "Failed to save the installed version", variant: "destructive" });
},
onSettled: (_data, _error, sent) => {
const next = queued.current;
queued.current = null;
if (next && next.value !== sent) mutation.mutate(next.value);
},
});

const suggestions = useMemo(
() => getVersionSuggestions(releaseNames, draft),
[releaseNames, draft]
);

const save = (value: string) => {
const next = value.trim() || null;
if (next === (installedVersion ?? null)) return;
Comment thread
Doezer marked this conversation as resolved.
Outdated
if (mutation.isPending) {
// Enter then blur would otherwise send the same value twice before the refetch lands.
queued.current = mutation.variables === next ? null : { value: next };
return;
}
mutation.mutate(next);
};

const showSaved =
!mutation.isPending && mutation.isSuccess && !!draft && draft === (installedVersion ?? "");

return (
<div data-testid="section-installed-version">
Comment thread
Doezer marked this conversation as resolved.
<label htmlFor="installed-version" className="font-semibold mb-2 flex items-center gap-2">
<Tag className="w-4 h-4" />
Installed version
</label>
<div className="relative w-full max-w-sm">
<Input
id="installed-version"
value={draft}
maxLength={64}
placeholder="Unknown (e.g. v1.2.3)"
autoComplete="off"
className="h-10 pr-9 sm:h-9"
onChange={(e) => setDraft(e.target.value)}
onBlur={() => save(draft)}
Comment thread
Doezer marked this conversation as resolved.
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
save(draft);
} else if (e.key === "Escape" && draft !== (installedVersion ?? "")) {
e.stopPropagation();
setDraft(installedVersion ?? "");
}
}}
data-testid="input-installed-version"
/>
<span
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-muted-foreground"
aria-live="polite"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" aria-label="Saving" />}
{showSaved && <Check className="w-4 h-4 text-emerald-500" aria-label="Saved" />}
</span>
</div>
{suggestions.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">From your downloads:</span>
{suggestions.map((version) => (
<Button
key={version}
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs"
aria-label={`Set installed version to ${version}`}
disabled={mutation.isPending}
// Keep focus in the input: its blur would otherwise save the typed draft and
// disable these buttons before the click lands.
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setDraft(version);
save(version);
}}
data-testid={`button-version-suggestion-${version}`}
>
{version}
</Button>
))}
</div>
)}
<p className="mt-1 text-xs text-muted-foreground">
Detected from release names when a download finishes. Updates that are not newer than this
version are not notified.
</p>
</div>
);
}
24 changes: 24 additions & 0 deletions client/src/hooks/use-game-updated-socket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getSocket } from "@/lib/socket";

/**
* Refreshes the games query when the server changes a game in the background (e.g. a finished
* download recording a newer installed version). Mounted app-wide: the games query never goes
* stale on its own, so an event missed while no details modal is open would otherwise leave the
* library showing old data until a reload.
*/
export function useGameUpdatedSocket(): void {
const queryClient = useQueryClient();

useEffect(() => {
const socket = getSocket();
const handler = () => {
queryClient.invalidateQueries({ queryKey: ["/api/games"] });
};
socket.on("gameUpdated", handler);
return () => {
socket.off("gameUpdated", handler);
};
}, [queryClient]);
}
1 change: 1 addition & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ JWT even where a table row doesn't repeat `authenticateToken`.
| PATCH | `/api/games/:id/status` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `sanitizeGameStatus`, `validateRequest` | `{ status: "wanted"\|"owned"\|"completed"\|"downloading" }` via `updateGameStatusSchema` | Updated `Game`; 404 if not found |
| PATCH | `/api/games/:id/hidden` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | `{ hidden: boolean }` via `updateGameHiddenSchema` | Updated `Game`; 404 if not found |
| PATCH | `/api/games/:id/user-rating` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | `{ userRating: number\|null }` (0.5–10, step 0.5) via `updateGameUserRatingSchema` | Updated `Game`; 404 if not found |
| PATCH | `/api/games/:id/installed-version` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | `{ installedVersion: string\|null }` (free text, trimmed, ≤64; blank clears) via `updateGameInstalledVersionSchema` | Updated `Game`; 404 if not found. Auto-search skips update releases whose version is not newer than this |
| POST | `/api/games/refresh-metadata` | JWT + `igdbRateLimiter` | — | `{ success, message, updatedCount, errorCount }` |
| DELETE | `/api/games/:id` | JWT + `sensitiveEndpointLimiter`, `sanitizeGameId`, `validateRequest` | Query: `deleteFiles?` ("true" to also remove the game's files on disk) | `{ success: true, fileDeletion }`; 404 if not found. `fileDeletion` is `null` when `deleteFiles` wasn't set, otherwise `{ deleted: true, path }` or `{ deleted: false, reason: "outside-library-root"\|"delete-failed", path }`. Files are only deleted when `libraryPath` resolves inside the configured library root **or** inside a root folder with `allowDelete: true` (see Root Folders below) |
| GET | `/api/games/:id/downloads` | JWT + `sanitizeGameId`, `validateRequest` (verifies game ownership) | — | `GameDownload[]`; 404 game not found, 403 not owner |
Expand Down
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project will be documented in this file.

## [1.5.1-rc1] - 2026-10-xx

### Added

- **Installed version**: each game records the version you have installed, editable in the game details modal with suggestions from your downloads' release names. It is filled in automatically when a full-game or update download finishes, and update releases that are not newer than it no longer raise "Game Updates Available" (#1142).

## [1.5.0] - 2026-09-xx

### Added
Expand Down
Loading
Loading