mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-13 03:32:13 +00:00
Merge pull request #1031 from hydralauncher/feature/game-achievements
game achievements
This commit is contained in:
commit
9c595583cd
187 changed files with 9763 additions and 2289 deletions
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
|
@ -40,8 +40,9 @@ jobs:
|
||||||
sudo apt-get install -y libarchive-tools
|
sudo apt-get install -y libarchive-tools
|
||||||
yarn build:linux
|
yarn build:linux
|
||||||
env:
|
env:
|
||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||||
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
@ -50,8 +51,9 @@ jobs:
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
run: yarn build:win
|
run: yarn build:win
|
||||||
env:
|
env:
|
||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
|
||||||
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
@ -61,7 +63,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: Build-${{ matrix.os }}
|
name: Build-${{ matrix.os }}
|
||||||
path: |
|
path: |
|
||||||
dist/win-unpacked/**
|
|
||||||
dist/*-portable.exe
|
dist/*-portable.exe
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/*.dmg
|
dist/*.dmg
|
||||||
|
|
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
|
@ -44,6 +44,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||||
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
@ -54,10 +55,27 @@ jobs:
|
||||||
env:
|
env:
|
||||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||||
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
|
||||||
|
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -1,5 +1,5 @@
|
||||||
.vscode
|
.vscode/
|
||||||
node_modules
|
node_modules/
|
||||||
hydra-download-manager/
|
hydra-download-manager/
|
||||||
fastlist.exe
|
fastlist.exe
|
||||||
__pycache__
|
__pycache__
|
||||||
|
@ -10,3 +10,4 @@ out
|
||||||
.env
|
.env
|
||||||
.vite
|
.vite
|
||||||
sentry.properties
|
sentry.properties
|
||||||
|
ludusavi/
|
24
README.md
24
README.md
|
@ -13,18 +13,18 @@
|
||||||
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
|
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
|
||||||
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
|
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
|
||||||
|
|
||||||
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
|
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](./README.pt-BR.md)
|
||||||
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
|
[![en](https://img.shields.io/badge/lang-en-red.svg)](./README.md)
|
||||||
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
|
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](./README.ru.md)
|
||||||
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
|
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](./README.uk-UA.md)
|
||||||
[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md)
|
[![be](https://img.shields.io/badge/lang-be-orange)](./README.be.md)
|
||||||
[![es](https://img.shields.io/badge/lang-es-red)](README.es.md)
|
[![es](https://img.shields.io/badge/lang-es-red)](./README.es.md)
|
||||||
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
|
[![fr](https://img.shields.io/badge/lang-fr-blue)](./README.fr.md)
|
||||||
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
|
[![de](https://img.shields.io/badge/lang-de-black)](./README.de.md)
|
||||||
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
|
[![ita](https://img.shields.io/badge/lang-it-red)](./README.it.md)
|
||||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
[![cs](https://img.shields.io/badge/lang-cs-purple)](./README.cs.md)
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](./README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](./README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./docs/screenshot.png)
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Katalog](./docs/screenshot.png)
|
![Hydra Katalog](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Katalog](./docs/screenshot.png)
|
![Hydra Katalog](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Catalogue Hydra](./docs/screenshot.png)
|
![Catalogue Hydra](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
|
||||||
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
|
||||||
|
|
||||||
![Hydra Catalogue](./docs/screenshot.png)
|
![Hydra Catalogue](./screenshot.png)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,6 +3,7 @@ productName: Hydra
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
extraResources:
|
extraResources:
|
||||||
|
- ludusavi
|
||||||
- hydra-download-manager
|
- hydra-download-manager
|
||||||
- seeds
|
- seeds
|
||||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||||
|
|
19
package.json
19
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "hydralauncher",
|
"name": "hydralauncher",
|
||||||
"version": "2.1.7",
|
"version": "3.0.0",
|
||||||
"description": "Hydra",
|
"description": "Hydra",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "Los Broxas",
|
"author": "Los Broxas",
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"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:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "electron-vite build && electron-builder --win",
|
"build:win": "electron-vite build && electron-builder --win",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac",
|
"build:mac": "electron-vite build && electron-builder --mac",
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
"@vanilla-extract/recipes": "^0.5.2",
|
"@vanilla-extract/recipes": "^0.5.2",
|
||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"better-sqlite3": "^11.2.1",
|
"better-sqlite3": "^11.3.0",
|
||||||
"check-disk-space": "^3.4.0",
|
"check-disk-space": "^3.4.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
|
@ -52,19 +52,18 @@
|
||||||
"create-desktop-shortcuts": "^1.11.0",
|
"create-desktop-shortcuts": "^1.11.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dexie": "^4.0.8",
|
"dexie": "^4.0.8",
|
||||||
"electron-log": "^5.1.4",
|
"electron-log": "^5.2.0",
|
||||||
"electron-updater": "^6.1.8",
|
"electron-updater": "^6.3.9",
|
||||||
"fetch-cookie": "^3.0.1",
|
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"i18next": "^23.11.2",
|
"i18next": "^23.11.2",
|
||||||
"i18next-browser-languagedetector": "^7.2.1",
|
"i18next-browser-languagedetector": "^7.2.1",
|
||||||
"icojs": "^0.19.3",
|
"icojs": "^0.19.4",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"knex": "^3.1.0",
|
"knex": "^3.1.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"parse-torrent": "^11.0.16",
|
"parse-torrent": "^11.0.17",
|
||||||
"piscina": "^4.5.1",
|
"piscina": "^4.5.1",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^14.1.0",
|
||||||
|
@ -72,6 +71,7 @@
|
||||||
"react-redux": "^9.1.1",
|
"react-redux": "^9.1.1",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"sudo-prompt": "^9.2.1",
|
"sudo-prompt": "^9.2.1",
|
||||||
|
"tar": "^7.4.3",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"user-agents": "^1.1.193",
|
"user-agents": "^1.1.193",
|
||||||
"yaml": "^2.4.1",
|
"yaml": "^2.4.1",
|
||||||
|
@ -88,6 +88,7 @@
|
||||||
"@swc/core": "^1.4.16",
|
"@swc/core": "^1.4.16",
|
||||||
"@types/auto-launch": "^5.0.5",
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/color": "^3.0.6",
|
"@types/color": "^3.0.6",
|
||||||
|
"@types/folder-hash": "^4.0.4",
|
||||||
"@types/jsdom": "^21.1.6",
|
"@types/jsdom": "^21.1.6",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
@ -99,7 +100,7 @@
|
||||||
"@vanilla-extract/vite-plugin": "^4.0.7",
|
"@vanilla-extract/vite-plugin": "^4.0.7",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"electron": "^30.3.0",
|
"electron": "^30.3.0",
|
||||||
"electron-builder": "^24.9.1",
|
"electron-builder": "^25.1.8",
|
||||||
"electron-vite": "^2.0.0",
|
"electron-vite": "^2.0.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.8.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,6 @@
|
||||||
libtorrent
|
libtorrent
|
||||||
cx_Freeze
|
cx_Freeze
|
||||||
cx_Logging; sys_platform == 'win32'
|
cx_Logging; sys_platform == 'win32'
|
||||||
lief; sys_platform == 'win32'
|
|
||||||
pywin32; sys_platform == 'win32'
|
pywin32; sys_platform == 'win32'
|
||||||
psutil
|
psutil
|
||||||
Pillow
|
Pillow
|
||||||
|
|
BIN
resources/achievement-sound.mp3
Normal file
BIN
resources/achievement-sound.mp3
Normal file
Binary file not shown.
|
@ -130,7 +130,38 @@
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
|
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
|
||||||
"warning": "Warning:",
|
"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",
|
||||||
|
"achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}",
|
||||||
|
"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",
|
||||||
|
"see_all_achievements": "See all achievements",
|
||||||
|
"sign_in_to_see_achievements": "Sign in to see achievements",
|
||||||
|
"mapping_method_automatic": "Automatic",
|
||||||
|
"mapping_method_manual": "Manual",
|
||||||
|
"mapping_method_label": "Mapping method",
|
||||||
|
"files_automatically_mapped": "Files automatically mapped",
|
||||||
|
"no_backups_created": "No backups created for this game",
|
||||||
|
"manage_files": "Manage files",
|
||||||
|
"loading_save_preview": "Searching for save games…",
|
||||||
|
"wine_prefix": "Wine Prefix",
|
||||||
|
"wine_prefix_description": "The Wine prefix used to run this game",
|
||||||
|
"no_download_option_info": "No information available",
|
||||||
|
"backup_deletion_failed": "Failed to delete backup",
|
||||||
|
"max_number_of_artifacts_reached": "Maximum number of backups reached for this game",
|
||||||
|
"achievements_not_sync": "Your achievements are not synchronized"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Activate Hydra",
|
"title": "Activate Hydra",
|
||||||
|
@ -217,7 +248,8 @@
|
||||||
"source_already_exists": "This source has been already added",
|
"source_already_exists": "This source has been already added",
|
||||||
"must_be_valid_url": "The source must be a valid URL",
|
"must_be_valid_url": "The source must be a valid URL",
|
||||||
"blocked_users": "Blocked users",
|
"blocked_users": "Blocked users",
|
||||||
"user_unblocked": "User has been unblocked"
|
"user_unblocked": "User has been unblocked",
|
||||||
|
"enable_achievement_notifications": "When an achievement in unlocked"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Download complete",
|
"download_complete": "Download complete",
|
||||||
|
@ -226,7 +258,9 @@
|
||||||
"repack_count_one": "{{count}} repack added",
|
"repack_count_one": "{{count}} repack added",
|
||||||
"repack_count_other": "{{count}} repacks added",
|
"repack_count_other": "{{count}} repacks added",
|
||||||
"new_update_available": "Version {{version}} available",
|
"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": {
|
"system_tray": {
|
||||||
"open": "Open Hydra",
|
"open": "Open Hydra",
|
||||||
|
@ -312,6 +346,26 @@
|
||||||
"report_reason_spam": "Spam",
|
"report_reason_spam": "Spam",
|
||||||
"report_reason_other": "Other",
|
"report_reason_other": "Other",
|
||||||
"profile_reported": "Profile reported",
|
"profile_reported": "Profile reported",
|
||||||
"your_friend_code": "Your friend code:"
|
"your_friend_code": "Your friend code:",
|
||||||
|
"upload_banner": "Upload banner",
|
||||||
|
"uploading_banner": "Uploading banner…"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"achievement_unlocked": "Achievement unlocked",
|
||||||
|
"user_achievements": "{{displayName}}'s Achievements",
|
||||||
|
"your_achievements": "Your Achievements",
|
||||||
|
"unlocked_at": "Unlocked at:",
|
||||||
|
"subscription_needed": "A Hydra Cloud subscription is needed to see this content",
|
||||||
|
"new_achievements_unlocked": "Unlocked {{achievementCount}} new achievements from {{gameCount}} games"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"subscription_tour_title": "Hydra Cloud Subscription",
|
||||||
|
"subscribe_now": "Subscribe now",
|
||||||
|
"cloud_saving": "Cloud saving (up to {{gameCount}} games)",
|
||||||
|
"cloud_achievements": "Save your achievements on the cloud",
|
||||||
|
"animated_profile_picture": "Animated profile pictures",
|
||||||
|
"premium_support": "Premium Support",
|
||||||
|
"show_and_compare_achievements": "Show and compare your achievements to other users",
|
||||||
|
"animated_profile_banner": "Animated profile banner"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"featured": "Destaques",
|
"featured": "Destaques",
|
||||||
"trending": "Populares",
|
"hot": "Populares",
|
||||||
"hot": "Populares agora",
|
|
||||||
"weekly": "📅 Mais baixados da semana",
|
"weekly": "📅 Mais baixados da semana",
|
||||||
|
"achievements": "🏆 Pra platinar",
|
||||||
"surprise_me": "Surpreenda-me",
|
"surprise_me": "Surpreenda-me",
|
||||||
"no_results": "Nenhum resultado encontrado",
|
"no_results": "Nenhum resultado encontrado",
|
||||||
"start_typing": "Comece a digitar para pesquisar…"
|
"start_typing": "Comece a digitar para pesquisar…"
|
||||||
|
@ -126,7 +126,38 @@
|
||||||
"download": "Baixar",
|
"download": "Baixar",
|
||||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||||
"warning": "Aviso:",
|
"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",
|
||||||
|
"achievements_count": "Conquistas ({{unlockedCount}}/{{achievementsCount}})",
|
||||||
|
"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",
|
||||||
|
"see_all_achievements": "Ver todas as conquistas",
|
||||||
|
"sign_in_to_see_achievements": "Faça login para ver as conquistas",
|
||||||
|
"mapping_method_automatic": "Automático",
|
||||||
|
"mapping_method_manual": "Manual",
|
||||||
|
"mapping_method_label": "Método de mapeamento",
|
||||||
|
"files_automatically_mapped": "Arquivos automaticamente mapeados",
|
||||||
|
"no_backups_created": "Nenhum backup criado para este jogo",
|
||||||
|
"manage_files": "Gerenciar arquivos",
|
||||||
|
"loading_save_preview": "Buscando por arquivos de salvamento…",
|
||||||
|
"wine_prefix": "Prefixo Wine",
|
||||||
|
"wine_prefix_description": "O prefixo Wine que foi utilizado para instalar o jogo",
|
||||||
|
"no_download_option_info": "Sem informações disponíveis",
|
||||||
|
"backup_deletion_failed": "Falha ao apagar backup",
|
||||||
|
"max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo",
|
||||||
|
"achievements_not_sync": "Suas conquistas não estão sincronizadas"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
|
@ -168,7 +199,7 @@
|
||||||
"enable_download_notifications": "Quando um download for concluído",
|
"enable_download_notifications": "Quando um download for concluído",
|
||||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
"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",
|
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
"behavior": "Comportamento",
|
"behavior": "Comportamento",
|
||||||
|
@ -216,7 +247,8 @@
|
||||||
"source_already_exists": "Essa fonte já foi adicionada",
|
"source_already_exists": "Essa fonte já foi adicionada",
|
||||||
"must_be_valid_url": "A fonte deve ser uma URL válida",
|
"must_be_valid_url": "A fonte deve ser uma URL válida",
|
||||||
"blocked_users": "Usuários bloqueados",
|
"blocked_users": "Usuários bloqueados",
|
||||||
"user_unblocked": "Usuário desbloqueado"
|
"user_unblocked": "Usuário desbloqueado",
|
||||||
|
"enable_achievement_notifications": "Quando uma conquista é desbloqueada"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"download_complete": "Download concluído",
|
"download_complete": "Download concluído",
|
||||||
|
@ -316,6 +348,25 @@
|
||||||
"report_reason_spam": "Spam",
|
"report_reason_spam": "Spam",
|
||||||
"report_reason_other": "Outro",
|
"report_reason_other": "Outro",
|
||||||
"profile_reported": "Perfil reportado",
|
"profile_reported": "Perfil reportado",
|
||||||
"your_friend_code": "Seu código de amigo:"
|
"your_friend_code": "Seu código de amigo:",
|
||||||
|
"upload_banner": "Carregar banner",
|
||||||
|
"uploading_banner": "Carregando banner…"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"achievement_unlocked": "Conquista desbloqueada",
|
||||||
|
"your_achievements": "Suas Conquistas",
|
||||||
|
"user_achievements": "Conquistas de {{displayName}}",
|
||||||
|
"unlocked_at": "Desbloqueado em:",
|
||||||
|
"subscription_needed": "Você precisa de uma assinatura Hydra Cloud para visualizar este conteúdo",
|
||||||
|
"new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos"
|
||||||
|
},
|
||||||
|
"tour": {
|
||||||
|
"subscription_tour_title": "Assinatura Hydra Cloud",
|
||||||
|
"subscribe_now": "Inscreva-se agora",
|
||||||
|
"cloud_achievements": "Salvamento de conquistas em nuvem",
|
||||||
|
"animated_profile_picture": "Fotos de perfil animadas",
|
||||||
|
"premium_support": "Suporte Premium",
|
||||||
|
"show_and_compare_achievements": "Exiba e compare suas conquistas com outros usuários",
|
||||||
|
"animated_profile_banner": "Banner animado no perfil"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,7 +115,10 @@
|
||||||
"download": "Transferir",
|
"download": "Transferir",
|
||||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||||
"warning": "Aviso:",
|
"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",
|
||||||
|
"achievements_count": "Conquistas ({{unlockedCount}}/{{achievementsCount}})",
|
||||||
|
"see_all_achievements": "Ver todas as conquistas"
|
||||||
},
|
},
|
||||||
"activation": {
|
"activation": {
|
||||||
"title": "Ativação",
|
"title": "Ativação",
|
||||||
|
@ -157,7 +160,7 @@
|
||||||
"enable_download_notifications": "Quando uma transferência for concluída",
|
"enable_download_notifications": "Quando uma transferência for concluída",
|
||||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
"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",
|
"launch_with_system": "Iniciar o Hydra com o sistema",
|
||||||
"general": "Geral",
|
"general": "Geral",
|
||||||
"behavior": "Comportamento",
|
"behavior": "Comportamento",
|
||||||
|
@ -277,5 +280,11 @@
|
||||||
"friend_code_copied": "Código de amigo copiado",
|
"friend_code_copied": "Código de amigo copiado",
|
||||||
"image_process_failure": "Falha ao processar a imagem",
|
"image_process_failure": "Falha ao processar a imagem",
|
||||||
"your_friend_code": "Seu código de amigo:"
|
"your_friend_code": "Seu código de amigo:"
|
||||||
|
},
|
||||||
|
"achievement": {
|
||||||
|
"achievement_unlocked": "Conquista desbloqueada",
|
||||||
|
"unlocked_at": "Desbloqueado em:",
|
||||||
|
"subscription_needed": "Você precisa de uma assinatura Hydra Cloud para visualizar este conteúdo",
|
||||||
|
"new_achievements_unlocked": "Encontradas {{achievementCount}} novas conquistas de {{gameCount}} jogos"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,12 @@ import path from "node:path";
|
||||||
export const defaultDownloadsPath = app.getPath("downloads");
|
export const defaultDownloadsPath = app.getPath("downloads");
|
||||||
|
|
||||||
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
|
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");
|
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(process.resourcesPath, "seeds")
|
||||||
: path.join(__dirname, "..", "..", "seeds");
|
: path.join(__dirname, "..", "..", "seeds");
|
||||||
|
|
||||||
|
export const backupsPath = path.join(app.getPath("userData"), "Backups");
|
||||||
|
|
||||||
export const appVersion = app.getVersion();
|
export const appVersion = app.getVersion();
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
Repack,
|
Repack,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
|
UserSubscription,
|
||||||
} from "@main/entity";
|
} from "@main/entity";
|
||||||
|
|
||||||
import { databasePath } from "./constants";
|
import { databasePath } from "./constants";
|
||||||
|
@ -16,11 +18,13 @@ export const dataSource = new DataSource({
|
||||||
entities: [
|
entities: [
|
||||||
Game,
|
Game,
|
||||||
Repack,
|
Repack,
|
||||||
|
UserAuth,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
|
UserSubscription,
|
||||||
GameShopCache,
|
GameShopCache,
|
||||||
DownloadSource,
|
DownloadSource,
|
||||||
DownloadQueue,
|
DownloadQueue,
|
||||||
UserAuth,
|
GameAchievement,
|
||||||
],
|
],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
database: databasePath,
|
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 | null;
|
||||||
|
|
||||||
|
@Column("text", { nullable: true })
|
||||||
|
achievements: string | null;
|
||||||
|
}
|
|
@ -18,6 +18,9 @@ export class GameShopCache {
|
||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
serializedData: string;
|
serializedData: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use IndexedDB's `howLongToBeatEntries` instead
|
||||||
|
*/
|
||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
howLongToBeatSerializedData: string;
|
howLongToBeatSerializedData: string;
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,9 @@ export class Game {
|
||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
executablePath: string | null;
|
executablePath: string | null;
|
||||||
|
|
||||||
|
@Column("text", { nullable: true })
|
||||||
|
winePrefixPath: string | null;
|
||||||
|
|
||||||
@Column("int", { default: 0 })
|
@Column("int", { default: 0 })
|
||||||
playTimeInMilliseconds: number;
|
playTimeInMilliseconds: number;
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
export * from "./game.entity";
|
export * from "./game.entity";
|
||||||
export * from "./repack.entity";
|
export * from "./repack.entity";
|
||||||
|
export * from "./user-auth.entity";
|
||||||
export * from "./user-preferences.entity";
|
export * from "./user-preferences.entity";
|
||||||
|
export * from "./user-subscription.entity";
|
||||||
export * from "./game-shop-cache.entity";
|
export * from "./game-shop-cache.entity";
|
||||||
|
export * from "./game.entity";
|
||||||
|
export * from "./game-achievements.entity";
|
||||||
export * from "./download-source.entity";
|
export * from "./download-source.entity";
|
||||||
export * from "./download-queue.entity";
|
export * from "./download-queue.entity";
|
||||||
export * from "./user-auth";
|
|
||||||
|
|
|
@ -4,7 +4,9 @@ import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
|
OneToOne,
|
||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
|
import { UserSubscription } from "./user-subscription.entity";
|
||||||
|
|
||||||
@Entity("user_auth")
|
@Entity("user_auth")
|
||||||
export class UserAuth {
|
export class UserAuth {
|
||||||
|
@ -20,6 +22,9 @@ export class UserAuth {
|
||||||
@Column("text", { nullable: true })
|
@Column("text", { nullable: true })
|
||||||
profileImageUrl: string | null;
|
profileImageUrl: string | null;
|
||||||
|
|
||||||
|
@Column("text", { nullable: true })
|
||||||
|
backgroundImageUrl: string | null;
|
||||||
|
|
||||||
@Column("text", { default: "" })
|
@Column("text", { default: "" })
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|
||||||
|
@ -29,6 +34,9 @@ export class UserAuth {
|
||||||
@Column("int", { default: 0 })
|
@Column("int", { default: 0 })
|
||||||
tokenExpirationTimestamp: number;
|
tokenExpirationTimestamp: number;
|
||||||
|
|
||||||
|
@OneToOne("UserSubscription", "user")
|
||||||
|
subscription: UserSubscription | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
|
@ -26,6 +26,9 @@ export class UserPreferences {
|
||||||
@Column("boolean", { default: false })
|
@Column("boolean", { default: false })
|
||||||
repackUpdatesNotificationsEnabled: boolean;
|
repackUpdatesNotificationsEnabled: boolean;
|
||||||
|
|
||||||
|
@Column("boolean", { default: true })
|
||||||
|
achievementNotificationsEnabled: boolean;
|
||||||
|
|
||||||
@Column("boolean", { default: false })
|
@Column("boolean", { default: false })
|
||||||
preferQuitInsteadOfHiding: boolean;
|
preferQuitInsteadOfHiding: boolean;
|
||||||
|
|
||||||
|
|
42
src/main/entity/user-subscription.entity.ts
Normal file
42
src/main/entity/user-subscription.entity.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import type { SubscriptionStatus } from "@types";
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { UserAuth } from "./user-auth.entity";
|
||||||
|
|
||||||
|
@Entity("user_subscription")
|
||||||
|
export class UserSubscription {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column("text", { default: "" })
|
||||||
|
subscriptionId: string;
|
||||||
|
|
||||||
|
@OneToOne("UserAuth", "subscription")
|
||||||
|
@JoinColumn()
|
||||||
|
user: UserAuth;
|
||||||
|
|
||||||
|
@Column("text", { default: "" })
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
|
||||||
|
@Column("text", { default: "" })
|
||||||
|
planId: string;
|
||||||
|
|
||||||
|
@Column("text", { default: "" })
|
||||||
|
planName: string;
|
||||||
|
|
||||||
|
@Column("datetime", { nullable: true })
|
||||||
|
expiresAt: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { AppUpdaterEvent } from "@types";
|
import type { AppUpdaterEvent } from "@types";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import updater, { UpdateInfo } from "electron-updater";
|
import updater, { UpdateInfo } from "electron-updater";
|
||||||
import { WindowManager } from "@main/services";
|
import { WindowManager } from "@main/services";
|
||||||
|
|
|
@ -15,7 +15,7 @@ const getCatalogue = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
|
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
|
||||||
`/games/${category}?${params.toString()}`,
|
`/catalogue/${category}?${params.toString()}`,
|
||||||
{},
|
{},
|
||||||
{ needsAuth: false }
|
{ needsAuth: false }
|
||||||
);
|
);
|
||||||
|
@ -30,7 +30,7 @@ const getCatalogue = async (
|
||||||
title: steamGame.name,
|
title: steamGame.name,
|
||||||
shop: game.shop,
|
shop: game.shop,
|
||||||
cover: steamUrlBuilder.library(game.objectId),
|
cover: steamUrlBuilder.library(game.objectId),
|
||||||
objectID: game.objectId,
|
objectId: game.objectId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -7,16 +7,16 @@ import { registerEvent } from "../register-event";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
|
|
||||||
const getLocalizedSteamAppDetails = async (
|
const getLocalizedSteamAppDetails = async (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
language: string
|
language: string
|
||||||
): Promise<ShopDetails | null> => {
|
): Promise<ShopDetails | null> => {
|
||||||
if (language === "english") {
|
if (language === "english") {
|
||||||
return getSteamAppDetails(objectID, language);
|
return getSteamAppDetails(objectId, language);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSteamAppDetails(objectID, language).then(
|
return getSteamAppDetails(objectId, language).then(
|
||||||
async (localizedAppDetails) => {
|
async (localizedAppDetails) => {
|
||||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||||
name: "getById",
|
name: "getById",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -34,21 +34,21 @@ const getLocalizedSteamAppDetails = async (
|
||||||
|
|
||||||
const getGameShopDetails = async (
|
const getGameShopDetails = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string,
|
objectId: string,
|
||||||
shop: GameShop,
|
shop: GameShop,
|
||||||
language: string
|
language: string
|
||||||
): Promise<ShopDetails | null> => {
|
): Promise<ShopDetails | null> => {
|
||||||
if (shop === "steam") {
|
if (shop === "steam") {
|
||||||
const cachedData = await gameShopCacheRepository.findOne({
|
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) => {
|
(result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
gameShopCacheRepository.upsert(
|
gameShopCacheRepository.upsert(
|
||||||
{
|
{
|
||||||
objectID,
|
objectID: objectId,
|
||||||
shop: "steam",
|
shop: "steam",
|
||||||
language,
|
language,
|
||||||
serializedData: JSON.stringify(result),
|
serializedData: JSON.stringify(result),
|
||||||
|
@ -68,7 +68,7 @@ const getGameShopDetails = async (
|
||||||
if (cachedGame) {
|
if (cachedGame) {
|
||||||
return {
|
return {
|
||||||
...cachedGame,
|
...cachedGame,
|
||||||
objectID,
|
objectId,
|
||||||
} as ShopDetails;
|
} as ShopDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,17 @@
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop, GameStats } from "@types";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import type { GameStats } from "@types";
|
|
||||||
|
|
||||||
const getGameStats = async (
|
const getGameStats = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectId: string,
|
objectId: string,
|
||||||
shop: GameShop
|
shop: GameShop
|
||||||
) => {
|
) => {
|
||||||
const params = new URLSearchParams({
|
return HydraApi.get<GameStats>(
|
||||||
objectId,
|
`/games/stats`,
|
||||||
shop,
|
{ objectId, shop },
|
||||||
});
|
{ needsAuth: false }
|
||||||
|
|
||||||
const response = await HydraApi.get<GameStats>(
|
|
||||||
`/games/stats?${params.toString()}`
|
|
||||||
);
|
);
|
||||||
return response;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getGameStats", getGameStats);
|
registerEvent("getGameStats", getGameStats);
|
||||||
|
|
|
@ -1,28 +1,29 @@
|
||||||
import type { CatalogueEntry } from "@types";
|
import type { CatalogueEntry } from "@types";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { HydraApi } from "@main/services";
|
||||||
import { steamUrlBuilder } from "@shared";
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
|
||||||
const getGames = async (
|
const getGames = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
take = 12,
|
take = 12,
|
||||||
cursor = 0
|
skip = 0
|
||||||
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
|
): Promise<CatalogueEntry[]> => {
|
||||||
const steamGames = await steamGamesWorker.run(
|
const searchParams = new URLSearchParams({
|
||||||
{ limit: take, offset: cursor },
|
take: take.toString(),
|
||||||
{ name: "list" }
|
skip: skip.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = await HydraApi.get<CatalogueEntry[]>(
|
||||||
|
`/games/catalogue?${searchParams.toString()}`,
|
||||||
|
undefined,
|
||||||
|
{ needsAuth: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return games.map((game) => ({
|
||||||
results: steamGames.map((steamGame) => ({
|
...game,
|
||||||
title: steamGame.name,
|
cover: steamUrlBuilder.library(game.objectId),
|
||||||
shop: "steam",
|
}));
|
||||||
cover: steamUrlBuilder.library(steamGame.id),
|
|
||||||
objectID: steamGame.id,
|
|
||||||
})),
|
|
||||||
cursor: cursor + steamGames.length,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getGames", getGames);
|
registerEvent("getGames", getGames);
|
||||||
|
|
|
@ -1,45 +1,23 @@
|
||||||
import type { GameShop, HowLongToBeatCategory } from "@types";
|
import type { HowLongToBeatCategory } from "@types";
|
||||||
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
|
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { gameShopCacheRepository } from "@main/repository";
|
import { formatName } from "@shared";
|
||||||
|
|
||||||
const getHowLongToBeat = async (
|
const getHowLongToBeat = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string,
|
|
||||||
shop: GameShop,
|
|
||||||
title: string
|
title: string
|
||||||
): Promise<HowLongToBeatCategory[] | null> => {
|
): Promise<HowLongToBeatCategory[] | null> => {
|
||||||
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
|
const response = await searchHowLongToBeat(title);
|
||||||
|
|
||||||
const gameShopCache = await gameShopCacheRepository.findOne({
|
const game = response.data.find((game) => {
|
||||||
where: { objectID, shop },
|
return formatName(game.game_name) === formatName(title);
|
||||||
});
|
});
|
||||||
|
|
||||||
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
|
|
||||||
? JSON.parse(gameShopCache?.howLongToBeatSerializedData)
|
|
||||||
: null;
|
|
||||||
if (howLongToBeatCachedData) return howLongToBeatCachedData;
|
|
||||||
|
|
||||||
return searchHowLongToBeatPromise.then(async (response) => {
|
|
||||||
const game = response.data.find(
|
|
||||||
(game) => game.profile_steam === Number(objectID)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
|
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
|
||||||
|
|
||||||
gameShopCacheRepository.upsert(
|
|
||||||
{
|
|
||||||
objectID,
|
|
||||||
shop,
|
|
||||||
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
|
|
||||||
},
|
|
||||||
["objectID"]
|
|
||||||
);
|
|
||||||
|
|
||||||
return howLongToBeat;
|
return howLongToBeat;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("getHowLongToBeat", getHowLongToBeat);
|
registerEvent("getHowLongToBeat", getHowLongToBeat);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { userPreferencesRepository } from "@main/repository";
|
import { userPreferencesRepository } from "@main/repository";
|
||||||
import { TrendingGame } from "@types";
|
import type { TrendingGame } from "@types";
|
||||||
|
|
||||||
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
|
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
const userPreferences = await userPreferencesRepository.findOne({
|
const userPreferences = await userPreferencesRepository.findOne({
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
|
||||||
import { CatalogueEntry } from "@types";
|
import type { CatalogueEntry } from "@types";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
|
|
||||||
const searchGamesEvent = async (
|
const searchGamesEvent = async (
|
||||||
|
|
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);
|
150
src/main/events/cloud-save/download-game-artifact.ts
Normal file
150
src/main/events/cloud-save/download-game-artifact.ts
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
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 os from "node:os";
|
||||||
|
import { app } from "electron";
|
||||||
|
import path from "node:path";
|
||||||
|
import { backupsPath } from "@main/constants";
|
||||||
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { normalizePath } from "@main/helpers";
|
||||||
|
|
||||||
|
export interface LudusaviBackup {
|
||||||
|
files: {
|
||||||
|
[key: string]: {
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceLudusaviBackupWithCurrentUser = (
|
||||||
|
backupPath: string,
|
||||||
|
title: string,
|
||||||
|
homeDir: string
|
||||||
|
) => {
|
||||||
|
const gameBackupPath = path.join(backupPath, title);
|
||||||
|
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 = normalizePath(app.getPath("home"));
|
||||||
|
|
||||||
|
/* Renaming logic */
|
||||||
|
if (os.platform() === "win32") {
|
||||||
|
const mappedHomeDir = path.join(
|
||||||
|
gameBackupPath,
|
||||||
|
path.join("drive-C", homeDir.replace("C:", ""))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fs.existsSync(mappedHomeDir)) {
|
||||||
|
fs.renameSync(
|
||||||
|
mappedHomeDir,
|
||||||
|
path.join(gameBackupPath, "drive-C", currentHomeDir.replace("C:", ""))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backups = manifest.backups.map((backup: LudusaviBackup) => {
|
||||||
|
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
|
||||||
|
const updatedKey = key.replace(homeDir, currentHomeDir);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[updatedKey]: value,
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...backup,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadGameArtifact = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
gameArtifactId: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
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(
|
||||||
|
backupPath,
|
||||||
|
game.replaceAll(":", "_"),
|
||||||
|
normalizePath(homeDir)
|
||||||
|
);
|
||||||
|
|
||||||
|
Ludusavi.restoreBackup(backupPath).then(() => {
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
`on-backup-download-complete-${objectId}-${shop}`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
`on-backup-download-complete-${objectId}-${shop}`,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 type { 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);
|
111
src/main/events/cloud-save/upload-save-game.ts
Normal file
111
src/main/events/cloud-save/upload-save-game.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
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 type { GameShop } from "@types";
|
||||||
|
import axios from "axios";
|
||||||
|
import os from "node:os";
|
||||||
|
import { backupsPath } from "@main/constants";
|
||||||
|
import { app } from "electron";
|
||||||
|
import { normalizePath } from "@main/helpers";
|
||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
|
const bundleBackup = async (
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string,
|
||||||
|
winePrefix: string | null
|
||||||
|
) => {
|
||||||
|
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||||
|
|
||||||
|
// Remove existing backup
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
fs.rmSync(backupPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix);
|
||||||
|
|
||||||
|
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`);
|
||||||
|
|
||||||
|
await tar.create(
|
||||||
|
{
|
||||||
|
gzip: false,
|
||||||
|
file: tarLocation,
|
||||||
|
cwd: backupPath,
|
||||||
|
},
|
||||||
|
["."]
|
||||||
|
);
|
||||||
|
|
||||||
|
return tarLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadSaveGame = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
downloadOptionTitle: string | null
|
||||||
|
) => {
|
||||||
|
const game = await gameRepository.findOne({
|
||||||
|
where: {
|
||||||
|
objectID: objectId,
|
||||||
|
shop,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bundleLocation = await bundleBackup(
|
||||||
|
shop,
|
||||||
|
objectId,
|
||||||
|
game?.winePrefixPath ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
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: normalizePath(app.getPath("home")),
|
||||||
|
downloadOptionTitle,
|
||||||
|
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);
|
|
@ -12,7 +12,7 @@ export interface SearchGamesArgs {
|
||||||
export const convertSteamGameToCatalogueEntry = (
|
export const convertSteamGameToCatalogueEntry = (
|
||||||
game: SteamGame
|
game: SteamGame
|
||||||
): CatalogueEntry => ({
|
): CatalogueEntry => ({
|
||||||
objectID: String(game.id),
|
objectId: String(game.id),
|
||||||
title: game.name,
|
title: game.name,
|
||||||
shop: "steam" as GameShop,
|
shop: "steam" as GameShop,
|
||||||
cover: steamUrlBuilder.library(String(game.id)),
|
cover: steamUrlBuilder.library(String(game.id)),
|
||||||
|
|
|
@ -24,6 +24,8 @@ import "./library/update-executable-path";
|
||||||
import "./library/verify-executable-path";
|
import "./library/verify-executable-path";
|
||||||
import "./library/remove-game";
|
import "./library/remove-game";
|
||||||
import "./library/remove-game-from-library";
|
import "./library/remove-game-from-library";
|
||||||
|
import "./library/select-game-wine-prefix";
|
||||||
|
import "./misc/open-checkout";
|
||||||
import "./misc/open-external";
|
import "./misc/open-external";
|
||||||
import "./misc/show-open-dialog";
|
import "./misc/show-open-dialog";
|
||||||
import "./torrenting/cancel-game-download";
|
import "./torrenting/cancel-game-download";
|
||||||
|
@ -48,6 +50,8 @@ import "./user/unblock-user";
|
||||||
import "./user/get-user-friends";
|
import "./user/get-user-friends";
|
||||||
import "./user/get-user-stats";
|
import "./user/get-user-stats";
|
||||||
import "./user/report-user";
|
import "./user/report-user";
|
||||||
|
import "./user/get-unlocked-achievements";
|
||||||
|
import "./user/get-compared-unlocked-achievements";
|
||||||
import "./profile/get-friend-requests";
|
import "./profile/get-friend-requests";
|
||||||
import "./profile/get-me";
|
import "./profile/get-me";
|
||||||
import "./profile/undo-friendship";
|
import "./profile/undo-friendship";
|
||||||
|
@ -56,8 +60,14 @@ import "./profile/update-profile";
|
||||||
import "./profile/process-profile-image";
|
import "./profile/process-profile-image";
|
||||||
import "./profile/send-friend-request";
|
import "./profile/send-friend-request";
|
||||||
import "./profile/sync-friend-requests";
|
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/delete-game-artifact";
|
||||||
import "./notifications/publish-new-repacks-notification";
|
import "./notifications/publish-new-repacks-notification";
|
||||||
import { isPortableVersion } from "@main/helpers";
|
import { isPortableVersion } from "@main/helpers";
|
||||||
|
import "./misc/show-item-in-folder";
|
||||||
|
|
||||||
ipcMain.handle("ping", () => "pong");
|
ipcMain.handle("ping", () => "pong");
|
||||||
ipcMain.handle("getVersion", () => appVersion);
|
ipcMain.handle("getVersion", () => appVersion);
|
||||||
|
|
|
@ -7,17 +7,18 @@ import type { GameShop } from "@types";
|
||||||
import { steamGamesWorker } from "@main/workers";
|
import { steamGamesWorker } from "@main/workers";
|
||||||
import { createGame } from "@main/services/library-sync";
|
import { createGame } from "@main/services/library-sync";
|
||||||
import { steamUrlBuilder } from "@shared";
|
import { steamUrlBuilder } from "@shared";
|
||||||
|
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
|
||||||
|
|
||||||
const addGameToLibrary = async (
|
const addGameToLibrary = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string,
|
objectId: string,
|
||||||
title: string,
|
title: string,
|
||||||
shop: GameShop
|
shop: GameShop
|
||||||
) => {
|
) => {
|
||||||
return gameRepository
|
return gameRepository
|
||||||
.update(
|
.update(
|
||||||
{
|
{
|
||||||
objectID,
|
objectID: objectId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shop,
|
shop,
|
||||||
|
@ -27,23 +28,27 @@ const addGameToLibrary = async (
|
||||||
)
|
)
|
||||||
.then(async ({ affected }) => {
|
.then(async ({ affected }) => {
|
||||||
if (!affected) {
|
if (!affected) {
|
||||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||||
name: "getById",
|
name: "getById",
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await gameRepository.insert({
|
await gameRepository.insert({
|
||||||
title,
|
title,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
objectID,
|
objectID: objectId,
|
||||||
shop,
|
shop,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const game = await gameRepository.findOne({ where: { objectID } });
|
const game = await gameRepository.findOne({
|
||||||
|
where: { objectID: objectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
updateLocalUnlockedAchivements(game!);
|
||||||
|
|
||||||
createGame(game!).catch(() => {});
|
createGame(game!).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,15 +2,15 @@ import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
const getGameByObjectID = async (
|
const getGameByObjectId = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
objectID: string
|
objectId: string
|
||||||
) =>
|
) =>
|
||||||
gameRepository.findOne({
|
gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
objectID,
|
objectID: objectId,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
registerEvent("getGameByObjectID", getGameByObjectID);
|
registerEvent("getGameByObjectId", getGameByObjectId);
|
||||||
|
|
|
@ -50,7 +50,8 @@ const openGameInstaller = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.lstatSync(gamePath).isFile()) {
|
if (fs.lstatSync(gamePath).isFile()) {
|
||||||
return executeGameInstaller(gamePath);
|
shell.showItemInFolder(gamePath);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupPath = path.join(gamePath, "setup.exe");
|
const setupPath = path.join(gamePath, "setup.exe");
|
||||||
|
|
13
src/main/events/library/select-game-wine-prefix.ts
Normal file
13
src/main/events/library/select-game-wine-prefix.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { gameRepository } from "@main/repository";
|
||||||
|
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
|
const selectGameWinePrefix = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
id: number,
|
||||||
|
winePrefixPath: string
|
||||||
|
) => {
|
||||||
|
return gameRepository.update({ id }, { winePrefixPath });
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("selectGameWinePrefix", selectGameWinePrefix);
|
26
src/main/events/misc/open-checkout.ts
Normal file
26
src/main/events/misc/open-checkout.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { shell } from "electron";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { userAuthRepository } from "@main/repository";
|
||||||
|
import { HydraApi } from "@main/services";
|
||||||
|
|
||||||
|
const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||||
|
const userAuth = await userAuthRepository.findOne({ where: { id: 1 } });
|
||||||
|
|
||||||
|
if (!userAuth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentToken = await HydraApi.post("/auth/payment", {
|
||||||
|
refreshToken: userAuth.refreshToken,
|
||||||
|
}).then((response) => response.accessToken);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
token: paymentToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
shell.openExternal(
|
||||||
|
`${import.meta.env.MAIN_VITE_CHECKOUT_URL}?${params.toString()}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("openCheckout", openCheckout);
|
11
src/main/events/misc/show-item-in-folder.ts
Normal file
11
src/main/events/misc/show-item-in-folder.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { shell } from "electron";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
|
||||||
|
const showItemInFolder = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
filePath: string
|
||||||
|
) => {
|
||||||
|
return shell.showItemInFolder(filePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("showItemInFolder", showItemInFolder);
|
|
@ -1,6 +1,6 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { FriendRequest } from "@types";
|
import type { FriendRequest } from "@types";
|
||||||
|
|
||||||
const getFriendRequests = async (
|
const getFriendRequests = async (
|
||||||
_event: Electron.IpcMainInvokeEvent
|
_event: Electron.IpcMainInvokeEvent
|
||||||
|
|
|
@ -1,34 +1,18 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import * as Sentry from "@sentry/electron/main";
|
import { logger } from "@main/services";
|
||||||
import { HydraApi } from "@main/services";
|
import type { ProfileVisibility, UserDetails } from "@types";
|
||||||
import { ProfileVisibility, UserDetails } from "@types";
|
|
||||||
import { userAuthRepository } from "@main/repository";
|
import { userAuthRepository } from "@main/repository";
|
||||||
import { UserNotLoggedInError } from "@shared";
|
import { UserNotLoggedInError } from "@shared";
|
||||||
|
import { getUserData } from "@main/services/user/get-user-data";
|
||||||
|
|
||||||
const getMe = async (
|
const getMe = async (
|
||||||
_event: Electron.IpcMainInvokeEvent
|
_event: Electron.IpcMainInvokeEvent
|
||||||
): Promise<UserDetails | null> => {
|
): Promise<UserDetails | null> => {
|
||||||
return HydraApi.get<UserDetails>(`/profile/me`)
|
return getUserData().catch(async (err) => {
|
||||||
.then(async (me) => {
|
|
||||||
userAuthRepository.upsert(
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
displayName: me.displayName,
|
|
||||||
profileImageUrl: me.profileImageUrl,
|
|
||||||
userId: me.id,
|
|
||||||
},
|
|
||||||
["id"]
|
|
||||||
);
|
|
||||||
|
|
||||||
Sentry.setUser({ id: me.id, username: me.username });
|
|
||||||
|
|
||||||
return me;
|
|
||||||
})
|
|
||||||
.catch(async (err) => {
|
|
||||||
if (err instanceof UserNotLoggedInError) {
|
if (err instanceof UserNotLoggedInError) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
logger.error("Failed to get logged user", err);
|
||||||
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
|
const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
|
||||||
|
|
||||||
if (loggedUser) {
|
if (loggedUser) {
|
||||||
|
@ -38,6 +22,17 @@ const getMe = async (
|
||||||
username: "",
|
username: "",
|
||||||
bio: "",
|
bio: "",
|
||||||
profileVisibility: "PUBLIC" as ProfileVisibility,
|
profileVisibility: "PUBLIC" as ProfileVisibility,
|
||||||
|
subscription: loggedUser.subscription
|
||||||
|
? {
|
||||||
|
id: loggedUser.subscription.subscriptionId,
|
||||||
|
status: loggedUser.subscription.status,
|
||||||
|
plan: {
|
||||||
|
id: loggedUser.subscription.planId,
|
||||||
|
name: loggedUser.subscription.planName,
|
||||||
|
},
|
||||||
|
expiresAt: loggedUser.subscription.expiresAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { FriendRequestSync } from "@types";
|
import { UserNotLoggedInError } from "@shared";
|
||||||
|
import type { FriendRequestSync } from "@types";
|
||||||
|
|
||||||
const syncFriendRequests = async (_event: Electron.IpcMainInvokeEvent) => {
|
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);
|
registerEvent("syncFriendRequests", syncFriendRequests);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { FriendRequestAction } from "@types";
|
import type { FriendRequestAction } from "@types";
|
||||||
|
|
||||||
const updateFriendRequest = async (
|
const updateFriendRequest = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
|
|
@ -1,56 +1,75 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi, PythonInstance } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { UpdateProfileRequest, UserProfile } from "@types";
|
import type { UpdateProfileRequest, UserProfile } from "@types";
|
||||||
import { omit } from "lodash-es";
|
import { omit } from "lodash-es";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { fileTypeFromFile } from "file-type";
|
||||||
interface PresignedResponse {
|
|
||||||
presignedUrl: string;
|
|
||||||
profileImageUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
|
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
|
||||||
return HydraApi.patch<UserProfile>("/profile", updateProfile);
|
return HydraApi.patch<UserProfile>("/profile", updateProfile);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNewProfileImageUrl = async (localImageUrl: string) => {
|
const uploadImage = async (
|
||||||
const { imagePath, mimeType } =
|
type: "profile-image" | "background-image",
|
||||||
await PythonInstance.processProfileImage(localImageUrl);
|
imagePath: string
|
||||||
|
) => {
|
||||||
const stats = fs.statSync(imagePath);
|
const stat = fs.statSync(imagePath);
|
||||||
const fileBuffer = fs.readFileSync(imagePath);
|
const fileBuffer = fs.readFileSync(imagePath);
|
||||||
const fileSizeInBytes = stats.size;
|
const fileSizeInBytes = stat.size;
|
||||||
|
|
||||||
const { presignedUrl, profileImageUrl } =
|
const response = await HydraApi.post<{ presignedUrl: string }>(
|
||||||
await HydraApi.post<PresignedResponse>(`/presigned-urls/profile-image`, {
|
`/presigned-urls/${type}`,
|
||||||
|
{
|
||||||
imageExt: path.extname(imagePath).slice(1),
|
imageExt: path.extname(imagePath).slice(1),
|
||||||
imageLength: fileSizeInBytes,
|
imageLength: fileSizeInBytes,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await axios.put(presignedUrl, fileBuffer, {
|
const mimeType = await fileTypeFromFile(imagePath);
|
||||||
|
|
||||||
|
await axios.put(response.presignedUrl, fileBuffer, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": mimeType,
|
"Content-Type": mimeType?.mime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return profileImageUrl;
|
if (type === "background-image") {
|
||||||
|
return response["backgroundImageUrl"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response["profileImageUrl"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateProfile = async (
|
const updateProfile = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
updateProfile: UpdateProfileRequest
|
updateProfile: UpdateProfileRequest
|
||||||
) => {
|
) => {
|
||||||
if (!updateProfile.profileImageUrl) {
|
const payload = omit(updateProfile, [
|
||||||
return patchUserProfile(omit(updateProfile, "profileImageUrl"));
|
"profileImageUrl",
|
||||||
}
|
"backgroundImageUrl",
|
||||||
|
]);
|
||||||
|
|
||||||
const profileImageUrl = await getNewProfileImageUrl(
|
if (updateProfile.profileImageUrl) {
|
||||||
|
const profileImageUrl = await uploadImage(
|
||||||
|
"profile-image",
|
||||||
updateProfile.profileImageUrl
|
updateProfile.profileImageUrl
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
|
|
||||||
return patchUserProfile({ ...updateProfile, profileImageUrl });
|
payload["profileImageUrl"] = profileImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateProfile.backgroundImageUrl) {
|
||||||
|
const backgroundImageUrl = await uploadImage(
|
||||||
|
"background-image",
|
||||||
|
updateProfile.backgroundImageUrl
|
||||||
|
).catch(() => undefined);
|
||||||
|
|
||||||
|
payload["backgroundImageUrl"] = backgroundImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return patchUserProfile(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
registerEvent("updateProfile", updateProfile);
|
registerEvent("updateProfile", updateProfile);
|
||||||
|
|
|
@ -14,7 +14,7 @@ const startGameDownload = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
payload: StartGameDownloadPayload
|
payload: StartGameDownloadPayload
|
||||||
) => {
|
) => {
|
||||||
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
|
const { objectId, title, shop, downloadPath, downloader, uri } = payload;
|
||||||
|
|
||||||
return dataSource.transaction(async (transactionalEntityManager) => {
|
return dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
const gameRepository = transactionalEntityManager.getRepository(Game);
|
const gameRepository = transactionalEntityManager.getRepository(Game);
|
||||||
|
@ -23,7 +23,7 @@ const startGameDownload = async (
|
||||||
|
|
||||||
const game = await gameRepository.findOne({
|
const game = await gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
objectID,
|
objectID: objectId,
|
||||||
shop,
|
shop,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -51,18 +51,18 @@ const startGameDownload = async (
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||||
name: "getById",
|
name: "getById",
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconUrl = steamGame?.clientIcon
|
const iconUrl = steamGame?.clientIcon
|
||||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await gameRepository.insert({
|
await gameRepository.insert({
|
||||||
title,
|
title,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
objectID,
|
objectID: objectId,
|
||||||
downloader,
|
downloader,
|
||||||
shop,
|
shop,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
@ -73,7 +73,7 @@ const startGameDownload = async (
|
||||||
|
|
||||||
const updatedGame = await gameRepository.findOne({
|
const updatedGame = await gameRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
objectID,
|
objectID: objectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { UserBlocks } from "@types";
|
import { UserNotLoggedInError } from "@shared";
|
||||||
|
import type { UserBlocks } from "@types";
|
||||||
|
|
||||||
export const getBlockedUsers = async (
|
export const getBlockedUsers = async (
|
||||||
_event: Electron.IpcMainInvokeEvent,
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
take: number,
|
take: number,
|
||||||
skip: number
|
skip: number
|
||||||
): Promise<UserBlocks> => {
|
): 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);
|
registerEvent("getBlockedUsers", getBlockedUsers);
|
||||||
|
|
44
src/main/events/user/get-compared-unlocked-achievements.ts
Normal file
44
src/main/events/user/get-compared-unlocked-achievements.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import type { ComparedAchievements, GameShop } from "@types";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { userPreferencesRepository } from "@main/repository";
|
||||||
|
import { HydraApi } from "@main/services";
|
||||||
|
|
||||||
|
const getComparedUnlockedAchievements = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
userId: string
|
||||||
|
) => {
|
||||||
|
const userPreferences = await userPreferencesRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return HydraApi.get<ComparedAchievements>(
|
||||||
|
`/users/${userId}/games/achievements/compare`,
|
||||||
|
{
|
||||||
|
shop,
|
||||||
|
objectId,
|
||||||
|
language: userPreferences?.language || "en",
|
||||||
|
}
|
||||||
|
).then((achievements) => {
|
||||||
|
const sortedAchievements = achievements.achievements.sort((a, b) => {
|
||||||
|
if (a.targetStat.unlocked && !b.targetStat.unlocked) return -1;
|
||||||
|
if (!a.targetStat.unlocked && b.targetStat.unlocked) return 1;
|
||||||
|
if (a.targetStat.unlocked && b.targetStat.unlocked) {
|
||||||
|
return b.targetStat.unlockTime! - a.targetStat.unlockTime!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(a.hidden) - Number(b.hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...achievements,
|
||||||
|
achievements: sortedAchievements,
|
||||||
|
} as ComparedAchievements;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent(
|
||||||
|
"getComparedUnlockedAchievements",
|
||||||
|
getComparedUnlockedAchievements
|
||||||
|
);
|
73
src/main/events/user/get-unlocked-achievements.ts
Normal file
73
src/main/events/user/get-unlocked-achievements.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import type { GameShop, UnlockedAchievement, UserAchievement } from "@types";
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { gameAchievementRepository } from "@main/repository";
|
||||||
|
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
|
||||||
|
|
||||||
|
export const getUnlockedAchievements = async (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
useCachedData: boolean
|
||||||
|
): Promise<UserAchievement[]> => {
|
||||||
|
const cachedAchievements = await gameAchievementRepository.findOne({
|
||||||
|
where: { objectId, shop },
|
||||||
|
});
|
||||||
|
|
||||||
|
const achievementsData = await getGameAchievementData(
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
useCachedData
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlockedAchievements = JSON.parse(
|
||||||
|
cachedAchievements?.unlockedAchievements || "[]"
|
||||||
|
) as UnlockedAchievement[];
|
||||||
|
|
||||||
|
return achievementsData
|
||||||
|
.map((achievementData) => {
|
||||||
|
const unlockedAchiementData = unlockedAchievements.find(
|
||||||
|
(localAchievement) => {
|
||||||
|
return (
|
||||||
|
localAchievement.name.toUpperCase() ==
|
||||||
|
achievementData.name.toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const icongray = achievementData.icongray.endsWith("/")
|
||||||
|
? achievementData.icon
|
||||||
|
: achievementData.icongray;
|
||||||
|
|
||||||
|
if (unlockedAchiementData) {
|
||||||
|
return {
|
||||||
|
...achievementData,
|
||||||
|
unlocked: true,
|
||||||
|
unlockTime: unlockedAchiementData.unlockTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...achievementData,
|
||||||
|
unlocked: false,
|
||||||
|
unlockTime: null,
|
||||||
|
icongray: icongray,
|
||||||
|
} as UserAchievement;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.unlocked && !b.unlocked) return -1;
|
||||||
|
if (!a.unlocked && b.unlocked) return 1;
|
||||||
|
if (a.unlocked && b.unlocked) {
|
||||||
|
return b.unlockTime! - a.unlockTime!;
|
||||||
|
}
|
||||||
|
return Number(a.hidden) - Number(b.hidden);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnlockedAchievementsEvent = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop
|
||||||
|
): Promise<UserAchievement[]> => {
|
||||||
|
return getUnlockedAchievements(objectId, shop, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getUnlockedAchievements", getUnlockedAchievementsEvent);
|
|
@ -1,7 +1,7 @@
|
||||||
import { userAuthRepository } from "@main/repository";
|
import { userAuthRepository } from "@main/repository";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { HydraApi } from "@main/services";
|
import { HydraApi } from "@main/services";
|
||||||
import { UserFriends } from "@types";
|
import type { UserFriends } from "@types";
|
||||||
|
|
||||||
export const getUserFriends = async (
|
export const getUserFriends = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import UserAgent from "user-agents";
|
import UserAgent from "user-agents";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
export const getFileBuffer = async (url: string) =>
|
export const getFileBuffer = async (url: string) =>
|
||||||
fetch(url, { method: "GET" }).then((response) =>
|
fetch(url, { method: "GET" }).then((response) =>
|
||||||
|
@ -27,3 +28,6 @@ export const requestWebPage = async (url: string) => {
|
||||||
|
|
||||||
export const isPortableVersion = () =>
|
export const isPortableVersion = () =>
|
||||||
process.env.PORTABLE_EXECUTABLE_FILE !== null;
|
process.env.PORTABLE_EXECUTABLE_FILE !== null;
|
||||||
|
|
||||||
|
export const normalizePath = (str: string) =>
|
||||||
|
path.posix.normalize(str).replace(/\\/g, "/");
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { app, BrowserWindow, net, protocol, session } from "electron";
|
import { app, BrowserWindow, net, protocol } from "electron";
|
||||||
import { init } from "@sentry/electron/main";
|
import { init } from "@sentry/electron/main";
|
||||||
import updater from "electron-updater";
|
import updater from "electron-updater";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
|
@ -102,47 +102,8 @@ app.whenReady().then(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
WindowManager.createMainWindow();
|
WindowManager.createMainWindow();
|
||||||
|
WindowManager.createNotificationWindow();
|
||||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||||
|
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
|
||||||
callback({
|
|
||||||
requestHeaders: {
|
|
||||||
...details.requestHeaders,
|
|
||||||
"user-agent":
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
|
||||||
const headers = {
|
|
||||||
"access-control-allow-origin": ["*"],
|
|
||||||
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
|
|
||||||
"access-control-expose-headers": ["ETag"],
|
|
||||||
"access-control-allow-headers": [
|
|
||||||
"Content-Type, Authorization, X-Requested-With, If-None-Match",
|
|
||||||
],
|
|
||||||
"access-control-allow-credentials": ["true"],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (details.method === "OPTIONS") {
|
|
||||||
callback({
|
|
||||||
cancel: false,
|
|
||||||
responseHeaders: {
|
|
||||||
...details.responseHeaders,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
statusLine: "HTTP/1.1 200 OK",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
callback({
|
|
||||||
responseHeaders: {
|
|
||||||
...details.responseHeaders,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
|
|
|
@ -6,6 +6,11 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
|
||||||
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
||||||
|
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
|
||||||
|
import { AddAchievementNotificationPreference } from "./migrations/20241013012900_add_achievement_notification_preference";
|
||||||
|
import { CreateUserSubscription } from "./migrations/20241015235142_create_user_subscription";
|
||||||
|
import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url";
|
||||||
|
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
|
||||||
|
|
||||||
export type HydraMigration = Knex.Migration & { name: string };
|
export type HydraMigration = Knex.Migration & { name: string };
|
||||||
|
|
||||||
|
@ -17,6 +22,11 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
||||||
UpdateUserLanguage,
|
UpdateUserLanguage,
|
||||||
EnsureRepackUris,
|
EnsureRepackUris,
|
||||||
FixMissingColumns,
|
FixMissingColumns,
|
||||||
|
CreateGameAchievement,
|
||||||
|
AddAchievementNotificationPreference,
|
||||||
|
CreateUserSubscription,
|
||||||
|
AddBackgroundImageUrl,
|
||||||
|
AddWinePrefixToGame,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
getMigrationName(migration: HydraMigration): string {
|
getMigrationName(migration: HydraMigration): string {
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import { DownloadManager, PythonInstance, startMainLoop } from "./services";
|
import {
|
||||||
|
DownloadManager,
|
||||||
|
Ludusavi,
|
||||||
|
PythonInstance,
|
||||||
|
startMainLoop,
|
||||||
|
} from "./services";
|
||||||
import {
|
import {
|
||||||
downloadQueueRepository,
|
downloadQueueRepository,
|
||||||
userPreferencesRepository,
|
userPreferencesRepository,
|
||||||
|
@ -7,6 +12,7 @@ import { UserPreferences } from "./entity";
|
||||||
import { RealDebridClient } from "./services/real-debrid";
|
import { RealDebridClient } from "./services/real-debrid";
|
||||||
import { HydraApi } from "./services/hydra-api";
|
import { HydraApi } from "./services/hydra-api";
|
||||||
import { uploadGamesBatch } from "./services/library-sync";
|
import { uploadGamesBatch } from "./services/library-sync";
|
||||||
|
import { getUserData } from "./services/user/get-user-data";
|
||||||
|
|
||||||
const loadState = async (userPreferences: UserPreferences | null) => {
|
const loadState = async (userPreferences: UserPreferences | null) => {
|
||||||
import("./events");
|
import("./events");
|
||||||
|
@ -15,7 +21,10 @@ const loadState = async (userPreferences: UserPreferences | null) => {
|
||||||
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
RealDebridClient.authorize(userPreferences?.realDebridApiToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
HydraApi.setupApi().then(() => {
|
Ludusavi.addManifestToLudusaviConfig();
|
||||||
|
|
||||||
|
HydraApi.setupApi().then(async () => {
|
||||||
|
await getUserData().catch(() => {});
|
||||||
uploadGamesBatch();
|
uploadGamesBatch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { HydraMigration } from "@main/knex-client";
|
||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
export const AddAchievementNotificationPreference: HydraMigration = {
|
||||||
|
name: "AddAchievementNotificationPreference",
|
||||||
|
up: (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("user_preferences", (table) => {
|
||||||
|
return table.boolean("achievementNotificationsEnabled").defaultTo(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("user_preferences", (table) => {
|
||||||
|
return table.dropColumn("achievementNotificationsEnabled");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { HydraMigration } from "@main/knex-client";
|
||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
export const CreateUserSubscription: HydraMigration = {
|
||||||
|
name: "CreateUserSubscription",
|
||||||
|
up: async (knex: Knex) => {
|
||||||
|
return knex.schema.createTable("user_subscription", (table) => {
|
||||||
|
table.increments("id").primary();
|
||||||
|
table.string("subscriptionId").defaultTo("");
|
||||||
|
table
|
||||||
|
.text("userId")
|
||||||
|
.notNullable()
|
||||||
|
.references("user_auth.id")
|
||||||
|
.onDelete("CASCADE");
|
||||||
|
table.string("status").defaultTo("");
|
||||||
|
table.string("planId").defaultTo("");
|
||||||
|
table.string("planName").defaultTo("");
|
||||||
|
table.dateTime("expiresAt").nullable();
|
||||||
|
table.dateTime("createdAt").defaultTo(knex.fn.now());
|
||||||
|
table.dateTime("updatedAt").defaultTo(knex.fn.now());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (knex: Knex) => {
|
||||||
|
return knex.schema.dropTable("user_subscription");
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { HydraMigration } from "@main/knex-client";
|
||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
export const AddBackgroundImageUrl: HydraMigration = {
|
||||||
|
name: "AddBackgroundImageUrl",
|
||||||
|
up: (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("user_auth", (table) => {
|
||||||
|
return table.text("backgroundImageUrl").nullable();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("user_auth", (table) => {
|
||||||
|
return table.dropColumn("backgroundImageUrl");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { HydraMigration } from "@main/knex-client";
|
||||||
|
import type { Knex } from "knex";
|
||||||
|
|
||||||
|
export const AddWinePrefixToGame: HydraMigration = {
|
||||||
|
name: "AddWinePrefixToGame",
|
||||||
|
up: (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("game", (table) => {
|
||||||
|
return table.text("winePrefixPath").nullable();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (knex: Knex) => {
|
||||||
|
return knex.schema.alterTable("game", (table) => {
|
||||||
|
return table.dropColumn("winePrefixPath");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
|
@ -3,8 +3,8 @@ import type { Knex } from "knex";
|
||||||
|
|
||||||
export const MigrationName: HydraMigration = {
|
export const MigrationName: HydraMigration = {
|
||||||
name: "MigrationName",
|
name: "MigrationName",
|
||||||
up: async (knex: Knex) => {
|
up: (knex: Knex) => {
|
||||||
await knex.schema.createTable("table_name", (table) => {});
|
return knex.schema.createTable("table_name", async (table) => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (knex: Knex) => {},
|
down: async (knex: Knex) => {},
|
||||||
|
|
|
@ -7,6 +7,8 @@ import {
|
||||||
Repack,
|
Repack,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
UserAuth,
|
UserAuth,
|
||||||
|
GameAchievement,
|
||||||
|
UserSubscription,
|
||||||
} from "@main/entity";
|
} from "@main/entity";
|
||||||
|
|
||||||
export const gameRepository = dataSource.getRepository(Game);
|
export const gameRepository = dataSource.getRepository(Game);
|
||||||
|
@ -24,3 +26,9 @@ export const downloadSourceRepository =
|
||||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||||
|
|
||||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||||
|
|
||||||
|
export const userSubscriptionRepository =
|
||||||
|
dataSource.getRepository(UserSubscription);
|
||||||
|
|
||||||
|
export const gameAchievementRepository =
|
||||||
|
dataSource.getRepository(GameAchievement);
|
||||||
|
|
253
src/main/services/achievements/achievement-watcher-manager.ts
Normal file
253
src/main/services/achievements/achievement-watcher-manager.ts
Normal file
|
@ -0,0 +1,253 @@
|
||||||
|
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,
|
||||||
|
findAchievementFiles,
|
||||||
|
findAllAchievementFiles,
|
||||||
|
getAlternativeObjectIds,
|
||||||
|
} from "./find-achivement-files";
|
||||||
|
import type { AchievementFile, UnlockedAchievement } from "@types";
|
||||||
|
import { achievementsLogger } from "../logger";
|
||||||
|
import { Cracker } from "@shared";
|
||||||
|
import { IsNull, Not } from "typeorm";
|
||||||
|
import { WindowManager } from "../window-manager";
|
||||||
|
|
||||||
|
const fileStats: Map<string, number> = new Map();
|
||||||
|
const fltFiles: Map<string, Set<string>> = new Map();
|
||||||
|
|
||||||
|
const watchAchievementsWindows = async () => {
|
||||||
|
const games = await gameRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (games.length === 0) return;
|
||||||
|
|
||||||
|
const achievementFiles = findAllAchievementFiles();
|
||||||
|
|
||||||
|
for (const game of games) {
|
||||||
|
const gameAchievementFiles: AchievementFile[] = [];
|
||||||
|
|
||||||
|
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||||
|
gameAchievementFiles.push(...(achievementFiles.get(objectId) || []));
|
||||||
|
|
||||||
|
gameAchievementFiles.push(
|
||||||
|
...findAchievementFileInExecutableDirectory(game)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of gameAchievementFiles) {
|
||||||
|
compareFile(game, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const watchAchievementsWithWine = async () => {
|
||||||
|
const games = await gameRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
winePrefixPath: Not(IsNull()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const game of games) {
|
||||||
|
const gameAchievementFiles = findAchievementFiles(game);
|
||||||
|
const achievementFileInsideDirectory =
|
||||||
|
findAchievementFileInExecutableDirectory(game);
|
||||||
|
|
||||||
|
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||||
|
|
||||||
|
for (const file of gameAchievementFiles) {
|
||||||
|
compareFile(game, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
achievementsLogger.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 = (game: Game, file: AchievementFile) => {
|
||||||
|
if (file.type === Cracker.flt) {
|
||||||
|
return compareFltFolder(game, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentStat = fs.statSync(file.filePath);
|
||||||
|
const previousStat = fileStats.get(file.filePath);
|
||||||
|
fileStats.set(file.filePath, currentStat.mtimeMs);
|
||||||
|
|
||||||
|
if (!previousStat || previousStat === -1) {
|
||||||
|
if (currentStat.mtimeMs) {
|
||||||
|
achievementsLogger.log(
|
||||||
|
"First change in file",
|
||||||
|
file.filePath,
|
||||||
|
previousStat,
|
||||||
|
currentStat.mtimeMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return processAchievementFileDiff(game, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousStat === currentStat.mtimeMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
achievementsLogger.log(
|
||||||
|
"Detected change in file",
|
||||||
|
file.filePath,
|
||||||
|
previousStat,
|
||||||
|
currentStat.mtimeMs
|
||||||
|
);
|
||||||
|
return processAchievementFileDiff(game, file);
|
||||||
|
} catch (err) {
|
||||||
|
fileStats.set(file.filePath, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processAchievementFileDiff = async (
|
||||||
|
game: Game,
|
||||||
|
file: AchievementFile
|
||||||
|
) => {
|
||||||
|
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
|
||||||
|
|
||||||
|
if (unlockedAchievements.length) {
|
||||||
|
return mergeAchievements(game, unlockedAchievements, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AchievementWatcherManager {
|
||||||
|
private static hasFinishedMergingWithRemote = false;
|
||||||
|
|
||||||
|
public static watchAchievements = () => {
|
||||||
|
if (!this.hasFinishedMergingWithRemote) return;
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return watchAchievementsWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
return watchAchievementsWithWine();
|
||||||
|
};
|
||||||
|
|
||||||
|
private static preProcessGameAchievementFiles = (
|
||||||
|
game: Game,
|
||||||
|
gameAchievementFiles: AchievementFile[]
|
||||||
|
) => {
|
||||||
|
const unlockedAchievements: UnlockedAchievement[] = [];
|
||||||
|
for (const achievementFile of gameAchievementFiles) {
|
||||||
|
const parsedAchievements = parseAchievementFile(
|
||||||
|
achievementFile.filePath,
|
||||||
|
achievementFile.type
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentStat = fs.statSync(achievementFile.filePath);
|
||||||
|
fileStats.set(achievementFile.filePath, currentStat.mtimeMs);
|
||||||
|
} catch {
|
||||||
|
fileStats.set(achievementFile.filePath, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedAchievements.length) {
|
||||||
|
unlockedAchievements.push(...parsedAchievements);
|
||||||
|
|
||||||
|
achievementsLogger.log(
|
||||||
|
"Achievement file for",
|
||||||
|
game.title,
|
||||||
|
achievementFile.filePath,
|
||||||
|
parsedAchievements
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeAchievements(game, unlockedAchievements, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
private static preSearchAchievementsWindows = async () => {
|
||||||
|
const games = await gameRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameAchievementFilesMap = findAllAchievementFiles();
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
games.map((game) => {
|
||||||
|
const gameAchievementFiles: AchievementFile[] = [];
|
||||||
|
|
||||||
|
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||||
|
gameAchievementFiles.push(
|
||||||
|
...(gameAchievementFilesMap.get(objectId) || [])
|
||||||
|
);
|
||||||
|
|
||||||
|
gameAchievementFiles.push(
|
||||||
|
...findAchievementFileInExecutableDirectory(game)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.preProcessGameAchievementFiles(game, gameAchievementFiles);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private static preSearchAchievementsWithWine = async () => {
|
||||||
|
const games = await gameRepository.find({
|
||||||
|
where: {
|
||||||
|
isDeleted: false,
|
||||||
|
winePrefixPath: Not(IsNull()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
games.map((game) => {
|
||||||
|
const gameAchievementFiles = findAchievementFiles(game);
|
||||||
|
const achievementFileInsideDirectory =
|
||||||
|
findAchievementFileInExecutableDirectory(game);
|
||||||
|
|
||||||
|
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||||
|
|
||||||
|
return this.preProcessGameAchievementFiles(game, gameAchievementFiles);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
public static preSearchAchievements = async () => {
|
||||||
|
const newAchievementsCount =
|
||||||
|
process.platform === "win32"
|
||||||
|
? await this.preSearchAchievementsWindows()
|
||||||
|
: await this.preSearchAchievementsWithWine();
|
||||||
|
|
||||||
|
WindowManager.notificationWindow?.webContents.send(
|
||||||
|
"on-combined-achievements-unlocked",
|
||||||
|
newAchievementsCount.filter((achievements) => achievements).length,
|
||||||
|
newAchievementsCount.reduce((acc, val) => acc + val, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasFinishedMergingWithRemote = true;
|
||||||
|
};
|
||||||
|
}
|
325
src/main/services/achievements/find-achivement-files.ts
Normal file
325
src/main/services/achievements/find-achivement-files.ts
Normal file
|
@ -0,0 +1,325 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
const getAppDataPath = () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return app.getPath("appData");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = app.getPath("home").split("/").pop();
|
||||||
|
|
||||||
|
return path.join("drive_c", "users", user || "", "AppData", "Roaming");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocumentsPath = () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return app.getPath("documents");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = app.getPath("home").split("/").pop();
|
||||||
|
|
||||||
|
return path.join("drive_c", "users", user || "", "Documents");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPublicDocumentsPath = () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return path.join("C:", "Users", "Public", "Documents");
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join("drive_c", "users", "Public", "Documents");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalAppDataPath = () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return path.join(appData, "..", "Local");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = app.getPath("home").split("/").pop();
|
||||||
|
|
||||||
|
return path.join("drive_c", "users", user || "", "AppData", "Local");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgramDataPath = () => {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return path.join("C:", "ProgramData");
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join("drive_c", "ProgramData");
|
||||||
|
};
|
||||||
|
|
||||||
|
//TODO: change to a automatized method
|
||||||
|
const publicDocuments = getPublicDocumentsPath();
|
||||||
|
const programData = getProgramDataPath();
|
||||||
|
const appData = getAppDataPath();
|
||||||
|
const documents = getDocumentsPath();
|
||||||
|
const localAppData = getLocalAppDataPath();
|
||||||
|
|
||||||
|
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, "OnlineFix"),
|
||||||
|
fileLocation: ["Stats", "Achievements.ini"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderPath: path.join(publicDocuments, "OnlineFix"),
|
||||||
|
fileLocation: ["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", "RLD!"),
|
||||||
|
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(
|
||||||
|
game.winePrefixPath ?? "",
|
||||||
|
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.winePrefixPath ?? "",
|
||||||
|
game.executablePath,
|
||||||
|
"..",
|
||||||
|
"SteamData",
|
||||||
|
"user_stats.ini"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: Cracker._3dm,
|
||||||
|
filePath: path.join(
|
||||||
|
game.winePrefixPath ?? "",
|
||||||
|
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;
|
||||||
|
};
|
61
src/main/services/achievements/get-game-achievement-data.ts
Normal file
61
src/main/services/achievements/get-game-achievement-data.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import {
|
||||||
|
gameAchievementRepository,
|
||||||
|
userPreferencesRepository,
|
||||||
|
} from "@main/repository";
|
||||||
|
import { HydraApi } from "../hydra-api";
|
||||||
|
import type { AchievementData, GameShop } from "@types";
|
||||||
|
import { UserNotLoggedInError } from "@shared";
|
||||||
|
import { logger } from "../logger";
|
||||||
|
|
||||||
|
export const getGameAchievementData = async (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
useCachedData: boolean
|
||||||
|
) => {
|
||||||
|
if (useCachedData) {
|
||||||
|
const cachedAchievements = await gameAchievementRepository.findOne({
|
||||||
|
where: { objectId, shop },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedAchievements && cachedAchievements.achievements) {
|
||||||
|
return JSON.parse(cachedAchievements.achievements) as AchievementData[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPreferences = await userPreferencesRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return HydraApi.get<AchievementData[]>("/games/achievements", {
|
||||||
|
shop,
|
||||||
|
objectId,
|
||||||
|
language: userPreferences?.language || "en",
|
||||||
|
})
|
||||||
|
.then((achievements) => {
|
||||||
|
gameAchievementRepository.upsert(
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
achievements: JSON.stringify(achievements),
|
||||||
|
},
|
||||||
|
["objectId", "shop"]
|
||||||
|
);
|
||||||
|
|
||||||
|
return achievements;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof UserNotLoggedInError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
logger.error("Failed to get game achievements", err);
|
||||||
|
return gameAchievementRepository
|
||||||
|
.findOne({
|
||||||
|
where: { objectId, shop },
|
||||||
|
})
|
||||||
|
.then((gameAchievements) => {
|
||||||
|
return JSON.parse(
|
||||||
|
gameAchievements?.achievements || "[]"
|
||||||
|
) as AchievementData[];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
160
src/main/services/achievements/merge-achievements.ts
Normal file
160
src/main/services/achievements/merge-achievements.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import {
|
||||||
|
gameAchievementRepository,
|
||||||
|
userPreferencesRepository,
|
||||||
|
} from "@main/repository";
|
||||||
|
import type { AchievementData, GameShop, UnlockedAchievement } from "@types";
|
||||||
|
import { WindowManager } from "../window-manager";
|
||||||
|
import { HydraApi } from "../hydra-api";
|
||||||
|
import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements";
|
||||||
|
import { Game } from "@main/entity";
|
||||||
|
import { achievementsLogger } from "../logger";
|
||||||
|
import { SubscriptionRequiredError } from "@shared";
|
||||||
|
|
||||||
|
const saveAchievementsOnLocal = async (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
achievements: any[],
|
||||||
|
sendUpdateEvent: boolean
|
||||||
|
) => {
|
||||||
|
return gameAchievementRepository
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
unlockedAchievements: JSON.stringify(achievements),
|
||||||
|
},
|
||||||
|
["objectId", "shop"]
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
if (!sendUpdateEvent) return;
|
||||||
|
|
||||||
|
return getUnlockedAchievements(objectId, shop, true)
|
||||||
|
.then((achievements) => {
|
||||||
|
WindowManager.mainWindow?.webContents.send(
|
||||||
|
`on-update-achievements-${objectId}-${shop}`,
|
||||||
|
achievements
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeAchievements = async (
|
||||||
|
game: Game,
|
||||||
|
achievements: UnlockedAchievement[],
|
||||||
|
publishNotification: boolean
|
||||||
|
) => {
|
||||||
|
const [localGameAchievement, userPreferences] = await Promise.all([
|
||||||
|
gameAchievementRepository.findOne({
|
||||||
|
where: {
|
||||||
|
objectId: game.objectID,
|
||||||
|
shop: game.shop,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
userPreferencesRepository.findOne({ where: { id: 1 } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const achievementsData = JSON.parse(
|
||||||
|
localGameAchievement?.achievements || "[]"
|
||||||
|
) as AchievementData[];
|
||||||
|
|
||||||
|
const unlockedAchievements = JSON.parse(
|
||||||
|
localGameAchievement?.unlockedAchievements || "[]"
|
||||||
|
).filter((achievement) => achievement.name) as UnlockedAchievement[];
|
||||||
|
|
||||||
|
const newAchievementsMap = new Map(
|
||||||
|
achievements.reverse().map((achievement) => {
|
||||||
|
return [achievement.name.toUpperCase(), achievement];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAchievements = [...newAchievementsMap.values()]
|
||||||
|
.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 &&
|
||||||
|
userPreferences?.achievementNotificationsEnabled
|
||||||
|
) {
|
||||||
|
const achievementsInfo = newAchievements
|
||||||
|
.sort((a, b) => {
|
||||||
|
return a.unlockTime - b.unlockTime;
|
||||||
|
})
|
||||||
|
.map((achievement) => {
|
||||||
|
return achievementsData.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",
|
||||||
|
game.objectID,
|
||||||
|
game.shop,
|
||||||
|
achievementsInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
|
||||||
|
|
||||||
|
if (game.remoteId) {
|
||||||
|
await HydraApi.put(
|
||||||
|
"/profile/games/achievements",
|
||||||
|
{
|
||||||
|
id: game.remoteId,
|
||||||
|
achievements: mergedLocalAchievements,
|
||||||
|
},
|
||||||
|
{ needsCloud: true }
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
return saveAchievementsOnLocal(
|
||||||
|
response.objectId,
|
||||||
|
response.shop,
|
||||||
|
response.achievements,
|
||||||
|
publishNotification
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!(err instanceof SubscriptionRequiredError)) {
|
||||||
|
achievementsLogger.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return saveAchievementsOnLocal(
|
||||||
|
game.objectID,
|
||||||
|
game.shop,
|
||||||
|
mergedLocalAchievements,
|
||||||
|
publishNotification
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await saveAchievementsOnLocal(
|
||||||
|
game.objectID,
|
||||||
|
game.shop,
|
||||||
|
mergedLocalAchievements,
|
||||||
|
publishNotification
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAchievements.length;
|
||||||
|
};
|
291
src/main/services/achievements/parse-achievement-file.ts
Normal file
291
src/main/services/achievements/parse-achievement-file.ts
Normal file
|
@ -0,0 +1,291 @@
|
||||||
|
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 fileContent = readFileSync(filePath, "utf-8");
|
||||||
|
|
||||||
|
const lines =
|
||||||
|
fileContent.charCodeAt(0) === 0xfeff
|
||||||
|
? fileContent.slice(1).split(/[\r\n]+/)
|
||||||
|
: fileContent.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 {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonParse = (filePath: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf-8"));
|
||||||
|
} catch (err) {
|
||||||
|
achievementsLogger.error(`Error parsing ${filePath}`, err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||||
|
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
|
||||||
|
|
||||||
|
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||||
|
const unlockedAchievement = unlockedAchievements[achievement];
|
||||||
|
|
||||||
|
if (unlockedAchievement?.achieved == "true") {
|
||||||
|
parsedUnlockedAchievements.push({
|
||||||
|
name: achievement,
|
||||||
|
unlockTime: unlockedAchievement.timestamp * 1000,
|
||||||
|
});
|
||||||
|
} else if (unlockedAchievement?.Achieved == "true") {
|
||||||
|
const unlockTime = unlockedAchievement.TimeUnlocked;
|
||||||
|
|
||||||
|
parsedUnlockedAchievements.push({
|
||||||
|
name: achievement,
|
||||||
|
unlockTime:
|
||||||
|
unlockTime.length === 7
|
||||||
|
? unlockTime * 1000 * 1000
|
||||||
|
: unlockTime * 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 == "true") {
|
||||||
|
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 == "1") {
|
||||||
|
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) {
|
||||||
|
const unlocked = new DataView(
|
||||||
|
new Uint8Array(
|
||||||
|
Buffer.from(unlockedAchievement.State.toString(), "hex")
|
||||||
|
).buffer
|
||||||
|
).getUint32(0, true);
|
||||||
|
|
||||||
|
if (unlocked === 1) {
|
||||||
|
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,32 @@
|
||||||
|
import {
|
||||||
|
findAchievementFiles,
|
||||||
|
findAchievementFileInExecutableDirectory,
|
||||||
|
} from "./find-achivement-files";
|
||||||
|
import { parseAchievementFile } from "./parse-achievement-file";
|
||||||
|
import { mergeAchievements } from "./merge-achievements";
|
||||||
|
import type { UnlockedAchievement } from "@types";
|
||||||
|
import { Game } from "@main/entity";
|
||||||
|
|
||||||
|
export const updateLocalUnlockedAchivements = async (game: Game) => {
|
||||||
|
const gameAchievementFiles = findAchievementFiles(game);
|
||||||
|
|
||||||
|
const achievementFileInsideDirectory =
|
||||||
|
findAchievementFileInExecutableDirectory(game);
|
||||||
|
|
||||||
|
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||||
|
|
||||||
|
const unlockedAchievements: UnlockedAchievement[] = [];
|
||||||
|
|
||||||
|
for (const achievementFile of gameAchievementFiles) {
|
||||||
|
const localAchievementFile = parseAchievementFile(
|
||||||
|
achievementFile.filePath,
|
||||||
|
achievementFile.type
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localAchievementFile.length) {
|
||||||
|
unlockedAchievements.push(...localAchievementFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeAchievements(game, unlockedAchievements, false);
|
||||||
|
};
|
|
@ -7,7 +7,7 @@ import {
|
||||||
startTorrentClient as startRPCClient,
|
startTorrentClient as startRPCClient,
|
||||||
} from "./torrent-client";
|
} from "./torrent-client";
|
||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
import { DownloadProgress } from "@types";
|
import type { DownloadProgress } from "@types";
|
||||||
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
|
||||||
import { calculateETA } from "./helpers";
|
import { calculateETA } from "./helpers";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
|
@ -1,32 +1,65 @@
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { requestWebPage } from "@main/helpers";
|
import { requestWebPage } from "@main/helpers";
|
||||||
import { HowLongToBeatCategory } from "@types";
|
import type {
|
||||||
|
HowLongToBeatCategory,
|
||||||
|
HowLongToBeatSearchResponse,
|
||||||
|
} from "@types";
|
||||||
import { formatName } from "@shared";
|
import { formatName } from "@shared";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import UserAgent from "user-agents";
|
||||||
|
|
||||||
export interface HowLongToBeatResult {
|
const state = {
|
||||||
game_id: number;
|
apiKey: null as string | null,
|
||||||
profile_steam: number;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export interface HowLongToBeatSearchResponse {
|
const getHowLongToBeatSearchApiKey = async () => {
|
||||||
data: HowLongToBeatResult[];
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
|
const document = await requestWebPage("https://howlongtobeat.com/");
|
||||||
|
const scripts = Array.from(document.querySelectorAll("script"));
|
||||||
|
|
||||||
|
const appScript = scripts.find((script) =>
|
||||||
|
script.src.startsWith("/_next/static/chunks/pages/_app")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!appScript) return null;
|
||||||
|
|
||||||
|
const response = await axios.get(
|
||||||
|
`https://howlongtobeat.com${appScript.src}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"User-Agent": userAgent.toString(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = /fetch\("\/api\/search\/"\.concat\("(.*?)"\)/gm.exec(
|
||||||
|
response.data
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!results) return null;
|
||||||
|
|
||||||
|
return results[1];
|
||||||
|
};
|
||||||
|
|
||||||
export const searchHowLongToBeat = async (gameName: string) => {
|
export const searchHowLongToBeat = async (gameName: string) => {
|
||||||
|
state.apiKey = state.apiKey ?? (await getHowLongToBeatSearchApiKey());
|
||||||
|
if (!state.apiKey) return { data: [] };
|
||||||
|
|
||||||
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
const response = await axios
|
const response = await axios
|
||||||
.post(
|
.post(
|
||||||
"https://howlongtobeat.com/api/search",
|
`https://howlongtobeat.com/api/search/${state.apiKey}`,
|
||||||
{
|
{
|
||||||
searchType: "games",
|
searchType: "games",
|
||||||
searchTerms: formatName(gameName).split(" "),
|
searchTerms: formatName(gameName).split(" "),
|
||||||
searchPage: 1,
|
searchPage: 1,
|
||||||
size: 100,
|
size: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent":
|
"User-Agent": userAgent.toString(),
|
||||||
"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",
|
|
||||||
Referer: "https://howlongtobeat.com/",
|
Referer: "https://howlongtobeat.com/",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,27 @@
|
||||||
import { userAuthRepository } from "@main/repository";
|
import {
|
||||||
|
userAuthRepository,
|
||||||
|
userSubscriptionRepository,
|
||||||
|
} from "@main/repository";
|
||||||
import axios, { AxiosError, AxiosInstance } from "axios";
|
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import url from "url";
|
import url from "url";
|
||||||
import { uploadGamesBatch } from "./library-sync";
|
import { uploadGamesBatch } from "./library-sync";
|
||||||
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
|
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { UserNotLoggedInError } from "@shared";
|
import { UserNotLoggedInError, SubscriptionRequiredError } from "@shared";
|
||||||
import { omit } from "lodash-es";
|
import { omit } from "lodash-es";
|
||||||
import { appVersion } from "@main/constants";
|
import { appVersion } from "@main/constants";
|
||||||
|
|
||||||
interface HydraApiOptions {
|
interface HydraApiOptions {
|
||||||
needsAuth: boolean;
|
needsAuth?: boolean;
|
||||||
|
needsCloud?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HydraApi {
|
export class HydraApi {
|
||||||
private static instance: AxiosInstance;
|
private static instance: AxiosInstance;
|
||||||
|
|
||||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
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;
|
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
|
||||||
|
|
||||||
|
@ -30,6 +35,19 @@ export class HydraApi {
|
||||||
return this.userAuth.authToken !== "";
|
return this.userAuth.authToken !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async hasCloudSubscription() {
|
||||||
|
return userSubscriptionRepository
|
||||||
|
.findOne({ where: { id: 1 } })
|
||||||
|
.then((userSubscription) => {
|
||||||
|
if (!userSubscription) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
!userSubscription.expiresAt ||
|
||||||
|
userSubscription!.expiresAt > new Date()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static async handleExternalAuth(uri: string) {
|
static async handleExternalAuth(uri: string) {
|
||||||
const { payload } = url.parse(uri, true).query;
|
const { payload } = url.parse(uri, true).query;
|
||||||
|
|
||||||
|
@ -87,6 +105,7 @@ export class HydraApi {
|
||||||
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
|
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.ADD_LOG_INTERCEPTOR) {
|
||||||
this.instance.interceptors.request.use(
|
this.instance.interceptors.request.use(
|
||||||
(request) => {
|
(request) => {
|
||||||
logger.log(" ---- REQUEST -----");
|
logger.log(" ---- REQUEST -----");
|
||||||
|
@ -101,7 +120,6 @@ export class HydraApi {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.instance.interceptors.response.use(
|
this.instance.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
logger.log(" ---- RESPONSE -----");
|
logger.log(" ---- RESPONSE -----");
|
||||||
|
@ -118,9 +136,7 @@ export class HydraApi {
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
logger.error(" ---- RESPONSE ERROR -----");
|
logger.error(" ---- RESPONSE ERROR -----");
|
||||||
|
|
||||||
const { config } = error;
|
const { config } = error;
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
config.method,
|
config.method,
|
||||||
config.baseURL,
|
config.baseURL,
|
||||||
|
@ -128,19 +144,22 @@ export class HydraApi {
|
||||||
config.headers,
|
config.headers,
|
||||||
config.data
|
config.data
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
logger.error("Response", error.response.status, error.response.data);
|
logger.error(
|
||||||
|
"Response",
|
||||||
|
error.response.status,
|
||||||
|
error.response.data
|
||||||
|
);
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
logger.error("Request", error.request);
|
logger.error("Request", error.request);
|
||||||
} else {
|
} else {
|
||||||
logger.error("Error", error.message);
|
logger.error("Error", error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(" ----- END RESPONSE ERROR -------");
|
logger.error(" ----- END RESPONSE ERROR -------");
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const userAuth = await userAuthRepository.findOne({
|
const userAuth = await userAuthRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
|
@ -227,15 +246,28 @@ export class HydraApi {
|
||||||
throw err;
|
throw err;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static async validateOptions(options?: HydraApiOptions) {
|
||||||
|
const needsAuth = options?.needsAuth == undefined || options.needsAuth;
|
||||||
|
const needsCloud = options?.needsCloud === true;
|
||||||
|
|
||||||
|
if (needsAuth) {
|
||||||
|
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
||||||
|
await this.revalidateAccessTokenIfExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsCloud) {
|
||||||
|
if (!(await this.hasCloudSubscription())) {
|
||||||
|
throw new SubscriptionRequiredError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async get<T = any>(
|
static async get<T = any>(
|
||||||
url: string,
|
url: string,
|
||||||
params?: any,
|
params?: any,
|
||||||
options?: HydraApiOptions
|
options?: HydraApiOptions
|
||||||
) {
|
) {
|
||||||
if (!options || options.needsAuth) {
|
await this.validateOptions(options);
|
||||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
|
||||||
await this.revalidateAccessTokenIfExpired();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.instance
|
return this.instance
|
||||||
.get<T>(url, { params, ...this.getAxiosConfig() })
|
.get<T>(url, { params, ...this.getAxiosConfig() })
|
||||||
|
@ -248,10 +280,7 @@ export class HydraApi {
|
||||||
data?: any,
|
data?: any,
|
||||||
options?: HydraApiOptions
|
options?: HydraApiOptions
|
||||||
) {
|
) {
|
||||||
if (!options || options.needsAuth) {
|
await this.validateOptions(options);
|
||||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
|
||||||
await this.revalidateAccessTokenIfExpired();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.instance
|
return this.instance
|
||||||
.post<T>(url, data, this.getAxiosConfig())
|
.post<T>(url, data, this.getAxiosConfig())
|
||||||
|
@ -264,10 +293,7 @@ export class HydraApi {
|
||||||
data?: any,
|
data?: any,
|
||||||
options?: HydraApiOptions
|
options?: HydraApiOptions
|
||||||
) {
|
) {
|
||||||
if (!options || options.needsAuth) {
|
await this.validateOptions(options);
|
||||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
|
||||||
await this.revalidateAccessTokenIfExpired();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.instance
|
return this.instance
|
||||||
.put<T>(url, data, this.getAxiosConfig())
|
.put<T>(url, data, this.getAxiosConfig())
|
||||||
|
@ -280,10 +306,7 @@ export class HydraApi {
|
||||||
data?: any,
|
data?: any,
|
||||||
options?: HydraApiOptions
|
options?: HydraApiOptions
|
||||||
) {
|
) {
|
||||||
if (!options || options.needsAuth) {
|
await this.validateOptions(options);
|
||||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
|
||||||
await this.revalidateAccessTokenIfExpired();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.instance
|
return this.instance
|
||||||
.patch<T>(url, data, this.getAxiosConfig())
|
.patch<T>(url, data, this.getAxiosConfig())
|
||||||
|
@ -292,10 +315,7 @@ export class HydraApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
static async delete<T = any>(url: string, options?: HydraApiOptions) {
|
static async delete<T = any>(url: string, options?: HydraApiOptions) {
|
||||||
if (!options || options.needsAuth) {
|
await this.validateOptions(options);
|
||||||
if (!this.isLoggedIn()) throw new UserNotLoggedInError();
|
|
||||||
await this.revalidateAccessTokenIfExpired();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.instance
|
return this.instance
|
||||||
.delete<T>(url, this.getAxiosConfig())
|
.delete<T>(url, this.getAxiosConfig())
|
||||||
|
|
|
@ -8,3 +8,4 @@ export * from "./how-long-to-beat";
|
||||||
export * from "./process-watcher";
|
export * from "./process-watcher";
|
||||||
export * from "./main-loop";
|
export * from "./main-loop";
|
||||||
export * from "./hydra-api";
|
export * from "./hydra-api";
|
||||||
|
export * from "./ludusavi";
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { IsNull } from "typeorm";
|
||||||
import { HydraApi } from "../hydra-api";
|
import { HydraApi } from "../hydra-api";
|
||||||
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
||||||
import { WindowManager } from "../window-manager";
|
import { WindowManager } from "../window-manager";
|
||||||
|
import { AchievementWatcherManager } from "../achievements/achievement-watcher-manager";
|
||||||
|
|
||||||
export const uploadGamesBatch = async () => {
|
export const uploadGamesBatch = async () => {
|
||||||
const games = await gameRepository.find({
|
const games = await gameRepository.find({
|
||||||
|
@ -28,6 +29,8 @@ export const uploadGamesBatch = async () => {
|
||||||
|
|
||||||
await mergeWithRemoteGames();
|
await mergeWithRemoteGames();
|
||||||
|
|
||||||
|
AchievementWatcherManager.preSearchAchievements();
|
||||||
|
|
||||||
if (WindowManager.mainWindow)
|
if (WindowManager.mainWindow)
|
||||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,6 +10,10 @@ log.transports.file.resolvePathFn = (
|
||||||
return path.join(logsPath, "pythoninstance.txt");
|
return path.join(logsPath, "pythoninstance.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message?.scope == "achievements") {
|
||||||
|
return path.join(logsPath, "achievements.txt");
|
||||||
|
}
|
||||||
|
|
||||||
if (message?.level === "error") {
|
if (message?.level === "error") {
|
||||||
return path.join(logsPath, "error.txt");
|
return path.join(logsPath, "error.txt");
|
||||||
}
|
}
|
||||||
|
@ -29,3 +33,4 @@ log.initialize();
|
||||||
|
|
||||||
export const pythonInstanceLogger = log.scope("python-instance");
|
export const pythonInstanceLogger = log.scope("python-instance");
|
||||||
export const logger = log.scope("main");
|
export const logger = log.scope("main");
|
||||||
|
export const achievementsLogger = log.scope("achievements");
|
||||||
|
|
112
src/main/services/ludusavi.ts
Normal file
112
src/main/services/ludusavi.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import type { GameShop, LudusaviBackup, LudusaviConfig } from "@types";
|
||||||
|
import Piscina from "piscina";
|
||||||
|
|
||||||
|
import { app } from "electron";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import YAML from "yaml";
|
||||||
|
|
||||||
|
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
|
||||||
|
|
||||||
|
export class Ludusavi {
|
||||||
|
private static ludusaviPath = path.join(app.getPath("appData"), "ludusavi");
|
||||||
|
private static ludusaviConfigPath = path.join(
|
||||||
|
this.ludusaviPath,
|
||||||
|
"config.yaml"
|
||||||
|
);
|
||||||
|
private static binaryPath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, "ludusavi", "ludusavi")
|
||||||
|
: path.join(__dirname, "..", "..", "ludusavi", "ludusavi");
|
||||||
|
|
||||||
|
private static worker = new Piscina({
|
||||||
|
filename: ludusaviWorkerPath,
|
||||||
|
workerData: {
|
||||||
|
binaryPath: this.binaryPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
static async findGames(shop: GameShop, objectId: string): Promise<string[]> {
|
||||||
|
const games = await this.worker.run(
|
||||||
|
{ objectId, shop },
|
||||||
|
{ name: "findGames" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return games;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getConfig() {
|
||||||
|
if (!fs.existsSync(this.ludusaviConfigPath)) {
|
||||||
|
await this.worker.run(undefined, { name: "generateConfig" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = YAML.parse(
|
||||||
|
fs.readFileSync(this.ludusaviConfigPath, "utf-8")
|
||||||
|
) as LudusaviConfig;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async backupGame(
|
||||||
|
shop: GameShop,
|
||||||
|
objectId: string,
|
||||||
|
backupPath: string,
|
||||||
|
winePrefix?: string | null
|
||||||
|
): 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, winePrefix },
|
||||||
|
{ 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 [game] = games;
|
||||||
|
|
||||||
|
const backupData = await this.worker.run(
|
||||||
|
{ title: game, backupPath, preview: true },
|
||||||
|
{ name: "backupGame" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return backupData;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async restoreBackup(backupPath: string) {
|
||||||
|
return this.worker.run(backupPath, { name: "restoreBackup" });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addManifestToLudusaviConfig() {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
|
||||||
|
config.manifest.enable = false;
|
||||||
|
config.manifest.secondary = [
|
||||||
|
{ url: "https://cdn.losbroxas.org/manifest.yaml", enable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async addCustomGame(title: string, savePath: string) {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
const filteredGames = config.customGames.filter(
|
||||||
|
(game) => game.name !== title
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredGames.push({
|
||||||
|
name: title,
|
||||||
|
files: [savePath],
|
||||||
|
registry: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
config.customGames = filteredGames;
|
||||||
|
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { sleep } from "@main/helpers";
|
import { sleep } from "@main/helpers";
|
||||||
import { DownloadManager } from "./download";
|
import { DownloadManager } from "./download";
|
||||||
import { watchProcesses } from "./process-watcher";
|
import { watchProcesses } from "./process-watcher";
|
||||||
|
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
|
||||||
|
|
||||||
export const startMainLoop = async () => {
|
export const startMainLoop = async () => {
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
@ -8,8 +9,9 @@ export const startMainLoop = async () => {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
watchProcesses(),
|
watchProcesses(),
|
||||||
DownloadManager.watchDownloads(),
|
DownloadManager.watchDownloads(),
|
||||||
|
AchievementWatcherManager.watchAchievements(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await sleep(1000);
|
await sleep(1500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { IsNull, Not } from "typeorm";
|
||||||
import { gameRepository } from "@main/repository";
|
import { gameRepository } from "@main/repository";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||||
import { GameRunning } from "@types";
|
import type { GameRunning } from "@types";
|
||||||
import { PythonInstance } from "./download";
|
import { PythonInstance } from "./download";
|
||||||
import { Game } from "@main/entity";
|
import { Game } from "@main/entity";
|
||||||
|
|
||||||
|
@ -25,12 +25,12 @@ export const watchProcesses = async () => {
|
||||||
if (games.length === 0) return;
|
if (games.length === 0) return;
|
||||||
const processes = await PythonInstance.getProcessList();
|
const processes = await PythonInstance.getProcessList();
|
||||||
|
|
||||||
|
const processSet = new Set(processes.map((process) => process.exe));
|
||||||
|
|
||||||
for (const game of games) {
|
for (const game of games) {
|
||||||
const executablePath = game.executablePath!;
|
const executablePath = game.executablePath!;
|
||||||
|
|
||||||
const gameProcess = processes.find((runningProcess) => {
|
const gameProcess = processSet.has(executablePath);
|
||||||
return executablePath == runningProcess.exe;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (gameProcess) {
|
if (gameProcess) {
|
||||||
if (gamesPlaytime.has(game.id)) {
|
if (gamesPlaytime.has(game.id)) {
|
||||||
|
|
|
@ -17,7 +17,7 @@ export const requestSteam250 = async (path: string) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: $title.textContent,
|
title: $title.textContent,
|
||||||
objectID: steamGameUrl.split("/").pop(),
|
objectId: steamGameUrl.split("/").pop(),
|
||||||
} as Steam250Game;
|
} as Steam250Game;
|
||||||
})
|
})
|
||||||
.filter((game) => game != null);
|
.filter((game) => game != null);
|
||||||
|
@ -38,7 +38,7 @@ export const getSteam250List = async () => {
|
||||||
).flat();
|
).flat();
|
||||||
|
|
||||||
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
|
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;
|
return map;
|
||||||
}, new Map());
|
}, new Map());
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { GameShop } from "@types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export interface SteamGridResponse {
|
export interface SteamGridResponse {
|
||||||
|
@ -20,9 +21,9 @@ export interface SteamGridGameResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSteamGridData = async (
|
export const getSteamGridData = async (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
path: string,
|
path: string,
|
||||||
shop: string,
|
shop: GameShop,
|
||||||
params: Record<string, string> = {}
|
params: Record<string, string> = {}
|
||||||
): Promise<SteamGridResponse> => {
|
): Promise<SteamGridResponse> => {
|
||||||
const searchParams = new URLSearchParams(params);
|
const searchParams = new URLSearchParams(params);
|
||||||
|
@ -32,7 +33,7 @@ export const getSteamGridData = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.get(
|
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: {
|
headers: {
|
||||||
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
|
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
|
||||||
|
@ -58,10 +59,10 @@ export const getSteamGridGameById = async (
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSteamGameClientIcon = async (objectID: string) => {
|
export const getSteamGameClientIcon = async (objectId: string) => {
|
||||||
const {
|
const {
|
||||||
data: { id: steamGridGameId },
|
data: { id: steamGridGameId },
|
||||||
} = await getSteamGridData(objectID, "games", "steam");
|
} = await getSteamGridData(objectId, "games", "steam");
|
||||||
|
|
||||||
const steamGridGame = await getSteamGridGameById(steamGridGameId);
|
const steamGridGame = await getSteamGridGameById(steamGridGameId);
|
||||||
return steamGridGame.data.platforms.steam.metadata.clienticon;
|
return steamGridGame.data.platforms.steam.metadata.clienticon;
|
||||||
|
|
|
@ -12,11 +12,11 @@ export interface SteamAppDetailsResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSteamAppDetails = async (
|
export const getSteamAppDetails = async (
|
||||||
objectID: string,
|
objectId: string,
|
||||||
language: string
|
language: string
|
||||||
) => {
|
) => {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
appids: objectID,
|
appids: objectId,
|
||||||
l: language,
|
l: language,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ export const getSteamAppDetails = async (
|
||||||
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
|
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.data[objectID].success) return response.data[objectID].data;
|
if (response.data[objectId].success) return response.data[objectId].data;
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
43
src/main/services/user/get-user-data.ts
Normal file
43
src/main/services/user/get-user-data.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import type { UserDetails } from "@types";
|
||||||
|
import { HydraApi } from "../hydra-api";
|
||||||
|
import {
|
||||||
|
userAuthRepository,
|
||||||
|
userSubscriptionRepository,
|
||||||
|
} from "@main/repository";
|
||||||
|
import * as Sentry from "@sentry/electron/main";
|
||||||
|
|
||||||
|
export const getUserData = () => {
|
||||||
|
return HydraApi.get<UserDetails>(`/profile/me`).then(async (me) => {
|
||||||
|
userAuthRepository.upsert(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
displayName: me.displayName,
|
||||||
|
profileImageUrl: me.profileImageUrl,
|
||||||
|
backgroundImageUrl: me.backgroundImageUrl,
|
||||||
|
userId: me.id,
|
||||||
|
},
|
||||||
|
["id"]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (me.subscription) {
|
||||||
|
await userSubscriptionRepository.upsert(
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
subscriptionId: me.subscription?.id || "",
|
||||||
|
status: me.subscription?.status || "",
|
||||||
|
planId: me.subscription?.plan.id || "",
|
||||||
|
planName: me.subscription?.plan.name || "",
|
||||||
|
expiresAt: me.subscription?.expiresAt || null,
|
||||||
|
user: { id: 1 },
|
||||||
|
},
|
||||||
|
["id"]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await userSubscriptionRepository.delete({ id: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.setUser({ id: me.id, username: me.username });
|
||||||
|
|
||||||
|
return me;
|
||||||
|
});
|
||||||
|
};
|
|
@ -16,11 +16,13 @@ import trayIcon from "@resources/tray-icon.png?asset";
|
||||||
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
import { gameRepository, userPreferencesRepository } from "@main/repository";
|
||||||
import { IsNull, Not } from "typeorm";
|
import { IsNull, Not } from "typeorm";
|
||||||
import { HydraApi } from "./hydra-api";
|
import { HydraApi } from "./hydra-api";
|
||||||
|
import UserAgent from "user-agents";
|
||||||
|
|
||||||
export class WindowManager {
|
export class WindowManager {
|
||||||
public static mainWindow: Electron.BrowserWindow | null = null;
|
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.
|
// HMR for renderer base on electron-vite cli.
|
||||||
// Load the remote URL for development or the local html file for production.
|
// Load the remote URL for development or the local html file for production.
|
||||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||||
|
@ -37,6 +39,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() {
|
public static createMainWindow() {
|
||||||
if (this.mainWindow) return;
|
if (this.mainWindow) return;
|
||||||
|
|
||||||
|
@ -61,7 +78,59 @@ export class WindowManager {
|
||||||
show: false,
|
show: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.loadURL();
|
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||||
|
(details, callback) => {
|
||||||
|
if (details.webContentsId !== this.mainWindow?.webContents.id) {
|
||||||
|
return callback(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = new UserAgent();
|
||||||
|
|
||||||
|
callback({
|
||||||
|
requestHeaders: {
|
||||||
|
...details.requestHeaders,
|
||||||
|
"user-agent": userAgent.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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.removeMenu();
|
||||||
|
|
||||||
this.mainWindow.on("ready-to-show", () => {
|
this.mainWindow.on("ready-to-show", () => {
|
||||||
|
@ -78,9 +147,36 @@ export class WindowManager {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
WindowManager.mainWindow?.setProgressBar(-1);
|
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() {
|
public static openAuthWindow() {
|
||||||
if (this.mainWindow) {
|
if (this.mainWindow) {
|
||||||
const authWindow = new BrowserWindow({
|
const authWindow = new BrowserWindow({
|
||||||
|
@ -101,6 +197,8 @@ export class WindowManager {
|
||||||
|
|
||||||
authWindow.removeMenu();
|
authWindow.removeMenu();
|
||||||
|
|
||||||
|
if (!app.isPackaged) authWindow.webContents.openDevTools();
|
||||||
|
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
lng: i18next.language,
|
lng: i18next.language,
|
||||||
});
|
});
|
||||||
|
@ -125,14 +223,14 @@ export class WindowManager {
|
||||||
|
|
||||||
public static redirect(hash: string) {
|
public static redirect(hash: string) {
|
||||||
if (!this.mainWindow) this.createMainWindow();
|
if (!this.mainWindow) this.createMainWindow();
|
||||||
this.loadURL(hash);
|
this.loadMainWindowURL(hash);
|
||||||
|
|
||||||
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
|
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
|
||||||
this.mainWindow?.focus();
|
this.mainWindow?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static createSystemTray(language: string) {
|
public static createSystemTray(language: string) {
|
||||||
let tray;
|
let tray: Tray;
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
const macIcon = nativeImage
|
const macIcon = nativeImage
|
||||||
|
|
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
|
@ -5,6 +5,7 @@ interface ImportMetaEnv {
|
||||||
readonly MAIN_VITE_API_URL: string;
|
readonly MAIN_VITE_API_URL: string;
|
||||||
readonly MAIN_VITE_AUTH_URL: string;
|
readonly MAIN_VITE_AUTH_URL: string;
|
||||||
readonly MAIN_VITE_SENTRY_DSN: string;
|
readonly MAIN_VITE_SENTRY_DSN: string;
|
||||||
|
readonly MAIN_VITE_CHECKOUT_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
65
src/main/workers/ludusavi.worker.ts
Normal file
65
src/main/workers/ludusavi.worker.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateConfig = () => {
|
||||||
|
const result = cp.execFileSync(binaryPath, ["schema", "config"]);
|
||||||
|
|
||||||
|
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
|
||||||
|
};
|
|
@ -1,4 +1,4 @@
|
||||||
import { SteamGame } from "@types";
|
import type { SteamGame } from "@types";
|
||||||
import { slice } from "lodash-es";
|
import { slice } from "lodash-es";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,8 @@ import type {
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { CatalogueCategory } from "@shared";
|
import type { CatalogueCategory } from "@shared";
|
||||||
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import { GameAchievement } from "@main/entity";
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electron", {
|
contextBridge.exposeInMainWorld("electron", {
|
||||||
/* Torrenting */
|
/* Torrenting */
|
||||||
|
@ -37,18 +39,63 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
||||||
getCatalogue: (category: CatalogueCategory) =>
|
getCatalogue: (category: CatalogueCategory) =>
|
||||||
ipcRenderer.invoke("getCatalogue", category),
|
ipcRenderer.invoke("getCatalogue", category),
|
||||||
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
||||||
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
|
||||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||||
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
|
getHowLongToBeat: (title: string) =>
|
||||||
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
|
ipcRenderer.invoke("getHowLongToBeat", title),
|
||||||
getGames: (take?: number, prevCursor?: number) =>
|
getGames: (take?: number, skip?: number) =>
|
||||||
ipcRenderer.invoke("getGames", take, prevCursor),
|
ipcRenderer.invoke("getGames", take, skip),
|
||||||
searchGameRepacks: (query: string) =>
|
searchGameRepacks: (query: string) =>
|
||||||
ipcRenderer.invoke("searchGameRepacks", query),
|
ipcRenderer.invoke("searchGameRepacks", query),
|
||||||
getGameStats: (objectId: string, shop: GameShop) =>
|
getGameStats: (objectId: string, shop: GameShop) =>
|
||||||
ipcRenderer.invoke("getGameStats", objectId, shop),
|
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
onCombinedAchievementsUnlocked: (
|
||||||
|
cb: (gameCount: number, achievementsCount: number) => void
|
||||||
|
) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
gameCount: number,
|
||||||
|
achievementCount: number
|
||||||
|
) => cb(gameCount, achievementCount);
|
||||||
|
ipcRenderer.on("on-combined-achievements-unlocked", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("on-combined-achievements-unlocked", listener);
|
||||||
|
},
|
||||||
|
onUpdateAchievements: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
cb: (achievements: GameAchievement[]) => void
|
||||||
|
) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
achievements: GameAchievement[]
|
||||||
|
) => cb(achievements);
|
||||||
|
ipcRenderer.on(`on-update-achievements-${objectId}-${shop}`, listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener(
|
||||||
|
`on-update-achievements-${objectId}-${shop}`,
|
||||||
|
listener
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
/* User preferences */
|
/* User preferences */
|
||||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||||
|
@ -64,12 +111,14 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
ipcRenderer.invoke("deleteDownloadSource", id),
|
ipcRenderer.invoke("deleteDownloadSource", id),
|
||||||
|
|
||||||
/* Library */
|
/* Library */
|
||||||
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
|
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
|
||||||
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
|
ipcRenderer.invoke("addGameToLibrary", objectId, title, shop),
|
||||||
createGameShortcut: (id: number) =>
|
createGameShortcut: (id: number) =>
|
||||||
ipcRenderer.invoke("createGameShortcut", id),
|
ipcRenderer.invoke("createGameShortcut", id),
|
||||||
updateExecutablePath: (id: number, executablePath: string) =>
|
updateExecutablePath: (id: number, executablePath: string) =>
|
||||||
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
|
ipcRenderer.invoke("updateExecutablePath", id, executablePath),
|
||||||
|
selectGameWinePrefix: (id: number, winePrefixPath: string) =>
|
||||||
|
ipcRenderer.invoke("selectGameWinePrefix", id, winePrefixPath),
|
||||||
verifyExecutablePathInUse: (executablePath: string) =>
|
verifyExecutablePathInUse: (executablePath: string) =>
|
||||||
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
|
ipcRenderer.invoke("verifyExecutablePathInUse", executablePath),
|
||||||
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
getLibrary: () => ipcRenderer.invoke("getLibrary"),
|
||||||
|
@ -87,8 +136,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
|
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
|
||||||
deleteGameFolder: (gameId: number) =>
|
deleteGameFolder: (gameId: number) =>
|
||||||
ipcRenderer.invoke("deleteGameFolder", gameId),
|
ipcRenderer.invoke("deleteGameFolder", gameId),
|
||||||
getGameByObjectID: (objectID: string) =>
|
getGameByObjectId: (objectId: string) =>
|
||||||
ipcRenderer.invoke("getGameByObjectID", objectID),
|
ipcRenderer.invoke("getGameByObjectId", objectId),
|
||||||
onGamesRunning: (
|
onGamesRunning: (
|
||||||
cb: (
|
cb: (
|
||||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||||
|
@ -110,14 +159,75 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
getDiskFreeSpace: (path: string) =>
|
getDiskFreeSpace: (path: string) =>
|
||||||
ipcRenderer.invoke("getDiskFreeSpace", path),
|
ipcRenderer.invoke("getDiskFreeSpace", path),
|
||||||
|
|
||||||
|
/* Cloud save */
|
||||||
|
uploadSaveGame: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
downloadOptionTitle: string | null
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke("uploadSaveGame", objectId, shop, downloadOptionTitle),
|
||||||
|
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),
|
||||||
|
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-progress-${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 */
|
/* Misc */
|
||||||
ping: () => ipcRenderer.invoke("ping"),
|
ping: () => ipcRenderer.invoke("ping"),
|
||||||
getVersion: () => ipcRenderer.invoke("getVersion"),
|
getVersion: () => ipcRenderer.invoke("getVersion"),
|
||||||
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
|
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
|
||||||
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
|
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
|
||||||
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
|
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
|
||||||
|
openCheckout: () => ipcRenderer.invoke("openCheckout"),
|
||||||
showOpenDialog: (options: Electron.OpenDialogOptions) =>
|
showOpenDialog: (options: Electron.OpenDialogOptions) =>
|
||||||
ipcRenderer.invoke("showOpenDialog", options),
|
ipcRenderer.invoke("showOpenDialog", options),
|
||||||
|
showItemInFolder: (path: string) =>
|
||||||
|
ipcRenderer.invoke("showItemInFolder", path),
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|
||||||
/* Auto update */
|
/* Auto update */
|
||||||
|
@ -162,6 +272,19 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
|
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
|
||||||
reportUser: (userId: string, reason: string, description: string) =>
|
reportUser: (userId: string, reason: string, description: string) =>
|
||||||
ipcRenderer.invoke("reportUser", userId, reason, description),
|
ipcRenderer.invoke("reportUser", userId, reason, description),
|
||||||
|
getComparedUnlockedAchievements: (
|
||||||
|
objectId: string,
|
||||||
|
shop: GameShop,
|
||||||
|
userId: string
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke(
|
||||||
|
"getComparedUnlockedAchievements",
|
||||||
|
objectId,
|
||||||
|
shop,
|
||||||
|
userId
|
||||||
|
),
|
||||||
|
getUnlockedAchievements: (objectId: string, shop: GameShop) =>
|
||||||
|
ipcRenderer.invoke("getUnlockedAchievements", objectId, shop),
|
||||||
|
|
||||||
/* Auth */
|
/* Auth */
|
||||||
signOut: () => ipcRenderer.invoke("signOut"),
|
signOut: () => ipcRenderer.invoke("signOut"),
|
||||||
|
|
|
@ -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: *;"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body style="background-color: #1c1c1c">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue