Merge branch 'main' into feature/game-achievements

# Conflicts:
#	src/renderer/src/context/game-details/game-details.context.tsx
#	src/renderer/src/main.tsx
This commit is contained in:
Zamitto 2024-09-27 20:52:40 -03:00
commit eda47fc6af
60 changed files with 900 additions and 641 deletions

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Katalog](./docs/screenshot.png)

186
README.da.md Normal file
View File

@ -0,0 +1,186 @@
<br>
<div align="center">
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra er en spil launcher med sin egen indbyggede bittorrent klient.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md)
[![es](https://img.shields.io/badge/lang-es-red)](README.es.md)
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
![Hydra Catalogue](./docs/screenshot.png)
</div>
## Indholdsfortegnelse
- [Indholdsfortegnelse](#indholdsfortegnelse)
- [Om](#om)
- [Funktioner](#funktioner)
- [Installation](#installation)
- [Bidrag](#-bidrag)
- [Bliv medlem af vores Telegram kanal](#-join-our-telegram)
- [Fork og klon dit repo](#fork-and-clone-your-repository)
- [Måder du kan bidrage](#ways-you-can-contribute)
- [Projekt Struktur](#project-structure)
- [Byg fra kildekode](#build-from-source)
- [Installér Node.js](#install-nodejs)
- [Installér Yarn](#install-yarn)
- [Installér Node Afhængigheder](#install-node-dependencies)
- [Installér Python 3.9](#install-python-39)
- [Installér Python Afhængigheder](#install-python-dependencies)
- [Miljøvariabler](#environment-variables)
- [Køre](#running)
- [Bygge](#build)
- [Bygge bittorrent klienten](#build-the-bittorrent-client)
- [Bygge Electron applikationen](#build-the-electron-application)
- [Bidragere](#contributors)
- [Licens](#license)
## Om
**Hydra** er en **Spil Launcher** med sin egen indbyggede **BitTorrent Klient**.
<br>
Launcheren er skrevet i TypeScript (Electron) og Python, som håndterer torrenting system ved brug af libtorrent.
## Funktioner
- Sin egen indbyggede bittorrent klient
- How Long To Beat (HLTB) integration på spil siden
- Downloadsti tilpasning
- Windows og Linux understøttelse
- Konstant opdateret
- Og mere ...
## Installation
Følg trinene her under for at installere:
1. Download den seneste version af Hydra fra [Releases](https://github.com/hydralauncher/hydra/releases/latest) siden.
- Download kun .exe hvis du vil installere Hydra på Windows.
- Download .deb, .rpm eller .zip hvis du vil installere Hydra på Linux. (afhænger af din Linux distro)
2. Kør den downloadede fil.
3. Nyd Hydra!
## <a name="bidrag"> Bidrag
### <a name="join-our-telegram"></a> Bliv medlem af vores Telegram kanal
Vi holder vores diskusioner i vores [Telegram](https://t.me/hydralauncher) kanal.
### Fork og klon dit repo
1. Fork repoet [(klik her for at forke nu)](https://github.com/hydralauncher/hydra/fork)
2. Klon din forkede kode `git clone https://github.com/dit_brugernavn/hydra`
3. Lav en ny branch
4. Skub dine commits
5. Indsend en ny Pull Request
### Måder du kan bidrage
- Oversættelse: Vi vil gerne have at Hydra er tilgængeligt for så mange folk som overhovedet muligt. Du er velkommen til at hjælpe med at oversætte til nye sprog eller at opdatere og forbedre de sprog som allerede er tilgængelige i Hydra.
- Kode: Hydra er lavet med Typescript, Electron og en lille smule Python. Hvis du har lyst til at bidrage, kan du blive medlem af vores [Telegram](https://t.me/hydralauncher) kanal! (Alt kommunikation foregår hovedsageligt på Engelsk, Brasiliansk eller Russisk)
### Projekt struktur
- torrent-client: Vi bruger libtorrent, et Python bibliotek, til at administrere torrent downloads
- src/renderer: UI'en i applikationen
- src/main: her har vi al logikken
## Byg fra kildekode
### Installér Node.js
Vær sikker på at du har Node.js installeret på din maskine. Hvis ikke, kan du downloade og installere det fra [nodejs.org](https://nodejs.org/).
### Installér Yarn
Yarn er et pakkehåndteringsprogram til Node.js. Hvis du ikke har installeret Yarn endnu, så kan du gøre det ved at følge instruktionerne på [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/).
### Installér Node Afhængigheder
Navigér til projekt mappen og installér Node afhængighederne ved bruge af Yarn:
```bash
cd hydra
yarn
```
### Installér Python 3.9
Vær sikker på at du har Python 3.9 installeret på din maskine. Du kan downloade og installere det her: [python.org](https://www.python.org/downloads/release/python-3913/).
### Installér Python Afhængigheder
Installér de påkrævede Python afhængigheder ved brug af pip:
```bash
pip install -r requirements.txt
```
## Miljøvariabler
Du får brug for en SteamGridDB API nøgle for at kunne hente spil ikonerne under installationen.
Når du har det, kan du kopiere og omdøbe `.env.example` filen til `.env` og indsætte nøglen som `STEAMGRIDDB_API_KEY`.
## Køre
Når alt er sat op, kan du køre den følgende kommando for at starte både Electron processen og bittorrent klienten:
```bash
yarn dev
```
## Bygge
### Byg bittorrent klienten
Byg bittorrent klienten ved brug af følgende kommando:
```bash
python torrent-client/setup.py build
```
### Byg Electron applikationen
Byg Electron applikationen ved brug af følgende kommando:
På Windows:
```bash
yarn build:win
```
På Linux:
```bash
yarn build:linux
```
## Bidragere
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
</a>
## Licens
Hydra benytter sig af [MIT Licensen](LICENSE).

View File

@ -23,6 +23,7 @@
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Katalog](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Catalogue Hydra](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -23,6 +23,7 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)

View File

@ -1,4 +1,4 @@
appId: site.hydralauncher.hydra
appId: gg.hydralauncher.hydra
productName: Hydra
directories:
buildResources: build

View File

@ -51,6 +51,7 @@
"color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"dexie": "^4.0.8",
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
"fetch-cookie": "^3.0.1",

View File

@ -1,8 +1,8 @@
import type { GameShop } from "@types";
import { registerEvent } from "../register-event";
import { HydraApi, RepacksManager } from "@main/services";
import { CatalogueCategory, formatName, steamUrlBuilder } from "@shared";
import { HydraApi } from "@main/services";
import { CatalogueCategory, steamUrlBuilder } from "@shared";
import { steamGamesWorker } from "@main/workers";
const getCatalogue = async (
@ -26,14 +26,9 @@ const getCatalogue = async (
name: "getById",
});
const repacks = RepacksManager.search({
query: formatName(steamGame.name),
});
return {
title: steamGame.name,
shop: game.shop,
repacks,
cover: steamUrlBuilder.library(game.objectId),
objectID: game.objectId,
};

View File

@ -45,15 +45,17 @@ const getGameShopDetails = async (
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
(result) => {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
if (result) {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
}
return result;
}

View File

@ -2,8 +2,7 @@ import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { RepacksManager } from "@main/services";
import { steamUrlBuilder } from "@shared";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
@ -15,13 +14,14 @@ const getGames = async (
{ name: "list" }
);
const entries = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
return {
results: entries,
cursor: cursor + entries.length,
results: steamGames.map((steamGame) => ({
title: steamGame.name,
shop: "steam",
cover: steamUrlBuilder.library(steamGame.id),
objectID: steamGame.id,
})),
cursor: cursor + steamGames.length,
};
};

View File

@ -3,32 +3,15 @@ import { shuffle } from "lodash-es";
import { getSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { getSteamGameById } from "../helpers/search-games";
import type { Steam250Game } from "@types";
const state = { games: Array<Steam250Game>(), index: 0 };
const filterGames = async (games: Steam250Game[]) => {
const results: Steam250Game[] = [];
for (const game of games) {
const steamGame = await getSteamGameById(game.objectID);
if (steamGame?.repacks.length) {
results.push(game);
}
}
return results;
};
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
if (state.games.length == 0) {
const steam250List = await getSteam250List();
const filteredSteam250List = await filterGames(steam250List);
state.games = shuffle(filteredSteam250List);
state.games = shuffle(steam250List);
}
if (state.games.length == 0) {

View File

@ -1,9 +0,0 @@
import { RepacksManager } from "@main/services";
import { registerEvent } from "../register-event";
const searchGameRepacks = (
_event: Electron.IpcMainInvokeEvent,
query: string
) => RepacksManager.search({ query });
registerEvent("searchGameRepacks", searchGameRepacks);

View File

@ -1,7 +1,7 @@
import { registerEvent } from "../register-event";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { CatalogueEntry } from "@types";
import { HydraApi, RepacksManager } from "@main/services";
import { HydraApi } from "@main/services";
const searchGamesEvent = async (
_event: Electron.IpcMainInvokeEvent,
@ -11,15 +11,13 @@ const searchGamesEvent = async (
{ objectId: string; title: string; shop: string }[]
>("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false });
const steamGames = games.map((game) => {
return games.map((game) => {
return convertSteamGameToCatalogueEntry({
id: Number(game.objectId),
name: game.title,
clientIcon: null,
});
});
return RepacksManager.findRepacksForCatalogueEntries(steamGames);
};
registerEvent("searchGames", searchGamesEvent);

View File

@ -1,42 +0,0 @@
import { registerEvent } from "../register-event";
import { dataSource } from "@main/data-source";
import { DownloadSource } from "@main/entity";
import axios from "axios";
import { downloadSourceSchema } from "../helpers/validators";
import { insertDownloadsFromSource } from "@main/helpers";
import { RepacksManager } from "@main/services";
const addDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const downloadSource = await dataSource.transaction(
async (transactionalEntityManager) => {
const downloadSource = await transactionalEntityManager
.getRepository(DownloadSource)
.save({
url,
name: source.name,
downloadCount: source.downloads.length,
});
await insertDownloadsFromSource(
transactionalEntityManager,
downloadSource,
source.downloads
);
return downloadSource;
}
);
await RepacksManager.updateRepacks();
return downloadSource;
};
registerEvent("addDownloadSource", addDownloadSource);

View File

@ -0,0 +1,9 @@
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const deleteDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => knexClient("download_source").where({ id }).delete();
registerEvent("deleteDownloadSource", deleteDownloadSource);

View File

@ -1,11 +1,7 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { knexClient } from "@main/knex-client";
const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
downloadSourceRepository.find({
order: {
createdAt: "DESC",
},
});
knexClient.select("*").from("download_source");
registerEvent("getDownloadSources", getDownloadSources);

View File

@ -1,13 +0,0 @@
import { downloadSourceRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { RepacksManager } from "@main/services";
const removeDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
id: number
) => {
await downloadSourceRepository.delete(id);
await RepacksManager.updateRepacks();
};
registerEvent("removeDownloadSource", removeDownloadSource);

View File

@ -1,7 +0,0 @@
import { registerEvent } from "../register-event";
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
fetchDownloadSourcesAndUpdate();
registerEvent("syncDownloadSources", syncDownloadSources);

View File

@ -1,27 +0,0 @@
import { registerEvent } from "../register-event";
import { downloadSourceRepository } from "@main/repository";
import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
const validateDownloadSource = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const existingSource = await downloadSourceRepository.findOne({
where: { url },
});
if (existingSource)
throw new Error("Source with the same url already exists");
const repacks = RepacksManager.repacks;
return downloadSourceWorker.run(
{ url, repacks },
{
name: "validateDownloadSource",
}
);
};
registerEvent("validateDownloadSource", validateDownloadSource);

View File

@ -1,7 +1,6 @@
import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { steamGamesWorker } from "@main/workers";
import { RepacksManager } from "@main/services";
import { steamUrlBuilder } from "@shared";
export interface SearchGamesArgs {
@ -17,7 +16,6 @@ export const convertSteamGameToCatalogueEntry = (
title: game.name,
shop: "steam" as GameShop,
cover: steamUrlBuilder.library(String(game.id)),
repacks: [],
});
export const getSteamGameById = async (
@ -29,9 +27,5 @@ export const getSteamGameById = async (
if (!steamGame) return null;
const catalogueEntry = convertSteamGameToCatalogueEntry(steamGame);
const result = RepacksManager.findRepacksForCatalogueEntry(catalogueEntry);
return result;
return convertSteamGameToCatalogueEntry(steamGame);
};

View File

@ -1,13 +0,0 @@
import { z } from "zod";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});

View File

@ -7,7 +7,6 @@ import "./catalogue/get-games";
import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game";
import "./catalogue/search-games";
import "./catalogue/search-game-repacks";
import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games";
import "./catalogue/get-game-achievements";
@ -38,11 +37,8 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid";
import "./download-sources/delete-download-source";
import "./download-sources/get-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/add-download-source";
import "./download-sources/remove-download-source";
import "./download-sources/sync-download-sources";
import "./auth/sign-out";
import "./auth/open-auth-window";
import "./auth/get-session-hash";
@ -61,6 +57,7 @@ import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/send-friend-request";
import "./profile/sync-friend-requests";
import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");

View File

@ -3,7 +3,6 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64 } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
@ -37,20 +36,12 @@ const addGameToLibrary = async (
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository
.insert({
title,
iconUrl,
objectID,
shop,
})
.then(() => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
});
await gameRepository.insert({
title,
iconUrl,
objectID,
shop,
});
}
updateLocalUnlockedAchivements(true, objectID);

View File

@ -0,0 +1,29 @@
import { Notification } from "electron";
import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { t } from "i18next";
const publishNewRepacksNotification = async (
_event: Electron.IpcMainInvokeEvent,
newRepacksCount: number
) => {
if (newRepacksCount < 1) return;
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
}),
body: t("repack_count", {
ns: "notifications",
count: newRepacksCount,
}),
}).show();
}
};
registerEvent("publishNewRepacksNotification", publishNewRepacksNotification);

View File

@ -1,7 +1,6 @@
import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types";
import { getFileBase64 } from "@main/helpers";
import { DownloadManager, HydraApi, logger } from "@main/services";
import { Not } from "typeorm";
@ -9,36 +8,25 @@ import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, Repack } from "@main/entity";
import { DownloadQueue, Game } from "@main/entity";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload
) => {
const { repackId, objectID, title, shop, downloadPath, downloader, uri } =
payload;
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
return dataSource.transaction(async (transactionalEntityManager) => {
const gameRepository = transactionalEntityManager.getRepository(Game);
const repackRepository = transactionalEntityManager.getRepository(Repack);
const downloadQueueRepository =
transactionalEntityManager.getRepository(DownloadQueue);
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
objectID,
shop,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
const game = await gameRepository.findOne({
where: {
objectID,
shop,
},
});
await DownloadManager.pauseDownload();
@ -71,26 +59,16 @@ const startGameDownload = async (
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null;
await gameRepository
.insert({
title,
iconUrl,
objectID,
downloader,
shop,
status: "active",
downloadPath,
uri,
})
.then((result) => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
return result;
});
await gameRepository.insert({
title,
iconUrl,
objectID,
downloader,
shop,
status: "active",
downloadPath,
uri,
});
}
const updatedGame = await gameRepository.findOne({

View File

@ -73,7 +73,6 @@ const getUser = async (
recentGames,
};
} catch (err) {
console.log(err);
return null;
}
};

View File

@ -1,76 +0,0 @@
import { dataSource } from "@main/data-source";
import { DownloadSource, Repack } from "@main/entity";
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { downloadSourceRepository } from "@main/repository";
import { RepacksManager } from "@main/services";
import { downloadSourceWorker } from "@main/workers";
import { chunk } from "lodash-es";
import type { EntityManager } from "typeorm";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { z } from "zod";
export const insertDownloadsFromSource = async (
trx: EntityManager,
downloadSource: DownloadSource,
downloads: z.infer<typeof downloadSourceSchema>["downloads"]
) => {
const repacks: QueryDeepPartialEntity<Repack>[] = downloads.map(
(download) => ({
title: download.title,
uris: JSON.stringify(download.uris),
magnet: download.uris[0]!,
fileSize: download.fileSize,
repacker: downloadSource.name,
uploadDate: download.uploadDate,
downloadSource: { id: downloadSource.id },
})
);
const downloadsChunks = chunk(repacks, 800);
for (const chunk of downloadsChunks) {
await trx
.getRepository(Repack)
.createQueryBuilder()
.insert()
.values(chunk)
.updateEntity(false)
.orIgnore()
.execute();
}
};
export const fetchDownloadSourcesAndUpdate = async () => {
const downloadSources = await downloadSourceRepository.find({
order: {
id: "desc",
},
});
const results = await downloadSourceWorker.run(downloadSources, {
name: "getUpdatedRepacks",
});
await dataSource.transaction(async (transactionalEntityManager) => {
for (const result of results) {
if (result.etag !== null) {
await transactionalEntityManager.getRepository(DownloadSource).update(
{ id: result.id },
{
etag: result.etag,
status: result.status,
downloadCount: result.downloads.length,
}
);
await insertDownloadsFromSource(
transactionalEntityManager,
result,
result.downloads
);
}
}
await RepacksManager.updateRepacks();
});
};

View File

@ -7,16 +7,6 @@ export const getFileBuffer = async (url: string) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
export const getFileBase64 = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => {
const base64 = Buffer.from(buffer).toString("base64");
const contentType = response.headers.get("content-type");
return `data:${contentType};base64,${base64}`;
})
);
export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
@ -36,6 +26,4 @@ export const requestWebPage = async (url: string) => {
};
export const isPortableVersion = () =>
process.env.PORTABLE_EXECUTABLE_FILE != null;
export * from "./download-source";
process.env.PORTABLE_EXECUTABLE_FILE !== null;

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, net, protocol } from "electron";
import { app, BrowserWindow, net, protocol, session } from "electron";
import { init } from "@sentry/electron/main";
import updater from "electron-updater";
import i18n from "i18next";
@ -68,14 +68,13 @@ const runMigrations = async () => {
});
await knexClient.migrate.latest(migrationConfig);
await knexClient.destroy();
};
// 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(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra");
electronApp.setAppUserModelId("gg.hydralauncher.hydra");
protocol.handle("local", (request) => {
const filePath = request.url.slice("local:".length);
@ -105,6 +104,46 @@ app.whenReady().then(async () => {
WindowManager.createMainWindow();
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
callback({
requestHeaders: {
...details.requestHeaders,
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
},
});
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const headers = {
"access-control-allow-origin": ["*"],
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
"access-control-expose-headers": ["ETag"],
"access-control-allow-headers": [
"Content-Type, Authorization, X-Requested-With, If-None-Match",
],
"access-control-allow-credentials": ["true"],
};
if (details.method === "OPTIONS") {
callback({
cancel: false,
responseHeaders: {
...details.responseHeaders,
...headers,
},
statusLine: "HTTP/1.1 200 OK",
});
} else {
callback({
responseHeaders: {
...details.responseHeaders,
...headers,
},
});
}
});
});
app.on("browser-window-created", (_, window) => {

View File

@ -1,25 +1,14 @@
import {
DownloadManager,
RepacksManager,
PythonInstance,
startMainLoop,
} from "./services";
import { DownloadManager, PythonInstance, startMainLoop } from "./services";
import {
downloadQueueRepository,
repackRepository,
userPreferencesRepository,
} from "./repository";
import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid";
import { fetchDownloadSourcesAndUpdate } from "./helpers";
import { publishNewRepacksNotifications } from "./services/notifications";
import { MoreThan } from "typeorm";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
const loadState = async (userPreferences: UserPreferences | null) => {
RepacksManager.updateRepacks();
import("./events");
if (userPreferences?.realDebridApiToken) {
@ -46,18 +35,6 @@ const loadState = async (userPreferences: UserPreferences | null) => {
}
startMainLoop();
const now = new Date();
fetchDownloadSourcesAndUpdate().then(async () => {
const newRepacksCount = await repackRepository.count({
where: {
createdAt: MoreThan(now),
},
});
if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount);
});
};
userPreferencesRepository

View File

@ -7,5 +7,4 @@ export * from "./download";
export * from "./how-long-to-beat";
export * from "./process-watcher";
export * from "./main-loop";
export * from "./repacks-manager";
export * from "./hydra-api";

View File

@ -49,24 +49,6 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
}
};
export const publishNewRepacksNotifications = async (count: number) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
}),
body: t("repack_count", {
ns: "notifications",
count: count,
}),
}).show();
}
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {

View File

@ -1,63 +0,0 @@
import { repackRepository } from "@main/repository";
import { formatName } from "@shared";
import { CatalogueEntry, GameRepack } from "@types";
import flexSearch from "flexsearch";
export class RepacksManager {
public static repacks: GameRepack[] = [];
private static repacksIndex = new flexSearch.Index();
public static async updateRepacks() {
this.repacks = await repackRepository
.find({
order: {
createdAt: "DESC",
},
})
.then((repacks) =>
repacks.map((repack) => {
const uris: string[] = [];
const magnet = repack?.magnet;
if (magnet) uris.push(magnet);
return {
...repack,
uris: [...uris, ...JSON.parse(repack.uris)],
};
})
);
for (let i = 0; i < this.repacks.length; i++) {
this.repacksIndex.remove(i);
}
this.repacksIndex = new flexSearch.Index();
for (let i = 0; i < this.repacks.length; i++) {
const repack = this.repacks[i];
const formattedTitle = formatName(repack.title);
this.repacksIndex.add(i, formattedTitle);
}
}
public static search(options: flexSearch.SearchOptions) {
return this.repacksIndex
.search({ ...options, query: formatName(options.query ?? "") })
.map((index) => this.repacks[index]);
}
public static findRepacksForCatalogueEntry(entry: CatalogueEntry) {
const repacks = this.search({ query: formatName(entry.title) });
return { ...entry, repacks };
}
public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) {
return entries.map((entry) => {
const repacks = this.search({ query: formatName(entry.title) });
return { ...entry, repacks };
});
}
}

View File

@ -1,71 +0,0 @@
import { downloadSourceSchema } from "@main/events/helpers/validators";
import { DownloadSourceStatus } from "@shared";
import type { DownloadSource, GameRepack } from "@types";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { z } from "zod";
export type DownloadSourceResponse = z.infer<typeof downloadSourceSchema> & {
etag: string | null;
status: DownloadSourceStatus;
};
export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
const results: DownloadSourceResponse[] = [];
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
results.push({
...downloadSource,
downloads: source.downloads,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
});
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
results.push({
...downloadSource,
downloads: [],
etag: null,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
return results;
};
export const validateDownloadSource = async ({
url,
repacks,
}: {
url: string;
repacks: GameRepack[];
}) => {
const response = await axios.get(url);
const source = downloadSourceSchema.parse(response.data);
const existingUris = source.downloads
.flatMap((download) => download.uris)
.filter((uri) => repacks.some((repack) => repack.magnet === uri));
return {
name: source.name,
downloadCount: source.downloads.length - existingUris.length,
};
};

View File

@ -1,6 +1,5 @@
import path from "node:path";
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
import downloadSourceWorkerPath from "./download-source.worker?modulePath";
import Piscina from "piscina";
@ -13,7 +12,3 @@ export const steamGamesWorker = new Piscina({
},
maxThreads: 1,
});
export const downloadSourceWorker = new Piscina({
filename: downloadSourceWorkerPath,
});

View File

@ -81,13 +81,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (id: number) =>
ipcRenderer.invoke("removeDownloadSource", id),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
/* Library */
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
@ -203,4 +198,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-signout", listener);
return () => ipcRenderer.removeListener("on-signout", listener);
},
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
});

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useContext, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
@ -26,6 +26,8 @@ import {
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context";
export interface AppProps {
children: React.ReactNode;
@ -37,8 +39,12 @@ export function App() {
const { t } = useTranslation("app");
const downloadSourceMigrationLock = useRef(false);
const { clearDownload, setLastPacket } = useDownload();
const { indexRepacks } = useContext(repacksContext);
const {
isFriendsModalVisible,
friendRequetsModalTab,
@ -197,7 +203,7 @@ export function App() {
useEffect(() => {
new MutationObserver(() => {
const modal = document.body.querySelector("[role=modal]");
const modal = document.body.querySelector("[role=dialog]");
dispatch(toggleDraggingDisabled(Boolean(modal)));
}).observe(document.body, {
@ -206,6 +212,49 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
useEffect(() => {
if (downloadSourceMigrationLock.current) return;
downloadSourceMigrationLock.current = true;
window.electron.getDownloadSources().then(async (downloadSources) => {
if (!downloadSources.length) {
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
channel.onmessage = (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
};
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}
for (const downloadSource of downloadSources) {
const channel = new BroadcastChannel(
`download_sources:import:${downloadSource.url}`
);
await new Promise((resolve) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
downloadSource.url,
]);
channel.onmessage = () => {
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
resolve(true);
});
indexRepacks();
channel.close();
};
}).catch(() => channel.close());
}
downloadSourceMigrationLock.current = false;
});
}, [indexRepacks]);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);

View File

@ -1,13 +1,14 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { CatalogueEntry, GameStats } from "@types";
import type { CatalogueEntry, GameRepack, GameStats } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { useCallback, useState } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { useFormat } from "@renderer/hooks";
import { repacksContext } from "@renderer/context";
export interface GameCardProps
extends React.DetailedHTMLProps<
@ -25,9 +26,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const [stats, setStats] = useState<GameStats | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(game.title).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, isIndexingRepacks, searchRepacks]);
const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker))
new Set(repacks.map(({ repacker }) => repacker))
);
const handleHover = useCallback(() => {

View File

@ -1,4 +1,10 @@
import { createContext, useCallback, useEffect, useState } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
@ -17,6 +23,7 @@ import type {
import { useTranslation } from "react-i18next";
import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
import { repacksContext } from "../repacks/repacks.context";
export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
@ -54,7 +61,6 @@ export function GameDetailsContextProvider({
const { objectID, shop } = useParams();
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
@ -67,10 +73,22 @@ export function GameDetailsContextProvider({
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [searchParams] = useSearchParams();
const gameTitle = searchParams.get("title")!;
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
if (!isIndexingRepacks) {
searchRepacks(gameTitle).then((repacks) => {
setRepacks(repacks);
});
}
}, [game, gameTitle, isIndexingRepacks, searchRepacks]);
const { i18n } = useTranslation("game_details");
const dispatch = useAppDispatch();
@ -94,42 +112,41 @@ export function GameDetailsContextProvider({
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => {
Promise.allSettled([
window.electron.getGameShopDetails(
window.electron
.getGameShopDetails(
objectID!,
shop as GameShop,
getSteamLanguage(i18n.language)
),
window.electron.searchGameRepacks(gameTitle),
window.electron.getGameStats(objectID!, shop as GameShop),
window.electron.getGameAchievements(objectID!, shop as GameShop),
])
.then(([appDetailsResult, repacksResult, statsResult, achievements]) => {
if (appDetailsResult.status === "fulfilled") {
setShopDetails(appDetailsResult.value);
)
.then((result) => {
setShopDetails(result);
if (
appDetailsResult.value?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
}
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
if (statsResult.status === "fulfilled") setStats(statsResult.value);
if (achievements.status === "fulfilled") {
setAchievements(achievements.value);
if (
result?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
})
.finally(() => {
setIsLoading(false);
});
window.electron
.getGameStats(objectID!, shop as GameShop)
.then((result) => {
setStats(result);
})
.catch(() => {});
window.electron
.getGameAchievements(objectID!, shop as GameShop)
.then((achievements) => {
setAchievements(achievements);
})
.catch(() => {});
updateGame();
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);

View File

@ -1,3 +1,4 @@
export * from "./game-details/game-details.context";
export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context";
export * from "./repacks/repacks.context";

View File

@ -0,0 +1,67 @@
import type { GameRepack } from "@types";
import { createContext, useCallback, useEffect, useState } from "react";
import { repacksWorker } from "@renderer/workers";
export interface RepacksContext {
searchRepacks: (query: string) => Promise<GameRepack[]>;
indexRepacks: () => void;
isIndexingRepacks: boolean;
}
export const repacksContext = createContext<RepacksContext>({
searchRepacks: async () => [] as GameRepack[],
indexRepacks: () => {},
isIndexingRepacks: false,
});
const { Provider } = repacksContext;
export const { Consumer: RepacksContextConsumer } = repacksContext;
export interface RepacksContextProps {
children: React.ReactNode;
}
export function RepacksContextProvider({ children }: RepacksContextProps) {
const [isIndexingRepacks, setIsIndexingRepacks] = useState(true);
const searchRepacks = useCallback(async (query: string) => {
return new Promise<GameRepack[]>((resolve) => {
const channelId = crypto.randomUUID();
repacksWorker.postMessage([channelId, query]);
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
resolve(event.data);
channel.close();
};
return [];
});
}, []);
const indexRepacks = useCallback(() => {
setIsIndexingRepacks(true);
repacksWorker.postMessage("INDEX_REPACKS");
repacksWorker.onmessage = () => {
setIsIndexingRepacks(false);
};
}, []);
useEffect(() => {
indexRepacks();
}, [indexRepacks]);
return (
<Provider
value={{
searchRepacks,
indexRepacks,
isIndexingRepacks,
}}
>
{children}
</Provider>
);
}

View File

@ -115,12 +115,7 @@ declare global {
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: (
url: string
) => Promise<{ name: string; downloadCount: number }>;
addDownloadSource: (url: string) => Promise<DownloadSource>;
removeDownloadSource: (id: number) => Promise<void>;
syncDownloadSources: () => Promise<void>;
deleteDownloadSource: (id: number) => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
@ -184,6 +179,9 @@ declare global {
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
}
interface Window {

13
src/renderer/src/dexie.ts Normal file
View File

@ -0,0 +1,13 @@
import { Dexie } from "dexie";
export const db = new Dexie("Hydra");
db.version(1).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
export const repacksTable = db.table("repacks");
db.open();

View File

@ -90,19 +90,9 @@ export function useUserDetails() {
username: userDetails?.username || "",
});
},
[updateUserDetails]
[updateUserDetails, userDetails?.username]
);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch]);
const syncFriendRequests = useCallback(async () => {
return window.electron
.syncFriendRequests()
@ -112,6 +102,16 @@ export function useUserDetails() {
.catch(() => {});
}, [dispatch]);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch, syncFriendRequests]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
dispatch(setFriendsModalVisible({ initialTab, userId }));

View File

@ -30,6 +30,9 @@ import { store } from "./store";
import resources from "@locales";
import { Achievemnt } from "./pages/achievement/achievement";
import "./workers";
import { RepacksContextProvider } from "./context";
Sentry.init({});
i18n
@ -55,20 +58,22 @@ i18n
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<HashRouter>
<Routes>
<Route element={<App />}>
<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} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
<Route path="/achievement-notification" Component={Achievemnt} />
</Routes>
</HashRouter>
<RepacksContextProvider>
<HashRouter>
<Routes>
<Route element={<App />}>
<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} />
<Route path="/profile/:userId" Component={Profile} />
</Route>
<Route path="/achievement-notification" Component={Achievemnt} />
</Routes>
</HashRouter>
</RepacksContextProvider>
</Provider>
</React.StrictMode>
);

View File

@ -1,6 +1,5 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent";
import { Badge, Button, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types";
@ -33,8 +32,6 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const [infoHash, setInfoHash] = useState<string | null>(null);
const { repacks, game } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@ -43,18 +40,9 @@ export function RepacksModal({
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
}, [repacks]);
const getInfoHash = useCallback(async () => {
if (game?.uri?.startsWith("magnet:")) {
const torrent = await parseTorrent(game?.uri ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash);
}
}, [game]);
useEffect(() => {
setFilteredRepacks(sortedRepacks);
if (game?.uri) getInfoHash();
}, [sortedRepacks, visible, game, getInfoHash]);
}, [sortedRepacks, visible, game]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
@ -77,9 +65,6 @@ export function RepacksModal({
};
const checkIfLastDownloadedOption = (repack: GameRepack) => {
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash));
if (!game?.uri) return false;
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
};

View File

@ -8,6 +8,9 @@ import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { downloadSourcesTable } from "@renderer/dexie";
import { DownloadSourceValidationResult } from "@types";
import { downloadSourcesWorker } from "@renderer/workers";
interface AddDownloadSourceModalProps {
visible: boolean;
@ -39,41 +42,48 @@ export function AddDownloadSourceModal({
setValue,
setError,
clearErrors,
formState: { errors },
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: yupResolver(schema),
});
const [validationResult, setValidationResult] = useState<{
name: string;
downloadCount: number;
} | null>(null);
const [validationResult, setValidationResult] =
useState<DownloadSourceValidationResult | null>(null);
const { sourceUrl } = useContext(settingsContext);
const onSubmit = useCallback(
async (values: FormValues) => {
setIsLoading(true);
const existingDownloadSource = await downloadSourcesTable
.where({ url: values.url })
.first();
try {
const result = await window.electron.validateDownloadSource(values.url);
setValidationResult(result);
if (existingDownloadSource) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
setUrl(values.url);
} catch (error: unknown) {
if (error instanceof Error) {
if (
error.message.endsWith("Source with the same url already exists")
) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
}
}
} finally {
setIsLoading(false);
return;
}
downloadSourcesWorker.postMessage([
"VALIDATE_DOWNLOAD_SOURCE",
values.url,
]);
const channel = new BroadcastChannel(
`download_sources:validate:${values.url}`
);
channel.onmessage = (
event: MessageEvent<DownloadSourceValidationResult>
) => {
setValidationResult(event.data);
channel.close();
};
setUrl(values.url);
},
[setError, t]
);
@ -91,9 +101,21 @@ export function AddDownloadSourceModal({
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(url);
onClose();
onAddDownloadSource();
setIsLoading(true);
if (validationResult) {
const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = () => {
setIsLoading(false);
onClose();
onAddDownloadSource();
channel.close();
};
}
};
return (
@ -122,7 +144,7 @@ export function AddDownloadSourceModal({
theme="outline"
style={{ alignSelf: "flex-end" }}
onClick={handleSubmit(onSubmit)}
disabled={isLoading}
disabled={isSubmitting || isLoading}
>
{t("validate_download_source")}
</Button>

View File

@ -10,7 +10,9 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { settingsContext } from "@renderer/context";
import { repacksContext, settingsContext } from "@renderer/context";
import { downloadSourcesTable } from "@renderer/dexie";
import { downloadSourcesWorker } from "@renderer/workers";
export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
@ -18,16 +20,23 @@ export function SettingsDownloadSources() {
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isSyncingDownloadSources, setIsSyncingDownloadSources] =
useState(false);
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
useState(false);
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast();
const { indexRepacks } = useContext(repacksContext);
const getDownloadSources = async () => {
return window.electron.getDownloadSources().then((sources) => {
setDownloadSources(sources);
});
await downloadSourcesTable
.toCollection()
.sortBy("createdAt")
.then((sources) => {
setDownloadSources(sources.reverse());
});
};
useEffect(() => {
@ -38,14 +47,24 @@ export function SettingsDownloadSources() {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id);
showSuccessToast(t("removed_download_source"));
const handleRemoveSource = (id: number) => {
setIsRemovingDownloadSource(true);
const channel = new BroadcastChannel(`download_sources:delete:${id}`);
getDownloadSources();
downloadSourcesWorker.postMessage(["DELETE_DOWNLOAD_SOURCE", id]);
channel.onmessage = () => {
showSuccessToast(t("removed_download_source"));
getDownloadSources();
indexRepacks();
setIsRemovingDownloadSource(false);
channel.close();
};
};
const handleAddDownloadSource = async () => {
indexRepacks();
await getDownloadSources();
showSuccessToast(t("added_download_source"));
};
@ -53,15 +72,17 @@ export function SettingsDownloadSources() {
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
window.electron
.syncDownloadSources()
.then(() => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
})
.finally(() => {
setIsSyncingDownloadSources(false);
});
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
channel.onmessage = () => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
setIsSyncingDownloadSources(false);
channel.close();
};
};
const statusTitle = {
@ -88,7 +109,11 @@ export function SettingsDownloadSources() {
<Button
type="button"
theme="outline"
disabled={!downloadSources.length || isSyncingDownloadSources}
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
<SyncIcon />
@ -99,6 +124,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
disabled={isSyncingDownloadSources}
>
<PlusCircleIcon />
{t("add_download_source")}
@ -148,6 +174,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => handleRemoveSource(downloadSource.id)}
disabled={isRemovingDownloadSource}
>
<NoEntryIcon />
{t("remove_download_source")}

View File

@ -0,0 +1,165 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus } from "@shared";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
type Payload =
| ["IMPORT_DOWNLOAD_SOURCE", string]
| ["DELETE_DOWNLOAD_SOURCE", number]
| ["VALIDATE_DOWNLOAD_SOURCE", string]
| ["SYNC_DOWNLOAD_SOURCES", string];
self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data;
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
const { name } = downloadSourceSchema.parse(response.data);
const channel = new BroadcastChannel(`download_sources:validate:${data}`);
channel.postMessage({
name,
etag: response.headers["etag"],
downloadCount: response.data.downloads.length,
});
}
if (type === "DELETE_DOWNLOAD_SOURCE") {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: data }).delete();
await downloadSourcesTable.where({ id: data }).delete();
});
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
channel.postMessage(true);
}
if (type === "IMPORT_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date();
const id = await downloadSourcesTable.add({
url: data,
name: response.data.name,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
downloadCount: response.data.downloads.length,
createdAt: now,
updatedAt: now,
});
const downloadSource = await downloadSourcesTable.get(id);
const repacks = response.data.downloads.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: response.data.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource!.id,
createdAt: now,
updatedAt: now,
}));
await repacksTable.bulkAdd(repacks);
});
const channel = new BroadcastChannel(`download_sources:import:${data}`);
channel.postMessage(true);
}
if (type === "SYNC_DOWNLOAD_SOURCES") {
const channel = new BroadcastChannel(`download_sources:sync:${data}`);
let newRepacksCount = 0;
try {
const downloadSources = await downloadSourcesTable.toArray();
const existingRepacks = await repacksTable.toArray();
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
await db.transaction(
"rw",
repacksTable,
downloadSourcesTable,
async () => {
await downloadSourcesTable.update(downloadSource.id, {
etag: response.headers["etag"],
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
const now = new Date();
const repacks = source.downloads
.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
)
.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: source.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
}));
newRepacksCount += repacks.length;
await repacksTable.bulkAdd(repacks);
}
);
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesTable.update(downloadSource.id, {
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
channel.postMessage(newRepacksCount);
} catch (err) {
channel.postMessage(-1);
}
}
};

View File

@ -0,0 +1,5 @@
import RepacksWorker from "./repacks.worker?worker";
import DownloadSourcesWorker from "./download-sources.worker?worker";
export const repacksWorker = new RepacksWorker();
export const downloadSourcesWorker = new DownloadSourcesWorker();

View File

@ -0,0 +1,50 @@
import { repacksTable } from "@renderer/dexie";
import { formatName } from "@shared";
import { GameRepack } from "@types";
import flexSearch from "flexsearch";
const index = new flexSearch.Index();
const state = {
repacks: [] as any[],
};
interface SerializedGameRepack extends Omit<GameRepack, "uris"> {
uris: string;
}
self.onmessage = async (
event: MessageEvent<[string, string] | "INDEX_REPACKS">
) => {
if (event.data === "INDEX_REPACKS") {
repacksTable
.toCollection()
.sortBy("uploadDate")
.then((results) => {
state.repacks = results.reverse();
for (let i = 0; i < state.repacks.length; i++) {
const repack = state.repacks[i];
const formattedTitle = formatName(repack.title);
index.add(i, formattedTitle);
}
self.postMessage("INDEXING_COMPLETE");
});
} else {
const [requestId, query] = event.data;
const results = index.search(formatName(query)).map((index) => {
const repack = state.repacks.at(index as number) as SerializedGameRepack;
return {
...repack,
uris: [...repack.uris, repack.magnet].filter(Boolean),
};
});
const channel = new BroadcastChannel(`repacks:search:${requestId}`);
channel.postMessage(results);
}
};

View File

@ -54,7 +54,6 @@ export interface CatalogueEntry {
title: string;
/* Epic Games covers cannot be guessed with objectID */
cover: string;
repacks: GameRepack[];
}
export interface UserGame {
@ -81,7 +80,6 @@ export interface Game {
status: GameStatus | null;
folderName: string;
downloadPath: string | null;
repacks: GameRepack[];
progress: number;
bytesDownloaded: number;
playTimeInMilliseconds: number;
@ -236,6 +234,19 @@ export interface UpdateProfileRequest {
bio?: string;
}
export interface DownloadSourceDownload {
title: string;
uris: string[];
uploadDate: string;
fileSize: string;
}
export interface DownloadSourceValidationResult {
name: string;
etag: string;
downloadCount: number;
}
export interface DownloadSource {
id: number;
name: string;

View File

@ -3638,6 +3638,11 @@ detect-node@^2.0.4:
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
dexie@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-4.0.8.tgz#21fca70686bdaa1d86fad45b6b19316f6a084a1d"
integrity sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"