This commit is contained in:
lilezek 2024-04-30 10:01:52 +02:00
commit 483f8223b6
156 changed files with 2841 additions and 7759 deletions

View file

@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydra</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -1,110 +0,0 @@
import { app, BrowserWindow } from "electron";
import { init } from "@sentry/electron/main";
import i18n from "i18next";
import path from "node:path";
import { resolveDatabaseUpdates, WindowManager } from "@main/services";
import { updateElectronApp } from "update-electron-app";
import { dataSource } from "@main/data-source";
import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require("electron-squirrel-startup")) app.quit();
if (process.platform !== "darwin") {
updateElectronApp();
}
if (process.env.SENTRY_DSN) {
init({
dsn: process.env.SENTRY_DSN,
beforeSend: async (event) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.telemetryEnabled) return event;
return null;
},
});
}
i18n.init({
resources,
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
const PROTOCOL = "hydralauncher";
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", () => {
dataSource.initialize().then(async () => {
await resolveDatabaseUpdates();
await import("./main");
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});
});
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
}
const [, path] = commandLine.pop().split("://");
if (path) WindowManager.redirect(path);
});
app.on("open-url", (_event, url) => {
const [, path] = url.split("://");
WindowManager.redirect(path);
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
WindowManager.mainWindow = null;
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
WindowManager.createMainWindow();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

View file

@ -82,7 +82,7 @@
"repacks_modal_description": "Choose the repack you want to download",
"downloads_path": "Downloads path",
"select_folder_hint": "To change the default folder, access the",
"hydra_settings": "Hydra settings",
"settings": "Hydra settings",
"download_now": "Download now"
},
"activation": {

View file

@ -16,8 +16,8 @@
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Descargando…)",
"filter": "Filtrar biblioteca",
"home": "Hogar",
"follow_us": "Síganos"
"home": "Inicio",
"follow_us": "Síguenos"
},
"header": {
"search": "Buscar",
@ -25,7 +25,7 @@
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes",
"home": "Início"
"home": "Inicio"
},
"bottom_panel": {
"no_downloads_in_progress": "Sin descargas en progreso",
@ -100,7 +100,7 @@
"checking_files": "Verificando archivos…",
"starting_download": "Iniciando descarga…",
"remove_from_list": "Eliminar",
"delete": "Quitar instalador",
"delete": "Eliminar instalador",
"delete_modal_description": "Esto eliminará todos los archivos de instalación de su computadora.",
"delete_modal_title": "¿Está seguro?",
"deleting": "Eliminando instalador…",
@ -112,8 +112,8 @@
"notifications": "Notificaciones",
"enable_download_notifications": "Cuando se completa una descarga",
"enable_repack_list_notifications": "Cuando se añade un repack nuevo",
"telemetry": "Telemetria",
"telemetry_description": "Habilitar estadísticas de uso anónimas"
"telemetry": "Telemetría",
"telemetry_description": "Habilitar recopilación de datos de manera anónima"
},
"notifications": {
"download_complete": "Descarga completada",
@ -132,7 +132,7 @@
"binary_not_found_modal": {
"title": "Programas no instalados",
"description": "Los ejecutables de Wine o Lutris no se encontraron en su sistema",
"instructions": "Comprueba la forma correcta de instalar cualquiera de ellos en tu distro Linux para que el juego pueda ejecutarse con normalidad"
"instructions": "Comprueba como instalar de forma correcta uno de los dos en tu distro de Linux para ejecutar el juego con normalidad"
},
"catalogue": {
"next_page": "Siguiente página",

View file

@ -1,147 +1,147 @@
{
"home": {
"featured": "Featured",
"recently_added": "Nemrég hozzáadott",
"trending": "Népszerű",
"surprise_me": "Lepj meg",
"no_results": "Nem található"
},
"sidebar": {
"catalogue": "Katalógus",
"downloads": "Letöltések",
"settings": "Beállítások",
"my_library": "Könyvtáram",
"downloading_metadata": "{{title}} (Metadata letöltése…)",
"checking_files": "{{title}} ({{percentage}} - Fájlok ellenőrzése…)",
"paused": "{{title}} (Szünet)",
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"follow_us": "Kövess minket",
"home": "Főoldal"
},
"header": {
"search": "Keresés",
"home": "Főoldal",
"catalogue": "Katalógus",
"downloads": "Letöltések",
"search_results": "Keresési eredmények",
"settings": "Beállítások"
},
"bottom_panel": {
"no_downloads_in_progress": "Nincsenek folyamatban lévő letöltések",
"downloading_metadata": "{{title}} metaadatainak letöltése…",
"checking_files": "{{title}} fájlok ellenőrzése… ({{percentage}} kész)",
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Következő olda",
"previous_page": "Előző olda"
},
"game_details": {
"open_download_options": "Letöltési lehetőségek",
"download_options_zero": "Nincs letöltési lehetőség",
"download_options_one": "{{count}} letöltési lehetőség",
"download_options_other": "{{count}} letöltési lehetőség",
"updated_at": "Frissítve: {{updated_at}}",
"install": "Letöltés",
"resume": "Folytatás",
"pause": "Szüneteltetés",
"cancel": "Mégse",
"remove": "Eltávolítás",
"remove_from_list": "Eltávolítás",
"space_left_on_disk": "{{space}} szabad hely a lemezen",
"eta": "Befejezés {{eta}}",
"downloading_metadata": "Metaadatok letöltése…",
"checking_files": "Fájlok ellenőrzése…",
"filter": "Repackek szűrése",
"requirements": "Rendszerkövetelmények",
"minimum": "Minimális",
"recommended": "Ajánlott",
"no_minimum_requirements": "{{title}} nem tartalmaz információt a minimális követelményekről",
"no_recommended_requirements": "{{title}} nem tartalmaz információt az ajánlott követelményekről",
"paused_progress": "{{progress}} (Szünetel)",
"release_date": "Megjelenés: {{date}}",
"publisher": "Kiadta: {{publisher}}",
"copy_link_to_clipboard": "Link másolása",
"copied_link_to_clipboard": "Link másolva",
"hours": "óra",
"minutes": "perc",
"accuracy": "{{accuracy}}% pontosság",
"add_to_library": "Hozzáadás a könyvtárhoz",
"remove_from_library": "Eltávolítás a könyvtárból",
"no_downloads": "Nincs elérhető letöltés",
"play_time": "Játszva: {{amount}}",
"last_time_played": "Utoljára játszva {{period}}",
"not_played_yet": "{{title}} még nem játszottál",
"next_suggestion": "Következő javaslat",
"play": "Játék",
"deleting": "Telepítő törlése…",
"close": "Bezárás",
"playing_now": "Jelenleg játszva",
"change": "Változtatás",
"repacks_modal_description": "Choose the repack you want to download",
"downloads_path": "Letöltések helye",
"select_folder_hint": "Ahhoz, hogy megváltoztasd a helyet, hozzákell férned a",
"hydra_settings": "Hydra beállítások",
"download_now": "Töltsd le most"
},
"activation": {
"title": "Hydra Aktiválása",
"installation_id": "Telepítési ID:",
"enter_activation_code": "Add meg az aktiválási kódodat",
"message": "Ha nem tudod, hol kérdezd meg ezt, akkor nem is kellene, hogy legyen ilyened.",
"activate": "Aktiválás",
"loading": "Betöltés…"
},
"downloads": {
"resume": "Folytatás",
"pause": "Szüneteltetés",
"eta": "Befejezés {{eta}}",
"paused": "Szüneteltetve",
"verifying": "Ellenőrzés…",
"completed_at": "Befejezve {{date}}-kor",
"completed": "Befejezve",
"cancelled": "Megszakítva",
"download_again": "Újra letöltés",
"cancel": "Mégse",
"filter": "Letöltött játékok szűrése",
"remove": "Eltávolítás",
"downloading_metadata": "Metaadatok letöltése…",
"checking_files": "Fájlok ellenőrzése…",
"starting_download": "Letöltés indítása…",
"deleting": "Telepítő törlése…",
"delete": "Telepítő eltávolítása",
"remove_from_list": "Eltávolítás",
"delete_modal_title": "Biztos vagy benne?",
"delete_modal_description": "Ez eltávolít minden telepítési fájlt a számítógépedről",
"install": "Telepítés"
},
"settings": {
"downloads_path": "Letöltések helye",
"change": "Frissítés",
"notifications": "Értesítések",
"enable_download_notifications": "Amikor egy letöltés befejeződik",
"enable_repack_list_notifications": "Amikor egy új repack hozzáadásra kerül",
"telemetry": "Telemetria",
"telemetry_description": "Névtelen felhasználási statisztikák engedélyezése"
},
"notifications": {
"download_complete": "Letöltés befejeződött",
"game_ready_to_install": "{{title}} telepítésre kész",
"repack_list_updated": "Repack lista frissítve",
"repack_count_one": "{{count}} repack hozzáadva",
"repack_count_other": "{{count}} repack hozzáadva"
},
"system_tray": {
"open": "Hydra megnyitása",
"quit": "Kilépés"
},
"game_card": {
"no_downloads": "Nincs elérhető letöltés"
},
"binary_not_found_modal": {
"title": "A programok nincsenek telepítve",
"description": "A Wine vagy a Lutris végrehajtható fájljai nem találhatók a rendszereden",
"instructions": "Ellenőrizd a megfelelő telepítési módot bármelyiküknek a Linux disztribúciódon, hogy a játék normálisan fusson"
}
}
{
"home": {
"featured": "Featured",
"recently_added": "Nemrég hozzáadott",
"trending": "Népszerű",
"surprise_me": "Lepj meg",
"no_results": "Nem található"
},
"sidebar": {
"catalogue": "Katalógus",
"downloads": "Letöltések",
"settings": "Beállítások",
"my_library": "Könyvtáram",
"downloading_metadata": "{{title}} (Metadata letöltése…)",
"checking_files": "{{title}} ({{percentage}} - Fájlok ellenőrzése…)",
"paused": "{{title}} (Szünet)",
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
"filter": "Könyvtár szűrése",
"follow_us": "Kövess minket",
"home": "Főoldal"
},
"header": {
"search": "Keresés",
"home": "Főoldal",
"catalogue": "Katalógus",
"downloads": "Letöltések",
"search_results": "Keresési eredmények",
"settings": "Beállítások"
},
"bottom_panel": {
"no_downloads_in_progress": "Nincsenek folyamatban lévő letöltések",
"downloading_metadata": "{{title}} metaadatainak letöltése…",
"checking_files": "{{title}} fájlok ellenőrzése… ({{percentage}} kész)",
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}"
},
"catalogue": {
"next_page": "Következő olda",
"previous_page": "Előző olda"
},
"game_details": {
"open_download_options": "Letöltési lehetőségek",
"download_options_zero": "Nincs letöltési lehetőség",
"download_options_one": "{{count}} letöltési lehetőség",
"download_options_other": "{{count}} letöltési lehetőség",
"updated_at": "Frissítve: {{updated_at}}",
"install": "Letöltés",
"resume": "Folytatás",
"pause": "Szüneteltetés",
"cancel": "Mégse",
"remove": "Eltávolítás",
"remove_from_list": "Eltávolítás",
"space_left_on_disk": "{{space}} szabad hely a lemezen",
"eta": "Befejezés {{eta}}",
"downloading_metadata": "Metaadatok letöltése…",
"checking_files": "Fájlok ellenőrzése…",
"filter": "Repackek szűrése",
"requirements": "Rendszerkövetelmények",
"minimum": "Minimális",
"recommended": "Ajánlott",
"no_minimum_requirements": "{{title}} nem tartalmaz információt a minimális követelményekről",
"no_recommended_requirements": "{{title}} nem tartalmaz információt az ajánlott követelményekről",
"paused_progress": "{{progress}} (Szünetel)",
"release_date": "Megjelenés: {{date}}",
"publisher": "Kiadta: {{publisher}}",
"copy_link_to_clipboard": "Link másolása",
"copied_link_to_clipboard": "Link másolva",
"hours": "óra",
"minutes": "perc",
"accuracy": "{{accuracy}}% pontosság",
"add_to_library": "Hozzáadás a könyvtárhoz",
"remove_from_library": "Eltávolítás a könyvtárból",
"no_downloads": "Nincs elérhető letöltés",
"play_time": "Játszva: {{amount}}",
"last_time_played": "Utoljára játszva {{period}}",
"not_played_yet": "{{title}} még nem játszottál",
"next_suggestion": "Következő javaslat",
"play": "Játék",
"deleting": "Telepítő törlése…",
"close": "Bezárás",
"playing_now": "Jelenleg játszva",
"change": "Változtatás",
"repacks_modal_description": "Choose the repack you want to download",
"downloads_path": "Letöltések helye",
"select_folder_hint": "Ahhoz, hogy megváltoztasd a helyet, hozzákell férned a",
"hydra_settings": "Hydra beállítások",
"download_now": "Töltsd le most"
},
"activation": {
"title": "Hydra Aktiválása",
"installation_id": "Telepítési ID:",
"enter_activation_code": "Add meg az aktiválási kódodat",
"message": "Ha nem tudod, hol kérdezd meg ezt, akkor nem is kellene, hogy legyen ilyened.",
"activate": "Aktiválás",
"loading": "Betöltés…"
},
"downloads": {
"resume": "Folytatás",
"pause": "Szüneteltetés",
"eta": "Befejezés {{eta}}",
"paused": "Szüneteltetve",
"verifying": "Ellenőrzés…",
"completed_at": "Befejezve {{date}}-kor",
"completed": "Befejezve",
"cancelled": "Megszakítva",
"download_again": "Újra letöltés",
"cancel": "Mégse",
"filter": "Letöltött játékok szűrése",
"remove": "Eltávolítás",
"downloading_metadata": "Metaadatok letöltése…",
"checking_files": "Fájlok ellenőrzése…",
"starting_download": "Letöltés indítása…",
"deleting": "Telepítő törlése…",
"delete": "Telepítő eltávolítása",
"remove_from_list": "Eltávolítás",
"delete_modal_title": "Biztos vagy benne?",
"delete_modal_description": "Ez eltávolít minden telepítési fájlt a számítógépedről",
"install": "Telepítés"
},
"settings": {
"downloads_path": "Letöltések helye",
"change": "Frissítés",
"notifications": "Értesítések",
"enable_download_notifications": "Amikor egy letöltés befejeződik",
"enable_repack_list_notifications": "Amikor egy új repack hozzáadásra kerül",
"telemetry": "Telemetria",
"telemetry_description": "Névtelen felhasználási statisztikák engedélyezése"
},
"notifications": {
"download_complete": "Letöltés befejeződött",
"game_ready_to_install": "{{title}} telepítésre kész",
"repack_list_updated": "Repack lista frissítve",
"repack_count_one": "{{count}} repack hozzáadva",
"repack_count_other": "{{count}} repack hozzáadva"
},
"system_tray": {
"open": "Hydra megnyitása",
"quit": "Kilépés"
},
"game_card": {
"no_downloads": "Nincs elérhető letöltés"
},
"binary_not_found_modal": {
"title": "A programok nincsenek telepítve",
"description": "A Wine vagy a Lutris végrehajtható fájljai nem találhatók a rendszereden",
"instructions": "Ellenőrizd a megfelelő telepítési módot bármelyiküknek a Linux disztribúciódon, hogy a játék normálisan fusson"
}
}

View file

@ -3,7 +3,7 @@
"featured": "Destaque",
"recently_added": "Novidades",
"trending": "Populares",
"surprise_me": "Me surpreenda",
"surprise_me": "Surpreenda-me",
"no_results": "Nenhum resultado encontrado"
},
"sidebar": {
@ -78,7 +78,7 @@
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"downloads_path": "Diretório do download",
"select_folder_hint": "Para trocar a pasta padrão, acesse as ",
"hydra_settings": "Configurações do Hydra",
"settings": "Configurações do Hydra",
"download_now": "Baixe agora"
},
"activation": {

View file

@ -15,7 +15,7 @@ import { databasePath } from "./constants";
export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
new DataSource({
type: "sqlite",
type: "better-sqlite3",
database: databasePath,
entities: [
Game,

View file

@ -44,7 +44,7 @@ export class Game {
shop: GameShop;
@Column("text", { nullable: true })
status: GameStatus | "";
status: GameStatus | null;
/**
* Progress is a float between 0 and 1

View file

@ -9,7 +9,7 @@ const steamGames = stateManager.getValue("steamGames");
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take?: number,
take = 12,
cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
const results: CatalogueEntry[] = [];

View file

@ -1,27 +1,40 @@
import shuffle from "lodash/shuffle";
import { shuffle } from "lodash-es";
import { getRandomSteam250List } from "@main/services";
import { Steam250Game, getSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { formatName } from "@main/helpers";
const state = { games: Array<Steam250Game>(), index: 0 };
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
return getRandomSteam250List().then(async (games) => {
const shuffledList = shuffle(games);
if (state.games.length == 0) {
const steam250List = await getSteam250List();
for (const game of shuffledList) {
const repacks = searchRepacks(formatName(game.title));
const filteredSteam250List = steam250List.filter((game) => {
const repacks = searchRepacks(game.title);
const catalogue = searchGames({ query: game.title });
if (repacks.length) {
const results = await searchGames({ query: game.title });
return repacks.length && catalogue.length;
});
if (results.length) {
return results[0].objectID;
}
}
}
});
state.games = shuffle(filteredSteam250List);
}
if (state.games.length == 0) {
return "";
}
const resultObjectId = state.games[state.index].objectID;
state.index += 1;
if (state.index == state.games.length) {
state.index = 0;
state.games = shuffle(state.games);
}
return resultObjectId;
};
registerEvent(getRandomGame, {

View file

@ -1,11 +1,15 @@
import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
import { CatalogueEntry } from "@types";
registerEvent(
(_event: Electron.IpcMainInvokeEvent, query: string) =>
searchGames({ query, take: 12 }),
{
name: "searchGames",
memoize: true,
}
);
const searchGamesEvent = async (
_event: Electron.IpcMainInvokeEvent,
query: string
): Promise<CatalogueEntry[]> => {
return searchGames({ query, take: 12 });
};
registerEvent(searchGamesEvent, {
name: "searchGames",
memoize: true,
});

View file

@ -1,5 +1,5 @@
import flexSearch from "flexsearch";
import orderBy from "lodash/orderBy";
import { orderBy } from "lodash-es";
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
@ -42,11 +42,11 @@ export interface SearchGamesArgs {
skip?: number;
}
export const searchGames = async ({
export const searchGames = ({
query,
take,
skip,
}: SearchGamesArgs): Promise<CatalogueEntry[]> => {
}: SearchGamesArgs): CatalogueEntry[] => {
const results = steamGamesIndex
.search(formatName(query || ""), { limit: take, offset: skip })
.map((index) => {
@ -61,11 +61,9 @@ export const searchGames = async ({
};
});
return Promise.all(results).then((resultsWithRepacks) =>
orderBy(
resultsWithRepacks,
[({ repacks }) => repacks.length, "repacks"],
["desc"]
)
return orderBy(
results,
[({ repacks }) => repacks.length, "repacks"],
["desc"]
);
};

View file

@ -10,12 +10,13 @@ import "./catalogue/search-games";
import "./hardware/get-disk-free-space";
import "./library/add-game-to-library";
import "./library/close-game";
import "./torrenting/delete-game-folder";
import "./library/delete-game-folder";
import "./library/get-game-by-object-id";
import "./library/get-library";
import "./library/get-repackers-friendly-names";
import "./library/open-game";
import "./library/open-game-installer";
import "./library/remove-game";
import "./library/remove-game-from-library";
import "./misc/get-or-cache-image";
import "./misc/open-external";
@ -24,7 +25,6 @@ import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
import "./torrenting/start-game-download";
import "./torrenting/remove-game-from-download";
import "./user-preferences/get-user-preferences";
import "./user-preferences/update-user-preferences";

View file

@ -1,9 +1,9 @@
import path from "node:path";
import { gameRepository } from "@main/repository";
import { getProcesses } from "@main/helpers";
import { registerEvent } from "../register-event";
import { getProcesses } from "@main/helpers";
const closeGame = async (
_event: Electron.IpcMainInvokeEvent,
@ -12,13 +12,17 @@ const closeGame = async (
const processes = await getProcesses();
const game = await gameRepository.findOne({ where: { id: gameId } });
const gameProcess = processes.find((runningProcess) => {
const basename = path.win32.basename(game.executablePath);
const basenameWithoutExtension = path.win32.basename(
game.executablePath,
path.extname(game.executablePath)
);
if (!game) return false;
const executablePath = game.executablePath!;
const basename = path.win32.basename(executablePath);
const basenameWithoutExtension = path.win32.basename(
executablePath,
path.extname(executablePath)
);
const gameProcess = processes.find((runningProcess) => {
if (process.platform === "win32") {
return runningProcess.name === basename;
}

View file

@ -2,10 +2,10 @@ import { gameRepository } from "@main/repository";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import sortBy from "lodash/sortBy";
import { GameStatus } from "@globals";
import { sortBy } from "lodash-es";
const getLibrary = async (_event: Electron.IpcMainInvokeEvent) =>
const getLibrary = async () =>
gameRepository
.find({
where: {

View file

@ -1,7 +1,7 @@
import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager";
const getRepackersFriendlyNames = async (_event: Electron.IpcMainInvokeEvent) =>
const getRepackersFriendlyNames = async () =>
stateManager.getValue("repackersFriendlyNames").reduce((prev, next) => {
return { ...prev, [next.name]: next.friendlyName };
}, {});

View file

@ -1,25 +1,16 @@
import { GameStatus } from "@globals";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { GameStatus } from "@globals";
const removeGameFromDownload = async (
const removeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
await gameRepository.update(
{
id: gameId,
status: GameStatus.Cancelled,
},
});
if (!game) return;
gameRepository.update(
{
id: game.id,
},
{
status: null,
downloadPath: null,
@ -29,6 +20,6 @@ const removeGameFromDownload = async (
);
};
registerEvent(removeGameFromDownload, {
name: "removeGameFromDownload",
registerEvent(removeGame, {
name: "removeGame",
});

View file

@ -25,7 +25,7 @@ const pauseGameDownload = async (
.then((result) => {
if (result.affected) {
Downloader.pauseDownload();
WindowManager.mainWindow.setProgressBar(-1);
WindowManager.mainWindow?.setProgressBar(-1);
}
});
};

View file

@ -1,7 +1,7 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getUserPreferences = async (_event: Electron.IpcMainInvokeEvent) =>
const getUserPreferences = async () =>
userPreferencesRepository.findOne({
where: { id: 1 },
});

View file

@ -1,52 +1,68 @@
import { stateManager } from "./state-manager";
import { repackers } from "./constants";
import {
getNewGOGGames,
getNewRepacksFromCPG,
getNewRepacksFromUser,
getNewRepacksFromXatab,
// getNewRepacksFromOnlineFix,
readPipe,
startProcessWatcher,
writePipe,
} from "./services";
import {
gameRepository,
repackRepository,
repackerFriendlyNameRepository,
steamGameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentClient } from "./services/donwloaders/torrent-client";
import { Repack } from "./entity";
import { Notification } from "electron";
import { t } from "i18next";
import { In } from "typeorm";
import { Downloader } from "./services/donwloaders/downloader";
import { GameStatus } from "@globals";
import { app, BrowserWindow } from "electron";
import { init } from "@sentry/electron/main";
import i18n from "i18next";
import path from "node:path";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { resolveDatabaseUpdates, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
startProcessWatcher();
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
if (import.meta.env.MAIN_VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.MAIN_VITE_SENTRY_DSN,
beforeSend: async (event) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
const game = await gameRepository.findOne({
where: {
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
if (userPreferences?.telemetryEnabled) return event;
return null;
},
relations: { repack: true },
});
}
if (game) {
Downloader.downloadGame(game, game.repack);
i18n.init({
resources,
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
const PROTOCOL = "hydralauncher";
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
readPipe.socket.on("data", (data) => {
TorrentClient.onSocketData(data);
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
electronApp.setAppUserModelId("site.hydralauncher.hydra");
dataSource.initialize().then(async () => {
await resolveDatabaseUpdates();
await import("./main");
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});
});
@ -64,63 +80,49 @@ const checkForNewRepacks = async () => {
where: { id: 1 },
});
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
getNewRepacksFromXatab(
existingRepacks.filter((repack) => repack.repacker === "Xatab")
),
getNewRepacksFromCPG(
existingRepacks.filter((repack) => repack.repacker === "CPG")
),
// getNewRepacksFromOnlineFix(
// existingRepacks.filter((repack) => repack.repacker === "onlinefix")
// ),
track1337xUsers(existingRepacks),
]).then(() => {
repackRepository.count().then((count) => {
const total = count - stateManager.getValue("repacks").length;
if (total > 0 && userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
lng: userPreferences?.language || "en",
}),
body: t("repack_count", {
ns: "notifications",
lng: userPreferences?.language || "en",
count: total,
}),
}).show();
}
});
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});
};
});
const loadState = async () => {
const [friendlyNames, repacks, steamGames] = await Promise.all([
repackerFriendlyNameRepository.find(),
repackRepository.find({
order: {
createdAt: "desc",
},
}),
steamGameRepository.find({
order: {
name: "asc",
},
}),
]);
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
stateManager.setValue("repackersFriendlyNames", friendlyNames);
stateManager.setValue("repacks", repacks);
stateManager.setValue("steamGames", steamGames);
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
import("./events");
};
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
}
loadState().then(() => checkForNewRepacks());
const [, path] = commandLine.pop().split("://");
if (path) WindowManager.redirect(path);
});
app.on("open-url", (_event, url) => {
const [, path] = url.split("://");
WindowManager.redirect(path);
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
WindowManager.mainWindow = null;
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
WindowManager.createMainWindow();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

129
src/main/main.ts Normal file
View file

@ -0,0 +1,129 @@
import { stateManager } from "./state-manager";
import { GameStatus, repackers } from "./constants";
import {
getNewGOGGames,
getNewRepacksFromCPG,
getNewRepacksFromUser,
getNewRepacksFromXatab,
// getNewRepacksFromOnlineFix,
readPipe,
startProcessWatcher,
writePipe,
} from "./services";
import {
gameRepository,
repackRepository,
repackerFriendlyNameRepository,
steamGameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentClient } from "./services/torrent-client";
import { Repack } from "./entity";
import { Notification } from "electron";
import { t } from "i18next";
import { In } from "typeorm";
startProcessWatcher();
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
const game = await gameRepository.findOne({
where: {
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
relations: { repack: true },
});
if (game) {
writePipe.write({
action: "start",
game_id: game.id,
magnet: game.repack.magnet,
save_path: game.downloadPath,
});
}
readPipe.socket?.on("data", (data) => {
TorrentClient.onSocketData(data);
});
});
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackers) {
await getNewRepacksFromUser(
repacker,
existingRepacks.filter((repack) => repack.repacker === repacker)
);
}
};
const checkForNewRepacks = async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
getNewRepacksFromXatab(
existingRepacks.filter((repack) => repack.repacker === "Xatab")
),
getNewRepacksFromCPG(
existingRepacks.filter((repack) => repack.repacker === "CPG")
),
// getNewRepacksFromOnlineFix(
// existingRepacks.filter((repack) => repack.repacker === "onlinefix")
// ),
track1337xUsers(existingRepacks),
]).then(() => {
repackRepository.count().then((count) => {
const total = count - stateManager.getValue("repacks").length;
if (total > 0 && userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
lng: userPreferences?.language || "en",
}),
body: t("repack_count", {
ns: "notifications",
lng: userPreferences?.language || "en",
count: total,
}),
}).show();
}
});
});
};
const loadState = async () => {
const [friendlyNames, repacks, steamGames] = await Promise.all([
repackerFriendlyNameRepository.find(),
repackRepository.find({
order: {
createdAt: "desc",
},
}),
steamGameRepository.find({
order: {
name: "asc",
},
}),
]);
stateManager.setValue("repackersFriendlyNames", friendlyNames);
stateManager.setValue("repacks", repacks);
stateManager.setValue("steamGames", steamGames);
import("./events");
};
loadState().then(() => checkForNewRepacks());

View file

@ -46,10 +46,9 @@ export class TorrentClient {
const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
if (app.isPackaged) {
const binaryName = binaryNameByPlatform[process.platform];
const binaryName = binaryNameByPlatform[process.platform]!;
const binaryPath = path.join(
process.resourcesPath,
"dist",
"hydra-download-manager",
binaryName
);

View file

@ -28,10 +28,11 @@ export const startProcessWatcher = async () => {
const processes = await getProcesses();
for (const game of games) {
const basename = path.win32.basename(game.executablePath);
const executablePath = game.executablePath!;
const basename = path.win32.basename(executablePath);
const basenameWithoutExtension = path.win32.basename(
game.executablePath,
path.extname(game.executablePath)
executablePath,
path.extname(executablePath)
);
const gameProcess = processes.find((runningProcess) => {
@ -46,7 +47,7 @@ export const startProcessWatcher = async () => {
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {
const zero = gamesPlaytime.get(game.id);
const zero = gamesPlaytime.get(game.id) ?? 0;
const delta = performance.now() - zero;
if (WindowManager.mainWindow) {

View file

@ -4,7 +4,6 @@ import { formatUploadDate } from "@main/helpers";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
export const request1337x = async (path: string) =>
requestWebPage(`https://1337xx.to${path}`);
@ -68,7 +67,7 @@ export const extractTorrentsFromDocument = async (
user: string,
document: Document,
existingRepacks: Repack[] = []
): Promise<GameRepackInput[]> => {
) => {
const $trs = Array.from(document.querySelectorAll("tbody tr"));
return Promise.all(
@ -108,7 +107,7 @@ export const getNewRepacksFromUser = async (
user: string,
existingRepacks: Repack[],
page = 1
): Promise<Repack[]> => {
) => {
const response = await request1337x(`/user/${user}/${page}`);
const { window } = new JSDOM(response);

View file

@ -3,7 +3,6 @@ import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
import { logger } from "../logger";
export const getNewRepacksFromCPG = async (
@ -14,22 +13,22 @@ export const getNewRepacksFromCPG = async (
const { window } = new JSDOM(data);
const repacks: GameRepackInput[] = [];
const repacks = [];
try {
Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
const $title = $post.querySelector(".entry-title");
const uploadDate = $post.querySelector("time").getAttribute("datetime");
const uploadDate = $post.querySelector("time")?.getAttribute("datetime");
const $downloadInfo = Array.from(
$post.querySelectorAll(".wp-block-heading")
).find(($heading) => $heading.textContent.startsWith("Download"));
).find(($heading) => $heading.textContent?.startsWith("Download"));
/* Side note: CPG often misspells "Magnet" as "Magent" */
const $magnet = Array.from($post.querySelectorAll("a")).find(
($a) =>
$a.textContent.startsWith("Magnet") ||
$a.textContent.startsWith("Magent")
$a.textContent?.startsWith("Magnet") ||
$a.textContent?.startsWith("Magent")
);
const fileSize = $downloadInfo.textContent

View file

@ -1,7 +1,8 @@
import { JSDOM, VirtualConsole } from "jsdom";
import { GameRepackInput, requestWebPage, savePage } from "./helpers";
import { requestWebPage, savePage } from "./helpers";
import { Repack } from "@main/entity";
import { logger } from "../logger";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
const virtualConsole = new VirtualConsole();
@ -36,43 +37,35 @@ const getGOGGame = async (url: string) => {
};
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
try {
const data = await requestWebPage(
"https://freegogpcgames.com/a-z-games-list/"
);
const data = await requestWebPage(
"https://freegogpcgames.com/a-z-games-list/"
);
const { window } = new JSDOM(data, { virtualConsole });
const { window } = new JSDOM(data, { virtualConsole });
const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
for (const $ul of $uls) {
const repacks: GameRepackInput[] = [];
const $lis = Array.from($ul.querySelectorAll("li"));
for (const $ul of $uls) {
const repacks: QueryDeepPartialEntity<Repack>[] = [];
const $lis = Array.from($ul.querySelectorAll("li"));
for (const $li of $lis) {
const $a = $li.querySelector("a");
const href = $a.href;
for (const $li of $lis) {
const $a = $li.querySelector("a");
const href = $a.href;
const title = $a.textContent.trim();
const title = $a.textContent.trim();
const gameExists = existingRepacks.some(
(existingRepack) => existingRepack.title === title
);
const gameExists = existingRepacks.some(
(existingRepack) => existingRepack.title === title
);
if (!gameExists) {
try {
const game = await getGOGGame(href);
if (!gameExists) {
const game = await getGOGGame(href);
repacks.push({ ...game, title });
} catch (err) {
logger.error(err.message, { method: "getGOGGame", url: href });
}
}
repacks.push({ ...game, title });
}
if (repacks.length) await savePage(repacks);
}
} catch (err) {
logger.error(err.message, { method: "getNewGOGGames" });
if (repacks.length) await savePage(repacks);
}
};

View file

@ -1,13 +1,9 @@
import type { Repack } from "@main/entity";
import { repackRepository } from "@main/repository";
import type { GameRepack } from "@types";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
export type GameRepackInput = Omit<
GameRepack,
"id" | "repackerFriendlyName" | "createdAt" | "updatedAt"
>;
export const savePage = async (repacks: GameRepackInput[]) =>
export const savePage = async (repacks: QueryDeepPartialEntity<Repack>[]) =>
Promise.all(
repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
);

View file

@ -1,6 +1,5 @@
import { Repack } from "@main/entity";
import { savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
import { logger } from "../logger";
import parseTorrent, {
toMagnetURI,
@ -21,7 +20,8 @@ export const getNewRepacksFromOnlineFix = async (
cookieJar = new CookieJar()
): Promise<void> => {
const hasCredentials =
process.env.ONLINEFIX_USERNAME && process.env.ONLINEFIX_PASSWORD;
import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME &&
import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD;
if (!hasCredentials) return;
const http = gotScraping.extend({
@ -58,8 +58,8 @@ export const getNewRepacksFromOnlineFix = async (
if (!preLogin.field || !preLogin.value) return;
const params = new URLSearchParams({
login_name: process.env.ONLINEFIX_USERNAME,
login_password: process.env.ONLINEFIX_PASSWORD,
login_name: import.meta.env.MAIN_VITE_ONLINEFIX_USERNAME,
login_password: import.meta.env.MAIN_VITE_ONLINEFIX_PASSWORD,
login: "submit",
[preLogin.field]: preLogin.value,
});
@ -84,10 +84,10 @@ export const getNewRepacksFromOnlineFix = async (
});
const document = new JSDOM(home.body).window.document;
const repacks: GameRepackInput[] = [];
const repacks = [];
const articles = Array.from(document.querySelectorAll(".news"));
const totalPages = Number(
document.querySelector("nav > a:nth-child(13)").textContent
document.querySelector("nav > a:nth-child(13)")?.textContent
);
try {
@ -185,8 +185,10 @@ export const getNewRepacksFromOnlineFix = async (
});
})
);
} catch (err) {
logger.error(err.message, { method: "getNewRepacksFromOnlineFix" });
} catch (err: unknown) {
logger.error((err as Error).message, {
method: "getNewRepacksFromOnlineFix",
});
}
const newRepacks = repacks.filter(

View file

@ -1,16 +1,14 @@
import { JSDOM } from "jsdom";
import parseTorrent, { toMagnetURI } from "parse-torrent";
import { Repack } from "@main/entity";
import { logger } from "../logger";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
const getTorrentBuffer = (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
import createWorker from "@main/workers/torrent-parser.worker?nodeWorker";
import { toMagnetURI } from "parse-torrent";
import type { Instance } from "parse-torrent";
const worker = createWorker({});
const formatXatabDate = (str: string) => {
const date = new Date();
@ -28,28 +26,36 @@ const formatXatabDate = (str: string) => {
const formatXatabDownloadSize = (str: string) =>
str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB");
const getXatabRepack = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data);
const getXatabRepack = (url: string) => {
return new Promise((resolve) => {
(async () => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data);
const { document } = window;
const $uploadDate = window.document.querySelector(".entry__date");
const $size = window.document.querySelector(".entry__info-size");
const $uploadDate = document.querySelector(".entry__date");
const $size = document.querySelector(".entry__info-size");
const $downloadButton = window.document.querySelector(
".download-torrent"
) as HTMLAnchorElement;
const $downloadButton = document.querySelector(
".download-torrent"
) as HTMLAnchorElement;
if (!$downloadButton) throw new Error("Download button not found");
if (!$downloadButton) throw new Error("Download button not found");
const torrentBuffer = await getTorrentBuffer($downloadButton.href);
const onMessage = (torrent: Instance) => {
resolve({
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
magnet: toMagnetURI(torrent),
uploadDate: formatXatabDate($uploadDate.textContent),
});
return {
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
magnet: toMagnetURI({
infoHash: parseTorrent(torrentBuffer).infoHash,
}),
uploadDate: formatXatabDate($uploadDate.textContent),
};
worker.removeListener("message", onMessage);
};
worker.on("message", onMessage);
worker.postMessage($downloadButton.href);
})();
});
};
export const getNewRepacksFromXatab = async (
@ -60,7 +66,7 @@ export const getNewRepacksFromXatab = async (
const { window } = new JSDOM(data);
const repacks: GameRepackInput[] = [];
const repacks = [];
for (const $a of Array.from(
window.document.querySelectorAll(".entry__title a")
@ -74,14 +80,15 @@ export const getNewRepacksFromXatab = async (
...repack,
page,
});
} catch (err) {
logger.error(err.message, { method: "getNewRepacksFromXatab" });
} catch (err: unknown) {
logger.error((err as Error).message, {
method: "getNewRepacksFromXatab",
});
}
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)

View file

@ -1,24 +1,31 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import shuffle from "lodash/shuffle";
export interface Steam250Game {
title: string;
objectID: string;
}
export const requestSteam250 = async (path: string) => {
return axios.get(`https://steam250.com${path}`).then((response) => {
const { window } = new JSDOM(response.data);
const { document } = window;
return axios
.get(`https://steam250.com${path}`)
.then((response) => {
const { window } = new JSDOM(response.data);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title: HTMLAnchorElement) => {
const steamGameUrl = $title.href;
if (!steamGameUrl) return null;
return Array.from(document.querySelectorAll(".appline .title a"))
.map(($title) => {
const steamGameUrl = ($title as HTMLAnchorElement).href;
if (!steamGameUrl) return null;
return {
title: $title.textContent,
objectID: steamGameUrl.split("/").pop(),
};
}
);
});
return {
title: $title.textContent,
objectID: steamGameUrl.split("/").pop(),
} as Steam250Game;
})
.filter((game) => game != null);
})
.catch((_) => [] as Steam250Game[]);
};
const steam250Paths = [
@ -28,7 +35,15 @@ const steam250Paths = [
"/most_played",
];
export const getRandomSteam250List = async () => {
const [path] = shuffle(steam250Paths);
return requestSteam250(path);
export const getSteam250List = async () => {
const gamesList = (
await Promise.all(steam250Paths.map((path) => requestSteam250(path)))
).flat();
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
map.set(item.objectID, item);
return map;
}, new Map());
return [...gamesMap.values()];
};

View file

@ -32,7 +32,7 @@ export const getSteamGridData = async (
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.STEAMGRIDDB_API_KEY}`,
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
},
}
);

View file

@ -1,7 +1,7 @@
import path from "node:path";
import { app } from "electron";
import chunk from "lodash/chunk";
import { chunk } from "lodash-es";
import { createDataSource, dataSource } from "@main/data-source";
import { Repack, RepackerFriendlyName, SteamGame } from "@main/entity";
@ -109,7 +109,7 @@ export const resolveDatabaseUpdates = async () => {
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "resources", "hydra.db"),
: path.join(__dirname, "..", "..", "hydra.db"),
});
return updateDataSource.initialize().then(async () => {

View file

@ -1,16 +1,30 @@
import { BrowserWindow, Menu, Tray, app } from "electron";
import { is } from "@electron-toolkit/utils";
import { t } from "i18next";
import path from "node:path";
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
import icon from "@resources/icon.png?asset";
import trayIcon from "@resources/tray-icon.png?asset";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
private static loadURL(hash = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.mainWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/${hash}`
);
} else {
this.mainWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash,
}
);
}
}
public static createMainWindow() {
// Create the browser window.
this.mainWindow = new BrowserWindow({
@ -19,7 +33,7 @@ export class WindowManager {
minWidth: 1024,
minHeight: 540,
titleBarStyle: "hidden",
icon: path.join(__dirname, "..", "..", "images", "icon.png"),
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
@ -27,40 +41,29 @@ export class WindowManager {
height: 34,
},
webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
});
this.loadURL();
this.mainWindow.removeMenu();
this.mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
this.mainWindow.webContents.on("did-finish-load", () => {
if (!app.isPackaged) {
// Open the DevTools.
this.mainWindow.webContents.openDevTools();
}
});
this.mainWindow.on("close", () => {
WindowManager.mainWindow.setProgressBar(-1);
WindowManager.mainWindow?.setProgressBar(-1);
});
}
public static redirect(path: string) {
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.mainWindow.loadURL(`${MAIN_WINDOW_WEBPACK_ENTRY}#${path}`);
this.loadURL(hash);
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
this.mainWindow.focus();
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
this.mainWindow?.focus();
}
public static createSystemTray(language: string) {
const tray = new Tray(
app.isPackaged
? path.join(process.resourcesPath, "icon_tray.png")
: path.join(__dirname, "..", "..", "resources", "icon_tray.png")
);
const tray = new Tray(trayIcon);
const contextMenu = Menu.buildFromTemplate([
{
@ -93,10 +96,10 @@ export class WindowManager {
if (process.platform === "win32") {
tray.addListener("click", () => {
if (this.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
if (WindowManager.mainWindow?.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
WindowManager.mainWindow?.focus();
return;
}

12
src/main/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_ONLINEFIX_USERNAME: string;
readonly MAIN_VITE_ONLINEFIX_PASSWORD: string;
readonly MAIN_VITE_SENTRY_DSN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -0,0 +1,17 @@
import { parentPort } from "worker_threads";
import parseTorrent from "parse-torrent";
const port = parentPort;
if (!port) throw new Error("IllegalState");
const getTorrentBuffer = (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
port.on("message", async (url: string) => {
const buffer = await getTorrentBuffer(url);
const torrent = await parseTorrent(buffer);
port.postMessage(torrent);
});

105
src/preload/index.d.ts vendored Normal file
View file

@ -0,0 +1,105 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import { contextBridge, ipcRenderer } from "electron";
import type {
CatalogueCategory,
GameShop,
TorrentProgress,
UserPreferences,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
/* Torrenting */
startGameDownload: (
repackId: number,
objectID: string,
title: string,
shop: GameShop
) => ipcRenderer.invoke("startGameDownload", repackId, objectID, title, shop),
cancelGameDownload: (gameId: number) =>
ipcRenderer.invoke("cancelGameDownload", gameId),
pauseGameDownload: (gameId: number) =>
ipcRenderer.invoke("pauseGameDownload", gameId),
resumeGameDownload: (gameId: number) =>
ipcRenderer.invoke("resumeGameDownload", gameId),
onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
value: TorrentProgress
) => cb(value);
ipcRenderer.on("on-download-progress", listener);
return () => ipcRenderer.removeListener("on-download-progress", listener);
},
/* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
getGames: (take?: number, prevCursor?: number) =>
ipcRenderer.invoke("getGames", take, prevCursor),
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
updateUserPreferences: (preferences: UserPreferences) =>
ipcRenderer.invoke("updateUserPreferences", preferences),
/* Library */
addGameToLibrary: (
objectID: string,
title: string,
shop: GameShop,
executablePath: string
) =>
ipcRenderer.invoke(
"addGameToLibrary",
objectID,
title,
shop,
executablePath
),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
getRepackersFriendlyNames: () =>
ipcRenderer.invoke("getRepackersFriendlyNames"),
openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId),
openGame: (gameId: number, executablePath: string) =>
ipcRenderer.invoke("openGame", gameId, executablePath),
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
removeGameFromLibrary: (gameId: number) =>
ipcRenderer.invoke("removeGameFromLibrary", gameId),
deleteGameFolder: (gameId: number) =>
ipcRenderer.invoke("deleteGameFolder", gameId),
getGameByObjectID: (objectID: string) =>
ipcRenderer.invoke("getGameByObjectID", objectID),
onPlaytime: (cb: (gameId: number) => void) => {
const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
cb(gameId);
ipcRenderer.on("on-playtime", listener);
return () => ipcRenderer.removeListener("on-playtime", listener);
},
onGameClose: (cb: (gameId: number) => void) => {
const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
cb(gameId);
ipcRenderer.on("on-game-close", listener);
return () => ipcRenderer.removeListener("on-game-close", listener);
},
/* Hardware */
getDiskFreeSpace: () => ipcRenderer.invoke("getDiskFreeSpace"),
/* Misc */
getOrCacheImage: (url: string) => ipcRenderer.invoke("getOrCacheImage", url),
ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
platform: process.platform,
});

View file

@ -82,8 +82,6 @@ contextBridge.exposeInMainWorld("electron", {
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
removeGameFromLibrary: (gameId: number) =>
ipcRenderer.invoke("removeGameFromLibrary", gameId),
removeGameFromDownload: (gameId: number) =>
ipcRenderer.invoke("removeGameFromDownload", gameId),
deleteGameFolder: (gameId: number) =>
ipcRenderer.invoke("deleteGameFolder", gameId),
getGameByObjectID: (objectID: string) =>

View file

@ -1,29 +0,0 @@
/**
* This file will automatically be loaded by vite and run in the "renderer" context.
* To learn more about the differences between the "main" and the "renderer" context in
* Electron, visit:
*
* https://electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes
*
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
* in a renderer process, please be aware of potential security implications. You can read
* more about security risks here:
*
* https://electronjs.org/docs/tutorial/security
*
* To enable Node.js integration in this file, open up `main.ts` and enable the `nodeIntegration`
* flag:
*
* ```
* // Create the browser window.
* mainWindow = new BrowserWindow({
* width: 800,
* height: 600,
* webPreferences: {
* nodeIntegration: true
* }
* });
* ```
*/
import "./renderer/main";

16
src/renderer/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com;"
/>
</head>
<body style="background-color: #1c1c1">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -12,7 +12,7 @@ import {
import * as styles from "./app.css";
import { themeClass } from "./theme.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import {
setSearch,
clearSearch,
@ -22,7 +22,7 @@ import {
document.body.classList.add(themeClass);
export function App() {
export function App({ children }: any) {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary } = useLibrary();
@ -112,7 +112,7 @@ export function App() {
/>
<section ref={contentRef} className={styles.content}>
<Outlet />
{children}
</section>
</article>
</main>

View file

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 828 B

After

Width:  |  Height:  |  Size: 828 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 697 B

After

Width:  |  Height:  |  Size: 697 B

Before After
Before After

View file

@ -25,3 +25,5 @@ export const AsyncImage = forwardRef<HTMLImageElement, AsyncImageProps>(
return <img ref={ref} {...props} src={source ?? props.src} />;
}
);
AsyncImage.displayName = "AsyncImage";

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks";
import * as styles from "./bottom-panel.css";
import { vars } from "@renderer/theme.css";
import { vars } from "../../theme.css";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants";
@ -23,7 +23,7 @@ export function BottomPanel() {
}, []);
const status = useMemo(() => {
if (isDownloading) {
if (isDownloading && game) {
if (game.status === GameStatus.DownloadingMetadata)
return t("downloading_metadata", { title: game.title });
@ -62,7 +62,7 @@ export function BottomPanel() {
</button>
<small>
v{version} "{VERSION_CODENAME}"
v{version} &quot;{VERSION_CODENAME}&quot;
</small>
</footer>
);

View file

@ -1,4 +1,4 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const checkboxField = style({

View file

@ -1,8 +1,8 @@
import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
import type { CatalogueEntry } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg";
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg?react";
import { AsyncImage } from "../async-image/async-image";

View file

@ -2,7 +2,7 @@ import type { ComplexStyleRule } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const slideIn = keyframes({
"0%": { transform: "translateX(20px)", opacity: "0" },

View file

@ -1,5 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const hero = style({
width: "100%",
@ -13,11 +13,6 @@ export const hero = style({
cursor: "pointer",
border: `solid 1px ${vars.color.borderColor}`,
zIndex: "1",
"@media": {
"(min-width: 1250px)": {
backgroundPosition: "center",
},
},
});
export const heroMedia = style({

View file

@ -6,7 +6,7 @@ import { ShopDetails } from "@types";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
import { useTranslation } from "react-i18next";
const FEATURED_GAME_ID = "377160";
const FEATURED_GAME_ID = "253230";
export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] =
@ -36,7 +36,7 @@ export function Hero() {
>
<div className={styles.backdrop}>
<AsyncImage
src="https://cdn2.steamgriddb.com/hero/e7a7ba56b1be30e178cd52820e063396.png"
src="https://cdn2.steamgriddb.com/hero/a6115ed32394915aac1e5502382eaaea.jpg"
alt={featuredGameDetails?.name}
className={styles.heroMedia}
/>

View file

@ -41,6 +41,7 @@ export function Modal({
const isTopMostModal = () => {
const openModals = document.querySelectorAll("[role=modal]");
return (
openModals.length &&
openModals[openModals.length - 1] === modalContentRef.current
@ -48,32 +49,37 @@ export function Modal({
};
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopMostModal()) {
handleCloseClick();
}
};
if (visible) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopMostModal()) {
handleCloseClick();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [handleCloseClick]);
const onMouseDown = (e: MouseEvent) => {
if (!isTopMostModal()) return;
if (modalContentRef.current) {
const clickedWithinModal = modalContentRef.current.contains(
e.target as Node
);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (!isTopMostModal()) return;
if (!clickedWithinModal) {
handleCloseClick();
}
}
};
const clickedOutsideContent = !modalContentRef.current.contains(
e.target as Node
);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("mousedown", onMouseDown);
if (clickedOutsideContent) {
handleCloseClick();
}
};
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("mousedown", onMouseDown);
};
}
window.addEventListener("mousedown", onMouseDown);
return () => window.removeEventListener("mousedown", onMouseDown);
}, [handleCloseClick]);
return () => {};
}, [handleCloseClick, visible]);
useEffect(() => {
dispatch(toggleDragging(visible));

View file

@ -6,13 +6,14 @@ import type { Game } from "@types";
import { AsyncImage, TextField } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { SPACING_UNIT } from "../../theme.css";
import { routes } from "./routes";
import { MarkGithubIcon } from "@primer/octicons-react";
import DiscordLogo from "@renderer/assets/discord-icon.svg";
import XLogo from "@renderer/assets/x-icon.svg";
import DiscordLogo from "@renderer/assets/discord-icon.svg?react";
import XLogo from "@renderer/assets/x-icon.svg?react";
import * as styles from "./sidebar.css";
import { GameStatus } from "@globals";

View file

@ -1,4 +1,4 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";

View file

@ -7,7 +7,7 @@ export interface TextFieldProps
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
theme?: RecipeVariants<typeof styles.textField>["theme"];
theme?: NonNullable<RecipeVariants<typeof styles.textField>>["theme"];
label?: string;
}

View file

@ -64,7 +64,6 @@ declare global {
openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
removeGameFromLibrary: (gameId: number) => Promise<void>;
removeGameFromDownload: (gameId: number) => Promise<vodi>;
deleteGameFolder: (gameId: number) => Promise<unknown>;
getGameByObjectID: (objectID: string) => Promise<Game | null>;
onPlaytime: (cb: (gameId: number) => void) => () => Electron.IpcRenderer;

View file

@ -14,7 +14,10 @@ export const userPreferencesSlice = createSlice({
name: "userPreferences",
initialState,
reducers: {
setUserPreferences: (state, action: PayloadAction<UserPreferences>) => {
setUserPreferences: (
state,
action: PayloadAction<UserPreferences | null>
) => {
state.value = action.payload;
},
},

View file

@ -59,15 +59,15 @@ export function useDownload() {
deleteGame(gameId);
});
const removeGameFromDownload = (gameId: number) =>
window.electron.removeGameFromDownload(gameId).then(() => {
const removeGameFromLibrary = (gameId: number) =>
window.electron.removeGameFromLibrary(gameId).then(() => {
updateLibrary();
});
const isVerifying = GameStatus.isVerifying(lastPacket?.game.status);
const getETA = () => {
if (isVerifying || !isFinite(lastPacket?.timeRemaining)) {
if (isVerifying || !isFinite(lastPacket?.timeRemaining ?? 0)) {
return "";
}
@ -125,7 +125,7 @@ export function useDownload() {
pauseDownload,
resumeDownload,
cancelDownload,
removeGameFromDownload,
removeGameFromLibrary,
deleteGame,
isGameDeleting,
clearDownload: () => dispatch(clearDownload()),

View file

@ -12,10 +12,5 @@ export function useLibrary() {
.then((updatedLibrary) => dispatch(setLibrary(updatedLibrary)));
}, [dispatch]);
const removeGameFromLibrary = (gameId: number) =>
window.electron.removeGameFromLibrary(gameId).then(() => {
updateLibrary();
});
return { library, updateLibrary, removeGameFromLibrary };
return { library, updateLibrary };
}

View file

@ -4,7 +4,7 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { Provider } from "react-redux";
import LanguageDetector from "i18next-browser-languagedetector";
import { createHashRouter, RouterProvider } from "react-router-dom";
import { HashRouter, Route, Routes } from "react-router-dom";
import { init } from "@sentry/electron/renderer";
import { init as reactInit } from "@sentry/react";
@ -31,10 +31,10 @@ import { store } from "./store";
import * as resources from "@locales";
if (process.env.SENTRY_DSN) {
if (import.meta.env.RENDERER_VITE_SENTRY_DSN) {
init(
{
dsn: process.env.SENTRY_DSN,
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
beforeSend: async (event) => {
const userPreferences = await window.electron.getUserPreferences();
@ -46,39 +46,6 @@ if (process.env.SENTRY_DSN) {
);
}
const router = createHashRouter([
{
path: "/",
Component: App,
children: [
{
path: "/",
Component: Home,
},
{
path: "/catalogue",
Component: Catalogue,
},
{
path: "/downloads",
Component: Downloads,
},
{
path: "/game/:shop/:objectID",
Component: GameDetails,
},
{
path: "/search",
Component: SearchResults,
},
{
path: "/settings",
Component: Settings,
},
],
},
]);
i18n
.use(LanguageDetector)
.use(initReactI18next)
@ -96,7 +63,18 @@ i18n
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<RouterProvider router={router} />
<HashRouter>
<App>
<Routes>
<Route path="/" Component={Home} />
<Route path="/catalogue" Component={Catalogue} />
<Route path="/downloads" Component={Downloads} />
<Route path="/game/:shop/:objectID" Component={GameDetails} />
<Route path="/search" Component={SearchResults} />
<Route path="/settings" Component={Settings} />
</Routes>
</App>
</HashRouter>
</Provider>
</React.StrictMode>
);

View file

@ -6,7 +6,7 @@ import type { CatalogueEntry } from "@types";
import { clearSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks";
import { vars } from "@renderer/theme.css";
import { vars } from "../../theme.css";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import * as styles from "../home/home.css";

View file

@ -1,4 +1,4 @@
import { SPACING_UNIT } from "@renderer/theme.css";
import { SPACING_UNIT } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const deleteActionsButtonsCtn = style({

View file

@ -1,4 +1,4 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";

View file

@ -34,6 +34,7 @@ export function Downloads() {
numSeeds,
pauseDownload,
resumeDownload,
removeGameFromLibrary,
cancelDownload,
deleteGame,
isGameDeleting,
@ -53,11 +54,6 @@ export function Downloads() {
updateLibrary();
});
const removeGameFromDownload = (gameId: number) =>
window.electron.removeGameFromDownload(gameId).then(() => {
updateLibrary();
});
const getFinalDownloadSize = (game: Game) => {
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
@ -195,7 +191,7 @@ export function Downloads() {
</Button>
<Button
onClick={() => removeGameFromDownload(game.id)}
onClick={() => removeGameFromLibrary(game.id)}
theme="outline"
disabled={deleting}
>

View file

@ -1,5 +1,5 @@
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const slideIn = keyframes({
"0%": { transform: `translateY(${40 + 16}px)` },
@ -246,7 +246,9 @@ globalStyle(`${description} img`, {
marginTop: `${SPACING_UNIT}px`,
marginBottom: `${SPACING_UNIT * 3}px`,
display: "block",
maxWidth: "100%",
width: "100%",
height: "auto",
objectFit: "cover",
});
globalStyle(`${description} a`, {

View file

@ -1,6 +1,6 @@
import Color from "color";
import { average } from "color.js";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import type {
@ -18,7 +18,7 @@ import { useAppDispatch, useDownload } from "@renderer/hooks";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import { vars } from "@renderer/theme.css";
import { vars } from "../../theme.css";
import Lottie from "lottie-react";
import { useTranslation } from "react-i18next";
import { SkeletonTheme } from "react-loading-skeleton";
@ -33,6 +33,7 @@ export function GameDetails() {
const { objectID, shop } = useParams();
const [isLoading, setIsLoading] = useState(false);
const [isLoadingRandomGame, setIsLoadingRandomGame] = useState(false);
const [color, setColor] = useState("");
const [gameDetails, setGameDetails] = useState<ShopDetails | null>(null);
const [howLongToBeat, setHowLongToBeat] = useState<{
@ -53,18 +54,10 @@ export function GameDetails() {
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const randomGameObjectID = useRef<string | null>(null);
const dispatch = useAppDispatch();
const { game: gameDownloading, startDownload, isDownloading } = useDownload();
const getRandomGame = useCallback(() => {
window.electron.getRandomGame().then((objectID) => {
randomGameObjectID.current = objectID;
});
}, []);
const handleImageSettled = useCallback((url: string) => {
average(url, { amount: 1, format: "hex" })
.then((color) => {
@ -75,7 +68,7 @@ export function GameDetails() {
const getGame = useCallback(() => {
window.electron
.getGameByObjectID(objectID)
.getGameByObjectID(objectID!)
.then((result) => setGame(result));
}, [setGame, objectID]);
@ -89,10 +82,8 @@ export function GameDetails() {
setIsGamePlaying(false);
dispatch(setHeaderTitle(""));
getRandomGame();
window.electron
.getGameShopDetails(objectID, "steam", getSteamLanguage(i18n.language))
.getGameShopDetails(objectID!, "steam", getSteamLanguage(i18n.language))
.then((result) => {
if (!result) {
navigate(-1);
@ -100,13 +91,14 @@ export function GameDetails() {
}
window.electron
.getHowLongToBeat(objectID, "steam", result.name)
.getHowLongToBeat(objectID!, "steam", result.name)
.then((data) => {
setHowLongToBeat({ isLoading: false, data });
});
setGameDetails(result);
dispatch(setHeaderTitle(result.name));
setIsLoadingRandomGame(false);
})
.finally(() => {
setIsLoading(false);
@ -114,7 +106,7 @@ export function GameDetails() {
getGame();
setHowLongToBeat({ isLoading: true, data: null });
}, [getGame, getRandomGame, dispatch, navigate, objectID, i18n.language]);
}, [getGame, dispatch, navigate, objectID, i18n.language]);
const isGameDownloading = isDownloading && gameDownloading?.id === game?.id;
@ -145,29 +137,30 @@ export function GameDetails() {
repackId: number,
downloadPath: string
) => {
return startDownload(
repackId,
gameDetails.objectID,
gameDetails.name,
shop as GameShop,
downloadPath
).then(() => {
getGame();
setShowRepacksModal(false);
setShowSelectFolderModal(false);
});
if (gameDetails) {
return startDownload(
repackId,
gameDetails.objectID,
gameDetails.name,
shop as GameShop,
downloadPath
).then(() => {
getGame();
setShowRepacksModal(false);
setShowSelectFolderModal(false);
});
}
};
const handleRandomizerClick = () => {
if (!randomGameObjectID.current) return;
const handleRandomizerClick = async () => {
setIsLoadingRandomGame(true);
const randomGameObjectID = await window.electron.getRandomGame();
const searchParams = new URLSearchParams({
fromRandomizer: "1",
});
navigate(
`/game/steam/${randomGameObjectID.current}?${searchParams.toString()}`
);
navigate(`/game/steam/${randomGameObjectID}?${searchParams.toString()}`);
};
const fromRandomizer = searchParams.get("fromRandomizer");
@ -191,7 +184,7 @@ export function GameDetails() {
<section className={styles.container}>
<div className={styles.hero}>
<AsyncImage
src={steamUrlBuilder.libraryHero(objectID)}
src={steamUrlBuilder.libraryHero(objectID!)}
className={styles.heroImage}
alt={game?.title}
onSettled={handleImageSettled}
@ -199,7 +192,7 @@ export function GameDetails() {
<div className={styles.heroBackdrop}>
<div className={styles.heroContent}>
<AsyncImage
src={steamUrlBuilder.logo(objectID)}
src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }}
/>
</div>
@ -270,7 +263,7 @@ export function GameDetails() {
title: gameDetails?.name,
}),
}}
></div>
/>
</div>
</div>
</section>
@ -281,6 +274,7 @@ export function GameDetails() {
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={isLoadingRandomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie

View file

@ -33,11 +33,11 @@ export function HeroPanelActions({
resumeDownload,
pauseDownload,
cancelDownload,
removeGameFromDownload,
removeGameFromLibrary,
isGameDeleting,
} = useDownload();
const { updateLibrary, removeGameFromLibrary } = useLibrary();
const { updateLibrary } = useLibrary();
const { t } = useTranslation("game_details");
@ -102,9 +102,6 @@ export function HeroPanelActions({
}
const gameExecutablePath = await selectGameExecutable();
if (!gameExecutablePath) return;
window.electron.openGame(game.id, gameExecutablePath);
};
@ -191,7 +188,7 @@ export function HeroPanelActions({
{t("open_download_options")}
</Button>
<Button
onClick={() => removeGameFromDownload(game.id).then(getGame)}
onClick={() => removeGameFromLibrary(game.id).then(getGame)}
theme="outline"
disabled={deleting}
>

View file

@ -1,5 +1,5 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const panel = style({
width: "100%",

Some files were not shown because too many files have changed in this diff Show more