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 @@ -129,9 +129,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 @@ -147,7 +147,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}} of {{total}} uninstalled game executables",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

total seems to be the number of games checked, not the number of uninstalled executables, so removed {{removed}} of {{total}} uninstalled game executables can sound like all checked games were uninstalled.

could you please adjust this text to something like removed {{removed}} stale executable paths from {{total}} checked games or simply removed {{removed}} stale 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, total: gamesToCheck.length };

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.

P2 There is no progress indicator during the executable-removal step. After scanInstalledGames completes, isScanning is still true but scanResult is now set, so the spinner (isScanning && !scanResult) is hidden. The user sees scan results but has no indication that the second step (removal) is still running. Consider adding a separate isRemovingExecutables flag and a corresponding loading state in the modal.

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

};

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 @@ -646,6 +646,8 @@ contextBridge.exposeInMainWorld("electron", {
additionalDirectories,
includeDefaultDirectories
),
removeUninstalledGameExecutables: () =>
ipcRenderer.invoke("removeUninstalledGameExecutables"),
getDefaultWinePrefixSelectionPath: () =>
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
createSteamShortcut: (
Expand Down
25 changes: 22 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,15 @@
});
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 }[];
total: number;
} | null>(null);

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

Expand Down Expand Up @@ -319,24 +324,35 @@
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 window.electron.removeUninstalledGameExecutables();

Check warning on line 336 in src/renderer/src/components/header/header.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=hydralauncher_hydra&issues=AZ7GgM0eixFPtcnb-x_5&open=AZ7GgM0eixFPtcnb-x_5&pullRequest=2328
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 +408,8 @@
<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 +496,9 @@
visible={showScanModal}
onClose={() => setShowScanModal(false)}
isScanning={isScanning}
isRemovingExecutables={isRemovingExecutables}
scanResult={scanResult}
removeExecutableResult={removeExeResult}
onStartScan={handleStartScan}
onClearResult={handleClearScanResult}
/>
Expand Down
63 changes: 56 additions & 7 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,18 @@
total: number;
}

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

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 +47,9 @@
visible,
onClose,
isScanning,
isRemovingExecutables,
scanResult,
removeExecutableResult,
onStartScan,
onClearResult,
}: Readonly<ScanGamesModalProps>) {
Expand Down Expand Up @@ -96,10 +105,10 @@
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 +201,15 @@
</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 +242,58 @@
{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,
total: removeExecutableResult.total,
})}
</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
? isRemovingExecutables
? t("scan_games_hide")
: t("scan_games_close")

Check warning on line 282 in src/renderer/src/components/header/scan-games-modal.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=hydralauncher_hydra&issues=AZ7Gn8NkwbzMV9KF1duZ&open=AZ7Gn8NkwbzMV9KF1duZ&pullRequest=2328
: isScanning || isRemovingExecutables
? t("scan_games_hide")
: t("scan_games_cancel")}

Check warning on line 285 in src/renderer/src/components/header/scan-games-modal.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=hydralauncher_hydra&issues=AZ7LLEaY3LFbDcUOk4iL&open=AZ7LLEaY3LFbDcUOk4iL&pullRequest=2328
</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 @@ -538,6 +538,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