Compare commits

...

135 Commits

Author SHA1 Message Date
Zamitto
49ca2bf3c2 Merge branch 'feature/game-achievements' into chore/test-preview
# Conflicts:
#	src/locales/en/translation.json
#	src/locales/pt-BR/translation.json
2024-10-07 12:35:25 -03:00
Zamitto
6d4f957e2b feat: add achievement section title 2024-10-07 12:26:36 -03:00
Zamitto
7c9c27801f feat: add dodi folder 2024-10-07 11:52:25 -03:00
Zamitto
737ad5a24d chore: update lock file 2024-10-06 15:19:03 -03:00
Zamitto
45c3cb8ca9 Merge branch 'feature/game-achievements' into chore/test-preview
# Conflicts:
#	src/main/events/library/add-game-to-library.ts
#	src/main/services/achievements/parse-achievement-file.ts
2024-10-06 15:17:06 -03:00
Zamitto
7f09a7796f feat: remove awaits 2024-10-06 15:15:24 -03:00
Zamitto
387ee86c0f feat: creamapi process 2024-10-06 11:18:35 -03:00
Zamitto
af83152997 feat: creamapi process 2024-10-06 11:18:14 -03:00
Zamitto
5d21adcbb1 wip: get alternate objectIds 2024-10-06 02:48:46 -03:00
Zamitto
b3a1111bfc Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-06 02:33:09 -03:00
Zamitto
456e7ed809 feat: add alternative objectIds 2024-10-06 02:33:02 -03:00
Zamitto
e97d439d13 Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-05 23:10:44 -03:00
Zamitto
f5da836b1b feat: remove unneeded log persist 2024-10-05 23:10:32 -03:00
Zamitto
96b15d341a Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-05 23:07:28 -03:00
Zamitto
1ea64d7243 fix: file not being processed if it was created after watcher started 2024-10-05 23:07:14 -03:00
Zamitto
cb141c9ceb Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-05 22:44:10 -03:00
Zamitto
57118ec5b9 feat: add rle folder 2024-10-05 22:43:19 -03:00
Zamitto
002028130b feat: try add FLT and fix possible bug on unlockedAchievements 2024-10-05 22:17:00 -03:00
Zamitto
8fdc6c4ab2 Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-05 20:33:06 -03:00
Zamitto
71e7f1ee58 fix: process user_stats.ini 2024-10-05 20:31:05 -03:00
Chubby Granny Chaser
0222121288 fix: fixing multiple folders 2024-10-05 07:12:47 +01:00
Chubby Granny Chaser
f6acfa4aee feat: removing existing directory 2024-10-05 04:53:15 +01:00
Zamitto
2a6b757e37 feat: get achievement from game directory on launch 2024-10-05 00:49:52 -03:00
Chubby Granny Chaser
9391b7e6c9 feat: removing directory sync 2024-10-05 04:29:29 +01:00
Chubby Granny Chaser
b99dbe83e2 adding mkdir for backup path 2024-10-05 03:55:48 +01:00
Chubby Granny Chaser
bcbe6c9619 updating current home dir 2024-10-05 03:53:51 +01:00
Chubby Granny Chaser
0873c8e244 fix: fixing windows path replacement 2024-10-05 03:27:40 +01:00
Chubby Granny Chaser
58502aeb1f feat: adding change hero 2024-10-05 02:25:46 +01:00
Chubby Granny Chaser
4222fcec52 feat: adding change hero 2024-10-05 02:22:43 +01:00
Chubby Granny Chaser
035e424a76 feat: adding change hero 2024-10-05 02:21:41 +01:00
Zamitto
5e313a0374 feat: add 3dm file inside game directory 2024-10-04 16:37:42 -03:00
Zamitto
81e2bda049 chore: add preview on version 2024-10-04 13:58:32 -03:00
Zamitto
d27ce9781f Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-04 13:56:40 -03:00
Zamitto
94e242168c fix: file location 2024-10-04 13:48:03 -03:00
Zamitto
e4ca3d38ec Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-04 13:27:03 -03:00
Zamitto
0acb0fd4c8 feat: add more folders and organize code 2024-10-04 13:26:52 -03:00
Zamitto
241d7692b9 feat: add new rld folder 2024-10-04 11:56:36 -03:00
Zamitto
0895e9ec72 Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-03 21:29:33 -03:00
Zamitto
9b932358e8 feat: increase notification time 2024-10-03 21:29:07 -03:00
Zamitto
3ed4547dfe Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-03 21:11:16 -03:00
Zamitto
9731035820 feat: searching new folders 2024-10-03 21:04:37 -03:00
Zamitto
7e2d9316f3 feat: achievement animation 2024-10-03 19:13:00 -03:00
Zamitto
7cddcd8147 feat: refactoring code 2024-10-03 11:16:30 -03:00
Zamitto
5da9eb6366 Merge branch 'feature/game-achievements' into chore/test-preview 2024-10-02 23:09:15 -03:00
Zamitto
9c7651d8e2 feat: dev tools 2024-10-02 23:08:58 -03:00
Zamitto
8b5ed96e9b Merge branch 'feature/game-achievements' into chore/test-preview
# Conflicts:
#	yarn.lock
2024-10-02 22:59:32 -03:00
Zamitto
f0e0abae8c feat: showing achievements queue 2024-10-02 22:58:10 -03:00
Zamitto
cadb9e8dff feat: add GSE Saves 2024-10-02 21:55:42 -03:00
Zamitto
beaa919c80 feat: refactoring notification window 2024-10-02 18:01:58 -03:00
Zamitto
ef4844b8c0 Merge branch 'feature/game-achievements' into chore/test-preview
# Conflicts:
#	src/main/services/window-manager.ts
#	src/renderer/src/context/game-details/game-details.context.tsx
#	src/renderer/src/declaration.d.ts
#	src/types/index.ts
#	yarn.lock
2024-10-02 15:16:43 -03:00
Zamitto
d5b1bcdc7f fix: adjustment to get achievements when file is created after watcher started 2024-10-02 13:59:48 -03:00
Zamitto
05652d9c1b feat: refactor watcher 2024-10-02 13:30:07 -03:00
Zamitto
6a0f47eacb feat: use uppercase 2024-10-01 23:53:37 -03:00
Zamitto
8f9508c00e chore: bump libraries
# Conflicts:
#	package.json
#	yarn.lock
2024-10-01 23:52:40 -03:00
Zamitto
44e59a5f6f feat: refactoring achievements watcher 2024-10-01 22:29:42 -03:00
Zamitto
c18c41ac95 feat: update achievement audio and refactors 2024-10-01 18:34:46 -03:00
Zamitto
084b7f5b9c chore: bump libraries
# Conflicts:
#	yarn.lock
2024-10-01 18:32:02 -03:00
Zamitto
92b0ced08a feat: logs 2024-10-01 11:08:08 -03:00
Zamitto
f6ce6eddb8 feat: logs 2024-10-01 10:50:30 -03:00
Zamitto
a031049b73 feat: adjustments 2024-09-29 16:46:12 -03:00
Zamitto
a48e269d7f feat: adjustment update achievements on api 2024-09-29 16:24:57 -03:00
Zamitto
333b143b17 feat: route adjustment 2024-09-29 11:36:44 -03:00
Chubby Granny Chaser
586df616e8 feat: removing session interception from auth 2024-09-28 00:59:17 +01:00
Zamitto
eda47fc6af Merge branch 'main' into feature/game-achievements
# Conflicts:
#	src/renderer/src/context/game-details/game-details.context.tsx
#	src/renderer/src/main.tsx
2024-09-27 20:54:02 -03:00
Chubby Granny Chaser
202751ddca feat: adding logger 2024-09-28 00:24:18 +01:00
Chubby Granny Chaser
790f7a2549 feat: adding logger 2024-09-28 00:23:47 +01:00
Chubby Granny Chaser
eebd09ccf2 feat: adding logger 2024-09-28 00:22:38 +01:00
Chubby Granny Chaser
ac9565f924 Merge branch 'main' of github.com:hydralauncher/hydra into feature/cloud-sync 2024-09-27 23:19:51 +01:00
Chubby Granny Chaser
55a92fd68a docs: moving readme 2024-09-27 23:19:39 +01:00
Chubby Granny Chaser
37111c11d8 Merge pull request #1008 from hydralauncher/fix/migrate-repacks-from-sqlite-to-dexie
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Fix/migrate repacks from sqlite to dexie
2024-09-27 23:15:37 +01:00
Zamitto
84420668fc fix: import 2024-09-27 18:20:29 -03:00
Zamitto
4bf25f8c52 feat: adjust notifications code 2024-09-27 17:12:12 -03:00
Zamitto
bdba3dd29c feat: browser window for notification 2024-09-27 14:59:08 -03:00
Chubby Granny Chaser
43e9919b6b fix: adding direct comparison last downloaded option 2024-09-27 03:39:42 +01:00
Chubby Granny Chaser
2aa393967f Merge branch 'fix/migrate-repacks-from-sqlite-to-dexie' of github.com:hydralauncher/hydra into fix/migrate-repacks-from-sqlite-to-dexie 2024-09-27 03:27:55 +01:00
Chubby Granny Chaser
17febcd88a feat: adding notification when all repacks are migrated 2024-09-27 03:27:02 +01:00
Zamitto
753a293cd7 feat: remove notification spam 2024-09-26 21:51:15 -03:00
Zamitto
d7c05247c3 feat: merge achievements with remote 2024-09-26 18:27:35 -03:00
Zamitto
54dae87a58 feat: grayscale and update game details context on achievement event 2024-09-26 17:47:15 -03:00
Zamitto
50b34dc864 feat: add catch 2024-09-26 16:13:41 -03:00
Zamitto
08fbd4c8d8 feat: fix notification icons 2024-09-26 16:09:36 -03:00
Zamitto
780ab5f909 feat: refactor 2024-09-26 15:33:32 -03:00
Zamitto
c72eefdb77 feat: notifications 2024-09-26 14:50:23 -03:00
Zamitto
5c790edb2c feat: real time achievement track 2024-09-26 13:37:33 -03:00
Zamitto
24d21b9839 feat: use different db for staging build 2024-09-26 09:30:37 -03:00
Chubby Granny Chaser
5b9d860937 feat: adding i18n for cloud sync 2024-09-25 22:22:52 +01:00
Chubby Granny Chaser
9b5e13ad35 feat: adding i18n for cloud sync 2024-09-25 22:22:26 +01:00
Zamitto
25cfdb50d8 feat: refactor 2024-09-25 18:16:32 -03:00
Chubby Granny Chaser
32fa69627c Merge branch 'feature/cloud-sync' of github.com:hydralauncher/hydra into feature/cloud-sync 2024-09-25 21:07:48 +01:00
Chubby Granny Chaser
3e165e05fb fix: adding no backup preview condition 2024-09-25 21:07:41 +01:00
Chubby Granny Chaser
89b830fe9a feat: clearing backup history on sign out 2024-09-25 20:44:56 +01:00
Chubby Granny Chaser
2d7aef34c6 Merge branch 'main' into feature/cloud-sync 2024-09-25 19:54:38 +01:00
Chubby Granny Chaser
0ea7329aa3 fix: fixing chmod for windows on postinstall 2024-09-25 19:53:37 +01:00
Chubby Granny Chaser
b87aade2a3 ci: pointing build to staging 2024-09-25 19:48:32 +01:00
Chubby Granny Chaser
41c80daaaa Merge branch 'main' into fix/migrate-repacks-from-sqlite-to-dexie 2024-09-25 19:38:43 +01:00
Chubby Granny Chaser
e64a414309 feat: adding cloud sync 2024-09-25 19:37:28 +01:00
Zamitto
f98432f6c6 feat: refactor game achievement table 2024-09-24 17:31:49 -03:00
Zamitto
f3a5f90bc7 feat: save achievements cache 2024-09-24 16:32:48 -03:00
Zamitto
d57980edc7 Merge pull request #987 from alexhostrup/danish-translation
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
Added a danish README
2024-09-24 14:28:07 -03:00
Zamitto
97dc9b03b0 Merge branch 'main' into danish-translation 2024-09-24 14:19:41 -03:00
Zamitto
7e3cf0a00e feat: starting showing local achievements 2024-09-24 14:00:56 -03:00
Zamitto
5b0cf1e82b feat: handle user not logged in error 2024-09-24 13:06:32 -03:00
Zamitto
500cd2a531 feat: saving achievements on open launcher 2024-09-24 13:06:24 -03:00
JackEnx
8fb62af0cf feature: wip-game-achievements
refactor: rename files
2024-09-24 10:33:54 -03:00
Zamitto
fabeedaa8a Merge pull request #1012 from hydralauncher/chore/update-steam-games
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run
chore: update steam games
2024-09-23 14:45:02 -03:00
Zamitto
e701a273d8 fix: ui when game has no icon 2024-09-23 14:20:57 -03:00
Zamitto
6c32fdbcbb Merge branch 'main' into chore/update-steam-games 2024-09-23 13:46:47 -03:00
Zamitto
61b2d55d47 Merge branch 'main' into danish-translation 2024-09-23 12:22:41 -03:00
Zamitto
7b5e4459d4 chore: update READMES 2024-09-23 12:16:22 -03:00
Zamitto
5b450db5eb chore: update steam games 2024-09-22 22:01:50 -03:00
Chubby Granny Chaser
d88e06e289 Merge branch 'main' of github.com:hydralauncher/hydra into fix/migrate-repacks-from-sqlite-to-dexie 2024-09-22 21:09:06 +01:00
Zamitto
e77991ea16 Merge pull request #1009 from hydralauncher/chore/update-api-url
Some checks are pending
Release / build (ubuntu-latest) (push) Waiting to run
Release / build (windows-latest) (push) Waiting to run
chore: update api and auth url
2024-09-22 16:42:19 -03:00
Zamitto
5e55c05bd7 chore: i18n for friend code 2024-09-22 16:30:47 -03:00
Zamitto
81e74f068f fix: import correctly vite env 2024-09-22 16:25:16 -03:00
Zamitto
d5a510175f feat: use env to open auth window 2024-09-22 16:21:01 -03:00
Zamitto
2b2f29da61 chore: add envs to pipeline 2024-09-22 16:20:45 -03:00
Chubby Granny Chaser
339dc89702 feat: adding dexie 2024-09-22 17:45:50 +01:00
Chubby Granny Chaser
50028fbfd8 feat: adding dexie 2024-09-22 17:45:01 +01:00
Chubby Granny Chaser
d97c5b894a feat: adding dexie 2024-09-22 17:44:06 +01:00
Chubby Granny Chaser
f860439fb5 feat: adding dexie 2024-09-22 17:43:05 +01:00
Chubby Granny Chaser
ddd6ff7dbe Merge branch 'main' of github.com:hydralauncher/hydra into feature/adding-flame-animation 2024-09-21 21:19:08 +01:00
Chubby Granny Chaser
849b6de6bc feat: adding dexie 2024-09-21 21:19:00 +01:00
Zamitto
79e2eb042a Merge pull request #1001 from hydralauncher/chore/bump-version-and-update-CSP
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
chore: bump version and change CSP
2024-09-21 10:47:32 -03:00
Zamitto
f9ad26c836 chore: bump version and change CSP 2024-09-21 10:38:53 -03:00
Zamitto
f6fbfe33e0 Merge pull request #988 from alexhostrup/nb-translation
Norwegian bokmål translation and README
2024-09-21 10:38:26 -03:00
Alexander Hostrup
18d6ca630a Merge branch 'main' into nb-translation 2024-09-20 23:39:59 +02:00
Alexander Hostrup
c7afcff0b4 Fixed small issue with language code being wrong 2024-09-20 23:38:20 +02:00
Zamitto
a3bdfe7641 Merge pull request #995 from hydralauncher/fix/game-details-gallery-align
Some checks failed
Release / build (ubuntu-latest) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled
fix: game details gallery align
2024-09-19 20:48:48 -03:00
Zamitto
e7b4f8e1c8 feat: add lazy loading 2024-09-19 15:38:33 -03:00
Zamitto
e526c0f650 feat: adjust align on wider images 2024-09-19 15:38:20 -03:00
Zamitto
9791c311f1 chore: update bug issue template 2024-09-18 15:46:14 -03:00
Alexander Hostrup
b1ea9cbfaa formatting 2024-09-18 19:54:08 +02:00
Alexander Hostrup
74d2ec8238 Added translation and README in Norwegian Bokmål 2024-09-18 19:53:46 +02:00
Alexander Hostrup
f8f2124cec Forgot something 2024-09-18 18:08:30 +02:00
Alexander Hostrup
3833e11e98 Added a danish README 2024-09-18 18:04:32 +02:00
153 changed files with 7804 additions and 2446 deletions

View File

@@ -1,4 +1,5 @@
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
SENTRY_AUTH_TOKEN=

View File

@@ -33,18 +33,11 @@ body:
attributes:
label: Additional information and data
description: |
If possible, add screenshots and upload your logs file here.
Add screenshots and upload your logs file here.
Logs location on Windows: "%appdata%/hydra"
Logs location on Linux: "~/.config/hydra/"
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If possible, add screenshots to help explain your problem.
validations:
required: false
required: true
- type: input
id: OS
attributes:

View File

@@ -40,7 +40,8 @@ jobs:
sudo apt-get install -y libarchive-tools
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -49,7 +50,8 @@ jobs:
if: matrix.os == 'windows-latest'
run: yarn build:win
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_STAGING_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -43,6 +43,7 @@ jobs:
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -52,10 +53,27 @@ jobs:
run: yarn build:win
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.os }}
path: |
dist/win-unpacked/**
dist/*-portable.exe
dist/*.zip
dist/*.dmg
dist/*.deb
dist/*.rpm
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
dist/*.pacman
- name: Release
uses: softprops/action-gh-release@v1
with:

5
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.vscode
node_modules
.vscode/
node_modules/
hydra-download-manager/
fastlist.exe
__pycache__
@@ -10,3 +10,4 @@ out
.env
.vite
sentry.properties
ludusavi/

View File

@@ -13,18 +13,20 @@
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md)
[![es](https://img.shields.io/badge/lang-es-red)](README.es.md)
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](./README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](./README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](./README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](./README.uk-UA.md)
[![be](https://img.shields.io/badge/lang-be-orange)](./README.be.md)
[![es](https://img.shields.io/badge/lang-es-red)](./README.es.md)
[![fr](https://img.shields.io/badge/lang-fr-blue)](./README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](./README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](./README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](./README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](./README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](./README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
![Hydra Catalogue](./screenshot.png)
</div>

View File

@@ -2,5 +2,6 @@
${ifNot} ${isUpdated}
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
RMDir /r "$APPDATA\hydra"
RMDir /r "$LOCALAPPDATA\hydralauncher-updater"
${endIf}
!macroend

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - гэта гульнявы лаўнчар з уласным убудаваным кліентам BitTorrent і самастойным scraper`ам для рэпакаў.</strong>
<strong>Hydra - гэта гульнявы лаўнчар з уласным убудаваным кліентам BitTorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,8 +23,10 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
![Hydra Catalogue](./screenshot.png)
</div>
@@ -139,9 +141,8 @@ pip install -r requirements.txt
## Пераменныя асяроддзі
Вам спатрэбіцца ключ API SteamGridDB, каб атрымаць значкі гульняў пры ўсталёўкі.
Калі вы жадаеце выкарыстоўваць onlinefix у якасці рэпака, вам трэба дадаць вашыя ўліковыя дадзеныя ў файл .env.
Як толькі вы атрымаеце ключ, вы зможаце скапіяваць або пераназваць файл `.env.example` у `.env` і змясціць у яго `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Як толькі вы атрымаеце ключ, вы зможаце скапіяваць або пераназваць файл `.env.example` у `.env` і змясціць у яго `STEAMGRIDDB_API_KEY`.
## Запуск

View File

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

186
docs/README.da.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra è un game launcher con il proprio client bittorrent e autogestore di repacks.</strong>
<strong>Hydra è un game launcher con il proprio client bittorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,8 +23,10 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
![Hydra Catalogue](./screenshot.png)
</div>
@@ -55,17 +57,15 @@
## A proposito
**Hydra** è un **Game Launcher** con il proprio **Client BitTorrent** e **autogestore di repack**.
**Hydra** è un **Game Launcher** con il proprio **Client BitTorrent**.
<br>
Il launcher è scritto in TypeScript (Electron) and Python, che gestisce il sistema di torrenting appoggiandosi a libtorrent.
## Caratteristiche
- Motore di ricerca automatizzato sulle fonti di repack dal [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Client Bittorrent integrato
- Integrazione How Long To Beat (HLTB) nella pagina del gioco
- Percorso del download Personalizzato
- Notifiche di aggiornamenti sulla list dei repacks
- Supporto Windows e Linux
- Costantemente Aggiornato
- E molto altro ...
@@ -139,9 +139,8 @@ pip install -r requirements.txt
## Variabili d'ambiente
Avrai bisogno di una chiave API SteamGridDB per poter caricare le icone di gioco.
Se intendi avere onlinefix come repacker dovrai aggiungere le tue credenziali al file .env
Una volta ottenuta, puoi copiare e rinominare il file `.env.example` a `.env` e metterlo in `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Una volta ottenuta, puoi copiare e rinominare il file `.env.example` a `.env` e metterlo in `STEAMGRIDDB_API_KEY`.
## Esecuzione

187
docs/README.nb.md Normal file
View File

@@ -0,0 +1,187 @@
<br>
<div align="center">
[<img src="./resources/icon.png" width="144"/>](https://hydralauncher.site)
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra er en spill launcher sin egen innebygt bittorrent klient.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
[![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases)
[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md)
[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md)
[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md)
[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md)
[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md)
[![es](https://img.shields.io/badge/lang-es-red)](README.es.md)
[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./screenshot.png)
</div>
## Innhold
- [Innhold](#innhold)
- [Om](#om)
- [Funksjoner](#funksjoner)
- [Installasjon](#installasjon)
- [Bidra](#-bidra)
- [Bli med i Telegram kanalen vår](#-join-our-telegram)
- [Forke og klone repoet ditt](#fork-and-clone-your-repository)
- [Måter du kan bidra](#ways-you-can-contribute)
- [Prosjekt Struktur](#project-structure)
- [Bygg fra kilden](#build-from-source)
- [Installere Node.js](#install-nodejs)
- [Installere Yarn](#install-yarn)
- [Installere Node-avhengigheter](#install-node-dependencies)
- [Installere Python 3.9](#install-python-39)
- [Installere Python-avhengigheter](#install-python-dependencies)
- [Miljøvariabler](#environment-variables)
- [Kjøre](#running)
- [Bygge](#build)
- [Bygg bittorrent klienten](#build-the-bittorrent-client)
- [Bygg Electron applikationen](#build-the-electron-application)
- [Bidragsytere](#contributors)
- [Lisens](#license)
## Om
**Hydra** er en **Spill Launcher** sin egne innbygte **BitTorrent Klient**.
<br>
Launcheren er skrevet i TypeScript (Electron) og Python, som håndterer torrent systemet ved bruk av libtorrent.
## Funksjoner
- Sin egen innebyggte bittorrent klient
- How Long To Beat (HLTB) integrasjon på spillsiden
- Nedlastingssti tilpasning
- Windows og Linux understøttelse
- Konstant oppdatert
- Og mer ...
## Installasjon
Følg trinnene her under for å innstallere:
1. Last ned den seneste versjonen av Hydra fra [Releases](https://github.com/hydralauncher/hydra/releases/latest) siden.
- Last kun .exe filen ned om du vil installere Hydra på Windows.
- Last kun .deb, .rpm eller .zip ned om du vil installere Hydra på Linux. (kommer an på Linux distroen din)
2. Kjør den nedlastede filen.
3. Nyt Hydra!
## <a name="contributing"> Bidra
### <a name="join-our-telegram"></a> Bli med i Telegram kanalen vår
Vi holder diskusjonene våres i [Telegram](https://t.me/hydralauncher) kanalen.
### Forke og klone repoet ditt
1. Fork repoet [(trykk her for å forke nå)](https://github.com/hydralauncher/hydra/fork)
2. Klon den forkede koden `git clone https://github.com/brukernavnet_ditt/hydra`
3. Lag en ny branch
4. Skyv committene dine
5. Send inn en ny Pull-forespørsel.
### Måter du kan bidra
- Oversetting: Vi har lyst at Hydra skal bli tilgjengelig for så mange som mulig. Hjelp gjerne med å oversette til nye språk eller oppdater og forbedre de som allerede er tilgjengelige i Hydra.
- Code: Hydra is built with Typescript, Electron and a little bit of Python. If you want to contribute, join our [Telegram](https://t.me/hydralauncher)!
- Kode: Hydra er laget med Typescript, Electron og lite gran Pythong. Hvis du har lyst på å bidra, bli med i [Telegram](https://t.me/hydralauncher) kanalen vår!
### Prosjektstruktur
- torrent-client: Vi bruker libtorrent, et Python-bibliotek, til å håndtere torrent nedlastinger.
- src/renderer: UIen til applikasjonen
- src/main: all logikken er her.
## Bygg fra kildekoden
### Installere Node.js
Vær sikker på at du har installert Node.js på maskinen din. Hvis du ikke har det, må du laste ned og installere det fra [nodejs.org](https://nodejs.org/).
### Installere Yarn
Yarn er et pakkehåndteringsverktøy til Node.js. Hvis du ikke allerede har installert Yarn, da kan du gjøre det ved å følge instruksjonene på [yarnpkg.com](https://classic.yarnpkg.com/lang/en/docs/install/).
### Installere Node-avhengigheter
Naviger til prosjektmappen og installer Node-avhengighetene ved bruk av Yarn:
```bash
cd hydra
yarn
```
### Installere Python 3.9
Vær sikker på at du har installert Python 3.9 på maskinen din. Du kan laste ned og installere det på [python.org](https://www.python.org/downloads/release/python-3913/).
### Installere Python-avhengigheter
Installer de nødvendige Python-avhengigheter ved bruk av pip:
```bash
pip install -r requirements.txt
```
## Miljøvariabler
Du trenger en SteamGridDB API nøkkel for å kunne hente spillikonene ved installasjon.
Når du har det, kan du kopiere eller endre navnet på `.env.example` filen til å være `.env` og lagre nøkkelen som `STEAMGRIDDB_API_KEY`.
## Kjøre
Når alt er satt op, kan du kjøre følgende kommando for å start både Electron prosessen og bittorrent klienten.
```bash
yarn dev
```
## Bygge
### Bygge bittorrent klienten
Bygg bittorrent klienten ved å bruke denne kommandoen:
```bash
python torrent-client/setup.py build
```
### Bygge Electron applikasjonen
Bygg Electron applikasjonen ved å bruke denne kommandoen:
På Windows:
```bash
yarn build:win
```
På Linux:
```bash
yarn build:linux
```
## Bidragsytere
<a href="https://github.com/hydralauncher/hydra/graphs/contributors">
<img src="https://contrib.rocks/image?repo=hydralauncher/hydra" />
</a>
## Lisens
Hydra bruker [MIT Lisensen](LICENSE).

View File

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

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra é um Launcher de Jogos com seu próprio cliente de bittorrent integrado e um wrapper autogerenciado para busca de repacks.</strong>
<strong>Hydra é um Launcher de Jogos com seu próprio cliente de bittorrent integrado.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,8 +23,10 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
![Hydra Catalogue](./screenshot.png)
</div>
@@ -55,17 +57,15 @@
## <a name="about"> Sobre
**Hydra** é um **Launcher de Jogos** com seu próprio **Cliente BitTorrent incorporado** e um **raspador de repack auto-gerenciado**.
**Hydra** é um **Launcher de Jogos** com seu próprio **Cliente BitTorrent incorporado**.
<br>
O launcher é escrito em TypeScript (Electron) e Python, que lida com o sistema de torrent usando libtorrent.
## <a name="features"> Recursos
- Wrapper de repacks auto-gerenciado entre todos os sites mais confiáveis no [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Cliente BitTorrent incorporado próprio
- Integração com [How Long To Beat (HLTB)](https://howlongtobeat.com/) na página do jogo
- Personalização do caminho de downloads
- Notificações de atualização da lista de repacks
- Suporte para Windows e Linux
- Constantemente atualizado
- E mais ...
@@ -136,14 +136,13 @@ Instale as dependências Python necessárias usando o pip:
pip install -r requirements.txt
```
## <a name="environment-variables"></a> Environment variables
## <a name="environment-variables"></a> Variáveis de ambiente
Você precisará de uma chave da API SteamGridDB para buscar os ícones do jogo durante a instalação.
Se você deseja ter o onlinefix como um repacker, precisará adicionar suas credenciais ao arquivo .env.
Depois de obtê-lo, você pode copiar ou renomear o arquivo `.env.example` para `.env` e inserir `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME` e `ONLINEFIX_PASSWORD`.
Depois de obtê-lo, você pode copiar ou renomear o arquivo `.env.example` para `.env` e inserir `STEAMGRIDDB_API_KEY`.
## <a name="running"></a> Running
## <a name="running"></a> Executando
Uma vez que você tenha configurado tudo, você pode executar o seguinte comando para iniciar tanto o processo Electron quanto o cliente BitTorrent:

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - это игровой лаунчер с собственным встроенным клиентом BitTorrent и самостоятельным scraper`ом для репаков.</strong>
<strong>Hydra - это игровой лаунчер с собственным встроенным клиентом BitTorrent.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,8 +23,10 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
![Hydra Catalogue](./screenshot.png)
</div>
@@ -139,9 +141,8 @@ pip install -r requirements.txt
## Переменные среды
Вам понадобится ключ API SteamGridDB, чтобы получить значки игр при установке.
Если вы хотите использовать onlinefix в качестве репака, вам нужно добавить ваши учетные данные в файл .env.
Как только у вас будет ключ, вы можете скопировать или переименовать файл `.env.example` в `.env` и поместить в него `STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Как только у вас будет ключ, вы можете скопировать или переименовать файл `.env.example` в `.env` и поместить в него `STEAMGRIDDB_API_KEY`.
## Запуск

View File

@@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1>
<p align="center">
<strong>Hydra - це ігровий лаунчер з власним вбудованим bittorrent-клієнтом і самокерованим збирачем репаків.</strong>
<strong>Hydra - це ігровий лаунчер з власним вбудованим bittorrent-клієнтом.</strong>
</p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@@ -23,8 +23,10 @@
[![de](https://img.shields.io/badge/lang-de-black)](README.de.md)
[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png)
![Hydra Catalogue](./screenshot.png)
</div>
@@ -143,9 +145,8 @@ pip install -r requirements.txt
## Змінні середовища
Вам знадобиться ключ API SteamGridDB, щоб отримати іконки ігор під час встановлення.
Якщо ви хочете використовувати onlinefix як перепакувальник, вам потрібно додати свої облікові дані до .env
Отримавши його, ви можете скопіювати або перейменувати файл `.env.example` на `.env`і помістити його на`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`.
Отримавши його, ви можете скопіювати або перейменувати файл `.env.example` на `.env`і помістити його на`STEAMGRIDDB_API_KEY`.
## Запустіть

View File

@@ -1,8 +1,9 @@
appId: site.hydralauncher.hydra
appId: gg.hydralauncher.hydra
productName: Hydra
directories:
buildResources: build
extraResources:
- ludusavi
- hydra-download-manager
- seeds
- from: node_modules/create-desktop-shortcuts/src/windows.vbs

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "2.1.5",
"version": "2.1.7-preview",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -23,7 +23,7 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
@@ -44,15 +44,16 @@
"@vanilla-extract/recipes": "^0.5.2",
"auto-launch": "^5.0.6",
"axios": "^1.7.7",
"better-sqlite3": "^11.2.1",
"better-sqlite3": "^11.3.0",
"check-disk-space": "^3.4.0",
"classnames": "^2.5.1",
"color": "^4.2.3",
"color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"electron-log": "^5.1.4",
"electron-updater": "^6.1.8",
"dexie": "^4.0.8",
"electron-log": "^5.2.0",
"electron-updater": "^6.3.4",
"fetch-cookie": "^3.0.1",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
@@ -71,6 +72,7 @@
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",
"sudo-prompt": "^9.2.1",
"tar": "^7.4.3",
"typeorm": "^0.3.20",
"user-agents": "^1.1.193",
"yaml": "^2.4.1",
@@ -87,6 +89,7 @@
"@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6",
"@types/folder-hash": "^4.0.4",
"@types/jsdom": "^21.1.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash-es": "^4.17.12",
@@ -98,7 +101,7 @@
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1",
"electron": "^30.3.0",
"electron-builder": "^24.9.1",
"electron-builder": "^25.1.6",
"electron-vite": "^2.0.0",
"eslint": "^8.56.0",
"eslint-plugin-jsx-a11y": "^6.8.0",

49
postinstall.cjs Normal file
View 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();

View File

@@ -1,7 +1,7 @@
libtorrent
cx_Freeze
cx_Logging; sys_platform == 'win32'
lief; sys_platform == 'win32'
pywin32; sys_platform == 'win32'
psutil
Pillow
requests

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -130,7 +130,22 @@
"download": "Download",
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
"warning": "Warning:",
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress."
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress.",
"achievements": "Achievements",
"cloud_save": "Cloud save",
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
"backups": "Backups",
"install_backup": "Install",
"delete_backup": "Delete",
"create_backup": "New backup",
"last_backup_date": "Last backup on {{date}}",
"no_backup_preview": "No save games were found for this title",
"restoring_backup": "Restoring backup ({{progress}} complete)…",
"uploading_backup": "Uploading backup…",
"no_backups": "You haven't created any backups for this game yet",
"backup_uploaded": "Backup uploaded",
"backup_deleted": "Backup deleted",
"backup_restored": "Backup restored"
},
"activation": {
"title": "Activate Hydra",
@@ -226,7 +241,9 @@
"repack_count_one": "{{count}} repack added",
"repack_count_other": "{{count}} repacks added",
"new_update_available": "Version {{version}} available",
"restart_to_install_update": "Restart Hydra to install the update"
"restart_to_install_update": "Restart Hydra to install the update",
"notification_achievement_unlocked_title": "Achievement unlocked for {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked"
},
"system_tray": {
"open": "Open Hydra",
@@ -311,6 +328,10 @@
"report_reason_violence": "Violence",
"report_reason_spam": "Spam",
"report_reason_other": "Other",
"profile_reported": "Profile reported"
"profile_reported": "Profile reported",
"your_friend_code": "Your friend code:"
},
"achievement": {
"achievement_unlocked": "Achievement unlocked"
}
}

View File

@@ -22,6 +22,7 @@ import ro from "./ro/translation.json";
import ca from "./ca/translation.json";
import kk from "./kk/translation.json";
import cs from "./cs/translation.json";
import nb from "./nb/translation.json";
export default {
"pt-BR": ptBR,
@@ -48,4 +49,5 @@ export default {
ca,
kk,
cs,
nb,
};

View File

@@ -0,0 +1,316 @@
{
"language_name": "Norsk Bokmål",
"app": {
"successfully_signed_in": "Logget inn vellykket"
},
"home": {
"featured": "Anbefalinger",
"trending": "Trender",
"surprise_me": "Overrask meg",
"no_results": "Ingen resultater fundet",
"start_typing": "Begynn å skrive for å søke...",
"hot": "Populært akkurat nå",
"weekly": "📅 De mest populære spillene denne uken"
},
"sidebar": {
"catalogue": "Katalog",
"downloads": "Nedlastinger",
"settings": "Innstillinger",
"my_library": "Mitt bibliotek",
"downloading_metadata": "{{title}} (Laster ned metadata…)",
"paused": "{{title}} (Satt på pause)",
"downloading": "{{title}} ({{percentage}} - Laster ned…)",
"filter": "Filtrér bibliotek",
"home": "Hjem",
"queued": "{{title}} (I køen)",
"game_has_no_executable": "Spillet har ikke noen kjørbar fil valgt",
"sign_in": "Logge inn",
"friends": "Venner"
},
"header": {
"search": "Søk efter spill",
"home": "Hjem",
"catalogue": "Katalog",
"downloads": "Nedlastinger",
"search_results": "Søkeresultater",
"settings": "Innstillinger",
"version_available_install": "Versjon {{version}} tilgjengelig. Klikk her for å gjenstarte og installere.",
"version_available_download": "Versjon {{version}} tilgjengelig. Klikk her for at laste ned."
},
"bottom_panel": {
"no_downloads_in_progress": "Ingen nedlastinger pågår",
"downloading_metadata": "Laster ned {{title}} metadata…",
"downloading": "Laster ned {{title}}… ({{percentage}} ferdig) - Fullstendig nedlastet {{eta}} - {{speed}}",
"calculating_eta": "Laster ned {{title}}… ({{percentage}} ferdig) - Regner ut resterende tid…",
"checking_files": "Sjekker {{title}} filer… ({{percentage}} ferdig)"
},
"catalogue": {
"next_page": "Neste side",
"previous_page": "Forrige side"
},
"game_details": {
"open_download_options": "Åpne nedlastingsmuligheter",
"download_options_zero": "Ingen nedlastingsmulighet",
"download_options_one": "{{count}} nedlastingsmulighet",
"download_options_other": "{{count}} nedlastingsmuligheter",
"updated_at": "Oppdatert {{updated_at}}",
"install": "Installere",
"resume": "Fortsett",
"pause": "Pause",
"cancel": "Kansellere",
"remove": "Fjern",
"space_left_on_disk": "{{space}} tilbake på harddisken",
"eta": "Konklusjon {{eta}}",
"calculating_eta": "Utregner resterende tid…",
"downloading_metadata": "Laster ned metadata…",
"filter": "Filtrér gjennpakkinger",
"requirements": "Systemkrav",
"minimum": "Mindste",
"recommended": "Anbefalet",
"paused": "Satt på pause",
"release_date": "Offentliggjort den {{date}}",
"publisher": "Gitt ut av {{publisher}}",
"hours": "timer",
"minutes": "minutter",
"amount_hours": "{{amount}} timer",
"amount_minutes": "{{amount}} minutter",
"accuracy": "{{accuracy}}% nøyaktighet",
"add_to_library": "Tilføy til biblioteket",
"remove_from_library": "Fjern fra biblioteket",
"no_downloads": "Ingen nedlastinger tilgjengelig",
"play_time": "Spilt i {{amount}}",
"last_time_played": "Sist spilt {{period}}",
"not_played_yet": "Du har ikke spilt {{title}} enda",
"next_suggestion": "Neste forslag",
"play": "Spil",
"deleting": "Sletter installatør…",
"close": "Lukk",
"playing_now": "Spiller nå",
"change": "Endre",
"repacks_modal_description": "Velg den gjennpakking du vil laste ned",
"select_folder_hint": "For å endre standard mappen, gå til <0>Innstillingene</0>",
"download_now": "Last ned nå",
"no_shop_details": "Kunne ikke modta butikksdetaljene.",
"download_options": "Nedlastingsmuligheter",
"download_path": "Nedlastingssti",
"previous_screenshot": "Forrige skjermbilde",
"next_screenshot": "Neste skjermbilde",
"screenshot": "Skjermbilde {{number}}",
"open_screenshot": "Åpen skjermbilde {{number}}",
"download_settings": "Nedlastingsinnstillinger",
"downloader": "Laster ned",
"select_executable": "Velg",
"no_executable_selected": "Ingen kjørbar fil valgt",
"open_folder": "Åpne mappe",
"open_download_location": "Se nedlastingede filer",
"create_shortcut": "Opprett snarvei på skrivebordet",
"remove_files": "Fjern filer",
"remove_from_library_title": "Er du sikker?",
"remove_from_library_description": "Dette vil fjerne {{game}} fra biblioteket ditt",
"options": "Valgmuligheter",
"executable_section_title": "Kjørbar fil",
"executable_section_description": "Sti til filen som skal brukes når det trykkes på \"Spill\"",
"downloads_secion_title": "Nedlastinger",
"downloads_section_description": "Sjekk for oppdateringer eller andre versjoner af dette spillet",
"danger_zone_section_title": "Faresonen",
"danger_zone_section_description": "Fjern dette spillet fra biblioteket ditt eller filene som har blitt lastet ned av Hydra",
"download_in_progress": "Nedlasting pågår",
"download_paused": "Nedlasting satt på pause",
"last_downloaded_option": "Siste nedlastingsmulighet",
"create_shortcut_success": "Opprettelse av snarvei vellykket",
"create_shortcut_error": "Feil under oprettelsen av snarvei",
"nsfw_content_title": "Dette spillet inneholder upassende innhold",
"nsfw_content_description": "{{title}} inneholder innhold som ikke passer til alle aldre. Er du sikker på at du vil fortsette?",
"allow_nsfw_content": "Fortsett",
"refuse_nsfw_content": "Gå tilbake",
"stats": "Statistikk",
"download_count": "Nedlastinger",
"player_count": "Aktive spillere",
"download_error": "Denne nedlastingsmulighet er ikke tilgjengelig",
"download": "Last ned",
"executable_path_in_use": "Kjørbar fil blir allerede brukt av \"{{game}}\"",
"warning": "Advarsel:",
"hydra_needs_to_remain_open": "Hydra skal forbli åpent for at denne nedlastingen kan gjennomføres. I tilfelle av at Hydra lukker før nedlastingen er ferdig, mister du fremskrittet ditt."
},
"activation": {
"title": "Aktivér Hydra",
"installation_id": "Installasjons ID:",
"enter_activation_code": "Inntast aktiveringskoden din",
"message": "Hvis du ikke vet hvor du skal spørre om dette, burde du ikke ha dette.",
"activate": "Aktivér",
"loading": "Innleser…"
},
"downloads": {
"resume": "Fortsett",
"pause": "Pause",
"eta": "Konklusjon {{eta}}",
"paused": "Satt på pause",
"verifying": "Verifiserer…",
"completed": "Ferdig",
"removed": "Ikke lastet ned",
"cancel": "Kansellér",
"filter": "Filtrér nedlastede spill",
"remove": "Fjern",
"downloading_metadata": "Laster ned metadata…",
"deleting": "Sletter installatør…",
"delete": "Fjern installatør",
"delete_modal_title": "Er du sikker?",
"delete_modal_description": "Dette vil fjerne alle installasjonsfilene fra datamaskinen din",
"install": "Installér",
"download_in_progress": "Pågår",
"queued_downloads": "Nedlastingskø",
"downloads_completed": "Gjennomførte",
"queued": "I kø",
"no_downloads_title": "Ganske tomt",
"no_downloads_description": "Du har ikke lastet ned noe med Hydra enda, men det er aldri for sent å begynne.",
"checking_files": "Undersøker filer…"
},
"settings": {
"downloads_path": "Nedlastingssti",
"change": "Oppdater",
"notifications": "Notifikasjoner",
"enable_download_notifications": "Når en nedlasting blir ferdig",
"enable_repack_list_notifications": "Når en ny gjennpakking bliver lagt til",
"real_debrid_api_token_label": "Real-Debrid API nøkkel",
"quit_app_instead_hiding": "Avslut Hydra i stedet for å minimere til prosesslinjen",
"launch_with_system": "Åpne Hydra ved oppstart av datamaskinen",
"general": "Generelt",
"behavior": "Oppførsel",
"download_sources": "Nedlastingskilder",
"language": "Språk",
"real_debrid_api_token": "API nøkkel",
"enable_real_debrid": "Slå på Real-Debrid",
"real_debrid_description": "Real-Debrid er en ubegrenset nedlaster som gør det mulig for deg å laste ned filer med en gang og med den beste utnyttelsen av internethastigheten din.",
"real_debrid_invalid_token": "Ugyldig API nøkkel",
"real_debrid_api_token_hint": "Du kan få API nøkkelen din <0>her</0>",
"real_debrid_free_account_error": "Brukeren \"{{username}}\" er en gratis bruker. Vennligst abboner på Real-Debrid",
"real_debrid_linked_message": "Brukeren \"{{username}}\" er forbunnet",
"save_changes": "Lagre endringer",
"changes_saved": "Lagring av endringer vellykket",
"download_sources_description": "Hydra vil hente nedlastingslenker fra disse kildene. Kilde URLen skal være en direkte lenke til en .json fil som inneholder nedlastingslenkene.",
"validate_download_source": "Validér",
"remove_download_source": "Fjern",
"add_download_source": "Legg til kilde",
"download_count_zero": "Ingen nedlastingsmuligheter",
"download_count_one": "{{countFormatted}} nedlastingsmulighet",
"download_count_other": "{{countFormatted}} nedlastingsmuligheter",
"download_source_url": "Last ned kilde URL",
"add_download_source_description": "Sett inn URLen som inneholder .json filen",
"download_source_up_to_date": "Oppdatert",
"download_source_errored": "Mislyktes",
"sync_download_sources": "Synkroniser kilder",
"removed_download_source": "Nedlastingskilde fjernet",
"added_download_source": "La til Nedlastingskilde",
"download_sources_synced": "Alle nedlastingskilder er synkroniserte",
"insert_valid_json_url": "Innsett en gyldig JSON url",
"found_download_option_zero": "Ingen nedlastingsmulighet funnet",
"found_download_option_one": "Fant {{countFormatted}} nedlastingsmulighet",
"found_download_option_other": "Fant {{countFormatted}} nedlastingsmuligheter",
"import": "Importer",
"public": "Offentlig",
"private": "Privat",
"friends_only": "Kun blant venner",
"privacy": "Privatliv",
"profile_visibility": "Synlighet av profil",
"profile_visibility_description": "Velg hvem som kan se profilen din og biblioteket ditt",
"required_field": "Dette feltet er påkrevet",
"source_already_exists": "Denne kilden har allerede blitt lagt til",
"must_be_valid_url": "Kilden må være en gyldig URL",
"blocked_users": "Blokerte brukere",
"user_unblocked": "Brukeren har blit avblokert"
},
"notifications": {
"download_complete": "Nedlasting ferdig",
"game_ready_to_install": "{{title}} er klar til å bli installert",
"repack_list_updated": "Gjennpakkingslisten er opdateret",
"repack_count_one": "{{count}} gjennpakking lagt til",
"repack_count_other": "{{count}} gjennpakkinger lagt til",
"new_update_available": "Versjon {{version}} tilgjengelig",
"restart_to_install_update": "Gjenstart Hydra for å installere oppdateringen"
},
"system_tray": {
"open": "Åpne Hydra",
"quit": "Avslutt"
},
"game_card": {
"no_downloads": "Ingen nedlastinger tilgjengelig"
},
"binary_not_found_modal": {
"title": "Programmer ikke installert",
"description": "Wine eller Lutris kjørbar ble ikke funnet på systemet ditt",
"instructions": "Sjekk den korrekte måten å installere noen av de, på Linux distributionen din, så spillet kan kjøre på vanlig måte"
},
"modal": {
"close": "Lukk knapp"
},
"forms": {
"toggle_password_visibility": "Skift synlighet af passord"
},
"user_profile": {
"amount_hours": "{{amount}} timer",
"amount_minutes": "{{amount}} minutter",
"last_time_played": "Sist spilt {{period}}",
"activity": "Seneste aktivitet",
"library": "Bibliotek",
"total_play_time": "Samlet spilltid: {{amount}}",
"no_recent_activity_title": "Hmmm… ikke noe her",
"no_recent_activity_description": "Du har ikke spilt noen spill for på det seneste. Det er det på tide at endre på!",
"display_name": "Brukernavn",
"saving": "Lagrer",
"save": "Lagre",
"edit_profile": "Rediger Profil",
"saved_successfully": "Lagring vellykket",
"try_again": "Vennligst, prøv igjen",
"sign_out_modal_title": "Er du sikker?",
"cancel": "Kansellér",
"successfully_signed_out": "Utlogging vellykket",
"sign_out": "Log ut",
"playing_for": "Spiller i {{amount}}",
"sign_out_modal_text": "Biblioteket ditt er sammenkobelt med den nåverende brukeren. Når du logger ut er biblioteket ditt ikke synlig lenger, og hvilken som helst form for fremskritt bliver ikke lagret. Vil du fortsette med å logge ut?",
"add_friends": "Legg til venner",
"add": "Legg til",
"friend_code": "Vennekode",
"see_profile": "Se profil",
"sending": "Sender",
"friend_request_sent": "Venneforespørsel sendt",
"friends": "Venner",
"friends_list": "Venneliste",
"user_not_found": "Bruker ikke funnet",
"block_user": "Blokkere bruker",
"add_friend": "Legg til venn",
"request_sent": "Forespørsel sendt",
"request_received": "Forespørsel modtatt",
"accept_request": "Akseptere forespørsel",
"ignore_request": "Ignorere forespørsel",
"cancel_request": "Kansellre forespørsel",
"undo_friendship": "Angre venskab",
"request_accepted": "Forespørsel akseptert",
"user_blocked_successfully": "Blokkering av bruker vellykket",
"user_block_modal_text": "Dette blokerer {{displayName}}",
"blocked_users": "Blokerte brukere",
"unblock": "Avblokere",
"no_friends_added": "Du har fortsatt ikke lagt til noen venner",
"pending": "Avventer",
"no_pending_invites": "Du har ingen avventende invitasjoner",
"no_blocked_users": "Du har ingen blokerte brukere",
"friend_code_copied": "Vennekode kopiert",
"undo_friendship_modal_text": "Dette vil angre venskapet ditt med {{displayName}}",
"privacy_hint": "For å justere på hvem som kan se dette, gå til <0>Innstillingene</0>",
"locked_profile": "Denne profilen er privat",
"image_process_failure": "Mislyktes under håndteringen av bildet",
"required_field": "Dette feltet er påkrevet",
"displayname_min_length": "Brukernavnet skal være minst 3 karakterer langt",
"displayname_max_length": "Brukernavnet skal være maksimalt 50 karakterer langt",
"report_profile": "Rapportér denne profilen",
"report_reason": "Hvorfor rapportérer du denne profilen?",
"report_description": "Mer informasjon",
"report_description_placeholder": "Mer informasjon",
"report": "Rapportér",
"report_reason_hate": "Hatytringer",
"report_reason_sexual_content": "Seksuelt innhold",
"report_reason_violence": "Vold",
"report_reason_spam": "Spam",
"report_reason_other": "Annet",
"profile_reported": "Profil rapportert"
}
}

View File

@@ -126,7 +126,22 @@
"download": "Baixar",
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
"warning": "Aviso:",
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
"achievements": "Conquistas",
"cloud_save": "Salvamento em nuvem",
"cloud_save_description": "Matenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo",
"backups": "Backups",
"install_backup": "Restaurar",
"delete_backup": "Apagar",
"create_backup": "Novo backup",
"last_backup_date": "Último backup em {{date}}",
"no_backup_preview": "Não foi possível encontrar nenhum salvamento para este jogo",
"restoring_backup": "Restaurando backup ({{progress}} concluído)…",
"uploading_backup": "Criando backup…",
"no_backups": "Você ainda não fez nenhum backup deste jogo",
"backup_uploaded": "Backup criado",
"backup_deleted": "Backup apagado",
"backup_restored": "Backup restaurado"
},
"activation": {
"title": "Ativação",
@@ -168,7 +183,7 @@
"enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid",
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
"launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral",
"behavior": "Comportamento",
@@ -315,6 +330,10 @@
"report_reason_violence": "Violência",
"report_reason_spam": "Spam",
"report_reason_other": "Outro",
"profile_reported": "Perfil reportado"
"profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada"
}
}

View File

@@ -115,7 +115,8 @@
"download": "Transferir",
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
"warning": "Aviso:",
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
"achievements": "Conquistas"
},
"activation": {
"title": "Ativação",
@@ -157,7 +158,7 @@
"enable_download_notifications": "Quando uma transferência for concluída",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid",
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
"launch_with_system": "Iniciar o Hydra com o sistema",
"general": "Geral",
"behavior": "Comportamento",
@@ -275,6 +276,10 @@
"no_pending_invites": "Não tens convites de amizade pendentes",
"no_blocked_users": "Não tens nenhum utilizador bloqueado",
"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:"
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada"
}
}

View File

@@ -4,7 +4,12 @@ import path from "node:path";
export const defaultDownloadsPath = app.getPath("downloads");
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
export const databasePath = path.join(databaseDirectory, "hydra.db");
export const databasePath = path.join(
databaseDirectory,
import.meta.env.MAIN_VITE_API_URL.includes("staging")
? "hydra_test.db"
: "hydra.db"
);
export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
@@ -12,4 +17,6 @@ export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds")
: path.join(__dirname, "..", "..", "seeds");
export const backupsPath = path.join(app.getPath("userData"), "Backups");
export const appVersion = app.getVersion();

View File

@@ -7,6 +7,7 @@ import {
Repack,
UserPreferences,
UserAuth,
GameAchievement,
} from "@main/entity";
import { databasePath } from "./constants";
@@ -21,6 +22,7 @@ export const dataSource = new DataSource({
DownloadSource,
DownloadQueue,
UserAuth,
GameAchievement,
],
synchronize: false,
database: databasePath,

View File

@@ -0,0 +1,19 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("game_achievement")
export class GameAchievement {
@PrimaryGeneratedColumn()
id: number;
@Column("text")
objectId: string;
@Column("text")
shop: string;
@Column("text", { nullable: true })
unlockedAchievements: string;
@Column("text", { nullable: true })
achievements: string;
}

View File

@@ -2,6 +2,8 @@ export * from "./game.entity";
export * from "./repack.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./game.entity";
export * from "./game-achievements.entity";
export * from "./download-source.entity";
export * from "./download-queue.entity";
export * from "./user-auth";

View File

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

View File

@@ -0,0 +1,51 @@
import type { GameAchievement, GameShop } from "@types";
import { registerEvent } from "../register-event";
import { gameAchievementRepository } from "@main/repository";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
const getGameAchievements = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
): Promise<GameAchievement[]> => {
const cachedAchievements = await gameAchievementRepository.findOne({
where: { objectId, shop },
});
const achievementsData = cachedAchievements?.achievements
? JSON.parse(cachedAchievements.achievements)
: await getGameAchievementData(objectId, shop);
const unlockedAchievements = JSON.parse(
cachedAchievements?.unlockedAchievements || "[]"
) as { name: string; unlockTime: number }[];
return achievementsData
.map((achievementData) => {
const unlockedAchiement = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
achievementData.name.toUpperCase()
);
}
);
if (unlockedAchiement) {
return {
...achievementData,
unlocked: true,
unlockTime: unlockedAchiement.unlockTime,
};
}
return { ...achievementData, unlocked: false, unlockTime: null };
})
.sort((a, b) => {
if (a.unlocked && !b.unlocked) return -1;
if (!a.unlocked && b.unlocked) return 1;
return b.unlockTime - a.unlockTime;
});
};
registerEvent("getGameAchievements", getGameAchievements);

View File

@@ -7,16 +7,16 @@ import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
const getLocalizedSteamAppDetails = async (
objectID: string,
objectId: string,
language: string
): Promise<ShopDetails | null> => {
if (language === "english") {
return getSteamAppDetails(objectID, language);
return getSteamAppDetails(objectId, language);
}
return getSteamAppDetails(objectID, language).then(
return getSteamAppDetails(objectId, language).then(
async (localizedAppDetails) => {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
@@ -34,26 +34,28 @@ const getLocalizedSteamAppDetails = async (
const getGameShopDetails = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
shop: GameShop,
language: string
): Promise<ShopDetails | null> => {
if (shop === "steam") {
const cachedData = await gameShopCacheRepository.findOne({
where: { objectID, language },
where: { objectID: objectId, language },
});
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
(result) => {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
if (result) {
gameShopCacheRepository.upsert(
{
objectID: objectId,
shop: "steam",
language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
}
return result;
}
@@ -66,7 +68,7 @@ const getGameShopDetails = async (
if (cachedGame) {
return {
...cachedGame,
objectID,
objectId,
} as ShopDetails;
}

View File

@@ -9,15 +9,11 @@ const getGameStats = async (
objectId: string,
shop: GameShop
) => {
const params = new URLSearchParams({
objectId,
shop,
});
const response = await HydraApi.get<GameStats>(
`/games/stats?${params.toString()}`
return HydraApi.get<GameStats>(
`/games/stats`,
{ objectId, shop },
{ needsAuth: false }
);
return response;
};
registerEvent("getGameStats", getGameStats);

View File

@@ -1,28 +1,29 @@
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
import { convertSteamGameToCatalogueEntry } from "../helpers/search-games";
import { RepacksManager } from "@main/services";
import { HydraApi } from "@main/services";
import { steamUrlBuilder } from "@shared";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take = 12,
cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
const steamGames = await steamGamesWorker.run(
{ limit: take, offset: cursor },
{ name: "list" }
skip = 0
): Promise<CatalogueEntry[]> => {
const searchParams = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
});
const games = await HydraApi.get<CatalogueEntry[]>(
`/games/catalogue?${searchParams.toString()}`,
undefined,
{ needsAuth: false }
);
const entries = RepacksManager.findRepacksForCatalogueEntries(
steamGames.map((game) => convertSteamGameToCatalogueEntry(game))
);
return {
results: entries,
cursor: cursor + entries.length,
};
return games.map((game) => ({
...game,
cover: steamUrlBuilder.library(game.objectId),
}));
};
registerEvent("getGames", getGames);

View File

@@ -6,14 +6,14 @@ import { gameShopCacheRepository } from "@main/repository";
const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
shop: GameShop,
title: string
): Promise<HowLongToBeatCategory[] | null> => {
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
const gameShopCache = await gameShopCacheRepository.findOne({
where: { objectID, shop },
where: { objectID: objectId, shop },
});
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
@@ -23,7 +23,7 @@ const getHowLongToBeat = async (
return searchHowLongToBeatPromise.then(async (response) => {
const game = response.data.find(
(game) => game.profile_steam === Number(objectID)
(game) => game.profile_steam === Number(objectId)
);
if (!game) return null;
@@ -31,7 +31,7 @@ const getHowLongToBeat = async (
gameShopCacheRepository.upsert(
{
objectID,
objectID: objectId,
shop,
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
},

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { Ludusavi } from "@main/services";
const checkGameCloudSyncSupport = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const games = await Ludusavi.findGames(shop, objectId);
return games.length === 1;
};
registerEvent("checkGameCloudSyncSupport", checkGameCloudSyncSupport);

View 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);

View File

@@ -0,0 +1,139 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import fs from "node:fs";
import * as tar from "tar";
import { registerEvent } from "../register-event";
import axios from "axios";
import { app } from "electron";
import path from "node:path";
import { backupsPath } from "@main/constants";
import type { GameShop } from "@types";
import YAML from "yaml";
export interface LudusaviBackup {
files: {
[key: string]: {
hash: string;
size: number;
};
};
}
const replaceLudusaviBackupWithCurrentUser = (
gameBackupPath: string,
backupHomeDir: string
) => {
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
const data = fs.readFileSync(mappingYamlPath, "utf8");
const manifest = YAML.parse(data) as {
backups: LudusaviBackup[];
drives: Record<string, string>;
};
const currentHomeDir = app.getPath("home");
// TODO: Only works on Windows
const usersDirPath = path.join(gameBackupPath, "drive-C", "Users");
const oldPath = path.join(usersDirPath, path.basename(backupHomeDir));
const newPath = path.join(usersDirPath, path.basename(currentHomeDir));
// Directories are different, rename
if (backupHomeDir !== currentHomeDir) {
if (fs.existsSync(newPath)) {
fs.rmSync(newPath, {
recursive: true,
force: true,
});
}
fs.renameSync(oldPath, newPath);
}
const backups = manifest.backups.map((backup: LudusaviBackup) => {
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
return {
...prev,
[key.replace(backupHomeDir, currentHomeDir)]: value,
};
}, {});
return {
...backup,
files,
};
});
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
};
const downloadGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
gameArtifactId: string
) => {
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
downloadUrl: string;
objectKey: string;
homeDir: string;
}>(`/profile/games/artifacts/${gameArtifactId}/download`);
const zipLocation = path.join(app.getPath("userData"), objectKey);
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
if (fs.existsSync(backupPath)) {
fs.rmSync(backupPath, {
recursive: true,
force: true,
});
}
const response = await axios.get(downloadUrl, {
responseType: "stream",
onDownloadProgress: (progressEvent) => {
WindowManager.mainWindow?.webContents.send(
`on-backup-download-progress-${objectId}-${shop}`,
progressEvent
);
},
});
const writer = fs.createWriteStream(zipLocation);
response.data.pipe(writer);
writer.on("error", (err) => {
logger.error("Failed to write zip", err);
throw err;
});
fs.mkdirSync(backupPath, { recursive: true });
writer.on("close", () => {
tar
.x({
file: zipLocation,
cwd: backupPath,
})
.then(async () => {
const [game] = await Ludusavi.findGames(shop, objectId);
if (!game) throw new Error("Game not found in Ludusavi manifest");
replaceLudusaviBackupWithCurrentUser(
path.join(backupPath, game),
path.normalize(homeDir).replace(/\\/g, "/")
);
Ludusavi.restoreBackup(backupPath).then(() => {
WindowManager.mainWindow?.webContents.send(
`on-backup-download-complete-${objectId}-${shop}`,
true
);
});
});
});
};
registerEvent("downloadGameArtifact", downloadGameArtifact);

View 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);

View File

@@ -0,0 +1,17 @@
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { Ludusavi } from "@main/services";
import path from "node:path";
import { backupsPath } from "@main/constants";
const getGameBackupPreview = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
return Ludusavi.getBackupPreview(shop, objectId, backupPath);
};
registerEvent("getGameBackupPreview", getGameBackupPreview);

View File

@@ -0,0 +1,87 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import * as tar from "tar";
import crypto from "node:crypto";
import { GameShop } from "@types";
import axios from "axios";
import os from "node:os";
import { backupsPath } from "@main/constants";
import { app } from "electron";
const bundleBackup = async (shop: GameShop, objectId: string) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
await Ludusavi.backupGame(shop, objectId, backupPath);
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.zip`);
await tar.create(
{
gzip: false,
file: tarLocation,
cwd: backupPath,
},
["."]
);
return tarLocation;
};
const uploadSaveGame = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const bundleLocation = await bundleBackup(shop, objectId);
fs.stat(bundleLocation, async (err, stat) => {
if (err) {
logger.error("Failed to get zip file stats", err);
throw err;
}
const { uploadUrl } = await HydraApi.post<{
id: string;
uploadUrl: string;
}>("/profile/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
homeDir: path.normalize(app.getPath("home")).replace(/\\/g, "/"),
platform: os.platform(),
});
fs.readFile(bundleLocation, async (err, fileBuffer) => {
if (err) {
logger.error("Failed to read zip file", err);
throw err;
}
await axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/tar",
},
onUploadProgress: (progressEvent) => {
console.log(progressEvent);
},
});
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
fs.rm(bundleLocation, (err) => {
if (err) {
logger.error("Failed to remove tar file", err);
throw err;
}
});
});
});
};
registerEvent("uploadSaveGame", uploadSaveGame);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,22 +3,22 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getFileBase64 } from "@main/helpers";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
title: string,
shop: GameShop
) => {
return gameRepository
.update(
{
objectID,
objectID: objectId,
},
{
shop,
@@ -28,31 +28,27 @@ const addGameToLibrary = async (
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gameRepository
.insert({
title,
iconUrl,
objectID,
shop,
})
.then(() => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
});
await gameRepository.insert({
title,
iconUrl,
objectID: objectId,
shop,
});
}
const game = await gameRepository.findOne({ where: { objectID } });
const game = await gameRepository.findOne({
where: { objectID: objectId },
});
updateLocalUnlockedAchivements(game!);
createGame(game!).catch(() => {});
});

View File

@@ -2,15 +2,15 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getGameByObjectID = async (
const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string
objectId: string
) =>
gameRepository.findOne({
where: {
objectID,
objectID: objectId,
isDeleted: false,
},
});
registerEvent("getGameByObjectID", getGameByObjectID);
registerEvent("getGameByObjectId", getGameByObjectId);

View File

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

View File

@@ -1,9 +1,17 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import { FriendRequestSync } from "@types";
const syncFriendRequests = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`);
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`).catch(
(err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequests: [] };
}
throw err;
}
);
};
registerEvent("syncFriendRequests", syncFriendRequests);

View File

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

View File

@@ -1,5 +1,6 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { UserNotLoggedInError } from "@shared";
import { UserBlocks } from "@types";
export const getBlockedUsers = async (
@@ -7,7 +8,12 @@ export const getBlockedUsers = async (
take: number,
skip: number
): Promise<UserBlocks> => {
return HydraApi.get(`/profile/blocks`, { take, skip });
return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { blocks: [] };
}
throw err;
});
};
registerEvent("getBlockedUsers", getBlockedUsers);

View File

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

View File

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

View File

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

View File

@@ -68,14 +68,13 @@ const runMigrations = async () => {
});
await knexClient.migrate.latest(migrationConfig);
await knexClient.destroy();
};
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra");
electronApp.setAppUserModelId("gg.hydralauncher.hydra");
protocol.handle("local", (request) => {
const filePath = request.url.slice("local:".length);
@@ -103,6 +102,7 @@ app.whenReady().then(async () => {
}
WindowManager.createMainWindow();
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});

View File

@@ -6,6 +6,7 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
import { app } from "electron";
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
export type HydraMigration = Knex.Migration & { name: string };
@@ -17,6 +18,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
UpdateUserLanguage,
EnsureRepackUris,
FixMissingColumns,
CreateGameAchievement,
]);
}
getMigrationName(migration: HydraMigration): string {

View File

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

View File

@@ -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");
},
};

View File

@@ -7,6 +7,7 @@ import {
Repack,
UserPreferences,
UserAuth,
GameAchievement,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
@@ -24,3 +25,6 @@ export const downloadSourceRepository =
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth);
export const gameAchievementRepository =
dataSource.getRepository(GameAchievement);

View File

@@ -0,0 +1,123 @@
import { gameRepository } from "@main/repository";
import { parseAchievementFile } from "./parse-achievement-file";
import { Game } from "@main/entity";
import { mergeAchievements } from "./merge-achievements";
import fs, { readdirSync } from "node:fs";
import {
findAchievementFileInExecutableDirectory,
findAllAchievementFiles,
getAlternativeObjectIds,
} from "./find-achivement-files";
import type { AchievementFile } from "@types";
import { achievementsLogger, logger } from "../logger";
import { Cracker } from "@shared";
const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map();
export const watchAchievements = async () => {
const games = await gameRepository.find({
where: {
isDeleted: false,
},
});
if (games.length === 0) return;
const achievementFiles = findAllAchievementFiles();
for (const game of games) {
for (const objectId of getAlternativeObjectIds(game.objectID)) {
const gameAchievementFiles = achievementFiles.get(objectId) || [];
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
if (!gameAchievementFiles.length) continue;
console.log(
"Achievements files to observe for:",
game.title,
gameAchievementFiles
);
for (const file of gameAchievementFiles) {
compareFile(game, file);
}
}
}
};
const processAchievementFileDiff = async (
game: Game,
file: AchievementFile
) => {
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
logger.log("Achievements from file", file.filePath, unlockedAchievements);
if (unlockedAchievements.length) {
return mergeAchievements(
game.objectID,
game.shop,
unlockedAchievements,
true
);
}
};
const compareFltFolder = async (game: Game, file: AchievementFile) => {
try {
const currentAchievements = new Set(readdirSync(file.filePath));
const previousAchievements = fltFiles.get(file.filePath);
fltFiles.set(file.filePath, currentAchievements);
if (
!previousAchievements ||
currentAchievements.difference(previousAchievements).size === 0
) {
return;
}
logger.log("Detected change in FLT folder", file.filePath);
await processAchievementFileDiff(game, file);
} catch (err) {
achievementsLogger.error(err);
fltFiles.set(file.filePath, new Set());
}
};
const compareFile = async (game: Game, file: AchievementFile) => {
if (file.type === Cracker.flt) {
await compareFltFolder(game, file);
return;
}
try {
const currentStat = fs.statSync(file.filePath);
const previousStat = fileStats.get(file.filePath);
fileStats.set(file.filePath, currentStat.mtimeMs);
if (!previousStat) {
if (currentStat.mtimeMs) {
await processAchievementFileDiff(game, file);
return;
}
}
if (previousStat === currentStat.mtimeMs) {
return;
}
logger.log(
"Detected change in file",
file.filePath,
currentStat.mtimeMs,
fileStats.get(file.filePath)
);
await processAchievementFileDiff(game, file);
} catch (err) {
fileStats.set(file.filePath, -1);
}
};

View File

@@ -0,0 +1,264 @@
import path from "node:path";
import fs from "node:fs";
import { app } from "electron";
import type { AchievementFile } from "@types";
import { Cracker } from "@shared";
import { Game } from "@main/entity";
import { achievementsLogger } from "../logger";
//TODO: change to a automatized method
const publicDocuments = path.join("C:", "Users", "Public", "Documents");
const programData = path.join("C:", "ProgramData");
const appData = app.getPath("appData");
const documents = app.getPath("documents");
const localAppData = path.join(appData, "..", "Local");
const crackers = [
Cracker.codex,
Cracker.goldberg,
Cracker.rune,
Cracker.onlineFix,
Cracker.userstats,
Cracker.rld,
Cracker.creamAPI,
Cracker.skidrow,
Cracker.smartSteamEmu,
Cracker.empress,
Cracker.flt,
];
const getPathFromCracker = (cracker: Cracker) => {
if (cracker === Cracker.codex) {
return [
{
folderPath: path.join(publicDocuments, "Steam", "CODEX"),
fileLocation: ["achievements.ini"],
},
{
folderPath: path.join(appData, "Steam", "CODEX"),
fileLocation: ["achievements.ini"],
},
];
}
if (cracker === Cracker.rune) {
return [
{
folderPath: path.join(publicDocuments, "Steam", "RUNE"),
fileLocation: ["achievements.ini"],
},
];
}
if (cracker === Cracker.onlineFix) {
return [
{
folderPath: path.join(publicDocuments, Cracker.onlineFix),
fileLocation: ["Stats", "Achievements.ini"],
},
];
}
if (cracker === Cracker.goldberg) {
return [
{
folderPath: path.join(appData, "Goldberg SteamEmu Saves"),
fileLocation: ["achievements.json"],
},
{
folderPath: path.join(appData, "GSE Saves"),
fileLocation: ["achievements.json"],
},
];
}
if (cracker === Cracker.userstats) {
return [];
}
if (cracker === Cracker.rld) {
return [
{
folderPath: path.join(programData, "RLD!"),
fileLocation: ["achievements.ini"],
},
{
folderPath: path.join(programData, "Steam", "Player"),
fileLocation: ["stats", "achievements.ini"],
},
{
folderPath: path.join(programData, "Steam", "dodi"),
fileLocation: ["stats", "achievements.ini"],
},
];
}
if (cracker === Cracker.empress) {
return [
{
folderPath: path.join(appData, "EMPRESS", "remote"),
fileLocation: ["achievements.json"],
},
{
folderPath: path.join(publicDocuments, "EMPRESS", "remote"),
fileLocation: ["achievements.json"],
},
];
}
if (cracker === Cracker.skidrow) {
return [
{
folderPath: path.join(documents, "SKIDROW"),
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
},
{
folderPath: path.join(documents, "Player"),
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
},
{
folderPath: path.join(localAppData, "SKIDROW"),
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
},
];
}
if (cracker === Cracker.creamAPI) {
return [
{
folderPath: path.join(appData, "CreamAPI"),
fileLocation: ["stats", "CreamAPI.Achievements.cfg"],
},
];
}
if (cracker === Cracker.smartSteamEmu) {
return [
{
folderPath: path.join(appData, "SmartSteamEmu"),
fileLocation: ["User", "Achievements.ini"],
},
];
}
if (cracker === Cracker._3dm) {
return [];
}
if (cracker === Cracker.flt) {
return [
{
folderPath: path.join(appData, "FLT"),
fileLocation: ["stats"],
},
];
}
if (cracker == Cracker.rle) {
return [
{
folderPath: path.join(appData, "RLE"),
fileLocation: ["achievements.ini"],
},
{
folderPath: path.join(appData, "RLE"),
fileLocation: ["Achievements.ini"],
},
];
}
achievementsLogger.error(`Cracker ${cracker} not implemented`);
throw new Error(`Cracker ${cracker} not implemented`);
};
export const getAlternativeObjectIds = (objectId: string) => {
// Dishonored
if (objectId === "205100") {
return ["205100", "217980", "31292"];
}
return [objectId];
};
export const findAchievementFiles = (game: Game) => {
const achievementFiles: AchievementFile[] = [];
for (const cracker of crackers) {
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
for (const objectId of getAlternativeObjectIds(game.objectID)) {
const filePath = path.join(folderPath, objectId, ...fileLocation);
if (fs.existsSync(filePath)) {
achievementFiles.push({
type: cracker,
filePath,
});
}
}
}
}
return achievementFiles;
};
export const findAchievementFileInExecutableDirectory = (
game: Game
): AchievementFile[] => {
if (!game.executablePath) {
return [];
}
return [
{
type: Cracker.userstats,
filePath: path.join(
game.executablePath,
"..",
"SteamData",
"user_stats.ini"
),
},
{
type: Cracker._3dm,
filePath: path.join(
game.executablePath,
"..",
"3DMGAME",
"Player",
"stats",
"achievements.ini"
),
},
];
};
export const findAllAchievementFiles = () => {
const gameAchievementFiles = new Map<string, AchievementFile[]>();
for (const cracker of crackers) {
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
if (!fs.existsSync(folderPath)) {
continue;
}
const objectIds = fs.readdirSync(folderPath);
for (const objectId of objectIds) {
const filePath = path.join(folderPath, objectId, ...fileLocation);
if (!fs.existsSync(filePath)) continue;
const achivementFile = {
type: cracker,
filePath,
};
gameAchievementFiles.get(objectId)
? gameAchievementFiles.get(objectId)!.push(achivementFile)
: gameAchievementFiles.set(objectId, [achivementFile]);
}
}
}
return gameAchievementFiles;
};

View File

@@ -0,0 +1,33 @@
import {
gameAchievementRepository,
userPreferencesRepository,
} from "@main/repository";
import { HydraApi } from "../hydra-api";
export const getGameAchievementData = async (
objectId: string,
shop: string
) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
return HydraApi.get("/games/achievements", {
shop,
objectId,
language: userPreferences?.language || "en",
})
.then(async (achievements) => {
await gameAchievementRepository.upsert(
{
objectId,
shop,
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
return achievements;
})
.catch(() => []);
};

View File

@@ -0,0 +1,118 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import type { GameShop, UnlockedAchievement } from "@types";
import { WindowManager } from "../window-manager";
import { HydraApi } from "../hydra-api";
const saveAchievementsOnLocal = async (
objectId: string,
shop: string,
achievements: any[]
) => {
return gameAchievementRepository
.upsert(
{
objectId,
shop,
unlockedAchievements: JSON.stringify(achievements),
},
["objectId", "shop"]
)
.then(() => {
WindowManager.mainWindow?.webContents.send(
"on-achievement-unlocked",
objectId,
shop
);
});
};
export const mergeAchievements = async (
objectId: string,
shop: string,
achievements: UnlockedAchievement[],
publishNotification: boolean
) => {
const game = await gameRepository.findOne({
where: { objectID: objectId, shop: shop as GameShop },
});
if (!game) return;
const localGameAchievement = await gameAchievementRepository.findOne({
where: {
objectId,
shop,
},
});
const unlockedAchievements = JSON.parse(
localGameAchievement?.unlockedAchievements || "[]"
).filter((achievement) => achievement.name);
const newAchievements = achievements
.filter((achievement) => {
return !unlockedAchievements.some((localAchievement) => {
return (
localAchievement.name.toUpperCase() === achievement.name.toUpperCase()
);
});
})
.map((achievement) => {
return {
name: achievement.name.toUpperCase(),
unlockTime: achievement.unlockTime,
};
});
if (newAchievements.length && publishNotification) {
const achievementsInfo = newAchievements
.sort((a, b) => {
return a.unlockTime - b.unlockTime;
})
.map((achievement) => {
return JSON.parse(localGameAchievement?.achievements || "[]").find(
(steamAchievement) => {
return (
achievement.name.toUpperCase() ===
steamAchievement.name.toUpperCase()
);
}
);
})
.filter((achievement) => achievement)
.map((achievement) => {
return {
displayName: achievement.displayName,
iconUrl: achievement.icon,
};
});
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
objectId,
shop,
achievementsInfo
);
}
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
if (game?.remoteId) {
return HydraApi.put("/profile/games/achievements", {
id: game.remoteId,
achievements: mergedLocalAchievements,
})
.then((response) => {
return saveAchievementsOnLocal(
response.objectId,
response.shop,
response.achievements
);
})
.catch(() => {
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
});
}
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
};

View File

@@ -0,0 +1,268 @@
import { Cracker } from "@shared";
import { UnlockedAchievement } from "@types";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { achievementsLogger } from "../logger";
export const parseAchievementFile = (
filePath: string,
type: Cracker
): UnlockedAchievement[] => {
if (!existsSync(filePath)) return [];
if (type == Cracker.codex) {
const parsed = iniParse(filePath);
return processDefault(parsed);
}
if (type == Cracker.rune) {
const parsed = iniParse(filePath);
return processDefault(parsed);
}
if (type === Cracker.onlineFix) {
const parsed = iniParse(filePath);
return processOnlineFix(parsed);
}
if (type === Cracker.goldberg) {
const parsed = jsonParse(filePath);
return processGoldberg(parsed);
}
if (type == Cracker.userstats) {
const parsed = iniParse(filePath);
return processUserStats(parsed);
}
if (type == Cracker.rld) {
const parsed = iniParse(filePath);
return processRld(parsed);
}
if (type === Cracker.skidrow) {
const parsed = iniParse(filePath);
return processSkidrow(parsed);
}
if (type === Cracker._3dm) {
const parsed = iniParse(filePath);
return process3DM(parsed);
}
if (type === Cracker.flt) {
const achievements = readdirSync(filePath);
return achievements.map((achievement) => {
return {
name: achievement,
unlockTime: Date.now(),
};
});
}
if (type === Cracker.creamAPI) {
const parsed = iniParse(filePath);
return processCreamAPI(parsed);
}
achievementsLogger.log(
`Unprocessed ${type} achievements found on ${filePath}`
);
return [];
};
const iniParse = (filePath: string) => {
try {
const lines = readFileSync(filePath, "utf-8").split(/[\r\n]+/);
let objectName = "";
const object: Record<string, Record<string, string | number>> = {};
for (const line of lines) {
if (line.startsWith("###") || !line.length) continue;
if (line.startsWith("[") && line.endsWith("]")) {
objectName = line.slice(1, -1);
object[objectName] = {};
} else {
const [name, ...value] = line.split("=");
object[objectName][name.trim()] = value.join("=").trim();
}
}
return object;
} catch (err) {
achievementsLogger.error(`Error parsing ${filePath}`, err);
return null;
}
};
const jsonParse = (filePath: string) => {
try {
return JSON.parse(readFileSync(filePath, "utf-8"));
} catch (err) {
achievementsLogger.error(`Error parsing ${filePath}`, err);
return null;
}
};
const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => {
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.achieved) {
parsedUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.timestamp * 1000,
});
}
}
return parsedUnlockedAchievements;
};
const processCreamAPI = (unlockedAchievements: any): UnlockedAchievement[] => {
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.achieved) {
const unlockTime = unlockedAchievement.unlocktime;
parsedUnlockedAchievements.push({
name: achievement,
unlockTime:
unlockTime.length === 7
? unlockTime * 1000 * 1000
: unlockTime * 1000,
});
}
}
return parsedUnlockedAchievements;
};
const processSkidrow = (unlockedAchievements: any): UnlockedAchievement[] => {
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
const achievements = unlockedAchievements["Achievements"];
for (const achievement of Object.keys(achievements)) {
const unlockedAchievement = achievements[achievement].split("@");
if (unlockedAchievement[0] === "1") {
parsedUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement[unlockedAchievement.length - 1] * 1000,
});
}
}
return parsedUnlockedAchievements;
};
const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.earned) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.earned_time * 1000,
});
}
}
return newUnlockedAchievements;
};
const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
const achievements = unlockedAchievements["State"];
const times = unlockedAchievements["Time"];
for (const achievement of Object.keys(achievements)) {
if (achievements[achievement] == "0101") {
const time = times[achievement];
newUnlockedAchievements.push({
name: achievement,
unlockTime:
new DataView(
new Uint8Array(Buffer.from(time.toString(), "hex")).buffer
).getUint32(0, true) * 1000,
});
}
}
return newUnlockedAchievements;
};
const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.Achieved) {
newUnlockedAchievements.push({
name: achievement,
unlockTime: unlockedAchievement.UnlockTime * 1000,
});
}
}
return newUnlockedAchievements;
};
const processRld = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
for (const achievement of Object.keys(unlockedAchievements)) {
if (achievement === "Steam") continue;
const unlockedAchievement = unlockedAchievements[achievement];
if (unlockedAchievement?.State) {
newUnlockedAchievements.push({
name: achievement,
unlockTime:
new DataView(
new Uint8Array(
Buffer.from(unlockedAchievement.Time.toString(), "hex")
).buffer
).getUint32(0, true) * 1000,
});
}
}
return newUnlockedAchievements;
};
const processUserStats = (unlockedAchievements: any): UnlockedAchievement[] => {
const newUnlockedAchievements: UnlockedAchievement[] = [];
const achievements = unlockedAchievements["ACHIEVEMENTS"];
if (!achievements) return [];
for (const achievement of Object.keys(achievements)) {
const unlockedAchievement = achievements[achievement];
const unlockTime = Number(
unlockedAchievement.slice(1, -1).replace("unlocked = true, time = ", "")
);
if (!isNaN(unlockTime)) {
newUnlockedAchievements.push({
name: achievement.replace(/"/g, ``),
unlockTime: unlockTime * 1000,
});
}
}
return newUnlockedAchievements;
};

View File

@@ -0,0 +1,91 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import {
findAllAchievementFiles,
findAchievementFiles,
findAchievementFileInExecutableDirectory,
getAlternativeObjectIds,
} from "./find-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements";
import type { UnlockedAchievement } from "@types";
import { getGameAchievementData } from "./get-game-achievement-data";
import { achievementsLogger } from "../logger";
import { Game } from "@main/entity";
export const updateAllLocalUnlockedAchievements = async () => {
const gameAchievementFilesMap = findAllAchievementFiles();
const games = await gameRepository.find({
where: {
isDeleted: false,
},
});
for (const game of games) {
for (const objectId of getAlternativeObjectIds(game.objectID)) {
const gameAchievementFiles = gameAchievementFilesMap.get(objectId) || [];
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
gameAchievementRepository
.findOne({
where: { objectId: game.objectID, shop: "steam" },
})
.then((localAchievements) => {
if (!localAchievements || !localAchievements.achievements) {
getGameAchievementData(game.objectID, "steam");
}
});
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const parsedAchievements = parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
if (parsedAchievements.length) {
unlockedAchievements.push(...parsedAchievements);
}
achievementsLogger.log(
"Achievement file for",
game.title,
achievementFile.filePath,
parsedAchievements
);
}
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
}
}
};
export const updateLocalUnlockedAchivements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory);
console.log("Achievements files for", game.title, gameAchievementFiles);
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const localAchievementFile = parseAchievementFile(
achievementFile.filePath,
achievementFile.type
);
if (localAchievementFile.length) {
unlockedAchievements.push(...localAchievementFile);
}
}
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
};

View File

@@ -17,6 +17,7 @@ export class HydraApi {
private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
@@ -51,7 +52,10 @@ export class HydraApi {
expirationTimestamp: tokenExpirationTimestamp,
};
logger.log("Sign in received", this.userAuth);
logger.log(
"Sign in received. Token expiration timestamp:",
tokenExpirationTimestamp
);
await userAuthRepository.upsert(
{
@@ -84,60 +88,66 @@ export class HydraApi {
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
});
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
const data = Array.isArray(request.data)
? request.data
: omit(request.data, ["refreshToken"]);
logger.log(request.method, request.url, request.params, data);
return request;
},
(error) => {
logger.error("request error", error);
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
const data = Array.isArray(response.data)
? response.data
: omit(response.data, ["username", "accessToken", "refreshToken"]);
logger.log(
response.status,
response.config.method,
response.config.url,
data
);
return response;
},
(error) => {
logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
if (error.response) {
logger.error("Response", error.response.status, error.response.data);
} else if (error.request) {
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
if (this.ADD_LOG_INTERCEPTOR) {
this.instance.interceptors.request.use(
(request) => {
logger.log(" ---- REQUEST -----");
const data = Array.isArray(request.data)
? request.data
: omit(request.data, ["refreshToken"]);
logger.log(request.method, request.url, request.params, data);
return request;
},
(error) => {
logger.error("request error", error);
return Promise.reject(error);
}
);
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
}
);
this.instance.interceptors.response.use(
(response) => {
logger.log(" ---- RESPONSE -----");
const data = Array.isArray(response.data)
? response.data
: omit(response.data, ["username", "accessToken", "refreshToken"]);
logger.log(
response.status,
response.config.method,
response.config.url,
data
);
return response;
},
(error) => {
logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
if (error.response) {
logger.error(
"Response",
error.response.status,
error.response.data
);
} else if (error.request) {
logger.error("Request", error.request);
} else {
logger.error("Error", error.message);
}
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
}
);
}
const userAuth = await userAuthRepository.findOne({
where: { id: 1 },

View File

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

View File

@@ -4,6 +4,7 @@ import { IsNull } from "typeorm";
import { HydraApi } from "../hydra-api";
import { mergeWithRemoteGames } from "./merge-with-remote-games";
import { WindowManager } from "../window-manager";
import { updateAllLocalUnlockedAchievements } from "../achievements/update-local-unlocked-achivements";
export const uploadGamesBatch = async () => {
const games = await gameRepository.find({
@@ -28,6 +29,8 @@ export const uploadGamesBatch = async () => {
await mergeWithRemoteGames();
await updateAllLocalUnlockedAchievements();
if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
};

View File

@@ -10,6 +10,10 @@ log.transports.file.resolvePathFn = (
return path.join(logsPath, "pythoninstance.txt");
}
if (message?.scope == "achievements") {
return path.join(logsPath, "achievements.txt");
}
if (message?.level === "error") {
return path.join(logsPath, "error.txt");
}
@@ -29,3 +33,4 @@ log.initialize();
export const pythonInstanceLogger = log.scope("python-instance");
export const logger = log.scope("main");
export const achievementsLogger = log.scope("achievements");

View File

@@ -0,0 +1,63 @@
import { GameShop, LudusaviBackup } from "@types";
import Piscina from "piscina";
import { app } from "electron";
import path from "node:path";
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "ludusavi", "ludusavi")
: path.join(__dirname, "..", "..", "ludusavi", "ludusavi");
export class Ludusavi {
private static worker = new Piscina({
filename: ludusaviWorkerPath,
workerData: {
binaryPath,
},
});
static async findGames(shop: GameShop, objectId: string): Promise<string[]> {
const games = await this.worker.run(
{ objectId, shop },
{ name: "findGames" }
);
return games;
}
static async backupGame(
shop: GameShop,
objectId: string,
backupPath: string
): Promise<LudusaviBackup> {
const games = await this.findGames(shop, objectId);
if (!games.length) throw new Error("Game not found");
return this.worker.run(
{ title: games[0], backupPath },
{ name: "backupGame" }
);
}
static async getBackupPreview(
shop: GameShop,
objectId: string,
backupPath: string
): Promise<LudusaviBackup | null> {
const games = await this.findGames(shop, objectId);
if (!games.length) return null;
const backupData = await this.worker.run(
{ title: games[0], backupPath, preview: true },
{ name: "backupGame" }
);
return backupData;
}
static async restoreBackup(backupPath: string) {
return this.worker.run(backupPath, { name: "restoreBackup" });
}
}

View File

@@ -1,6 +1,7 @@
import { sleep } from "@main/helpers";
import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher";
import { watchAchievements } from "./achievements/achievement-watcher";
export const startMainLoop = async () => {
// eslint-disable-next-line no-constant-condition
@@ -8,8 +9,9 @@ export const startMainLoop = async () => {
await Promise.allSettled([
watchProcesses(),
DownloadManager.watchDownloads(),
watchAchievements(),
]);
await sleep(1000);
await sleep(1500);
}
};

View File

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

View File

@@ -2,7 +2,7 @@ import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync";
import { GameRunning } from "@types";
import type { GameRunning } from "@types";
import { PythonInstance } from "./download";
import { Game } from "@main/entity";
@@ -25,12 +25,12 @@ export const watchProcesses = async () => {
if (games.length === 0) return;
const processes = await PythonInstance.getProcessList();
const processSet = new Set(processes.map((process) => process.exe));
for (const game of games) {
const executablePath = game.executablePath!;
const gameProcess = processes.find((runningProcess) => {
return executablePath == runningProcess.exe;
});
const gameProcess = processSet.has(executablePath);
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {

View File

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

View File

@@ -17,7 +17,7 @@ export const requestSteam250 = async (path: string) => {
return {
title: $title.textContent,
objectID: steamGameUrl.split("/").pop(),
objectId: steamGameUrl.split("/").pop(),
} as Steam250Game;
})
.filter((game) => game != null);
@@ -38,7 +38,7 @@ export const getSteam250List = async () => {
).flat();
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
if (item) map.set(item.objectID, item);
if (item) map.set(item.objectId, item);
return map;
}, new Map());

View File

@@ -1,3 +1,4 @@
import type { GameShop } from "@types";
import axios from "axios";
export interface SteamGridResponse {
@@ -20,9 +21,9 @@ export interface SteamGridGameResponse {
}
export const getSteamGridData = async (
objectID: string,
objectId: string,
path: string,
shop: string,
shop: GameShop,
params: Record<string, string> = {}
): Promise<SteamGridResponse> => {
const searchParams = new URLSearchParams(params);
@@ -32,7 +33,7 @@ export const getSteamGridData = async (
}
const response = await axios.get(
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectId}?${searchParams.toString()}`,
{
headers: {
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
@@ -58,10 +59,10 @@ export const getSteamGridGameById = async (
return response.data;
};
export const getSteamGameClientIcon = async (objectID: string) => {
export const getSteamGameClientIcon = async (objectId: string) => {
const {
data: { id: steamGridGameId },
} = await getSteamGridData(objectID, "games", "steam");
} = await getSteamGridData(objectId, "games", "steam");
const steamGridGame = await getSteamGridGameById(steamGridGameId);
return steamGridGame.data.platforms.steam.metadata.clienticon;

View File

@@ -12,11 +12,11 @@ export interface SteamAppDetailsResponse {
}
export const getSteamAppDetails = async (
objectID: string,
objectId: string,
language: string
) => {
const searchParams = new URLSearchParams({
appids: objectID,
appids: objectId,
l: language,
});
@@ -25,7 +25,7 @@ export const getSteamAppDetails = async (
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
)
.then((response) => {
if (response.data[objectID].success) return response.data[objectID].data;
if (response.data[objectId].success) return response.data[objectId].data;
return null;
})
.catch((err) => {

View File

@@ -19,8 +19,9 @@ import { HydraApi } from "./hydra-api";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
private static loadURL(hash = "") {
private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
@@ -37,6 +38,21 @@ export class WindowManager {
}
}
private static loadNotificationWindowURL() {
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.notificationWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
);
} else {
this.notificationWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "achievement-notification",
}
);
}
}
public static createMainWindow() {
if (this.mainWindow) return;
@@ -61,7 +77,54 @@ export class WindowManager {
show: false,
});
this.loadURL();
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
(details, callback) => {
callback({
requestHeaders: {
...details.requestHeaders,
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
},
});
}
);
this.mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
if (details.webContentsId !== this.mainWindow?.webContents.id) {
return callback(details);
}
const headers = {
"access-control-allow-origin": ["*"],
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
"access-control-expose-headers": ["ETag"],
"access-control-allow-headers": [
"Content-Type, Authorization, X-Requested-With, If-None-Match",
],
};
if (details.method === "OPTIONS") {
return callback({
cancel: false,
responseHeaders: {
...details.responseHeaders,
...headers,
},
statusLine: "HTTP/1.1 200 OK",
});
}
return callback({
responseHeaders: {
...details.responseHeaders,
...headers,
},
});
}
);
this.loadMainWindowURL();
this.mainWindow.removeMenu();
this.mainWindow.on("ready-to-show", () => {
@@ -78,9 +141,37 @@ export class WindowManager {
app.quit();
}
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow = null;
});
}
public static createNotificationWindow() {
this.notificationWindow = new BrowserWindow({
transparent: true,
maximizable: false,
autoHideMenuBar: true,
minimizable: false,
focusable: false,
skipTaskbar: true,
frame: false,
width: 350,
height: 104,
x: 0,
y: 0,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
});
this.notificationWindow.setIgnoreMouseEvents(true);
this.notificationWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL();
}
public static openAuthWindow() {
if (this.mainWindow) {
const authWindow = new BrowserWindow({
@@ -101,12 +192,14 @@ export class WindowManager {
authWindow.removeMenu();
if (!app.isPackaged) authWindow.webContents.openDevTools();
const searchParams = new URLSearchParams({
lng: i18next.language,
});
authWindow.loadURL(
`https://auth.hydralauncher.gg/?${searchParams.toString()}`
`${import.meta.env.MAIN_VITE_AUTH_URL}/?${searchParams.toString()}`
);
authWindow.once("ready-to-show", () => {
@@ -125,14 +218,14 @@ export class WindowManager {
public static redirect(hash: string) {
if (!this.mainWindow) this.createMainWindow();
this.loadURL(hash);
this.loadMainWindowURL(hash);
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
this.mainWindow?.focus();
}
public static createSystemTray(language: string) {
let tray;
let tray: Tray;
if (process.platform === "darwin") {
const macIcon = nativeImage

View File

@@ -3,6 +3,7 @@
interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_API_URL: string;
readonly MAIN_VITE_AUTH_URL: string;
readonly MAIN_VITE_SENTRY_DSN: string;
}

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
import type { GameShop, LudusaviBackup, LudusaviFindResult } from "@types";
import cp from "node:child_process";
import { workerData } from "node:worker_threads";
const { binaryPath } = workerData;
export const findGames = ({
shop,
objectId,
}: {
shop: GameShop;
objectId: string;
}) => {
const args = ["find", "--api"];
if (shop === "steam") {
args.push("--steam-id", objectId);
}
const result = cp.execFileSync(binaryPath, args);
const games = JSON.parse(result.toString("utf-8")) as LudusaviFindResult;
return Object.keys(games.games);
};
export const backupGame = ({
title,
backupPath,
preview = false,
winePrefix,
}: {
title: string;
backupPath: string;
preview?: boolean;
winePrefix?: string;
}) => {
const args = ["backup", title, "--api", "--force"];
if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath);
if (winePrefix) args.push("--wine-prefix", winePrefix);
const result = cp.execFileSync(binaryPath, args);
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};
export const restoreBackup = (backupPath: string) => {
const result = cp.execFileSync(binaryPath, [
"restore",
"--path",
backupPath,
"--api",
"--force",
]);
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};
// --wine-prefix

View File

@@ -13,6 +13,7 @@ import type {
UpdateProfileRequest,
} from "@types";
import type { CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
contextBridge.exposeInMainWorld("electron", {
/* Torrenting */
@@ -37,18 +38,37 @@ contextBridge.exposeInMainWorld("electron", {
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
getGames: (take?: number, prevCursor?: number) =>
ipcRenderer.invoke("getGames", take, prevCursor),
getHowLongToBeat: (objectId: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectId, shop, title),
getGames: (take?: number, skip?: number) =>
ipcRenderer.invoke("getGames", take, skip),
searchGameRepacks: (query: string) =>
ipcRenderer.invoke("searchGameRepacks", query),
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
getGameAchievements: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameAchievements", objectId, shop),
onAchievementUnlocked: (
cb: (
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => cb(objectId, shop, achievements);
ipcRenderer.on("on-achievement-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
@@ -60,17 +80,12 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
validateDownloadSource: (url: string) =>
ipcRenderer.invoke("validateDownloadSource", url),
addDownloadSource: (url: string) =>
ipcRenderer.invoke("addDownloadSource", url),
removeDownloadSource: (id: number) =>
ipcRenderer.invoke("removeDownloadSource", id),
syncDownloadSources: () => ipcRenderer.invoke("syncDownloadSources"),
deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("deleteDownloadSource", id),
/* Library */
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectId, title, shop),
createGameShortcut: (id: number) =>
ipcRenderer.invoke("createGameShortcut", id),
updateExecutablePath: (id: number, executablePath: string) =>
@@ -92,8 +107,8 @@ contextBridge.exposeInMainWorld("electron", {
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
deleteGameFolder: (gameId: number) =>
ipcRenderer.invoke("deleteGameFolder", gameId),
getGameByObjectID: (objectID: string) =>
ipcRenderer.invoke("getGameByObjectID", objectID),
getGameByObjectId: (objectId: string) =>
ipcRenderer.invoke("getGameByObjectId", objectId),
onGamesRunning: (
cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
@@ -115,6 +130,62 @@ contextBridge.exposeInMainWorld("electron", {
getDiskFreeSpace: (path: string) =>
ipcRenderer.invoke("getDiskFreeSpace", path),
/* Cloud save */
uploadSaveGame: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("uploadSaveGame", objectId, shop),
downloadGameArtifact: (
objectId: string,
shop: GameShop,
gameArtifactId: string
) =>
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
getGameArtifacts: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
checkGameCloudSyncSupport: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) =>
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener);
return () =>
ipcRenderer.removeListener(
`on-upload-complete-${objectId}-${shop}`,
listener
);
},
onBackupDownloadProgress: (
objectId: string,
shop: GameShop,
cb: (progress: AxiosProgressEvent) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
progress: AxiosProgressEvent
) => cb(progress);
ipcRenderer.on(`on-backup-download-progress-${objectId}-${shop}`, listener);
return () =>
ipcRenderer.removeListener(
`on-backup-download-complete-${objectId}-${shop}`,
listener
);
},
onBackupDownloadComplete: (
objectId: string,
shop: GameShop,
cb: () => void
) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on(`on-backup-download-complete-${objectId}-${shop}`, listener);
return () =>
ipcRenderer.removeListener(
`on-backup-download-complete-${objectId}-${shop}`,
listener
);
},
/* Misc */
ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"),
@@ -182,4 +253,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-signout", listener);
return () => ipcRenderer.removeListener("on-signout", listener);
},
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
});

View File

@@ -6,10 +6,10 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com https://video.akamai.steamstatic.com;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
/>
</head>
<body style="background-color: #1c1c1c">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -26,6 +26,10 @@ globalStyle("::-webkit-scrollbar-thumb", {
borderRadius: "24px",
});
globalStyle("::-webkit-scrollbar-thumb:hover", {
backgroundColor: "rgba(255, 255, 255, 0.16)",
});
globalStyle("html, body, #root, main", {
height: "100%",
});
@@ -35,7 +39,6 @@ globalStyle("body", {
userSelect: "none",
fontFamily: "Noto Sans, sans-serif",
fontSize: vars.size.body,
background: vars.color.background,
color: vars.color.body,
margin: "0",
});

View File

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

Binary file not shown.

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