Compare commits

..

79 Commits

Author SHA1 Message Date
Zamitto
a5aabe0ad7 fix: notifications not working on first run 2025-05-17 20:32:24 -03:00
Zamitto
276c098fbc chore: bump version 2025-05-17 19:35:02 -03:00
Zamitto
3455812a43 Merge pull request #1698 from hydralauncher/feat/HYD-822
feat: new game achievement notification [hyd-822]
2025-05-17 19:33:53 -03:00
Zamitto
87a994f0f0 feat: i18n and preview fix 2025-05-17 19:32:32 -03:00
Zamitto
15ddc71445 feat: i18n 2025-05-17 19:24:02 -03:00
Zamitto
ee916b998a feat: refactor test notiication event 2025-05-17 19:06:01 -03:00
Chubby Granny Chaser
914942d328 feat: renaming class names to BEM 2025-05-17 23:00:33 +01:00
Chubby Granny Chaser
5ae67a3dc7 feat: renaming class names to BEM 2025-05-17 22:59:38 +01:00
Zamitto
5475708b36 feat: i18n 2025-05-17 18:55:01 -03:00
Zamitto
c85f46844e feat: adjust gradient angle 2025-05-17 18:40:07 -03:00
Zamitto
1247a105a0 feat: info for rare and platinum achievements 2025-05-17 18:07:18 -03:00
Zamitto
3cc4ee3ee4 feat: trophy gradient for variations 2025-05-17 17:42:46 -03:00
Zamitto
7fca31338c fix: trophy and ellipses 2025-05-17 17:18:49 -03:00
Zamitto
0d747d03ab feat: refactor css 2025-05-17 17:14:09 -03:00
Zamitto
6a59036e21 feat: alignments 2025-05-17 16:56:09 -03:00
Zamitto
baddd4a99b feat: animation and borders 2025-05-17 14:29:01 -03:00
Zamitto
c40d26ef0a feat: refactor and adding variation animations 2025-05-17 02:36:12 -03:00
Zamitto
e4f7747200 feat: improve and fix animations 2025-05-16 18:14:18 -03:00
Zamitto
bc06ae5c03 feat: notification preview on theme editor 2025-05-16 16:18:19 -03:00
Zamitto
39c073634c feat: animation update 2025-05-16 06:56:21 -03:00
Zamitto
c5beeb861e feat: i18n update 2025-05-16 05:45:06 -03:00
Zamitto
0a4bdf160c feat: i18n and refactor 2025-05-16 05:19:33 -03:00
Zamitto
6f43da8d28 feat: achievement notification custom position and animations 2025-05-15 19:42:23 -03:00
Zamitto
42e8a68c08 Merge branch 'main' into feat/HYD-822 2025-05-14 19:55:43 -03:00
Zamitto
f960bb4f6f feat: set achievements cache only if game has achievements 2025-05-14 19:52:43 -03:00
Chubby Granny Chaser
7f988c0bba Merge pull request #1689 from hydralauncher/feat/HYD-819
feat: adding possibility to create steam shortcut
2025-05-14 21:57:54 +01:00
Chubby Granny Chaser
dcf05d3386 Merge branch 'main' into feat/HYD-819 2025-05-14 21:52:53 +01:00
Zamitto
96385d90d8 feat: custom achievement notification position 2025-05-14 17:42:30 -03:00
Zamitto
96cfa8c015 feat: re adding achievement notification window 2025-05-14 16:37:49 -03:00
Zamitto
ae067efd5e feat: delay SystemPath validations 2025-05-14 08:44:58 -03:00
Chubby Granny Chaser
8c16779052 feat: adding logging to steam copy 2025-05-14 10:58:40 +01:00
Chubby Granny Chaser
5c7a289299 fix: adding greptile fixes 2025-05-14 10:17:44 +01:00
Chubby Granny Chaser
e8e524182a feat: only downloading files once 2025-05-14 10:11:29 +01:00
Chubby Granny Chaser
521d9faa0c feat: automatically adding wine prefix 2025-05-14 00:50:30 +01:00
Chubby Granny Chaser
ca7ac73836 feat: adding shortcut for all users 2025-05-14 00:17:40 +01:00
Chubby Granny Chaser
ed42935e7b feat: adding shortcut for all users 2025-05-14 00:16:48 +01:00
Chubby Granny Chaser
f0c5ec6f1a feat: adding dynamic dir to get steam user id 2025-05-13 23:38:47 +01:00
Chubby Granny Chaser
66ced3c779 fix: fixing error message for path being used 2025-05-13 23:16:38 +01:00
Chubby Granny Chaser
4f8212f8e3 feat: using hydralauncher fork 2025-05-13 23:12:43 +01:00
Chubby Granny Chaser
86de5aa89e feat: adding possibility to create steam shortcut 2025-05-13 22:57:33 +01:00
Chubby Granny Chaser
00065ab0c9 Merge pull request #1679 from hydralauncher/feat/cross-cloud-save
feat: adding cross cloud save
2025-05-13 19:58:22 +01:00
Chubby Granny Chaser
e89202f750 feat: adding standalone aria2c 2025-05-13 01:24:29 +01:00
Chubby Granny Chaser
1df2353f06 fix: using realpath for fedora wine prefix 2025-05-12 17:31:47 +01:00
Chubby Granny Chaser
475ab4119b fix: removing replace from transformation 2025-05-12 12:55:55 +01:00
Chubby Granny Chaser
1346ff49a5 fix: adding path transformation for wine 2025-05-12 12:11:37 +01:00
Chubby Granny Chaser
4ff0132d53 fix: fixing home dir mapping 2025-05-12 11:54:57 +01:00
Chubby Granny Chaser
749a88b2b6 feat: adding wine prefix to backup creation on linux 2025-05-12 10:55:44 +01:00
Chubby Granny Chaser
427b77c597 fix: fixing return logic for wine prefix 2025-05-12 02:25:32 +00:00
Chubby Granny Chaser
e901df9ac7 fix: fixing casing for appimage 2025-05-11 19:04:15 -07:00
Chubby Granny Chaser
43e565bcc9 ci: adding appimage to build 2025-05-11 18:46:15 -07:00
Chubby Granny Chaser
f4e710c7d1 fix: fixing ludusavi download 2025-05-12 01:58:29 +01:00
Chubby Granny Chaser
592ac45740 feat: adding cross cloud save 2025-05-11 19:07:30 +01:00
Chubby Granny Chaser
6c55d667bd chore: bump version 2025-05-10 19:38:03 +01:00
Chubby Granny Chaser
a4bdc3b5f0 Merge pull request #1671 from hydralauncher/feat/HYD-781
Feat/hyd 781
2025-05-10 19:06:30 +01:00
Chubby Granny Chaser
44dc8f73e8 feat: adding staging urls for ws 2025-05-10 18:54:55 +01:00
Chubby Granny Chaser
8b8ead531d ci: updating build process 2025-05-10 18:08:47 +01:00
Chubby Granny Chaser
ec40dfdb0b ci: adding sonar ignore 2025-05-10 17:49:03 +01:00
Chubby Granny Chaser
74d93da9b3 chore: adding protobuf ts as dev dependency 2025-05-10 17:46:17 +01:00
Chubby Granny Chaser
216f813771 feat: adding new friend session notification 2025-05-10 17:43:09 +01:00
Chubby Granny Chaser
fee9cfb3e8 Merge branch 'main' of github.com:hydralauncher/hydra into feat/HYD-781 2025-05-10 17:20:57 +01:00
Chubby Granny Chaser
dcd00cda98 Merge pull request #1662 from hydralauncher/feat/get-image-assets-from-api
feat: getting image assets from api [HYD-811]
2025-05-10 17:20:36 +01:00
Zamitto
c9135715fa feat: refactor 2025-05-09 18:59:32 -03:00
Zamitto
64cea7ff85 feat: simplify get user event 2025-05-09 18:50:14 -03:00
Zamitto
382a618c3f feat: refactor assets in game details page 2025-05-09 18:30:45 -03:00
Chubby Granny Chaser
d906e3f145 ci: updating build to support ws url 2025-05-09 20:55:29 +01:00
Chubby Granny Chaser
e987b27aec ci: updating build to support ws url 2025-05-09 20:53:53 +01:00
Chubby Granny Chaser
18815a027f ci: updating build to support ws url 2025-05-09 20:53:21 +01:00
Chubby Granny Chaser
6c44cc0cc4 ci: updating build to support ws url 2025-05-09 20:52:03 +01:00
Zamitto
171c728616 Merge branch 'main' into feat/get-image-assets-from-api 2025-05-09 16:03:37 -03:00
Chubby Granny Chaser
b4ff16cfa4 Merge pull request #1667 from hydralauncher/fix/HYD-807
fix: storing rpc encrypted password
2025-05-09 15:06:23 +01:00
Chubby Granny Chaser
eb6317e659 fix: adding fallback to language 2025-05-09 13:18:38 +01:00
Zamitto
8377f85f0b fix: undo isStaging change 2025-05-09 08:49:58 -03:00
Zamitto
10504cdaf8 Merge branch 'main' into feat/get-image-assets-from-api 2025-05-09 08:49:37 -03:00
Chubby Granny Chaser
a01c77b424 fix: storing rpc encrypted password 2025-05-09 10:29:25 +01:00
Zamitto
cf818d0f4f feat: get game assets from stats 2025-05-08 09:19:12 -03:00
Zamitto
48e9536169 feat: adjust isLoading on game details context 2025-05-08 09:05:49 -03:00
Zamitto
00c589a138 feat: get image assets from api 2025-05-08 08:53:51 -03:00
Zamitto
30584492af feat: get image assets from api 2025-05-07 21:05:50 -03:00
Chubby Granny Chaser
aa18b57ada feat: adding ws 2025-04-29 10:05:27 +01:00
116 changed files with 3423 additions and 907 deletions

View File

@@ -1,4 +1,5 @@
MAIN_VITE_API_URL=API_URL MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID= RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE= RENDERER_VITE_TORBOX_REFERRAL_CODE=

View File

@@ -4,3 +4,4 @@ out
.gitignore .gitignore
migration.stub migration.stub
hydra-python-rpc/ hydra-python-rpc/
src/main/generated/

View File

@@ -48,6 +48,7 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -63,6 +64,7 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }} MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }} MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_STAGING_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -97,3 +99,4 @@ jobs:
dist/*.yml dist/*.yml
dist/*.blockmap dist/*.blockmap
dist/*.pacman dist/*.pacman
dist/*.AppImage

View File

@@ -51,6 +51,7 @@ jobs:
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
@@ -66,6 +67,7 @@ jobs:
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }} MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }} MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }} RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}

3
.gitignore vendored
View File

@@ -7,7 +7,8 @@ out
*.log* *.log*
.env .env
.vite .vite
ludusavi/ ludusavi/**
!ludusavi/config.yaml
hydra-python-rpc/ hydra-python-rpc/
.python-version .python-version

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "proto"]
path = proto
url = https://github.com/hydralauncher/hydra-protos.git

BIN
binaries/aria2c Executable file

Binary file not shown.

BIN
binaries/aria2c.exe Executable file

Binary file not shown.

View File

@@ -3,7 +3,6 @@ productName: Hydra
directories: directories:
buildResources: build buildResources: build
extraResources: extraResources:
- aria2
- ludusavi - ludusavi
- hydra-python-rpc - hydra-python-rpc
- seeds - seeds
@@ -21,6 +20,7 @@ asarUnpack:
win: win:
executableName: Hydra executableName: Hydra
extraResources: extraResources:
- from: binaries/aria2c.exe
- from: binaries/7z.exe - from: binaries/7z.exe
- from: binaries/7z.dll - from: binaries/7z.dll
target: target:
@@ -51,6 +51,7 @@ dmg:
linux: linux:
extraResources: extraResources:
- from: binaries/7zzs - from: binaries/7zzs
- from: binaries/aria2c
target: target:
- AppImage - AppImage
- snap - snap

6
ludusavi/config.yaml Normal file
View File

@@ -0,0 +1,6 @@
manifest:
enable: false
secondary:
- url: https://cdn.losbroxas.org/manifest.yaml
enable: true
customGames: []

View File

@@ -1,6 +1,6 @@
{ {
"name": "hydralauncher", "name": "hydralauncher",
"version": "3.4.9", "version": "3.5.0",
"description": "Hydra", "description": "Hydra",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "Los Broxas", "author": "Los Broxas",
@@ -28,7 +28,8 @@
"build:win": "electron-vite build && electron-builder --win", "build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac", "build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux", "build:linux": "electron-vite build && electron-builder --linux",
"prepare": "husky" "prepare": "husky",
"protoc": "npx protoc --ts_out src/main/generated --proto_path proto proto/*.proto"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
@@ -48,6 +49,7 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"color": "^4.2.3", "color": "^4.2.3",
"color.js": "^1.2.0", "color.js": "^1.2.0",
"crc": "^4.3.2",
"create-desktop-shortcuts": "^1.11.1", "create-desktop-shortcuts": "^1.11.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dexie": "^4.0.10", "dexie": "^4.0.10",
@@ -61,7 +63,6 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"parse-torrent": "^11.0.17", "parse-torrent": "^11.0.17",
"piscina": "^4.7.0",
"rc-virtual-list": "^3.16.1", "rc-virtual-list": "^3.16.1",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
@@ -70,10 +71,13 @@
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-tooltip": "^5.28.0", "react-tooltip": "^5.28.0",
"sound-play": "^1.1.0", "sound-play": "^1.1.0",
"steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"tar": "^7.4.3", "tar": "^7.4.3",
"tough-cookie": "^5.1.1", "tough-cookie": "^5.1.1",
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"winreg": "^1.2.5",
"ws": "^8.18.1",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"yup": "^1.5.0", "yup": "^1.5.0",
"zod": "^3.24.1" "zod": "^3.24.1"
@@ -85,6 +89,7 @@
"@electron-toolkit/eslint-config-prettier": "^2.0.0", "@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
"@protobuf-ts/plugin": "^2.10.0",
"@swc/core": "^1.4.16", "@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6", "@types/color": "^3.0.6",
@@ -97,6 +102,8 @@
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/sound-play": "^1.1.3", "@types/sound-play": "^1.1.3",
"@types/user-agents": "^1.0.4", "@types/user-agents": "^1.0.4",
"@types/winreg": "^1.2.36",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"electron": "^31.7.7", "electron": "^31.7.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",

1
proto Submodule

Submodule proto added at 7a23620f93

View File

@@ -3,7 +3,6 @@ const tar = require("tar");
const util = require("node:util"); const util = require("node:util");
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { spawnSync } = require("node:child_process");
const exec = util.promisify(require("node:child_process").exec); const exec = util.promisify(require("node:child_process").exec);
@@ -15,8 +14,18 @@ const fileName = {
darwin: `ludusavi-v${ludusaviVersion}-mac.tar.gz`, darwin: `ludusavi-v${ludusaviVersion}-mac.tar.gz`,
}; };
const ludusaviBinaryName = {
win32: "ludusavi.exe",
linux: "ludusavi",
darwin: "ludusavi",
};
const downloadLudusavi = async () => { const downloadLudusavi = async () => {
if (fs.existsSync("ludusavi")) { if (
fs.existsSync(
path.join(process.cwd(), "ludusavi", ludusaviBinaryName[process.platform])
)
) {
console.log("Ludusavi already exists, skipping download..."); console.log("Ludusavi already exists, skipping download...");
return; return;
} }
@@ -58,79 +67,4 @@ const downloadLudusavi = async () => {
}); });
}; };
const downloadAria2WindowsAndLinux = async () => {
const file =
process.platform === "win32"
? "aria2-1.37.0-win-64bit-build1.zip"
: "aria2-1.37.0-1-x86_64.pkg.tar.zst";
const downloadUrl =
process.platform === "win32"
? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}`
: "https://archlinux.org/packages/extra/x86_64/aria2/download/";
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...`);
if (process.platform === "win32") {
await exec(`npx extract-zip ${file}`);
console.log("Extracted. Renaming folder...");
fs.mkdirSync("aria2");
fs.copyFileSync(
path.join(file.replace(".zip", ""), "aria2c.exe"),
"aria2/aria2c.exe"
);
fs.rmSync(file.replace(".zip", ""), { recursive: true });
} else {
await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`);
console.log("Extracted. Copying binary file...");
fs.mkdirSync("aria2");
fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c");
fs.rmSync("usr", { recursive: true });
}
console.log(`Extracted ${file}, removing compressed downloaded file...`);
fs.rmSync(file);
});
};
const copyAria2Macos = async () => {
console.log("Checking if aria2 is installed...");
const isAria2Installed = spawnSync("which", ["aria2c"]).status;
if (isAria2Installed != 0) {
console.log("Please install aria2");
console.log("brew install aria2");
return;
}
console.log("Copying aria2 binary...");
fs.mkdirSync("aria2");
await exec(`cp $(which aria2c) aria2/aria2c`);
};
const copyAria2 = () => {
const aria2Path =
process.platform === "win32" ? "aria2/aria2c.exe" : "aria2/aria2c";
if (fs.existsSync(aria2Path)) {
console.log("Aria2 already exists, skipping download...");
return;
}
if (process.platform == "darwin") {
copyAria2Macos();
} else {
downloadAria2WindowsAndLinux();
}
};
copyAria2();
downloadLudusavi(); downloadLudusavi();

View File

@@ -20,7 +20,7 @@ const s3 = new S3Client({
const dist = path.resolve(__dirname, "..", "dist"); const dist = path.resolve(__dirname, "..", "dist");
const extensionsToUpload = [".deb", ".exe", ".pacman"]; const extensionsToUpload = [".deb", ".exe", ".pacman", ".AppImage"];
fs.readdir(dist, async (err, files) => { fs.readdir(dist, async (err, files) => {
if (err) throw err; if (err) throw err;

File diff suppressed because one or more lines are too long

1
sonar-project.properties Normal file
View File

@@ -0,0 +1 @@
sonar.exclusions=src/main/generated/**

View File

@@ -130,9 +130,11 @@
"download_in_progress": "Download in progress", "download_in_progress": "Download in progress",
"download_paused": "Download paused", "download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option", "last_downloaded_option": "Last downloaded option",
"create_steam_shortcut": "Create Steam shortcut",
"create_shortcut_success": "Shortcut created successfully", "create_shortcut_success": "Shortcut created successfully",
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
"create_shortcut_error": "Error creating shortcut", "create_shortcut_error": "Error creating shortcut",
"nsfw_content_title": "This game contains innapropriate content", "nsfw_content_title": "This game contains inappropriate content",
"nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?", "nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
"allow_nsfw_content": "Continue", "allow_nsfw_content": "Continue",
"refuse_nsfw_content": "Go back", "refuse_nsfw_content": "Go back",
@@ -199,7 +201,10 @@
"game_removed_from_favorites": "Game removed from favorites", "game_removed_from_favorites": "Game removed from favorites",
"game_added_to_favorites": "Game added to favorites", "game_added_to_favorites": "Game added to favorites",
"automatically_extract_downloaded_files": "Automatically extract downloaded files", "automatically_extract_downloaded_files": "Automatically extract downloaded files",
"create_start_menu_shortcut": "Create Start Menu shortcut" "create_start_menu_shortcut": "Create Start Menu shortcut",
"invalid_wine_prefix_path": "Invalid Wine prefix path",
"invalid_wine_prefix_path_description": "The path to the Wine prefix is invalid. Please check the path and try again.",
"missing_wine_prefix": "Wine prefix is required to create a backup on Linux"
}, },
"activation": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",
@@ -358,7 +363,23 @@
"install_common_redist": "Install", "install_common_redist": "Install",
"installing_common_redist": "Installing…", "installing_common_redist": "Installing…",
"show_download_speed_in_megabytes": "Show download speed in megabytes per second", "show_download_speed_in_megabytes": "Show download speed in megabytes per second",
"extract_files_by_default": "Extract files by default after download" "extract_files_by_default": "Extract files by default after download",
"achievement_custom_notification_position": "Achievement custom notification position",
"top-left": "Top left",
"top-center": "Top center",
"top-right": "Top right",
"bottom-left": "Bottom left",
"bottom-center": "Bottom center",
"bottom-right": "Bottom right",
"enable_achievement_custom_notifications": "Enable achievement custom notifications",
"alignment": "Alignment",
"variation": "Variation",
"default": "Default",
"rare": "Rare",
"platinum": "Platinum",
"hidden": "Hidden",
"test_notification": "Test notification",
"notification_preview": "Achievement Notification Preview"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
@@ -370,10 +391,13 @@
"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_title": "Achievement unlocked for {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked", "notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked",
"new_friend_request_description": "You have received a new friend request", "new_friend_request_description": "{{displayName}} sent you a friend request",
"new_friend_request_title": "New friend request", "new_friend_request_title": "New friend request",
"extraction_complete": "Extraction complete", "extraction_complete": "Extraction complete",
"game_extracted": "{{title}} extracted successfully" "game_extracted": "{{title}} extracted successfully",
"friend_started_playing_game": "{{displayName}} started playing a game",
"test_achievement_notification_title": "This is a test notification",
"test_achievement_notification_description": "Pretty cool, huh?"
}, },
"system_tray": { "system_tray": {
"open": "Open Hydra", "open": "Open Hydra",

View File

@@ -130,8 +130,10 @@
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)", "danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)",
"download_in_progress": "Descarga en progreso", "download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada", "download_paused": "Descarga pausada",
"create_steam_shortcut": "Crear atajo de Steam",
"last_downloaded_option": "Última opción descargada", "last_downloaded_option": "Última opción descargada",
"create_shortcut_success": "Atajo creado con éxito", "create_shortcut_success": "Atajo creado con éxito",
"you_might_need_to_restart_steam": "Es posible que necesites reiniciar Steam para ver los cambios",
"create_shortcut_error": "Error al crear un atajo", "create_shortcut_error": "Error al crear un atajo",
"nsfw_content_title": "Este juego contiene contenido inapropiado.", "nsfw_content_title": "Este juego contiene contenido inapropiado.",
"nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?", "nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?",
@@ -198,7 +200,10 @@
"download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.", "download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.",
"download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y el estado de descarga del sondeo aún no está disponible.", "download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y el estado de descarga del sondeo aún no está disponible.",
"game_added_to_favorites": "Juego añadido a favoritos", "game_added_to_favorites": "Juego añadido a favoritos",
"game_removed_from_favorites": "Juego removido de favoritos" "game_removed_from_favorites": "Juego removido de favoritos",
"invalid_wine_prefix_path": "Ruta de prefixo Wine inválida",
"invalid_wine_prefix_path_description": "La ruta al prefixo Wine es inválida. Por favor, verifica la ruta y vuelve a intentarlo.",
"missing_wine_prefix": ""
}, },
"activation": { "activation": {
"title": "Activar Hydra", "title": "Activar Hydra",
@@ -371,7 +376,8 @@
"notification_achievement_unlocked_title": "Logro desbloqueado de {{game}}", "notification_achievement_unlocked_title": "Logro desbloqueado de {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados", "notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados",
"new_friend_request_title": "Nueva solicitud de amistad", "new_friend_request_title": "Nueva solicitud de amistad",
"new_friend_request_description": "Has recibido una nueva solicitud de amistad" "new_friend_request_description": "{{displayName}} te envió una solicitud de amistad",
"friend_started_playing_game": "{{displayName}} está jugando"
}, },
"system_tray": { "system_tray": {
"open": "Abrir Hydra", "open": "Abrir Hydra",

View File

@@ -118,7 +118,9 @@
"download_in_progress": "Download em andamento", "download_in_progress": "Download em andamento",
"download_paused": "Download pausado", "download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada", "last_downloaded_option": "Última opção baixada",
"create_steam_shortcut": "Criar atalho na Steam",
"create_shortcut_success": "Atalho criado com sucesso", "create_shortcut_success": "Atalho criado com sucesso",
"you_might_need_to_restart_steam": "Você pode precisar reiniciar a Steam para ver as alterações",
"create_shortcut_error": "Erro ao criar atalho", "create_shortcut_error": "Erro ao criar atalho",
"nsfw_content_title": "Este jogo contém conteúdo inapropriado", "nsfw_content_title": "Este jogo contém conteúdo inapropriado",
"nsfw_content_description": "{{title}} contém conteúdo que pode não ser apropriado para todas as idades. Você deseja continuar?", "nsfw_content_description": "{{title}} contém conteúdo que pode não ser apropriado para todas as idades. Você deseja continuar?",
@@ -188,7 +190,9 @@
"game_removed_from_favorites": "Jogo removido dos favoritos", "game_removed_from_favorites": "Jogo removido dos favoritos",
"game_added_to_favorites": "Jogo adicionado aos favoritos", "game_added_to_favorites": "Jogo adicionado aos favoritos",
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados", "automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados",
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar" "create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
"invalid_wine_prefix_path": "Caminho do prefixo Wine inválido",
"invalid_wine_prefix_path_description": "O caminho para o prefixo Wine é inválido. Por favor, verifique o caminho e tente novamente."
}, },
"activation": { "activation": {
"title": "Ativação", "title": "Ativação",
@@ -345,7 +349,23 @@
"install_common_redist": "Instalar", "install_common_redist": "Instalar",
"installing_common_redist": "Instalando…", "installing_common_redist": "Instalando…",
"show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo", "show_download_speed_in_megabytes": "Exibir taxas de download em megabytes por segundo",
"extract_files_by_default": "Extrair arquivos automaticamente após o download" "extract_files_by_default": "Extrair arquivos automaticamente após o download",
"enable_achievement_custom_notifications": "Habilitar notificações customizadas de conquistas",
"top-left": "Superior esquerdo",
"top-center": "Superior central",
"top-right": "Superior direito",
"bottom-left": "Inferior esquerdo",
"bottom-right": "Inferior direito",
"bottom-center": "Inferior central",
"achievement_custom_notification_position": "Posição das notificações customizadas de conquista",
"alignment": "Alinhamento",
"variation": "Variação",
"default": "Padrão",
"rare": "Rara",
"platinum": "Platina",
"hidden": "Oculta",
"test_notification": "Testar notificação",
"notification_preview": "Prévia da Notificação de Conquistas"
}, },
"notifications": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@@ -356,9 +376,12 @@
"new_update_available": "Versão {{version}} disponível", "new_update_available": "Versão {{version}} disponível",
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão", "restart_to_install_update": "Reinicie o Hydra para instalar a nova versão",
"new_friend_request_title": "Novo pedido de amizade", "new_friend_request_title": "Novo pedido de amizade",
"new_friend_request_description": "Você recebeu um novo pedido de amizade", "new_friend_request_description": "{{displayName}} te enviou um pedido de amizade",
"extraction_complete": "Extração concluída", "extraction_complete": "Extração concluída",
"game_extracted": "{{title}} extraído com sucesso" "game_extracted": "{{title}} extraído com sucesso",
"friend_started_playing_game": "{{displayName}} começou a jogar",
"test_achievement_notification_title": "Esta é uma notificação de teste",
"test_achievement_notification_description": "Bem legal, né?"
}, },
"system_tray": { "system_tray": {
"open": "Abrir Hydra", "open": "Abrir Hydra",

View File

@@ -341,7 +341,8 @@
"new_update_available": "Versão {{version}} disponível", "new_update_available": "Versão {{version}} disponível",
"restart_to_install_update": "Reinicia o Hydra para instalar a nova versão", "restart_to_install_update": "Reinicia o Hydra para instalar a nova versão",
"new_friend_request_title": "Novo pedido de amizade", "new_friend_request_title": "Novo pedido de amizade",
"new_friend_request_description": "Recebeste um novo pedido de amizade" "new_friend_request_description": "{{displayName}} te enviou um pedido de amizade",
"friend_started_playing_game": "{{displayName}} começou a jogar"
}, },
"system_tray": { "system_tray": {
"open": "Abrir o Hydra", "open": "Abrir o Hydra",

View File

@@ -2,8 +2,6 @@ import { app } from "electron";
import path from "node:path"; import path from "node:path";
import { SystemPath } from "./services/system-path"; import { SystemPath } from "./services/system-path";
export const LUDUSAVI_MANIFEST_URL = "https://cdn.losbroxas.org/manifest.yaml";
export const defaultDownloadsPath = SystemPath.getPath("downloads"); export const defaultDownloadsPath = SystemPath.getPath("downloads");
export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging"); export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging");
@@ -16,6 +14,8 @@ export const windowsStartMenuPath = path.join(
"Programs" "Programs"
); );
export const publicProfilePath = "C:/Users/Public";
export const levelDatabasePath = path.join( export const levelDatabasePath = path.join(
SystemPath.getPath("userData"), SystemPath.getPath("userData"),
`hydra-db${isStaging ? "-staging" : ""}` `hydra-db${isStaging ? "-staging" : ""}`
@@ -40,4 +40,6 @@ export const backupsPath = path.join(SystemPath.getPath("userData"), "Backups");
export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets");
export const MAIN_LOOP_INTERVAL = 1500; export const MAIN_LOOP_INTERVAL = 1500;

View File

@@ -1,5 +1,10 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; import {
DownloadManager,
HydraApi,
WSClient,
gamesPlaytime,
} from "@main/services";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const signOut = async (_event: Electron.IpcMainInvokeEvent) => { const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
@@ -30,6 +35,8 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
databaseOperations, databaseOperations,
HydraApi.post("/auth/logout").catch(() => {}), HydraApi.post("/auth/logout").catch(() => {}),
]); ]);
WSClient.close();
}; };
registerEvent("signOut", signOut); registerEvent("signOut", signOut);

View File

@@ -1,6 +1,7 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { CatalogueCategory } from "@shared"; import { CatalogueCategory } from "@shared";
import { ShopAssets } from "@types";
const getCatalogue = async ( const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -11,7 +12,7 @@ const getCatalogue = async (
skip: "0", skip: "0",
}); });
return HydraApi.get( return HydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`, `/catalogue/${category}?${params.toString()}`,
{}, {},
{ needsAuth: false } { needsAuth: false }

View File

@@ -1,10 +1,13 @@
import { getSteamAppDetails, logger } from "@main/services"; import { getSteamAppDetails, logger } from "@main/services";
import type { ShopDetails, GameShop } from "@types"; import type { ShopDetails, GameShop, ShopDetailsWithAssets } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers"; import {
import { gamesShopCacheSublevel, levelKeys } from "@main/level"; gamesShopAssetsSublevel,
gamesShopCacheSublevel,
levelKeys,
} from "@main/level";
const getLocalizedSteamAppDetails = async ( const getLocalizedSteamAppDetails = async (
objectId: string, objectId: string,
@@ -14,22 +17,7 @@ const getLocalizedSteamAppDetails = async (
return getSteamAppDetails(objectId, language); return getSteamAppDetails(objectId, language);
} }
return getSteamAppDetails(objectId, language).then( return getSteamAppDetails(objectId, language);
async (localizedAppDetails) => {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
if (steamGame && localizedAppDetails) {
return {
...localizedAppDetails,
name: steamGame.name,
};
}
return null;
}
);
}; };
const getGameShopDetails = async ( const getGameShopDetails = async (
@@ -37,34 +25,44 @@ const getGameShopDetails = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
language: string language: string
): Promise<ShopDetails | null> => { ): Promise<ShopDetailsWithAssets | null> => {
if (shop === "steam") { if (shop === "steam") {
const cachedData = await gamesShopCacheSublevel.get( const [cachedData, cachedAssets] = await Promise.all([
levelKeys.gameShopCacheItem(shop, objectId, language) gamesShopCacheSublevel.get(
); levelKeys.gameShopCacheItem(shop, objectId, language)
),
gamesShopAssetsSublevel.get(levelKeys.game(shop, objectId)),
]);
const appDetails = getLocalizedSteamAppDetails(objectId, language).then( const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
(result) => { (result) => {
if (result) { if (result) {
result.name = cachedAssets?.title ?? result.name;
gamesShopCacheSublevel gamesShopCacheSublevel
.put(levelKeys.gameShopCacheItem(shop, objectId, language), result) .put(levelKeys.gameShopCacheItem(shop, objectId, language), result)
.catch((err) => { .catch((err) => {
logger.error("Could not cache game details", err); logger.error("Could not cache game details", err);
}); });
return {
...result,
assets: cachedAssets ?? null,
};
} }
return result; return null;
} }
); );
if (cachedData) { if (cachedData) {
return { return {
...cachedData, ...cachedData,
objectId, assets: cachedAssets ?? null,
} as ShopDetails; };
} }
return Promise.resolve(appDetails); return appDetails;
} }
throw new Error("Not implemented"); throw new Error("Not implemented");

View File

@@ -6,17 +6,17 @@ import type { TrendingGame } from "@types";
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => { const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db const language = await db
.get<string, string>(levelKeys.language, { .get<string, string>(levelKeys.language, {
valueEncoding: "utf-8", valueEncoding: "utf8",
}) })
.then((language) => language || "en"); .then((language) => language || "en");
const trendingGames = await HydraApi.get<TrendingGame[]>( const trendingGames = await HydraApi.get<TrendingGame[]>(
"/games/trending", "/games/featured",
{ language }, { language },
{ needsAuth: false } { needsAuth: false }
).catch(() => []); ).catch(() => []);
return trendingGames; return trendingGames.slice(0, 1);
}; };
registerEvent("getTrendingGames", getTrendingGames); registerEvent("getTrendingGames", getTrendingGames);

View File

@@ -0,0 +1,14 @@
import type { GameShop, ShopAssets } from "@types";
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
const saveGameShopAssets = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
assets: ShopAssets
): Promise<void> => {
return gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), assets);
};
registerEvent("saveGameShopAssets", saveGameShopAssets);

View File

@@ -1,74 +1,93 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services"; import { CloudSync, HydraApi, logger, WindowManager } from "@main/services";
import fs from "node:fs"; import fs from "node:fs";
import * as tar from "tar"; import * as tar from "tar";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import axios from "axios"; import axios from "axios";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { backupsPath } from "@main/constants"; import { backupsPath, publicProfilePath } from "@main/constants";
import type { GameShop } from "@types"; import type { GameShop, LudusaviBackupMapping } from "@types";
import YAML from "yaml"; import YAML from "yaml";
import { normalizePath } from "@main/helpers"; import { addTrailingSlash, normalizePath } from "@main/helpers";
import { SystemPath } from "@main/services/system-path"; import { SystemPath } from "@main/services/system-path";
import { gamesSublevel, levelKeys } from "@main/level";
export interface LudusaviBackup { export const transformLudusaviBackupPathIntoWindowsPath = (
files: { backupPath: string,
[key: string]: { winePrefixPath?: string | null
hash: string; ) => {
size: number; return backupPath
}; .replace(winePrefixPath ? addTrailingSlash(winePrefixPath) : "", "")
}; .replace("drive_c", "C:");
} };
const replaceLudusaviBackupWithCurrentUser = ( export const addWinePrefixToWindowsPath = (
windowsPath: string,
winePrefixPath?: string | null
) => {
if (!winePrefixPath) {
return windowsPath;
}
return path.join(winePrefixPath, windowsPath.replace("C:", "drive_c"));
};
const restoreLudusaviBackup = (
backupPath: string, backupPath: string,
title: string, title: string,
homeDir: string homeDir: string,
winePrefixPath?: string | null,
artifactWinePrefixPath?: string | null
) => { ) => {
const gameBackupPath = path.join(backupPath, title); const gameBackupPath = path.join(backupPath, title);
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml"); const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
const data = fs.readFileSync(mappingYamlPath, "utf8"); const data = fs.readFileSync(mappingYamlPath, "utf8");
const manifest = YAML.parse(data) as { const manifest = YAML.parse(data) as {
backups: LudusaviBackup[]; backups: LudusaviBackupMapping[];
drives: Record<string, string>; drives: Record<string, string>;
}; };
const currentHomeDir = normalizePath(SystemPath.getPath("home")); const userProfilePath =
CloudSync.getWindowsLikeUserProfilePath(winePrefixPath);
/* Renaming logic */ manifest.backups.forEach((backup) => {
if (os.platform() === "win32") { Object.keys(backup.files).forEach((key) => {
const mappedHomeDir = path.join( const sourcePathWithDrives = Object.entries(manifest.drives).reduce(
gameBackupPath, (prev, [driveKey, driveValue]) => {
path.join("drive-C", homeDir.replace("C:", "")) return prev.replace(driveValue, driveKey);
); },
key
if (fs.existsSync(mappedHomeDir)) {
fs.renameSync(
mappedHomeDir,
path.join(gameBackupPath, "drive-C", currentHomeDir.replace("C:", ""))
); );
}
}
const backups = manifest.backups.map((backup: LudusaviBackup) => { const sourcePath = path.join(gameBackupPath, sourcePathWithDrives);
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
const updatedKey = key.replace(homeDir, currentHomeDir);
return { logger.info(`Source path: ${sourcePath}`);
...prev,
[updatedKey]: value,
};
}, {});
return { const destinationPath = transformLudusaviBackupPathIntoWindowsPath(
...backup, key,
files, artifactWinePrefixPath
}; )
.replace(
homeDir,
addWinePrefixToWindowsPath(userProfilePath, winePrefixPath)
)
.replace(
publicProfilePath,
addWinePrefixToWindowsPath(publicProfilePath, winePrefixPath)
);
logger.info(`Moving ${sourcePath} to ${destinationPath}`);
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
if (fs.existsSync(destinationPath)) {
fs.unlinkSync(destinationPath);
}
fs.renameSync(sourcePath, destinationPath);
});
}); });
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
}; };
const downloadGameArtifact = async ( const downloadGameArtifact = async (
@@ -78,10 +97,18 @@ const downloadGameArtifact = async (
gameArtifactId: string gameArtifactId: string
) => { ) => {
try { try {
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{ const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
const {
downloadUrl,
objectKey,
homeDir,
winePrefixPath: artifactWinePrefixPath,
} = await HydraApi.post<{
downloadUrl: string; downloadUrl: string;
objectKey: string; objectKey: string;
homeDir: string; homeDir: string;
winePrefixPath: string | null;
}>(`/profile/games/artifacts/${gameArtifactId}/download`); }>(`/profile/games/artifacts/${gameArtifactId}/download`);
const zipLocation = path.join(SystemPath.getPath("userData"), objectKey); const zipLocation = path.join(SystemPath.getPath("userData"), objectKey);
@@ -109,34 +136,34 @@ const downloadGameArtifact = async (
response.data.pipe(writer); response.data.pipe(writer);
writer.on("error", (err) => { writer.on("error", (err) => {
logger.error("Failed to write zip", err); logger.error("Failed to write tar file", err);
throw err; throw err;
}); });
fs.mkdirSync(backupPath, { recursive: true }); fs.mkdirSync(backupPath, { recursive: true });
writer.on("close", () => { writer.on("close", async () => {
tar await tar.x({
.x({ file: zipLocation,
file: zipLocation, cwd: backupPath,
cwd: backupPath, });
})
.then(async () => {
replaceLudusaviBackupWithCurrentUser(
backupPath,
objectId,
normalizePath(homeDir)
);
Ludusavi.restoreBackup(backupPath).then(() => { restoreLudusaviBackup(
WindowManager.mainWindow?.webContents.send( backupPath,
`on-backup-download-complete-${objectId}-${shop}`, objectId,
true normalizePath(homeDir),
); game?.winePrefixPath,
}); artifactWinePrefixPath
}); );
WindowManager.mainWindow?.webContents.send(
`on-backup-download-complete-${objectId}-${shop}`,
true
);
}); });
} catch (err) { } catch (err) {
logger.error("Failed to download game artifact", err);
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
`on-backup-download-complete-${objectId}-${shop}`, `on-backup-download-complete-${objectId}-${shop}`,
false false

View File

@@ -3,6 +3,7 @@ import { ipcMain } from "electron";
import "./catalogue/get-catalogue"; import "./catalogue/get-catalogue";
import "./catalogue/get-game-shop-details"; import "./catalogue/get-game-shop-details";
import "./catalogue/save-game-shop-assets";
import "./catalogue/get-how-long-to-beat"; import "./catalogue/get-how-long-to-beat";
import "./catalogue/get-random-game"; import "./catalogue/get-random-game";
import "./catalogue/search-games"; import "./catalogue/search-games";
@@ -33,6 +34,8 @@ import "./library/remove-game-from-library";
import "./library/select-game-wine-prefix"; import "./library/select-game-wine-prefix";
import "./library/reset-game-achievements"; import "./library/reset-game-achievements";
import "./library/toggle-automatic-cloud-sync"; import "./library/toggle-automatic-cloud-sync";
import "./library/get-default-wine-prefix-selection-path";
import "./library/create-steam-shortcut";
import "./misc/open-checkout"; import "./misc/open-checkout";
import "./misc/open-external"; import "./misc/open-external";
import "./misc/show-open-dialog"; import "./misc/show-open-dialog";
@@ -84,6 +87,8 @@ import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact"; import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path"; import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification"; import "./notifications/publish-new-repacks-notification";
import "./notifications/update-achievement-notification-window";
import "./notifications/show-achievement-test-notification";
import "./themes/add-custom-theme"; import "./themes/add-custom-theme";
import "./themes/delete-custom-theme"; import "./themes/delete-custom-theme";
import "./themes/get-all-custom-themes"; import "./themes/get-all-custom-themes";

View File

@@ -1,12 +1,13 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements"; import { updateLocalUnlockedAchievements } from "@main/services/achievements/update-local-unlocked-achivements";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
const addGameToLibrary = async ( const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -17,6 +18,8 @@ const addGameToLibrary = async (
const gameKey = levelKeys.game(shop, objectId); const gameKey = levelKeys.game(shop, objectId);
let game = await gamesSublevel.get(gameKey); let game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
if (game) { if (game) {
await downloadsSublevel.del(gameKey); await downloadsSublevel.del(gameKey);
@@ -24,17 +27,9 @@ const addGameToLibrary = async (
await gamesSublevel.put(gameKey, game); await gamesSublevel.put(gameKey, game);
} else { } else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
game = { game = {
title, title,
iconUrl, iconUrl: gameAssets?.iconUrl ?? null,
objectId, objectId,
shop, shop,
remoteId: null, remoteId: null,

View File

@@ -0,0 +1,181 @@
import { registerEvent } from "../register-event";
import type { GameShop, GameStats } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
import {
composeSteamShortcut,
getSteamLocation,
getSteamShortcuts,
getSteamUsersIds,
HydraApi,
logger,
SystemPath,
writeSteamShortcuts,
} from "@main/services";
import fs from "node:fs";
import axios from "axios";
import path from "node:path";
import { ASSETS_PATH } from "@main/constants";
const downloadAsset = async (downloadPath: string, url?: string | null) => {
try {
if (fs.existsSync(downloadPath)) {
return downloadPath;
}
if (!url) {
return null;
}
fs.mkdirSync(path.dirname(downloadPath), { recursive: true });
const response = await axios.get(url, { responseType: "arraybuffer" });
fs.writeFileSync(downloadPath, response.data);
return downloadPath;
} catch (error) {
logger.error("Failed to download asset", error);
return null;
}
};
const downloadAssetsFromSteam = async (
shop: GameShop,
objectId: string,
assets: GameStats["assets"]
) => {
const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`);
return await Promise.all([
downloadAsset(path.join(gameAssetsPath, "icon.ico"), assets?.iconUrl),
downloadAsset(
path.join(gameAssetsPath, "hero.jpg"),
assets?.libraryHeroImageUrl
),
downloadAsset(path.join(gameAssetsPath, "logo.png"), assets?.logoImageUrl),
downloadAsset(
path.join(gameAssetsPath, "cover.jpg"),
assets?.coverImageUrl
),
downloadAsset(
path.join(gameAssetsPath, "library.jpg"),
assets?.libraryImageUrl
),
]);
};
const copyAssetIfExists = async (
sourcePath: string | null,
destinationPath: string
) => {
if (sourcePath && fs.existsSync(sourcePath)) {
logger.info("Copying Steam asset", sourcePath, destinationPath);
await fs.promises.cp(sourcePath, destinationPath);
}
};
const createSteamShortcut = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);
if (game) {
if (!game.executablePath) {
throw new Error("No executable path found for game");
}
const { assets } = await HydraApi.get<GameStats>(
`/games/stats?objectId=${objectId}&shop=${shop}`
);
const steamUserIds = await getSteamUsersIds();
if (!steamUserIds.length) {
logger.error("No Steam user ID found");
return;
}
const [iconImage, heroImage, logoImage, coverImage, libraryImage] =
await downloadAssetsFromSteam(game.shop, game.objectId, assets);
const newShortcut = composeSteamShortcut(
game.title,
game.executablePath,
iconImage
);
for (const steamUserId of steamUserIds) {
logger.info("Adding shortcut for Steam user", steamUserId);
const steamShortcuts = await getSteamShortcuts(steamUserId);
if (steamShortcuts.some((shortcut) => shortcut.appname === game.title)) {
continue;
}
const gridPath = path.join(
await getSteamLocation(),
"userdata",
steamUserId.toString(),
"config",
"grid"
);
await fs.promises.mkdir(gridPath, { recursive: true });
await Promise.all([
copyAssetIfExists(
heroImage,
path.join(gridPath, `${newShortcut.appid}_hero.jpg`)
),
copyAssetIfExists(
logoImage,
path.join(gridPath, `${newShortcut.appid}_logo.png`)
),
copyAssetIfExists(
coverImage,
path.join(gridPath, `${newShortcut.appid}p.jpg`)
),
copyAssetIfExists(
libraryImage,
path.join(gridPath, `${newShortcut.appid}.jpg`)
),
]);
steamShortcuts.push(newShortcut);
logger.info(newShortcut);
logger.info("Writing Steam shortcuts", steamShortcuts);
await writeSteamShortcuts(steamUserId, steamShortcuts);
}
if (process.platform === "linux" && !game.winePrefixPath) {
const steamWinePrefixes = path.join(
SystemPath.getPath("home"),
".local",
"share",
"Steam",
"steamapps",
"compatdata"
);
const winePrefixPath = path.join(
steamWinePrefixes,
newShortcut.appid.toString(),
"pfx"
);
await fs.promises.mkdir(winePrefixPath, { recursive: true });
await gamesSublevel.put(gameKey, {
...game,
winePrefixPath,
});
}
}
};
registerEvent("createSteamShortcut", createSteamShortcut);

View File

@@ -0,0 +1,30 @@
import { logger, SystemPath } from "@main/services";
import fs from "node:fs";
import path from "node:path";
import { registerEvent } from "../register-event";
const getDefaultWinePrefixSelectionPath = async (
_event: Electron.IpcMainInvokeEvent
) => {
try {
const steamWinePrefixes = path.join(
SystemPath.getPath("home"),
".local",
"share",
"Steam",
"steamapps",
"compatdata"
);
return await fs.promises.realpath(steamWinePrefixes);
} catch (err) {
logger.error("Failed to get default wine prefix selection path", err);
return null;
}
};
registerEvent(
"getDefaultWinePrefixSelectionPath",
getDefaultWinePrefixSelectionPath
);

View File

@@ -1,6 +1,10 @@
import type { LibraryGame } from "@types"; import type { LibraryGame } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { downloadsSublevel, gamesSublevel } from "@main/level"; import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
} from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => { const getLibrary = async (): Promise<LibraryGame[]> => {
return gamesSublevel return gamesSublevel
@@ -12,11 +16,13 @@ const getLibrary = async (): Promise<LibraryGame[]> => {
.filter(([_key, game]) => game.isDeleted === false) .filter(([_key, game]) => game.isDeleted === false)
.map(async ([key, game]) => { .map(async ([key, game]) => {
const download = await downloadsSublevel.get(key); const download = await downloadsSublevel.get(key);
const gameAssets = await gamesShopAssetsSublevel.get(key);
return { return {
id: key, id: key,
...game, ...game,
download: download ?? null, download: download ?? null,
...gameAssets,
}; };
}) })
); );

View File

@@ -1,5 +1,7 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import fs from "node:fs";
import { levelKeys, gamesSublevel } from "@main/level"; import { levelKeys, gamesSublevel } from "@main/level";
import { Wine } from "@main/services";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
const selectGameWinePrefix = async ( const selectGameWinePrefix = async (
@@ -14,9 +16,24 @@ const selectGameWinePrefix = async (
if (!game) return; if (!game) return;
if (!winePrefixPath) {
await gamesSublevel.put(gameKey, {
...game,
winePrefixPath: null,
});
return;
}
const realWinePrefixPath = await fs.promises.realpath(winePrefixPath);
if (!Wine.validatePrefix(realWinePrefixPath)) {
throw new Error("Invalid wine prefix path");
}
await gamesSublevel.put(gameKey, { await gamesSublevel.put(gameKey, {
...game, ...game,
winePrefixPath: winePrefixPath, winePrefixPath: realWinePrefixPath,
}); });
}; };

View File

@@ -7,11 +7,11 @@ const verifyExecutablePathInUse = async (
) => { ) => {
for await (const game of gamesSublevel.values()) { for await (const game of gamesSublevel.values()) {
if (game.executablePath === executablePath) { if (game.executablePath === executablePath) {
return true; return game;
} }
} }
return false; return null;
}; };
registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse); registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse);

View File

@@ -6,7 +6,7 @@ import { db, levelKeys } from "@main/level";
const getBadges = async (_event: Electron.IpcMainInvokeEvent) => { const getBadges = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db const language = await db
.get<string, string>(levelKeys.language, { .get<string, string>(levelKeys.language, {
valueEncoding: "utf-8", valueEncoding: "utf8",
}) })
.then((language) => language || "en"); .then((language) => language || "en");

View File

@@ -0,0 +1,15 @@
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
const showAchievementTestNotification = async (
_event: Electron.IpcMainInvokeEvent
) => {
setTimeout(() => {
WindowManager.showAchievementTestNotification();
}, 1000);
};
registerEvent(
"showAchievementTestNotification",
showAchievementTestNotification
);

View File

@@ -0,0 +1,29 @@
import { db, levelKeys } from "@main/level";
import { registerEvent } from "../register-event";
import { WindowManager } from "@main/services";
import { UserPreferences } from "@types";
const updateAchievementCustomNotificationWindow = async (
_event: Electron.IpcMainInvokeEvent
) => {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
WindowManager.closeNotificationWindow();
if (
userPreferences.achievementNotificationsEnabled !== false &&
userPreferences.achievementCustomNotificationsEnabled !== false
) {
WindowManager.createNotificationWindow();
}
};
registerEvent(
"updateAchievementCustomNotificationWindow",
updateAchievementCustomNotificationWindow
);

View File

@@ -1,34 +1,11 @@
import { MAIN_LOOP_INTERVAL } from "@main/constants";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi, WindowManager } from "@main/services"; import { HydraApi, WindowManager } from "@main/services";
import { publishNewFriendRequestNotification } from "@main/services/notifications";
import { UserNotLoggedInError } from "@shared"; import { UserNotLoggedInError } from "@shared";
import type { FriendRequestSync } from "@types"; import type { FriendRequestSync } from "@types";
interface SyncState { export const syncFriendRequests = async () => {
friendRequestCount: number | null;
tick: number;
}
const ticksToUpdate = (2 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 2 minutes
const syncState: SyncState = {
friendRequestCount: null,
tick: 0,
};
const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`) return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => { .then((res) => {
if (
syncState.friendRequestCount != null &&
syncState.friendRequestCount < res.friendRequestCount
) {
publishNewFriendRequestNotification();
}
syncState.friendRequestCount = res.friendRequestCount;
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
"on-sync-friend-requests", "on-sync-friend-requests",
res res
@@ -44,16 +21,4 @@ const syncFriendRequests = async () => {
}); });
}; };
const syncFriendRequestsEvent = async (_event: Electron.IpcMainInvokeEvent) => { registerEvent("syncFriendRequests", syncFriendRequests);
return syncFriendRequests();
};
export const watchFriendRequests = async () => {
if (syncState.tick % ticksToUpdate === 0) {
await syncFriendRequests();
}
syncState.tick++;
};
registerEvent("syncFriendRequests", syncFriendRequestsEvent);

View File

@@ -21,6 +21,7 @@ const updateCustomTheme = async (
if (theme.isActive) { if (theme.isActive) {
WindowManager.mainWindow?.webContents.send("css-injected", code); WindowManager.mainWindow?.webContents.send("css-injected", code);
WindowManager.notificationWindow?.webContents.send("css-injected", code);
} }
}; };

View File

@@ -1,11 +1,14 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { Download, StartGameDownloadPayload } from "@types"; import type { Download, StartGameDownloadPayload } from "@types";
import { DownloadManager, HydraApi, logger } from "@main/services"; import { DownloadManager, HydraApi, logger } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { Downloader, DownloadError, steamUrlBuilder } from "@shared"; import { Downloader, DownloadError } from "@shared";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import {
downloadsSublevel,
gamesShopAssetsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
const startGameDownload = async ( const startGameDownload = async (
@@ -36,6 +39,7 @@ const startGameDownload = async (
} }
const game = await gamesSublevel.get(gameKey); const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */ /* Delete any previous download */
await downloadsSublevel.del(gameKey); await downloadsSublevel.del(gameKey);
@@ -46,17 +50,9 @@ const startGameDownload = async (
isDeleted: false, isDeleted: false,
}); });
} else { } else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gamesSublevel.put(gameKey, { await gamesSublevel.put(gameKey, {
title, title,
iconUrl, iconUrl: gameAssets?.iconUrl ?? null,
objectId, objectId,
shop, shop,
remoteId: null, remoteId: null,

View File

@@ -16,7 +16,7 @@ const updateUserPreferences = async (
if (preferences.language) { if (preferences.language) {
await db.put<string, string>(levelKeys.language, preferences.language, { await db.put<string, string>(levelKeys.language, preferences.language, {
valueEncoding: "utf-8", valueEncoding: "utf8",
}); });
i18next.changeLanguage(preferences.language); i18next.changeLanguage(preferences.language);

View File

@@ -1,97 +1,12 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi, logger } from "@main/services"; import { HydraApi } from "@main/services";
import { steamGamesWorker } from "@main/workers";
import type { UserProfile } from "@types"; import type { UserProfile } from "@types";
import { steamUrlBuilder } from "@shared";
const getSteamGame = async (objectId: string) => {
try {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
return {
title: steamGame.name as string,
iconUrl: steamUrlBuilder.icon(objectId, steamGame.clientIcon),
};
} catch (err) {
logger.error("Failed to get Steam game", err);
return null;
}
};
const getUser = async ( const getUser = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { return HydraApi.get<UserProfile>(`/users/${userId}`).catch(() => null);
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
if (!profile) return null;
const recentGames = await Promise.all(
profile.recentGames
.map(async (game) => {
const steamGame = await getSteamGame(game.objectId);
return {
...game,
...steamGame,
};
})
.filter((game) => game)
);
const libraryGames = await Promise.all(
profile.libraryGames
.map(async (game) => {
const steamGame = await getSteamGame(game.objectId);
return {
...game,
...steamGame,
};
})
.filter((game) => game)
);
if (profile.currentGame) {
const steamGame = await getSteamGame(profile.currentGame.objectId);
if (steamGame) {
profile.currentGame = {
...profile.currentGame,
...steamGame,
};
}
}
const friends = await Promise.all(
profile.friends.map(async (friend) => {
if (!friend.currentGame) return friend;
const currentGame = await getSteamGame(friend.currentGame.objectId);
return {
...friend,
currentGame: {
...friend.currentGame,
...currentGame,
},
};
})
);
return {
...profile,
friends,
libraryGames,
recentGames,
};
} catch (err) {
return null;
}
}; };
registerEvent("getUser", getUser); registerEvent("getUser", getUser);

View File

@@ -0,0 +1,352 @@
// @generated by protobuf-ts 2.10.0
// @generated from protobuf file "envelope.proto" (syntax proto3)
// tslint:disable
import type { BinaryWriteOptions } from "@protobuf-ts/runtime";
import type { IBinaryWriter } from "@protobuf-ts/runtime";
import { WireType } from "@protobuf-ts/runtime";
import type { BinaryReadOptions } from "@protobuf-ts/runtime";
import type { IBinaryReader } from "@protobuf-ts/runtime";
import { UnknownFieldHandler } from "@protobuf-ts/runtime";
import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
/**
* @generated from protobuf message FriendRequest
*/
export interface FriendRequest {
/**
* @generated from protobuf field: int32 friend_request_count = 1;
*/
friendRequestCount: number;
/**
* @generated from protobuf field: optional string sender_id = 2;
*/
senderId?: string;
}
/**
* @generated from protobuf message FriendGameSession
*/
export interface FriendGameSession {
/**
* @generated from protobuf field: string object_id = 1;
*/
objectId: string;
/**
* @generated from protobuf field: string shop = 2;
*/
shop: string;
/**
* @generated from protobuf field: string friend_id = 3;
*/
friendId: string;
}
/**
* @generated from protobuf message Envelope
*/
export interface Envelope {
/**
* @generated from protobuf oneof: payload
*/
payload:
| {
oneofKind: "friendRequest";
/**
* @generated from protobuf field: FriendRequest friend_request = 1;
*/
friendRequest: FriendRequest;
}
| {
oneofKind: "friendGameSession";
/**
* @generated from protobuf field: FriendGameSession friend_game_session = 2;
*/
friendGameSession: FriendGameSession;
}
| {
oneofKind: undefined;
};
}
// @generated message type with reflection information, may provide speed optimized methods
class FriendRequest$Type extends MessageType<FriendRequest> {
constructor() {
super("FriendRequest", [
{
no: 1,
name: "friend_request_count",
kind: "scalar",
T: 5 /*ScalarType.INT32*/,
},
{
no: 2,
name: "sender_id",
kind: "scalar",
opt: true,
T: 9 /*ScalarType.STRING*/,
},
]);
}
create(value?: PartialMessage<FriendRequest>): FriendRequest {
const message = globalThis.Object.create(this.messagePrototype!);
message.friendRequestCount = 0;
if (value !== undefined)
reflectionMergePartial<FriendRequest>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: FriendRequest
): FriendRequest {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* int32 friend_request_count */ 1:
message.friendRequestCount = reader.int32();
break;
case /* optional string sender_id */ 2:
message.senderId = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: FriendRequest,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* int32 friend_request_count = 1; */
if (message.friendRequestCount !== 0)
writer.tag(1, WireType.Varint).int32(message.friendRequestCount);
/* optional string sender_id = 2; */
if (message.senderId !== undefined)
writer.tag(2, WireType.LengthDelimited).string(message.senderId);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message FriendRequest
*/
export const FriendRequest = new FriendRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class FriendGameSession$Type extends MessageType<FriendGameSession> {
constructor() {
super("FriendGameSession", [
{ no: 1, name: "object_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: "shop", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "friend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
]);
}
create(value?: PartialMessage<FriendGameSession>): FriendGameSession {
const message = globalThis.Object.create(this.messagePrototype!);
message.objectId = "";
message.shop = "";
message.friendId = "";
if (value !== undefined)
reflectionMergePartial<FriendGameSession>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: FriendGameSession
): FriendGameSession {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string object_id */ 1:
message.objectId = reader.string();
break;
case /* string shop */ 2:
message.shop = reader.string();
break;
case /* string friend_id */ 3:
message.friendId = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: FriendGameSession,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* string object_id = 1; */
if (message.objectId !== "")
writer.tag(1, WireType.LengthDelimited).string(message.objectId);
/* string shop = 2; */
if (message.shop !== "")
writer.tag(2, WireType.LengthDelimited).string(message.shop);
/* string friend_id = 3; */
if (message.friendId !== "")
writer.tag(3, WireType.LengthDelimited).string(message.friendId);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message FriendGameSession
*/
export const FriendGameSession = new FriendGameSession$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> {
constructor() {
super("Envelope", [
{
no: 1,
name: "friend_request",
kind: "message",
oneof: "payload",
T: () => FriendRequest,
},
{
no: 2,
name: "friend_game_session",
kind: "message",
oneof: "payload",
T: () => FriendGameSession,
},
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
const message = globalThis.Object.create(this.messagePrototype!);
message.payload = { oneofKind: undefined };
if (value !== undefined)
reflectionMergePartial<Envelope>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Envelope
): Envelope {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* FriendRequest friend_request */ 1:
message.payload = {
oneofKind: "friendRequest",
friendRequest: FriendRequest.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).friendRequest
),
};
break;
case /* FriendGameSession friend_game_session */ 2:
message.payload = {
oneofKind: "friendGameSession",
friendGameSession: FriendGameSession.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).friendGameSession
),
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: Envelope,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* FriendRequest friend_request = 1; */
if (message.payload.oneofKind === "friendRequest")
FriendRequest.internalBinaryWrite(
message.payload.friendRequest,
writer.tag(1, WireType.LengthDelimited).fork(),
options
).join();
/* FriendGameSession friend_game_session = 2; */
if (message.payload.oneofKind === "friendGameSession")
FriendGameSession.internalBinaryWrite(
message.payload.friendGameSession,
writer.tag(2, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message Envelope
*/
export const Envelope = new Envelope$Type();

View File

@@ -32,3 +32,8 @@ export const isPortableVersion = () => {
export const normalizePath = (str: string) => export const normalizePath = (str: string) =>
path.posix.normalize(str).replace(/\\/g, "/"); path.posix.normalize(str).replace(/\\/g, "/");
export const addTrailingSlash = (str: string) =>
str.endsWith("/") ? str : `${str}/`;
export * from "./reg-parser";

View File

@@ -0,0 +1,58 @@
type RegValue = string | number | null;
interface RegEntry {
path: string;
timestamp?: string;
values: Record<string, RegValue>;
}
export function parseRegFile(content: string): RegEntry[] {
const lines = content.split(/\r?\n/);
const entries: RegEntry[] = [];
let currentPath: string | null = null;
let currentEntry: RegEntry | null = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith(";") || line.startsWith(";;")) continue;
if (line.startsWith("#")) {
const match = line.match(/^#time=(\w+)/);
if (match && currentEntry) {
currentEntry.timestamp = match[1];
}
continue;
}
if (line.startsWith("[")) {
const match = line.match(/^\[(.+?)\](?:\s+\d+)?/);
if (match) {
if (currentEntry) entries.push(currentEntry);
currentPath = match[1];
currentEntry = { path: currentPath, values: {} };
}
} else if (currentEntry) {
const kvMatch = line.match(/^"?(.*?)"?=(.*)$/);
if (kvMatch) {
const [, key, rawValue] = kvMatch;
let value: RegValue;
if (rawValue === '""') {
value = "";
} else if (rawValue.startsWith("dword:")) {
value = parseInt(rawValue.slice(6), 16);
} else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
value = rawValue.slice(1, -1);
} else {
value = rawValue;
}
currentEntry.values[key || "@"] = value;
}
}
}
if (currentEntry) entries.push(currentEntry);
return entries;
}

View File

@@ -23,7 +23,9 @@ autoUpdater.logger = logger;
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit(); if (!gotTheLock) app.quit();
app.commandLine.appendSwitch("--no-sandbox"); if (process.platform !== "linux") {
app.commandLine.appendSwitch("--no-sandbox");
}
i18n.init({ i18n.init({
resources, resources,
@@ -59,9 +61,11 @@ app.whenReady().then(async () => {
await loadState(); await loadState();
const language = await db.get<string, string>(levelKeys.language, { const language = await db
valueEncoding: "utf-8", .get<string, string>(levelKeys.language, {
}); valueEncoding: "utf8",
})
.catch(() => "en");
if (language) i18n.changeLanguage(language); if (language) i18n.changeLanguage(language);
@@ -69,6 +73,7 @@ app.whenReady().then(async () => {
WindowManager.createMainWindow(); WindowManager.createMainWindow();
} }
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(language || "en"); WindowManager.createSystemTray(language || "en");
}); });

View File

@@ -0,0 +1,11 @@
import type { ShopAssets } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesShopAssetsSublevel = db.sublevel<string, ShopAssets>(
levelKeys.gameShopAssets,
{
valueEncoding: "json",
}
);

View File

@@ -1,5 +1,6 @@
export * from "./downloads"; export * from "./downloads";
export * from "./games"; export * from "./games";
export * from "./game-shop-assets";
export * from "./game-shop-cache"; export * from "./game-shop-cache";
export * from "./game-achievements"; export * from "./game-achievements";
export * from "./keys"; export * from "./keys";

View File

@@ -6,6 +6,7 @@ export const levelKeys = {
user: "user", user: "user",
auth: "auth", auth: "auth",
themes: "themes", themes: "themes",
gameShopAssets: "gameShopAssets",
gameShopCache: "gameShopCache", gameShopCache: "gameShopCache",
gameShopCacheItem: (shop: GameShop, objectId: string, language: string) => gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
`${shop}:${objectId}:${language}`, `${shop}:${objectId}:${language}`,
@@ -14,4 +15,5 @@ export const levelKeys = {
userPreferences: "userPreferences", userPreferences: "userPreferences",
language: "language", language: "language",
screenState: "screenState", screenState: "screenState",
rpcPassword: "rpcPassword",
}; };

View File

@@ -1,19 +1,23 @@
import { Aria2, DownloadManager, Ludusavi, startMainLoop } from "./services";
import { RealDebridClient } from "./services/download/real-debrid";
import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync";
import { downloadsSublevel } from "./level/sublevels/downloads"; import { downloadsSublevel } from "./level/sublevels/downloads";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { levelKeys, db } from "./level"; import { levelKeys, db } from "./level";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
import { TorBoxClient } from "./services/download/torbox"; import {
import { CommonRedistManager } from "./services/common-redist-manager"; WSClient,
import { SystemPath } from "./services/system-path"; SystemPath,
CommonRedistManager,
TorBoxClient,
RealDebridClient,
Aria2,
DownloadManager,
HydraApi,
uploadGamesBatch,
startMainLoop,
Ludusavi,
} from "@main/services";
export const loadState = async () => { export const loadState = async () => {
SystemPath.checkIfPathsAreAvailable();
const userPreferences = await db.get<string, UserPreferences | null>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
@@ -23,7 +27,9 @@ export const loadState = async () => {
await import("./events"); await import("./events");
Aria2.spawn(); if (process.platform !== "darwin") {
Aria2.spawn();
}
if (userPreferences?.realDebridApiToken) { if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken); RealDebridClient.authorize(userPreferences.realDebridApiToken);
@@ -33,10 +39,11 @@ export const loadState = async () => {
TorBoxClient.authorize(userPreferences.torBoxApiToken); TorBoxClient.authorize(userPreferences.torBoxApiToken);
} }
Ludusavi.addManifestToLudusaviConfig(); Ludusavi.copyConfigFileToUserData();
await HydraApi.setupApi().then(() => { await HydraApi.setupApi().then(() => {
uploadGamesBatch(); uploadGamesBatch();
WSClient.connect();
}); });
const downloads = await downloadsSublevel const downloads = await downloadsSublevel
@@ -70,4 +77,6 @@ export const loadState = async () => {
startMainLoop(); startMainLoop();
CommonRedistManager.downloadCommonRedist(); CommonRedistManager.downloadCommonRedist();
SystemPath.checkIfPathsAreAvailable();
}; };

View File

@@ -7,11 +7,18 @@ import {
findAllAchievementFiles, findAllAchievementFiles,
getAlternativeObjectIds, getAlternativeObjectIds,
} from "./find-achivement-files"; } from "./find-achivement-files";
import type { AchievementFile, Game, UnlockedAchievement } from "@types"; import type {
AchievementFile,
Game,
UnlockedAchievement,
UserPreferences,
} from "@types";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { Cracker } from "@shared"; import { Cracker } from "@shared";
import { publishCombinedNewAchievementNotification } from "../notifications"; import { publishCombinedNewAchievementNotification } from "../notifications";
import { gamesSublevel } from "@main/level"; import { db, gamesSublevel, levelKeys } from "@main/level";
import { WindowManager } from "../window-manager";
import { sleep } from "@main/helpers";
const fileStats: Map<string, number> = new Map(); const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map(); const fltFiles: Map<string, Set<string>> = new Map();
@@ -184,7 +191,7 @@ export class AchievementWatcherManager {
return mergeAchievements(game, unlockedAchievements, false); return mergeAchievements(game, unlockedAchievements, false);
} }
private static preSearchAchievementsWindows = async () => { private static async getGameAchievementFilesWindows() {
const games = await gamesSublevel const games = await gamesSublevel
.values() .values()
.all() .all()
@@ -194,24 +201,24 @@ export class AchievementWatcherManager {
return Promise.all( return Promise.all(
games.map((game) => { games.map((game) => {
const gameAchievementFiles: AchievementFile[] = []; const achievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectId)) { for (const objectId of getAlternativeObjectIds(game.objectId)) {
gameAchievementFiles.push( achievementFiles.push(
...(gameAchievementFilesMap.get(objectId) || []) ...(gameAchievementFilesMap.get(objectId) || [])
); );
gameAchievementFiles.push( achievementFiles.push(
...findAchievementFileInExecutableDirectory(game) ...findAchievementFileInExecutableDirectory(game)
); );
} }
return this.preProcessGameAchievementFiles(game, gameAchievementFiles); return { game, achievementFiles };
}) })
); );
}; }
private static preSearchAchievementsWithWine = async () => { private static async getGameAchievementFilesLinux() {
const games = await gamesSublevel const games = await gamesSublevel
.values() .values()
.all() .all()
@@ -219,37 +226,70 @@ export class AchievementWatcherManager {
return Promise.all( return Promise.all(
games.map((game) => { games.map((game) => {
const gameAchievementFiles = findAchievementFiles(game); const achievementFiles = findAchievementFiles(game);
const achievementFileInsideDirectory = const achievementFileInsideDirectory =
findAchievementFileInExecutableDirectory(game); findAchievementFileInExecutableDirectory(game);
gameAchievementFiles.push(...achievementFileInsideDirectory); achievementFiles.push(...achievementFileInsideDirectory);
return this.preProcessGameAchievementFiles(game, gameAchievementFiles); return { game, achievementFiles };
}) })
); );
}; }
public static async preSearchAchievements() { public static async preSearchAchievements() {
await sleep(2000);
try { try {
const newAchievementsCount = const gameAchievementFiles =
process.platform === "win32" process.platform === "win32"
? await this.preSearchAchievementsWindows() ? await this.getGameAchievementFilesWindows()
: await this.preSearchAchievementsWithWine(); : await this.getGameAchievementFilesLinux();
const newAchievementsCount: number[] = [];
for (const { game, achievementFiles } of gameAchievementFiles) {
const result = await this.preProcessGameAchievementFiles(
game,
achievementFiles
);
newAchievementsCount.push(result);
}
const totalNewGamesWithAchievements = newAchievementsCount.filter( const totalNewGamesWithAchievements = newAchievementsCount.filter(
(achievements) => achievements (achievements) => achievements
).length; ).length;
const totalNewAchievements = newAchievementsCount.reduce( const totalNewAchievements = newAchievementsCount.reduce(
(acc, val) => acc + val, (acc, val) => acc + val,
0 0
); );
if (totalNewAchievements > 0) { if (totalNewAchievements > 0) {
publishCombinedNewAchievementNotification( const userPreferences = await db.get<string, UserPreferences>(
totalNewAchievements, levelKeys.userPreferences,
totalNewGamesWithAchievements {
valueEncoding: "json",
}
); );
if (userPreferences.achievementNotificationsEnabled !== false) {
if (userPreferences.achievementCustomNotificationsEnabled !== false) {
WindowManager.notificationWindow?.webContents.send(
"on-combined-achievements-unlocked",
totalNewGamesWithAchievements,
totalNewAchievements,
userPreferences.achievementCustomNotificationPosition ??
"top-left"
);
} else {
publishCombinedNewAchievementNotification(
totalNewAchievements,
totalNewGamesWithAchievements
);
}
}
} }
} catch (err) { } catch (err) {
achievementsLogger.error("Error on preSearchAchievements", err); achievementsLogger.error("Error on preSearchAchievements", err);

View File

@@ -25,7 +25,7 @@ export const getGameAchievementData = async (
const language = await db const language = await db
.get<string, string>(levelKeys.language, { .get<string, string>(levelKeys.language, {
valueEncoding: "utf-8", valueEncoding: "utf8",
}) })
.then((language) => language || "en"); .then((language) => language || "en");
@@ -38,7 +38,9 @@ export const getGameAchievementData = async (
await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), { await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), {
unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [], unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [],
achievements, achievements,
cacheExpiresTimestamp: Date.now() + 1000 * 60 * 30, // 30 minutes cacheExpiresTimestamp: achievements.length
? Date.now() + 1000 * 60 * 30 // 30 minutes
: undefined,
}); });
return achievements; return achievements;

View File

@@ -1,4 +1,5 @@
import type { import type {
AchievementNotificationInfo,
Game, Game,
GameShop, GameShop,
UnlockedAchievement, UnlockedAchievement,
@@ -12,6 +13,13 @@ import { publishNewAchievementNotification } from "../notifications";
import { SubscriptionRequiredError } from "@shared"; import { SubscriptionRequiredError } from "@shared";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
import { getGameAchievementData } from "./get-game-achievement-data";
const isRareAchievement = (points: number) => {
const rawPercentage = (50 - Math.sqrt(points)) * 2;
return rawPercentage < 10;
};
const saveAchievementsOnLocal = async ( const saveAchievementsOnLocal = async (
objectId: string, objectId: string,
@@ -48,12 +56,22 @@ export const mergeAchievements = async (
achievements: UnlockedAchievement[], achievements: UnlockedAchievement[],
publishNotification: boolean publishNotification: boolean
) => { ) => {
const [localGameAchievement, userPreferences] = await Promise.all([ let localGameAchievement = await gameAchievementsSublevel.get(
gameAchievementsSublevel.get(levelKeys.game(game.shop, game.objectId)), levelKeys.game(game.shop, game.objectId)
db.get<string, UserPreferences>(levelKeys.userPreferences, { );
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json", valueEncoding: "json",
}), }
]); );
if (!localGameAchievement) {
await getGameAchievementData(game.objectId, game.shop, true);
localGameAchievement = await gameAchievementsSublevel.get(
levelKeys.game(game.shop, game.objectId)
);
}
const achievementsData = localGameAchievement?.achievements ?? []; const achievementsData = localGameAchievement?.achievements ?? [];
const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? []; const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? [];
@@ -84,9 +102,9 @@ export const mergeAchievements = async (
if ( if (
newAchievements.length && newAchievements.length &&
publishNotification && publishNotification &&
userPreferences?.achievementNotificationsEnabled userPreferences.achievementNotificationsEnabled !== false
) { ) {
const achievementsInfo = newAchievements const filteredAchievements = newAchievements
.toSorted((a, b) => { .toSorted((a, b) => {
return a.unlockTime - b.unlockTime; return a.unlockTime - b.unlockTime;
}) })
@@ -98,21 +116,41 @@ export const mergeAchievements = async (
); );
}); });
}) })
.filter((achievement) => Boolean(achievement)) .filter((achievement) => !!achievement);
.map((achievement) => {
const achievementsInfo: AchievementNotificationInfo[] =
filteredAchievements.map((achievement, index) => {
return { return {
displayName: achievement!.displayName, title: achievement.displayName,
iconUrl: achievement!.icon, description: achievement.description,
points: achievement.points,
isHidden: achievement.hidden,
isRare: achievement.points
? isRareAchievement(achievement.points)
: false,
isPlatinum:
index === filteredAchievements.length - 1 &&
newAchievements.length + unlockedAchievements.length ===
achievementsData.length,
iconUrl: achievement.icon,
}; };
}); });
publishNewAchievementNotification({ if (userPreferences.achievementCustomNotificationsEnabled !== false) {
achievements: achievementsInfo, WindowManager.notificationWindow?.webContents.send(
unlockedAchievementCount: mergedLocalAchievements.length, "on-achievement-unlocked",
totalAchievementCount: achievementsData.length, userPreferences.achievementCustomNotificationPosition ?? "top-left",
gameTitle: game.title, achievementsInfo
gameIcon: game.iconUrl, );
}); } else {
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
}
} }
if (game.remoteId) { if (game.remoteId) {

View File

@@ -7,8 +7,8 @@ export class Aria2 {
public static spawn() { public static spawn() {
const binaryPath = app.isPackaged const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "aria2", "aria2c") ? path.join(process.resourcesPath, "aria2c")
: path.join(__dirname, "..", "..", "aria2", "aria2c"); : path.join(__dirname, "..", "..", "binaries", "aria2c");
this.process = cp.spawn( this.process = cp.spawn(
binaryPath, binaryPath,

View File

@@ -7,7 +7,7 @@ import os from "node:os";
import type { GameShop, User } from "@types"; import type { GameShop, User } from "@types";
import { backupsPath } from "@main/constants"; import { backupsPath } from "@main/constants";
import { HydraApi } from "./hydra-api"; import { HydraApi } from "./hydra-api";
import { normalizePath } from "@main/helpers"; import { normalizePath, parseRegFile } from "@main/helpers";
import { logger } from "./logger"; import { logger } from "./logger";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import axios from "axios"; import axios from "axios";
@@ -17,6 +17,39 @@ import i18next, { t } from "i18next";
import { SystemPath } from "./system-path"; import { SystemPath } from "./system-path";
export class CloudSync { export class CloudSync {
public static getWindowsLikeUserProfilePath(winePrefixPath?: string | null) {
if (process.platform === "linux") {
if (!winePrefixPath) {
throw new Error("Wine prefix path is required");
}
const userReg = fs.readFileSync(
path.join(winePrefixPath, "user.reg"),
"utf8"
);
const entries = parseRegFile(userReg);
const volatileEnvironment = entries.find(
(entry) => entry.path === "Volatile Environment"
);
if (!volatileEnvironment) {
throw new Error("Volatile environment not found in user.reg");
}
const { values } = volatileEnvironment;
const userProfile = String(values["USERPROFILE"]);
if (userProfile) {
return normalizePath(userProfile);
} else {
throw new Error("User profile not found in user.reg");
}
}
return normalizePath(SystemPath.getPath("home"));
}
public static getBackupLabel(automatic: boolean) { public static getBackupLabel(automatic: boolean) {
const language = i18next.language; const language = i18next.language;
@@ -102,9 +135,12 @@ export class CloudSync {
shop, shop,
objectId, objectId,
hostname: os.hostname(), hostname: os.hostname(),
homeDir: normalizePath(SystemPath.getPath("home")), winePrefixPath: game?.winePrefixPath
? fs.realpathSync(game.winePrefixPath)
: null,
homeDir: this.getWindowsLikeUserProfilePath(game?.winePrefixPath ?? null),
downloadOptionTitle, downloadOptionTitle,
platform: os.platform(), platform: process.platform,
label, label,
}); });

View File

@@ -1 +1,3 @@
export * from "./download-manager"; export * from "./download-manager";
export * from "./real-debrid";
export * from "./torbox";

View File

@@ -11,6 +11,7 @@ import { getUserData } from "./user/get-user-data";
import { db } from "@main/level"; import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels"; import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types"; import type { Auth, User } from "@types";
import { WSClient } from "./ws/ws-client";
interface HydraApiOptions { interface HydraApiOptions {
needsAuth?: boolean; needsAuth?: boolean;
@@ -41,7 +42,7 @@ export class HydraApi {
subscription: null, subscription: null,
}; };
private static isLoggedIn() { public static isLoggedIn() {
return this.userAuth.authToken !== ""; return this.userAuth.authToken !== "";
} }
@@ -101,6 +102,8 @@ export class HydraApi {
WindowManager.mainWindow.webContents.send("on-signin"); WindowManager.mainWindow.webContents.send("on-signin");
await clearGamesRemoteIds(); await clearGamesRemoteIds();
uploadGamesBatch(); uploadGamesBatch();
WSClient.close();
WSClient.connect();
} }
} }

View File

@@ -12,3 +12,7 @@ export * from "./7zip";
export * from "./game-files-manager"; export * from "./game-files-manager";
export * from "./common-redist-manager"; export * from "./common-redist-manager";
export * from "./aria2"; export * from "./aria2";
export * from "./ws";
export * from "./system-path";
export * from "./library-sync";
export * from "./wine";

View File

@@ -1,10 +1,15 @@
import { ShopAssets } from "@types";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers"; import { gamesShopAssetsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { steamUrlBuilder } from "@shared";
import { gamesSublevel, levelKeys } from "@main/level"; type ProfileGame = {
id: string;
lastTimePlayed: Date | null;
playTimeInMilliseconds: number;
} & ShopAssets;
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
return HydraApi.get("/profile/games") return HydraApi.get<ProfileGame[]>("/profile/games")
.then(async (response) => { .then(async (response) => {
for (const game of response) { for (const game of response) {
const localGame = await gamesSublevel.get( const localGame = await gamesSublevel.get(
@@ -31,25 +36,32 @@ export const mergeWithRemoteGames = async () => {
playTimeInMilliseconds: updatedPlayTime, playTimeInMilliseconds: updatedPlayTime,
}); });
} else { } else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null;
await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { await gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
objectId: game.objectId, objectId: game.objectId,
title: steamGame?.name, title: game.title,
remoteId: game.id, remoteId: game.id,
shop: game.shop, shop: game.shop,
iconUrl, iconUrl: game.iconUrl,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds, playTimeInMilliseconds: game.playTimeInMilliseconds,
isDeleted: false, isDeleted: false,
}); });
} }
await gamesShopAssetsSublevel.put(
levelKeys.game(game.shop, game.objectId),
{
shop: game.shop,
objectId: game.objectId,
title: game.title,
coverImageUrl: game.coverImageUrl,
libraryHeroImageUrl: game.libraryHeroImageUrl,
libraryImageUrl: game.libraryImageUrl,
logoImageUrl: game.logoImageUrl,
iconUrl: game.iconUrl,
logoPosition: game.logoPosition,
}
);
} }
}) })
.catch(() => {}); .catch(() => {});

View File

@@ -15,7 +15,7 @@ export const uploadGamesBatch = async () => {
); );
}); });
const gamesChunks = chunk(games, 50); const gamesChunks = chunk(games, 30);
for (const chunk of gamesChunks) { for (const chunk of gamesChunks) {
await HydraApi.post( await HydraApi.post(
@@ -33,7 +33,9 @@ export const uploadGamesBatch = async () => {
await mergeWithRemoteGames(); await mergeWithRemoteGames();
AchievementWatcherManager.preSearchAchievements(); if (HydraApi.isLoggedIn()) {
AchievementWatcherManager.preSearchAchievements();
}
if (WindowManager.mainWindow) if (WindowManager.mainWindow)
WindowManager.mainWindow.webContents.send("on-library-batch-complete"); WindowManager.mainWindow.webContents.send("on-library-batch-complete");

View File

@@ -1,70 +1,89 @@
import type { GameShop, LudusaviBackup, LudusaviConfig } from "@types"; import type { GameShop, LudusaviBackup, LudusaviConfig } from "@types";
import Piscina from "piscina";
import { app } from "electron"; import { app } from "electron";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import YAML from "yaml"; import YAML from "yaml";
import cp from "node:child_process";
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
import { LUDUSAVI_MANIFEST_URL } from "@main/constants";
import { SystemPath } from "./system-path"; import { SystemPath } from "./system-path";
export class Ludusavi { export class Ludusavi {
private static ludusaviPath = path.join( private static ludusaviPath = app.isPackaged
SystemPath.getPath("appData"), ? path.join(process.resourcesPath, "ludusavi")
: path.join(__dirname, "..", "..", "ludusavi");
private static binaryPath = path.join(this.ludusaviPath, "ludusavi");
private static configPath = path.join(
SystemPath.getPath("userData"),
"ludusavi" "ludusavi"
); );
private static ludusaviConfigPath = path.join(
this.ludusaviPath,
"config.yaml"
);
private static binaryPath = app.isPackaged
? path.join(process.resourcesPath, "ludusavi", "ludusavi")
: path.join(__dirname, "..", "..", "ludusavi", "ludusavi");
private static worker = new Piscina({
filename: ludusaviWorkerPath,
workerData: {
binaryPath: this.binaryPath,
},
maxThreads: 1,
});
static async getConfig() {
if (!fs.existsSync(this.ludusaviConfigPath)) {
await this.worker.run(undefined, { name: "generateConfig" });
}
public static async getConfig() {
const config = YAML.parse( const config = YAML.parse(
fs.readFileSync(this.ludusaviConfigPath, "utf-8") fs.readFileSync(path.join(this.ludusaviPath, "config.yaml"), "utf-8")
) as LudusaviConfig; ) as LudusaviConfig;
return config; return config;
} }
static async backupGame( public static async copyConfigFileToUserData() {
_shop: GameShop, if (!fs.existsSync(this.configPath)) {
objectId: string, fs.mkdirSync(this.configPath, { recursive: true });
backupPath: string, fs.cpSync(
winePrefix?: string | null path.join(this.ludusaviPath, "config.yaml"),
): Promise<LudusaviBackup> { path.join(this.configPath, "config.yaml")
return this.worker.run( );
{ title: objectId, backupPath, winePrefix }, }
{ name: "backupGame" }
);
} }
static async getBackupPreview( public static async backupGame(
_shop: GameShop,
objectId: string,
backupPath?: string | null,
winePrefix?: string | null,
preview?: boolean
): Promise<LudusaviBackup> {
return new Promise((resolve, reject) => {
const args = [
"--config",
this.configPath,
"backup",
objectId,
"--api",
"--force",
];
if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath);
if (winePrefix) args.push("--wine-prefix", winePrefix);
cp.execFile(
this.binaryPath,
args,
(err: cp.ExecFileException | null, stdout: string) => {
if (err) {
return reject(err);
}
return resolve(JSON.parse(stdout) as LudusaviBackup);
}
);
});
}
public static async getBackupPreview(
_shop: GameShop, _shop: GameShop,
objectId: string, objectId: string,
winePrefix?: string | null winePrefix?: string | null
): Promise<LudusaviBackup | null> { ): Promise<LudusaviBackup | null> {
const config = await this.getConfig(); const config = await this.getConfig();
const backupData = await this.worker.run( const backupData = await this.backupGame(
{ title: objectId, winePrefix, preview: true }, _shop,
{ name: "backupGame" } objectId,
null,
winePrefix,
true
); );
const customGame = config.customGames.find( const customGame = config.customGames.find(
@@ -77,19 +96,6 @@ export class Ludusavi {
}; };
} }
static async restoreBackup(backupPath: string) {
return this.worker.run(backupPath, { name: "restoreBackup" });
}
static async addManifestToLudusaviConfig() {
const config = await this.getConfig();
config.manifest.enable = false;
config.manifest.secondary = [{ url: LUDUSAVI_MANIFEST_URL, enable: true }];
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));
}
static async addCustomGame(title: string, savePath: string | null) { static async addCustomGame(title: string, savePath: string | null) {
const config = await this.getConfig(); const config = await this.getConfig();
const filteredGames = config.customGames.filter( const filteredGames = config.customGames.filter(
@@ -105,6 +111,10 @@ export class Ludusavi {
} }
config.customGames = filteredGames; config.customGames = filteredGames;
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));
fs.writeFileSync(
path.join(this.configPath, "config.yaml"),
YAML.stringify(config)
);
} }
} }

View File

@@ -3,7 +3,6 @@ import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher"; import { watchProcesses } from "./process-watcher";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager"; import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
import { UpdateManager } from "./update-manager"; import { UpdateManager } from "./update-manager";
import { watchFriendRequests } from "@main/events/profile/sync-friend-requests";
import { MAIN_LOOP_INTERVAL } from "@main/constants"; import { MAIN_LOOP_INTERVAL } from "@main/constants";
export const startMainLoop = async () => { export const startMainLoop = async () => {
@@ -11,7 +10,6 @@ export const startMainLoop = async () => {
while (true) { while (true) {
await Promise.allSettled([ await Promise.allSettled([
watchProcesses(), watchProcesses(),
watchFriendRequests(),
DownloadManager.watchDownloads(), DownloadManager.watchDownloads(),
AchievementWatcherManager.watchAchievements(), AchievementWatcherManager.watchAchievements(),
DownloadManager.getSeedStatus(), DownloadManager.getSeedStatus(),

View File

@@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml"; import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger"; import { logger } from "../logger";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import type { Game, UserPreferences } from "@types"; import type { Game, GameStats, UserPreferences, UserProfile } from "@types";
import { db, levelKeys } from "@main/level"; import { db, levelKeys } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
import { SystemPath } from "../system-path"; import { SystemPath } from "../system-path";
@@ -81,7 +81,9 @@ export const publishNotificationUpdateReadyToInstall = async (
.show(); .show();
}; };
export const publishNewFriendRequestNotification = async () => { export const publishNewFriendRequestNotification = async (
user: UserProfile
) => {
const userPreferences = await db.get<string, UserPreferences | null>( const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences, levelKeys.userPreferences,
{ {
@@ -97,8 +99,27 @@ export const publishNewFriendRequestNotification = async () => {
}), }),
body: t("new_friend_request_description", { body: t("new_friend_request_description", {
ns: "notifications", ns: "notifications",
displayName: user.displayName,
}), }),
icon: trayIcon, icon: user?.profileImageUrl
? await downloadImage(user.profileImageUrl)
: trayIcon,
}).show();
};
export const publishFriendStartedPlayingGameNotification = async (
friend: UserProfile,
game: GameStats
) => {
new Notification({
title: t("friend_started_playing_game", {
ns: "notifications",
displayName: friend.displayName,
}),
body: game.assets?.title,
icon: friend?.profileImageUrl
? await downloadImage(friend.profileImageUrl)
: trayIcon,
}).show(); }).show();
}; };
@@ -141,7 +162,7 @@ export const publishExtractionCompleteNotification = async (game: Game) => {
}; };
export const publishNewAchievementNotification = async (info: { export const publishNewAchievementNotification = async (info: {
achievements: { displayName: string; iconUrl: string }[]; achievements: { title: string; iconUrl: string }[];
unlockedAchievementCount: number; unlockedAchievementCount: number;
totalAchievementCount: number; totalAchievementCount: number;
gameTitle: string; gameTitle: string;
@@ -155,12 +176,12 @@ export const publishNewAchievementNotification = async (info: {
gameTitle: info.gameTitle, gameTitle: info.gameTitle,
achievementCount: info.achievements.length, achievementCount: info.achievements.length,
}), }),
body: info.achievements.map((a) => a.displayName).join(", "), body: info.achievements.map((a) => a.title).join(", "),
icon: (await downloadImage(info.gameIcon)) ?? icon, icon: (await downloadImage(info.gameIcon)) ?? icon,
} }
: { : {
title: t("achievement_unlocked", { ns: "achievement" }), title: t("achievement_unlocked", { ns: "achievement" }),
body: info.achievements[0].displayName, body: info.achievements[0].title,
icon: (await downloadImage(info.achievements[0].iconUrl)) ?? icon, icon: (await downloadImage(info.achievements[0].iconUrl)) ?? icon,
}; };

View File

@@ -8,6 +8,7 @@ import crypto from "node:crypto";
import { pythonRpcLogger } from "./logger"; import { pythonRpcLogger } from "./logger";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import { db, levelKeys } from "@main/level";
interface GamePayload { interface GamePayload {
game_id: string; game_id: string;
@@ -21,26 +22,15 @@ const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
win32: "hydra-python-rpc.exe", win32: "hydra-python-rpc.exe",
}; };
const rustBinaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-httpdl",
linux: "hydra-httpdl",
win32: "hydra-httpdl.exe",
};
export class PythonRPC { export class PythonRPC {
public static readonly BITTORRENT_PORT = "5881"; public static readonly BITTORRENT_PORT = "5881";
public static readonly RPC_PORT = "8084"; public static readonly RPC_PORT = "8084";
private static readonly RPC_PASSWORD = crypto.randomBytes(32).toString("hex");
private static pythonProcess: cp.ChildProcess | null = null;
public static readonly rpc = axios.create({ public static readonly rpc = axios.create({
baseURL: `http://localhost:${this.RPC_PORT}`, baseURL: `http://localhost:${this.RPC_PORT}`,
headers: {
"x-hydra-rpc-password": this.RPC_PASSWORD,
},
}); });
private static pythonProcess: cp.ChildProcess | null = null;
private static logStderr(readable: Readable | null) { private static logStderr(readable: Readable | null) {
if (!readable) return; if (!readable) return;
@@ -48,30 +38,34 @@ export class PythonRPC {
readable.on("data", pythonRpcLogger.log); readable.on("data", pythonRpcLogger.log);
} }
public static spawn( private static async getRPCPassword() {
const existingPassword = await db.get(levelKeys.rpcPassword, {
valueEncoding: "utf8",
});
if (existingPassword) return existingPassword;
const newPassword = crypto.randomBytes(32).toString("hex");
await db.put(levelKeys.rpcPassword, newPassword, {
valueEncoding: "utf8",
});
return newPassword;
}
public static async spawn(
initialDownload?: GamePayload, initialDownload?: GamePayload,
initialSeeding?: GamePayload[] initialSeeding?: GamePayload[]
) { ) {
const rpcPassword = await this.getRPCPassword();
const commonArgs = [ const commonArgs = [
this.BITTORRENT_PORT, this.BITTORRENT_PORT,
this.RPC_PORT, this.RPC_PORT,
this.RPC_PASSWORD, rpcPassword,
initialDownload ? JSON.stringify(initialDownload) : "", initialDownload ? JSON.stringify(initialDownload) : "",
initialSeeding ? JSON.stringify(initialSeeding) : "", initialSeeding ? JSON.stringify(initialSeeding) : "",
app.isPackaged
? path.join(
process.resourcesPath,
rustBinaryNameByPlatform[process.platform]!
)
: path.join(
__dirname,
"..",
"..",
"rust_rpc",
"target",
"debug",
rustBinaryNameByPlatform[process.platform]!
),
]; ];
if (app.isPackaged) { if (app.isPackaged) {
@@ -116,6 +110,8 @@ export class PythonRPC {
this.pythonProcess = childProcess; this.pythonProcess = childProcess;
} }
this.rpc.defaults.headers.common["x-hydra-rpc-password"] = rpcPassword;
} }
public static kill() { public static kill() {

View File

@@ -1,8 +1,14 @@
import axios from "axios"; import axios from "axios";
import path from "node:path";
import fs from "node:fs";
import { crc32 } from "crc";
import WinReg from "winreg";
import { parseBuffer, writeBuffer } from "steam-shortcut-editor";
import type { SteamAppDetails } from "@types"; import type { SteamAppDetails, SteamShortcut } from "@types";
import { logger } from "./logger"; import { logger } from "./logger";
import { SystemPath } from "./system-path";
export interface SteamAppDetailsResponse { export interface SteamAppDetailsResponse {
[key: string]: { [key: string]: {
@@ -11,6 +17,36 @@ export interface SteamAppDetailsResponse {
}; };
} }
export const getSteamLocation = async () => {
if (process.platform === "linux") {
return path.join(SystemPath.getPath("home"), ".local", "share", "Steam");
}
if (process.platform === "darwin") {
return path.join(
SystemPath.getPath("home"),
"Library",
"Application Support",
"Steam"
);
}
const regKey = new WinReg({
hive: WinReg.HKCU,
key: "\\Software\\Valve\\Steam",
});
return new Promise<string>((resolve, reject) => {
regKey.get("SteamPath", (err, value) => {
if (err) {
reject(err);
}
resolve(value.value);
});
});
};
export const getSteamAppDetails = async ( export const getSteamAppDetails = async (
objectId: string, objectId: string,
language: string language: string
@@ -40,3 +76,86 @@ export const getSteamAppDetails = async (
return null; return null;
}); });
}; };
export const getSteamUsersIds = async () => {
const userDataPath = await getSteamLocation();
const userIds = fs.readdirSync(path.join(userDataPath, "userdata"), {
withFileTypes: true,
});
return userIds
.filter((dir) => dir.isDirectory())
.map((dir) => Number(dir.name));
};
export const getSteamShortcuts = async (steamUserId: number) => {
const shortcutsPath = path.join(
await getSteamLocation(),
"userdata",
steamUserId.toString(),
"config",
"shortcuts.vdf"
);
if (!fs.existsSync(shortcutsPath)) {
return [];
}
const shortcuts = parseBuffer(fs.readFileSync(shortcutsPath));
return shortcuts.shortcuts as SteamShortcut[];
};
export const generateSteamShortcutAppId = (
exePath: string,
gameName: string
) => {
const input = exePath + gameName;
const crcValue = crc32(input) >>> 0;
const steamAppId = (crcValue | 0x80000000) >>> 0;
return steamAppId;
};
export const composeSteamShortcut = (
title: string,
executablePath: string,
iconPath: string | null
): SteamShortcut => {
return {
appid: generateSteamShortcutAppId(executablePath, title),
appname: title,
Exe: `"${executablePath}"`,
StartDir: `"${path.dirname(executablePath)}"`,
icon: iconPath ?? "",
ShortcutPath: "",
LaunchOptions: "",
IsHidden: false,
AllowDesktopConfig: true,
AllowOverlay: true,
OpenVR: false,
Devkit: false,
DevkitGameID: "",
DevkitOverrideAppID: false,
LastPlayTime: 0,
FlatpakAppID: "",
};
};
export const writeSteamShortcuts = async (
steamUserId: number,
shortcuts: SteamShortcut[]
) => {
const buffer = writeBuffer({ shortcuts });
return fs.promises.writeFile(
path.join(
await getSteamLocation(),
"userdata",
steamUserId.toString(),
"config",
"shortcuts.vdf"
),
buffer
);
};

View File

@@ -38,7 +38,7 @@ export class SystemPath {
try { try {
return app.getPath(pathName); return app.getPath(pathName);
} catch (error) { } catch (error) {
logger.error(`Error getting path: ${error}`); console.error(`Error getting path: ${error}`);
return ""; return "";
} }
} }

View File

@@ -6,6 +6,7 @@ import {
Tray, Tray,
app, app,
nativeImage, nativeImage,
screen,
shell, shell,
} from "electron"; } from "electron";
import { is } from "@electron-toolkit/utils"; import { is } from "@electron-toolkit/utils";
@@ -17,12 +18,17 @@ import { HydraApi } from "./hydra-api";
import UserAgent from "user-agents"; import UserAgent from "user-agents";
import { db, gamesSublevel, levelKeys } from "@main/level"; import { db, gamesSublevel, levelKeys } from "@main/level";
import { orderBy, slice } from "lodash-es"; import { orderBy, slice } from "lodash-es";
import type { ScreenState, UserPreferences } from "@types"; import type {
import { AuthPage } from "@shared"; AchievementCustomNotificationPosition,
ScreenState,
UserPreferences,
} from "@types";
import { AuthPage, generateAchievementCustomNotificationTest } from "@shared";
import { isStaging } from "@main/constants"; import { isStaging } from "@main/constants";
export class WindowManager { export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null; public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map(); private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
@@ -259,6 +265,141 @@ 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",
}
);
}
}
private static readonly NOTIFICATION_WINDOW_WIDTH = 360;
private static readonly NOTIFICATION_WINDOW_HEIGHT = 140;
private static async getNotificationWindowPosition(
position: AchievementCustomNotificationPosition | undefined
) {
const display = screen.getPrimaryDisplay();
const { width, height } = display.workAreaSize;
if (position === "bottom-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "bottom-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "top-center") {
return {
x: (width - this.NOTIFICATION_WINDOW_WIDTH) / 2,
y: 0,
};
}
if (position === "bottom-left") {
return {
x: 0,
y: height - this.NOTIFICATION_WINDOW_HEIGHT,
};
}
if (position === "top-right") {
return {
x: width - this.NOTIFICATION_WINDOW_WIDTH,
y: 0,
};
}
return {
x: 0,
y: 0,
};
}
public static async createNotificationWindow() {
if (this.notificationWindow) return;
const userPreferences = await db.get<string, UserPreferences | undefined>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (
userPreferences?.achievementNotificationsEnabled === false ||
userPreferences?.achievementCustomNotificationsEnabled === false
) {
return;
}
const { x, y } = await this.getNotificationWindowPosition(
userPreferences?.achievementCustomNotificationPosition
);
this.notificationWindow = new BrowserWindow({
transparent: true,
maximizable: false,
autoHideMenuBar: true,
minimizable: false,
focusable: false,
skipTaskbar: true,
frame: false,
width: this.NOTIFICATION_WINDOW_WIDTH,
height: this.NOTIFICATION_WINDOW_HEIGHT,
x,
y,
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 async showAchievementTestNotification() {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
const language = userPreferences.language ?? "en";
this.notificationWindow?.webContents.send(
"on-achievement-unlocked",
userPreferences.achievementCustomNotificationPosition ?? "top-left",
[generateAchievementCustomNotificationTest(t, language)]
);
}
public static async closeNotificationWindow() {
if (this.notificationWindow) {
this.notificationWindow.close();
this.notificationWindow = null;
}
}
public static openEditorWindow(themeId: string) { public static openEditorWindow(themeId: string) {
if (this.mainWindow) { if (this.mainWindow) {
const existingWindow = this.editorWindows.get(themeId); const existingWindow = this.editorWindows.get(themeId);
@@ -271,13 +412,13 @@ export class WindowManager {
} }
const editorWindow = new BrowserWindow({ const editorWindow = new BrowserWindow({
width: 600, width: 720,
height: 720, height: 720,
minWidth: 600, minWidth: 600,
minHeight: 540, minHeight: 540,
backgroundColor: "#1c1c1c", backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden", titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}), icon,
trafficLightPosition: { x: 16, y: 16 }, trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: { titleBarOverlay: {
symbolColor: "#DADBE1", symbolColor: "#DADBE1",

30
src/main/services/wine.ts Normal file
View File

@@ -0,0 +1,30 @@
import fs from "node:fs";
import path from "node:path";
export class Wine {
public static validatePrefix(winePrefixPath: string) {
const requiredFiles = [
{ name: "system.reg", type: "file" },
{ name: "user.reg", type: "file" },
{ name: "userdef.reg", type: "file" },
{ name: "dosdevices", type: "dir" },
{ name: "drive_c", type: "dir" },
];
for (const file of requiredFiles) {
const filePath = path.join(winePrefixPath, file.name);
if (file.type === "file" && !fs.existsSync(filePath)) {
return false;
}
if (file.type === "dir") {
if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isDirectory()) {
return false;
}
}
}
return true;
}
}

View File

@@ -0,0 +1,17 @@
import type { FriendGameSession } from "@main/generated/envelope";
import { HydraApi } from "@main/services/hydra-api";
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
import { GameStats } from "@types";
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
const [friend, gameStats] = await Promise.all([
HydraApi.get(`/users/${payload.friendId}`),
HydraApi.get<GameStats>(
`/games/stats?objectId=${payload.objectId}&shop=steam`
),
]);
if (friend && gameStats) {
publishFriendStartedPlayingGameNotification(friend, gameStats);
}
};

View File

@@ -0,0 +1,16 @@
import type { FriendRequest } from "@main/generated/envelope";
import { HydraApi } from "@main/services/hydra-api";
import { publishNewFriendRequestNotification } from "@main/services/notifications";
import { WindowManager } from "@main/services/window-manager";
export const friendRequestEvent = async (payload: FriendRequest) => {
WindowManager.mainWindow?.webContents.send("on-sync-friend-requests", {
friendRequestCount: payload.friendRequestCount,
});
const user = await HydraApi.get(`/users/${payload.senderId}`);
if (user) {
publishNewFriendRequestNotification(user);
}
};

View File

@@ -0,0 +1 @@
export * from "./ws-client";

View File

@@ -0,0 +1,119 @@
import { WebSocket } from "ws";
import { HydraApi } from "../hydra-api";
import { Envelope } from "@main/generated/envelope";
import { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session";
export class WSClient {
private static ws: WebSocket | null = null;
private static reconnectInterval = 1_000;
private static readonly maxReconnectInterval = 30_000;
private static shouldReconnect = true;
private static reconnecting = false;
private static heartbeatInterval: NodeJS.Timeout | null = null;
static async connect() {
this.shouldReconnect = true;
try {
const { token } = await HydraApi.post<{ token: string }>("/auth/ws");
this.ws = new WebSocket(import.meta.env.MAIN_VITE_WS_URL, {
headers: {
Authorization: `Bearer ${token}`,
},
});
this.ws.on("open", () => {
logger.info("WS connected");
this.reconnectInterval = 1000;
this.reconnecting = false;
this.startHeartbeat();
});
this.ws.on("message", (message) => {
const envelope = Envelope.fromBinary(
new Uint8Array(Buffer.from(message.toString()))
);
logger.info("Received WS envelope:", envelope);
if (envelope.payload.oneofKind === "friendRequest") {
friendRequestEvent(envelope.payload.friendRequest);
}
if (envelope.payload.oneofKind === "friendGameSession") {
friendGameSessionEvent(envelope.payload.friendGameSession);
}
});
this.ws.on("close", () => this.handleDisconnect("close"));
this.ws.on("error", (err) => {
logger.error("WS error:", err);
this.handleDisconnect("error");
});
} catch (err) {
logger.error("Failed to connect WS:", err);
this.handleDisconnect("auth-failed");
}
}
private static handleDisconnect(reason: string) {
logger.warn(`WS disconnected due to ${reason}`);
if (this.shouldReconnect) {
this.cleanupSocket();
this.tryReconnect();
}
}
private static async tryReconnect() {
if (this.reconnecting) return;
this.reconnecting = true;
logger.info(`Reconnecting in ${this.reconnectInterval / 1000}s...`);
setTimeout(async () => {
try {
await this.connect();
} catch (err) {
logger.error("Reconnect failed:", err);
this.reconnectInterval = Math.min(
this.reconnectInterval * 2,
this.maxReconnectInterval
);
this.reconnecting = false;
this.tryReconnect();
}
}, this.reconnectInterval);
}
private static cleanupSocket() {
if (this.ws) {
this.ws.removeAllListeners();
this.ws.close();
this.ws = null;
}
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
public static close() {
this.shouldReconnect = false;
this.reconnecting = false;
this.cleanupSocket();
}
private static startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.ping();
}
}, 15_000);
}
}

View File

@@ -6,6 +6,7 @@ interface ImportMetaEnv {
readonly MAIN_VITE_AUTH_URL: string; readonly MAIN_VITE_AUTH_URL: string;
readonly MAIN_VITE_CHECKOUT_URL: string; readonly MAIN_VITE_CHECKOUT_URL: string;
readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string; readonly MAIN_VITE_EXTERNAL_RESOURCES_URL: string;
readonly MAIN_VITE_WS_URL: string;
} }
interface ImportMeta { interface ImportMeta {

View File

@@ -1,14 +0,0 @@
import path from "node:path";
import steamGamesWorkerPath from "./steam-games.worker?modulePath";
import Piscina from "piscina";
import { seedsPath } from "@main/constants";
export const steamGamesWorker = new Piscina({
filename: steamGamesWorkerPath,
workerData: {
steamGamesPath: path.join(seedsPath, "steam-games.json"),
},
maxThreads: 1,
});

View File

@@ -1,56 +0,0 @@
import type { LudusaviBackup } from "@types";
import cp from "node:child_process";
import { workerData } from "node:worker_threads";
const { binaryPath } = workerData;
export const backupGame = ({
title,
backupPath,
preview = false,
winePrefix,
}: {
title: string;
backupPath: string;
preview?: boolean;
winePrefix?: string;
}) => {
return new Promise((resolve, reject) => {
const args = ["backup", title, "--api", "--force"];
if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath);
if (winePrefix) args.push("--wine-prefix", winePrefix);
cp.execFile(
binaryPath,
args,
(err: cp.ExecFileException | null, stdout: string) => {
if (err) {
return reject(err);
}
return resolve(JSON.parse(stdout) as LudusaviBackup);
}
);
});
};
export const restoreBackup = (backupPath: string) => {
const result = cp.execFileSync(binaryPath, [
"restore",
"--path",
backupPath,
"--api",
"--force",
]);
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};
export const generateConfig = () => {
const result = cp.execFileSync(binaryPath, ["schema", "config"]);
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};

View File

@@ -1,17 +0,0 @@
import type { SteamGame } from "@types";
import { slice } from "lodash-es";
import fs from "node:fs";
import { workerData } from "node:worker_threads";
const { steamGamesPath } = workerData;
const data = fs.readFileSync(steamGamesPath, "utf-8");
const steamGames = JSON.parse(data) as SteamGame[];
export const getById = (id: number) =>
steamGames.find((game) => game.id === id);
export const list = ({ limit, offset }: { limit: number; offset: number }) =>
slice(steamGames, offset, offset + limit);

View File

@@ -17,6 +17,9 @@ import type {
Theme, Theme,
FriendRequestSync, FriendRequestSync,
ShortcutLocation, ShortcutLocation,
ShopAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types"; } from "@types";
import type { AuthPage, CatalogueCategory } from "@shared"; import type { AuthPage, CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
@@ -64,6 +67,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("searchGames", payload, take, skip), ipcRenderer.invoke("searchGames", payload, take, skip),
getCatalogue: (category: CatalogueCategory) => getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category), ipcRenderer.invoke("getCatalogue", category),
saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
getGameShopDetails: (objectId: string, shop: GameShop, language: string) => getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language), ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"), getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
@@ -186,6 +191,10 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("resetGameAchievements", shop, objectId), ipcRenderer.invoke("resetGameAchievements", shop, objectId),
extractGameDownload: (shop: GameShop, objectId: string) => extractGameDownload: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("extractGameDownload", shop, objectId), ipcRenderer.invoke("extractGameDownload", shop, objectId),
getDefaultWinePrefixSelectionPath: () =>
ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"),
createSteamShortcut: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("createSteamShortcut", shop, objectId),
onGamesRunning: ( onGamesRunning: (
cb: ( cb: (
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[] gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
@@ -202,12 +211,6 @@ contextBridge.exposeInMainWorld("electron", {
return () => return () =>
ipcRenderer.removeListener("on-library-batch-complete", listener); ipcRenderer.removeListener("on-library-batch-complete", listener);
}, },
onAchievementUnlocked: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-achievement-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
onExtractionComplete: (cb: (shop: GameShop, objectId: string) => void) => { onExtractionComplete: (cb: (shop: GameShop, objectId: string) => void) => {
const listener = ( const listener = (
_event: Electron.IpcRendererEvent, _event: Electron.IpcRendererEvent,
@@ -405,6 +408,42 @@ contextBridge.exposeInMainWorld("electron", {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,
achievements?: AchievementNotificationInfo[]
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
position?: AchievementCustomNotificationPosition,
achievements?: AchievementNotificationInfo[]
) => cb(position, achievements);
ipcRenderer.on("on-achievement-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
onCombinedAchievementsUnlocked: (
cb: (
gameCount: number,
achievementsCount: number,
position: AchievementCustomNotificationPosition
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
gameCount: number,
achievementCount: number,
position: AchievementCustomNotificationPosition
) => cb(gameCount, achievementCount, position);
ipcRenderer.on("on-combined-achievements-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-combined-achievements-unlocked", listener);
},
updateAchievementCustomNotificationWindow: () =>
ipcRenderer.invoke("updateAchievementCustomNotificationWindow"),
showAchievementTestNotification: () =>
ipcRenderer.invoke("showAchievementTestNotification"),
/* Themes */ /* Themes */
addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme), addCustomTheme: (theme: Theme) => ipcRenderer.invoke("addCustomTheme", theme),

View File

@@ -20,7 +20,6 @@ import {
setUserDetails, setUserDetails,
setProfileBackground, setProfileBackground,
setGameRunning, setGameRunning,
setFriendRequestCount,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -155,16 +154,6 @@ export function App() {
}); });
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); }, [fetchUserDetails, t, showSuccessToast, updateUserDetails]);
useEffect(() => {
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
dispatch(setFriendRequestCount(result.friendRequestCount));
});
return () => {
unsubscribe();
};
}, [dispatch]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => { const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {
if (gamesRunning.length) { if (gamesRunning.length) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,5 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 933">
<path id="Vector" d="M29.3333 10.5H26.8333V8.83333C26.8333 8.61232 26.7455 8.40036 26.5893 8.24408C26.433 8.0878 26.221 8 26 8H11C10.779 8 10.567 8.0878 10.4107 8.24408C10.2545 8.40036 10.1667 8.61232 10.1667 8.83333V10.5H7.66667C7.22464 10.5 6.80072 10.6756 6.48816 10.9882C6.17559 11.3007 6 11.7246 6 12.1667V13.8333C6 14.9384 6.43899 15.9982 7.22039 16.7796C7.6073 17.1665 8.06663 17.4734 8.57215 17.6828C9.07768 17.8922 9.61949 18 10.1667 18H10.5469C11.0378 19.5556 11.9737 20.9333 13.2391 21.9628C14.5044 22.9923 16.0437 23.6285 17.6667 23.7927V26.3333H15.1667C14.9457 26.3333 14.7337 26.4211 14.5774 26.5774C14.4211 26.7337 14.3333 26.9457 14.3333 27.1667C14.3333 27.3877 14.4211 27.5996 14.5774 27.7559C14.7337 27.9122 14.9457 28 15.1667 28H21.8333C22.0543 28 22.2663 27.9122 22.4226 27.7559C22.5789 27.5996 22.6667 27.3877 22.6667 27.1667C22.6667 26.9457 22.5789 26.7337 22.4226 26.5774C22.2663 26.4211 22.0543 26.3333 21.8333 26.3333H19.3333V23.7896C22.6604 23.4531 25.4208 21.1187 26.425 18H26.8333C27.9384 18 28.9982 17.561 29.7796 16.7796C30.561 15.9982 31 14.9384 31 13.8333V12.1667C31 11.7246 30.8244 11.3007 30.5118 10.9882C30.1993 10.6756 29.7754 10.5 29.3333 10.5ZM10.1667 16.3333C9.50363 16.3333 8.86774 16.0699 8.3989 15.6011C7.93006 15.1323 7.66667 14.4964 7.66667 13.8333V12.1667H10.1667V15.5C10.1667 15.7778 10.1802 16.0556 10.2073 16.3333H10.1667ZM29.3333 13.8333C29.3333 14.4964 29.0699 15.1323 28.6011 15.6011C28.1323 16.0699 27.4964 16.3333 26.8333 16.3333H26.7812C26.8154 16.0255 26.8328 15.716 26.8333 15.4062V12.1667H29.3333V13.8333Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,519 @@
@use "../../../scss/globals.scss";
$margin-horizontal: 40px;
$margin-top: 52px;
$margin-bottom: 28px;
@keyframes content-in {
0% {
width: 80px;
opacity: 0;
transform: scale(0);
}
100% {
width: 80px;
opacity: 1;
transform: scale(1);
}
}
@keyframes content-wait {
0% {
width: 80px;
}
100% {
width: 80px;
}
}
@keyframes trophy-out {
0% {
opacity: 1;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes ellipses-stand-by {
0% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ellipses-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
scale: 1.5;
}
}
@keyframes content-expand {
0% {
width: 80px;
}
100% {
width: calc(360px - $margin-horizontal);
}
}
@keyframes chip-stand-by {
0% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes chip-in {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes title-in {
0% {
transform: translateY(10px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes description-in {
0% {
transform: translateY(20px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes dark-overlay {
0% {
opacity: 0.7;
}
50% {
opacity: 0.7;
}
100% {
opacity: 0;
}
}
@keyframes content-out {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-20px);
opacity: 0;
}
}
@keyframes shine {
from {
transform: translateX(0px) rotate(36deg);
}
to {
transform: translateX(420px) rotate(36deg);
}
}
.achievement-notification {
width: 360px;
height: 192px;
display: flex;
&--top-left {
align-items: start;
}
&--top-center {
align-items: start;
}
&--top-right {
justify-content: end;
align-items: start;
}
&--bottom-left {
align-items: end;
}
&--bottom-center {
align-items: end;
}
&--bottom-right {
justify-content: end;
align-items: end;
}
&__outer-container {
position: relative;
display: grid;
width: calc(360px - $margin-horizontal);
overflow: clip;
border: 1px solid #ffffff1a;
animation:
content-in 450ms ease-in-out,
content-wait 450ms ease-in-out 450ms,
content-expand 450ms ease-in-out 900ms;
box-shadow: 0px 2px 16px 0px rgba(0, 0, 0, 0.25);
}
&--top-left &__outer-container {
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-center &__outer-container {
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-right &__outer-container {
margin: $margin-top $margin-horizontal 0 0;
}
&--bottom-left &__outer-container {
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-center &__outer-container {
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-right &__outer-container {
margin: 0 $margin-horizontal $margin-bottom 0;
}
&--closing .achievement-notification__outer-container {
animation: content-out 450ms ease-in-out;
animation-fill-mode: forwards;
}
&__container {
width: calc(360px - $margin-horizontal);
display: flex;
padding: 8px 16px 8px 8px;
background: globals.$background-color;
}
&--platinum &__container {
background: linear-gradient(94deg, #1c1c1c -25%, #044838 100%);
}
&--rare &__container {
&::before {
content: "";
position: absolute;
top: -50%;
left: -60px;
width: 29px;
height: 134px;
transform: translateX(0px) rotate(36deg);
opacity: 0.2;
background: #d9d9d9;
filter: blur(8px);
animation: shine 450ms ease-in-out 1350ms;
}
}
&__content {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
width: 100%;
z-index: 1;
}
&__icon {
box-sizing: border-box;
min-width: 64px;
min-height: 64px;
width: 64px;
height: 64px;
border-radius: 2px;
flex: 1;
}
&--rare &__icon {
outline: 1px solid #f4a510;
box-shadow: 0px 0px 12px 0px rgba(244, 165, 16, 0.25);
}
&--platinum &__icon {
outline: 1px solid #0cf1ca;
box-shadow: 0px 0px 12px 0px rgba(12, 241, 202, 0.25);
}
&__additional-overlay {
position: absolute;
top: 0;
left: 0;
width: 80px;
height: 80px;
}
&__dark-overlay {
position: absolute;
top: 8px;
left: 8px;
width: 64px;
height: 64px;
background: #000;
opacity: 0;
z-index: 1;
animation: dark-overlay 900ms ease-in-out;
}
&__trophy-overlay {
position: absolute;
mask-image: url("/src/assets/icons/trophy.svg");
top: 22px;
left: 22px;
width: 36px;
height: 36px;
opacity: 0;
z-index: 1;
animation: trophy-out 900ms ease-in-out;
background: #fff;
}
&--rare &__trophy-overlay {
background: linear-gradient(
118deg,
#e8ad15 18.96%,
#d5900f 26.41%,
#e8ad15 29.99%,
#e4aa15 38.89%,
#ca890e 42.43%,
#ca880e 46.59%,
#ecbe1a 50.08%,
#ecbd1a 53.48%,
#b3790d 57.39%,
#66470a 75.64%,
#a37a13 78.2%,
#987112 79.28%,
#503808 83.6%,
#3e2d08 85.77%
),
#fff;
}
&--platinum &__trophy-overlay {
background: linear-gradient(
118deg,
#15e8d6 18.96%,
#0fd5a7 26.41%,
#15e8b7 29.99%,
#15e4b4 38.89%,
#0eca7f 42.43%,
#0eca9e 46.59%,
#1aecbb 50.08%,
#1aecb0 53.48%,
#0db392 57.39%,
#0a6648 75.64%,
#13a38b 78.2%,
#129862 79.28%,
#085042 83.6%,
#083e31 85.77%
);
}
&__ellipses-overlay {
position: absolute;
top: 8px;
left: 8px;
width: 64px;
height: 64px;
z-index: 2;
opacity: 0;
animation: ellipses-out 900ms ease-in-out;
}
&__text-container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
overflow: hidden;
}
&__title {
font-size: 14px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: globals.$muted-color;
animation: title-in 450ms ease-in-out 900ms;
}
&__hidden-icon {
margin-right: 4px;
opacity: 0.5;
}
&__description {
font-size: 14px;
font-weight: 400;
overflow: hidden;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
display: -webkit-box;
-webkit-box-orient: vertical;
color: globals.$body-color;
animation: description-in 450ms ease-in-out 900ms;
}
&--closing &__chip {
animation: content-out 450ms ease-in-out;
animation-fill-mode: forwards;
}
&__chip {
position: absolute;
right: 8px;
display: flex;
gap: 4px;
padding: 0 8px;
border-radius: 300px;
align-items: center;
background: globals.$muted-color;
height: 24px;
animation:
chip-stand-by 900ms ease-in-out,
chip-in 450ms ease-in-out 900ms;
z-index: 2;
&__icon {
width: 16px;
height: 16px;
path {
fill: globals.$background-color;
}
}
&__label {
color: globals.$background-color;
font-weight: 700;
}
}
&--top-left &__chip {
top: -12px;
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-center &__chip {
top: -12px;
margin: $margin-top 0 0 $margin-horizontal;
}
&--top-right &__chip {
top: -12px;
margin: $margin-top $margin-horizontal 0 0;
}
&--bottom-left &__chip {
bottom: 70px;
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-center &__chip {
bottom: 70px;
margin: 0 0 $margin-bottom $margin-horizontal;
}
&--bottom-right &__chip {
bottom: 70px;
margin: 0 $margin-horizontal $margin-bottom 0;
}
&--rare &__chip {
background: linear-gradient(
160deg,
#e8ad15 18.96%,
#d5900f 26.41%,
#e8ad15 29.99%,
#e4aa15 38.89%,
#ca890e 42.43%,
#ca880e 46.59%,
#ecbe1a 50.08%,
#ecbd1a 53.48%,
#b3790d 57.39%,
#66470a 75.64%,
#a37a13 78.2%,
#987112 79.28%,
#503808 83.6%,
#3e2d08 85.77%
);
&__icon {
path {
fill: #fff;
}
}
&__label {
color: #fff;
}
}
&--platinum &__chip {
background: linear-gradient(
118deg,
#15e8d6 18.96%,
#0fd5a7 26.41%,
#15e8b7 29.99%,
#15e4b4 38.89%,
#0eca7f 42.43%,
#0eca9e 46.59%,
#1aecbb 50.08%,
#1aecb0 53.48%,
#0db392 57.39%,
#0a6648 75.64%,
#13a38b 78.2%,
#129862 79.28%,
#085042 83.6%,
#083e31 85.77%
);
&__icon {
path {
fill: #fff;
}
}
&__label {
color: #fff;
}
}
&--closing * {
animation: none;
}
&--closing *::before,
&--closing *::after {
animation: none !important;
}
}

View File

@@ -0,0 +1,79 @@
import {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import cn from "classnames";
import "./achievement-notification.scss";
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { EyeClosedIcon } from "@primer/octicons-react";
import Ellipses from "@renderer/assets/icons/ellipses.png";
interface AchievementNotificationProps {
position: AchievementCustomNotificationPosition;
achievement: AchievementNotificationInfo;
isClosing: boolean;
}
export function AchievementNotificationItem({
position,
achievement,
isClosing,
}: Readonly<AchievementNotificationProps>) {
const baseClassName = "achievement-notification";
return (
<div
className={cn("achievement-notification", {
[`${baseClassName}--${position}`]: true,
[`${baseClassName}--closing`]: isClosing,
[`${baseClassName}--hidden`]: achievement.isHidden,
[`${baseClassName}--rare`]: achievement.isRare,
[`${baseClassName}--platinum`]: achievement.isPlatinum,
})}
>
{achievement.points && (
<div className="achievement-notification__chip">
<HydraIcon className="achievement-notification__chip__icon" />
<span className="achievement-notification__chip__label">
+{achievement.points}
</span>
</div>
)}
<div className="achievement-notification__outer-container">
<div className="achievement-notification__container">
<div className="achievement-notification__content">
<img
src={achievement.iconUrl}
alt={achievement.title}
className="achievement-notification__icon"
/>
<div className="achievement-notification__text-container">
<p className="achievement-notification__title">
{achievement.isHidden && (
<span className="achievement-notification__hidden-icon">
<EyeClosedIcon size={16} />
</span>
)}
{achievement.title}
</p>
<p className="achievement-notification__description">
{achievement.description}
</p>
</div>
</div>
<div className="achievement-notification__additional-overlay">
<div className="achievement-notification__dark-overlay"></div>
<img
className="achievement-notification__ellipses-overlay"
src={Ellipses}
alt="Ellipses effect"
/>
<div className="achievement-notification__trophy-overlay"></div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,16 @@
import cn from "classnames"; import cn from "classnames";
import { PlacesType, Tooltip } from "react-tooltip";
import "./button.scss"; import "./button.scss";
import { useId } from "react";
export interface ButtonProps export interface ButtonProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>, React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement HTMLButtonElement
> { > {
tooltip?: string;
tooltipPlace?: PlacesType;
theme?: "primary" | "outline" | "dark" | "danger"; theme?: "primary" | "outline" | "dark" | "danger";
} }
@@ -14,15 +18,32 @@ export function Button({
children, children,
theme = "primary", theme = "primary",
className, className,
tooltip,
tooltipPlace = "top",
...props ...props
}: Readonly<ButtonProps>) { }: Readonly<ButtonProps>) {
const id = useId();
const tooltipProps = tooltip
? {
"data-tooltip-id": id,
"data-tooltip-place": tooltipPlace,
"data-tooltip-content": tooltip,
}
: {};
return ( return (
<button <>
type="button" <button
className={cn("button", `button--${theme}`, className)} type="button"
{...props} className={cn("button", `button--${theme}`, className)}
> {...props}
{children} {...tooltipProps}
</button> >
{children}
</button>
{tooltip && <Tooltip id={id} />}
</>
); );
} }

View File

@@ -0,0 +1,40 @@
@use "../../scss/globals.scss";
.collapsed-menu {
&__button {
height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;
color: globals.$muted-color;
width: 100%;
cursor: pointer;
transition: all ease 0.2s;
gap: globals.$spacing-unit;
font-size: globals.$body-font-size;
font-weight: bold;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
&:active {
opacity: globals.$active-opacity;
}
}
&__chevron {
transition: transform ease 0.2s;
&--open {
transform: rotate(180deg);
}
}
&__content {
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
position: relative;
}
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "@primer/octicons-react";
import "./collapsed-menu.scss";
export interface CollapsedMenuProps {
title: string;
children: React.ReactNode;
}
export function CollapsedMenu({
title,
children,
}: Readonly<CollapsedMenuProps>) {
const content = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(true);
const [height, setHeight] = useState(0);
useEffect(() => {
if (content.current && content.current.scrollHeight !== height) {
setHeight(isOpen ? content.current.scrollHeight : 0);
} else if (!isOpen) {
setHeight(0);
}
}, [isOpen, children, height]);
return (
<div className="collapsed-menu">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="collapsed-menu__button"
>
<ChevronDownIcon
className={`collapsed-menu__chevron ${
isOpen ? "collapsed-menu__chevron--open" : ""
}`}
/>
<span>{title}</span>
</button>
<div
ref={content}
className="collapsed-menu__content"
style={{
maxHeight: `${height}px`,
}}
>
{children}
</div>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge"; import { Badge } from "../badge/badge";
import { useCallback, useState, useMemo } from "react"; import { useCallback, useState, useMemo } from "react";
import { useFormat, useRepacks } from "@renderer/hooks"; import { useFormat, useRepacks } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
export interface GameCardProps export interface GameCardProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
@@ -63,7 +62,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
> >
<div className="game-card__backdrop"> <div className="game-card__backdrop">
<img <img
src={steamUrlBuilder.library(game.objectId)} src={game.libraryImageUrl}
alt={game.title} alt={game.title}
className="game-card__cover" className="game-card__cover"
loading="lazy" loading="lazy"

View File

@@ -33,29 +33,27 @@ export function Hero() {
} }
if (featuredGameDetails?.length) { if (featuredGameDetails?.length) {
return featuredGameDetails.map((game, index) => ( return featuredGameDetails.map((game) => (
<button <button
type="button" type="button"
onClick={() => navigate(game.uri)} onClick={() => navigate(game.uri)}
className="hero" className="hero"
key={index} key={game.uri}
> >
<div className="hero__backdrop"> <div className="hero__backdrop">
<img <img
src={game.background} src={game.libraryHeroImageUrl}
alt={game.description} alt={game.description ?? ""}
className="hero__media" className="hero__media"
/> />
<div className="hero__content"> <div className="hero__content">
{game.logo && ( <img
<img src={game.logoImageUrl}
src={game.logo} width="250px"
width="250px" alt={game.description ?? ""}
alt={game.description} loading="eager"
loading="eager" />
/>
)}
<p className="hero__description">{game.description}</p> <p className="hero__description">{game.description}</p>
</div> </div>
</div> </div>

View File

@@ -18,12 +18,13 @@ export function SelectField({
options = [{ key: "-", value: value?.toString() || "-", label: "-" }], options = [{ key: "-", value: value?.toString() || "-", label: "-" }],
theme = "primary", theme = "primary",
onChange, onChange,
}: SelectProps) { className,
}: Readonly<SelectProps>) {
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const id = useId(); const id = useId();
return ( return (
<div className="select-field__container"> <div className={cn("select-field__container", className)}>
{label && ( {label && (
<label htmlFor={id} className="select-field__label"> <label htmlFor={id} className="select-field__label">
{label} {label}

View File

@@ -23,6 +23,8 @@ import { sortBy } from "lodash-es";
import cn from "classnames"; import cn from "classnames";
import { CommentDiscussionIcon } from "@primer/octicons-react"; import { CommentDiscussionIcon } from "@primer/octicons-react";
import { SidebarGameItem } from "./sidebar-game-item"; import { SidebarGameItem } from "./sidebar-game-item";
import { setFriendRequestCount } from "@renderer/features/user-details-slice";
import { useDispatch } from "react-redux";
const SIDEBAR_MIN_WIDTH = 200; const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250; const SIDEBAR_INITIAL_WIDTH = 250;
@@ -33,6 +35,8 @@ const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
export function Sidebar() { export function Sidebar() {
const filterRef = useRef<HTMLInputElement>(null); const filterRef = useRef<HTMLInputElement>(null);
const dispatch = useDispatch();
const { t } = useTranslation("sidebar"); const { t } = useTranslation("sidebar");
const { library, updateLibrary } = useLibrary(); const { library, updateLibrary } = useLibrary();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -60,6 +64,16 @@ export function Sidebar() {
updateLibrary(); updateLibrary();
}, [lastPacket?.gameId, updateLibrary]); }, [lastPacket?.gameId, updateLibrary]);
useEffect(() => {
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
dispatch(setFriendRequestCount(result.friendRequestCount));
});
return () => {
unsubscribe();
};
}, [dispatch]);
const sidebarRef = useRef<HTMLElement>(null); const sidebarRef = useRef<HTMLElement>(null);
const cursorPos = useRef({ x: 0 }); const cursorPos = useRef({ x: 0 });

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared"; import { Downloader } from "@shared";
export const VERSION_CODENAME = "Polychrome"; export const VERSION_CODENAME = "Lumen";
export const DOWNLOADER_NAME = { export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid", [Downloader.RealDebrid]: "Real-Debrid",

View File

@@ -21,7 +21,7 @@ import type {
GameShop, GameShop,
GameStats, GameStats,
LibraryGame, LibraryGame,
ShopDetails, ShopDetailsWithAssets,
UserAchievement, UserAchievement,
} from "@types"; } from "@types";
@@ -69,7 +69,9 @@ export function GameDetailsContextProvider({
gameTitle, gameTitle,
shop, shop,
}: Readonly<GameDetailsContextProps>) { }: Readonly<GameDetailsContextProps>) {
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null); const [shopDetails, setShopDetails] = useState<ShopDetailsWithAssets | null>(
null
);
const [achievements, setAchievements] = useState<UserAchievement[] | null>( const [achievements, setAchievements] = useState<UserAchievement[] | null>(
null null
); );
@@ -79,7 +81,7 @@ export function GameDetailsContextProvider({
const [stats, setStats] = useState<GameStats | null>(null); const [stats, setStats] = useState<GameStats | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true);
const [gameColor, setGameColor] = useState(""); const [gameColor, setGameColor] = useState("");
const [isGameRunning, setIsGameRunning] = useState(false); const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false); const [showRepacksModal, setShowRepacksModal] = useState(false);
@@ -120,7 +122,7 @@ export function GameDetailsContextProvider({
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
window.electron const shopDetailsPromise = window.electron
.getGameShopDetails(objectId, shop, getSteamLanguage(i18n.language)) .getGameShopDetails(objectId, shop, getSteamLanguage(i18n.language))
.then((result) => { .then((result) => {
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
@@ -135,15 +137,41 @@ export function GameDetailsContextProvider({
) { ) {
setHasNSFWContentBlocked(true); setHasNSFWContentBlocked(true);
} }
})
.finally(() => { if (result?.assets) {
setIsLoading(false); setIsLoading(false);
}
}); });
window.electron.getGameStats(objectId, shop).then((result) => { const statsPromise = window.electron
if (abortController.signal.aborted) return; .getGameStats(objectId, shop)
setStats(result); .then((result) => {
}); if (abortController.signal.aborted) return null;
setStats(result);
return result;
});
Promise.all([shopDetailsPromise, statsPromise])
.then(([_, stats]) => {
if (stats) {
const assets = stats.assets;
if (assets) {
window.electron.saveGameShopAssets(objectId, shop, assets);
setShopDetails((prev) => {
if (!prev) return null;
return {
...prev,
assets,
};
});
}
}
})
.finally(() => {
if (abortController.signal.aborted) return;
setIsLoading(false);
});
if (userDetails) { if (userDetails) {
window.electron window.electron

View File

@@ -3,13 +3,13 @@ import type {
GameShop, GameShop,
GameStats, GameStats,
LibraryGame, LibraryGame,
ShopDetails, ShopDetailsWithAssets,
UserAchievement, UserAchievement,
} from "@types"; } from "@types";
export interface GameDetailsContext { export interface GameDetailsContext {
game: LibraryGame | null; game: LibraryGame | null;
shopDetails: ShopDetails | null; shopDetails: ShopDetailsWithAssets | null;
repacks: GameRepack[]; repacks: GameRepack[];
shop: GameShop; shop: GameShop;
gameTitle: string; gameTitle: string;

View File

@@ -3,7 +3,6 @@ import type {
AppUpdaterEvent, AppUpdaterEvent,
GameShop, GameShop,
HowLongToBeatCategory, HowLongToBeatCategory,
ShopDetails,
Steam250Game, Steam250Game,
DownloadProgress, DownloadProgress,
SeedingStatus, SeedingStatus,
@@ -33,6 +32,11 @@ import type {
Badge, Badge,
Auth, Auth,
ShortcutLocation, ShortcutLocation,
CatalogueSearchResult,
ShopAssets,
ShopDetailsWithAssets,
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios"; import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage"; import type disk from "diskusage";
@@ -69,13 +73,18 @@ declare global {
payload: CatalogueSearchPayload, payload: CatalogueSearchPayload,
take: number, take: number,
skip: number skip: number
) => Promise<{ edges: any[]; count: number }>; ) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
getCatalogue: (category: CatalogueCategory) => Promise<any[]>; getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
saveGameShopAssets: (
objectId: string,
shop: GameShop,
assets: ShopAssets
) => Promise<void>;
getGameShopDetails: ( getGameShopDetails: (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
language: string language: string
) => Promise<ShopDetails | null>; ) => Promise<ShopDetailsWithAssets | null>;
getRandomGame: () => Promise<Steam250Game>; getRandomGame: () => Promise<Steam250Game>;
getHowLongToBeat: ( getHowLongToBeat: (
objectId: string, objectId: string,
@@ -168,10 +177,11 @@ declare global {
minimized: boolean; minimized: boolean;
}) => Promise<void>; }) => Promise<void>;
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>; extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
onExtractionComplete: ( onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
/* Download sources */ /* Download sources */
putDownloadSource: ( putDownloadSource: (
@@ -314,6 +324,21 @@ declare global {
/* Notifications */ /* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>; publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
onAchievementUnlocked: (
cb: (
position?: AchievementCustomNotificationPosition,
achievements?: AchievementNotificationInfo[]
) => void
) => () => Electron.IpcRenderer;
onCombinedAchievementsUnlocked: (
cb: (
gameCount: number,
achievementCount: number,
position: AchievementCustomNotificationPosition
) => void
) => () => Electron.IpcRenderer;
updateAchievementCustomNotificationWindow: () => Promise<void>;
showAchievementTestNotification: () => Promise<void>;
/* Themes */ /* Themes */
addCustomTheme: (theme: Theme) => Promise<void>; addCustomTheme: (theme: Theme) => Promise<void>;

View File

@@ -30,6 +30,7 @@ import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile"; import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements"; import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor"; import ThemeEditor from "./pages/theme-editor/theme-editor";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
Sentry.init({ Sentry.init({
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN, dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
@@ -84,6 +85,10 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
</Route> </Route>
<Route path="/theme-editor" element={<ThemeEditor />} /> <Route path="/theme-editor" element={<ThemeEditor />} />
<Route
path="/achievement-notification"
element={<AchievementNotification />}
/>
</Routes> </Routes>
</HashRouter> </HashRouter>
</Provider> </Provider>

View File

@@ -1,6 +1,5 @@
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch, useUserDetails } from "@renderer/hooks"; import { useAppDispatch, useUserDetails } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -120,8 +119,15 @@ export function AchievementsContent({
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false); const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const { gameTitle, objectId, shop, achievements, gameColor, setGameColor } = const {
useContext(gameDetailsContext); gameTitle,
objectId,
shop,
shopDetails,
achievements,
gameColor,
setGameColor,
} = useContext(gameDetailsContext);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -131,10 +137,13 @@ export function AchievementsContent({
}, [dispatch, gameTitle]); }, [dispatch, gameTitle]);
const handleHeroLoad = async () => { const handleHeroLoad = async () => {
const output = await average(steamUrlBuilder.libraryHero(objectId!), { const output = await average(
amount: 1, shopDetails?.assets?.libraryHeroImageUrl ?? "",
format: "hex", {
}); amount: 1,
format: "hex",
}
);
const backgroundColor = output const backgroundColor = output
? (new Color(output).darken(0.7).toString() as string) ? (new Color(output).darken(0.7).toString() as string)
@@ -179,7 +188,7 @@ export function AchievementsContent({
return ( return (
<div className="achievements-content__achievements-list"> <div className="achievements-content__achievements-list">
<img <img
src={steamUrlBuilder.libraryHero(objectId)} src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="achievements-content__achievements-list__image" className="achievements-content__achievements-list__image"
alt={gameTitle} alt={gameTitle}
onLoad={handleHeroLoad} onLoad={handleHeroLoad}
@@ -205,7 +214,7 @@ export function AchievementsContent({
to={buildGameDetailsPath({ shop, objectId, title: gameTitle })} to={buildGameDetailsPath({ shop, objectId, title: gameTitle })}
> >
<img <img
src={steamUrlBuilder.logo(objectId)} src={shopDetails?.assets?.logoImageUrl ?? ""}
className="achievements-content__achievements-list__section__container__hero__content__game-logo" className="achievements-content__achievements-list__section__container__hero__content__game-logo"
alt={gameTitle} alt={gameTitle}
/> />

View File

@@ -0,0 +1,162 @@
import { useCallback, useEffect, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import { injectCustomCss } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
const NOTIFICATION_TIMEOUT = 4000;
export function AchievementNotification() {
const { t } = useTranslation("achievement");
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] =
useState<AchievementCustomNotificationPosition>("top-left");
const [achievements, setAchievements] = useState<
AchievementNotificationInfo[]
>([]);
const [currentAchievement, setCurrentAchievement] =
useState<AchievementNotificationInfo | null>(null);
const achievementAnimation = useRef(-1);
const closingAnimation = useRef(-1);
const visibleAnimation = useRef(-1);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.1;
audio.play();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onCombinedAchievementsUnlocked(
(gameCount, achievementCount, position) => {
if (gameCount === 0 || achievementCount === 0) return;
setPosition(position);
setAchievements([
{
title: t("new_achievements_unlocked", {
gameCount,
achievementCount,
}),
isHidden: false,
isRare: false,
isPlatinum: false,
points: 0,
iconUrl: "https://cdn.losbroxas.org/favicon.svg",
},
]);
playAudio();
}
);
return () => {
unsubscribe();
};
}, [t, playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(position, achievements) => {
if (!achievements?.length) return;
if (position) {
setPosition(position);
}
setAchievements((ach) => ach.concat(achievements));
playAudio();
}
);
return () => {
unsubscribe();
};
}, [playAudio]);
const hasAchievementsPending = achievements.length > 0;
const startAnimateClosing = useCallback(() => {
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
setIsClosing(true);
const zero = performance.now();
closingAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= 450) {
closingAnimation.current = requestAnimationFrame(animateClosing);
} else {
setIsVisible(false);
setAchievements((ach) => ach.slice(1));
}
}
);
}, []);
useEffect(() => {
if (hasAchievementsPending) {
setIsClosing(false);
setIsVisible(true);
let zero = performance.now();
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
achievementAnimation.current = requestAnimationFrame(
function animateLock(time) {
if (time - zero > NOTIFICATION_TIMEOUT) {
zero = performance.now();
startAnimateClosing();
}
achievementAnimation.current = requestAnimationFrame(animateLock);
}
);
}
}, [hasAchievementsPending, startAnimateClosing, currentAchievement]);
useEffect(() => {
if (achievements.length) {
setCurrentAchievement(achievements[0]);
}
}, [achievements]);
useEffect(() => {
const loadAndApplyTheme = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
}
};
loadAndApplyTheme();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected((cssString) => {
injectCustomCss(cssString);
});
return () => unsubscribe();
}, []);
if (!isVisible || !currentAchievement) return null;
return (
<AchievementNotificationItem
achievement={currentAchievement}
isClosing={isClosing}
position={position}
/>
);
}

View File

@@ -1,4 +1,4 @@
import type { DownloadSource } from "@types"; import type { CatalogueSearchResult, DownloadSource } from "@types";
import { import {
useAppDispatch, useAppDispatch,
@@ -44,7 +44,7 @@ export default function Catalogue() {
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]); const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<any[]>([]); const [results, setResults] = useState<CatalogueSearchResult[]>([]);
const [itemsCount, setItemsCount] = useState(0); const [itemsCount, setItemsCount] = useState(0);

View File

@@ -25,6 +25,21 @@
border-right: 1px solid globals.$border-color; border-right: 1px solid globals.$border-color;
} }
&__cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: globals.$body-color;
width: 200px;
height: 103px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 50%,
rgba(255, 255, 255, 0.1) 100%
);
}
&__details { &__details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,15 +1,16 @@
import { Badge } from "@renderer/components"; import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import { useAppSelector, useRepacks } from "@renderer/hooks"; import { useAppSelector, useRepacks } from "@renderer/hooks";
import { steamUrlBuilder } from "@shared";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./game-item.scss"; import "./game-item.scss";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CatalogueSearchResult } from "@types";
import { QuestionIcon } from "@primer/octicons-react";
export interface GameItemProps { export interface GameItemProps {
game: any; game: CatalogueSearchResult;
} }
export function GameItem({ game }: GameItemProps) { export function GameItem({ game }: GameItemProps) {
@@ -43,18 +44,32 @@ export function GameItem({ game }: GameItemProps) {
}); });
}, [game.genres, language, steamGenres]); }, [game.genres, language, steamGenres]);
const libraryImage = useMemo(() => {
if (game.libraryImageUrl) {
return (
<img
className="game-item__cover"
src={game.libraryImageUrl}
alt={game.title}
loading="lazy"
/>
);
}
return (
<div className="game-item__cover-placeholder">
<QuestionIcon size={28} />
</div>
);
}, [game.libraryImageUrl, game.title]);
return ( return (
<button <button
type="button" type="button"
className="game-item" className="game-item"
onClick={() => navigate(buildGameDetailsPath(game))} onClick={() => navigate(buildGameDetailsPath(game))}
> >
<img {libraryImage}
className="game-item__cover"
src={steamUrlBuilder.library(game.objectId)}
alt={game.title}
loading="lazy"
/>
<div className="game-item__details"> <div className="game-item__details">
<span>{game.title}</span> <span>{game.title}</span>

View File

@@ -9,7 +9,7 @@ import {
formatDownloadProgress, formatDownloadProgress,
} from "@renderer/helpers"; } from "@renderer/helpers";
import { Downloader, formatBytes, steamUrlBuilder } from "@shared"; import { Downloader, formatBytes } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants"; import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks"; import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
@@ -319,7 +319,7 @@ export function DownloadGroup({
<div className="download-group__cover"> <div className="download-group__cover">
<div className="download-group__cover-backdrop"> <div className="download-group__cover-backdrop">
<img <img
src={steamUrlBuilder.library(game.objectId)} src={game.libraryImageUrl ?? ""}
className="download-group__cover-image" className="download-group__cover-image"
alt={game.title} alt={game.title}
/> />

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