mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 10:22:59 -03:00
Compare commits
15 Commits
v3.8.1
...
feat/LBX-3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049a989e85 | ||
|
|
88b2581797 | ||
|
|
c9801644ac | ||
|
|
98cfe7be98 | ||
|
|
7293afb618 | ||
|
|
194e7918ca | ||
|
|
979958aca6 | ||
|
|
6e92e0f79f | ||
|
|
aef069d4c7 | ||
|
|
1f447cc478 | ||
|
|
5d2dc3616c | ||
|
|
1f9972f74e | ||
|
|
3344f68408 | ||
|
|
65be11cc07 | ||
|
|
96140e614c |
1
.github/workflows/build-renderer.yml
vendored
1
.github/workflows/build-renderer.yml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
run: yarn build
|
||||
env:
|
||||
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
|
||||
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@reduxjs/toolkit": "^2.2.3",
|
||||
"@sentry/react": "^10.33.0",
|
||||
"@tiptap/extension-bold": "^3.6.2",
|
||||
"@tiptap/extension-italic": "^3.6.2",
|
||||
"@tiptap/extension-link": "^3.6.2",
|
||||
|
||||
@@ -108,7 +108,17 @@
|
||||
"search_results": "Search results",
|
||||
"settings": "Settings",
|
||||
"version_available_install": "Version {{version}} available. Click here to restart and install.",
|
||||
"version_available_download": "Version {{version}} available. Click here to download."
|
||||
"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. This may take several minutes.",
|
||||
"scan_games_start": "Start Scan",
|
||||
"scan_games_cancel": "Cancel",
|
||||
"scan_games_result": "Found {{found}} of {{total}} games without executable path",
|
||||
"scan_games_no_results": "We couldn't find any installed games.",
|
||||
"scan_games_in_progress": "Scanning your disks for installed games...",
|
||||
"scan_games_close": "Close",
|
||||
"scan_games_scan_again": "Scan Again"
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "No downloads in progress",
|
||||
|
||||
@@ -25,7 +25,7 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
|
||||
const achievements = await gameAchievementsSublevel.get(key);
|
||||
|
||||
unlockedAchievementCount =
|
||||
achievements?.unlockedAchievements.length ?? 0;
|
||||
achievements?.unlockedAchievements?.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -24,6 +24,7 @@ import "./remove-game-from-favorites";
|
||||
import "./remove-game-from-library";
|
||||
import "./remove-game";
|
||||
import "./reset-game-achievements";
|
||||
import "./scan-installed-games";
|
||||
import "./select-game-wine-prefix";
|
||||
import "./toggle-automatic-cloud-sync";
|
||||
import "./toggle-game-pin";
|
||||
|
||||
129
src/main/events/library/scan-installed-games.ts
Normal file
129
src/main/events/library/scan-installed-games.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel } from "@main/level";
|
||||
import { GameExecutables, logger, WindowManager } from "@main/services";
|
||||
|
||||
const SCAN_DIRECTORIES = [
|
||||
String.raw`C:\Games`,
|
||||
String.raw`D:\Games`,
|
||||
String.raw`C:\Program Files (x86)\Steam\steamapps\common`,
|
||||
String.raw`C:\Program Files\Steam\steamapps\common`,
|
||||
String.raw`C:\Program Files (x86)\DODI-Repacks`,
|
||||
];
|
||||
|
||||
interface FoundGame {
|
||||
title: string;
|
||||
executablePath: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
foundGames: FoundGame[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const scanInstalledGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent
|
||||
): Promise<ScanResult> => {
|
||||
const games = await gamesSublevel
|
||||
.iterator()
|
||||
.all()
|
||||
.then((results) =>
|
||||
results
|
||||
.filter(
|
||||
([_key, game]) => game.isDeleted === false && game.shop !== "custom"
|
||||
)
|
||||
.map(([key, game]) => ({ key, game }))
|
||||
);
|
||||
|
||||
const foundGames: FoundGame[] = [];
|
||||
|
||||
for (const { key, game } of games) {
|
||||
if (game.executablePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const executableNames = GameExecutables.getExecutablesForGame(
|
||||
game.objectId
|
||||
);
|
||||
|
||||
if (!executableNames || executableNames.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedNames = new Set(
|
||||
executableNames.map((name) => name.toLowerCase())
|
||||
);
|
||||
|
||||
let foundPath: string | null = null;
|
||||
|
||||
for (const scanDir of SCAN_DIRECTORIES) {
|
||||
if (!fs.existsSync(scanDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foundPath = await findExecutableInFolder(scanDir, normalizedNames);
|
||||
|
||||
if (foundPath) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundPath) {
|
||||
await gamesSublevel.put(key, {
|
||||
...game,
|
||||
executablePath: foundPath,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[ScanInstalledGames] Found executable for ${game.objectId}: ${foundPath}`
|
||||
);
|
||||
|
||||
foundGames.push({
|
||||
title: game.title,
|
||||
executablePath: foundPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||
|
||||
return {
|
||||
foundGames,
|
||||
total: games.filter((g) => !g.game.executablePath).length,
|
||||
};
|
||||
};
|
||||
|
||||
async function findExecutableInFolder(
|
||||
folderPath: string,
|
||||
executableNames: Set<string>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(folderPath, {
|
||||
withFileTypes: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const fileName = entry.name.toLowerCase();
|
||||
|
||||
if (executableNames.has(fileName)) {
|
||||
const parentPath =
|
||||
"parentPath" in entry ? entry.parentPath : folderPath;
|
||||
|
||||
return path.join(parentPath, entry.name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[ScanInstalledGames] Error reading folder ${folderPath}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
registerEvent("scanInstalledGames", scanInstalledGames);
|
||||
@@ -362,6 +362,11 @@ export class DownloadManager {
|
||||
|
||||
if (download.automaticallyExtract) {
|
||||
this.handleExtraction(download, game);
|
||||
} else {
|
||||
// For downloads without extraction (e.g., torrents with ready-to-play files),
|
||||
// search for executable in the download folder
|
||||
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||
gameFilesManager.searchAndBindExecutable();
|
||||
}
|
||||
|
||||
await this.processNextQueuedDownload();
|
||||
|
||||
13
src/main/services/game-executables.ts
Normal file
13
src/main/services/game-executables.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { gameExecutables } from "./process-watcher";
|
||||
|
||||
export class GameExecutables {
|
||||
static getExecutablesForGame(objectId: string): string[] | null {
|
||||
const executables = gameExecutables[objectId];
|
||||
|
||||
if (!executables || executables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return executables.map((exe) => exe.exe);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { SevenZip, ExtractionProgress } from "./7zip";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { publishExtractionCompleteNotification } from "./notifications";
|
||||
import { logger } from "./logger";
|
||||
import { GameExecutables } from "./game-executables";
|
||||
|
||||
const PROGRESS_THROTTLE_MS = 1000;
|
||||
|
||||
@@ -151,6 +152,100 @@ export class GameFilesManager {
|
||||
if (publishNotification && game) {
|
||||
publishExtractionCompleteNotification(game);
|
||||
}
|
||||
|
||||
await this.searchAndBindExecutable();
|
||||
}
|
||||
|
||||
async searchAndBindExecutable(): Promise<void> {
|
||||
try {
|
||||
const [download, game] = await Promise.all([
|
||||
downloadsSublevel.get(this.gameKey),
|
||||
gamesSublevel.get(this.gameKey),
|
||||
]);
|
||||
|
||||
if (!download || !game || game.executablePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const executableNames = GameExecutables.getExecutablesForGame(
|
||||
this.objectId
|
||||
);
|
||||
|
||||
if (!executableNames || executableNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!download.folderName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameFolderPath = path.join(
|
||||
download.downloadPath,
|
||||
download.folderName
|
||||
);
|
||||
|
||||
if (!fs.existsSync(gameFolderPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const foundExePath = await this.findExecutableInFolder(
|
||||
gameFolderPath,
|
||||
executableNames
|
||||
);
|
||||
|
||||
if (foundExePath) {
|
||||
logger.info(
|
||||
`[GameFilesManager] Auto-detected executable for ${this.objectId}: ${foundExePath}`
|
||||
);
|
||||
|
||||
await gamesSublevel.put(this.gameKey, {
|
||||
...game,
|
||||
executablePath: foundExePath,
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send("on-library-batch-complete");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[GameFilesManager] Error searching for executable: ${this.objectId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findExecutableInFolder(
|
||||
folderPath: string,
|
||||
executableNames: string[]
|
||||
): Promise<string | null> {
|
||||
const normalizedNames = new Set(
|
||||
executableNames.map((name) => name.toLowerCase())
|
||||
);
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(folderPath, {
|
||||
withFileTypes: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const fileName = entry.name.toLowerCase();
|
||||
|
||||
if (normalizedNames.has(fileName)) {
|
||||
const parentPath =
|
||||
"parentPath" in entry
|
||||
? entry.parentPath
|
||||
: (entry as unknown as { path?: string }).path || folderPath;
|
||||
|
||||
return path.join(parentPath, entry.name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail if folder cannot be read
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async extractDownloadedFile() {
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./ludusavi";
|
||||
export * from "./cloud-sync";
|
||||
export * from "./7zip";
|
||||
export * from "./game-files-manager";
|
||||
export * from "./game-executables";
|
||||
export * from "./common-redist-manager";
|
||||
export * from "./aria2";
|
||||
export * from "./ws";
|
||||
|
||||
@@ -69,7 +69,7 @@ const getGameExecutables = async () => {
|
||||
return gameExecutables;
|
||||
};
|
||||
|
||||
const gameExecutables = await getGameExecutables();
|
||||
export const gameExecutables = await getGameExecutables();
|
||||
|
||||
const findGamePathByProcess = async (
|
||||
processMap: Map<string, Set<string>>,
|
||||
|
||||
@@ -138,12 +138,21 @@ export class WindowManager {
|
||||
(details, callback) => {
|
||||
if (
|
||||
details.webContentsId !== this.mainWindow?.webContents.id ||
|
||||
details.url.includes("chatwoot") ||
|
||||
details.url.includes("workwonders")
|
||||
details.url.includes("chatwoot")
|
||||
) {
|
||||
return callback(details);
|
||||
}
|
||||
|
||||
if (details.url.includes("workwonders")) {
|
||||
return callback({
|
||||
...details,
|
||||
requestHeaders: {
|
||||
Origin: "https://workwonders.app",
|
||||
...details.requestHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const userAgent = new UserAgent();
|
||||
|
||||
callback({
|
||||
|
||||
@@ -241,6 +241,7 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("changeGamePlayTime", shop, objectId, playtime),
|
||||
extractGameDownload: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("extractGameDownload", shop, objectId),
|
||||
scanInstalledGames: () => ipcRenderer.invoke("scanInstalledGames"),
|
||||
getDefaultWinePrefixSelectionPath: () =>
|
||||
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
|
||||
createSteamShortcut: (shop: GameShop, objectId: string) =>
|
||||
|
||||
@@ -134,7 +134,10 @@ export function App() {
|
||||
|
||||
await workwondersRef.current.initChangelogWidget();
|
||||
workwondersRef.current.initChangelogWidgetMini();
|
||||
workwondersRef.current.initFeedbackWidget();
|
||||
|
||||
if (token) {
|
||||
workwondersRef.current.initFeedbackWidget();
|
||||
}
|
||||
},
|
||||
[workwondersRef]
|
||||
);
|
||||
|
||||
@@ -65,6 +65,19 @@
|
||||
&:hover {
|
||||
color: #dadbe1;
|
||||
}
|
||||
|
||||
&--scanning {
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(-0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__section {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
SearchIcon,
|
||||
SyncIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
|
||||
import "./header.scss";
|
||||
import { AutoUpdateSubHeader } from "./auto-update-sub-header";
|
||||
import { ScanGamesModal } from "./scan-games-modal";
|
||||
import { setFilters, setLibrarySearchQuery } from "@renderer/features";
|
||||
import cn from "classnames";
|
||||
import { SearchDropdown } from "@renderer/components";
|
||||
@@ -29,6 +36,7 @@ const pathTitle: Record<string, string> = {
|
||||
export function Header() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scanButtonTooltipId = useId();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -61,6 +69,12 @@ export function Header() {
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [showScanModal, setShowScanModal] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<{
|
||||
foundGames: { title: string; executablePath: string }[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
@@ -224,6 +238,25 @@ export function Header() {
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const handleStartScan = async () => {
|
||||
if (isScanning) return;
|
||||
|
||||
setIsScanning(true);
|
||||
setScanResult(null);
|
||||
setShowScanModal(false);
|
||||
|
||||
try {
|
||||
const result = await window.electron.scanInstalledGames();
|
||||
setScanResult(result);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearScanResult = () => {
|
||||
setScanResult(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownVisible) return;
|
||||
|
||||
@@ -265,6 +298,21 @@ export function Header() {
|
||||
</section>
|
||||
|
||||
<section className="header__section">
|
||||
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn("header__action-button", {
|
||||
"header__action-button--scanning": isScanning,
|
||||
})}
|
||||
onClick={() => setShowScanModal(true)}
|
||||
data-tooltip-id={scanButtonTooltipId}
|
||||
data-tooltip-content={t("scan_games_tooltip")}
|
||||
data-tooltip-place="bottom"
|
||||
>
|
||||
<SyncIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={cn("header__search", {
|
||||
@@ -304,6 +352,11 @@ export function Header() {
|
||||
</div>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
{isOnLibraryPage && window.electron.platform === "win32" && (
|
||||
<Tooltip id={scanButtonTooltipId} style={{ zIndex: 1 }} />
|
||||
)}
|
||||
|
||||
<AutoUpdateSubHeader />
|
||||
|
||||
<SearchDropdown
|
||||
@@ -327,6 +380,15 @@ export function Header() {
|
||||
currentQuery={searchValue}
|
||||
searchContainerRef={searchContainerRef}
|
||||
/>
|
||||
|
||||
<ScanGamesModal
|
||||
visible={showScanModal}
|
||||
onClose={() => setShowScanModal(false)}
|
||||
isScanning={isScanning}
|
||||
scanResult={scanResult}
|
||||
onStartScan={handleStartScan}
|
||||
onClearResult={handleClearScanResult}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
107
src/renderer/src/components/header/scan-games-modal.scss
Normal file
@@ -0,0 +1,107 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.scan-games-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
min-width: 400px;
|
||||
|
||||
&__description {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__result {
|
||||
color: globals.$body-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
padding: calc(globals.$spacing-unit * 2) 0;
|
||||
}
|
||||
|
||||
&__scanning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 3) 0;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
color: globals.$muted-color;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
&__scanning-text {
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__games-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
background-color: globals.$dark-background-color;
|
||||
border-radius: 4px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__game-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: globals.$spacing-unit;
|
||||
border-bottom: 1px solid globals.$border-color;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__game-title {
|
||||
color: globals.$body-color;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__game-path {
|
||||
color: globals.$muted-color;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
126
src/renderer/src/components/header/scan-games-modal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SyncIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
|
||||
import "./scan-games-modal.scss";
|
||||
|
||||
interface FoundGame {
|
||||
title: string;
|
||||
executablePath: string;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
foundGames: FoundGame[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ScanGamesModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
isScanning: boolean;
|
||||
scanResult: ScanResult | null;
|
||||
onStartScan: () => void;
|
||||
onClearResult: () => void;
|
||||
}
|
||||
|
||||
export function ScanGamesModal({
|
||||
visible,
|
||||
onClose,
|
||||
isScanning,
|
||||
scanResult,
|
||||
onStartScan,
|
||||
onClearResult,
|
||||
}: ScanGamesModalProps) {
|
||||
const { t } = useTranslation("header");
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleStartScan = () => {
|
||||
onStartScan();
|
||||
};
|
||||
|
||||
const handleScanAgain = () => {
|
||||
onClearResult();
|
||||
onStartScan();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("scan_games_title")}
|
||||
onClose={handleClose}
|
||||
clickOutsideToClose={!isScanning}
|
||||
>
|
||||
<div className="scan-games-modal">
|
||||
{!scanResult && !isScanning && (
|
||||
<p className="scan-games-modal__description">
|
||||
{t("scan_games_description")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isScanning && !scanResult && (
|
||||
<div className="scan-games-modal__scanning">
|
||||
<SyncIcon size={24} className="scan-games-modal__spinner" />
|
||||
<p className="scan-games-modal__scanning-text">
|
||||
{t("scan_games_in_progress")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanResult && (
|
||||
<div className="scan-games-modal__results">
|
||||
{scanResult.foundGames.length > 0 ? (
|
||||
<>
|
||||
<p className="scan-games-modal__result">
|
||||
{t("scan_games_result", {
|
||||
found: scanResult.foundGames.length,
|
||||
total: scanResult.total,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<ul className="scan-games-modal__games-list">
|
||||
{scanResult.foundGames.map((game) => (
|
||||
<li
|
||||
key={game.executablePath}
|
||||
className="scan-games-modal__game-item"
|
||||
>
|
||||
<span className="scan-games-modal__game-title">
|
||||
{game.title}
|
||||
</span>
|
||||
<span className="scan-games-modal__game-path">
|
||||
{game.executablePath}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="scan-games-modal__no-results">
|
||||
{t("scan_games_no_results")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="scan-games-modal__actions">
|
||||
<Button theme="outline" onClick={handleClose}>
|
||||
{scanResult ? t("scan_games_close") : t("scan_games_cancel")}
|
||||
</Button>
|
||||
{!scanResult && (
|
||||
<Button onClick={handleStartScan} disabled={isScanning}>
|
||||
{t("scan_games_start")}
|
||||
</Button>
|
||||
)}
|
||||
{scanResult && (
|
||||
<Button onClick={handleScanAgain}>
|
||||
{t("scan_games_scan_again")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +225,16 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onLibraryBatchComplete(() => {
|
||||
updateGame();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
try {
|
||||
|
||||
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@@ -211,6 +211,10 @@ declare global {
|
||||
minimized: boolean;
|
||||
}) => Promise<void>;
|
||||
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
|
||||
scanInstalledGames: () => Promise<{
|
||||
foundGames: { title: string; executablePath: string }[];
|
||||
total: number;
|
||||
}>;
|
||||
onExtractionComplete: (
|
||||
cb: (shop: GameShop, objectId: string) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
@@ -21,6 +21,7 @@ import resources from "@locales";
|
||||
|
||||
import { logger } from "./logger";
|
||||
import { addCookieInterceptor } from "./cookies";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { levelDBService } from "./services/leveldb.service";
|
||||
import Catalogue from "./pages/catalogue/catalogue";
|
||||
import Home from "./pages/home/home";
|
||||
@@ -36,6 +37,18 @@ import { AchievementNotification } from "./pages/achievements/notification/achie
|
||||
|
||||
console.log = logger.log;
|
||||
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration(),
|
||||
],
|
||||
tracesSampleRate: 0.5,
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 0,
|
||||
release: "hydra-launcher@" + (await window.electron.getVersion()),
|
||||
});
|
||||
|
||||
const isStaging = await window.electron.isStaging();
|
||||
addCookieInterceptor(isStaging);
|
||||
|
||||
|
||||
54
yarn.lock
54
yarn.lock
@@ -2174,6 +2174,60 @@
|
||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz#5b2dd648a960b8fa00d76f2cc4eea2f03daa80f4"
|
||||
integrity sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==
|
||||
|
||||
"@sentry-internal/browser-utils@10.33.0":
|
||||
version "10.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.33.0.tgz#4a5d98352267b63fcc449efe14627c0fc082089e"
|
||||
integrity sha512-nDJFHAfiFifBfJB0OF6DV6BIsIV5uah4lDsV4UBAgPBf+YAHclO10y1gi2U/JMh58c+s4lXi9p+PI1TFXZ0c6w==
|
||||
dependencies:
|
||||
"@sentry/core" "10.33.0"
|
||||
|
||||
"@sentry-internal/feedback@10.33.0":
|
||||
version "10.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.33.0.tgz#5865b4a68d607bb48d8159a100464ae640a638e7"
|
||||
integrity sha512-sN/VLWtEf0BeV6w6wldIpTxUQxNVc9o9tjLRQa8je1ZV2FCgXA124Iff/zsowsz82dLqtg7qp6GA5zYXVq+JMA==
|
||||
dependencies:
|
||||
"@sentry/core" "10.33.0"
|
||||
|
||||
"@sentry-internal/replay-canvas@10.33.0":
|
||||
version "10.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.33.0.tgz#9ea15b320618ad220e5d8f7c804a0d9ca55b04af"
|
||||
integrity sha512-MTmP6uoAVzw4CCPeqCgCLsRSiOfGLxgyMFjGTCW3E7t62MJ9S0H5sLsQ34sHxXUa1gFU9UNAjEvRRpZ0JvWrPw==
|
||||
dependencies:
|
||||
"@sentry-internal/replay" "10.33.0"
|
||||
"@sentry/core" "10.33.0"
|
||||
|
||||
"@sentry-internal/replay@10.33.0":
|
||||
version "10.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.33.0.tgz#8cfe3a353731fcd81e7afb646b6befeb0f9feb0f"
|
||||
integrity sha512-UOU9PYxuXnPop3HoQ3l4Q7SZUXJC3Vmfm0Adgad8U03UcrThWIHYc5CxECSrVzfDFNOT7w9o7HQgRAgWxBPMXg==
|
||||
dependencies:
|
||||
"@sentry-internal/browser-utils" "10.33.0"
|
||||
"@sentry/core" "10.33.0"
|
||||
|
||||
"@sentry/browser@10.33.0":
|
||||
version "10.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.33.0.tgz#33284952a1cdf43cdac15ac144c85e81e7cbaa93"
|
||||
integrity sha512-iWiPjik9zetM84jKfk01UveW1J0+X7w8XmJ8+IrhTyNDBVUWCRJWD8FrksiN1dRSg5mFWgfMRzKMz27hAScRwg==
|
||||
dependencies:
|
||||
"@sentry-internal/browser-utils" "10.33.0"
|
||||
"@sentry-internal/feedback" "10.33.0"
|
||||
"@sentry-internal/replay" "10.33.0"
|
||||
"@sentry-internal/replay-canvas" "10.33.0"
|
||||
"@sentry/core" "10.33.0"
|
||||
|
||||
"@sentry/core@10.33.0":
|
||||
version "10.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.33.0.tgz#ea4964fbec290503b419ccaf1a313924d30ad1c8"
|
||||
integrity sha512-ehH1VSUclIHZKEZVdv+klofsFIh8FFzqA6AAV23RtLepptzA8wqQzUGraEuSN25sYcNmYJ0jti5U0Ys+WZv5Dw==
|
||||
|
||||
"@sentry/react@^10.33.0":
|
||||
version "10.33.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.33.0.tgz#89a3be88d43e49de90943ad2ac86ee1664048097"
|
||||
integrity sha512-iMdC2Iw54ibAccatJ5TjoLlIy3VotFteied7JFvOudgj1/2eBBeWthRobZ5p6/nAOpj4p9vJk0DeLrc012sd2g==
|
||||
dependencies:
|
||||
"@sentry/browser" "10.33.0"
|
||||
"@sentry/core" "10.33.0"
|
||||
|
||||
"@sindresorhus/is@^4.0.0":
|
||||
version "4.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
|
||||
|
||||
Reference in New Issue
Block a user