Skip to content
Open
11 changes: 7 additions & 4 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@
"settings": "Settings",
"version_available_install": "Version {{version}} available. Click here to restart and install.",
"version_available_download": "Version {{version}} available. Click here to download.",
"scan_games_tooltip": "Scan PC for installed games",
"scan_games_title": "Scan PC for installed games",
"scan_games_description": "This will scan your disks for known game executables.\nThis may take several minutes.",
"scan_games_tooltip": "Sync Installed Games with Library",
"scan_games_title": "Sync Installed Games with Library",
"scan_games_description": "This will: \n 1. Scan your disks for known game executables \n 2. Remove uninstalled executables from the library.\nThis may take several minutes.",
"scan_games_start": "Start Scan",
"scan_games_cancel": "Cancel",
"scan_games_result": "Found {{found}} of {{total}} games without executable path",
Expand All @@ -151,7 +151,10 @@
"scan_games_add_folder": "Add folder",
"scan_games_remove_folder": "Remove folder",
"scan_games_folders_hint_manual": "Add at least one folder to scan for games.",
"scan_games_detection_warning": "Detection relies on a community-maintained list of known games and may be outdated, so some games might not be found."
"scan_games_detection_warning": "Detection relies on a community-maintained list of known games and may be outdated, so some games might not be found.",
"remove_executables_in_progress": "Checking for uninstalled game executables...",
"remove_executables_result": "Removed {{removed}} stale game executable paths",
"remove_executables_no_results": "No uninstalled game executables found."
},
"bottom_panel": {
"no_downloads_in_progress": "No downloads in progress",
Expand Down
1 change: 1 addition & 0 deletions src/main/events/library/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import "./remove-game-from-library";
import "./remove-game";
import "./reset-game-achievements";
import "./scan-installed-games";
import "./remove-uninstalled-game-executables";
import "./select-game-proton-path";
import "./select-game-wine-prefix";
import "./toggle-automatic-cloud-sync";
Expand Down
51 changes: 51 additions & 0 deletions src/main/events/library/remove-uninstalled-game-executables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import fs from "node:fs";
import { registerEvent } from "../register-event";
import { updateGameExecutablePath } from "@main/helpers/update-executable-path";
import { gamesSublevel } from "@main/level";
import { logger, WindowManager } from "@main/services";

interface RemovedGame {
title: string;
}

const removeUninstalledGameExecutables = async () => {
const games = await gamesSublevel
.iterator()
.all()
.then((results) =>
results
.filter(
([_key, game]) => game.isDeleted === false && game.shop !== "custom"
)
.map(([key, game]) => ({ key, game }))
Comment on lines +13 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Custom-shop games incorrectly included in executable removal

The filter here does not exclude games with shop === "custom", but scanInstalledGames explicitly does (game.isDeleted === false && game.shop !== "custom"). This inconsistency means the removal step will clear executable paths for custom (manually-added) games that the scan never touched, silently breaking those library entries for users who have manually configured them.

);

const removedGames: RemovedGame[] = [];
const gamesToCheck = games.filter((g) => g.game.executablePath);

for (const { key, game } of gamesToCheck) {
const exePath = game.executablePath!;
if (!fs.existsSync(exePath)) {
await gamesSublevel.put(key, {
...updateGameExecutablePath(game, null),
installedSizeInBytes: null,
automaticCloudSync: false,
});

logger.info(
`[RemoveUninstalledGameExecutables] Removed executable for ${game.objectId}: ${exePath}`
);
removedGames.push({ title: game.title });
}
}

if (removedGames.length > 0) {
WindowManager.sendToAppWindows("on-library-batch-complete");
}
return { removedGames: removedGames };
};

registerEvent(
"removeUninstalledGameExecutables",
removeUninstalledGameExecutables
);
2 changes: 2 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,8 @@ contextBridge.exposeInMainWorld("electron", {
additionalDirectories,
includeDefaultDirectories
),
removeUninstalledGameExecutables: () =>
ipcRenderer.invoke("removeUninstalledGameExecutables"),
getDefaultWinePrefixSelectionPath: () =>
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
createSteamShortcut: (
Expand Down
24 changes: 21 additions & 3 deletions src/renderer/src/components/header/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,14 @@ export function Header() {
});
const [showScanModal, setShowScanModal] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [isRemovingExecutables, setIsRemovingExecutables] = useState(false);
const [scanResult, setScanResult] = useState<{
foundGames: { title: string; executablePath: string }[];
total: number;
} | null>(null);
const [removeExeResult, setRemoveExeResult] = useState<{
removedGames: { title: string }[];
} | null>(null);

const { t } = useTranslation("header");

Expand Down Expand Up @@ -319,24 +323,35 @@ export function Header() {
additionalDirectories: string[] = [],
includeDefaultDirectories = true
) => {
if (isScanning) return;
if (isScanning || isRemovingExecutables) return;

setIsScanning(true);
setIsScanning(false);
setIsRemovingExecutables(true);
setScanResult(null);
setRemoveExeResult(null);

try {
const exeResult =
await globalThis.electron.removeUninstalledGameExecutables();
setRemoveExeResult(exeResult);

setIsRemovingExecutables(false);
setIsScanning(true);

const result = await window.electron.scanInstalledGames(
additionalDirectories,
includeDefaultDirectories
);
setScanResult(result);
} finally {
setIsScanning(false);
setIsRemovingExecutables(false);
}
};

const handleClearScanResult = () => {
setScanResult(null);
setRemoveExeResult(null);
};

useEffect(() => {
Expand Down Expand Up @@ -392,7 +407,8 @@ export function Header() {
<button
type="button"
className={cn("header__action-button", {
"header__action-button--scanning": isScanning,
"header__action-button--scanning":
isScanning || isRemovingExecutables,
})}
onClick={() => setShowScanModal(true)}
data-tooltip-id={scanButtonTooltipId}
Expand Down Expand Up @@ -479,7 +495,9 @@ export function Header() {
visible={showScanModal}
onClose={() => setShowScanModal(false)}
isScanning={isScanning}
isRemovingExecutables={isRemovingExecutables}
scanResult={scanResult}
removeExecutableResult={removeExeResult}
onStartScan={handleStartScan}
onClearResult={handleClearScanResult}
/>
Expand Down
73 changes: 63 additions & 10 deletions src/renderer/src/components/header/scan-games-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@ interface ScanResult {
total: number;
}

interface RemoveExecutableResult {
removedGames: { title: string }[];
}

export interface ScanGamesModalProps {
visible: boolean;
onClose: () => void;
isScanning: boolean;
isRemovingExecutables: boolean;
scanResult: ScanResult | null;
removeExecutableResult: RemoveExecutableResult | null;
onStartScan: (
additionalDirectories: string[],
includeDefaultDirectories: boolean
Expand All @@ -40,7 +46,9 @@ export function ScanGamesModal({
visible,
onClose,
isScanning,
isRemovingExecutables,
scanResult,
removeExecutableResult,
onStartScan,
onClearResult,
}: Readonly<ScanGamesModalProps>) {
Expand Down Expand Up @@ -91,15 +99,27 @@ export function ScanGamesModal({
setSelectedFolders((prev) => prev.filter((item) => item !== folder));
};

let closeButtonLabel: string;

if (scanResult) {
closeButtonLabel = isRemovingExecutables
? t("scan_games_hide")
: t("scan_games_close");
} else if (isScanning || isRemovingExecutables) {
closeButtonLabel = t("scan_games_hide");
} else {
closeButtonLabel = t("scan_games_cancel");
}

return (
<Modal
visible={visible}
title={t("scan_games_title")}
onClose={handleClose}
clickOutsideToClose={!isScanning}
clickOutsideToClose={!isScanning && !isRemovingExecutables}
>
<div className="scan-games-modal">
{!scanResult && !isScanning && (
{!scanResult && !isScanning && !isRemovingExecutables && (
<>
{isWindows && (
<div className="scan-games-modal__mode-toggle">
Expand Down Expand Up @@ -192,6 +212,15 @@ export function ScanGamesModal({
</div>
)}

{isRemovingExecutables && !removeExecutableResult && !scanResult && (
<div className="scan-games-modal__scanning">
<SyncIcon size={24} className="scan-games-modal__spinner" />
<p className="scan-games-modal__scanning-text">
{t("remove_executables_in_progress")}
</p>
</div>
)}

{scanResult && (
<div className="scan-games-modal__results">
{scanResult.foundGames.length > 0 ? (
Expand Down Expand Up @@ -224,27 +253,51 @@ export function ScanGamesModal({
{t("scan_games_no_results")}
</p>
)}

{removeExecutableResult &&
(removeExecutableResult.removedGames.length > 0 ? (
<>
<p className="scan-games-modal__result">
{t("remove_executables_result", {
removed: removeExecutableResult.removedGames.length,
})}
</p>

<ul className="scan-games-modal__games-list">
{removeExecutableResult.removedGames.map((game, index) => (
<li
key={`${game.title}-${index}`}
className="scan-games-modal__game-item"
>
<span className="scan-games-modal__game-title">
{game.title}
</span>
</li>
))}
</ul>
</>
) : (
<p className="scan-games-modal__no-results">
{t("remove_executables_no_results")}
</p>
))}
</div>
)}

<div className="scan-games-modal__actions">
<Button theme="outline" onClick={handleClose}>
{scanResult
? t("scan_games_close")
: isScanning
? t("scan_games_hide")
: t("scan_games_cancel")}
{closeButtonLabel}
</Button>
{!scanResult && (
{!scanResult && !isRemovingExecutables && !isScanning && (
<Button
onClick={handleStartScan}
disabled={isScanning || requiresFolderSelection}
disabled={requiresFolderSelection}
>
{t("scan_games_start")}
</Button>
)}
{scanResult && (
<Button onClick={handleScanAgain}>
<Button onClick={handleScanAgain} disabled={isRemovingExecutables}>
{t("scan_games_scan_again")}
</Button>
)}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/declaration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,10 @@ declare global {
foundGames: { title: string; executablePath: string }[];
total: number;
}>;
removeUninstalledGameExecutables: () => Promise<{
removedGames: { title: string }[];
total: number;
}>;
onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer;
Expand Down
Loading