mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-16 00:43:00 -03:00
Compare commits
119 Commits
v2.1.7
...
chore/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ca2bf3c2 | ||
|
|
6d4f957e2b | ||
|
|
7c9c27801f | ||
|
|
737ad5a24d | ||
|
|
45c3cb8ca9 | ||
|
|
7f09a7796f | ||
|
|
387ee86c0f | ||
|
|
af83152997 | ||
|
|
5d21adcbb1 | ||
|
|
b3a1111bfc | ||
|
|
456e7ed809 | ||
|
|
e97d439d13 | ||
|
|
f5da836b1b | ||
|
|
96b15d341a | ||
|
|
1ea64d7243 | ||
|
|
cb141c9ceb | ||
|
|
57118ec5b9 | ||
|
|
002028130b | ||
|
|
8fdc6c4ab2 | ||
|
|
71e7f1ee58 | ||
|
|
0222121288 | ||
|
|
f6acfa4aee | ||
|
|
2a6b757e37 | ||
|
|
9391b7e6c9 | ||
|
|
b99dbe83e2 | ||
|
|
bcbe6c9619 | ||
|
|
0873c8e244 | ||
|
|
58502aeb1f | ||
|
|
4222fcec52 | ||
|
|
035e424a76 | ||
|
|
5e313a0374 | ||
|
|
81e2bda049 | ||
|
|
d27ce9781f | ||
|
|
94e242168c | ||
|
|
e4ca3d38ec | ||
|
|
0acb0fd4c8 | ||
|
|
241d7692b9 | ||
|
|
0895e9ec72 | ||
|
|
9b932358e8 | ||
|
|
3ed4547dfe | ||
|
|
9731035820 | ||
|
|
7e2d9316f3 | ||
|
|
7cddcd8147 | ||
|
|
5da9eb6366 | ||
|
|
9c7651d8e2 | ||
|
|
8b5ed96e9b | ||
|
|
f0e0abae8c | ||
|
|
cadb9e8dff | ||
|
|
beaa919c80 | ||
|
|
ef4844b8c0 | ||
|
|
d5b1bcdc7f | ||
|
|
05652d9c1b | ||
|
|
6a0f47eacb | ||
|
|
8f9508c00e | ||
|
|
44e59a5f6f | ||
|
|
c18c41ac95 | ||
|
|
084b7f5b9c | ||
|
|
92b0ced08a | ||
|
|
f6ce6eddb8 | ||
|
|
a031049b73 | ||
|
|
a48e269d7f | ||
|
|
333b143b17 | ||
|
|
586df616e8 | ||
|
|
eda47fc6af | ||
|
|
202751ddca | ||
|
|
790f7a2549 | ||
|
|
eebd09ccf2 | ||
|
|
ac9565f924 | ||
|
|
55a92fd68a | ||
|
|
37111c11d8 | ||
|
|
84420668fc | ||
|
|
4bf25f8c52 | ||
|
|
bdba3dd29c | ||
|
|
43e9919b6b | ||
|
|
2aa393967f | ||
|
|
17febcd88a | ||
|
|
753a293cd7 | ||
|
|
d7c05247c3 | ||
|
|
54dae87a58 | ||
|
|
50b34dc864 | ||
|
|
08fbd4c8d8 | ||
|
|
780ab5f909 | ||
|
|
c72eefdb77 | ||
|
|
5c790edb2c | ||
|
|
24d21b9839 | ||
|
|
5b9d860937 | ||
|
|
9b5e13ad35 | ||
|
|
25cfdb50d8 | ||
|
|
32fa69627c | ||
|
|
3e165e05fb | ||
|
|
89b830fe9a | ||
|
|
2d7aef34c6 | ||
|
|
0ea7329aa3 | ||
|
|
b87aade2a3 | ||
|
|
41c80daaaa | ||
|
|
e64a414309 | ||
|
|
f98432f6c6 | ||
|
|
f3a5f90bc7 | ||
|
|
d57980edc7 | ||
|
|
97dc9b03b0 | ||
|
|
7e3cf0a00e | ||
|
|
5b0cf1e82b | ||
|
|
500cd2a531 | ||
|
|
8fb62af0cf | ||
|
|
fabeedaa8a | ||
|
|
e701a273d8 | ||
|
|
6c32fdbcbb | ||
|
|
61b2d55d47 | ||
|
|
7b5e4459d4 | ||
|
|
5b450db5eb | ||
|
|
d88e06e289 | ||
|
|
339dc89702 | ||
|
|
50028fbfd8 | ||
|
|
d97c5b894a | ||
|
|
f860439fb5 | ||
|
|
ddd6ff7dbe | ||
|
|
849b6de6bc | ||
|
|
f8f2124cec | ||
|
|
3833e11e98 |
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -40,8 +40,8 @@ jobs:
|
||||
sudo apt-get install -y libarchive-tools
|
||||
yarn build:linux
|
||||
env:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -50,8 +50,8 @@ jobs:
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: yarn build:win
|
||||
env:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -58,6 +58,22 @@ jobs:
|
||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Build-${{ matrix.os }}
|
||||
path: |
|
||||
dist/win-unpacked/**
|
||||
dist/*-portable.exe
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
dist/*.tar.gz
|
||||
dist/*.yml
|
||||
dist/*.blockmap
|
||||
dist/*.pacman
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
.vscode
|
||||
node_modules
|
||||
.vscode/
|
||||
node_modules/
|
||||
hydra-download-manager/
|
||||
fastlist.exe
|
||||
__pycache__
|
||||
@@ -10,3 +10,4 @@ out
|
||||
.env
|
||||
.vite
|
||||
sentry.properties
|
||||
ludusavi/
|
||||
25
README.md
25
README.md
@@ -13,19 +13,20 @@
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
[](https://github.com/hydralauncher/hydra/releases)
|
||||
|
||||
[](README.pt-BR.md)
|
||||
[](README.md)
|
||||
[](README.ru.md)
|
||||
[](README.uk-UA.md)
|
||||
[](README.be.md)
|
||||
[](README.es.md)
|
||||
[](README.fr.md)
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.nb.md)
|
||||
[](./README.pt-BR.md)
|
||||
[](./README.md)
|
||||
[](./README.ru.md)
|
||||
[](./README.uk-UA.md)
|
||||
[](./README.be.md)
|
||||
[](./README.es.md)
|
||||
[](./README.fr.md)
|
||||
[](./README.de.md)
|
||||
[](./README.it.md)
|
||||
[](./README.cs.md)
|
||||
[](./README.da.md)
|
||||
[](./README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra - гэта гульнявы лаўнчар з уласным убудаваным кліентам BitTorrent і самастойным scraper`ам для рэпакаў.</strong>
|
||||
<strong>Hydra - гэта гульнявы лаўнчар з уласным убудаваным кліентам BitTorrent.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -140,9 +141,8 @@ pip install -r requirements.txt
|
||||
## Пераменныя асяроддзі
|
||||
|
||||
Вам спатрэбіцца ключ API SteamGridDB, каб атрымаць значкі гульняў пры ўсталёўкі.
|
||||
Калі вы жадаеце выкарыстоўваць onlinefix у якасці рэпака, вам трэба дадаць вашыя ўліковыя дадзеныя ў файл .env.
|
||||
|
||||
Як толькі вы атрымаеце ключ, вы зможаце скапіяваць або пераназваць файл `.env.example` у `.env` і змясціць у яго `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Як толькі вы атрымаеце ключ, вы зможаце скапіяваць або пераназваць файл `.env.example` у `.env` і змясціць у яго `STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Запуск
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
186
docs/README.da.md
Normal file
186
docs/README.da.md
Normal 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>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
[](https://github.com/hydralauncher/hydra/releases)
|
||||
|
||||
[](README.pt-BR.md)
|
||||
[](README.md)
|
||||
[](README.ru.md)
|
||||
[](README.uk-UA.md)
|
||||
[](README.be.md)
|
||||
[](README.es.md)
|
||||
[](README.fr.md)
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
|
||||

|
||||
|
||||
</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).
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.fr.md)
|
||||
[](README.de.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra es un launcher de juegos con su propio cliente de bittorrent y gestor propio de repacks.</strong>
|
||||
<strong>Hydra es un launcher de juegos con su propio cliente de bittorrent.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,17 +57,15 @@
|
||||
|
||||
## Acerca de
|
||||
|
||||
**Hydra** es un **Launcher de Juegos** con su propio **Cliente Bittorrent** y **autogestor de Repacks**.
|
||||
**Hydra** es un **Launcher de Juegos** con su propio **Cliente Bittorrent**.
|
||||
<br>
|
||||
El launcher está escrito en TypeScript (Electron) y Python, el cuál se encarga del sistema de torrent usando libtorrent.
|
||||
|
||||
## Caracteristicas
|
||||
|
||||
- Buscador e instalador autogestionado de repacks a través de las páginas más confiables en él [Megahilo](https://www.reddit.com/r/Piracy/wiki/megathread/)
|
||||
- Cliente propio de bittorrent integrado
|
||||
- Integración de How Long To Beat (HLTB) en la página del juego
|
||||
- Customización de rutas de descargas
|
||||
- Notificaciones en actualizaciones a listas de repacks
|
||||
- Soporte a Windows y Linux
|
||||
- En constante actualización
|
||||
- Y mucho más ...
|
||||
@@ -140,9 +139,8 @@ pip install -r requirements.txt
|
||||
## Variables del Entorno
|
||||
|
||||
Necesitas una llave API de SteamGridDB para así poder obtener los íconos de los juegos en la instalación.
|
||||
Si quieres también tener los repacks de onlinefix, necesitarás añadir tus credenciales al .env
|
||||
|
||||
Una vez que los tengas, puedes copiar o renombrar el archivo `.env.example` cómo `.env` y colocarlo en `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Una vez que los tengas, puedes copiar o renombrar el archivo `.env.example` cómo `.env` y colocarlo en `STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Ejecucion
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra est un lanceur de jeux avec son propre client bittorrent intégré et un scraper de repack auto-géré.</strong>
|
||||
<strong>Hydra est un lanceur de jeux avec son propre client bittorrent intégré.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,17 +57,15 @@
|
||||
|
||||
## À propos
|
||||
|
||||
**Hydra** est un **lanceur de jeux** avec son propre **client BitTorrent** intégré et un **scraper de repack auto-géré**.
|
||||
**Hydra** est un **lanceur de jeux** avec son propre **client BitTorrent** intégré.
|
||||
<br>
|
||||
Le lanceur est écrit en TypeScript (Electron) et Python, qui gère le système de torrent en utilisant libtorrent.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Scraper de repack auto-géré parmi tous les sites les plus fiables sur le [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
|
||||
- Client bittorrent intégré
|
||||
- Intégration How Long To Beat (HLTB) sur la page du jeu
|
||||
- Personnalisation des chemins de téléchargement
|
||||
- Notifications de mise à jour de la liste de repack
|
||||
- Support pour Windows et Linux
|
||||
- Constamment mis à jour
|
||||
- Et plus encore ...
|
||||
@@ -140,9 +139,8 @@ pip install -r requirements.txt
|
||||
## Variables d'environnement
|
||||
|
||||
Vous aurez besoin d'une clé API SteamGridDB pour récupérer les icônes de jeux lors de l'installation.
|
||||
Si vous voulez avoir onlinefix comme repacker, vous devrez ajouter vos identifiants au fichier .env.
|
||||
|
||||
Une fois que vous l'avez, vous pouvez copier ou renommer le fichier `.env.example` en `.env` et y mettre `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Une fois que vous l'avez, vous pouvez copier ou renommer le fichier `.env.example` en `.env` et y mettre `STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Lancement
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra è un game launcher con il proprio client bittorrent e autogestore di repacks.</strong>
|
||||
<strong>Hydra è un game launcher con il proprio client bittorrent.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,17 +57,15 @@
|
||||
|
||||
## A proposito
|
||||
|
||||
**Hydra** è un **Game Launcher** con il proprio **Client BitTorrent** e **autogestore di repack**.
|
||||
**Hydra** è un **Game Launcher** con il proprio **Client BitTorrent**.
|
||||
<br>
|
||||
Il launcher è scritto in TypeScript (Electron) and Python, che gestisce il sistema di torrenting appoggiandosi a libtorrent.
|
||||
|
||||
## Caratteristiche
|
||||
|
||||
- Motore di ricerca automatizzato sulle fonti di repack dal [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
|
||||
- Client Bittorrent integrato
|
||||
- Integrazione How Long To Beat (HLTB) nella pagina del gioco
|
||||
- Percorso del download Personalizzato
|
||||
- Notifiche di aggiornamenti sulla list dei repacks
|
||||
- Supporto Windows e Linux
|
||||
- Costantemente Aggiornato
|
||||
- E molto altro ...
|
||||
@@ -140,9 +139,8 @@ pip install -r requirements.txt
|
||||
## Variabili d'ambiente
|
||||
|
||||
Avrai bisogno di una chiave API SteamGridDB per poter caricare le icone di gioco.
|
||||
Se intendi avere onlinefix come repacker dovrai aggiungere le tue credenziali al file .env
|
||||
|
||||
Una volta ottenuta, puoi copiare e rinominare il file `.env.example` a `.env` e metterlo in `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Una volta ottenuta, puoi copiare e rinominare il file `.env.example` a `.env` e metterlo in `STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Esecuzione
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](README.cs.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra - to program uruchamiający gry z własnym wbudowanym klientem bittorrent i samodzielnie zarządzanym repackagerem..</strong>
|
||||
<strong>Hydra - to program uruchamiający gry z własnym wbudowanym klientem bittorrent.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,17 +57,15 @@
|
||||
|
||||
## O nas
|
||||
|
||||
**Hydra** - jest **programem uruchamiającym gry** z wbudowanym **klientem BitTorrent** i **samozarządzającym się repackagerem**.
|
||||
**Hydra** - jest **programem uruchamiającym gry** z wbudowanym **klientem BitTorrent**.
|
||||
<br>
|
||||
Ten launcher jest napisany w TypeScript (Electron) i Pythonie, który współpracuje z systemem torrent przy użyciu libtorrent.
|
||||
|
||||
## Cechy
|
||||
|
||||
- Samodzielnie zarządzany repackager wśród wszystkich najbardziej zaufanych stron na [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/").
|
||||
- Własny wbudowany klient bittorrent
|
||||
- Integracja funkcji How Long To Beat (HLTB) na stronie gry
|
||||
- Personalizacja folderu pobierania
|
||||
- Powiadomienia o aktualizacjach listy repacków
|
||||
- Wsparcie dla systemów Windows i Linux
|
||||
- Stała aktualizacja
|
||||
- I nie tylko ...
|
||||
@@ -144,9 +143,8 @@ pip install -r requirements.txt
|
||||
## Zmienne środowiskowe
|
||||
|
||||
Będziesz potrzebował klucza API SteamGridDB, aby uzyskać ikony gier podczas instalacji.
|
||||
Jeśli chcesz użyć onlinefix jako repackagera, musisz dodać swoje dane uwierzytelniające do .env
|
||||
|
||||
Po jego uzyskaniu można skopiować plik lub zmienić jego nazwę `.env.example` na `.env` i umieść go na`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Po jego uzyskaniu można skopiować plik lub zmienić jego nazwę `.env.example` na `.env` i umieść go na`STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra é um Launcher de Jogos com seu próprio cliente de bittorrent integrado e um wrapper autogerenciado para busca de repacks.</strong>
|
||||
<strong>Hydra é um Launcher de Jogos com seu próprio cliente de bittorrent integrado.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -56,17 +57,15 @@
|
||||
|
||||
## <a name="about"> Sobre
|
||||
|
||||
**Hydra** é um **Launcher de Jogos** com seu próprio **Cliente BitTorrent incorporado** e um **raspador de repack auto-gerenciado**.
|
||||
**Hydra** é um **Launcher de Jogos** com seu próprio **Cliente BitTorrent incorporado**.
|
||||
<br>
|
||||
O launcher é escrito em TypeScript (Electron) e Python, que lida com o sistema de torrent usando libtorrent.
|
||||
|
||||
## <a name="features"> Recursos
|
||||
|
||||
- Wrapper de repacks auto-gerenciado entre todos os sites mais confiáveis no [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
|
||||
- Cliente BitTorrent incorporado próprio
|
||||
- Integração com [How Long To Beat (HLTB)](https://howlongtobeat.com/) na página do jogo
|
||||
- Personalização do caminho de downloads
|
||||
- Notificações de atualização da lista de repacks
|
||||
- Suporte para Windows e Linux
|
||||
- Constantemente atualizado
|
||||
- E mais ...
|
||||
@@ -137,14 +136,13 @@ Instale as dependências Python necessárias usando o pip:
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## <a name="environment-variables"></a> Environment variables
|
||||
## <a name="environment-variables"></a> Variáveis de ambiente
|
||||
|
||||
Você precisará de uma chave da API SteamGridDB para buscar os ícones do jogo durante a instalação.
|
||||
Se você deseja ter o onlinefix como um repacker, precisará adicionar suas credenciais ao arquivo .env.
|
||||
|
||||
Depois de obtê-lo, você pode copiar ou renomear o arquivo `.env.example` para `.env` e inserir `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME` e `ONLINEFIX_PASSWORD`.
|
||||
Depois de obtê-lo, você pode copiar ou renomear o arquivo `.env.example` para `.env` e inserir `STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## <a name="running"></a> Running
|
||||
## <a name="running"></a> Executando
|
||||
|
||||
Uma vez que você tenha configurado tudo, você pode executar o seguinte comando para iniciar tanto o processo Electron quanto o cliente BitTorrent:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra - это игровой лаунчер с собственным встроенным клиентом BitTorrent и самостоятельным scraper`ом для репаков.</strong>
|
||||
<strong>Hydra - это игровой лаунчер с собственным встроенным клиентом BitTorrent.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -140,9 +141,8 @@ pip install -r requirements.txt
|
||||
## Переменные среды
|
||||
|
||||
Вам понадобится ключ API SteamGridDB, чтобы получить значки игр при установке.
|
||||
Если вы хотите использовать onlinefix в качестве репака, вам нужно добавить ваши учетные данные в файл .env.
|
||||
|
||||
Как только у вас будет ключ, вы можете скопировать или переименовать файл `.env.example` в `.env` и поместить в него `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Как только у вас будет ключ, вы можете скопировать или переименовать файл `.env.example` в `.env` и поместить в него `STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Запуск
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1 align="center">Hydra Launcher</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Hydra - це ігровий лаунчер з власним вбудованим bittorrent-клієнтом і самокерованим збирачем репаків.</strong>
|
||||
<strong>Hydra - це ігровий лаунчер з власним вбудованим bittorrent-клієнтом.</strong>
|
||||
</p>
|
||||
|
||||
[](https://github.com/hydralauncher/hydra/actions)
|
||||
@@ -23,9 +23,10 @@
|
||||
[](README.de.md)
|
||||
[](README.it.md)
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -144,9 +145,8 @@ pip install -r requirements.txt
|
||||
## Змінні середовища
|
||||
|
||||
Вам знадобиться ключ API SteamGridDB, щоб отримати іконки ігор під час встановлення.
|
||||
Якщо ви хочете використовувати onlinefix як перепакувальник, вам потрібно додати свої облікові дані до .env
|
||||
|
||||
Отримавши його, ви можете скопіювати або перейменувати файл `.env.example` на `.env`і помістити його на`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
|
||||
Отримавши його, ви можете скопіювати або перейменувати файл `.env.example` на `.env`і помістити його на`STEAMGRIDDB_API_KEY`.
|
||||
|
||||
## Запустіть
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
appId: site.hydralauncher.hydra
|
||||
appId: gg.hydralauncher.hydra
|
||||
productName: Hydra
|
||||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- ludusavi
|
||||
- hydra-download-manager
|
||||
- seeds
|
||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||
|
||||
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "2.1.7",
|
||||
"version": "2.1.7-preview",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -23,7 +23,7 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
@@ -44,15 +44,16 @@
|
||||
"@vanilla-extract/recipes": "^0.5.2",
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.7.7",
|
||||
"better-sqlite3": "^11.2.1",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"check-disk-space": "^3.4.0",
|
||||
"classnames": "^2.5.1",
|
||||
"color": "^4.2.3",
|
||||
"color.js": "^1.2.0",
|
||||
"create-desktop-shortcuts": "^1.11.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"electron-log": "^5.1.4",
|
||||
"electron-updater": "^6.1.8",
|
||||
"dexie": "^4.0.8",
|
||||
"electron-log": "^5.2.0",
|
||||
"electron-updater": "^6.3.4",
|
||||
"fetch-cookie": "^3.0.1",
|
||||
"flexsearch": "^0.7.43",
|
||||
"i18next": "^23.11.2",
|
||||
@@ -71,6 +72,7 @@
|
||||
"react-redux": "^9.1.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"tar": "^7.4.3",
|
||||
"typeorm": "^0.3.20",
|
||||
"user-agents": "^1.1.193",
|
||||
"yaml": "^2.4.1",
|
||||
@@ -87,6 +89,7 @@
|
||||
"@swc/core": "^1.4.16",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/color": "^3.0.6",
|
||||
"@types/folder-hash": "^4.0.4",
|
||||
"@types/jsdom": "^21.1.6",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -98,7 +101,7 @@
|
||||
"@vanilla-extract/vite-plugin": "^4.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^30.3.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"electron-builder": "^25.1.6",
|
||||
"electron-vite": "^2.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
|
||||
49
postinstall.cjs
Normal file
49
postinstall.cjs
Normal file
@@ -0,0 +1,49 @@
|
||||
const { default: axios } = require("axios");
|
||||
const util = require("node:util");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const exec = util.promisify(require("node:child_process").exec);
|
||||
|
||||
const fileName = {
|
||||
win32: "ludusavi-v0.25.0-win64.zip",
|
||||
linux: "ludusavi-v0.25.0-linux.zip",
|
||||
darwin: "ludusavi-v0.25.0-mac.zip",
|
||||
};
|
||||
|
||||
const downloadLudusavi = async () => {
|
||||
if (fs.existsSync("ludusavi")) {
|
||||
console.log("Ludusavi already exists, skipping download...");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileName[process.platform];
|
||||
const downloadUrl = `https://github.com/mtkennerly/ludusavi/releases/download/v0.25.0/${file}`;
|
||||
|
||||
console.log(`Downloading ${file}...`);
|
||||
|
||||
const response = await axios.get(downloadUrl, { responseType: "stream" });
|
||||
|
||||
const stream = response.data.pipe(fs.createWriteStream(file));
|
||||
|
||||
stream.on("finish", async () => {
|
||||
console.log(`Downloaded ${file}, extracting...`);
|
||||
|
||||
const pwd = process.cwd();
|
||||
|
||||
const targetPath = path.join(pwd, "ludusavi");
|
||||
|
||||
await exec(`npx extract-zip ${file} ${targetPath}`);
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(path.join(targetPath, "ludusavi"), 0o755);
|
||||
}
|
||||
|
||||
console.log("Extracted. Renaming folder...");
|
||||
|
||||
console.log(`Extracted ${file}, removing compressed downloaded file...`);
|
||||
fs.rmSync(file);
|
||||
});
|
||||
};
|
||||
|
||||
downloadLudusavi();
|
||||
@@ -1,7 +1,7 @@
|
||||
libtorrent
|
||||
cx_Freeze
|
||||
cx_Logging; sys_platform == 'win32'
|
||||
lief; sys_platform == 'win32'
|
||||
pywin32; sys_platform == 'win32'
|
||||
psutil
|
||||
Pillow
|
||||
requests
|
||||
|
||||
BIN
resources/achievement-sound.mp3
Normal file
BIN
resources/achievement-sound.mp3
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -130,7 +130,22 @@
|
||||
"download": "Download",
|
||||
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
|
||||
"warning": "Warning:",
|
||||
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress."
|
||||
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress.",
|
||||
"achievements": "Achievements",
|
||||
"cloud_save": "Cloud save",
|
||||
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
|
||||
"backups": "Backups",
|
||||
"install_backup": "Install",
|
||||
"delete_backup": "Delete",
|
||||
"create_backup": "New backup",
|
||||
"last_backup_date": "Last backup on {{date}}",
|
||||
"no_backup_preview": "No save games were found for this title",
|
||||
"restoring_backup": "Restoring backup ({{progress}} complete)…",
|
||||
"uploading_backup": "Uploading backup…",
|
||||
"no_backups": "You haven't created any backups for this game yet",
|
||||
"backup_uploaded": "Backup uploaded",
|
||||
"backup_deleted": "Backup deleted",
|
||||
"backup_restored": "Backup restored"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activate Hydra",
|
||||
@@ -226,7 +241,9 @@
|
||||
"repack_count_one": "{{count}} repack added",
|
||||
"repack_count_other": "{{count}} repacks added",
|
||||
"new_update_available": "Version {{version}} available",
|
||||
"restart_to_install_update": "Restart Hydra to install the update"
|
||||
"restart_to_install_update": "Restart Hydra to install the update",
|
||||
"notification_achievement_unlocked_title": "Achievement unlocked for {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Open Hydra",
|
||||
@@ -313,5 +330,8 @@
|
||||
"report_reason_other": "Other",
|
||||
"profile_reported": "Profile reported",
|
||||
"your_friend_code": "Your friend code:"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement unlocked"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,22 @@
|
||||
"download": "Baixar",
|
||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||
"warning": "Aviso:",
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
||||
"achievements": "Conquistas",
|
||||
"cloud_save": "Salvamento em nuvem",
|
||||
"cloud_save_description": "Matenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo",
|
||||
"backups": "Backups",
|
||||
"install_backup": "Restaurar",
|
||||
"delete_backup": "Apagar",
|
||||
"create_backup": "Novo backup",
|
||||
"last_backup_date": "Último backup em {{date}}",
|
||||
"no_backup_preview": "Não foi possível encontrar nenhum salvamento para este jogo",
|
||||
"restoring_backup": "Restaurando backup ({{progress}} concluído)…",
|
||||
"uploading_backup": "Criando backup…",
|
||||
"no_backups": "Você ainda não fez nenhum backup deste jogo",
|
||||
"backup_uploaded": "Backup criado",
|
||||
"backup_deleted": "Backup apagado",
|
||||
"backup_restored": "Backup restaurado"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -168,7 +183,7 @@
|
||||
"enable_download_notifications": "Quando um download for concluído",
|
||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
|
||||
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
||||
"general": "Geral",
|
||||
"behavior": "Comportamento",
|
||||
@@ -317,5 +332,8 @@
|
||||
"report_reason_other": "Outro",
|
||||
"profile_reported": "Perfil reportado",
|
||||
"your_friend_code": "Seu código de amigo:"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Conquista desbloqueada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
"download": "Transferir",
|
||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||
"warning": "Aviso:",
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
||||
"achievements": "Conquistas"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -157,7 +158,7 @@
|
||||
"enable_download_notifications": "Quando uma transferência for concluída",
|
||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
|
||||
"launch_with_system": "Iniciar o Hydra com o sistema",
|
||||
"general": "Geral",
|
||||
"behavior": "Comportamento",
|
||||
@@ -277,5 +278,8 @@
|
||||
"friend_code_copied": "Código de amigo copiado",
|
||||
"image_process_failure": "Falha ao processar a imagem",
|
||||
"your_friend_code": "Seu código de amigo:"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Conquista desbloqueada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import path from "node:path";
|
||||
export const defaultDownloadsPath = app.getPath("downloads");
|
||||
|
||||
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
|
||||
export const databasePath = path.join(databaseDirectory, "hydra.db");
|
||||
export const databasePath = path.join(
|
||||
databaseDirectory,
|
||||
import.meta.env.MAIN_VITE_API_URL.includes("staging")
|
||||
? "hydra_test.db"
|
||||
: "hydra.db"
|
||||
);
|
||||
|
||||
export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
|
||||
|
||||
@@ -12,4 +17,6 @@ export const seedsPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "seeds")
|
||||
: path.join(__dirname, "..", "..", "seeds");
|
||||
|
||||
export const backupsPath = path.join(app.getPath("userData"), "Backups");
|
||||
|
||||
export const appVersion = app.getVersion();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Repack,
|
||||
UserPreferences,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
} from "@main/entity";
|
||||
|
||||
import { databasePath } from "./constants";
|
||||
@@ -21,6 +22,7 @@ export const dataSource = new DataSource({
|
||||
DownloadSource,
|
||||
DownloadQueue,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
],
|
||||
synchronize: false,
|
||||
database: databasePath,
|
||||
|
||||
19
src/main/entity/game-achievements.entity.ts
Normal file
19
src/main/entity/game-achievements.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("game_achievement")
|
||||
export class GameAchievement {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text")
|
||||
objectId: string;
|
||||
|
||||
@Column("text")
|
||||
shop: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
unlockedAchievements: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
achievements: string;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ export * from "./game.entity";
|
||||
export * from "./repack.entity";
|
||||
export * from "./user-preferences.entity";
|
||||
export * from "./game-shop-cache.entity";
|
||||
export * from "./game.entity";
|
||||
export * from "./game-achievements.entity";
|
||||
export * from "./download-source.entity";
|
||||
export * from "./download-queue.entity";
|
||||
export * from "./user-auth";
|
||||
|
||||
@@ -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,16 +26,11 @@ 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,
|
||||
objectId: game.objectId,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
51
src/main/events/catalogue/get-game-achievements.ts
Normal file
51
src/main/events/catalogue/get-game-achievements.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { GameAchievement, GameShop } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gameAchievementRepository } from "@main/repository";
|
||||
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
|
||||
|
||||
const getGameAchievements = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
): Promise<GameAchievement[]> => {
|
||||
const cachedAchievements = await gameAchievementRepository.findOne({
|
||||
where: { objectId, shop },
|
||||
});
|
||||
|
||||
const achievementsData = cachedAchievements?.achievements
|
||||
? JSON.parse(cachedAchievements.achievements)
|
||||
: await getGameAchievementData(objectId, shop);
|
||||
|
||||
const unlockedAchievements = JSON.parse(
|
||||
cachedAchievements?.unlockedAchievements || "[]"
|
||||
) as { name: string; unlockTime: number }[];
|
||||
|
||||
return achievementsData
|
||||
.map((achievementData) => {
|
||||
const unlockedAchiement = unlockedAchievements.find(
|
||||
(localAchievement) => {
|
||||
return (
|
||||
localAchievement.name.toUpperCase() ==
|
||||
achievementData.name.toUpperCase()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (unlockedAchiement) {
|
||||
return {
|
||||
...achievementData,
|
||||
unlocked: true,
|
||||
unlockTime: unlockedAchiement.unlockTime,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...achievementData, unlocked: false, unlockTime: null };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.unlocked && !b.unlocked) return -1;
|
||||
if (!a.unlocked && b.unlocked) return 1;
|
||||
return b.unlockTime - a.unlockTime;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getGameAchievements", getGameAchievements);
|
||||
@@ -7,16 +7,16 @@ import { registerEvent } from "../register-event";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
|
||||
const getLocalizedSteamAppDetails = async (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
language: string
|
||||
): Promise<ShopDetails | null> => {
|
||||
if (language === "english") {
|
||||
return getSteamAppDetails(objectID, language);
|
||||
return getSteamAppDetails(objectId, language);
|
||||
}
|
||||
|
||||
return getSteamAppDetails(objectID, language).then(
|
||||
return getSteamAppDetails(objectId, language).then(
|
||||
async (localizedAppDetails) => {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
@@ -34,26 +34,28 @@ const getLocalizedSteamAppDetails = async (
|
||||
|
||||
const getGameShopDetails = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
language: string
|
||||
): Promise<ShopDetails | null> => {
|
||||
if (shop === "steam") {
|
||||
const cachedData = await gameShopCacheRepository.findOne({
|
||||
where: { objectID, language },
|
||||
where: { objectID: objectId, language },
|
||||
});
|
||||
|
||||
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
|
||||
const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
|
||||
(result) => {
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID,
|
||||
shop: "steam",
|
||||
language,
|
||||
serializedData: JSON.stringify(result),
|
||||
},
|
||||
["objectID"]
|
||||
);
|
||||
if (result) {
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID: objectId,
|
||||
shop: "steam",
|
||||
language,
|
||||
serializedData: JSON.stringify(result),
|
||||
},
|
||||
["objectID"]
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -66,7 +68,7 @@ const getGameShopDetails = async (
|
||||
if (cachedGame) {
|
||||
return {
|
||||
...cachedGame,
|
||||
objectID,
|
||||
objectId,
|
||||
} as ShopDetails;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,11 @@ const getGameStats = async (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
objectId,
|
||||
shop,
|
||||
});
|
||||
|
||||
const response = await HydraApi.get<GameStats>(
|
||||
`/games/stats?${params.toString()}`
|
||||
return HydraApi.get<GameStats>(
|
||||
`/games/stats`,
|
||||
{ objectId, shop },
|
||||
{ needsAuth: false }
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
registerEvent("getGameStats", getGameStats);
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
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 { HydraApi } from "@main/services";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
const getGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
take = 12,
|
||||
cursor = 0
|
||||
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
|
||||
const steamGames = await steamGamesWorker.run(
|
||||
{ limit: take, offset: cursor },
|
||||
{ name: "list" }
|
||||
skip = 0
|
||||
): Promise<CatalogueEntry[]> => {
|
||||
const searchParams = new URLSearchParams({
|
||||
take: take.toString(),
|
||||
skip: skip.toString(),
|
||||
});
|
||||
|
||||
const games = await HydraApi.get<CatalogueEntry[]>(
|
||||
`/games/catalogue?${searchParams.toString()}`,
|
||||
undefined,
|
||||
{ needsAuth: false }
|
||||
);
|
||||
|
||||
const entries = RepacksManager.findRepacksForCatalogueEntries(
|
||||
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
|
||||
);
|
||||
|
||||
return {
|
||||
results: entries,
|
||||
cursor: cursor + entries.length,
|
||||
};
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
cover: steamUrlBuilder.library(game.objectId),
|
||||
}));
|
||||
};
|
||||
|
||||
registerEvent("getGames", getGames);
|
||||
|
||||
@@ -6,14 +6,14 @@ import { gameShopCacheRepository } from "@main/repository";
|
||||
|
||||
const getHowLongToBeat = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
title: string
|
||||
): Promise<HowLongToBeatCategory[] | null> => {
|
||||
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
|
||||
|
||||
const gameShopCache = await gameShopCacheRepository.findOne({
|
||||
where: { objectID, shop },
|
||||
where: { objectID: objectId, shop },
|
||||
});
|
||||
|
||||
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
|
||||
@@ -23,7 +23,7 @@ const getHowLongToBeat = async (
|
||||
|
||||
return searchHowLongToBeatPromise.then(async (response) => {
|
||||
const game = response.data.find(
|
||||
(game) => game.profile_steam === Number(objectID)
|
||||
(game) => game.profile_steam === Number(objectId)
|
||||
);
|
||||
|
||||
if (!game) return null;
|
||||
@@ -31,7 +31,7 @@ const getHowLongToBeat = async (
|
||||
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
shop,
|
||||
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
14
src/main/events/cloud-save/check-game-cloud-sync-support.ts
Normal file
14
src/main/events/cloud-save/check-game-cloud-sync-support.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameShop } from "@types";
|
||||
import { Ludusavi } from "@main/services";
|
||||
|
||||
const checkGameCloudSyncSupport = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const games = await Ludusavi.findGames(shop, objectId);
|
||||
return games.length === 1;
|
||||
};
|
||||
|
||||
registerEvent("checkGameCloudSyncSupport", checkGameCloudSyncSupport);
|
||||
12
src/main/events/cloud-save/delete-game-artifact.ts
Normal file
12
src/main/events/cloud-save/delete-game-artifact.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteGameArtifact = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameArtifactId: string
|
||||
) =>
|
||||
HydraApi.delete<{ ok: boolean }>(
|
||||
`/profile/games/artifacts/${gameArtifactId}`
|
||||
);
|
||||
|
||||
registerEvent("deleteGameArtifact", deleteGameArtifact);
|
||||
139
src/main/events/cloud-save/download-game-artifact.ts
Normal file
139
src/main/events/cloud-save/download-game-artifact.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
|
||||
import fs from "node:fs";
|
||||
import * as tar from "tar";
|
||||
import { registerEvent } from "../register-event";
|
||||
import axios from "axios";
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
import { backupsPath } from "@main/constants";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
import YAML from "yaml";
|
||||
|
||||
export interface LudusaviBackup {
|
||||
files: {
|
||||
[key: string]: {
|
||||
hash: string;
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const replaceLudusaviBackupWithCurrentUser = (
|
||||
gameBackupPath: string,
|
||||
backupHomeDir: string
|
||||
) => {
|
||||
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
|
||||
|
||||
const data = fs.readFileSync(mappingYamlPath, "utf8");
|
||||
const manifest = YAML.parse(data) as {
|
||||
backups: LudusaviBackup[];
|
||||
drives: Record<string, string>;
|
||||
};
|
||||
|
||||
const currentHomeDir = app.getPath("home");
|
||||
|
||||
// TODO: Only works on Windows
|
||||
const usersDirPath = path.join(gameBackupPath, "drive-C", "Users");
|
||||
|
||||
const oldPath = path.join(usersDirPath, path.basename(backupHomeDir));
|
||||
const newPath = path.join(usersDirPath, path.basename(currentHomeDir));
|
||||
|
||||
// Directories are different, rename
|
||||
if (backupHomeDir !== currentHomeDir) {
|
||||
if (fs.existsSync(newPath)) {
|
||||
fs.rmSync(newPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
fs.renameSync(oldPath, newPath);
|
||||
}
|
||||
|
||||
const backups = manifest.backups.map((backup: LudusaviBackup) => {
|
||||
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
|
||||
return {
|
||||
...prev,
|
||||
[key.replace(backupHomeDir, currentHomeDir)]: value,
|
||||
};
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...backup,
|
||||
files,
|
||||
};
|
||||
});
|
||||
|
||||
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
|
||||
};
|
||||
|
||||
const downloadGameArtifact = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
gameArtifactId: string
|
||||
) => {
|
||||
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
|
||||
downloadUrl: string;
|
||||
objectKey: string;
|
||||
homeDir: string;
|
||||
}>(`/profile/games/artifacts/${gameArtifactId}/download`);
|
||||
|
||||
const zipLocation = path.join(app.getPath("userData"), objectKey);
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.rmSync(backupPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios.get(downloadUrl, {
|
||||
responseType: "stream",
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-backup-download-progress-${objectId}-${shop}`,
|
||||
progressEvent
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(zipLocation);
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
writer.on("error", (err) => {
|
||||
logger.error("Failed to write zip", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
fs.mkdirSync(backupPath, { recursive: true });
|
||||
|
||||
writer.on("close", () => {
|
||||
tar
|
||||
.x({
|
||||
file: zipLocation,
|
||||
cwd: backupPath,
|
||||
})
|
||||
.then(async () => {
|
||||
const [game] = await Ludusavi.findGames(shop, objectId);
|
||||
if (!game) throw new Error("Game not found in Ludusavi manifest");
|
||||
|
||||
replaceLudusaviBackupWithCurrentUser(
|
||||
path.join(backupPath, game),
|
||||
path.normalize(homeDir).replace(/\\/g, "/")
|
||||
);
|
||||
|
||||
Ludusavi.restoreBackup(backupPath).then(() => {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-backup-download-complete-${objectId}-${shop}`,
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("downloadGameArtifact", downloadGameArtifact);
|
||||
20
src/main/events/cloud-save/get-game-artifacts.ts
Normal file
20
src/main/events/cloud-save/get-game-artifacts.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import type { GameArtifact, GameShop } from "@types";
|
||||
|
||||
const getGameArtifacts = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
objectId,
|
||||
shop,
|
||||
});
|
||||
|
||||
return HydraApi.get<GameArtifact[]>(
|
||||
`/profile/games/artifacts?${params.toString()}`
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("getGameArtifacts", getGameArtifacts);
|
||||
17
src/main/events/cloud-save/get-game-backup-preview.ts
Normal file
17
src/main/events/cloud-save/get-game-backup-preview.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameShop } from "@types";
|
||||
import { Ludusavi } from "@main/services";
|
||||
import path from "node:path";
|
||||
import { backupsPath } from "@main/constants";
|
||||
|
||||
const getGameBackupPreview = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
return Ludusavi.getBackupPreview(shop, objectId, backupPath);
|
||||
};
|
||||
|
||||
registerEvent("getGameBackupPreview", getGameBackupPreview);
|
||||
87
src/main/events/cloud-save/upload-save-game.ts
Normal file
87
src/main/events/cloud-save/upload-save-game.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import crypto from "node:crypto";
|
||||
import { GameShop } from "@types";
|
||||
import axios from "axios";
|
||||
import os from "node:os";
|
||||
import { backupsPath } from "@main/constants";
|
||||
import { app } from "electron";
|
||||
|
||||
const bundleBackup = async (shop: GameShop, objectId: string) => {
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
await Ludusavi.backupGame(shop, objectId, backupPath);
|
||||
|
||||
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.zip`);
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
gzip: false,
|
||||
file: tarLocation,
|
||||
cwd: backupPath,
|
||||
},
|
||||
["."]
|
||||
);
|
||||
|
||||
return tarLocation;
|
||||
};
|
||||
|
||||
const uploadSaveGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const bundleLocation = await bundleBackup(shop, objectId);
|
||||
|
||||
fs.stat(bundleLocation, async (err, stat) => {
|
||||
if (err) {
|
||||
logger.error("Failed to get zip file stats", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { uploadUrl } = await HydraApi.post<{
|
||||
id: string;
|
||||
uploadUrl: string;
|
||||
}>("/profile/games/artifacts", {
|
||||
artifactLengthInBytes: stat.size,
|
||||
shop,
|
||||
objectId,
|
||||
hostname: os.hostname(),
|
||||
homeDir: path.normalize(app.getPath("home")).replace(/\\/g, "/"),
|
||||
platform: os.platform(),
|
||||
});
|
||||
|
||||
fs.readFile(bundleLocation, async (err, fileBuffer) => {
|
||||
if (err) {
|
||||
logger.error("Failed to read zip file", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await axios.put(uploadUrl, fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/tar",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
console.log(progressEvent);
|
||||
},
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-upload-complete-${objectId}-${shop}`,
|
||||
true
|
||||
);
|
||||
|
||||
fs.rm(bundleLocation, (err) => {
|
||||
if (err) {
|
||||
logger.error("Failed to remove tar file", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("uploadSaveGame", uploadSaveGame);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -1,7 +0,0 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { fetchDownloadSourcesAndUpdate } from "@main/helpers";
|
||||
|
||||
const syncDownloadSources = async (_event: Electron.IpcMainInvokeEvent) =>
|
||||
fetchDownloadSourcesAndUpdate();
|
||||
|
||||
registerEvent("syncDownloadSources", syncDownloadSources);
|
||||
@@ -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);
|
||||
@@ -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 {
|
||||
@@ -13,11 +12,10 @@ export interface SearchGamesArgs {
|
||||
export const convertSteamGameToCatalogueEntry = (
|
||||
game: SteamGame
|
||||
): CatalogueEntry => ({
|
||||
objectID: String(game.id),
|
||||
objectId: String(game.id),
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
),
|
||||
});
|
||||
@@ -7,9 +7,9 @@ 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";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/create-game-shortcut";
|
||||
@@ -37,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";
|
||||
@@ -60,6 +57,13 @@ import "./profile/update-profile";
|
||||
import "./profile/process-profile-image";
|
||||
import "./profile/send-friend-request";
|
||||
import "./profile/sync-friend-requests";
|
||||
import "./cloud-save/download-game-artifact";
|
||||
import "./cloud-save/get-game-artifacts";
|
||||
import "./cloud-save/get-game-backup-preview";
|
||||
import "./cloud-save/upload-save-game";
|
||||
import "./cloud-save/check-game-cloud-sync-support";
|
||||
import "./cloud-save/delete-game-artifact";
|
||||
import "./notifications/publish-new-repacks-notification";
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
ipcMain.handle("ping", () => "pong");
|
||||
|
||||
@@ -3,22 +3,22 @@ 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";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
|
||||
|
||||
const addGameToLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
title: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
return gameRepository
|
||||
.update(
|
||||
{
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
},
|
||||
{
|
||||
shop,
|
||||
@@ -28,31 +28,27 @@ const addGameToLibrary = async (
|
||||
)
|
||||
.then(async ({ affected }) => {
|
||||
if (!affected) {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||
? 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: objectId,
|
||||
shop,
|
||||
});
|
||||
}
|
||||
|
||||
const game = await gameRepository.findOne({ where: { objectID } });
|
||||
const game = await gameRepository.findOne({
|
||||
where: { objectID: objectId },
|
||||
});
|
||||
|
||||
updateLocalUnlockedAchivements(game!);
|
||||
|
||||
createGame(game!).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,15 @@ import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getGameByObjectID = async (
|
||||
const getGameByObjectId = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string
|
||||
objectId: string
|
||||
) =>
|
||||
gameRepository.findOne({
|
||||
where: {
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
registerEvent("getGameByObjectID", getGameByObjectID);
|
||||
registerEvent("getGameByObjectId", getGameByObjectId);
|
||||
|
||||
@@ -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);
|
||||
@@ -1,9 +1,17 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import { FriendRequestSync } from "@types";
|
||||
|
||||
const syncFriendRequests = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`);
|
||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`).catch(
|
||||
(err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return { friendRequests: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("syncFriendRequests", syncFriendRequests);
|
||||
|
||||
@@ -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: objectId,
|
||||
shop,
|
||||
},
|
||||
});
|
||||
|
||||
await DownloadManager.pauseDownload();
|
||||
|
||||
@@ -63,39 +51,29 @@ const startGameDownload = async (
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||
? 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: objectId,
|
||||
downloader,
|
||||
shop,
|
||||
status: "active",
|
||||
downloadPath,
|
||||
uri,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedGame = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import { UserBlocks } from "@types";
|
||||
|
||||
export const getBlockedUsers = async (
|
||||
@@ -7,7 +8,12 @@ export const getBlockedUsers = async (
|
||||
take: number,
|
||||
skip: number
|
||||
): Promise<UserBlocks> => {
|
||||
return HydraApi.get(`/profile/blocks`, { take, skip });
|
||||
return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return { blocks: [] };
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getBlockedUsers", getBlockedUsers);
|
||||
|
||||
@@ -73,7 +73,6 @@ const getUser = async (
|
||||
recentGames,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -103,6 +102,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
WindowManager.createMainWindow();
|
||||
WindowManager.createNotificationWindow();
|
||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
|
||||
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
||||
import { app } from "electron";
|
||||
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
||||
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
|
||||
|
||||
export type HydraMigration = Knex.Migration & { name: string };
|
||||
|
||||
@@ -17,6 +18,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
||||
UpdateUserLanguage,
|
||||
EnsureRepackUris,
|
||||
FixMissingColumns,
|
||||
CreateGameAchievement,
|
||||
]);
|
||||
}
|
||||
getMigrationName(migration: HydraMigration): string {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { HydraMigration } from "@main/knex-client";
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export const CreateGameAchievement: HydraMigration = {
|
||||
name: "CreateGameAchievement",
|
||||
up: (knex: Knex) => {
|
||||
return knex.schema.createTable("game_achievement", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.text("objectId").notNullable();
|
||||
table.text("shop").notNullable();
|
||||
table.text("achievements");
|
||||
table.text("unlockedAchievements");
|
||||
table.unique(["objectId", "shop"]);
|
||||
});
|
||||
},
|
||||
|
||||
down: (knex: Knex) => {
|
||||
return knex.schema.dropTable("game_achievement");
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Repack,
|
||||
UserPreferences,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
} from "@main/entity";
|
||||
|
||||
export const gameRepository = dataSource.getRepository(Game);
|
||||
@@ -24,3 +25,6 @@ export const downloadSourceRepository =
|
||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||
|
||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||
|
||||
export const gameAchievementRepository =
|
||||
dataSource.getRepository(GameAchievement);
|
||||
|
||||
123
src/main/services/achievements/achievement-watcher.ts
Normal file
123
src/main/services/achievements/achievement-watcher.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { parseAchievementFile } from "./parse-achievement-file";
|
||||
import { Game } from "@main/entity";
|
||||
import { mergeAchievements } from "./merge-achievements";
|
||||
import fs, { readdirSync } from "node:fs";
|
||||
import {
|
||||
findAchievementFileInExecutableDirectory,
|
||||
findAllAchievementFiles,
|
||||
getAlternativeObjectIds,
|
||||
} from "./find-achivement-files";
|
||||
import type { AchievementFile } from "@types";
|
||||
import { achievementsLogger, logger } from "../logger";
|
||||
import { Cracker } from "@shared";
|
||||
|
||||
const fileStats: Map<string, number> = new Map();
|
||||
const fltFiles: Map<string, Set<string>> = new Map();
|
||||
|
||||
export const watchAchievements = async () => {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (games.length === 0) return;
|
||||
|
||||
const achievementFiles = findAllAchievementFiles();
|
||||
|
||||
for (const game of games) {
|
||||
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||
const gameAchievementFiles = achievementFiles.get(objectId) || [];
|
||||
const achievementFileInsideDirectory =
|
||||
findAchievementFileInExecutableDirectory(game);
|
||||
|
||||
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||
|
||||
if (!gameAchievementFiles.length) continue;
|
||||
|
||||
console.log(
|
||||
"Achievements files to observe for:",
|
||||
game.title,
|
||||
gameAchievementFiles
|
||||
);
|
||||
|
||||
for (const file of gameAchievementFiles) {
|
||||
compareFile(game, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processAchievementFileDiff = async (
|
||||
game: Game,
|
||||
file: AchievementFile
|
||||
) => {
|
||||
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
|
||||
|
||||
logger.log("Achievements from file", file.filePath, unlockedAchievements);
|
||||
|
||||
if (unlockedAchievements.length) {
|
||||
return mergeAchievements(
|
||||
game.objectID,
|
||||
game.shop,
|
||||
unlockedAchievements,
|
||||
true
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const compareFltFolder = async (game: Game, file: AchievementFile) => {
|
||||
try {
|
||||
const currentAchievements = new Set(readdirSync(file.filePath));
|
||||
const previousAchievements = fltFiles.get(file.filePath);
|
||||
|
||||
fltFiles.set(file.filePath, currentAchievements);
|
||||
if (
|
||||
!previousAchievements ||
|
||||
currentAchievements.difference(previousAchievements).size === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("Detected change in FLT folder", file.filePath);
|
||||
await processAchievementFileDiff(game, file);
|
||||
} catch (err) {
|
||||
achievementsLogger.error(err);
|
||||
fltFiles.set(file.filePath, new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const compareFile = async (game: Game, file: AchievementFile) => {
|
||||
if (file.type === Cracker.flt) {
|
||||
await compareFltFolder(game, file);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentStat = fs.statSync(file.filePath);
|
||||
const previousStat = fileStats.get(file.filePath);
|
||||
fileStats.set(file.filePath, currentStat.mtimeMs);
|
||||
|
||||
if (!previousStat) {
|
||||
if (currentStat.mtimeMs) {
|
||||
await processAchievementFileDiff(game, file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (previousStat === currentStat.mtimeMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(
|
||||
"Detected change in file",
|
||||
file.filePath,
|
||||
currentStat.mtimeMs,
|
||||
fileStats.get(file.filePath)
|
||||
);
|
||||
await processAchievementFileDiff(game, file);
|
||||
} catch (err) {
|
||||
fileStats.set(file.filePath, -1);
|
||||
}
|
||||
};
|
||||
264
src/main/services/achievements/find-achivement-files.ts
Normal file
264
src/main/services/achievements/find-achivement-files.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { app } from "electron";
|
||||
import type { AchievementFile } from "@types";
|
||||
import { Cracker } from "@shared";
|
||||
import { Game } from "@main/entity";
|
||||
import { achievementsLogger } from "../logger";
|
||||
|
||||
//TODO: change to a automatized method
|
||||
const publicDocuments = path.join("C:", "Users", "Public", "Documents");
|
||||
const programData = path.join("C:", "ProgramData");
|
||||
const appData = app.getPath("appData");
|
||||
const documents = app.getPath("documents");
|
||||
const localAppData = path.join(appData, "..", "Local");
|
||||
|
||||
const crackers = [
|
||||
Cracker.codex,
|
||||
Cracker.goldberg,
|
||||
Cracker.rune,
|
||||
Cracker.onlineFix,
|
||||
Cracker.userstats,
|
||||
Cracker.rld,
|
||||
Cracker.creamAPI,
|
||||
Cracker.skidrow,
|
||||
Cracker.smartSteamEmu,
|
||||
Cracker.empress,
|
||||
Cracker.flt,
|
||||
];
|
||||
|
||||
const getPathFromCracker = (cracker: Cracker) => {
|
||||
if (cracker === Cracker.codex) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(publicDocuments, "Steam", "CODEX"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(appData, "Steam", "CODEX"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.rune) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(publicDocuments, "Steam", "RUNE"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.onlineFix) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(publicDocuments, Cracker.onlineFix),
|
||||
fileLocation: ["Stats", "Achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.goldberg) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "Goldberg SteamEmu Saves"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(appData, "GSE Saves"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.userstats) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.rld) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(programData, "RLD!"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(programData, "Steam", "Player"),
|
||||
fileLocation: ["stats", "achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(programData, "Steam", "dodi"),
|
||||
fileLocation: ["stats", "achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.empress) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "EMPRESS", "remote"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(publicDocuments, "EMPRESS", "remote"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.skidrow) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(documents, "SKIDROW"),
|
||||
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(documents, "Player"),
|
||||
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(localAppData, "SKIDROW"),
|
||||
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.creamAPI) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "CreamAPI"),
|
||||
fileLocation: ["stats", "CreamAPI.Achievements.cfg"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.smartSteamEmu) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "SmartSteamEmu"),
|
||||
fileLocation: ["User", "Achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker._3dm) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.flt) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "FLT"),
|
||||
fileLocation: ["stats"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker == Cracker.rle) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "RLE"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(appData, "RLE"),
|
||||
fileLocation: ["Achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
achievementsLogger.error(`Cracker ${cracker} not implemented`);
|
||||
throw new Error(`Cracker ${cracker} not implemented`);
|
||||
};
|
||||
|
||||
export const getAlternativeObjectIds = (objectId: string) => {
|
||||
// Dishonored
|
||||
if (objectId === "205100") {
|
||||
return ["205100", "217980", "31292"];
|
||||
}
|
||||
|
||||
return [objectId];
|
||||
};
|
||||
|
||||
export const findAchievementFiles = (game: Game) => {
|
||||
const achievementFiles: AchievementFile[] = [];
|
||||
|
||||
for (const cracker of crackers) {
|
||||
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
|
||||
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||
const filePath = path.join(folderPath, objectId, ...fileLocation);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
achievementFiles.push({
|
||||
type: cracker,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return achievementFiles;
|
||||
};
|
||||
|
||||
export const findAchievementFileInExecutableDirectory = (
|
||||
game: Game
|
||||
): AchievementFile[] => {
|
||||
if (!game.executablePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: Cracker.userstats,
|
||||
filePath: path.join(
|
||||
game.executablePath,
|
||||
"..",
|
||||
"SteamData",
|
||||
"user_stats.ini"
|
||||
),
|
||||
},
|
||||
{
|
||||
type: Cracker._3dm,
|
||||
filePath: path.join(
|
||||
game.executablePath,
|
||||
"..",
|
||||
"3DMGAME",
|
||||
"Player",
|
||||
"stats",
|
||||
"achievements.ini"
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const findAllAchievementFiles = () => {
|
||||
const gameAchievementFiles = new Map<string, AchievementFile[]>();
|
||||
|
||||
for (const cracker of crackers) {
|
||||
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectIds = fs.readdirSync(folderPath);
|
||||
|
||||
for (const objectId of objectIds) {
|
||||
const filePath = path.join(folderPath, objectId, ...fileLocation);
|
||||
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const achivementFile = {
|
||||
type: cracker,
|
||||
filePath,
|
||||
};
|
||||
|
||||
gameAchievementFiles.get(objectId)
|
||||
? gameAchievementFiles.get(objectId)!.push(achivementFile)
|
||||
: gameAchievementFiles.set(objectId, [achivementFile]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gameAchievementFiles;
|
||||
};
|
||||
33
src/main/services/achievements/get-game-achievement-data.ts
Normal file
33
src/main/services/achievements/get-game-achievement-data.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
gameAchievementRepository,
|
||||
userPreferencesRepository,
|
||||
} from "@main/repository";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
|
||||
export const getGameAchievementData = async (
|
||||
objectId: string,
|
||||
shop: string
|
||||
) => {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
return HydraApi.get("/games/achievements", {
|
||||
shop,
|
||||
objectId,
|
||||
language: userPreferences?.language || "en",
|
||||
})
|
||||
.then(async (achievements) => {
|
||||
await gameAchievementRepository.upsert(
|
||||
{
|
||||
objectId,
|
||||
shop,
|
||||
achievements: JSON.stringify(achievements),
|
||||
},
|
||||
["objectId", "shop"]
|
||||
);
|
||||
|
||||
return achievements;
|
||||
})
|
||||
.catch(() => []);
|
||||
};
|
||||
118
src/main/services/achievements/merge-achievements.ts
Normal file
118
src/main/services/achievements/merge-achievements.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { gameAchievementRepository, gameRepository } from "@main/repository";
|
||||
import type { GameShop, UnlockedAchievement } from "@types";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
|
||||
const saveAchievementsOnLocal = async (
|
||||
objectId: string,
|
||||
shop: string,
|
||||
achievements: any[]
|
||||
) => {
|
||||
return gameAchievementRepository
|
||||
.upsert(
|
||||
{
|
||||
objectId,
|
||||
shop,
|
||||
unlockedAchievements: JSON.stringify(achievements),
|
||||
},
|
||||
["objectId", "shop"]
|
||||
)
|
||||
.then(() => {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-achievement-unlocked",
|
||||
objectId,
|
||||
shop
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const mergeAchievements = async (
|
||||
objectId: string,
|
||||
shop: string,
|
||||
achievements: UnlockedAchievement[],
|
||||
publishNotification: boolean
|
||||
) => {
|
||||
const game = await gameRepository.findOne({
|
||||
where: { objectID: objectId, shop: shop as GameShop },
|
||||
});
|
||||
|
||||
if (!game) return;
|
||||
|
||||
const localGameAchievement = await gameAchievementRepository.findOne({
|
||||
where: {
|
||||
objectId,
|
||||
shop,
|
||||
},
|
||||
});
|
||||
|
||||
const unlockedAchievements = JSON.parse(
|
||||
localGameAchievement?.unlockedAchievements || "[]"
|
||||
).filter((achievement) => achievement.name);
|
||||
|
||||
const newAchievements = achievements
|
||||
.filter((achievement) => {
|
||||
return !unlockedAchievements.some((localAchievement) => {
|
||||
return (
|
||||
localAchievement.name.toUpperCase() === achievement.name.toUpperCase()
|
||||
);
|
||||
});
|
||||
})
|
||||
.map((achievement) => {
|
||||
return {
|
||||
name: achievement.name.toUpperCase(),
|
||||
unlockTime: achievement.unlockTime,
|
||||
};
|
||||
});
|
||||
|
||||
if (newAchievements.length && publishNotification) {
|
||||
const achievementsInfo = newAchievements
|
||||
.sort((a, b) => {
|
||||
return a.unlockTime - b.unlockTime;
|
||||
})
|
||||
.map((achievement) => {
|
||||
return JSON.parse(localGameAchievement?.achievements || "[]").find(
|
||||
(steamAchievement) => {
|
||||
return (
|
||||
achievement.name.toUpperCase() ===
|
||||
steamAchievement.name.toUpperCase()
|
||||
);
|
||||
}
|
||||
);
|
||||
})
|
||||
.filter((achievement) => achievement)
|
||||
.map((achievement) => {
|
||||
return {
|
||||
displayName: achievement.displayName,
|
||||
iconUrl: achievement.icon,
|
||||
};
|
||||
});
|
||||
|
||||
WindowManager.notificationWindow?.webContents.send(
|
||||
"on-achievement-unlocked",
|
||||
objectId,
|
||||
shop,
|
||||
achievementsInfo
|
||||
);
|
||||
}
|
||||
|
||||
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
|
||||
|
||||
if (game?.remoteId) {
|
||||
return HydraApi.put("/profile/games/achievements", {
|
||||
id: game.remoteId,
|
||||
achievements: mergedLocalAchievements,
|
||||
})
|
||||
.then((response) => {
|
||||
return saveAchievementsOnLocal(
|
||||
response.objectId,
|
||||
response.shop,
|
||||
response.achievements
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
|
||||
});
|
||||
}
|
||||
|
||||
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
|
||||
};
|
||||
268
src/main/services/achievements/parse-achievement-file.ts
Normal file
268
src/main/services/achievements/parse-achievement-file.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Cracker } from "@shared";
|
||||
import { UnlockedAchievement } from "@types";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { achievementsLogger } from "../logger";
|
||||
|
||||
export const parseAchievementFile = (
|
||||
filePath: string,
|
||||
type: Cracker
|
||||
): UnlockedAchievement[] => {
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
if (type == Cracker.codex) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.rune) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.onlineFix) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processOnlineFix(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.goldberg) {
|
||||
const parsed = jsonParse(filePath);
|
||||
return processGoldberg(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.userstats) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processUserStats(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.rld) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processRld(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.skidrow) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processSkidrow(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker._3dm) {
|
||||
const parsed = iniParse(filePath);
|
||||
return process3DM(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.flt) {
|
||||
const achievements = readdirSync(filePath);
|
||||
|
||||
return achievements.map((achievement) => {
|
||||
return {
|
||||
name: achievement,
|
||||
unlockTime: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (type === Cracker.creamAPI) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processCreamAPI(parsed);
|
||||
}
|
||||
|
||||
achievementsLogger.log(
|
||||
`Unprocessed ${type} achievements found on ${filePath}`
|
||||
);
|
||||
return [];
|
||||
};
|
||||
|
||||
const iniParse = (filePath: string) => {
|
||||
try {
|
||||
const lines = readFileSync(filePath, "utf-8").split(/[\r\n]+/);
|
||||
|
||||
let objectName = "";
|
||||
const object: Record<string, Record<string, string | number>> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("###") || !line.length) continue;
|
||||
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
objectName = line.slice(1, -1);
|
||||
object[objectName] = {};
|
||||
} else {
|
||||
const [name, ...value] = line.split("=");
|
||||
object[objectName][name.trim()] = value.join("=").trim();
|
||||
}
|
||||
}
|
||||
|
||||
return object;
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error parsing ${filePath}`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const jsonParse = (filePath: string) => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error parsing ${filePath}`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.achieved) {
|
||||
parsedUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement.timestamp * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processCreamAPI = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.achieved) {
|
||||
const unlockTime = unlockedAchievement.unlocktime;
|
||||
parsedUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime:
|
||||
unlockTime.length === 7
|
||||
? unlockTime * 1000 * 1000
|
||||
: unlockTime * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processSkidrow = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
const achievements = unlockedAchievements["Achievements"];
|
||||
|
||||
for (const achievement of Object.keys(achievements)) {
|
||||
const unlockedAchievement = achievements[achievement].split("@");
|
||||
|
||||
if (unlockedAchievement[0] === "1") {
|
||||
parsedUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement[unlockedAchievement.length - 1] * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.earned) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement.earned_time * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
const achievements = unlockedAchievements["State"];
|
||||
const times = unlockedAchievements["Time"];
|
||||
|
||||
for (const achievement of Object.keys(achievements)) {
|
||||
if (achievements[achievement] == "0101") {
|
||||
const time = times[achievement];
|
||||
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime:
|
||||
new DataView(
|
||||
new Uint8Array(Buffer.from(time.toString(), "hex")).buffer
|
||||
).getUint32(0, true) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.Achieved) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement.UnlockTime * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processRld = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
if (achievement === "Steam") continue;
|
||||
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.State) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime:
|
||||
new DataView(
|
||||
new Uint8Array(
|
||||
Buffer.from(unlockedAchievement.Time.toString(), "hex")
|
||||
).buffer
|
||||
).getUint32(0, true) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processUserStats = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
const achievements = unlockedAchievements["ACHIEVEMENTS"];
|
||||
|
||||
if (!achievements) return [];
|
||||
|
||||
for (const achievement of Object.keys(achievements)) {
|
||||
const unlockedAchievement = achievements[achievement];
|
||||
|
||||
const unlockTime = Number(
|
||||
unlockedAchievement.slice(1, -1).replace("unlocked = true, time = ", "")
|
||||
);
|
||||
|
||||
if (!isNaN(unlockTime)) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement.replace(/"/g, ``),
|
||||
unlockTime: unlockTime * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { gameAchievementRepository, gameRepository } from "@main/repository";
|
||||
import {
|
||||
findAllAchievementFiles,
|
||||
findAchievementFiles,
|
||||
findAchievementFileInExecutableDirectory,
|
||||
getAlternativeObjectIds,
|
||||
} from "./find-achivement-files";
|
||||
import { parseAchievementFile } from "./parse-achievement-file";
|
||||
import { mergeAchievements } from "./merge-achievements";
|
||||
import type { UnlockedAchievement } from "@types";
|
||||
import { getGameAchievementData } from "./get-game-achievement-data";
|
||||
import { achievementsLogger } from "../logger";
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
export const updateAllLocalUnlockedAchievements = async () => {
|
||||
const gameAchievementFilesMap = findAllAchievementFiles();
|
||||
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const game of games) {
|
||||
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||
const gameAchievementFiles = gameAchievementFilesMap.get(objectId) || [];
|
||||
const achievementFileInsideDirectory =
|
||||
findAchievementFileInExecutableDirectory(game);
|
||||
|
||||
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||
|
||||
gameAchievementRepository
|
||||
.findOne({
|
||||
where: { objectId: game.objectID, shop: "steam" },
|
||||
})
|
||||
.then((localAchievements) => {
|
||||
if (!localAchievements || !localAchievements.achievements) {
|
||||
getGameAchievementData(game.objectID, "steam");
|
||||
}
|
||||
});
|
||||
|
||||
const unlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievementFile of gameAchievementFiles) {
|
||||
const parsedAchievements = parseAchievementFile(
|
||||
achievementFile.filePath,
|
||||
achievementFile.type
|
||||
);
|
||||
|
||||
if (parsedAchievements.length) {
|
||||
unlockedAchievements.push(...parsedAchievements);
|
||||
}
|
||||
|
||||
achievementsLogger.log(
|
||||
"Achievement file for",
|
||||
game.title,
|
||||
achievementFile.filePath,
|
||||
parsedAchievements
|
||||
);
|
||||
}
|
||||
|
||||
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateLocalUnlockedAchivements = async (game: Game) => {
|
||||
const gameAchievementFiles = findAchievementFiles(game);
|
||||
|
||||
const achievementFileInsideDirectory =
|
||||
findAchievementFileInExecutableDirectory(game);
|
||||
|
||||
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||
|
||||
console.log("Achievements files for", game.title, gameAchievementFiles);
|
||||
|
||||
const unlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievementFile of gameAchievementFiles) {
|
||||
const localAchievementFile = parseAchievementFile(
|
||||
achievementFile.filePath,
|
||||
achievementFile.type
|
||||
);
|
||||
|
||||
if (localAchievementFile.length) {
|
||||
unlockedAchievements.push(...localAchievementFile);
|
||||
}
|
||||
}
|
||||
|
||||
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
|
||||
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
|
||||
|
||||
@@ -87,60 +88,66 @@ export class HydraApi {
|
||||
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
|
||||
});
|
||||
|
||||
this.instance.interceptors.request.use(
|
||||
(request) => {
|
||||
logger.log(" ---- REQUEST -----");
|
||||
const data = Array.isArray(request.data)
|
||||
? request.data
|
||||
: omit(request.data, ["refreshToken"]);
|
||||
logger.log(request.method, request.url, request.params, data);
|
||||
return request;
|
||||
},
|
||||
(error) => {
|
||||
logger.error("request error", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.log(" ---- RESPONSE -----");
|
||||
const data = Array.isArray(response.data)
|
||||
? response.data
|
||||
: omit(response.data, ["username", "accessToken", "refreshToken"]);
|
||||
logger.log(
|
||||
response.status,
|
||||
response.config.method,
|
||||
response.config.url,
|
||||
data
|
||||
);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
logger.error(" ---- RESPONSE ERROR -----");
|
||||
|
||||
const { config } = error;
|
||||
|
||||
logger.error(
|
||||
config.method,
|
||||
config.baseURL,
|
||||
config.url,
|
||||
config.headers,
|
||||
config.data
|
||||
);
|
||||
|
||||
if (error.response) {
|
||||
logger.error("Response", error.response.status, error.response.data);
|
||||
} else if (error.request) {
|
||||
logger.error("Request", error.request);
|
||||
} else {
|
||||
logger.error("Error", error.message);
|
||||
if (this.ADD_LOG_INTERCEPTOR) {
|
||||
this.instance.interceptors.request.use(
|
||||
(request) => {
|
||||
logger.log(" ---- REQUEST -----");
|
||||
const data = Array.isArray(request.data)
|
||||
? request.data
|
||||
: omit(request.data, ["refreshToken"]);
|
||||
logger.log(request.method, request.url, request.params, data);
|
||||
return request;
|
||||
},
|
||||
(error) => {
|
||||
logger.error("request error", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
logger.error(" ----- END RESPONSE ERROR -------");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.log(" ---- RESPONSE -----");
|
||||
const data = Array.isArray(response.data)
|
||||
? response.data
|
||||
: omit(response.data, ["username", "accessToken", "refreshToken"]);
|
||||
logger.log(
|
||||
response.status,
|
||||
response.config.method,
|
||||
response.config.url,
|
||||
data
|
||||
);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
logger.error(" ---- RESPONSE ERROR -----");
|
||||
|
||||
const { config } = error;
|
||||
|
||||
logger.error(
|
||||
config.method,
|
||||
config.baseURL,
|
||||
config.url,
|
||||
config.headers,
|
||||
config.data
|
||||
);
|
||||
|
||||
if (error.response) {
|
||||
logger.error(
|
||||
"Response",
|
||||
error.response.status,
|
||||
error.response.data
|
||||
);
|
||||
} else if (error.request) {
|
||||
logger.error("Request", error.request);
|
||||
} else {
|
||||
logger.error("Error", error.message);
|
||||
}
|
||||
|
||||
logger.error(" ----- END RESPONSE ERROR -------");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const userAuth = await userAuthRepository.findOne({
|
||||
where: { id: 1 },
|
||||
|
||||
@@ -7,5 +7,5 @@ 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";
|
||||
export * from "./ludusavi";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsNull } from "typeorm";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { updateAllLocalUnlockedAchievements } from "../achievements/update-local-unlocked-achivements";
|
||||
|
||||
export const uploadGamesBatch = async () => {
|
||||
const games = await gameRepository.find({
|
||||
@@ -28,6 +29,8 @@ export const uploadGamesBatch = async () => {
|
||||
|
||||
await mergeWithRemoteGames();
|
||||
|
||||
await updateAllLocalUnlockedAchievements();
|
||||
|
||||
if (WindowManager.mainWindow)
|
||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ log.transports.file.resolvePathFn = (
|
||||
return path.join(logsPath, "pythoninstance.txt");
|
||||
}
|
||||
|
||||
if (message?.scope == "achievements") {
|
||||
return path.join(logsPath, "achievements.txt");
|
||||
}
|
||||
|
||||
if (message?.level === "error") {
|
||||
return path.join(logsPath, "error.txt");
|
||||
}
|
||||
@@ -29,3 +33,4 @@ log.initialize();
|
||||
|
||||
export const pythonInstanceLogger = log.scope("python-instance");
|
||||
export const logger = log.scope("main");
|
||||
export const achievementsLogger = log.scope("achievements");
|
||||
|
||||
63
src/main/services/ludusavi.ts
Normal file
63
src/main/services/ludusavi.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { GameShop, LudusaviBackup } from "@types";
|
||||
import Piscina from "piscina";
|
||||
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
|
||||
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "ludusavi", "ludusavi")
|
||||
: path.join(__dirname, "..", "..", "ludusavi", "ludusavi");
|
||||
|
||||
export class Ludusavi {
|
||||
private static worker = new Piscina({
|
||||
filename: ludusaviWorkerPath,
|
||||
workerData: {
|
||||
binaryPath,
|
||||
},
|
||||
});
|
||||
|
||||
static async findGames(shop: GameShop, objectId: string): Promise<string[]> {
|
||||
const games = await this.worker.run(
|
||||
{ objectId, shop },
|
||||
{ name: "findGames" }
|
||||
);
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
static async backupGame(
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
backupPath: string
|
||||
): Promise<LudusaviBackup> {
|
||||
const games = await this.findGames(shop, objectId);
|
||||
if (!games.length) throw new Error("Game not found");
|
||||
|
||||
return this.worker.run(
|
||||
{ title: games[0], backupPath },
|
||||
{ name: "backupGame" }
|
||||
);
|
||||
}
|
||||
|
||||
static async getBackupPreview(
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
backupPath: string
|
||||
): Promise<LudusaviBackup | null> {
|
||||
const games = await this.findGames(shop, objectId);
|
||||
if (!games.length) return null;
|
||||
|
||||
const backupData = await this.worker.run(
|
||||
{ title: games[0], backupPath, preview: true },
|
||||
{ name: "backupGame" }
|
||||
);
|
||||
|
||||
return backupData;
|
||||
}
|
||||
|
||||
static async restoreBackup(backupPath: string) {
|
||||
return this.worker.run(backupPath, { name: "restoreBackup" });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sleep } from "@main/helpers";
|
||||
import { DownloadManager } from "./download";
|
||||
import { watchProcesses } from "./process-watcher";
|
||||
import { watchAchievements } from "./achievements/achievement-watcher";
|
||||
|
||||
export const startMainLoop = async () => {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
@@ -8,8 +9,9 @@ export const startMainLoop = async () => {
|
||||
await Promise.allSettled([
|
||||
watchProcesses(),
|
||||
DownloadManager.watchDownloads(),
|
||||
watchAchievements(),
|
||||
]);
|
||||
|
||||
await sleep(1000);
|
||||
await sleep(1500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IsNull, Not } from "typeorm";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||
import { GameRunning } from "@types";
|
||||
import type { GameRunning } from "@types";
|
||||
import { PythonInstance } from "./download";
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
@@ -25,12 +25,12 @@ export const watchProcesses = async () => {
|
||||
if (games.length === 0) return;
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
|
||||
const processSet = new Set(processes.map((process) => process.exe));
|
||||
|
||||
for (const game of games) {
|
||||
const executablePath = game.executablePath!;
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
return executablePath == runningProcess.exe;
|
||||
});
|
||||
const gameProcess = processSet.has(executablePath);
|
||||
|
||||
if (gameProcess) {
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const requestSteam250 = async (path: string) => {
|
||||
|
||||
return {
|
||||
title: $title.textContent,
|
||||
objectID: steamGameUrl.split("/").pop(),
|
||||
objectId: steamGameUrl.split("/").pop(),
|
||||
} as Steam250Game;
|
||||
})
|
||||
.filter((game) => game != null);
|
||||
@@ -38,7 +38,7 @@ export const getSteam250List = async () => {
|
||||
).flat();
|
||||
|
||||
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
|
||||
if (item) map.set(item.objectID, item);
|
||||
if (item) map.set(item.objectId, item);
|
||||
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { GameShop } from "@types";
|
||||
import axios from "axios";
|
||||
|
||||
export interface SteamGridResponse {
|
||||
@@ -20,9 +21,9 @@ export interface SteamGridGameResponse {
|
||||
}
|
||||
|
||||
export const getSteamGridData = async (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
path: string,
|
||||
shop: string,
|
||||
shop: GameShop,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<SteamGridResponse> => {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
@@ -32,7 +33,7 @@ export const getSteamGridData = async (
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
|
||||
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectId}?${searchParams.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
|
||||
@@ -58,10 +59,10 @@ export const getSteamGridGameById = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSteamGameClientIcon = async (objectID: string) => {
|
||||
export const getSteamGameClientIcon = async (objectId: string) => {
|
||||
const {
|
||||
data: { id: steamGridGameId },
|
||||
} = await getSteamGridData(objectID, "games", "steam");
|
||||
} = await getSteamGridData(objectId, "games", "steam");
|
||||
|
||||
const steamGridGame = await getSteamGridGameById(steamGridGameId);
|
||||
return steamGridGame.data.platforms.steam.metadata.clienticon;
|
||||
|
||||
@@ -12,11 +12,11 @@ export interface SteamAppDetailsResponse {
|
||||
}
|
||||
|
||||
export const getSteamAppDetails = async (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
language: string
|
||||
) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
appids: objectID,
|
||||
appids: objectId,
|
||||
l: language,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export const getSteamAppDetails = async (
|
||||
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data[objectID].success) return response.data[objectID].data;
|
||||
if (response.data[objectId].success) return response.data[objectId].data;
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -19,8 +19,9 @@ import { HydraApi } from "./hydra-api";
|
||||
|
||||
export class WindowManager {
|
||||
public static mainWindow: Electron.BrowserWindow | null = null;
|
||||
public static notificationWindow: Electron.BrowserWindow | null = null;
|
||||
|
||||
private static loadURL(hash = "") {
|
||||
private static loadMainWindowURL(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"]) {
|
||||
@@ -37,6 +38,21 @@ export class WindowManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static loadNotificationWindowURL() {
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
this.notificationWindow?.loadURL(
|
||||
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
|
||||
);
|
||||
} else {
|
||||
this.notificationWindow?.loadFile(
|
||||
path.join(__dirname, "../renderer/index.html"),
|
||||
{
|
||||
hash: "achievement-notification",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static createMainWindow() {
|
||||
if (this.mainWindow) return;
|
||||
|
||||
@@ -61,7 +77,54 @@ export class WindowManager {
|
||||
show: false,
|
||||
});
|
||||
|
||||
this.loadURL();
|
||||
this.mainWindow.webContents.session.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",
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
if (details.webContentsId !== this.mainWindow?.webContents.id) {
|
||||
return callback(details);
|
||||
}
|
||||
|
||||
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",
|
||||
],
|
||||
};
|
||||
|
||||
if (details.method === "OPTIONS") {
|
||||
return callback({
|
||||
cancel: false,
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
statusLine: "HTTP/1.1 200 OK",
|
||||
});
|
||||
}
|
||||
|
||||
return callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.loadMainWindowURL();
|
||||
this.mainWindow.removeMenu();
|
||||
|
||||
this.mainWindow.on("ready-to-show", () => {
|
||||
@@ -78,9 +141,37 @@ export class WindowManager {
|
||||
app.quit();
|
||||
}
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
WindowManager.mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
public static createNotificationWindow() {
|
||||
this.notificationWindow = new BrowserWindow({
|
||||
transparent: true,
|
||||
maximizable: false,
|
||||
autoHideMenuBar: true,
|
||||
minimizable: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
frame: false,
|
||||
width: 350,
|
||||
height: 104,
|
||||
x: 0,
|
||||
y: 0,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.mjs"),
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.notificationWindow.setIgnoreMouseEvents(true);
|
||||
this.notificationWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
|
||||
this.loadNotificationWindowURL();
|
||||
}
|
||||
|
||||
public static openAuthWindow() {
|
||||
if (this.mainWindow) {
|
||||
const authWindow = new BrowserWindow({
|
||||
@@ -101,6 +192,8 @@ export class WindowManager {
|
||||
|
||||
authWindow.removeMenu();
|
||||
|
||||
if (!app.isPackaged) authWindow.webContents.openDevTools();
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
lng: i18next.language,
|
||||
});
|
||||
@@ -125,14 +218,14 @@ export class WindowManager {
|
||||
|
||||
public static redirect(hash: string) {
|
||||
if (!this.mainWindow) this.createMainWindow();
|
||||
this.loadURL(hash);
|
||||
this.loadMainWindowURL(hash);
|
||||
|
||||
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
|
||||
this.mainWindow?.focus();
|
||||
}
|
||||
|
||||
public static createSystemTray(language: string) {
|
||||
let tray;
|
||||
let tray: Tray;
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const macIcon = nativeImage
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
61
src/main/workers/ludusavi.worker.ts
Normal file
61
src/main/workers/ludusavi.worker.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { GameShop, LudusaviBackup, LudusaviFindResult } from "@types";
|
||||
import cp from "node:child_process";
|
||||
|
||||
import { workerData } from "node:worker_threads";
|
||||
|
||||
const { binaryPath } = workerData;
|
||||
|
||||
export const findGames = ({
|
||||
shop,
|
||||
objectId,
|
||||
}: {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
}) => {
|
||||
const args = ["find", "--api"];
|
||||
|
||||
if (shop === "steam") {
|
||||
args.push("--steam-id", objectId);
|
||||
}
|
||||
|
||||
const result = cp.execFileSync(binaryPath, args);
|
||||
|
||||
const games = JSON.parse(result.toString("utf-8")) as LudusaviFindResult;
|
||||
return Object.keys(games.games);
|
||||
};
|
||||
|
||||
export const backupGame = ({
|
||||
title,
|
||||
backupPath,
|
||||
preview = false,
|
||||
winePrefix,
|
||||
}: {
|
||||
title: string;
|
||||
backupPath: string;
|
||||
preview?: boolean;
|
||||
winePrefix?: string;
|
||||
}) => {
|
||||
const args = ["backup", title, "--api", "--force"];
|
||||
|
||||
if (preview) args.push("--preview");
|
||||
if (backupPath) args.push("--path", backupPath);
|
||||
if (winePrefix) args.push("--wine-prefix", winePrefix);
|
||||
|
||||
const result = cp.execFileSync(binaryPath, args);
|
||||
|
||||
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
|
||||
};
|
||||
|
||||
export const restoreBackup = (backupPath: string) => {
|
||||
const result = cp.execFileSync(binaryPath, [
|
||||
"restore",
|
||||
"--path",
|
||||
backupPath,
|
||||
"--api",
|
||||
"--force",
|
||||
]);
|
||||
|
||||
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
|
||||
};
|
||||
|
||||
// --wine-prefix
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
UpdateProfileRequest,
|
||||
} from "@types";
|
||||
import type { CatalogueCategory } from "@shared";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
/* Torrenting */
|
||||
@@ -37,18 +38,37 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
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),
|
||||
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),
|
||||
getHowLongToBeat: (objectId: string, shop: GameShop, title: string) =>
|
||||
ipcRenderer.invoke("getHowLongToBeat", objectId, shop, title),
|
||||
getGames: (take?: number, skip?: number) =>
|
||||
ipcRenderer.invoke("getGames", take, skip),
|
||||
searchGameRepacks: (query: string) =>
|
||||
ipcRenderer.invoke("searchGameRepacks", query),
|
||||
getGameStats: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
||||
getGameAchievements: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameAchievements", objectId, shop),
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
achievements?: { displayName: string; iconUrl: string }[]
|
||||
) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
achievements?: { displayName: string; iconUrl: string }[]
|
||||
) => cb(objectId, shop, achievements);
|
||||
ipcRenderer.on("on-achievement-unlocked", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-achievement-unlocked", listener);
|
||||
},
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||
@@ -60,17 +80,12 @@ 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) =>
|
||||
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
|
||||
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("addGameToLibrary", objectId, title, shop),
|
||||
createGameShortcut: (id: number) =>
|
||||
ipcRenderer.invoke("createGameShortcut", id),
|
||||
updateExecutablePath: (id: number, executablePath: string) =>
|
||||
@@ -92,8 +107,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
|
||||
deleteGameFolder: (gameId: number) =>
|
||||
ipcRenderer.invoke("deleteGameFolder", gameId),
|
||||
getGameByObjectID: (objectID: string) =>
|
||||
ipcRenderer.invoke("getGameByObjectID", objectID),
|
||||
getGameByObjectId: (objectId: string) =>
|
||||
ipcRenderer.invoke("getGameByObjectId", objectId),
|
||||
onGamesRunning: (
|
||||
cb: (
|
||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||
@@ -115,6 +130,62 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getDiskFreeSpace: (path: string) =>
|
||||
ipcRenderer.invoke("getDiskFreeSpace", path),
|
||||
|
||||
/* Cloud save */
|
||||
uploadSaveGame: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("uploadSaveGame", objectId, shop),
|
||||
downloadGameArtifact: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
gameArtifactId: string
|
||||
) =>
|
||||
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
|
||||
getGameArtifacts: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
|
||||
getGameBackupPreview: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
|
||||
checkGameCloudSyncSupport: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop),
|
||||
deleteGameArtifact: (gameArtifactId: string) =>
|
||||
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
|
||||
onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||
ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(
|
||||
`on-upload-complete-${objectId}-${shop}`,
|
||||
listener
|
||||
);
|
||||
},
|
||||
onBackupDownloadProgress: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: (progress: AxiosProgressEvent) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
progress: AxiosProgressEvent
|
||||
) => cb(progress);
|
||||
ipcRenderer.on(`on-backup-download-progress-${objectId}-${shop}`, listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(
|
||||
`on-backup-download-complete-${objectId}-${shop}`,
|
||||
listener
|
||||
);
|
||||
},
|
||||
onBackupDownloadComplete: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: () => void
|
||||
) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||
ipcRenderer.on(`on-backup-download-complete-${objectId}-${shop}`, listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(
|
||||
`on-backup-download-complete-${objectId}-${shop}`,
|
||||
listener
|
||||
);
|
||||
},
|
||||
|
||||
/* Misc */
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
getVersion: () => ipcRenderer.invoke("getVersion"),
|
||||
@@ -182,4 +253,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.on("on-signout", listener);
|
||||
return () => ipcRenderer.removeListener("on-signout", listener);
|
||||
},
|
||||
|
||||
/* Notifications */
|
||||
publishNewRepacksNotification: (newRepacksCount: number) =>
|
||||
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
|
||||
/>
|
||||
</head>
|
||||
<body style="background-color: #1c1c1c">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -26,6 +26,10 @@ globalStyle("::-webkit-scrollbar-thumb", {
|
||||
borderRadius: "24px",
|
||||
});
|
||||
|
||||
globalStyle("::-webkit-scrollbar-thumb:hover", {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.16)",
|
||||
});
|
||||
|
||||
globalStyle("html, body, #root, main", {
|
||||
height: "100%",
|
||||
});
|
||||
@@ -35,7 +39,6 @@ globalStyle("body", {
|
||||
userSelect: "none",
|
||||
fontFamily: "Noto Sans, sans-serif",
|
||||
fontSize: vars.size.body,
|
||||
background: vars.color.background,
|
||||
color: vars.color.body,
|
||||
margin: "0",
|
||||
});
|
||||
|
||||
@@ -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,9 @@ 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";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@@ -37,8 +40,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 +204,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 +213,55 @@ 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) {
|
||||
logger.info("Migrating download source", downloadSource.url);
|
||||
|
||||
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);
|
||||
logger.info(
|
||||
"Deleted download source from SQLite",
|
||||
downloadSource.url
|
||||
);
|
||||
});
|
||||
|
||||
indexRepacks();
|
||||
channel.close();
|
||||
};
|
||||
}).catch(() => channel.close());
|
||||
}
|
||||
|
||||
downloadSourceMigrationLock.current = false;
|
||||
});
|
||||
}, [indexRepacks]);
|
||||
|
||||
const handleToastClose = useCallback(() => {
|
||||
dispatch(closeToast());
|
||||
}, [dispatch]);
|
||||
|
||||
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
Binary file not shown.
725
src/renderer/src/assets/lottie/cloud.json
Normal file
725
src/renderer/src/assets/lottie/cloud.json
Normal file
@@ -0,0 +1,725 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 400,
|
||||
"h": 400,
|
||||
"nm": "Cloud",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "Layer 6",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [322.789, 202.565, 0],
|
||||
"to": [-1.5, -0.167, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [313.789, 201.565, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-1.5, -0.167, 0]
|
||||
},
|
||||
{ "t": 60, "s": [322.789, 202.565, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -38.564],
|
||||
[38.564, 0],
|
||||
[0, 38.564],
|
||||
[-38.564, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 38.564],
|
||||
[-38.564, 0],
|
||||
[0, -38.564],
|
||||
[38.564, 0]
|
||||
],
|
||||
"v": [
|
||||
[69.827, 0],
|
||||
[0, 69.827],
|
||||
[-69.827, 0],
|
||||
[0, -69.827]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "Layer 5",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [243.704, 202.565, 0],
|
||||
"to": [-1.667, 0, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [233.704, 202.565, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-1.667, 0, 0]
|
||||
},
|
||||
{ "t": 60, "s": [243.704, 202.565, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -38.564],
|
||||
[38.564, 0],
|
||||
[0, 38.564],
|
||||
[-38.564, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 38.564],
|
||||
[-38.564, 0],
|
||||
[0, -38.564],
|
||||
[38.564, 0]
|
||||
],
|
||||
"v": [
|
||||
[69.827, 0],
|
||||
[0, 69.827],
|
||||
[-69.827, 0],
|
||||
[0, -69.827]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "Layer 4",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [260.681, 151.053, 0],
|
||||
"to": [1.333, -1.333, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [268.681, 143.053, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [1.333, -1.333, 0]
|
||||
},
|
||||
{ "t": 60, "s": [260.681, 151.053, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -38.564],
|
||||
[38.564, 0],
|
||||
[0, 38.564],
|
||||
[-38.564, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 38.564],
|
||||
[-38.564, 0],
|
||||
[0, -38.564],
|
||||
[38.564, 0]
|
||||
],
|
||||
"v": [
|
||||
[69.827, 0],
|
||||
[0, 69.827],
|
||||
[-69.827, 0],
|
||||
[0, -69.827]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "Layer 3",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [162.135, 206.563, 0],
|
||||
"to": [-0.833, -0.167, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [157.135, 205.563, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-0.833, -0.167, 0]
|
||||
},
|
||||
{ "t": 60, "s": [162.135, 206.563, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -36.66],
|
||||
[36.66, 0],
|
||||
[0, 36.66],
|
||||
[-36.66, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 36.66],
|
||||
[-36.66, 0],
|
||||
[0, -36.66],
|
||||
[36.66, 0]
|
||||
],
|
||||
"v": [
|
||||
[66.378, 0],
|
||||
[0, 66.378],
|
||||
[-66.378, 0],
|
||||
[0, -66.378]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 6,
|
||||
"ty": 4,
|
||||
"nm": "Layer 2",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [180.178, 132.225, 0],
|
||||
"to": [-0.5, -2.333, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [177.178, 118.225, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-0.5, -2.333, 0]
|
||||
},
|
||||
{ "t": 60, "s": [180.178, 132.225, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -50.068],
|
||||
[50.068, 0],
|
||||
[0, 50.068],
|
||||
[-50.068, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 50.068],
|
||||
[-50.068, 0],
|
||||
[0, -50.068],
|
||||
[50.068, 0]
|
||||
],
|
||||
"v": [
|
||||
[90.655, 0],
|
||||
[0, 90.655],
|
||||
[-90.655, 0],
|
||||
[0, -90.655]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 7,
|
||||
"ty": 4,
|
||||
"nm": "Layer 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [95.756, 208.288, 0],
|
||||
"to": [-1.167, 0, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [88.756, 208.288, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-1.167, 0, 0]
|
||||
},
|
||||
{ "t": 60, "s": [95.756, 208.288, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -35.403],
|
||||
[35.403, 0],
|
||||
[0, 35.403],
|
||||
[-35.403, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 35.403],
|
||||
[-35.403, 0],
|
||||
[0, -35.403],
|
||||
[35.403, 0]
|
||||
],
|
||||
"v": [
|
||||
[64.103, 0],
|
||||
[0, 64.103],
|
||||
[-64.103, 0],
|
||||
[0, -64.103]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 8,
|
||||
"ty": 3,
|
||||
"nm": "Null 1",
|
||||
"parent": 6,
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 0, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [19.822, 67.775, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"props": {}
|
||||
}
|
||||
@@ -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,14 +26,25 @@ 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(() => {
|
||||
if (!stats) {
|
||||
window.electron.getGameStats(game.objectID, game.shop).then((stats) => {
|
||||
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
|
||||
setStats(stats);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppSelector, useUserDetails } from "@renderer/hooks";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
|
||||
const LONG_POLLING_INTERVAL = 60_000;
|
||||
|
||||
@@ -68,6 +69,23 @@ export function SidebarProfile() {
|
||||
);
|
||||
}, [userDetails, t, friendRequestCount, showFriendsModal]);
|
||||
|
||||
const gameRunningDetails = () => {
|
||||
if (!userDetails || !gameRunning) return null;
|
||||
|
||||
if (gameRunning.iconUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={gameRunning.title}
|
||||
width={24}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={gameRunning.iconUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SteamLogo />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.profileContainer}>
|
||||
<button
|
||||
@@ -108,14 +126,7 @@ export function SidebarProfile() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userDetails && gameRunning && (
|
||||
<img
|
||||
alt={gameRunning.title}
|
||||
width={24}
|
||||
style={{ borderRadius: 4 }}
|
||||
src={gameRunning.iconUrl!}
|
||||
/>
|
||||
)}
|
||||
{gameRunningDetails()}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -140,7 +140,10 @@ export function Sidebar() {
|
||||
event: React.MouseEvent,
|
||||
game: LibraryGame
|
||||
) => {
|
||||
const path = buildGameDetailsPath(game);
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectID,
|
||||
});
|
||||
if (path !== location.pathname) {
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
213
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
213
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { gameBackupsTable } from "@renderer/dexie";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { logger } from "@renderer/logger";
|
||||
import type { LudusaviBackup, GameArtifact, GameShop } from "@types";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export enum CloudSyncState {
|
||||
New,
|
||||
Different,
|
||||
Same,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
export interface CloudSyncContext {
|
||||
backupPreview: LudusaviBackup | null;
|
||||
artifacts: GameArtifact[];
|
||||
showCloudSyncModal: boolean;
|
||||
showCloudSyncFilesModal: boolean;
|
||||
supportsCloudSync: boolean | null;
|
||||
backupState: CloudSyncState;
|
||||
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
|
||||
uploadSaveGame: () => Promise<void>;
|
||||
deleteGameArtifact: (gameArtifactId: string) => Promise<void>;
|
||||
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
restoringBackup: boolean;
|
||||
uploadingBackup: boolean;
|
||||
}
|
||||
|
||||
export const cloudSyncContext = createContext<CloudSyncContext>({
|
||||
backupPreview: null,
|
||||
showCloudSyncModal: false,
|
||||
supportsCloudSync: null,
|
||||
backupState: CloudSyncState.Unknown,
|
||||
setShowCloudSyncModal: () => {},
|
||||
downloadGameArtifact: async () => {},
|
||||
uploadSaveGame: async () => {},
|
||||
artifacts: [],
|
||||
deleteGameArtifact: async () => {},
|
||||
showCloudSyncFilesModal: false,
|
||||
setShowCloudSyncFilesModal: () => {},
|
||||
restoringBackup: false,
|
||||
uploadingBackup: false,
|
||||
});
|
||||
|
||||
const { Provider } = cloudSyncContext;
|
||||
export const { Consumer: CloudSyncContextConsumer } = cloudSyncContext;
|
||||
|
||||
export interface CloudSyncContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
}
|
||||
|
||||
export function CloudSyncContextProvider({
|
||||
children,
|
||||
objectId,
|
||||
shop,
|
||||
}: CloudSyncContextProviderProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
const [artifacts, setArtifacts] = useState<GameArtifact[]>([]);
|
||||
const [showCloudSyncModal, setShowCloudSyncModal] = useState(false);
|
||||
const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>(
|
||||
null
|
||||
);
|
||||
const [restoringBackup, setRestoringBackup] = useState(false);
|
||||
const [uploadingBackup, setUploadingBackup] = useState(false);
|
||||
const [showCloudSyncFilesModal, setShowCloudSyncFilesModal] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const downloadGameArtifact = useCallback(
|
||||
async (gameArtifactId: string) => {
|
||||
setRestoringBackup(true);
|
||||
window.electron.downloadGameArtifact(objectId, shop, gameArtifactId);
|
||||
},
|
||||
[objectId, shop]
|
||||
);
|
||||
|
||||
const getGameBackupPreview = useCallback(async () => {
|
||||
window.electron.getGameArtifacts(objectId, shop).then((results) => {
|
||||
setArtifacts(results);
|
||||
});
|
||||
|
||||
window.electron
|
||||
.getGameBackupPreview(objectId, shop)
|
||||
.then((preview) => {
|
||||
logger.info("Game backup preview", objectId, shop, preview);
|
||||
if (preview && Object.keys(preview.games).length) {
|
||||
setBackupPreview(preview);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to get game backup preview", objectId, shop, err);
|
||||
});
|
||||
}, [objectId, shop]);
|
||||
|
||||
const uploadSaveGame = useCallback(async () => {
|
||||
setUploadingBackup(true);
|
||||
window.electron.uploadSaveGame(objectId, shop);
|
||||
}, [objectId, shop]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeUploadCompleteListener = window.electron.onUploadComplete(
|
||||
objectId,
|
||||
shop,
|
||||
() => {
|
||||
showSuccessToast(t("backup_uploaded"));
|
||||
|
||||
setUploadingBackup(false);
|
||||
gameBackupsTable.add({
|
||||
objectId,
|
||||
shop,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
getGameBackupPreview();
|
||||
}
|
||||
);
|
||||
|
||||
const removeDownloadCompleteListener =
|
||||
window.electron.onBackupDownloadComplete(objectId, shop, () => {
|
||||
showSuccessToast(t("backup_restored"));
|
||||
|
||||
setRestoringBackup(false);
|
||||
getGameBackupPreview();
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeUploadCompleteListener();
|
||||
removeDownloadCompleteListener();
|
||||
};
|
||||
}, [objectId, shop, showSuccessToast, t, getGameBackupPreview]);
|
||||
|
||||
const deleteGameArtifact = useCallback(
|
||||
async (gameArtifactId: string) => {
|
||||
return window.electron.deleteGameArtifact(gameArtifactId).then(() => {
|
||||
getGameBackupPreview();
|
||||
});
|
||||
},
|
||||
[getGameBackupPreview]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron
|
||||
.checkGameCloudSyncSupport(objectId, shop)
|
||||
.then((result) => {
|
||||
logger.info("Cloud sync support", objectId, shop, result);
|
||||
setSupportsCloudSync(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to check cloud sync support", err);
|
||||
});
|
||||
}, [objectId, shop, getGameBackupPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
setBackupPreview(null);
|
||||
setArtifacts([]);
|
||||
setSupportsCloudSync(null);
|
||||
setShowCloudSyncModal(false);
|
||||
setRestoringBackup(false);
|
||||
setUploadingBackup(false);
|
||||
}, [objectId, shop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCloudSyncModal) {
|
||||
getGameBackupPreview();
|
||||
}
|
||||
}, [getGameBackupPreview, showCloudSyncModal]);
|
||||
|
||||
const backupState = useMemo(() => {
|
||||
if (!backupPreview) return CloudSyncState.Unknown;
|
||||
if (backupPreview.overall.changedGames.new) return CloudSyncState.New;
|
||||
if (backupPreview.overall.changedGames.different)
|
||||
return CloudSyncState.Different;
|
||||
if (backupPreview.overall.changedGames.same) return CloudSyncState.Same;
|
||||
|
||||
return CloudSyncState.Unknown;
|
||||
}, [backupPreview]);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
supportsCloudSync,
|
||||
backupPreview,
|
||||
showCloudSyncModal,
|
||||
artifacts,
|
||||
backupState,
|
||||
restoringBackup,
|
||||
uploadingBackup,
|
||||
showCloudSyncFilesModal,
|
||||
setShowCloudSyncModal,
|
||||
uploadSaveGame,
|
||||
downloadGameArtifact,
|
||||
deleteGameArtifact,
|
||||
setShowCloudSyncFilesModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
@@ -7,6 +12,7 @@ import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameAchievement,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
@@ -16,6 +22,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,
|
||||
@@ -25,11 +32,12 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
gameTitle: "",
|
||||
isGameRunning: false,
|
||||
isLoading: false,
|
||||
objectID: undefined,
|
||||
objectId: undefined,
|
||||
gameColor: "",
|
||||
showRepacksModal: false,
|
||||
showGameOptionsModal: false,
|
||||
stats: null,
|
||||
achievements: [],
|
||||
hasNSFWContentBlocked: false,
|
||||
setGameColor: () => {},
|
||||
selectGameExecutable: async () => null,
|
||||
@@ -44,15 +52,19 @@ export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
|
||||
|
||||
export interface GameDetailsContextProps {
|
||||
children: React.ReactNode;
|
||||
objectId: string;
|
||||
gameTitle: string;
|
||||
shop: GameShop;
|
||||
}
|
||||
|
||||
export function GameDetailsContextProvider({
|
||||
children,
|
||||
objectId,
|
||||
gameTitle,
|
||||
shop,
|
||||
}: GameDetailsContextProps) {
|
||||
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);
|
||||
|
||||
@@ -64,9 +76,17 @@ export function GameDetailsContextProvider({
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
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");
|
||||
|
||||
@@ -80,9 +100,9 @@ export function GameDetailsContextProvider({
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectID(objectID!)
|
||||
.getGameByObjectId(objectId!)
|
||||
.then((result) => setGame(result));
|
||||
}, [setGame, objectID]);
|
||||
}, [setGame, objectId]);
|
||||
|
||||
const isGameDownloading = lastPacket?.game.id === game?.id;
|
||||
|
||||
@@ -91,47 +111,51 @@ export function GameDetailsContextProvider({
|
||||
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
window.electron.getGameShopDetails(
|
||||
objectID!,
|
||||
window.electron
|
||||
.getGameShopDetails(
|
||||
objectId!,
|
||||
shop as GameShop,
|
||||
getSteamLanguage(i18n.language)
|
||||
),
|
||||
window.electron.searchGameRepacks(gameTitle),
|
||||
window.electron.getGameStats(objectID!, shop as GameShop),
|
||||
])
|
||||
.then(([appDetailsResult, repacksResult, statsResult]) => {
|
||||
if (appDetailsResult.status === "fulfilled") {
|
||||
setShopDetails(appDetailsResult.value);
|
||||
)
|
||||
.then((result) => {
|
||||
setShopDetails(result);
|
||||
|
||||
if (
|
||||
appDetailsResult.value?.content_descriptors.ids.includes(
|
||||
SteamContentDescriptor.AdultOnlySexualContent
|
||||
)
|
||||
) {
|
||||
setHasNSFWContentBlocked(true);
|
||||
}
|
||||
if (
|
||||
result?.content_descriptors.ids.includes(
|
||||
SteamContentDescriptor.AdultOnlySexualContent
|
||||
)
|
||||
) {
|
||||
setHasNSFWContentBlocked(true);
|
||||
}
|
||||
|
||||
if (repacksResult.status === "fulfilled")
|
||||
setRepacks(repacksResult.value);
|
||||
|
||||
if (statsResult.status === "fulfilled") setStats(statsResult.value);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
window.electron.getGameStats(objectId!, shop as GameShop).then((result) => {
|
||||
setStats(result);
|
||||
});
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectId!, shop as GameShop)
|
||||
.then((achievements) => {
|
||||
setAchievements(achievements);
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: handle user not logged in error
|
||||
});
|
||||
|
||||
updateGame();
|
||||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
setShopDetails(null);
|
||||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
setAchievements([]);
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectID, gameTitle, dispatch]);
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||
@@ -150,6 +174,23 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(
|
||||
(objectId, shop) => {
|
||||
if (objectId !== objectId || shop !== shop) return;
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectId!, shop as GameShop)
|
||||
.then(setAchievements)
|
||||
.catch(() => {});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [objectId, shop]);
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
return window.electron.getDefaultDownloadsPath();
|
||||
@@ -188,11 +229,12 @@ export function GameDetailsContextProvider({
|
||||
gameTitle,
|
||||
isGameRunning,
|
||||
isLoading,
|
||||
objectID,
|
||||
objectId,
|
||||
gameColor,
|
||||
showGameOptionsModal,
|
||||
showRepacksModal,
|
||||
stats,
|
||||
achievements,
|
||||
hasNSFWContentBlocked,
|
||||
setHasNSFWContentBlocked,
|
||||
setGameColor,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user