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

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](README.cs.md)
|
||||
[](README.da.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](README.cs.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
[](README.da.md)
|
||||
[](README.nb.md)
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ productName: Hydra
|
||||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- ludusavi
|
||||
- hydra-download-manager
|
||||
- seeds
|
||||
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "2.1.7",
|
||||
"version": "2.1.7-preview",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
@@ -23,7 +23,7 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "electron-builder install-app-deps && node ./postinstall.cjs",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@vanilla-extract/recipes": "^0.5.2",
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.7.7",
|
||||
"better-sqlite3": "^11.2.1",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"check-disk-space": "^3.4.0",
|
||||
"classnames": "^2.5.1",
|
||||
"color": "^4.2.3",
|
||||
@@ -52,8 +52,8 @@
|
||||
"create-desktop-shortcuts": "^1.11.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"dexie": "^4.0.8",
|
||||
"electron-log": "^5.1.4",
|
||||
"electron-updater": "^6.1.8",
|
||||
"electron-log": "^5.2.0",
|
||||
"electron-updater": "^6.3.4",
|
||||
"fetch-cookie": "^3.0.1",
|
||||
"flexsearch": "^0.7.43",
|
||||
"i18next": "^23.11.2",
|
||||
@@ -72,6 +72,7 @@
|
||||
"react-redux": "^9.1.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"tar": "^7.4.3",
|
||||
"typeorm": "^0.3.20",
|
||||
"user-agents": "^1.1.193",
|
||||
"yaml": "^2.4.1",
|
||||
@@ -88,6 +89,7 @@
|
||||
"@swc/core": "^1.4.16",
|
||||
"@types/auto-launch": "^5.0.5",
|
||||
"@types/color": "^3.0.6",
|
||||
"@types/folder-hash": "^4.0.4",
|
||||
"@types/jsdom": "^21.1.6",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -99,7 +101,7 @@
|
||||
"@vanilla-extract/vite-plugin": "^4.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"electron": "^30.3.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"electron-builder": "^25.1.6",
|
||||
"electron-vite": "^2.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
|
||||
49
postinstall.cjs
Normal file
49
postinstall.cjs
Normal file
@@ -0,0 +1,49 @@
|
||||
const { default: axios } = require("axios");
|
||||
const util = require("node:util");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const exec = util.promisify(require("node:child_process").exec);
|
||||
|
||||
const fileName = {
|
||||
win32: "ludusavi-v0.25.0-win64.zip",
|
||||
linux: "ludusavi-v0.25.0-linux.zip",
|
||||
darwin: "ludusavi-v0.25.0-mac.zip",
|
||||
};
|
||||
|
||||
const downloadLudusavi = async () => {
|
||||
if (fs.existsSync("ludusavi")) {
|
||||
console.log("Ludusavi already exists, skipping download...");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileName[process.platform];
|
||||
const downloadUrl = `https://github.com/mtkennerly/ludusavi/releases/download/v0.25.0/${file}`;
|
||||
|
||||
console.log(`Downloading ${file}...`);
|
||||
|
||||
const response = await axios.get(downloadUrl, { responseType: "stream" });
|
||||
|
||||
const stream = response.data.pipe(fs.createWriteStream(file));
|
||||
|
||||
stream.on("finish", async () => {
|
||||
console.log(`Downloaded ${file}, extracting...`);
|
||||
|
||||
const pwd = process.cwd();
|
||||
|
||||
const targetPath = path.join(pwd, "ludusavi");
|
||||
|
||||
await exec(`npx extract-zip ${file} ${targetPath}`);
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
fs.chmodSync(path.join(targetPath, "ludusavi"), 0o755);
|
||||
}
|
||||
|
||||
console.log("Extracted. Renaming folder...");
|
||||
|
||||
console.log(`Extracted ${file}, removing compressed downloaded file...`);
|
||||
fs.rmSync(file);
|
||||
});
|
||||
};
|
||||
|
||||
downloadLudusavi();
|
||||
@@ -1,7 +1,7 @@
|
||||
libtorrent
|
||||
cx_Freeze
|
||||
cx_Logging; sys_platform == 'win32'
|
||||
lief; sys_platform == 'win32'
|
||||
pywin32; sys_platform == 'win32'
|
||||
psutil
|
||||
Pillow
|
||||
requests
|
||||
|
||||
BIN
resources/achievement-sound.mp3
Normal file
BIN
resources/achievement-sound.mp3
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -130,7 +130,22 @@
|
||||
"download": "Download",
|
||||
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
|
||||
"warning": "Warning:",
|
||||
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress."
|
||||
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util its conclusion. In case Hydra closes before the conclusion, you will lose your progress.",
|
||||
"achievements": "Achievements",
|
||||
"cloud_save": "Cloud save",
|
||||
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
|
||||
"backups": "Backups",
|
||||
"install_backup": "Install",
|
||||
"delete_backup": "Delete",
|
||||
"create_backup": "New backup",
|
||||
"last_backup_date": "Last backup on {{date}}",
|
||||
"no_backup_preview": "No save games were found for this title",
|
||||
"restoring_backup": "Restoring backup ({{progress}} complete)…",
|
||||
"uploading_backup": "Uploading backup…",
|
||||
"no_backups": "You haven't created any backups for this game yet",
|
||||
"backup_uploaded": "Backup uploaded",
|
||||
"backup_deleted": "Backup deleted",
|
||||
"backup_restored": "Backup restored"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activate Hydra",
|
||||
@@ -226,7 +241,9 @@
|
||||
"repack_count_one": "{{count}} repack added",
|
||||
"repack_count_other": "{{count}} repacks added",
|
||||
"new_update_available": "Version {{version}} available",
|
||||
"restart_to_install_update": "Restart Hydra to install the update"
|
||||
"restart_to_install_update": "Restart Hydra to install the update",
|
||||
"notification_achievement_unlocked_title": "Achievement unlocked for {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Open Hydra",
|
||||
@@ -313,5 +330,8 @@
|
||||
"report_reason_other": "Other",
|
||||
"profile_reported": "Profile reported",
|
||||
"your_friend_code": "Your friend code:"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement unlocked"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"trending": "Tendencias",
|
||||
"surprise_me": "¡Sorpréndeme!",
|
||||
"no_results": "No se encontraron resultados",
|
||||
"hot": "Popular Ahora",
|
||||
"weekly": "📅 Destacados de esta semana",
|
||||
"hot": "Caliente ahora",
|
||||
"weekly": "📅 Los mejores juegos de la semana",
|
||||
"start_typing": "Empieza a escribir para buscar..."
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -123,11 +123,11 @@
|
||||
"download": "Descargar",
|
||||
"download_count": "Descargas",
|
||||
"download_error": "Esta opción de descarga no está disponible.",
|
||||
"executable_path_in_use": "El ejecutable se encuentra en uso por \"{{game}}\"",
|
||||
"nsfw_content_description": "{{title}} puede ser no adecuado para todas las edades por su contenido. \n¿Deseas continuar de igual forma?",
|
||||
"executable_path_in_use": "Ejecutable ya en uso por \"{{game}}\"",
|
||||
"nsfw_content_description": "{{title}} incluye contenido que puede no ser adecuado para todas las edades. \n¿Estás seguro de que quieres continuar?",
|
||||
"nsfw_content_title": "Este juego contiene contenido inapropiado.",
|
||||
"player_count": "Jugadores activos",
|
||||
"refuse_nsfw_content": "No, gracias",
|
||||
"refuse_nsfw_content": "Volver",
|
||||
"stats": "Estadísticas"
|
||||
},
|
||||
"activation": {
|
||||
@@ -209,7 +209,7 @@
|
||||
"download_options_one": "",
|
||||
"download_options_other": "",
|
||||
"download_options_zero": "",
|
||||
"friends_only": "Solo amigos",
|
||||
"friends_only": "solo amigos",
|
||||
"must_be_valid_url": "La fuente debe ser una URL válida.",
|
||||
"privacy": "Privacidad",
|
||||
"private": "Privado",
|
||||
@@ -296,21 +296,21 @@
|
||||
"no_blocked_users": "No has bloqueado a ningún usuario",
|
||||
"friend_code_copied": "Código de amigo copiado",
|
||||
"undo_friendship_modal_text": "Esto deshará tu amistad con {{displayName}}",
|
||||
"displayname_max_length": "El nombre a mostrar debe tener como máximo 50 caracteres",
|
||||
"displayname_min_length": "El nombre a mostrar debe tener al menos 3 caracteres",
|
||||
"displayname_max_length": "El nombre para mostrar debe tener como máximo 50 caracteres",
|
||||
"displayname_min_length": "El nombre para mostrar debe tener al menos 3 caracteres",
|
||||
"locked_profile": "Este perfil es privado.",
|
||||
"privacy_hint": "Para ajustar quién puede ver esto, ve a <0>Configuración</0>.",
|
||||
"profile_locked": "Este perfil es privado",
|
||||
"profile_locked": "",
|
||||
"profile_reported": "Perfil reportado",
|
||||
"report": "Reportar",
|
||||
"report": "Informe",
|
||||
"report_description": "Información adicional",
|
||||
"report_description_placeholder": "Información adicional",
|
||||
"report_profile": "Reportar este perfil",
|
||||
"report_reason": "¿Cual es el motivo del reporte?",
|
||||
"report_reason_hate": "Discursos de odio",
|
||||
"report_reason": "¿Por qué estás denunciando este perfil?",
|
||||
"report_reason_hate": "Discurso de odio",
|
||||
"report_reason_other": "Otro",
|
||||
"report_reason_sexual_content": "Contenido sexual",
|
||||
"report_reason_spam": "Spam/Contenido no deseado",
|
||||
"report_reason_spam": "Correo basura",
|
||||
"report_reason_violence": "Violencia",
|
||||
"required_field": "Este campo es obligatorio",
|
||||
"image_process_failure": "Error al procesar la imagen"
|
||||
|
||||
@@ -126,7 +126,22 @@
|
||||
"download": "Baixar",
|
||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||
"warning": "Aviso:",
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
||||
"achievements": "Conquistas",
|
||||
"cloud_save": "Salvamento em nuvem",
|
||||
"cloud_save_description": "Matenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo",
|
||||
"backups": "Backups",
|
||||
"install_backup": "Restaurar",
|
||||
"delete_backup": "Apagar",
|
||||
"create_backup": "Novo backup",
|
||||
"last_backup_date": "Último backup em {{date}}",
|
||||
"no_backup_preview": "Não foi possível encontrar nenhum salvamento para este jogo",
|
||||
"restoring_backup": "Restaurando backup ({{progress}} concluído)…",
|
||||
"uploading_backup": "Criando backup…",
|
||||
"no_backups": "Você ainda não fez nenhum backup deste jogo",
|
||||
"backup_uploaded": "Backup criado",
|
||||
"backup_deleted": "Backup apagado",
|
||||
"backup_restored": "Backup restaurado"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -168,7 +183,7 @@
|
||||
"enable_download_notifications": "Quando um download for concluído",
|
||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
|
||||
"launch_with_system": "Iniciar o Hydra junto com o sistema",
|
||||
"general": "Geral",
|
||||
"behavior": "Comportamento",
|
||||
@@ -317,5 +332,8 @@
|
||||
"report_reason_other": "Outro",
|
||||
"profile_reported": "Perfil reportado",
|
||||
"your_friend_code": "Seu código de amigo:"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Conquista desbloqueada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
"download": "Transferir",
|
||||
"executable_path_in_use": "Executável em uso por \"{{game}}\"",
|
||||
"warning": "Aviso:",
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso."
|
||||
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
|
||||
"achievements": "Conquistas"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
@@ -157,7 +158,7 @@
|
||||
"enable_download_notifications": "Quando uma transferência for concluída",
|
||||
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
|
||||
"real_debrid_api_token_label": "Token de API do Real-Debrid",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
|
||||
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",
|
||||
"launch_with_system": "Iniciar o Hydra com o sistema",
|
||||
"general": "Geral",
|
||||
"behavior": "Comportamento",
|
||||
@@ -277,5 +278,8 @@
|
||||
"friend_code_copied": "Código de amigo copiado",
|
||||
"image_process_failure": "Falha ao processar a imagem",
|
||||
"your_friend_code": "Seu código de amigo:"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Conquista desbloqueada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import path from "node:path";
|
||||
export const defaultDownloadsPath = app.getPath("downloads");
|
||||
|
||||
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
|
||||
export const databasePath = path.join(databaseDirectory, "hydra.db");
|
||||
export const databasePath = path.join(
|
||||
databaseDirectory,
|
||||
import.meta.env.MAIN_VITE_API_URL.includes("staging")
|
||||
? "hydra_test.db"
|
||||
: "hydra.db"
|
||||
);
|
||||
|
||||
export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
|
||||
|
||||
@@ -12,4 +17,6 @@ export const seedsPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "seeds")
|
||||
: path.join(__dirname, "..", "..", "seeds");
|
||||
|
||||
export const backupsPath = path.join(app.getPath("userData"), "Backups");
|
||||
|
||||
export const appVersion = app.getVersion();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Repack,
|
||||
UserPreferences,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
} from "@main/entity";
|
||||
|
||||
import { databasePath } from "./constants";
|
||||
@@ -21,6 +22,7 @@ export const dataSource = new DataSource({
|
||||
DownloadSource,
|
||||
DownloadQueue,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
],
|
||||
synchronize: false,
|
||||
database: databasePath,
|
||||
|
||||
19
src/main/entity/game-achievements.entity.ts
Normal file
19
src/main/entity/game-achievements.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("game_achievement")
|
||||
export class GameAchievement {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column("text")
|
||||
objectId: string;
|
||||
|
||||
@Column("text")
|
||||
shop: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
unlockedAchievements: string;
|
||||
|
||||
@Column("text", { nullable: true })
|
||||
achievements: string;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ export * from "./game.entity";
|
||||
export * from "./repack.entity";
|
||||
export * from "./user-preferences.entity";
|
||||
export * from "./game-shop-cache.entity";
|
||||
export * from "./game.entity";
|
||||
export * from "./game-achievements.entity";
|
||||
export * from "./download-source.entity";
|
||||
export * from "./download-queue.entity";
|
||||
export * from "./user-auth";
|
||||
|
||||
@@ -30,7 +30,7 @@ const getCatalogue = async (
|
||||
title: steamGame.name,
|
||||
shop: game.shop,
|
||||
cover: steamUrlBuilder.library(game.objectId),
|
||||
objectID: game.objectId,
|
||||
objectId: game.objectId,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
51
src/main/events/catalogue/get-game-achievements.ts
Normal file
51
src/main/events/catalogue/get-game-achievements.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { GameAchievement, GameShop } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gameAchievementRepository } from "@main/repository";
|
||||
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
|
||||
|
||||
const getGameAchievements = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
): Promise<GameAchievement[]> => {
|
||||
const cachedAchievements = await gameAchievementRepository.findOne({
|
||||
where: { objectId, shop },
|
||||
});
|
||||
|
||||
const achievementsData = cachedAchievements?.achievements
|
||||
? JSON.parse(cachedAchievements.achievements)
|
||||
: await getGameAchievementData(objectId, shop);
|
||||
|
||||
const unlockedAchievements = JSON.parse(
|
||||
cachedAchievements?.unlockedAchievements || "[]"
|
||||
) as { name: string; unlockTime: number }[];
|
||||
|
||||
return achievementsData
|
||||
.map((achievementData) => {
|
||||
const unlockedAchiement = unlockedAchievements.find(
|
||||
(localAchievement) => {
|
||||
return (
|
||||
localAchievement.name.toUpperCase() ==
|
||||
achievementData.name.toUpperCase()
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (unlockedAchiement) {
|
||||
return {
|
||||
...achievementData,
|
||||
unlocked: true,
|
||||
unlockTime: unlockedAchiement.unlockTime,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...achievementData, unlocked: false, unlockTime: null };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.unlocked && !b.unlocked) return -1;
|
||||
if (!a.unlocked && b.unlocked) return 1;
|
||||
return b.unlockTime - a.unlockTime;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getGameAchievements", getGameAchievements);
|
||||
@@ -7,16 +7,16 @@ import { registerEvent } from "../register-event";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
|
||||
const getLocalizedSteamAppDetails = async (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
language: string
|
||||
): Promise<ShopDetails | null> => {
|
||||
if (language === "english") {
|
||||
return getSteamAppDetails(objectID, language);
|
||||
return getSteamAppDetails(objectId, language);
|
||||
}
|
||||
|
||||
return getSteamAppDetails(objectID, language).then(
|
||||
return getSteamAppDetails(objectId, language).then(
|
||||
async (localizedAppDetails) => {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
@@ -34,21 +34,21 @@ const getLocalizedSteamAppDetails = async (
|
||||
|
||||
const getGameShopDetails = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
language: string
|
||||
): Promise<ShopDetails | null> => {
|
||||
if (shop === "steam") {
|
||||
const cachedData = await gameShopCacheRepository.findOne({
|
||||
where: { objectID, language },
|
||||
where: { objectID: objectId, language },
|
||||
});
|
||||
|
||||
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
|
||||
const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
|
||||
(result) => {
|
||||
if (result) {
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
shop: "steam",
|
||||
language,
|
||||
serializedData: JSON.stringify(result),
|
||||
@@ -68,7 +68,7 @@ const getGameShopDetails = async (
|
||||
if (cachedGame) {
|
||||
return {
|
||||
...cachedGame,
|
||||
objectID,
|
||||
objectId,
|
||||
} as ShopDetails;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,11 @@ const getGameStats = async (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
objectId,
|
||||
shop,
|
||||
});
|
||||
|
||||
const response = await HydraApi.get<GameStats>(
|
||||
`/games/stats?${params.toString()}`
|
||||
return HydraApi.get<GameStats>(
|
||||
`/games/stats`,
|
||||
{ objectId, shop },
|
||||
{ needsAuth: false }
|
||||
);
|
||||
return response;
|
||||
};
|
||||
|
||||
registerEvent("getGameStats", getGameStats);
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import type { CatalogueEntry } from "@types";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
|
||||
const getGames = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
take = 12,
|
||||
cursor = 0
|
||||
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
|
||||
const steamGames = await steamGamesWorker.run(
|
||||
{ limit: take, offset: cursor },
|
||||
{ name: "list" }
|
||||
skip = 0
|
||||
): Promise<CatalogueEntry[]> => {
|
||||
const searchParams = new URLSearchParams({
|
||||
take: take.toString(),
|
||||
skip: skip.toString(),
|
||||
});
|
||||
|
||||
const games = await HydraApi.get<CatalogueEntry[]>(
|
||||
`/games/catalogue?${searchParams.toString()}`,
|
||||
undefined,
|
||||
{ needsAuth: false }
|
||||
);
|
||||
|
||||
return {
|
||||
results: steamGames.map((steamGame) => ({
|
||||
title: steamGame.name,
|
||||
shop: "steam",
|
||||
cover: steamUrlBuilder.library(steamGame.id),
|
||||
objectID: steamGame.id,
|
||||
})),
|
||||
cursor: cursor + steamGames.length,
|
||||
};
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
cover: steamUrlBuilder.library(game.objectId),
|
||||
}));
|
||||
};
|
||||
|
||||
registerEvent("getGames", getGames);
|
||||
|
||||
@@ -6,14 +6,14 @@ import { gameShopCacheRepository } from "@main/repository";
|
||||
|
||||
const getHowLongToBeat = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
title: string
|
||||
): Promise<HowLongToBeatCategory[] | null> => {
|
||||
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
|
||||
|
||||
const gameShopCache = await gameShopCacheRepository.findOne({
|
||||
where: { objectID, shop },
|
||||
where: { objectID: objectId, shop },
|
||||
});
|
||||
|
||||
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
|
||||
@@ -23,7 +23,7 @@ const getHowLongToBeat = async (
|
||||
|
||||
return searchHowLongToBeatPromise.then(async (response) => {
|
||||
const game = response.data.find(
|
||||
(game) => game.profile_steam === Number(objectID)
|
||||
(game) => game.profile_steam === Number(objectId)
|
||||
);
|
||||
|
||||
if (!game) return null;
|
||||
@@ -31,7 +31,7 @@ const getHowLongToBeat = async (
|
||||
|
||||
gameShopCacheRepository.upsert(
|
||||
{
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
shop,
|
||||
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
|
||||
},
|
||||
|
||||
14
src/main/events/cloud-save/check-game-cloud-sync-support.ts
Normal file
14
src/main/events/cloud-save/check-game-cloud-sync-support.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameShop } from "@types";
|
||||
import { Ludusavi } from "@main/services";
|
||||
|
||||
const checkGameCloudSyncSupport = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const games = await Ludusavi.findGames(shop, objectId);
|
||||
return games.length === 1;
|
||||
};
|
||||
|
||||
registerEvent("checkGameCloudSyncSupport", checkGameCloudSyncSupport);
|
||||
12
src/main/events/cloud-save/delete-game-artifact.ts
Normal file
12
src/main/events/cloud-save/delete-game-artifact.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const deleteGameArtifact = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
gameArtifactId: string
|
||||
) =>
|
||||
HydraApi.delete<{ ok: boolean }>(
|
||||
`/profile/games/artifacts/${gameArtifactId}`
|
||||
);
|
||||
|
||||
registerEvent("deleteGameArtifact", deleteGameArtifact);
|
||||
139
src/main/events/cloud-save/download-game-artifact.ts
Normal file
139
src/main/events/cloud-save/download-game-artifact.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
|
||||
import fs from "node:fs";
|
||||
import * as tar from "tar";
|
||||
import { registerEvent } from "../register-event";
|
||||
import axios from "axios";
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
import { backupsPath } from "@main/constants";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
import YAML from "yaml";
|
||||
|
||||
export interface LudusaviBackup {
|
||||
files: {
|
||||
[key: string]: {
|
||||
hash: string;
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const replaceLudusaviBackupWithCurrentUser = (
|
||||
gameBackupPath: string,
|
||||
backupHomeDir: string
|
||||
) => {
|
||||
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
|
||||
|
||||
const data = fs.readFileSync(mappingYamlPath, "utf8");
|
||||
const manifest = YAML.parse(data) as {
|
||||
backups: LudusaviBackup[];
|
||||
drives: Record<string, string>;
|
||||
};
|
||||
|
||||
const currentHomeDir = app.getPath("home");
|
||||
|
||||
// TODO: Only works on Windows
|
||||
const usersDirPath = path.join(gameBackupPath, "drive-C", "Users");
|
||||
|
||||
const oldPath = path.join(usersDirPath, path.basename(backupHomeDir));
|
||||
const newPath = path.join(usersDirPath, path.basename(currentHomeDir));
|
||||
|
||||
// Directories are different, rename
|
||||
if (backupHomeDir !== currentHomeDir) {
|
||||
if (fs.existsSync(newPath)) {
|
||||
fs.rmSync(newPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
fs.renameSync(oldPath, newPath);
|
||||
}
|
||||
|
||||
const backups = manifest.backups.map((backup: LudusaviBackup) => {
|
||||
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
|
||||
return {
|
||||
...prev,
|
||||
[key.replace(backupHomeDir, currentHomeDir)]: value,
|
||||
};
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...backup,
|
||||
files,
|
||||
};
|
||||
});
|
||||
|
||||
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
|
||||
};
|
||||
|
||||
const downloadGameArtifact = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
gameArtifactId: string
|
||||
) => {
|
||||
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
|
||||
downloadUrl: string;
|
||||
objectKey: string;
|
||||
homeDir: string;
|
||||
}>(`/profile/games/artifacts/${gameArtifactId}/download`);
|
||||
|
||||
const zipLocation = path.join(app.getPath("userData"), objectKey);
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.rmSync(backupPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios.get(downloadUrl, {
|
||||
responseType: "stream",
|
||||
onDownloadProgress: (progressEvent) => {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-backup-download-progress-${objectId}-${shop}`,
|
||||
progressEvent
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(zipLocation);
|
||||
|
||||
response.data.pipe(writer);
|
||||
|
||||
writer.on("error", (err) => {
|
||||
logger.error("Failed to write zip", err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
fs.mkdirSync(backupPath, { recursive: true });
|
||||
|
||||
writer.on("close", () => {
|
||||
tar
|
||||
.x({
|
||||
file: zipLocation,
|
||||
cwd: backupPath,
|
||||
})
|
||||
.then(async () => {
|
||||
const [game] = await Ludusavi.findGames(shop, objectId);
|
||||
if (!game) throw new Error("Game not found in Ludusavi manifest");
|
||||
|
||||
replaceLudusaviBackupWithCurrentUser(
|
||||
path.join(backupPath, game),
|
||||
path.normalize(homeDir).replace(/\\/g, "/")
|
||||
);
|
||||
|
||||
Ludusavi.restoreBackup(backupPath).then(() => {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-backup-download-complete-${objectId}-${shop}`,
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("downloadGameArtifact", downloadGameArtifact);
|
||||
20
src/main/events/cloud-save/get-game-artifacts.ts
Normal file
20
src/main/events/cloud-save/get-game-artifacts.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { HydraApi } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import type { GameArtifact, GameShop } from "@types";
|
||||
|
||||
const getGameArtifacts = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
objectId,
|
||||
shop,
|
||||
});
|
||||
|
||||
return HydraApi.get<GameArtifact[]>(
|
||||
`/profile/games/artifacts?${params.toString()}`
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("getGameArtifacts", getGameArtifacts);
|
||||
17
src/main/events/cloud-save/get-game-backup-preview.ts
Normal file
17
src/main/events/cloud-save/get-game-backup-preview.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { GameShop } from "@types";
|
||||
import { Ludusavi } from "@main/services";
|
||||
import path from "node:path";
|
||||
import { backupsPath } from "@main/constants";
|
||||
|
||||
const getGameBackupPreview = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
return Ludusavi.getBackupPreview(shop, objectId, backupPath);
|
||||
};
|
||||
|
||||
registerEvent("getGameBackupPreview", getGameBackupPreview);
|
||||
87
src/main/events/cloud-save/upload-save-game.ts
Normal file
87
src/main/events/cloud-save/upload-save-game.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
|
||||
import { registerEvent } from "../register-event";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import crypto from "node:crypto";
|
||||
import { GameShop } from "@types";
|
||||
import axios from "axios";
|
||||
import os from "node:os";
|
||||
import { backupsPath } from "@main/constants";
|
||||
import { app } from "electron";
|
||||
|
||||
const bundleBackup = async (shop: GameShop, objectId: string) => {
|
||||
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
|
||||
|
||||
await Ludusavi.backupGame(shop, objectId, backupPath);
|
||||
|
||||
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.zip`);
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
gzip: false,
|
||||
file: tarLocation,
|
||||
cwd: backupPath,
|
||||
},
|
||||
["."]
|
||||
);
|
||||
|
||||
return tarLocation;
|
||||
};
|
||||
|
||||
const uploadSaveGame = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
const bundleLocation = await bundleBackup(shop, objectId);
|
||||
|
||||
fs.stat(bundleLocation, async (err, stat) => {
|
||||
if (err) {
|
||||
logger.error("Failed to get zip file stats", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { uploadUrl } = await HydraApi.post<{
|
||||
id: string;
|
||||
uploadUrl: string;
|
||||
}>("/profile/games/artifacts", {
|
||||
artifactLengthInBytes: stat.size,
|
||||
shop,
|
||||
objectId,
|
||||
hostname: os.hostname(),
|
||||
homeDir: path.normalize(app.getPath("home")).replace(/\\/g, "/"),
|
||||
platform: os.platform(),
|
||||
});
|
||||
|
||||
fs.readFile(bundleLocation, async (err, fileBuffer) => {
|
||||
if (err) {
|
||||
logger.error("Failed to read zip file", err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await axios.put(uploadUrl, fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/tar",
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
console.log(progressEvent);
|
||||
},
|
||||
});
|
||||
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
`on-upload-complete-${objectId}-${shop}`,
|
||||
true
|
||||
);
|
||||
|
||||
fs.rm(bundleLocation, (err) => {
|
||||
if (err) {
|
||||
logger.error("Failed to remove tar file", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("uploadSaveGame", uploadSaveGame);
|
||||
@@ -12,7 +12,7 @@ export interface SearchGamesArgs {
|
||||
export const convertSteamGameToCatalogueEntry = (
|
||||
game: SteamGame
|
||||
): CatalogueEntry => ({
|
||||
objectID: String(game.id),
|
||||
objectId: String(game.id),
|
||||
title: game.name,
|
||||
shop: "steam" as GameShop,
|
||||
cover: steamUrlBuilder.library(String(game.id)),
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./catalogue/get-random-game";
|
||||
import "./catalogue/search-games";
|
||||
import "./catalogue/get-game-stats";
|
||||
import "./catalogue/get-trending-games";
|
||||
import "./catalogue/get-game-achievements";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./library/add-game-to-library";
|
||||
import "./library/create-game-shortcut";
|
||||
@@ -56,6 +57,12 @@ import "./profile/update-profile";
|
||||
import "./profile/process-profile-image";
|
||||
import "./profile/send-friend-request";
|
||||
import "./profile/sync-friend-requests";
|
||||
import "./cloud-save/download-game-artifact";
|
||||
import "./cloud-save/get-game-artifacts";
|
||||
import "./cloud-save/get-game-backup-preview";
|
||||
import "./cloud-save/upload-save-game";
|
||||
import "./cloud-save/check-game-cloud-sync-support";
|
||||
import "./cloud-save/delete-game-artifact";
|
||||
import "./notifications/publish-new-repacks-notification";
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
|
||||
@@ -7,17 +7,18 @@ import type { GameShop } from "@types";
|
||||
import { steamGamesWorker } from "@main/workers";
|
||||
import { createGame } from "@main/services/library-sync";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
|
||||
|
||||
const addGameToLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
title: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
return gameRepository
|
||||
.update(
|
||||
{
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
},
|
||||
{
|
||||
shop,
|
||||
@@ -27,23 +28,27 @@ const addGameToLibrary = async (
|
||||
)
|
||||
.then(async ({ affected }) => {
|
||||
if (!affected) {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
await gameRepository.insert({
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
shop,
|
||||
});
|
||||
}
|
||||
|
||||
const game = await gameRepository.findOne({ where: { objectID } });
|
||||
const game = await gameRepository.findOne({
|
||||
where: { objectID: objectId },
|
||||
});
|
||||
|
||||
updateLocalUnlockedAchivements(game!);
|
||||
|
||||
createGame(game!).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,15 @@ import { gameRepository } from "@main/repository";
|
||||
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const getGameByObjectID = async (
|
||||
const getGameByObjectId = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectID: string
|
||||
objectId: string
|
||||
) =>
|
||||
gameRepository.findOne({
|
||||
where: {
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
registerEvent("getGameByObjectID", getGameByObjectID);
|
||||
registerEvent("getGameByObjectId", getGameByObjectId);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import { FriendRequestSync } from "@types";
|
||||
|
||||
const syncFriendRequests = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`);
|
||||
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`).catch(
|
||||
(err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return { friendRequests: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("syncFriendRequests", syncFriendRequests);
|
||||
|
||||
@@ -14,7 +14,7 @@ const startGameDownload = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
payload: StartGameDownloadPayload
|
||||
) => {
|
||||
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
|
||||
const { objectId, title, shop, downloadPath, downloader, uri } = payload;
|
||||
|
||||
return dataSource.transaction(async (transactionalEntityManager) => {
|
||||
const gameRepository = transactionalEntityManager.getRepository(Game);
|
||||
@@ -23,7 +23,7 @@ const startGameDownload = async (
|
||||
|
||||
const game = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
shop,
|
||||
},
|
||||
});
|
||||
@@ -51,18 +51,18 @@ const startGameDownload = async (
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectID), {
|
||||
const steamGame = await steamGamesWorker.run(Number(objectId), {
|
||||
name: "getById",
|
||||
});
|
||||
|
||||
const iconUrl = steamGame?.clientIcon
|
||||
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
|
||||
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
|
||||
: null;
|
||||
|
||||
await gameRepository.insert({
|
||||
title,
|
||||
iconUrl,
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
downloader,
|
||||
shop,
|
||||
status: "active",
|
||||
@@ -73,7 +73,7 @@ const startGameDownload = async (
|
||||
|
||||
const updatedGame = await gameRepository.findOne({
|
||||
where: {
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { UserNotLoggedInError } from "@shared";
|
||||
import { UserBlocks } from "@types";
|
||||
|
||||
export const getBlockedUsers = async (
|
||||
@@ -7,7 +8,12 @@ export const getBlockedUsers = async (
|
||||
take: number,
|
||||
skip: number
|
||||
): Promise<UserBlocks> => {
|
||||
return HydraApi.get(`/profile/blocks`, { take, skip });
|
||||
return HydraApi.get(`/profile/blocks`, { take, skip }).catch((err) => {
|
||||
if (err instanceof UserNotLoggedInError) {
|
||||
return { blocks: [] };
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("getBlockedUsers", getBlockedUsers);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, net, protocol, session } from "electron";
|
||||
import { app, BrowserWindow, net, protocol } from "electron";
|
||||
import { init } from "@sentry/electron/main";
|
||||
import updater from "electron-updater";
|
||||
import i18n from "i18next";
|
||||
@@ -102,47 +102,8 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
WindowManager.createMainWindow();
|
||||
WindowManager.createNotificationWindow();
|
||||
WindowManager.createSystemTray(userPreferences?.language || "en");
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
callback({
|
||||
requestHeaders: {
|
||||
...details.requestHeaders,
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
const headers = {
|
||||
"access-control-allow-origin": ["*"],
|
||||
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
|
||||
"access-control-expose-headers": ["ETag"],
|
||||
"access-control-allow-headers": [
|
||||
"Content-Type, Authorization, X-Requested-With, If-None-Match",
|
||||
],
|
||||
"access-control-allow-credentials": ["true"],
|
||||
};
|
||||
|
||||
if (details.method === "OPTIONS") {
|
||||
callback({
|
||||
cancel: false,
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
statusLine: "HTTP/1.1 200 OK",
|
||||
});
|
||||
} else {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_lang
|
||||
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
|
||||
import { app } from "electron";
|
||||
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
|
||||
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
|
||||
|
||||
export type HydraMigration = Knex.Migration & { name: string };
|
||||
|
||||
@@ -17,6 +18,7 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
|
||||
UpdateUserLanguage,
|
||||
EnsureRepackUris,
|
||||
FixMissingColumns,
|
||||
CreateGameAchievement,
|
||||
]);
|
||||
}
|
||||
getMigrationName(migration: HydraMigration): string {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { HydraMigration } from "@main/knex-client";
|
||||
import type { Knex } from "knex";
|
||||
|
||||
export const CreateGameAchievement: HydraMigration = {
|
||||
name: "CreateGameAchievement",
|
||||
up: (knex: Knex) => {
|
||||
return knex.schema.createTable("game_achievement", (table) => {
|
||||
table.increments("id").primary();
|
||||
table.text("objectId").notNullable();
|
||||
table.text("shop").notNullable();
|
||||
table.text("achievements");
|
||||
table.text("unlockedAchievements");
|
||||
table.unique(["objectId", "shop"]);
|
||||
});
|
||||
},
|
||||
|
||||
down: (knex: Knex) => {
|
||||
return knex.schema.dropTable("game_achievement");
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Repack,
|
||||
UserPreferences,
|
||||
UserAuth,
|
||||
GameAchievement,
|
||||
} from "@main/entity";
|
||||
|
||||
export const gameRepository = dataSource.getRepository(Game);
|
||||
@@ -24,3 +25,6 @@ export const downloadSourceRepository =
|
||||
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
|
||||
|
||||
export const userAuthRepository = dataSource.getRepository(UserAuth);
|
||||
|
||||
export const gameAchievementRepository =
|
||||
dataSource.getRepository(GameAchievement);
|
||||
|
||||
123
src/main/services/achievements/achievement-watcher.ts
Normal file
123
src/main/services/achievements/achievement-watcher.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { parseAchievementFile } from "./parse-achievement-file";
|
||||
import { Game } from "@main/entity";
|
||||
import { mergeAchievements } from "./merge-achievements";
|
||||
import fs, { readdirSync } from "node:fs";
|
||||
import {
|
||||
findAchievementFileInExecutableDirectory,
|
||||
findAllAchievementFiles,
|
||||
getAlternativeObjectIds,
|
||||
} from "./find-achivement-files";
|
||||
import type { AchievementFile } from "@types";
|
||||
import { achievementsLogger, logger } from "../logger";
|
||||
import { Cracker } from "@shared";
|
||||
|
||||
const fileStats: Map<string, number> = new Map();
|
||||
const fltFiles: Map<string, Set<string>> = new Map();
|
||||
|
||||
export const watchAchievements = async () => {
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (games.length === 0) return;
|
||||
|
||||
const achievementFiles = findAllAchievementFiles();
|
||||
|
||||
for (const game of games) {
|
||||
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||
const gameAchievementFiles = achievementFiles.get(objectId) || [];
|
||||
const achievementFileInsideDirectory =
|
||||
findAchievementFileInExecutableDirectory(game);
|
||||
|
||||
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||
|
||||
if (!gameAchievementFiles.length) continue;
|
||||
|
||||
console.log(
|
||||
"Achievements files to observe for:",
|
||||
game.title,
|
||||
gameAchievementFiles
|
||||
);
|
||||
|
||||
for (const file of gameAchievementFiles) {
|
||||
compareFile(game, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processAchievementFileDiff = async (
|
||||
game: Game,
|
||||
file: AchievementFile
|
||||
) => {
|
||||
const unlockedAchievements = parseAchievementFile(file.filePath, file.type);
|
||||
|
||||
logger.log("Achievements from file", file.filePath, unlockedAchievements);
|
||||
|
||||
if (unlockedAchievements.length) {
|
||||
return mergeAchievements(
|
||||
game.objectID,
|
||||
game.shop,
|
||||
unlockedAchievements,
|
||||
true
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const compareFltFolder = async (game: Game, file: AchievementFile) => {
|
||||
try {
|
||||
const currentAchievements = new Set(readdirSync(file.filePath));
|
||||
const previousAchievements = fltFiles.get(file.filePath);
|
||||
|
||||
fltFiles.set(file.filePath, currentAchievements);
|
||||
if (
|
||||
!previousAchievements ||
|
||||
currentAchievements.difference(previousAchievements).size === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("Detected change in FLT folder", file.filePath);
|
||||
await processAchievementFileDiff(game, file);
|
||||
} catch (err) {
|
||||
achievementsLogger.error(err);
|
||||
fltFiles.set(file.filePath, new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const compareFile = async (game: Game, file: AchievementFile) => {
|
||||
if (file.type === Cracker.flt) {
|
||||
await compareFltFolder(game, file);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentStat = fs.statSync(file.filePath);
|
||||
const previousStat = fileStats.get(file.filePath);
|
||||
fileStats.set(file.filePath, currentStat.mtimeMs);
|
||||
|
||||
if (!previousStat) {
|
||||
if (currentStat.mtimeMs) {
|
||||
await processAchievementFileDiff(game, file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (previousStat === currentStat.mtimeMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(
|
||||
"Detected change in file",
|
||||
file.filePath,
|
||||
currentStat.mtimeMs,
|
||||
fileStats.get(file.filePath)
|
||||
);
|
||||
await processAchievementFileDiff(game, file);
|
||||
} catch (err) {
|
||||
fileStats.set(file.filePath, -1);
|
||||
}
|
||||
};
|
||||
264
src/main/services/achievements/find-achivement-files.ts
Normal file
264
src/main/services/achievements/find-achivement-files.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { app } from "electron";
|
||||
import type { AchievementFile } from "@types";
|
||||
import { Cracker } from "@shared";
|
||||
import { Game } from "@main/entity";
|
||||
import { achievementsLogger } from "../logger";
|
||||
|
||||
//TODO: change to a automatized method
|
||||
const publicDocuments = path.join("C:", "Users", "Public", "Documents");
|
||||
const programData = path.join("C:", "ProgramData");
|
||||
const appData = app.getPath("appData");
|
||||
const documents = app.getPath("documents");
|
||||
const localAppData = path.join(appData, "..", "Local");
|
||||
|
||||
const crackers = [
|
||||
Cracker.codex,
|
||||
Cracker.goldberg,
|
||||
Cracker.rune,
|
||||
Cracker.onlineFix,
|
||||
Cracker.userstats,
|
||||
Cracker.rld,
|
||||
Cracker.creamAPI,
|
||||
Cracker.skidrow,
|
||||
Cracker.smartSteamEmu,
|
||||
Cracker.empress,
|
||||
Cracker.flt,
|
||||
];
|
||||
|
||||
const getPathFromCracker = (cracker: Cracker) => {
|
||||
if (cracker === Cracker.codex) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(publicDocuments, "Steam", "CODEX"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(appData, "Steam", "CODEX"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.rune) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(publicDocuments, "Steam", "RUNE"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.onlineFix) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(publicDocuments, Cracker.onlineFix),
|
||||
fileLocation: ["Stats", "Achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.goldberg) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "Goldberg SteamEmu Saves"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(appData, "GSE Saves"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.userstats) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.rld) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(programData, "RLD!"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(programData, "Steam", "Player"),
|
||||
fileLocation: ["stats", "achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(programData, "Steam", "dodi"),
|
||||
fileLocation: ["stats", "achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.empress) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "EMPRESS", "remote"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(publicDocuments, "EMPRESS", "remote"),
|
||||
fileLocation: ["achievements.json"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.skidrow) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(documents, "SKIDROW"),
|
||||
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(documents, "Player"),
|
||||
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(localAppData, "SKIDROW"),
|
||||
fileLocation: ["SteamEmu", "UserStats", "achiev.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.creamAPI) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "CreamAPI"),
|
||||
fileLocation: ["stats", "CreamAPI.Achievements.cfg"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.smartSteamEmu) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "SmartSteamEmu"),
|
||||
fileLocation: ["User", "Achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker === Cracker._3dm) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (cracker === Cracker.flt) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "FLT"),
|
||||
fileLocation: ["stats"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (cracker == Cracker.rle) {
|
||||
return [
|
||||
{
|
||||
folderPath: path.join(appData, "RLE"),
|
||||
fileLocation: ["achievements.ini"],
|
||||
},
|
||||
{
|
||||
folderPath: path.join(appData, "RLE"),
|
||||
fileLocation: ["Achievements.ini"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
achievementsLogger.error(`Cracker ${cracker} not implemented`);
|
||||
throw new Error(`Cracker ${cracker} not implemented`);
|
||||
};
|
||||
|
||||
export const getAlternativeObjectIds = (objectId: string) => {
|
||||
// Dishonored
|
||||
if (objectId === "205100") {
|
||||
return ["205100", "217980", "31292"];
|
||||
}
|
||||
|
||||
return [objectId];
|
||||
};
|
||||
|
||||
export const findAchievementFiles = (game: Game) => {
|
||||
const achievementFiles: AchievementFile[] = [];
|
||||
|
||||
for (const cracker of crackers) {
|
||||
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
|
||||
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||
const filePath = path.join(folderPath, objectId, ...fileLocation);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
achievementFiles.push({
|
||||
type: cracker,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return achievementFiles;
|
||||
};
|
||||
|
||||
export const findAchievementFileInExecutableDirectory = (
|
||||
game: Game
|
||||
): AchievementFile[] => {
|
||||
if (!game.executablePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: Cracker.userstats,
|
||||
filePath: path.join(
|
||||
game.executablePath,
|
||||
"..",
|
||||
"SteamData",
|
||||
"user_stats.ini"
|
||||
),
|
||||
},
|
||||
{
|
||||
type: Cracker._3dm,
|
||||
filePath: path.join(
|
||||
game.executablePath,
|
||||
"..",
|
||||
"3DMGAME",
|
||||
"Player",
|
||||
"stats",
|
||||
"achievements.ini"
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const findAllAchievementFiles = () => {
|
||||
const gameAchievementFiles = new Map<string, AchievementFile[]>();
|
||||
|
||||
for (const cracker of crackers) {
|
||||
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
|
||||
if (!fs.existsSync(folderPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectIds = fs.readdirSync(folderPath);
|
||||
|
||||
for (const objectId of objectIds) {
|
||||
const filePath = path.join(folderPath, objectId, ...fileLocation);
|
||||
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const achivementFile = {
|
||||
type: cracker,
|
||||
filePath,
|
||||
};
|
||||
|
||||
gameAchievementFiles.get(objectId)
|
||||
? gameAchievementFiles.get(objectId)!.push(achivementFile)
|
||||
: gameAchievementFiles.set(objectId, [achivementFile]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gameAchievementFiles;
|
||||
};
|
||||
33
src/main/services/achievements/get-game-achievement-data.ts
Normal file
33
src/main/services/achievements/get-game-achievement-data.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
gameAchievementRepository,
|
||||
userPreferencesRepository,
|
||||
} from "@main/repository";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
|
||||
export const getGameAchievementData = async (
|
||||
objectId: string,
|
||||
shop: string
|
||||
) => {
|
||||
const userPreferences = await userPreferencesRepository.findOne({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
return HydraApi.get("/games/achievements", {
|
||||
shop,
|
||||
objectId,
|
||||
language: userPreferences?.language || "en",
|
||||
})
|
||||
.then(async (achievements) => {
|
||||
await gameAchievementRepository.upsert(
|
||||
{
|
||||
objectId,
|
||||
shop,
|
||||
achievements: JSON.stringify(achievements),
|
||||
},
|
||||
["objectId", "shop"]
|
||||
);
|
||||
|
||||
return achievements;
|
||||
})
|
||||
.catch(() => []);
|
||||
};
|
||||
118
src/main/services/achievements/merge-achievements.ts
Normal file
118
src/main/services/achievements/merge-achievements.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { gameAchievementRepository, gameRepository } from "@main/repository";
|
||||
import type { GameShop, UnlockedAchievement } from "@types";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
|
||||
const saveAchievementsOnLocal = async (
|
||||
objectId: string,
|
||||
shop: string,
|
||||
achievements: any[]
|
||||
) => {
|
||||
return gameAchievementRepository
|
||||
.upsert(
|
||||
{
|
||||
objectId,
|
||||
shop,
|
||||
unlockedAchievements: JSON.stringify(achievements),
|
||||
},
|
||||
["objectId", "shop"]
|
||||
)
|
||||
.then(() => {
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-achievement-unlocked",
|
||||
objectId,
|
||||
shop
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const mergeAchievements = async (
|
||||
objectId: string,
|
||||
shop: string,
|
||||
achievements: UnlockedAchievement[],
|
||||
publishNotification: boolean
|
||||
) => {
|
||||
const game = await gameRepository.findOne({
|
||||
where: { objectID: objectId, shop: shop as GameShop },
|
||||
});
|
||||
|
||||
if (!game) return;
|
||||
|
||||
const localGameAchievement = await gameAchievementRepository.findOne({
|
||||
where: {
|
||||
objectId,
|
||||
shop,
|
||||
},
|
||||
});
|
||||
|
||||
const unlockedAchievements = JSON.parse(
|
||||
localGameAchievement?.unlockedAchievements || "[]"
|
||||
).filter((achievement) => achievement.name);
|
||||
|
||||
const newAchievements = achievements
|
||||
.filter((achievement) => {
|
||||
return !unlockedAchievements.some((localAchievement) => {
|
||||
return (
|
||||
localAchievement.name.toUpperCase() === achievement.name.toUpperCase()
|
||||
);
|
||||
});
|
||||
})
|
||||
.map((achievement) => {
|
||||
return {
|
||||
name: achievement.name.toUpperCase(),
|
||||
unlockTime: achievement.unlockTime,
|
||||
};
|
||||
});
|
||||
|
||||
if (newAchievements.length && publishNotification) {
|
||||
const achievementsInfo = newAchievements
|
||||
.sort((a, b) => {
|
||||
return a.unlockTime - b.unlockTime;
|
||||
})
|
||||
.map((achievement) => {
|
||||
return JSON.parse(localGameAchievement?.achievements || "[]").find(
|
||||
(steamAchievement) => {
|
||||
return (
|
||||
achievement.name.toUpperCase() ===
|
||||
steamAchievement.name.toUpperCase()
|
||||
);
|
||||
}
|
||||
);
|
||||
})
|
||||
.filter((achievement) => achievement)
|
||||
.map((achievement) => {
|
||||
return {
|
||||
displayName: achievement.displayName,
|
||||
iconUrl: achievement.icon,
|
||||
};
|
||||
});
|
||||
|
||||
WindowManager.notificationWindow?.webContents.send(
|
||||
"on-achievement-unlocked",
|
||||
objectId,
|
||||
shop,
|
||||
achievementsInfo
|
||||
);
|
||||
}
|
||||
|
||||
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
|
||||
|
||||
if (game?.remoteId) {
|
||||
return HydraApi.put("/profile/games/achievements", {
|
||||
id: game.remoteId,
|
||||
achievements: mergedLocalAchievements,
|
||||
})
|
||||
.then((response) => {
|
||||
return saveAchievementsOnLocal(
|
||||
response.objectId,
|
||||
response.shop,
|
||||
response.achievements
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
|
||||
});
|
||||
}
|
||||
|
||||
return saveAchievementsOnLocal(objectId, shop, mergedLocalAchievements);
|
||||
};
|
||||
268
src/main/services/achievements/parse-achievement-file.ts
Normal file
268
src/main/services/achievements/parse-achievement-file.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Cracker } from "@shared";
|
||||
import { UnlockedAchievement } from "@types";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { achievementsLogger } from "../logger";
|
||||
|
||||
export const parseAchievementFile = (
|
||||
filePath: string,
|
||||
type: Cracker
|
||||
): UnlockedAchievement[] => {
|
||||
if (!existsSync(filePath)) return [];
|
||||
|
||||
if (type == Cracker.codex) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.rune) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processDefault(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.onlineFix) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processOnlineFix(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.goldberg) {
|
||||
const parsed = jsonParse(filePath);
|
||||
return processGoldberg(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.userstats) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processUserStats(parsed);
|
||||
}
|
||||
|
||||
if (type == Cracker.rld) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processRld(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.skidrow) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processSkidrow(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker._3dm) {
|
||||
const parsed = iniParse(filePath);
|
||||
return process3DM(parsed);
|
||||
}
|
||||
|
||||
if (type === Cracker.flt) {
|
||||
const achievements = readdirSync(filePath);
|
||||
|
||||
return achievements.map((achievement) => {
|
||||
return {
|
||||
name: achievement,
|
||||
unlockTime: Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (type === Cracker.creamAPI) {
|
||||
const parsed = iniParse(filePath);
|
||||
return processCreamAPI(parsed);
|
||||
}
|
||||
|
||||
achievementsLogger.log(
|
||||
`Unprocessed ${type} achievements found on ${filePath}`
|
||||
);
|
||||
return [];
|
||||
};
|
||||
|
||||
const iniParse = (filePath: string) => {
|
||||
try {
|
||||
const lines = readFileSync(filePath, "utf-8").split(/[\r\n]+/);
|
||||
|
||||
let objectName = "";
|
||||
const object: Record<string, Record<string, string | number>> = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("###") || !line.length) continue;
|
||||
|
||||
if (line.startsWith("[") && line.endsWith("]")) {
|
||||
objectName = line.slice(1, -1);
|
||||
object[objectName] = {};
|
||||
} else {
|
||||
const [name, ...value] = line.split("=");
|
||||
object[objectName][name.trim()] = value.join("=").trim();
|
||||
}
|
||||
}
|
||||
|
||||
return object;
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error parsing ${filePath}`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const jsonParse = (filePath: string) => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
} catch (err) {
|
||||
achievementsLogger.error(`Error parsing ${filePath}`, err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const processOnlineFix = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.achieved) {
|
||||
parsedUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement.timestamp * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processCreamAPI = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.achieved) {
|
||||
const unlockTime = unlockedAchievement.unlocktime;
|
||||
parsedUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime:
|
||||
unlockTime.length === 7
|
||||
? unlockTime * 1000 * 1000
|
||||
: unlockTime * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processSkidrow = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const parsedUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
const achievements = unlockedAchievements["Achievements"];
|
||||
|
||||
for (const achievement of Object.keys(achievements)) {
|
||||
const unlockedAchievement = achievements[achievement].split("@");
|
||||
|
||||
if (unlockedAchievement[0] === "1") {
|
||||
parsedUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement[unlockedAchievement.length - 1] * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processGoldberg = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.earned) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement.earned_time * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const process3DM = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
const achievements = unlockedAchievements["State"];
|
||||
const times = unlockedAchievements["Time"];
|
||||
|
||||
for (const achievement of Object.keys(achievements)) {
|
||||
if (achievements[achievement] == "0101") {
|
||||
const time = times[achievement];
|
||||
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime:
|
||||
new DataView(
|
||||
new Uint8Array(Buffer.from(time.toString(), "hex")).buffer
|
||||
).getUint32(0, true) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processDefault = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.Achieved) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime: unlockedAchievement.UnlockTime * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processRld = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievement of Object.keys(unlockedAchievements)) {
|
||||
if (achievement === "Steam") continue;
|
||||
|
||||
const unlockedAchievement = unlockedAchievements[achievement];
|
||||
|
||||
if (unlockedAchievement?.State) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement,
|
||||
unlockTime:
|
||||
new DataView(
|
||||
new Uint8Array(
|
||||
Buffer.from(unlockedAchievement.Time.toString(), "hex")
|
||||
).buffer
|
||||
).getUint32(0, true) * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
|
||||
const processUserStats = (unlockedAchievements: any): UnlockedAchievement[] => {
|
||||
const newUnlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
const achievements = unlockedAchievements["ACHIEVEMENTS"];
|
||||
|
||||
if (!achievements) return [];
|
||||
|
||||
for (const achievement of Object.keys(achievements)) {
|
||||
const unlockedAchievement = achievements[achievement];
|
||||
|
||||
const unlockTime = Number(
|
||||
unlockedAchievement.slice(1, -1).replace("unlocked = true, time = ", "")
|
||||
);
|
||||
|
||||
if (!isNaN(unlockTime)) {
|
||||
newUnlockedAchievements.push({
|
||||
name: achievement.replace(/"/g, ``),
|
||||
unlockTime: unlockTime * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newUnlockedAchievements;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { gameAchievementRepository, gameRepository } from "@main/repository";
|
||||
import {
|
||||
findAllAchievementFiles,
|
||||
findAchievementFiles,
|
||||
findAchievementFileInExecutableDirectory,
|
||||
getAlternativeObjectIds,
|
||||
} from "./find-achivement-files";
|
||||
import { parseAchievementFile } from "./parse-achievement-file";
|
||||
import { mergeAchievements } from "./merge-achievements";
|
||||
import type { UnlockedAchievement } from "@types";
|
||||
import { getGameAchievementData } from "./get-game-achievement-data";
|
||||
import { achievementsLogger } from "../logger";
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
export const updateAllLocalUnlockedAchievements = async () => {
|
||||
const gameAchievementFilesMap = findAllAchievementFiles();
|
||||
|
||||
const games = await gameRepository.find({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const game of games) {
|
||||
for (const objectId of getAlternativeObjectIds(game.objectID)) {
|
||||
const gameAchievementFiles = gameAchievementFilesMap.get(objectId) || [];
|
||||
const achievementFileInsideDirectory =
|
||||
findAchievementFileInExecutableDirectory(game);
|
||||
|
||||
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||
|
||||
gameAchievementRepository
|
||||
.findOne({
|
||||
where: { objectId: game.objectID, shop: "steam" },
|
||||
})
|
||||
.then((localAchievements) => {
|
||||
if (!localAchievements || !localAchievements.achievements) {
|
||||
getGameAchievementData(game.objectID, "steam");
|
||||
}
|
||||
});
|
||||
|
||||
const unlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievementFile of gameAchievementFiles) {
|
||||
const parsedAchievements = parseAchievementFile(
|
||||
achievementFile.filePath,
|
||||
achievementFile.type
|
||||
);
|
||||
|
||||
if (parsedAchievements.length) {
|
||||
unlockedAchievements.push(...parsedAchievements);
|
||||
}
|
||||
|
||||
achievementsLogger.log(
|
||||
"Achievement file for",
|
||||
game.title,
|
||||
achievementFile.filePath,
|
||||
parsedAchievements
|
||||
);
|
||||
}
|
||||
|
||||
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateLocalUnlockedAchivements = async (game: Game) => {
|
||||
const gameAchievementFiles = findAchievementFiles(game);
|
||||
|
||||
const achievementFileInsideDirectory =
|
||||
findAchievementFileInExecutableDirectory(game);
|
||||
|
||||
gameAchievementFiles.push(...achievementFileInsideDirectory);
|
||||
|
||||
console.log("Achievements files for", game.title, gameAchievementFiles);
|
||||
|
||||
const unlockedAchievements: UnlockedAchievement[] = [];
|
||||
|
||||
for (const achievementFile of gameAchievementFiles) {
|
||||
const localAchievementFile = parseAchievementFile(
|
||||
achievementFile.filePath,
|
||||
achievementFile.type
|
||||
);
|
||||
|
||||
if (localAchievementFile.length) {
|
||||
unlockedAchievements.push(...localAchievementFile);
|
||||
}
|
||||
}
|
||||
|
||||
mergeAchievements(game.objectID, "steam", unlockedAchievements, false);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
|
||||
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
|
||||
|
||||
@@ -87,60 +88,66 @@ export class HydraApi {
|
||||
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
|
||||
});
|
||||
|
||||
this.instance.interceptors.request.use(
|
||||
(request) => {
|
||||
logger.log(" ---- REQUEST -----");
|
||||
const data = Array.isArray(request.data)
|
||||
? request.data
|
||||
: omit(request.data, ["refreshToken"]);
|
||||
logger.log(request.method, request.url, request.params, data);
|
||||
return request;
|
||||
},
|
||||
(error) => {
|
||||
logger.error("request error", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.log(" ---- RESPONSE -----");
|
||||
const data = Array.isArray(response.data)
|
||||
? response.data
|
||||
: omit(response.data, ["username", "accessToken", "refreshToken"]);
|
||||
logger.log(
|
||||
response.status,
|
||||
response.config.method,
|
||||
response.config.url,
|
||||
data
|
||||
);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
logger.error(" ---- RESPONSE ERROR -----");
|
||||
|
||||
const { config } = error;
|
||||
|
||||
logger.error(
|
||||
config.method,
|
||||
config.baseURL,
|
||||
config.url,
|
||||
config.headers,
|
||||
config.data
|
||||
);
|
||||
|
||||
if (error.response) {
|
||||
logger.error("Response", error.response.status, error.response.data);
|
||||
} else if (error.request) {
|
||||
logger.error("Request", error.request);
|
||||
} else {
|
||||
logger.error("Error", error.message);
|
||||
if (this.ADD_LOG_INTERCEPTOR) {
|
||||
this.instance.interceptors.request.use(
|
||||
(request) => {
|
||||
logger.log(" ---- REQUEST -----");
|
||||
const data = Array.isArray(request.data)
|
||||
? request.data
|
||||
: omit(request.data, ["refreshToken"]);
|
||||
logger.log(request.method, request.url, request.params, data);
|
||||
return request;
|
||||
},
|
||||
(error) => {
|
||||
logger.error("request error", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
logger.error(" ----- END RESPONSE ERROR -------");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
this.instance.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.log(" ---- RESPONSE -----");
|
||||
const data = Array.isArray(response.data)
|
||||
? response.data
|
||||
: omit(response.data, ["username", "accessToken", "refreshToken"]);
|
||||
logger.log(
|
||||
response.status,
|
||||
response.config.method,
|
||||
response.config.url,
|
||||
data
|
||||
);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
logger.error(" ---- RESPONSE ERROR -----");
|
||||
|
||||
const { config } = error;
|
||||
|
||||
logger.error(
|
||||
config.method,
|
||||
config.baseURL,
|
||||
config.url,
|
||||
config.headers,
|
||||
config.data
|
||||
);
|
||||
|
||||
if (error.response) {
|
||||
logger.error(
|
||||
"Response",
|
||||
error.response.status,
|
||||
error.response.data
|
||||
);
|
||||
} else if (error.request) {
|
||||
logger.error("Request", error.request);
|
||||
} else {
|
||||
logger.error("Error", error.message);
|
||||
}
|
||||
|
||||
logger.error(" ----- END RESPONSE ERROR -------");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const userAuth = await userAuthRepository.findOne({
|
||||
where: { id: 1 },
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./how-long-to-beat";
|
||||
export * from "./process-watcher";
|
||||
export * from "./main-loop";
|
||||
export * from "./hydra-api";
|
||||
export * from "./ludusavi";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsNull } from "typeorm";
|
||||
import { HydraApi } from "../hydra-api";
|
||||
import { mergeWithRemoteGames } from "./merge-with-remote-games";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import { updateAllLocalUnlockedAchievements } from "../achievements/update-local-unlocked-achivements";
|
||||
|
||||
export const uploadGamesBatch = async () => {
|
||||
const games = await gameRepository.find({
|
||||
@@ -28,6 +29,8 @@ export const uploadGamesBatch = async () => {
|
||||
|
||||
await mergeWithRemoteGames();
|
||||
|
||||
await updateAllLocalUnlockedAchievements();
|
||||
|
||||
if (WindowManager.mainWindow)
|
||||
WindowManager.mainWindow.webContents.send("on-library-batch-complete");
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ log.transports.file.resolvePathFn = (
|
||||
return path.join(logsPath, "pythoninstance.txt");
|
||||
}
|
||||
|
||||
if (message?.scope == "achievements") {
|
||||
return path.join(logsPath, "achievements.txt");
|
||||
}
|
||||
|
||||
if (message?.level === "error") {
|
||||
return path.join(logsPath, "error.txt");
|
||||
}
|
||||
@@ -29,3 +33,4 @@ log.initialize();
|
||||
|
||||
export const pythonInstanceLogger = log.scope("python-instance");
|
||||
export const logger = log.scope("main");
|
||||
export const achievementsLogger = log.scope("achievements");
|
||||
|
||||
63
src/main/services/ludusavi.ts
Normal file
63
src/main/services/ludusavi.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { GameShop, LudusaviBackup } from "@types";
|
||||
import Piscina from "piscina";
|
||||
|
||||
import { app } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
|
||||
|
||||
const binaryPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, "ludusavi", "ludusavi")
|
||||
: path.join(__dirname, "..", "..", "ludusavi", "ludusavi");
|
||||
|
||||
export class Ludusavi {
|
||||
private static worker = new Piscina({
|
||||
filename: ludusaviWorkerPath,
|
||||
workerData: {
|
||||
binaryPath,
|
||||
},
|
||||
});
|
||||
|
||||
static async findGames(shop: GameShop, objectId: string): Promise<string[]> {
|
||||
const games = await this.worker.run(
|
||||
{ objectId, shop },
|
||||
{ name: "findGames" }
|
||||
);
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
static async backupGame(
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
backupPath: string
|
||||
): Promise<LudusaviBackup> {
|
||||
const games = await this.findGames(shop, objectId);
|
||||
if (!games.length) throw new Error("Game not found");
|
||||
|
||||
return this.worker.run(
|
||||
{ title: games[0], backupPath },
|
||||
{ name: "backupGame" }
|
||||
);
|
||||
}
|
||||
|
||||
static async getBackupPreview(
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
backupPath: string
|
||||
): Promise<LudusaviBackup | null> {
|
||||
const games = await this.findGames(shop, objectId);
|
||||
if (!games.length) return null;
|
||||
|
||||
const backupData = await this.worker.run(
|
||||
{ title: games[0], backupPath, preview: true },
|
||||
{ name: "backupGame" }
|
||||
);
|
||||
|
||||
return backupData;
|
||||
}
|
||||
|
||||
static async restoreBackup(backupPath: string) {
|
||||
return this.worker.run(backupPath, { name: "restoreBackup" });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sleep } from "@main/helpers";
|
||||
import { DownloadManager } from "./download";
|
||||
import { watchProcesses } from "./process-watcher";
|
||||
import { watchAchievements } from "./achievements/achievement-watcher";
|
||||
|
||||
export const startMainLoop = async () => {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
@@ -8,8 +9,9 @@ export const startMainLoop = async () => {
|
||||
await Promise.allSettled([
|
||||
watchProcesses(),
|
||||
DownloadManager.watchDownloads(),
|
||||
watchAchievements(),
|
||||
]);
|
||||
|
||||
await sleep(1000);
|
||||
await sleep(1500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { IsNull, Not } from "typeorm";
|
||||
import { gameRepository } from "@main/repository";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { createGame, updateGamePlaytime } from "./library-sync";
|
||||
import { GameRunning } from "@types";
|
||||
import type { GameRunning } from "@types";
|
||||
import { PythonInstance } from "./download";
|
||||
import { Game } from "@main/entity";
|
||||
|
||||
@@ -25,12 +25,12 @@ export const watchProcesses = async () => {
|
||||
if (games.length === 0) return;
|
||||
const processes = await PythonInstance.getProcessList();
|
||||
|
||||
const processSet = new Set(processes.map((process) => process.exe));
|
||||
|
||||
for (const game of games) {
|
||||
const executablePath = game.executablePath!;
|
||||
|
||||
const gameProcess = processes.find((runningProcess) => {
|
||||
return executablePath == runningProcess.exe;
|
||||
});
|
||||
const gameProcess = processSet.has(executablePath);
|
||||
|
||||
if (gameProcess) {
|
||||
if (gamesPlaytime.has(game.id)) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const requestSteam250 = async (path: string) => {
|
||||
|
||||
return {
|
||||
title: $title.textContent,
|
||||
objectID: steamGameUrl.split("/").pop(),
|
||||
objectId: steamGameUrl.split("/").pop(),
|
||||
} as Steam250Game;
|
||||
})
|
||||
.filter((game) => game != null);
|
||||
@@ -38,7 +38,7 @@ export const getSteam250List = async () => {
|
||||
).flat();
|
||||
|
||||
const gamesMap: Map<string, Steam250Game> = gamesList.reduce((map, item) => {
|
||||
if (item) map.set(item.objectID, item);
|
||||
if (item) map.set(item.objectId, item);
|
||||
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { GameShop } from "@types";
|
||||
import axios from "axios";
|
||||
|
||||
export interface SteamGridResponse {
|
||||
@@ -20,9 +21,9 @@ export interface SteamGridGameResponse {
|
||||
}
|
||||
|
||||
export const getSteamGridData = async (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
path: string,
|
||||
shop: string,
|
||||
shop: GameShop,
|
||||
params: Record<string, string> = {}
|
||||
): Promise<SteamGridResponse> => {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
@@ -32,7 +33,7 @@ export const getSteamGridData = async (
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
|
||||
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectId}?${searchParams.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.MAIN_VITE_STEAMGRIDDB_API_KEY}`,
|
||||
@@ -58,10 +59,10 @@ export const getSteamGridGameById = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSteamGameClientIcon = async (objectID: string) => {
|
||||
export const getSteamGameClientIcon = async (objectId: string) => {
|
||||
const {
|
||||
data: { id: steamGridGameId },
|
||||
} = await getSteamGridData(objectID, "games", "steam");
|
||||
} = await getSteamGridData(objectId, "games", "steam");
|
||||
|
||||
const steamGridGame = await getSteamGridGameById(steamGridGameId);
|
||||
return steamGridGame.data.platforms.steam.metadata.clienticon;
|
||||
|
||||
@@ -12,11 +12,11 @@ export interface SteamAppDetailsResponse {
|
||||
}
|
||||
|
||||
export const getSteamAppDetails = async (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
language: string
|
||||
) => {
|
||||
const searchParams = new URLSearchParams({
|
||||
appids: objectID,
|
||||
appids: objectId,
|
||||
l: language,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export const getSteamAppDetails = async (
|
||||
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data[objectID].success) return response.data[objectID].data;
|
||||
if (response.data[objectId].success) return response.data[objectId].data;
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -19,8 +19,9 @@ import { HydraApi } from "./hydra-api";
|
||||
|
||||
export class WindowManager {
|
||||
public static mainWindow: Electron.BrowserWindow | null = null;
|
||||
public static notificationWindow: Electron.BrowserWindow | null = null;
|
||||
|
||||
private static loadURL(hash = "") {
|
||||
private static loadMainWindowURL(hash = "") {
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
@@ -37,6 +38,21 @@ export class WindowManager {
|
||||
}
|
||||
}
|
||||
|
||||
private static loadNotificationWindowURL() {
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
this.notificationWindow?.loadURL(
|
||||
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
|
||||
);
|
||||
} else {
|
||||
this.notificationWindow?.loadFile(
|
||||
path.join(__dirname, "../renderer/index.html"),
|
||||
{
|
||||
hash: "achievement-notification",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static createMainWindow() {
|
||||
if (this.mainWindow) return;
|
||||
|
||||
@@ -61,7 +77,54 @@ export class WindowManager {
|
||||
show: false,
|
||||
});
|
||||
|
||||
this.loadURL();
|
||||
this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
(details, callback) => {
|
||||
callback({
|
||||
requestHeaders: {
|
||||
...details.requestHeaders,
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
if (details.webContentsId !== this.mainWindow?.webContents.id) {
|
||||
return callback(details);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"access-control-allow-origin": ["*"],
|
||||
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
|
||||
"access-control-expose-headers": ["ETag"],
|
||||
"access-control-allow-headers": [
|
||||
"Content-Type, Authorization, X-Requested-With, If-None-Match",
|
||||
],
|
||||
};
|
||||
|
||||
if (details.method === "OPTIONS") {
|
||||
return callback({
|
||||
cancel: false,
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
statusLine: "HTTP/1.1 200 OK",
|
||||
});
|
||||
}
|
||||
|
||||
return callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.loadMainWindowURL();
|
||||
this.mainWindow.removeMenu();
|
||||
|
||||
this.mainWindow.on("ready-to-show", () => {
|
||||
@@ -78,9 +141,37 @@ export class WindowManager {
|
||||
app.quit();
|
||||
}
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
WindowManager.mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
public static createNotificationWindow() {
|
||||
this.notificationWindow = new BrowserWindow({
|
||||
transparent: true,
|
||||
maximizable: false,
|
||||
autoHideMenuBar: true,
|
||||
minimizable: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
frame: false,
|
||||
width: 350,
|
||||
height: 104,
|
||||
x: 0,
|
||||
y: 0,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.mjs"),
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.notificationWindow.setIgnoreMouseEvents(true);
|
||||
this.notificationWindow.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
|
||||
this.loadNotificationWindowURL();
|
||||
}
|
||||
|
||||
public static openAuthWindow() {
|
||||
if (this.mainWindow) {
|
||||
const authWindow = new BrowserWindow({
|
||||
@@ -101,6 +192,8 @@ export class WindowManager {
|
||||
|
||||
authWindow.removeMenu();
|
||||
|
||||
if (!app.isPackaged) authWindow.webContents.openDevTools();
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
lng: i18next.language,
|
||||
});
|
||||
@@ -125,14 +218,14 @@ export class WindowManager {
|
||||
|
||||
public static redirect(hash: string) {
|
||||
if (!this.mainWindow) this.createMainWindow();
|
||||
this.loadURL(hash);
|
||||
this.loadMainWindowURL(hash);
|
||||
|
||||
if (this.mainWindow?.isMinimized()) this.mainWindow.restore();
|
||||
this.mainWindow?.focus();
|
||||
}
|
||||
|
||||
public static createSystemTray(language: string) {
|
||||
let tray;
|
||||
let tray: Tray;
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const macIcon = nativeImage
|
||||
|
||||
61
src/main/workers/ludusavi.worker.ts
Normal file
61
src/main/workers/ludusavi.worker.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { GameShop, LudusaviBackup, LudusaviFindResult } from "@types";
|
||||
import cp from "node:child_process";
|
||||
|
||||
import { workerData } from "node:worker_threads";
|
||||
|
||||
const { binaryPath } = workerData;
|
||||
|
||||
export const findGames = ({
|
||||
shop,
|
||||
objectId,
|
||||
}: {
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
}) => {
|
||||
const args = ["find", "--api"];
|
||||
|
||||
if (shop === "steam") {
|
||||
args.push("--steam-id", objectId);
|
||||
}
|
||||
|
||||
const result = cp.execFileSync(binaryPath, args);
|
||||
|
||||
const games = JSON.parse(result.toString("utf-8")) as LudusaviFindResult;
|
||||
return Object.keys(games.games);
|
||||
};
|
||||
|
||||
export const backupGame = ({
|
||||
title,
|
||||
backupPath,
|
||||
preview = false,
|
||||
winePrefix,
|
||||
}: {
|
||||
title: string;
|
||||
backupPath: string;
|
||||
preview?: boolean;
|
||||
winePrefix?: string;
|
||||
}) => {
|
||||
const args = ["backup", title, "--api", "--force"];
|
||||
|
||||
if (preview) args.push("--preview");
|
||||
if (backupPath) args.push("--path", backupPath);
|
||||
if (winePrefix) args.push("--wine-prefix", winePrefix);
|
||||
|
||||
const result = cp.execFileSync(binaryPath, args);
|
||||
|
||||
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
|
||||
};
|
||||
|
||||
export const restoreBackup = (backupPath: string) => {
|
||||
const result = cp.execFileSync(binaryPath, [
|
||||
"restore",
|
||||
"--path",
|
||||
backupPath,
|
||||
"--api",
|
||||
"--force",
|
||||
]);
|
||||
|
||||
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
|
||||
};
|
||||
|
||||
// --wine-prefix
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
UpdateProfileRequest,
|
||||
} from "@types";
|
||||
import type { CatalogueCategory } from "@shared";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
/* Torrenting */
|
||||
@@ -37,18 +38,37 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
|
||||
getCatalogue: (category: CatalogueCategory) =>
|
||||
ipcRenderer.invoke("getCatalogue", category),
|
||||
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
|
||||
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
|
||||
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
||||
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
|
||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
|
||||
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
|
||||
getGames: (take?: number, prevCursor?: number) =>
|
||||
ipcRenderer.invoke("getGames", take, prevCursor),
|
||||
getHowLongToBeat: (objectId: string, shop: GameShop, title: string) =>
|
||||
ipcRenderer.invoke("getHowLongToBeat", objectId, shop, title),
|
||||
getGames: (take?: number, skip?: number) =>
|
||||
ipcRenderer.invoke("getGames", take, skip),
|
||||
searchGameRepacks: (query: string) =>
|
||||
ipcRenderer.invoke("searchGameRepacks", query),
|
||||
getGameStats: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
||||
getGameAchievements: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameAchievements", objectId, shop),
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
achievements?: { displayName: string; iconUrl: string }[]
|
||||
) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
achievements?: { displayName: string; iconUrl: string }[]
|
||||
) => cb(objectId, shop, achievements);
|
||||
ipcRenderer.on("on-achievement-unlocked", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("on-achievement-unlocked", listener);
|
||||
},
|
||||
|
||||
/* User preferences */
|
||||
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
|
||||
@@ -64,8 +84,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("deleteDownloadSource", id),
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
|
||||
addGameToLibrary: (objectId: string, title: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("addGameToLibrary", objectId, title, shop),
|
||||
createGameShortcut: (id: number) =>
|
||||
ipcRenderer.invoke("createGameShortcut", id),
|
||||
updateExecutablePath: (id: number, executablePath: string) =>
|
||||
@@ -87,8 +107,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
|
||||
deleteGameFolder: (gameId: number) =>
|
||||
ipcRenderer.invoke("deleteGameFolder", gameId),
|
||||
getGameByObjectID: (objectID: string) =>
|
||||
ipcRenderer.invoke("getGameByObjectID", objectID),
|
||||
getGameByObjectId: (objectId: string) =>
|
||||
ipcRenderer.invoke("getGameByObjectId", objectId),
|
||||
onGamesRunning: (
|
||||
cb: (
|
||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||
@@ -110,6 +130,62 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
getDiskFreeSpace: (path: string) =>
|
||||
ipcRenderer.invoke("getDiskFreeSpace", path),
|
||||
|
||||
/* Cloud save */
|
||||
uploadSaveGame: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("uploadSaveGame", objectId, shop),
|
||||
downloadGameArtifact: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
gameArtifactId: string
|
||||
) =>
|
||||
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
|
||||
getGameArtifacts: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
|
||||
getGameBackupPreview: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
|
||||
checkGameCloudSyncSupport: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop),
|
||||
deleteGameArtifact: (gameArtifactId: string) =>
|
||||
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
|
||||
onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||
ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(
|
||||
`on-upload-complete-${objectId}-${shop}`,
|
||||
listener
|
||||
);
|
||||
},
|
||||
onBackupDownloadProgress: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: (progress: AxiosProgressEvent) => void
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
progress: AxiosProgressEvent
|
||||
) => cb(progress);
|
||||
ipcRenderer.on(`on-backup-download-progress-${objectId}-${shop}`, listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(
|
||||
`on-backup-download-complete-${objectId}-${shop}`,
|
||||
listener
|
||||
);
|
||||
},
|
||||
onBackupDownloadComplete: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: () => void
|
||||
) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => cb();
|
||||
ipcRenderer.on(`on-backup-download-complete-${objectId}-${shop}`, listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener(
|
||||
`on-backup-download-complete-${objectId}-${shop}`,
|
||||
listener
|
||||
);
|
||||
},
|
||||
|
||||
/* Misc */
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
getVersion: () => ipcRenderer.invoke("getVersion"),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
|
||||
/>
|
||||
</head>
|
||||
<body style="background-color: #1c1c1c">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -26,6 +26,10 @@ globalStyle("::-webkit-scrollbar-thumb", {
|
||||
borderRadius: "24px",
|
||||
});
|
||||
|
||||
globalStyle("::-webkit-scrollbar-thumb:hover", {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.16)",
|
||||
});
|
||||
|
||||
globalStyle("html, body, #root, main", {
|
||||
height: "100%",
|
||||
});
|
||||
@@ -35,7 +39,6 @@ globalStyle("body", {
|
||||
userSelect: "none",
|
||||
fontFamily: "Noto Sans, sans-serif",
|
||||
fontSize: vars.size.body,
|
||||
background: vars.color.background,
|
||||
color: vars.color.body,
|
||||
margin: "0",
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { downloadSourcesWorker } from "./workers";
|
||||
import { repacksContext } from "./context";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export interface AppProps {
|
||||
children: React.ReactNode;
|
||||
@@ -231,6 +232,8 @@ export function App() {
|
||||
}
|
||||
|
||||
for (const downloadSource of downloadSources) {
|
||||
logger.info("Migrating download source", downloadSource.url);
|
||||
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:import:${downloadSource.url}`
|
||||
);
|
||||
@@ -243,6 +246,10 @@ export function App() {
|
||||
channel.onmessage = () => {
|
||||
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
|
||||
resolve(true);
|
||||
logger.info(
|
||||
"Deleted download source from SQLite",
|
||||
downloadSource.url
|
||||
);
|
||||
});
|
||||
|
||||
indexRepacks();
|
||||
|
||||
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
BIN
src/renderer/src/assets/audio/achievement.wav
Normal file
Binary file not shown.
725
src/renderer/src/assets/lottie/cloud.json
Normal file
725
src/renderer/src/assets/lottie/cloud.json
Normal file
@@ -0,0 +1,725 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 30,
|
||||
"ip": 0,
|
||||
"op": 60,
|
||||
"w": 400,
|
||||
"h": 400,
|
||||
"nm": "Cloud",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "Layer 6",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [322.789, 202.565, 0],
|
||||
"to": [-1.5, -0.167, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [313.789, 201.565, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-1.5, -0.167, 0]
|
||||
},
|
||||
{ "t": 60, "s": [322.789, 202.565, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -38.564],
|
||||
[38.564, 0],
|
||||
[0, 38.564],
|
||||
[-38.564, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 38.564],
|
||||
[-38.564, 0],
|
||||
[0, -38.564],
|
||||
[38.564, 0]
|
||||
],
|
||||
"v": [
|
||||
[69.827, 0],
|
||||
[0, 69.827],
|
||||
[-69.827, 0],
|
||||
[0, -69.827]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "Layer 5",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [243.704, 202.565, 0],
|
||||
"to": [-1.667, 0, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [233.704, 202.565, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-1.667, 0, 0]
|
||||
},
|
||||
{ "t": 60, "s": [243.704, 202.565, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -38.564],
|
||||
[38.564, 0],
|
||||
[0, 38.564],
|
||||
[-38.564, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 38.564],
|
||||
[-38.564, 0],
|
||||
[0, -38.564],
|
||||
[38.564, 0]
|
||||
],
|
||||
"v": [
|
||||
[69.827, 0],
|
||||
[0, 69.827],
|
||||
[-69.827, 0],
|
||||
[0, -69.827]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "Layer 4",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [260.681, 151.053, 0],
|
||||
"to": [1.333, -1.333, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [268.681, 143.053, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [1.333, -1.333, 0]
|
||||
},
|
||||
{ "t": 60, "s": [260.681, 151.053, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -38.564],
|
||||
[38.564, 0],
|
||||
[0, 38.564],
|
||||
[-38.564, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 38.564],
|
||||
[-38.564, 0],
|
||||
[0, -38.564],
|
||||
[38.564, 0]
|
||||
],
|
||||
"v": [
|
||||
[69.827, 0],
|
||||
[0, 69.827],
|
||||
[-69.827, 0],
|
||||
[0, -69.827]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "Layer 3",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [162.135, 206.563, 0],
|
||||
"to": [-0.833, -0.167, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [157.135, 205.563, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-0.833, -0.167, 0]
|
||||
},
|
||||
{ "t": 60, "s": [162.135, 206.563, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -36.66],
|
||||
[36.66, 0],
|
||||
[0, 36.66],
|
||||
[-36.66, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 36.66],
|
||||
[-36.66, 0],
|
||||
[0, -36.66],
|
||||
[36.66, 0]
|
||||
],
|
||||
"v": [
|
||||
[66.378, 0],
|
||||
[0, 66.378],
|
||||
[-66.378, 0],
|
||||
[0, -66.378]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 6,
|
||||
"ty": 4,
|
||||
"nm": "Layer 2",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [180.178, 132.225, 0],
|
||||
"to": [-0.5, -2.333, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [177.178, 118.225, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-0.5, -2.333, 0]
|
||||
},
|
||||
{ "t": 60, "s": [180.178, 132.225, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -50.068],
|
||||
[50.068, 0],
|
||||
[0, 50.068],
|
||||
[-50.068, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 50.068],
|
||||
[-50.068, 0],
|
||||
[0, -50.068],
|
||||
[50.068, 0]
|
||||
],
|
||||
"v": [
|
||||
[90.655, 0],
|
||||
[0, 90.655],
|
||||
[-90.655, 0],
|
||||
[0, -90.655]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 7,
|
||||
"ty": 4,
|
||||
"nm": "Layer 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 0,
|
||||
"s": [95.756, 208.288, 0],
|
||||
"to": [-1.167, 0, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": { "x": 0.667, "y": 1 },
|
||||
"o": { "x": 0.333, "y": 0 },
|
||||
"t": 30,
|
||||
"s": [88.756, 208.288, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [-1.167, 0, 0]
|
||||
},
|
||||
{ "t": 60, "s": [95.756, 208.288, 0] }
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, -35.403],
|
||||
[35.403, 0],
|
||||
[0, 35.403],
|
||||
[-35.403, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 35.403],
|
||||
[-35.403, 0],
|
||||
[0, -35.403],
|
||||
[35.403, 0]
|
||||
],
|
||||
"v": [
|
||||
[64.103, 0],
|
||||
[0, 64.103],
|
||||
[-64.103, 0],
|
||||
[0, -64.103]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
|
||||
"ix": 4
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 5 },
|
||||
"r": 1,
|
||||
"bm": 0,
|
||||
"nm": "Fill 1",
|
||||
"mn": "ADBE Vector Graphic - Fill",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Group 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 8,
|
||||
"ty": 3,
|
||||
"nm": "Null 1",
|
||||
"parent": 6,
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 0, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [19.822, 67.775, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
|
||||
},
|
||||
"ao": 0,
|
||||
"ip": 0,
|
||||
"op": 270,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"props": {}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
|
||||
const handleHover = useCallback(() => {
|
||||
if (!stats) {
|
||||
window.electron.getGameStats(game.objectID, game.shop).then((stats) => {
|
||||
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
|
||||
setStats(stats);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,7 +140,10 @@ export function Sidebar() {
|
||||
event: React.MouseEvent,
|
||||
game: LibraryGame
|
||||
) => {
|
||||
const path = buildGameDetailsPath(game);
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectID,
|
||||
});
|
||||
if (path !== location.pathname) {
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
213
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
213
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { gameBackupsTable } from "@renderer/dexie";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { logger } from "@renderer/logger";
|
||||
import type { LudusaviBackup, GameArtifact, GameShop } from "@types";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export enum CloudSyncState {
|
||||
New,
|
||||
Different,
|
||||
Same,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
export interface CloudSyncContext {
|
||||
backupPreview: LudusaviBackup | null;
|
||||
artifacts: GameArtifact[];
|
||||
showCloudSyncModal: boolean;
|
||||
showCloudSyncFilesModal: boolean;
|
||||
supportsCloudSync: boolean | null;
|
||||
backupState: CloudSyncState;
|
||||
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
|
||||
uploadSaveGame: () => Promise<void>;
|
||||
deleteGameArtifact: (gameArtifactId: string) => Promise<void>;
|
||||
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
restoringBackup: boolean;
|
||||
uploadingBackup: boolean;
|
||||
}
|
||||
|
||||
export const cloudSyncContext = createContext<CloudSyncContext>({
|
||||
backupPreview: null,
|
||||
showCloudSyncModal: false,
|
||||
supportsCloudSync: null,
|
||||
backupState: CloudSyncState.Unknown,
|
||||
setShowCloudSyncModal: () => {},
|
||||
downloadGameArtifact: async () => {},
|
||||
uploadSaveGame: async () => {},
|
||||
artifacts: [],
|
||||
deleteGameArtifact: async () => {},
|
||||
showCloudSyncFilesModal: false,
|
||||
setShowCloudSyncFilesModal: () => {},
|
||||
restoringBackup: false,
|
||||
uploadingBackup: false,
|
||||
});
|
||||
|
||||
const { Provider } = cloudSyncContext;
|
||||
export const { Consumer: CloudSyncContextConsumer } = cloudSyncContext;
|
||||
|
||||
export interface CloudSyncContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
}
|
||||
|
||||
export function CloudSyncContextProvider({
|
||||
children,
|
||||
objectId,
|
||||
shop,
|
||||
}: CloudSyncContextProviderProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
const [artifacts, setArtifacts] = useState<GameArtifact[]>([]);
|
||||
const [showCloudSyncModal, setShowCloudSyncModal] = useState(false);
|
||||
const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>(
|
||||
null
|
||||
);
|
||||
const [restoringBackup, setRestoringBackup] = useState(false);
|
||||
const [uploadingBackup, setUploadingBackup] = useState(false);
|
||||
const [showCloudSyncFilesModal, setShowCloudSyncFilesModal] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const downloadGameArtifact = useCallback(
|
||||
async (gameArtifactId: string) => {
|
||||
setRestoringBackup(true);
|
||||
window.electron.downloadGameArtifact(objectId, shop, gameArtifactId);
|
||||
},
|
||||
[objectId, shop]
|
||||
);
|
||||
|
||||
const getGameBackupPreview = useCallback(async () => {
|
||||
window.electron.getGameArtifacts(objectId, shop).then((results) => {
|
||||
setArtifacts(results);
|
||||
});
|
||||
|
||||
window.electron
|
||||
.getGameBackupPreview(objectId, shop)
|
||||
.then((preview) => {
|
||||
logger.info("Game backup preview", objectId, shop, preview);
|
||||
if (preview && Object.keys(preview.games).length) {
|
||||
setBackupPreview(preview);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to get game backup preview", objectId, shop, err);
|
||||
});
|
||||
}, [objectId, shop]);
|
||||
|
||||
const uploadSaveGame = useCallback(async () => {
|
||||
setUploadingBackup(true);
|
||||
window.electron.uploadSaveGame(objectId, shop);
|
||||
}, [objectId, shop]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeUploadCompleteListener = window.electron.onUploadComplete(
|
||||
objectId,
|
||||
shop,
|
||||
() => {
|
||||
showSuccessToast(t("backup_uploaded"));
|
||||
|
||||
setUploadingBackup(false);
|
||||
gameBackupsTable.add({
|
||||
objectId,
|
||||
shop,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
getGameBackupPreview();
|
||||
}
|
||||
);
|
||||
|
||||
const removeDownloadCompleteListener =
|
||||
window.electron.onBackupDownloadComplete(objectId, shop, () => {
|
||||
showSuccessToast(t("backup_restored"));
|
||||
|
||||
setRestoringBackup(false);
|
||||
getGameBackupPreview();
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeUploadCompleteListener();
|
||||
removeDownloadCompleteListener();
|
||||
};
|
||||
}, [objectId, shop, showSuccessToast, t, getGameBackupPreview]);
|
||||
|
||||
const deleteGameArtifact = useCallback(
|
||||
async (gameArtifactId: string) => {
|
||||
return window.electron.deleteGameArtifact(gameArtifactId).then(() => {
|
||||
getGameBackupPreview();
|
||||
});
|
||||
},
|
||||
[getGameBackupPreview]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron
|
||||
.checkGameCloudSyncSupport(objectId, shop)
|
||||
.then((result) => {
|
||||
logger.info("Cloud sync support", objectId, shop, result);
|
||||
setSupportsCloudSync(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to check cloud sync support", err);
|
||||
});
|
||||
}, [objectId, shop, getGameBackupPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
setBackupPreview(null);
|
||||
setArtifacts([]);
|
||||
setSupportsCloudSync(null);
|
||||
setShowCloudSyncModal(false);
|
||||
setRestoringBackup(false);
|
||||
setUploadingBackup(false);
|
||||
}, [objectId, shop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCloudSyncModal) {
|
||||
getGameBackupPreview();
|
||||
}
|
||||
}, [getGameBackupPreview, showCloudSyncModal]);
|
||||
|
||||
const backupState = useMemo(() => {
|
||||
if (!backupPreview) return CloudSyncState.Unknown;
|
||||
if (backupPreview.overall.changedGames.new) return CloudSyncState.New;
|
||||
if (backupPreview.overall.changedGames.different)
|
||||
return CloudSyncState.Different;
|
||||
if (backupPreview.overall.changedGames.same) return CloudSyncState.Same;
|
||||
|
||||
return CloudSyncState.Unknown;
|
||||
}, [backupPreview]);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
supportsCloudSync,
|
||||
backupPreview,
|
||||
showCloudSyncModal,
|
||||
artifacts,
|
||||
backupState,
|
||||
restoringBackup,
|
||||
uploadingBackup,
|
||||
showCloudSyncFilesModal,
|
||||
setShowCloudSyncModal,
|
||||
uploadSaveGame,
|
||||
downloadGameArtifact,
|
||||
deleteGameArtifact,
|
||||
setShowCloudSyncFilesModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
@@ -13,6 +12,7 @@ import { useAppDispatch, useAppSelector, useDownload } from "@renderer/hooks";
|
||||
|
||||
import type {
|
||||
Game,
|
||||
GameAchievement,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
@@ -32,11 +32,12 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
gameTitle: "",
|
||||
isGameRunning: false,
|
||||
isLoading: false,
|
||||
objectID: undefined,
|
||||
objectId: undefined,
|
||||
gameColor: "",
|
||||
showRepacksModal: false,
|
||||
showGameOptionsModal: false,
|
||||
stats: null,
|
||||
achievements: [],
|
||||
hasNSFWContentBlocked: false,
|
||||
setGameColor: () => {},
|
||||
selectGameExecutable: async () => null,
|
||||
@@ -51,14 +52,19 @@ export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
|
||||
|
||||
export interface GameDetailsContextProps {
|
||||
children: React.ReactNode;
|
||||
objectId: string;
|
||||
gameTitle: string;
|
||||
shop: GameShop;
|
||||
}
|
||||
|
||||
export function GameDetailsContextProvider({
|
||||
children,
|
||||
objectId,
|
||||
gameTitle,
|
||||
shop,
|
||||
}: GameDetailsContextProps) {
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [achievements, setAchievements] = useState<GameAchievement[]>([]);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
|
||||
@@ -72,10 +78,6 @@ export function GameDetailsContextProvider({
|
||||
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const gameTitle = searchParams.get("title")!;
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,9 +100,9 @@ export function GameDetailsContextProvider({
|
||||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectID(objectID!)
|
||||
.getGameByObjectId(objectId!)
|
||||
.then((result) => setGame(result));
|
||||
}, [setGame, objectID]);
|
||||
}, [setGame, objectId]);
|
||||
|
||||
const isGameDownloading = lastPacket?.game.id === game?.id;
|
||||
|
||||
@@ -111,7 +113,7 @@ export function GameDetailsContextProvider({
|
||||
useEffect(() => {
|
||||
window.electron
|
||||
.getGameShopDetails(
|
||||
objectID!,
|
||||
objectId!,
|
||||
shop as GameShop,
|
||||
getSteamLanguage(i18n.language)
|
||||
)
|
||||
@@ -130,20 +132,30 @@ export function GameDetailsContextProvider({
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
window.electron.getGameStats(objectID!, shop as GameShop).then((result) => {
|
||||
window.electron.getGameStats(objectId!, shop as GameShop).then((result) => {
|
||||
setStats(result);
|
||||
});
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectId!, shop as GameShop)
|
||||
.then((achievements) => {
|
||||
setAchievements(achievements);
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: handle user not logged in error
|
||||
});
|
||||
|
||||
updateGame();
|
||||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
setShopDetails(null);
|
||||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
setAchievements([]);
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectID, gameTitle, dispatch]);
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||
@@ -162,6 +174,23 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(
|
||||
(objectId, shop) => {
|
||||
if (objectId !== objectId || shop !== shop) return;
|
||||
|
||||
window.electron
|
||||
.getGameAchievements(objectId!, shop as GameShop)
|
||||
.then(setAchievements)
|
||||
.catch(() => {});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [objectId, shop]);
|
||||
|
||||
const getDownloadsPath = async () => {
|
||||
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
|
||||
return window.electron.getDefaultDownloadsPath();
|
||||
@@ -200,11 +229,12 @@ export function GameDetailsContextProvider({
|
||||
gameTitle,
|
||||
isGameRunning,
|
||||
isLoading,
|
||||
objectID,
|
||||
objectId,
|
||||
gameColor,
|
||||
showGameOptionsModal,
|
||||
showRepacksModal,
|
||||
stats,
|
||||
achievements,
|
||||
hasNSFWContentBlocked,
|
||||
setHasNSFWContentBlocked,
|
||||
setGameColor,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Game,
|
||||
GameAchievement,
|
||||
GameRepack,
|
||||
GameShop,
|
||||
GameStats,
|
||||
@@ -14,11 +15,12 @@ export interface GameDetailsContext {
|
||||
gameTitle: string;
|
||||
isGameRunning: boolean;
|
||||
isLoading: boolean;
|
||||
objectID: string | undefined;
|
||||
objectId: string | undefined;
|
||||
gameColor: string;
|
||||
showRepacksModal: boolean;
|
||||
showGameOptionsModal: boolean;
|
||||
stats: GameStats | null;
|
||||
achievements: GameAchievement[];
|
||||
hasNSFWContentBlocked: boolean;
|
||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./game-details/game-details.context";
|
||||
export * from "./settings/settings.context";
|
||||
export * from "./user-profile/user-profile.context";
|
||||
export * from "./repacks/repacks.context";
|
||||
export * from "./cloud-sync/cloud-sync.context";
|
||||
|
||||
@@ -50,6 +50,7 @@ export function RepacksContextProvider({ children }: RepacksContextProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("CALLED");
|
||||
indexRepacks();
|
||||
}, [indexRepacks]);
|
||||
|
||||
|
||||
64
src/renderer/src/declaration.d.ts
vendored
64
src/renderer/src/declaration.d.ts
vendored
@@ -25,7 +25,11 @@ import type {
|
||||
UserStats,
|
||||
UserDetails,
|
||||
FriendRequestSync,
|
||||
GameAchievement,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
declare global {
|
||||
@@ -48,27 +52,35 @@ declare global {
|
||||
searchGames: (query: string) => Promise<CatalogueEntry[]>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
|
||||
getGameShopDetails: (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
language: string
|
||||
) => Promise<ShopDetails | null>;
|
||||
getRandomGame: () => Promise<Steam250Game>;
|
||||
getHowLongToBeat: (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
title: string
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
getGames: (
|
||||
take?: number,
|
||||
prevCursor?: number
|
||||
) => Promise<{ results: CatalogueEntry[]; cursor: number }>;
|
||||
getGames: (take?: number, skip?: number) => Promise<CatalogueEntry[]>;
|
||||
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
|
||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||
getGameAchievements: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<GameAchievement[]>;
|
||||
onAchievementUnlocked: (
|
||||
cb: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
achievements?: { displayName: string; iconUrl: string }[]
|
||||
) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Library */
|
||||
addGameToLibrary: (
|
||||
objectID: string,
|
||||
objectId: string,
|
||||
title: string,
|
||||
shop: GameShop
|
||||
) => Promise<void>;
|
||||
@@ -84,7 +96,7 @@ declare global {
|
||||
removeGameFromLibrary: (gameId: number) => Promise<void>;
|
||||
removeGame: (gameId: number) => Promise<void>;
|
||||
deleteGameFolder: (gameId: number) => Promise<unknown>;
|
||||
getGameByObjectID: (objectID: string) => Promise<Game | null>;
|
||||
getGameByObjectId: (objectId: string) => Promise<Game | null>;
|
||||
onGamesRunning: (
|
||||
cb: (
|
||||
gamesRunning: Pick<GameRunning, "id" | "sessionDurationInMillis">[]
|
||||
@@ -107,6 +119,42 @@ declare global {
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
|
||||
/* Cloud save */
|
||||
uploadSaveGame: (objectId: string, shop: GameShop) => Promise<void>;
|
||||
downloadGameArtifact: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
gameArtifactId: string
|
||||
) => Promise<void>;
|
||||
getGameArtifacts: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<GameArtifact[]>;
|
||||
getGameBackupPreview: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<LudusaviBackup | null>;
|
||||
checkGameCloudSyncSupport: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<boolean>;
|
||||
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
|
||||
onBackupDownloadComplete: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: () => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onUploadComplete: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: () => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onBackupDownloadProgress: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: (progress: AxiosProgressEvent) => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Misc */
|
||||
openExternal: (src: string) => Promise<void>;
|
||||
getVersion: () => Promise<string>;
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { GameShop } from "@types";
|
||||
import { Dexie } from "dexie";
|
||||
|
||||
export interface GameBackup {
|
||||
id?: number;
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(1).stores({
|
||||
db.version(3).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
||||
gameBackups: `++id, [shop+objectId], createdAt`,
|
||||
});
|
||||
|
||||
export const downloadSourcesTable = db.table("downloadSources");
|
||||
export const repacksTable = db.table("repacks");
|
||||
export const gameBackupsTable = db.table<GameBackup>("gameBackups");
|
||||
|
||||
db.open();
|
||||
|
||||
@@ -27,11 +27,11 @@ export const getSteamLanguage = (language: string) => {
|
||||
};
|
||||
|
||||
export const buildGameDetailsPath = (
|
||||
game: { shop: GameShop; objectID: string; title: string },
|
||||
game: { shop: GameShop; objectId: string; title: string },
|
||||
params: Record<string, string> = {}
|
||||
) => {
|
||||
const searchParams = new URLSearchParams({ title: game.title, ...params });
|
||||
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
|
||||
return `/game/${game.shop}/${game.objectId}?${searchParams.toString()}`;
|
||||
};
|
||||
|
||||
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatDistance, subMilliseconds } from "date-fns";
|
||||
import { format, formatDistance, subMilliseconds } from "date-fns";
|
||||
import type { FormatDistanceOptions } from "date-fns";
|
||||
import {
|
||||
ptBR,
|
||||
@@ -67,5 +67,13 @@ export function useDate() {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
format: (timestamp: number): string => {
|
||||
const locale = getDateLocale();
|
||||
return format(
|
||||
timestamp,
|
||||
locale == enUS ? "MM/dd/yyyy - HH:mm" : "dd/MM/yyyy - HH:mm"
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
UserDetails,
|
||||
} from "@types";
|
||||
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
|
||||
import { gameBackupsTable } from "@renderer/dexie";
|
||||
|
||||
export function useUserDetails() {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -32,6 +33,7 @@ export function useUserDetails() {
|
||||
dispatch(setUserDetails(null));
|
||||
dispatch(setProfileBackground(null));
|
||||
|
||||
await gameBackupsTable.clear();
|
||||
window.localStorage.removeItem("userDetails");
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -44,32 +46,9 @@ export function useUserDetails() {
|
||||
const updateUserDetails = useCallback(
|
||||
async (userDetails: UserDetails) => {
|
||||
dispatch(setUserDetails(userDetails));
|
||||
|
||||
if (userDetails.profileImageUrl) {
|
||||
// TODO: Decide if we want to use this
|
||||
// const profileBackground = await profileBackgroundFromProfileImage(
|
||||
// userDetails.profileImageUrl
|
||||
// ).catch((err) => {
|
||||
// logger.error("profileBackgroundFromProfileImage", err);
|
||||
// return `#151515B3`;
|
||||
// });
|
||||
// dispatch(setProfileBackground(profileBackground));
|
||||
|
||||
window.localStorage.setItem(
|
||||
"userDetails",
|
||||
JSON.stringify({ ...userDetails, profileBackground })
|
||||
);
|
||||
} else {
|
||||
const profileBackground = `#151515B3`;
|
||||
dispatch(setProfileBackground(profileBackground));
|
||||
|
||||
window.localStorage.setItem(
|
||||
"userDetails",
|
||||
JSON.stringify({ ...userDetails, profileBackground })
|
||||
);
|
||||
}
|
||||
window.localStorage.setItem("userDetails", JSON.stringify(userDetails));
|
||||
},
|
||||
[dispatch, profileBackground]
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const fetchUserDetails = useCallback(async () => {
|
||||
|
||||
3
src/renderer/src/logger.ts
Normal file
3
src/renderer/src/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import log from "electron-log/renderer";
|
||||
|
||||
export const logger = log.scope("renderer");
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { store } from "./store";
|
||||
|
||||
import resources from "@locales";
|
||||
import { Achievement } from "./pages/achievement/achievement";
|
||||
|
||||
import "./workers";
|
||||
import { RepacksContextProvider } from "./context";
|
||||
@@ -64,11 +65,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/" Component={Home} />
|
||||
<Route path="/catalogue" Component={Catalogue} />
|
||||
<Route path="/downloads" Component={Downloads} />
|
||||
<Route path="/game/:shop/:objectID" Component={GameDetails} />
|
||||
<Route path="/game/:shop/:objectId" Component={GameDetails} />
|
||||
<Route path="/search" Component={SearchResults} />
|
||||
<Route path="/settings" Component={Settings} />
|
||||
<Route path="/profile/:userId" Component={Profile} />
|
||||
</Route>
|
||||
<Route path="/achievement-notification" Component={Achievement} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</RepacksContextProvider>
|
||||
|
||||
44
src/renderer/src/pages/achievement/achievement.css.ts
Normal file
44
src/renderer/src/pages/achievement/achievement.css.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
import { vars } from "../../theme.css";
|
||||
import { keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
const animationIn = keyframes({
|
||||
"0%": { transform: `translateY(-240px)` },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
});
|
||||
|
||||
const animationOut = keyframes({
|
||||
"0%": { transform: `translateY(0)` },
|
||||
"100%": { transform: "translateY(-240px)" },
|
||||
});
|
||||
|
||||
export const container = recipe({
|
||||
base: {
|
||||
marginTop: "24px",
|
||||
marginLeft: "24px",
|
||||
animationDuration: "1.0s",
|
||||
height: "60px",
|
||||
display: "flex",
|
||||
},
|
||||
variants: {
|
||||
closing: {
|
||||
true: {
|
||||
animationName: animationOut,
|
||||
transform: "translateY(-240px)",
|
||||
},
|
||||
false: {
|
||||
animationName: animationIn,
|
||||
transform: "translateY(0)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
background: vars.color.background,
|
||||
paddingRight: "8px",
|
||||
});
|
||||
117
src/renderer/src/pages/achievement/achievement.tsx
Normal file
117
src/renderer/src/pages/achievement/achievement.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import achievementSound from "@renderer/assets/audio/achievement.wav";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./achievement.css";
|
||||
|
||||
interface AchievementInfo {
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
const NOTIFICATION_TIMEOUT = 4000;
|
||||
|
||||
export function Achievement() {
|
||||
const { t } = useTranslation("achievement");
|
||||
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [achievements, setAchievements] = useState<AchievementInfo[]>([]);
|
||||
const [currentAchievement, setCurrentAchievement] =
|
||||
useState<AchievementInfo | null>(null);
|
||||
|
||||
const achievementAnimation = useRef(-1);
|
||||
const closingAnimation = useRef(-1);
|
||||
const visibleAnimation = useRef(-1);
|
||||
|
||||
const audio = useMemo(() => {
|
||||
const audio = new Audio(achievementSound);
|
||||
audio.volume = 0.2;
|
||||
audio.preload = "auto";
|
||||
return audio;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onAchievementUnlocked(
|
||||
(_object, _shop, achievements) => {
|
||||
if (!achievements || !achievements.length) return;
|
||||
|
||||
setAchievements((ach) => ach.concat(achievements));
|
||||
|
||||
audio.play();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [audio]);
|
||||
|
||||
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 <= 1000) {
|
||||
closingAnimation.current = requestAnimationFrame(animateClosing);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
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();
|
||||
setAchievements((ach) => ach.slice(1));
|
||||
}
|
||||
achievementAnimation.current = requestAnimationFrame(animateLock);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
startAnimateClosing();
|
||||
}
|
||||
}, [hasAchievementsPending]);
|
||||
|
||||
useEffect(() => {
|
||||
if (achievements.length) {
|
||||
setCurrentAchievement(achievements[0]);
|
||||
}
|
||||
}, [achievements]);
|
||||
|
||||
if (!isVisible || !currentAchievement) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.container({ closing: isClosing })}>
|
||||
<div className={styles.content}>
|
||||
<img
|
||||
src={currentAchievement.iconUrl}
|
||||
alt={currentAchievement.displayName}
|
||||
style={{ flex: 1, width: "60px" }}
|
||||
/>
|
||||
<div>
|
||||
<p>{t("achievement_unlocked")}</p>
|
||||
<p>{currentAchievement.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,10 @@ export function Catalogue() {
|
||||
|
||||
const contentRef = useRef<HTMLElement>(null);
|
||||
|
||||
const cursorRef = useRef<number>(0);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const cursor = Number(searchParams.get("cursor") ?? 0);
|
||||
const skip = Number(searchParams.get("skip") ?? 0);
|
||||
|
||||
const handleGameClick = (game: CatalogueEntry) => {
|
||||
dispatch(clearSearch());
|
||||
@@ -42,11 +40,10 @@ export function Catalogue() {
|
||||
setSearchResults([]);
|
||||
|
||||
window.electron
|
||||
.getGames(24, cursor)
|
||||
.then(({ results, cursor }) => {
|
||||
.getGames(24, skip)
|
||||
.then((results) => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
cursorRef.current = cursor;
|
||||
setSearchResults(results);
|
||||
resolve(null);
|
||||
}, 500);
|
||||
@@ -55,11 +52,11 @@ export function Catalogue() {
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [dispatch, cursor, searchParams]);
|
||||
}, [dispatch, skip, searchParams]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
const params = new URLSearchParams({
|
||||
cursor: cursorRef.current.toString(),
|
||||
skip: String(skip + 24),
|
||||
});
|
||||
|
||||
navigate(`/catalogue?${params.toString()}`);
|
||||
@@ -80,7 +77,7 @@ export function Catalogue() {
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
theme="outline"
|
||||
disabled={cursor === 0 || isLoading}
|
||||
disabled={skip === 0 || isLoading}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
{t("previous_page")}
|
||||
@@ -103,7 +100,7 @@ export function Catalogue() {
|
||||
<>
|
||||
{searchResults.map((game) => (
|
||||
<GameCard
|
||||
key={game.objectID}
|
||||
key={game.objectId}
|
||||
game={game}
|
||||
onClick={() => handleGameClick(game)}
|
||||
/>
|
||||
|
||||
@@ -93,6 +93,7 @@ export const downloadRightContent = style({
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
flex: "1",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
background: "linear-gradient(90deg, transparent 20%, rgb(0 0 0 / 20%) 100%)",
|
||||
});
|
||||
|
||||
export const downloadActions = style({
|
||||
|
||||
@@ -227,7 +227,14 @@ export function DownloadGroup({
|
||||
<button
|
||||
type="button"
|
||||
className={styles.downloadTitle}
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectID,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
|
||||
export const artifacts = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
flexDirection: "column",
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
});
|
||||
|
||||
export const artifactButton = style({
|
||||
display: "flex",
|
||||
textAlign: "left",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.body,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
border: `1px solid ${vars.color.border}`,
|
||||
borderRadius: "4px",
|
||||
justifyContent: "space-between",
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Modal, ModalProps } from "@renderer/components";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { cloudSyncContext } from "@renderer/context";
|
||||
|
||||
export interface CloudSyncFilesModalProps
|
||||
extends Omit<ModalProps, "children" | "title"> {}
|
||||
|
||||
export function CloudSyncFilesModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: CloudSyncFilesModalProps) {
|
||||
const { backupPreview } = useContext(cloudSyncContext);
|
||||
|
||||
console.log(backupPreview);
|
||||
|
||||
const files = useMemo(() => {
|
||||
if (!backupPreview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [game] = Object.values(backupPreview.games);
|
||||
const entries = Object.entries(game.files);
|
||||
|
||||
return entries.map(([key, value]) => {
|
||||
return { path: key, ...value };
|
||||
});
|
||||
}, [backupPreview]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="Gerenciar arquivos"
|
||||
description="Escolha quais diretórios serão sincronizados"
|
||||
onClose={onClose}
|
||||
>
|
||||
{/* <div className={styles.downloaders}>
|
||||
{["AUTOMATIC", "CUSTOM"].map((downloader) => (
|
||||
<Button
|
||||
key={downloader}
|
||||
className={styles.downloaderOption}
|
||||
theme={selectedDownloader === downloader ? "primary" : "outline"}
|
||||
disabled={
|
||||
downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken
|
||||
}
|
||||
onClick={() => setSelectedDownloader(downloader)}
|
||||
>
|
||||
{selectedDownloader === downloader && (
|
||||
<CheckCircleFillIcon className={styles.downloaderIcon} />
|
||||
)}
|
||||
{DOWNLOADER_NAME[downloader]}
|
||||
</Button>
|
||||
))}
|
||||
</div> */}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>Arquivo</th>
|
||||
<th style={{ textAlign: "left" }}>Hash</th>
|
||||
<th style={{ textAlign: "left" }}>Tamanho</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file) => (
|
||||
<tr key={file.path}>
|
||||
<td style={{ textAlign: "left" }}>{file.path}</td>
|
||||
<td style={{ textAlign: "left" }}>{file.change}</td>
|
||||
<td style={{ textAlign: "left" }}>{file.path}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
|
||||
export const rotate = keyframes({
|
||||
"0%": { transform: "rotate(0deg)" },
|
||||
"100%": {
|
||||
transform: "rotate(360deg)",
|
||||
},
|
||||
});
|
||||
|
||||
export const artifacts = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
flexDirection: "column",
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
});
|
||||
|
||||
export const artifactButton = style({
|
||||
display: "flex",
|
||||
textAlign: "left",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.body,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
border: `1px solid ${vars.color.border}`,
|
||||
borderRadius: "4px",
|
||||
justifyContent: "space-between",
|
||||
});
|
||||
|
||||
export const syncIcon = style({
|
||||
animationName: rotate,
|
||||
animationDuration: "1s",
|
||||
animationIterationCount: "infinite",
|
||||
animationTimingFunction: "linear",
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Button, Modal, ModalProps } from "@renderer/components";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import * as styles from "./cloud-sync-modal.css";
|
||||
import { formatBytes } from "@shared";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
CheckCircleFillIcon,
|
||||
ClockIcon,
|
||||
DeviceDesktopIcon,
|
||||
HistoryIcon,
|
||||
SyncIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { GameBackup, gameBackupsTable } from "@renderer/dexie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosProgressEvent } from "axios";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
|
||||
export interface CloudSyncModalProps
|
||||
extends Omit<ModalProps, "children" | "title"> {}
|
||||
|
||||
export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
const [deletingArtifact, setDeletingArtifact] = useState(false);
|
||||
const [lastBackup, setLastBackup] = useState<GameBackup | null>(null);
|
||||
const [backupDownloadProgress, setBackupDownloadProgress] =
|
||||
useState<AxiosProgressEvent | null>(null);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
backupPreview,
|
||||
uploadingBackup,
|
||||
restoringBackup,
|
||||
uploadSaveGame,
|
||||
downloadGameArtifact,
|
||||
deleteGameArtifact,
|
||||
setShowCloudSyncFilesModal,
|
||||
} = useContext(cloudSyncContext);
|
||||
|
||||
const { objectId, shop, gameTitle } = useContext(gameDetailsContext);
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const handleDeleteArtifactClick = async (gameArtifactId: string) => {
|
||||
setDeletingArtifact(true);
|
||||
|
||||
try {
|
||||
await deleteGameArtifact(gameArtifactId);
|
||||
|
||||
showSuccessToast(t("backup_deleted"));
|
||||
} catch (err) {
|
||||
showErrorToast("backup_deletion_failed");
|
||||
} finally {
|
||||
setDeletingArtifact(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
gameBackupsTable
|
||||
.where({ shop: shop, objectId })
|
||||
.last()
|
||||
.then((lastBackup) => setLastBackup(lastBackup || null));
|
||||
|
||||
const removeBackupDownloadProgressListener =
|
||||
window.electron.onBackupDownloadProgress(
|
||||
objectId!,
|
||||
shop,
|
||||
(progressEvent) => {
|
||||
setBackupDownloadProgress(progressEvent);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeBackupDownloadProgressListener();
|
||||
};
|
||||
}, [backupPreview, objectId, shop]);
|
||||
|
||||
const handleBackupInstallClick = async (artifactId: string) => {
|
||||
setBackupDownloadProgress(null);
|
||||
downloadGameArtifact(artifactId);
|
||||
};
|
||||
|
||||
const backupStateLabel = useMemo(() => {
|
||||
if (uploadingBackup) {
|
||||
return (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<SyncIcon className={styles.syncIcon} />
|
||||
{t("uploading_backup")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (restoringBackup) {
|
||||
return (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<SyncIcon className={styles.syncIcon} />
|
||||
{t("restoring_backup", {
|
||||
progress: formatDownloadProgress(
|
||||
backupDownloadProgress?.progress ?? 0
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastBackup) {
|
||||
return (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<i style={{ color: vars.color.success }}>
|
||||
<CheckCircleFillIcon />
|
||||
</i>
|
||||
|
||||
{t("last_backup_date", {
|
||||
date: format(lastBackup.createdAt, "dd/MM/yyyy HH:mm"),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!backupPreview) {
|
||||
return t("no_backup_preview");
|
||||
}
|
||||
|
||||
return t("no_backups");
|
||||
}, [
|
||||
uploadingBackup,
|
||||
backupDownloadProgress?.progress,
|
||||
lastBackup,
|
||||
backupPreview,
|
||||
restoringBackup,
|
||||
t,
|
||||
]);
|
||||
|
||||
const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("cloud_save")}
|
||||
description={t("cloud_save_description")}
|
||||
onClose={onClose}
|
||||
large
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
|
||||
<h2>{gameTitle}</h2>
|
||||
<p>{backupStateLabel}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
alignSelf: "flex-start",
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
color: vars.color.body,
|
||||
}}
|
||||
onClick={() => setShowCloudSyncFilesModal(true)}
|
||||
>
|
||||
Gerenciar arquivos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={uploadSaveGame}
|
||||
disabled={disableActions || !backupPreview}
|
||||
>
|
||||
<UploadIcon />
|
||||
{t("create_backup")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: SPACING_UNIT,
|
||||
}}
|
||||
>
|
||||
<h2>{t("backups")}</h2>
|
||||
<small>{artifacts.length} / 2</small>
|
||||
</div>
|
||||
|
||||
<ul className={styles.artifacts}>
|
||||
{artifacts.map((artifact) => (
|
||||
<li key={artifact.id} className={styles.artifactButton}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<h3>Backup do dia {format(artifact.createdAt, "dd/MM")}</h3>
|
||||
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
|
||||
</div>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<DeviceDesktopIcon size={14} />
|
||||
{artifact.hostname}
|
||||
</span>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<ClockIcon size={14} />
|
||||
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleBackupInstallClick(artifact.id)}
|
||||
disabled={disableActions}
|
||||
>
|
||||
<HistoryIcon />
|
||||
{t("install_backup")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDeleteArtifactClick(artifact.id)}
|
||||
theme="danger"
|
||||
disabled={disableActions}
|
||||
>
|
||||
<TrashIcon />
|
||||
{t("delete_backup")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user