diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 58be5cbd46..fd2786bf18 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", @@ -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", diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts index 20f0e7e35f..1cb1016153 100644 --- a/src/main/events/library/index.ts +++ b/src/main/events/library/index.ts @@ -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"; diff --git a/src/main/events/library/remove-uninstalled-game-executables.ts b/src/main/events/library/remove-uninstalled-game-executables.ts new file mode 100644 index 0000000000..102af04c8a --- /dev/null +++ b/src/main/events/library/remove-uninstalled-game-executables.ts @@ -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 })) + ); + + 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 +); diff --git a/src/preload/index.ts b/src/preload/index.ts index 7102ea2cff..74b8eca8bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -691,6 +691,8 @@ contextBridge.exposeInMainWorld("electron", { additionalDirectories, includeDefaultDirectories ), + removeUninstalledGameExecutables: () => + ipcRenderer.invoke("removeUninstalledGameExecutables"), getDefaultWinePrefixSelectionPath: () => ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"), createSteamShortcut: ( diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 6588e0b712..a89bd015ff 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -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"); @@ -319,12 +323,21 @@ 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 @@ -332,11 +345,13 @@ export function Header() { setScanResult(result); } finally { setIsScanning(false); + setIsRemovingExecutables(false); } }; const handleClearScanResult = () => { setScanResult(null); + setRemoveExeResult(null); }; useEffect(() => { @@ -392,7 +407,8 @@ export function Header() { - {!scanResult && ( + {!scanResult && !isRemovingExecutables && !isScanning && ( )} {scanResult && ( - )} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index d87d1aac61..8eb05fb9c6 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -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;