diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5cc4aa4d..1a01d550 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96b6a08d..9fc71ec1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.gitignore b/.gitignore index fb4badd7..b9dcfecb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md index 64612f4b..427a5f59 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,20 @@ [![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) [![release](https://img.shields.io/github/package-json/v/hydralauncher/hydra)](https://github.com/hydralauncher/hydra/releases) -[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](README.pt-BR.md) -[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) -[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](README.ru.md) -[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](README.uk-UA.md) -[![be](https://img.shields.io/badge/lang-be-orange)](README.be.md) -[![es](https://img.shields.io/badge/lang-es-red)](README.es.md) -[![fr](https://img.shields.io/badge/lang-fr-blue)](README.fr.md) -[![de](https://img.shields.io/badge/lang-de-black)](README.de.md) -[![ita](https://img.shields.io/badge/lang-it-red)](README.it.md) -[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md) -[![da](https://img.shields.io/badge/lang-da-red)](README.da.md) -[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) +[![pt-BR](https://img.shields.io/badge/lang-pt--BR-green.svg)](./README.pt-BR.md) +[![en](https://img.shields.io/badge/lang-en-red.svg)](./README.md) +[![ru](https://img.shields.io/badge/lang-ru-yellow.svg)](./README.ru.md) +[![uk-UA](https://img.shields.io/badge/lang-uk--UA-blue)](./README.uk-UA.md) +[![be](https://img.shields.io/badge/lang-be-orange)](./README.be.md) +[![es](https://img.shields.io/badge/lang-es-red)](./README.es.md) +[![fr](https://img.shields.io/badge/lang-fr-blue)](./README.fr.md) +[![de](https://img.shields.io/badge/lang-de-black)](./README.de.md) +[![ita](https://img.shields.io/badge/lang-it-red)](./README.it.md) +[![cs](https://img.shields.io/badge/lang-cs-purple)](./README.cs.md) +[![da](https://img.shields.io/badge/lang-da-red)](./README.da.md) +[![nb](https://img.shields.io/badge/lang-nb-blue)](./README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.be.md b/docs/README.be.md similarity index 99% rename from README.be.md rename to docs/README.be.md index cc6bafb5..b861d582 100644 --- a/README.be.md +++ b/docs/README.be.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.cs.md b/docs/README.cs.md similarity index 99% rename from README.cs.md rename to docs/README.cs.md index 7179711a..866a841c 100644 --- a/README.cs.md +++ b/docs/README.cs.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Katalog](./docs/screenshot.png) +![Hydra Katalog](./screenshot.png) diff --git a/README.da.md b/docs/README.da.md similarity index 99% rename from README.da.md rename to docs/README.da.md index 9f0eb7f7..abfe7817 100644 --- a/README.da.md +++ b/docs/README.da.md @@ -25,7 +25,7 @@ [![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md) [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.de.md b/docs/README.de.md similarity index 99% rename from README.de.md rename to docs/README.de.md index 1d7f05f8..a1629fbb 100644 --- a/README.de.md +++ b/docs/README.de.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Katalog](./docs/screenshot.png) +![Hydra Katalog](./screenshot.png) diff --git a/README.es.md b/docs/README.es.md similarity index 99% rename from README.es.md rename to docs/README.es.md index 09d8e4e2..525d3e02 100644 --- a/README.es.md +++ b/docs/README.es.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.fr.md b/docs/README.fr.md similarity index 99% rename from README.fr.md rename to docs/README.fr.md index 351b73a9..648c30ea 100644 --- a/README.fr.md +++ b/docs/README.fr.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Catalogue Hydra](./docs/screenshot.png) +![Catalogue Hydra](./screenshot.png) diff --git a/README.it.md b/docs/README.it.md similarity index 99% rename from README.it.md rename to docs/README.it.md index b78abe2b..656a9aac 100644 --- a/README.it.md +++ b/docs/README.it.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.nb.md b/docs/README.nb.md similarity index 99% rename from README.nb.md rename to docs/README.nb.md index 5be4fcbb..62f04781 100644 --- a/README.nb.md +++ b/docs/README.nb.md @@ -25,7 +25,7 @@ [![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.pl.md b/docs/README.pl.md similarity index 99% rename from README.pl.md rename to docs/README.pl.md index b4cd5a6a..2ee4e847 100644 --- a/README.pl.md +++ b/docs/README.pl.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.pt-BR.md b/docs/README.pt-BR.md similarity index 99% rename from README.pt-BR.md rename to docs/README.pt-BR.md index 8eee0c06..9e6d9f6a 100644 --- a/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.ru.md b/docs/README.ru.md similarity index 99% rename from README.ru.md rename to docs/README.ru.md index 7bc0d9d8..6c0a6a0f 100644 --- a/README.ru.md +++ b/docs/README.ru.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/README.uk-UA.md b/docs/README.uk-UA.md similarity index 99% rename from README.uk-UA.md rename to docs/README.uk-UA.md index d69ffc21..db2f2c12 100644 --- a/README.uk-UA.md +++ b/docs/README.uk-UA.md @@ -26,7 +26,7 @@ [![da](https://img.shields.io/badge/lang-da-red)](README.da.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) -![Hydra Catalogue](./docs/screenshot.png) +![Hydra Catalogue](./screenshot.png) diff --git a/electron-builder.yml b/electron-builder.yml index 06473566..a7151ed3 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,6 +3,7 @@ productName: Hydra directories: buildResources: build extraResources: + - ludusavi - hydra-download-manager - seeds - from: node_modules/create-desktop-shortcuts/src/windows.vbs diff --git a/ludusavi/ludusavi.exe b/ludusavi/ludusavi.exe deleted file mode 100644 index da9835ab..00000000 Binary files a/ludusavi/ludusavi.exe and /dev/null differ diff --git a/package.json b/package.json index 9d2cf8bf..601b399f 100644 --- a/package.json +++ b/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", @@ -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", diff --git a/postinstall.cjs b/postinstall.cjs new file mode 100644 index 00000000..25d27c0a --- /dev/null +++ b/postinstall.cjs @@ -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(); diff --git a/requirements.txt b/requirements.txt index 3685495b..cdd5371d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ libtorrent cx_Freeze cx_Logging; sys_platform == 'win32' -lief; sys_platform == 'win32' pywin32; sys_platform == 'win32' psutil Pillow +requests diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2849bb20..931f295e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -131,7 +131,21 @@ "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.", - "achievements": "Achievements" + "achievements": "Achievements {{unlockedCount}}/{{achievementsCount}}", + "cloud_save": "Cloud save", + "cloud_save_description": "Save your progress in the cloud and continue playing on any device", + "backups": "Backups", + "install_backup": "Install", + "delete_backup": "Delete", + "create_backup": "New backup", + "last_backup_date": "Last backup on {{date}}", + "no_backup_preview": "No save games were found for this title", + "restoring_backup": "Restoring backup ({{progress}} complete)…", + "uploading_backup": "Uploading backup…", + "no_backups": "You haven't created any backups for this game yet", + "backup_uploaded": "Backup uploaded", + "backup_deleted": "Backup deleted", + "backup_restored": "Backup restored" }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 83e35fac..5b811f7b 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -127,7 +127,21 @@ "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.", - "achievements": "Conquistas" + "achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}", + "cloud_save": "Salvamento em nuvem", + "cloud_save_description": "Matenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo", + "backups": "Backups", + "install_backup": "Restaurar", + "delete_backup": "Apagar", + "create_backup": "Novo backup", + "last_backup_date": "Último backup em {{date}}", + "no_backup_preview": "Não foi possível encontrar nenhum salvamento para este jogo", + "restoring_backup": "Restaurando backup ({{progress}} concluído)…", + "uploading_backup": "Criando backup…", + "no_backups": "Você ainda não fez nenhum backup deste jogo", + "backup_uploaded": "Backup criado", + "backup_deleted": "Backup apagado", + "backup_restored": "Backup restaurado" }, "activation": { "title": "Ativação", @@ -169,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", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 27fbb932..fec0c366 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -116,7 +116,7 @@ "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.", - "achievements": "Conquistas" + "achievements": "Conquistas {{unlockedCount}}/{{achievementsCount}}" }, "activation": { "title": "Ativação", @@ -158,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", diff --git a/src/main/constants.ts b/src/main/constants.ts index e1d05d73..4e2f1c00 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -17,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(); diff --git a/src/main/entity/game-shop-cache.entity.ts b/src/main/entity/game-shop-cache.entity.ts index f83e1b0c..3382da1c 100644 --- a/src/main/entity/game-shop-cache.entity.ts +++ b/src/main/entity/game-shop-cache.entity.ts @@ -18,6 +18,9 @@ export class GameShopCache { @Column("text", { nullable: true }) serializedData: string; + /** + * @deprecated Use IndexedDB's `howLongToBeatEntries` instead + */ @Column("text", { nullable: true }) howLongToBeatSerializedData: string; diff --git a/src/main/events/catalogue/get-catalogue.ts b/src/main/events/catalogue/get-catalogue.ts index 4fdb95bd..145f3166 100644 --- a/src/main/events/catalogue/get-catalogue.ts +++ b/src/main/events/catalogue/get-catalogue.ts @@ -30,7 +30,7 @@ const getCatalogue = async ( title: steamGame.name, shop: game.shop, cover: steamUrlBuilder.library(game.objectId), - objectID: game.objectId, + objectId: game.objectId, }; }) ); diff --git a/src/main/events/catalogue/get-game-shop-details.ts b/src/main/events/catalogue/get-game-shop-details.ts index 3a435013..08366abc 100644 --- a/src/main/events/catalogue/get-game-shop-details.ts +++ b/src/main/events/catalogue/get-game-shop-details.ts @@ -7,16 +7,16 @@ import { registerEvent } from "../register-event"; import { steamGamesWorker } from "@main/workers"; const getLocalizedSteamAppDetails = async ( - objectID: string, + objectId: string, language: string ): Promise => { 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 => { 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; } diff --git a/src/main/events/catalogue/get-games.ts b/src/main/events/catalogue/get-games.ts index 81717806..3eb1f135 100644 --- a/src/main/events/catalogue/get-games.ts +++ b/src/main/events/catalogue/get-games.ts @@ -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 => { + const searchParams = new URLSearchParams({ + take: take.toString(), + skip: skip.toString(), + }); + + const games = await HydraApi.get( + `/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); diff --git a/src/main/events/catalogue/get-how-long-to-beat.ts b/src/main/events/catalogue/get-how-long-to-beat.ts index 642dd9a3..01966afc 100644 --- a/src/main/events/catalogue/get-how-long-to-beat.ts +++ b/src/main/events/catalogue/get-how-long-to-beat.ts @@ -1,45 +1,23 @@ -import type { GameShop, HowLongToBeatCategory } from "@types"; +import type { HowLongToBeatCategory } from "@types"; import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services"; import { registerEvent } from "../register-event"; -import { gameShopCacheRepository } from "@main/repository"; +import { formatName } from "@shared"; const getHowLongToBeat = async ( _event: Electron.IpcMainInvokeEvent, - objectID: string, - shop: GameShop, title: string ): Promise => { - const searchHowLongToBeatPromise = searchHowLongToBeat(title); + const response = await searchHowLongToBeat(title); - const gameShopCache = await gameShopCacheRepository.findOne({ - where: { objectID, shop }, + const game = response.data.find((game) => { + return formatName(game.game_name) === formatName(title); }); - const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData - ? JSON.parse(gameShopCache?.howLongToBeatSerializedData) - : null; - if (howLongToBeatCachedData) return howLongToBeatCachedData; + if (!game) return null; + const howLongToBeat = await getHowLongToBeatGame(String(game.game_id)); - return searchHowLongToBeatPromise.then(async (response) => { - const game = response.data.find( - (game) => game.profile_steam === Number(objectID) - ); - - if (!game) return null; - const howLongToBeat = await getHowLongToBeatGame(String(game.game_id)); - - gameShopCacheRepository.upsert( - { - objectID, - shop, - howLongToBeatSerializedData: JSON.stringify(howLongToBeat), - }, - ["objectID"] - ); - - return howLongToBeat; - }); + return howLongToBeat; }; registerEvent("getHowLongToBeat", getHowLongToBeat); diff --git a/src/main/events/cloud-save/check-game-cloud-sync-support.ts b/src/main/events/cloud-save/check-game-cloud-sync-support.ts new file mode 100644 index 00000000..4054d430 --- /dev/null +++ b/src/main/events/cloud-save/check-game-cloud-sync-support.ts @@ -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); diff --git a/src/main/events/cloud-save/delete-game-artifact.ts b/src/main/events/cloud-save/delete-game-artifact.ts new file mode 100644 index 00000000..e293bc56 --- /dev/null +++ b/src/main/events/cloud-save/delete-game-artifact.ts @@ -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); diff --git a/src/main/events/cloud-save/download-game-artifact.ts b/src/main/events/cloud-save/download-game-artifact.ts new file mode 100644 index 00000000..d0e8d845 --- /dev/null +++ b/src/main/events/cloud-save/download-game-artifact.ts @@ -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; + }; + + 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); diff --git a/src/main/events/cloud-save/get-game-artifacts.ts b/src/main/events/cloud-save/get-game-artifacts.ts new file mode 100644 index 00000000..fc47076a --- /dev/null +++ b/src/main/events/cloud-save/get-game-artifacts.ts @@ -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( + `/profile/games/artifacts?${params.toString()}` + ); +}; + +registerEvent("getGameArtifacts", getGameArtifacts); diff --git a/src/main/events/cloud-save/get-game-backup-preview.ts b/src/main/events/cloud-save/get-game-backup-preview.ts new file mode 100644 index 00000000..433fccc4 --- /dev/null +++ b/src/main/events/cloud-save/get-game-backup-preview.ts @@ -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); diff --git a/src/main/events/cloud-save/upload-save-game.ts b/src/main/events/cloud-save/upload-save-game.ts new file mode 100644 index 00000000..919cfd9e --- /dev/null +++ b/src/main/events/cloud-save/upload-save-game.ts @@ -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); diff --git a/src/main/events/helpers/search-games.ts b/src/main/events/helpers/search-games.ts index 1f1fc756..74e0b6a8 100644 --- a/src/main/events/helpers/search-games.ts +++ b/src/main/events/helpers/search-games.ts @@ -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)), diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 22eb341f..37e28447 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -57,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"; diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index a47c96e5..898c25cd 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -11,14 +11,14 @@ import { updateLocalUnlockedAchivements } from "@main/services/achievements/upda const addGameToLibrary = async ( _event: Electron.IpcMainInvokeEvent, - objectID: string, + objectId: string, title: string, shop: GameShop ) => { return gameRepository .update( { - objectID, + objectID: objectId, }, { shop, @@ -28,23 +28,25 @@ 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!); diff --git a/src/main/events/library/get-game-by-object-id.ts b/src/main/events/library/get-game-by-object-id.ts index 91cc1b5a..d68aac69 100644 --- a/src/main/events/library/get-game-by-object-id.ts +++ b/src/main/events/library/get-game-by-object-id.ts @@ -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); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index a2c51a01..deac1d2c 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -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, }, }); diff --git a/src/main/index.ts b/src/main/index.ts index 42080d1f..1b1629ef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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"; @@ -104,46 +104,6 @@ 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) => { diff --git a/src/main/services/how-long-to-beat.ts b/src/main/services/how-long-to-beat.ts index c7164d09..5a82d8e7 100644 --- a/src/main/services/how-long-to-beat.ts +++ b/src/main/services/how-long-to-beat.ts @@ -1,32 +1,65 @@ import axios from "axios"; import { requestWebPage } from "@main/helpers"; -import { HowLongToBeatCategory } from "@types"; +import type { + HowLongToBeatCategory, + HowLongToBeatSearchResponse, +} from "@types"; import { formatName } from "@shared"; import { logger } from "./logger"; +import UserAgent from "user-agents"; -export interface HowLongToBeatResult { - game_id: number; - profile_steam: number; -} +const state = { + apiKey: null as string | null, +}; -export interface HowLongToBeatSearchResponse { - data: HowLongToBeatResult[]; -} +const getHowLongToBeatSearchApiKey = async () => { + const userAgent = new UserAgent(); + + const document = await requestWebPage("https://howlongtobeat.com/"); + const scripts = Array.from(document.querySelectorAll("script")); + + const appScript = scripts.find((script) => + script.src.startsWith("/_next/static/chunks/pages/_app") + ); + + if (!appScript) return null; + + const response = await axios.get( + `https://howlongtobeat.com${appScript.src}`, + { + headers: { + "User-Agent": userAgent.toString(), + }, + } + ); + + const results = /fetch\("\/api\/search\/"\.concat\("(.*?)"\)/gm.exec( + response.data + ); + + if (!results) return null; + + return results[1]; +}; export const searchHowLongToBeat = async (gameName: string) => { + state.apiKey = state.apiKey ?? (await getHowLongToBeatSearchApiKey()); + if (!state.apiKey) return { data: [] }; + + const userAgent = new UserAgent(); + const response = await axios .post( - "https://howlongtobeat.com/api/search", + "https://howlongtobeat.com/api/search/8fbd64723a8204dd", { searchType: "games", searchTerms: formatName(gameName).split(" "), searchPage: 1, - size: 100, + size: 20, }, { headers: { - "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", + "User-Agent": userAgent.toString(), Referer: "https://howlongtobeat.com/", }, } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 8664062f..27abf579 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -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"; diff --git a/src/main/services/ludusavi.ts b/src/main/services/ludusavi.ts new file mode 100644 index 00000000..838b5f9b --- /dev/null +++ b/src/main/services/ludusavi.ts @@ -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 { + const games = await this.worker.run( + { objectId, shop }, + { name: "findGames" } + ); + + return games; + } + + static async backupGame( + shop: GameShop, + objectId: string, + backupPath: string + ): Promise { + 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 { + 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" }); + } +} diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index ac2e35e5..51b39bbe 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -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"; diff --git a/src/main/services/steam-250.ts b/src/main/services/steam-250.ts index 9833c278..0abc2f14 100644 --- a/src/main/services/steam-250.ts +++ b/src/main/services/steam-250.ts @@ -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 = gamesList.reduce((map, item) => { - if (item) map.set(item.objectID, item); + if (item) map.set(item.objectId, item); return map; }, new Map()); diff --git a/src/main/services/steam-grid.ts b/src/main/services/steam-grid.ts index c762eaf6..540e5857 100644 --- a/src/main/services/steam-grid.ts +++ b/src/main/services/steam-grid.ts @@ -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 = {} ): Promise => { 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; diff --git a/src/main/services/steam.ts b/src/main/services/steam.ts index 53e243f3..1d8ed2d9 100644 --- a/src/main/services/steam.ts +++ b/src/main/services/steam.ts @@ -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) => { diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 76a5d830..eaa05f7a 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -16,6 +16,7 @@ import trayIcon from "@resources/tray-icon.png?asset"; import { gameRepository, userPreferencesRepository } from "@main/repository"; import { IsNull, Not } from "typeorm"; import { HydraApi } from "./hydra-api"; +import UserAgent from "user-agents"; export class WindowManager { public static mainWindow: Electron.BrowserWindow | null = null; @@ -77,6 +78,54 @@ export class WindowManager { show: false, }); + this.mainWindow.webContents.session.webRequest.onBeforeSendHeaders( + (details, callback) => { + const userAgent = new UserAgent(); + + callback({ + requestHeaders: { + ...details.requestHeaders, + "user-agent": userAgent.toString(), + }, + }); + } + ); + + this.mainWindow.webContents.session.webRequest.onHeadersReceived( + (details, callback) => { + if (details.webContentsId !== this.mainWindow?.webContents.id) { + return callback(details); + } + + const headers = { + "access-control-allow-origin": ["*"], + "access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"], + "access-control-expose-headers": ["ETag"], + "access-control-allow-headers": [ + "Content-Type, Authorization, X-Requested-With, If-None-Match", + ], + }; + + if (details.method === "OPTIONS") { + return callback({ + cancel: false, + responseHeaders: { + ...details.responseHeaders, + ...headers, + }, + statusLine: "HTTP/1.1 200 OK", + }); + } + + return callback({ + responseHeaders: { + ...details.responseHeaders, + ...headers, + }, + }); + } + ); + this.loadMainWindowURL(); this.mainWindow.removeMenu(); @@ -116,11 +165,10 @@ export class WindowManager { sandbox: false, }, }); - this.notificationWindow.setIgnoreMouseEvents(true); - this.notificationWindow.setVisibleOnAllWorkspaces(true, { - visibleOnFullScreen: true, - }); + // this.notificationWindow.setVisibleOnAllWorkspaces(true, { + // visibleOnFullScreen: true, + // }); this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1); this.loadNotificationWindowURL(); } @@ -145,6 +193,8 @@ export class WindowManager { authWindow.removeMenu(); + if (!app.isPackaged) authWindow.webContents.openDevTools(); + const searchParams = new URLSearchParams({ lng: i18next.language, }); @@ -176,7 +226,7 @@ export class WindowManager { } public static createSystemTray(language: string) { - let tray; + let tray: Tray; if (process.platform === "darwin") { const macIcon = nativeImage diff --git a/src/main/workers/ludusavi.worker.ts b/src/main/workers/ludusavi.worker.ts new file mode 100644 index 00000000..e6ccdaad --- /dev/null +++ b/src/main/workers/ludusavi.worker.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 0a7e8fce..8c0486a4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,7 @@ import type { UpdateProfileRequest, } from "@types"; import type { CatalogueCategory } from "@shared"; +import type { AxiosProgressEvent } from "axios"; contextBridge.exposeInMainWorld("electron", { /* Torrenting */ @@ -37,13 +38,13 @@ 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: (title: string) => + ipcRenderer.invoke("getHowLongToBeat", title), + getGames: (take?: number, skip?: number) => + ipcRenderer.invoke("getGames", take, skip), searchGameRepacks: (query: string) => ipcRenderer.invoke("searchGameRepacks", query), getGameStats: (objectId: string, shop: GameShop) => @@ -83,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) => @@ -106,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[] @@ -129,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"), diff --git a/src/renderer/src/app.css.ts b/src/renderer/src/app.css.ts index 40e15835..a52d81f6 100644 --- a/src/renderer/src/app.css.ts +++ b/src/renderer/src/app.css.ts @@ -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%", }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 37e63154..7c572a56 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -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(); diff --git a/src/renderer/src/assets/lottie/cloud.json b/src/renderer/src/assets/lottie/cloud.json new file mode 100644 index 00000000..c8e4bce7 --- /dev/null +++ b/src/renderer/src/assets/lottie/cloud.json @@ -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": {} +} diff --git a/src/renderer/src/components/game-card/game-card.tsx b/src/renderer/src/components/game-card/game-card.tsx index 9d54bad8..c548be52 100644 --- a/src/renderer/src/components/game-card/game-card.tsx +++ b/src/renderer/src/components/game-card/game-card.tsx @@ -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); }); } diff --git a/src/renderer/src/components/sidebar/sidebar.tsx b/src/renderer/src/components/sidebar/sidebar.tsx index 383d2197..c509e489 100644 --- a/src/renderer/src/components/sidebar/sidebar.tsx +++ b/src/renderer/src/components/sidebar/sidebar.tsx @@ -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); } diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx new file mode 100644 index 00000000..086a8c94 --- /dev/null +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -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>; + downloadGameArtifact: (gameArtifactId: string) => Promise; + uploadSaveGame: () => Promise; + deleteGameArtifact: (gameArtifactId: string) => Promise; + setShowCloudSyncFilesModal: React.Dispatch>; + restoringBackup: boolean; + uploadingBackup: boolean; +} + +export const cloudSyncContext = createContext({ + 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( + null + ); + const [artifacts, setArtifacts] = useState([]); + const [showCloudSyncModal, setShowCloudSyncModal] = useState(false); + const [backupPreview, setBackupPreview] = useState( + 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 ( + + {children} + + ); +} diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 883a80a5..2a819e1b 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -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"; @@ -33,7 +32,7 @@ export const gameDetailsContext = createContext({ gameTitle: "", isGameRunning: false, isLoading: false, - objectID: undefined, + objectId: undefined, gameColor: "", showRepacksModal: false, showGameOptionsModal: false, @@ -53,13 +52,17 @@ 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(null); const [achievements, setAchievements] = useState([]); const [game, setGame] = useState(null); @@ -75,10 +78,6 @@ export function GameDetailsContextProvider({ const [repacks, setRepacks] = useState([]); - const [searchParams] = useSearchParams(); - - const gameTitle = searchParams.get("title")!; - const { searchRepacks, isIndexingRepacks } = useContext(repacksContext); useEffect(() => { @@ -101,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; @@ -114,7 +113,7 @@ export function GameDetailsContextProvider({ useEffect(() => { window.electron .getGameShopDetails( - objectID!, + objectId!, shop as GameShop, getSteamLanguage(i18n.language) ) @@ -133,15 +132,12 @@ export function GameDetailsContextProvider({ setIsLoading(false); }); - window.electron - .getGameStats(objectID!, shop as GameShop) - .then((result) => { - setStats(result); - }) - .catch(() => {}); + window.electron.getGameStats(objectId!, shop as GameShop).then((result) => { + setStats(result); + }); window.electron - .getGameAchievements(objectID!, shop as GameShop) + .getGameAchievements(objectId!, shop as GameShop) .then((achievements) => { setAchievements(achievements); }) @@ -150,7 +146,7 @@ export function GameDetailsContextProvider({ }); updateGame(); - }, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]); + }, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]); useEffect(() => { setShopDetails(null); @@ -159,7 +155,7 @@ export function GameDetailsContextProvider({ setisGameRunning(false); setAchievements([]); dispatch(setHeaderTitle(gameTitle)); - }, [objectID, gameTitle, dispatch]); + }, [objectId, gameTitle, dispatch]); useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { @@ -181,10 +177,10 @@ export function GameDetailsContextProvider({ useEffect(() => { const unsubscribe = window.electron.onAchievementUnlocked( (objectId, shop) => { - if (objectID !== objectId || shop !== shop) return; + if (objectId !== objectId || shop !== shop) return; window.electron - .getGameAchievements(objectID!, shop as GameShop) + .getGameAchievements(objectId!, shop as GameShop) .then(setAchievements) .catch(() => {}); } @@ -193,7 +189,7 @@ export function GameDetailsContextProvider({ return () => { unsubscribe(); }; - }, [objectID, shop]); + }, [objectId, shop]); const getDownloadsPath = async () => { if (userPreferences?.downloadsPath) return userPreferences.downloadsPath; @@ -233,7 +229,7 @@ export function GameDetailsContextProvider({ gameTitle, isGameRunning, isLoading, - objectID, + objectId, gameColor, showGameOptionsModal, showRepacksModal, diff --git a/src/renderer/src/context/game-details/game-details.context.types.ts b/src/renderer/src/context/game-details/game-details.context.types.ts index 507f0a9e..956d5c68 100644 --- a/src/renderer/src/context/game-details/game-details.context.types.ts +++ b/src/renderer/src/context/game-details/game-details.context.types.ts @@ -15,7 +15,7 @@ export interface GameDetailsContext { gameTitle: string; isGameRunning: boolean; isLoading: boolean; - objectID: string | undefined; + objectId: string | undefined; gameColor: string; showRepacksModal: boolean; showGameOptionsModal: boolean; diff --git a/src/renderer/src/context/index.ts b/src/renderer/src/context/index.ts index 8d8b9223..948b90b2 100644 --- a/src/renderer/src/context/index.ts +++ b/src/renderer/src/context/index.ts @@ -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"; diff --git a/src/renderer/src/context/repacks/repacks.context.tsx b/src/renderer/src/context/repacks/repacks.context.tsx index cddbb209..b688793c 100644 --- a/src/renderer/src/context/repacks/repacks.context.tsx +++ b/src/renderer/src/context/repacks/repacks.context.tsx @@ -41,15 +41,18 @@ export function RepacksContextProvider({ children }: RepacksContextProps) { }, []); const indexRepacks = useCallback(() => { + console.log("INDEXING"); setIsIndexingRepacks(true); repacksWorker.postMessage("INDEX_REPACKS"); repacksWorker.onmessage = () => { + console.log("INDEXING COMPLETE"); setIsIndexingRepacks(false); }; }, []); useEffect(() => { + console.log("CALLED"); indexRepacks(); }, [indexRepacks]); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 351b8e0a..d83b2b6c 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -26,7 +26,10 @@ import type { UserDetails, FriendRequestSync, GameAchievement, + GameArtifact, + LudusaviBackup, } from "@types"; +import type { AxiosProgressEvent } from "axios"; import type { DiskSpace } from "check-disk-space"; declare global { @@ -49,20 +52,15 @@ declare global { searchGames: (query: string) => Promise; getCatalogue: (category: CatalogueCategory) => Promise; getGameShopDetails: ( - objectID: string, + objectId: string, shop: GameShop, language: string ) => Promise; getRandomGame: () => Promise; getHowLongToBeat: ( - objectID: string, - shop: GameShop, title: string ) => Promise; - getGames: ( - take?: number, - prevCursor?: number - ) => Promise<{ results: CatalogueEntry[]; cursor: number }>; + getGames: (take?: number, skip?: number) => Promise; searchGameRepacks: (query: string) => Promise; getGameStats: (objectId: string, shop: GameShop) => Promise; getTrendingGames: () => Promise; @@ -81,7 +79,7 @@ declare global { /* Library */ addGameToLibrary: ( - objectID: string, + objectId: string, title: string, shop: GameShop ) => Promise; @@ -97,7 +95,7 @@ declare global { removeGameFromLibrary: (gameId: number) => Promise; removeGame: (gameId: number) => Promise; deleteGameFolder: (gameId: number) => Promise; - getGameByObjectID: (objectID: string) => Promise; + getGameByObjectId: (objectId: string) => Promise; onGamesRunning: ( cb: ( gamesRunning: Pick[] @@ -120,6 +118,42 @@ declare global { /* Hardware */ getDiskFreeSpace: (path: string) => Promise; + /* Cloud save */ + uploadSaveGame: (objectId: string, shop: GameShop) => Promise; + downloadGameArtifact: ( + objectId: string, + shop: GameShop, + gameArtifactId: string + ) => Promise; + getGameArtifacts: ( + objectId: string, + shop: GameShop + ) => Promise; + getGameBackupPreview: ( + objectId: string, + shop: GameShop + ) => Promise; + checkGameCloudSyncSupport: ( + objectId: string, + shop: GameShop + ) => Promise; + 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; getVersion: () => Promise; diff --git a/src/renderer/src/dexie.ts b/src/renderer/src/dexie.ts index 23f0bf83..e0e86a7f 100644 --- a/src/renderer/src/dexie.ts +++ b/src/renderer/src/dexie.ts @@ -1,13 +1,36 @@ +import type { GameShop, HowLongToBeatCategory } from "@types"; import { Dexie } from "dexie"; +export interface GameBackup { + id?: number; + shop: GameShop; + objectId: string; + createdAt: Date; +} + +export interface HowLongToBeatEntry { + id?: number; + objectId: string; + categories: HowLongToBeatCategory[]; + shop: GameShop; + createdAt: Date; + updatedAt: Date; +} + export const db = new Dexie("Hydra"); -db.version(1).stores({ +db.version(4).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`, + howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`, }); export const downloadSourcesTable = db.table("downloadSources"); export const repacksTable = db.table("repacks"); +export const gameBackupsTable = db.table("gameBackups"); +export const howLongToBeatEntriesTable = db.table( + "howLongToBeatEntries" +); db.open(); diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index 3dea26d3..a9fc3cdd 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -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 = {} ) => { 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) => diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index 50e2fad9..7e08144d 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -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 () => { diff --git a/src/renderer/src/logger.ts b/src/renderer/src/logger.ts new file mode 100644 index 00000000..052b4452 --- /dev/null +++ b/src/renderer/src/logger.ts @@ -0,0 +1,3 @@ +import log from "electron-log/renderer"; + +export const logger = log.scope("renderer"); diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 5312530d..5f91b424 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -66,7 +66,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + diff --git a/src/renderer/src/pages/catalogue/catalogue.tsx b/src/renderer/src/pages/catalogue/catalogue.tsx index ee1f5395..0ce77fa6 100644 --- a/src/renderer/src/pages/catalogue/catalogue.tsx +++ b/src/renderer/src/pages/catalogue/catalogue.tsx @@ -24,12 +24,10 @@ export function Catalogue() { const contentRef = useRef(null); - const cursorRef = useRef(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() { diff --git a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.css.ts b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.css.ts new file mode 100644 index 00000000..bb3335fa --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.css.ts @@ -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", +}); diff --git a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx new file mode 100644 index 00000000..f8a31223 --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx @@ -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 {} + +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 ( + + {/*
+ {["AUTOMATIC", "CUSTOM"].map((downloader) => ( + + ))} +
*/} + + + + + + + + + + + {files.map((file) => ( + + + + + + ))} + +
ArquivoHashTamanho
{file.path}{file.change}{file.path}
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts new file mode 100644 index 00000000..916b7a1f --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts @@ -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", +}); diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx new file mode 100644 index 00000000..27ac7d80 --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx @@ -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 {} + +export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { + const [deletingArtifact, setDeletingArtifact] = useState(false); + const [lastBackup, setLastBackup] = useState(null); + const [backupDownloadProgress, setBackupDownloadProgress] = + useState(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 ( + + + {t("uploading_backup")} + + ); + } + + if (restoringBackup) { + return ( + + + {t("restoring_backup", { + progress: formatDownloadProgress( + backupDownloadProgress?.progress ?? 0 + ), + })} + + ); + } + + if (lastBackup) { + return ( + + + + + + {t("last_backup_date", { + date: format(lastBackup.createdAt, "dd/MM/yyyy HH:mm"), + })} + + ); + } + + if (!backupPreview) { + return t("no_backup_preview"); + } + + return t("no_backups"); + }, [ + uploadingBackup, + backupDownloadProgress?.progress, + lastBackup, + backupPreview, + restoringBackup, + t, + ]); + + const disableActions = uploadingBackup || restoringBackup || deletingArtifact; + + return ( + +
+
+

{gameTitle}

+

{backupStateLabel}

+ + +
+ + +
+ +
+

{t("backups")}

+ {artifacts.length} / 2 +
+ +
    + {artifacts.map((artifact) => ( +
  • +
    +
    +

    Backup do dia {format(artifact.createdAt, "dd/MM")}

    + {formatBytes(artifact.artifactLengthInBytes)} +
    + + + + {artifact.hostname} + + + + + {format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")} + +
    + +
    + + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 2ba19246..fab6a49b 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -9,8 +9,12 @@ import { Sidebar } from "./sidebar/sidebar"; import * as styles from "./game-details.css"; import { useTranslation } from "react-i18next"; -import { gameDetailsContext } from "@renderer/context"; +import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { steamUrlBuilder } from "@shared"; +import Lottie from "lottie-react"; + +import downloadingAnimation from "@renderer/assets/lottie/cloud.json"; +import { useUserDetails } from "@renderer/hooks"; const HERO_ANIMATION_THRESHOLD = 25; @@ -22,7 +26,7 @@ export function GameDetailsContent() { const { t } = useTranslation("game_details"); const { - objectID, + objectId, shopDetails, game, gameColor, @@ -30,10 +34,15 @@ export function GameDetailsContent() { hasNSFWContentBlocked, } = useContext(gameDetailsContext); + const { userDetails } = useUserDetails(); + + const { supportsCloudSync, setShowCloudSyncModal } = + useContext(cloudSyncContext); + const [backdropOpactiy, setBackdropOpacity] = useState(1); const handleHeroLoad = async () => { - const output = await average(steamUrlBuilder.libraryHero(objectID!), { + const output = await average(steamUrlBuilder.libraryHero(objectId!), { amount: 1, format: "hex", }); @@ -47,7 +56,7 @@ export function GameDetailsContent() { useEffect(() => { setBackdropOpacity(1); - }, [objectID]); + }, [objectId]); const onScroll: React.UIEventHandler = (event) => { const heroHeight = heroRef.current?.clientHeight ?? styles.HERO_HEIGHT; @@ -69,10 +78,19 @@ export function GameDetailsContent() { setBackdropOpacity(opacity); }; + const handleCloudSaveButtonClick = () => { + if (!userDetails) { + window.electron.openAuthWindow(); + return; + } + + setShowCloudSyncModal(true); + }; + return (
{game?.title}
{game?.title} + + {supportsCloudSync && ( + + )}
diff --git a/src/renderer/src/pages/game-details/game-details-skeleton.tsx b/src/renderer/src/pages/game-details/game-details-skeleton.tsx index 23f0c6f1..24cfe2cb 100644 --- a/src/renderer/src/pages/game-details/game-details-skeleton.tsx +++ b/src/renderer/src/pages/game-details/game-details-skeleton.tsx @@ -43,23 +43,6 @@ export function GameDetailsSkeleton() {
- {/*
-

HowLongToBeat

-
-
    - {Array.from({ length: 3 }).map((_, index) => ( - - ))} -
*/} -
-

{t("requirements")}

-
- )} - + + setShowCloudSyncFilesModal(false)} + visible={showCloudSyncFilesModal} + /> + + )} + + + + {isLoading ? : } + + setShowRepacksModal(false)} + /> + + setHasNSFWContentBlocked(false)} + clickOutsideToClose={false} + /> + + {game && ( + { + setShowGameOptionsModal(false); + }} + /> + )} + + {fromRandomizer && ( + + )} + + ); }} diff --git a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx index 323b3c8f..88eb7c63 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel-actions.tsx @@ -18,7 +18,7 @@ export function HeroPanelActions() { game, repacks, isGameRunning, - objectID, + objectId, gameTitle, setShowGameOptionsModal, setShowRepacksModal, @@ -39,7 +39,7 @@ export function HeroPanelActions() { setToggleLibraryGameDisabled(true); try { - await window.electron.addGameToLibrary(objectID!, gameTitle, "steam"); + await window.electron.addGameToLibrary(objectId!, gameTitle, "steam"); updateLibrary(); updateGame(); diff --git a/src/renderer/src/pages/game-details/hero/hero-panel.tsx b/src/renderer/src/pages/game-details/hero/hero-panel.tsx index 7f010705..d1313db7 100644 --- a/src/renderer/src/pages/game-details/hero/hero-panel.tsx +++ b/src/renderer/src/pages/game-details/hero/hero-panel.tsx @@ -29,6 +29,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) { const [latestRepack] = repacks; if (latestRepack) { + console.log(latestRepack); const lastUpdate = format(latestRepack.uploadDate!, "dd/MM/yyyy"); const repacksCount = repacks.length; diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 9d8a1a11..de94bcff 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -65,7 +65,8 @@ export function RepacksModal({ }; const checkIfLastDownloadedOption = (repack: GameRepack) => { - return repack.uris.some((uri) => uri.includes(game?.uri ?? "")); + if (!game) return false; + return repack.uris.some((uri) => uri.includes(game.uri!)); }; return ( diff --git a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.css.ts b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.css.ts new file mode 100644 index 00000000..cd9fdc8f --- /dev/null +++ b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.css.ts @@ -0,0 +1,37 @@ +import { recipe } from "@vanilla-extract/recipes"; +import { SPACING_UNIT, vars } from "../../../theme.css"; +import { style } from "@vanilla-extract/css"; + +export const sidebarSectionButton = style({ + height: "72px", + padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`, + display: "flex", + alignItems: "center", + backgroundColor: vars.color.background, + color: vars.color.muted, + width: "100%", + cursor: "pointer", + transition: "all ease 0.2s", + gap: `${SPACING_UNIT}px`, + fontSize: "14px", + fontWeight: "bold", + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.05)", + }, + ":active": { + opacity: vars.opacity.active, + }, +}); + +export const chevron = recipe({ + base: { + transition: "transform ease 0.2s", + }, + variants: { + open: { + true: { + transform: "rotate(180deg)", + }, + }, + }, +}); diff --git a/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx new file mode 100644 index 00000000..9ed48c9b --- /dev/null +++ b/src/renderer/src/pages/game-details/sidebar-section/sidebar-section.tsx @@ -0,0 +1,38 @@ +import { ChevronDownIcon } from "@primer/octicons-react"; +import { useRef, useState } from "react"; + +import * as styles from "./sidebar-section.css"; + +export interface SidebarSectionProps { + title: string; + children: React.ReactNode; +} + +export function SidebarSection({ title, children }: SidebarSectionProps) { + const content = useRef(null); + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ + +
+ {children} +
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx index ffd148e5..d63879f5 100644 --- a/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx +++ b/src/renderer/src/pages/game-details/sidebar/how-long-to-beat-section.tsx @@ -4,6 +4,7 @@ import type { HowLongToBeatCategory } from "@types"; import { vars } from "@renderer/theme.css"; import * as styles from "./sidebar.css"; +import { SidebarSection } from "../sidebar-section/sidebar-section"; const durationTranslation: Record = { Hours: "hours", @@ -30,41 +31,42 @@ export function HowLongToBeatSection({ return ( -
-

HowLongToBeat

-
- -
    - {howLongToBeatData - ? howLongToBeatData.map((category) => ( -
  • -

    +

      + {howLongToBeatData + ? howLongToBeatData.map((category) => ( +
    • - {category.title} -

      +

      + {category.title} +

      -

      - {getDuration(category.duration)} -

      +

      + {getDuration(category.duration)} +

      - {category.accuracy !== "00" && ( - - {t("accuracy", { accuracy: category.accuracy })} - - )} -
    • - )) - : Array.from({ length: 4 }).map((_, index) => ( - - ))} -
    + {category.accuracy !== "00" && ( + + {t("accuracy", { accuracy: category.accuracy })} + + )} +
  • + )) + : Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
); } diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts b/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts index d1a0e8cd..c909fba2 100644 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.css.ts @@ -3,7 +3,8 @@ import { globalStyle, style } from "@vanilla-extract/css"; import { SPACING_UNIT, vars } from "../../../theme.css"; export const contentSidebar = style({ - borderLeft: `solid 1px ${vars.color.border};`, + borderLeft: `solid 1px ${vars.color.border}`, + backgroundColor: vars.color.darkBackground, width: "100%", height: "100%", "@media": { @@ -18,15 +19,6 @@ export const contentSidebar = style({ }, }); -export const contentSidebarTitle = style({ - height: "72px", - padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 2}px`, - display: "flex", - alignItems: "center", - backgroundColor: vars.color.background, - justifyContent: "space-between", -}); - export const requirementButtonContainer = style({ width: "100%", display: "flex", @@ -56,7 +48,7 @@ export const requirementsDetailsSkeleton = style({ export const howLongToBeatCategoriesList = style({ margin: "0", - padding: "16px", + padding: `${SPACING_UNIT * 2}px`, display: "flex", flexDirection: "column", gap: "16px", @@ -66,7 +58,8 @@ export const howLongToBeatCategory = style({ display: "flex", flexDirection: "column", gap: "4px", - backgroundColor: vars.color.background, + background: + "linear-gradient(90deg, transparent 20%, rgb(255 255 255 / 2%) 100%)", borderRadius: "4px", padding: `8px 16px`, border: `solid 1px ${vars.color.border}`, @@ -87,6 +80,8 @@ export const statsSection = style({ gap: `${SPACING_UNIT * 2}px`, padding: `${SPACING_UNIT * 2}px`, justifyContent: "space-between", + transition: "max-height ease 0.5s", + overflow: "hidden", "@media": { "(min-width: 1024px)": { flexDirection: "column", diff --git a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx index c0c066a1..a72d6c90 100644 --- a/src/renderer/src/pages/game-details/sidebar/sidebar.tsx +++ b/src/renderer/src/pages/game-details/sidebar/sidebar.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import type { HowLongToBeatCategory, SteamAppDetails } from "@types"; import { useTranslation } from "react-i18next"; import { Button, Link } from "@renderer/components"; @@ -8,9 +8,12 @@ import { gameDetailsContext } from "@renderer/context"; import { useDate, useFormat } from "@renderer/hooks"; import { DownloadIcon, PeopleIcon } from "@primer/octicons-react"; import { SPACING_UNIT } from "@renderer/theme.css"; +import { HowLongToBeatSection } from "./how-long-to-beat-section"; +import { howLongToBeatEntriesTable } from "@renderer/dexie"; +import { SidebarSection } from "../sidebar-section/sidebar-section"; export function Sidebar() { - const [_howLongToBeat, _setHowLongToBeat] = useState<{ + const [howLongToBeat, setHowLongToBeat] = useState<{ isLoading: boolean; data: HowLongToBeatCategory[] | null; }>({ isLoading: true, data: null }); @@ -18,7 +21,7 @@ export function Sidebar() { const [activeRequirement, setActiveRequirement] = useState("minimum"); - const { gameTitle, shopDetails, stats, achievements, shop, objectID } = + const { gameTitle, shopDetails, objectId, shop, stats, achievements } = useContext(gameDetailsContext); const { t } = useTranslation("game_details"); @@ -27,50 +30,60 @@ export function Sidebar() { const { numberFormatter } = useFormat(); const buildGameAchievementPath = () => { - const urlParams = new URLSearchParams({ objectId: objectID!, shop }); + const urlParams = new URLSearchParams({ objectId: objectId!, shop }); return `/achievements?${urlParams.toString()}`; }; - // useEffect(() => { - // if (objectID) { - // setHowLongToBeat({ isLoading: true, data: null }); + useEffect(() => { + if (objectId) { + setHowLongToBeat({ isLoading: true, data: null }); - // window.electron - // .getHowLongToBeat(objectID, "steam", gameTitle) - // .then((howLongToBeat) => { - // setHowLongToBeat({ isLoading: false, data: howLongToBeat }); - // }) - // .catch(() => { - // setHowLongToBeat({ isLoading: false, data: null }); - // }); - // } - // }, [objectID, gameTitle]); + howLongToBeatEntriesTable + .where({ shop, objectId }) + .first() + .then(async (cachedHowLongToBeat) => { + if (cachedHowLongToBeat) { + setHowLongToBeat({ + isLoading: false, + data: cachedHowLongToBeat.categories, + }); + } else { + try { + const howLongToBeat = + await window.electron.getHowLongToBeat(gameTitle); + + if (howLongToBeat) { + howLongToBeatEntriesTable.add({ + objectId, + shop: "steam", + createdAt: new Date(), + updatedAt: new Date(), + categories: howLongToBeat, + }); + } + + setHowLongToBeat({ isLoading: false, data: howLongToBeat }); + } catch (err) { + setHowLongToBeat({ isLoading: false, data: null }); + } + } + }); + } + }, [objectId, shop, gameTitle]); return ( ); } diff --git a/src/renderer/src/pages/home/home.tsx b/src/renderer/src/pages/home/home.tsx index dccb98a3..e70a58fd 100644 --- a/src/renderer/src/pages/home/home.tsx +++ b/src/renderer/src/pages/home/home.tsx @@ -186,7 +186,7 @@ export function Home() { )) : catalogue[currentCatalogueCategory].map((result) => ( navigate(buildGameDetailsPath(result))} /> diff --git a/src/renderer/src/pages/home/search-results.tsx b/src/renderer/src/pages/home/search-results.tsx index 7ab830f0..32c4ad89 100644 --- a/src/renderer/src/pages/home/search-results.tsx +++ b/src/renderer/src/pages/home/search-results.tsx @@ -115,7 +115,7 @@ export function SearchResults() { <> {searchResults.map((game) => ( handleGameClick(game)} /> diff --git a/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx b/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx index cd43641a..0d86bddc 100644 --- a/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx +++ b/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx @@ -64,6 +64,8 @@ export function EditProfileModal( const { showSuccessToast, showErrorToast } = useToast(); const onSubmit = async (values: FormValues) => { + console.log(values); + return patchUser(values) .then(async () => { await Promise.allSettled([fetchUserDetails(), getUserProfile()]); @@ -118,6 +120,8 @@ export function EditProfileModal( return { imagePath: null }; }); + console.log(imagePath); + onChange(imagePath); } }; diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.css.ts b/src/renderer/src/pages/profile/profile-content/profile-content.css.ts index 6e9c4679..139fc051 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.css.ts +++ b/src/renderer/src/pages/profile/profile-content/profile-content.css.ts @@ -6,6 +6,7 @@ export const gameCover = style({ transition: "all ease 0.2s", boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)", width: "100%", + position: "relative", ":before": { content: "", top: "0", @@ -14,7 +15,7 @@ export const gameCover = style({ height: "172%", position: "absolute", background: - "linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%);", + "linear-gradient(35deg, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.07) 51.5%, rgba(255, 255, 255, 0.15) 54%, rgba(255, 255, 255, 0.15) 100%)", transition: "all ease 0.3s", transform: "translateY(-36%)", opacity: "0.5", @@ -188,3 +189,15 @@ export const defaultAvatarWrapper = style({ border: `solid 1px ${vars.color.border}`, borderRadius: "4px", }); + +export const achievementsProgressBar = style({ + width: "100%", + height: "4px", + transition: "all ease 0.2s", + "::-webkit-progress-bar": { + backgroundColor: "rgba(255, 255, 255, 0.15)", + }, + "::-webkit-progress-value": { + backgroundColor: vars.color.muted, + }, +}); diff --git a/src/renderer/src/pages/profile/profile-content/profile-content.tsx b/src/renderer/src/pages/profile/profile-content/profile-content.tsx index a885bddf..b7c955f9 100644 --- a/src/renderer/src/pages/profile/profile-content/profile-content.tsx +++ b/src/renderer/src/pages/profile/profile-content/profile-content.tsx @@ -1,13 +1,13 @@ import { userProfileContext } from "@renderer/context"; -import { useContext, useEffect, useMemo } from "react"; +import { useCallback, useContext, useEffect, useMemo } from "react"; import { ProfileHero } from "../profile-hero/profile-hero"; import { useAppDispatch, useFormat } from "@renderer/hooks"; import { setHeaderTitle } from "@renderer/features"; import { steamUrlBuilder } from "@shared"; -import { SPACING_UNIT } from "@renderer/theme.css"; +import { SPACING_UNIT, vars } from "@renderer/theme.css"; import * as styles from "./profile-content.css"; -import { TelescopeIcon } from "@primer/octicons-react"; +import { ClockIcon, TelescopeIcon, TrophyIcon } from "@primer/octicons-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { LockedProfile } from "./locked-profile"; @@ -15,7 +15,11 @@ import { ReportProfile } from "../report-profile/report-profile"; import { FriendsBox } from "./friends-box"; import { RecentGamesBox } from "./recent-games-box"; import { UserGame } from "@types"; -import { buildGameDetailsPath } from "@renderer/helpers"; +import { + buildGameDetailsPath, + formatDownloadProgress, +} from "@renderer/helpers"; +import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants"; export function ProfileContent() { const { userProfile, isMe, userStats } = useContext(userProfileContext); @@ -43,9 +47,25 @@ export function ProfileContent() { const buildUserGameDetailsPath = (game: UserGame) => buildGameDetailsPath({ ...game, - objectID: game.objectId, + objectId: game.objectId, }); + const formatPlayTime = useCallback( + (playTimeInSeconds = 0) => { + const minutes = playTimeInSeconds / 60; + + if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) { + return t("amount_minutes", { + amount: minutes.toFixed(0), + }); + } + + const hours = minutes / 60; + return t("amount_hours", { amount: numberFormatter.format(hours) }); + }, + [numberFormatter, t] + ); + const content = useMemo(() => { if (!userProfile) return null; @@ -98,6 +118,7 @@ export function ProfileContent() { borderRadius: 4, overflow: "hidden", position: "relative", + display: "flex", }} className={styles.game} > @@ -109,13 +130,93 @@ export function ProfileContent() { className={styles.gameCover} onClick={() => navigate(buildUserGameDetailsPath(game))} > +
+ + + {formatPlayTime(game.playTimeInSeconds)} + + +
+
+
+ + + {game.unlockedAchievementCount} /{" "} + {game.achievementCount} + +
+ + + {formatDownloadProgress( + game.unlockedAchievementCount / + game.achievementCount + )} + +
+ + +
+
+ {game.title} @@ -143,6 +244,7 @@ export function ProfileContent() { userStats, numberFormatter, t, + formatPlayTime, navigate, ]); diff --git a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx index f6d3bc0d..f2e0249b 100644 --- a/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/recent-games-box.tsx @@ -37,7 +37,7 @@ export function RecentGamesBox() { const buildUserGameDetailsPath = (game: UserGame) => buildGameDetailsPath({ ...game, - objectID: game.objectId, + objectId: game.objectId, }); if (!userProfile?.recentGames.length) return null; diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.css.ts b/src/renderer/src/pages/profile/profile-hero/profile-hero.css.ts index 5ef6cc75..2334d605 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.css.ts +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.css.ts @@ -4,6 +4,7 @@ import { style } from "@vanilla-extract/css"; export const profileContentBox = style({ display: "flex", flexDirection: "column", + position: "relative", }); export const profileAvatarButton = style({ @@ -69,7 +70,7 @@ export const heroPanel = style({ export const userInformation = style({ display: "flex", - padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`, + padding: `${SPACING_UNIT * 6}px ${SPACING_UNIT * 3}px`, alignItems: "center", gap: `${SPACING_UNIT * 2}px`, }); diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx index 9234f487..f81761ea 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -1,4 +1,4 @@ -import { SPACING_UNIT } from "@renderer/theme.css"; +import { SPACING_UNIT, vars } from "@renderer/theme.css"; import * as styles from "./profile-hero.css"; import { useCallback, useContext, useMemo, useState } from "react"; @@ -10,6 +10,7 @@ import { PersonAddIcon, PersonIcon, SignOutIcon, + UploadIcon, XCircleFillIcon, } from "@primer/octicons-react"; import { buildGameDetailsPath } from "@renderer/helpers"; @@ -36,8 +37,7 @@ export function ProfileHero() { const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [isPerformingAction, setIsPerformingAction] = useState(false); - const { isMe, heroBackground, getUserProfile, userProfile } = - useContext(userProfileContext); + const { isMe, getUserProfile, userProfile } = useContext(userProfileContext); const { signOut, updateFriendRequestState, @@ -48,6 +48,8 @@ export function ProfileHero() { const { gameRunning } = useAppSelector((state) => state.gameRunning); + const [hero, setHero] = useState(""); + const { t } = useTranslation("user_profile"); const { formatDistance } = useDate(); @@ -124,6 +126,7 @@ export function ProfileHero() { theme="outline" onClick={() => setShowEditProfileModal(true)} disabled={isPerformingAction} + style={{ borderColor: vars.color.body }} > {t("edit_profile")} @@ -148,6 +151,7 @@ export function ProfileHero() { theme="outline" onClick={() => handleFriendAction(userProfile.id, "SEND")} disabled={isPerformingAction} + style={{ borderColor: vars.color.body }} > {t("add_friend")} @@ -198,6 +202,7 @@ export function ProfileHero() { handleFriendAction(userProfile.relation!.BId, "CANCEL") } disabled={isPerformingAction} + style={{ borderColor: vars.color.body }} > {t("cancel_request")} @@ -212,11 +217,12 @@ export function ProfileHero() { handleFriendAction(userProfile.relation!.AId, "ACCEPTED") } disabled={isPerformingAction} + style={{ borderColor: vars.color.body }} > {t("accept_request")} +
+ +
+
+ -
- {userProfile ? ( -

- {userProfile?.displayName} -

- ) : ( - - )} +
+ {userProfile ? ( +

+ {userProfile?.displayName} +

+ ) : ( + + )} - {currentGame && ( -
-
- - {currentGame.title} - -
+ {currentGame && ( +
+
+ + {currentGame.title} + +
- - {t("playing_for", { - amount: formatDistance( - addSeconds( - new Date(), - -currentGame.sessionDurationInSeconds + + {t("playing_for", { + amount: formatDistance( + addSeconds( + new Date(), + -currentGame.sessionDurationInSeconds + ), + new Date() ), - new Date() - ), - })} - -
- )} + })} + +
+ )} +
+ +
-
+
{ uris: string; } +const state = { + repacks: [] as SerializedGameRepack[], + index: null as flexSearch.Index | null, +}; + self.onmessage = async ( event: MessageEvent<[string, string] | "INDEX_REPACKS"> ) => { if (event.data === "INDEX_REPACKS") { + state.index = new flexSearch.Index(); + repacksTable .toCollection() .sortBy("uploadDate") @@ -26,7 +27,7 @@ self.onmessage = async ( for (let i = 0; i < state.repacks.length; i++) { const repack = state.repacks[i]; const formattedTitle = formatName(repack.title); - index.add(i, formattedTitle); + state.index!.add(i, formattedTitle); } self.postMessage("INDEXING_COMPLETE"); @@ -34,7 +35,7 @@ self.onmessage = async ( } else { const [requestId, query] = event.data; - const results = index.search(formatName(query)).map((index) => { + const results = state.index!.search(formatName(query)).map((index) => { const repack = state.repacks.at(index as number) as SerializedGameRepack; return { diff --git a/src/shared/char-map.ts b/src/shared/char-map.ts new file mode 100644 index 00000000..7f29509e --- /dev/null +++ b/src/shared/char-map.ts @@ -0,0 +1,461 @@ +export const charMap = { + À: "A", + Á: "A", + Â: "A", + Ã: "A", + Ä: "A", + Å: "A", + Ấ: "A", + Ắ: "A", + Ẳ: "A", + Ẵ: "A", + Ặ: "A", + Æ: "AE", + Ầ: "A", + Ằ: "A", + Ȃ: "A", + Ả: "A", + Ạ: "A", + Ẩ: "A", + Ẫ: "A", + Ậ: "A", + Ç: "C", + Ḉ: "C", + È: "E", + É: "E", + Ê: "E", + Ë: "E", + Ế: "E", + Ḗ: "E", + Ề: "E", + Ḕ: "E", + Ḝ: "E", + Ȇ: "E", + Ẻ: "E", + Ẽ: "E", + Ẹ: "E", + Ể: "E", + Ễ: "E", + Ệ: "E", + Ì: "I", + Í: "I", + Î: "I", + Ï: "I", + Ḯ: "I", + Ȋ: "I", + Ỉ: "I", + Ị: "I", + Ð: "D", + Ñ: "N", + Ò: "O", + Ó: "O", + Ô: "O", + Õ: "O", + Ö: "O", + Ø: "O", + Ố: "O", + Ṍ: "O", + Ṓ: "O", + Ȏ: "O", + Ỏ: "O", + Ọ: "O", + Ổ: "O", + Ỗ: "O", + Ộ: "O", + Ờ: "O", + Ở: "O", + Ỡ: "O", + Ớ: "O", + Ợ: "O", + Ù: "U", + Ú: "U", + Û: "U", + Ü: "U", + Ủ: "U", + Ụ: "U", + Ử: "U", + Ữ: "U", + Ự: "U", + Ý: "Y", + à: "a", + á: "a", + â: "a", + ã: "a", + ä: "a", + å: "a", + ấ: "a", + ắ: "a", + ẳ: "a", + ẵ: "a", + ặ: "a", + æ: "ae", + ầ: "a", + ằ: "a", + ȃ: "a", + ả: "a", + ạ: "a", + ẩ: "a", + ẫ: "a", + ậ: "a", + ç: "c", + ḉ: "c", + è: "e", + é: "e", + ê: "e", + ë: "e", + ế: "e", + ḗ: "e", + ề: "e", + ḕ: "e", + ḝ: "e", + ȇ: "e", + ẻ: "e", + ẽ: "e", + ẹ: "e", + ể: "e", + ễ: "e", + ệ: "e", + ì: "i", + í: "i", + î: "i", + ï: "i", + ḯ: "i", + ȋ: "i", + ỉ: "i", + ị: "i", + ð: "d", + ñ: "n", + ò: "o", + ó: "o", + ô: "o", + õ: "o", + ö: "o", + ø: "o", + ố: "o", + ṍ: "o", + ṓ: "o", + ȏ: "o", + ỏ: "o", + ọ: "o", + ổ: "o", + ỗ: "o", + ộ: "o", + ờ: "o", + ở: "o", + ỡ: "o", + ớ: "o", + ợ: "o", + ù: "u", + ú: "u", + û: "u", + ü: "u", + ủ: "u", + ụ: "u", + ử: "u", + ữ: "u", + ự: "u", + ý: "y", + ÿ: "y", + Ā: "A", + ā: "a", + Ă: "A", + ă: "a", + Ą: "A", + ą: "a", + Ć: "C", + ć: "c", + Ĉ: "C", + ĉ: "c", + Ċ: "C", + ċ: "c", + Č: "C", + č: "c", + C̆: "C", + c̆: "c", + Ď: "D", + ď: "d", + Đ: "D", + đ: "d", + Ē: "E", + ē: "e", + Ĕ: "E", + ĕ: "e", + Ė: "E", + ė: "e", + Ę: "E", + ę: "e", + Ě: "E", + ě: "e", + Ĝ: "G", + Ǵ: "G", + ĝ: "g", + ǵ: "g", + Ğ: "G", + ğ: "g", + Ġ: "G", + ġ: "g", + Ģ: "G", + ģ: "g", + Ĥ: "H", + ĥ: "h", + Ħ: "H", + ħ: "h", + Ḫ: "H", + ḫ: "h", + Ĩ: "I", + ĩ: "i", + Ī: "I", + ī: "i", + Ĭ: "I", + ĭ: "i", + Į: "I", + į: "i", + İ: "I", + ı: "i", + IJ: "IJ", + ij: "ij", + Ĵ: "J", + ĵ: "j", + Ķ: "K", + ķ: "k", + Ḱ: "K", + ḱ: "k", + K̆: "K", + k̆: "k", + Ĺ: "L", + ĺ: "l", + Ļ: "L", + ļ: "l", + Ľ: "L", + ľ: "l", + Ŀ: "L", + ŀ: "l", + Ł: "l", + ł: "l", + Ḿ: "M", + ḿ: "m", + M̆: "M", + m̆: "m", + Ń: "N", + ń: "n", + Ņ: "N", + ņ: "n", + Ň: "N", + ň: "n", + ʼn: "n", + N̆: "N", + n̆: "n", + Ō: "O", + ō: "o", + Ŏ: "O", + ŏ: "o", + Ő: "O", + ő: "o", + Œ: "OE", + œ: "oe", + P̆: "P", + p̆: "p", + Ŕ: "R", + ŕ: "r", + Ŗ: "R", + ŗ: "r", + Ř: "R", + ř: "r", + R̆: "R", + r̆: "r", + Ȓ: "R", + ȓ: "r", + Ś: "S", + ś: "s", + Ŝ: "S", + ŝ: "s", + Ş: "S", + Ș: "S", + ș: "s", + ş: "s", + Š: "S", + š: "s", + Ţ: "T", + ţ: "t", + ț: "t", + Ț: "T", + Ť: "T", + ť: "t", + Ŧ: "T", + ŧ: "t", + T̆: "T", + t̆: "t", + Ũ: "U", + ũ: "u", + Ū: "U", + ū: "u", + Ŭ: "U", + ŭ: "u", + Ů: "U", + ů: "u", + Ű: "U", + ű: "u", + Ų: "U", + ų: "u", + Ȗ: "U", + ȗ: "u", + V̆: "V", + v̆: "v", + Ŵ: "W", + ŵ: "w", + Ẃ: "W", + ẃ: "w", + X̆: "X", + x̆: "x", + Ŷ: "Y", + ŷ: "y", + Ÿ: "Y", + Y̆: "Y", + y̆: "y", + Ź: "Z", + ź: "z", + Ż: "Z", + ż: "z", + Ž: "Z", + ž: "z", + ſ: "s", + ƒ: "f", + Ơ: "O", + ơ: "o", + Ư: "U", + ư: "u", + Ǎ: "A", + ǎ: "a", + Ǐ: "I", + ǐ: "i", + Ǒ: "O", + ǒ: "o", + Ǔ: "U", + ǔ: "u", + Ǖ: "U", + ǖ: "u", + Ǘ: "U", + ǘ: "u", + Ǚ: "U", + ǚ: "u", + Ǜ: "U", + ǜ: "u", + Ứ: "U", + ứ: "u", + Ṹ: "U", + ṹ: "u", + Ǻ: "A", + ǻ: "a", + Ǽ: "AE", + ǽ: "ae", + Ǿ: "O", + ǿ: "o", + Þ: "TH", + þ: "th", + Ṕ: "P", + ṕ: "p", + Ṥ: "S", + ṥ: "s", + X́: "X", + x́: "x", + Ѓ: "Г", + ѓ: "г", + Ќ: "К", + ќ: "к", + A̋: "A", + a̋: "a", + E̋: "E", + e̋: "e", + I̋: "I", + i̋: "i", + Ǹ: "N", + ǹ: "n", + Ồ: "O", + ồ: "o", + Ṑ: "O", + ṑ: "o", + Ừ: "U", + ừ: "u", + Ẁ: "W", + ẁ: "w", + Ỳ: "Y", + ỳ: "y", + Ȁ: "A", + ȁ: "a", + Ȅ: "E", + ȅ: "e", + Ȉ: "I", + ȉ: "i", + Ȍ: "O", + ȍ: "o", + Ȑ: "R", + ȑ: "r", + Ȕ: "U", + ȕ: "u", + B̌: "B", + b̌: "b", + Č̣: "C", + č̣: "c", + Ê̌: "E", + ê̌: "e", + F̌: "F", + f̌: "f", + Ǧ: "G", + ǧ: "g", + Ȟ: "H", + ȟ: "h", + J̌: "J", + ǰ: "j", + Ǩ: "K", + ǩ: "k", + M̌: "M", + m̌: "m", + P̌: "P", + p̌: "p", + Q̌: "Q", + q̌: "q", + Ř̩: "R", + ř̩: "r", + Ṧ: "S", + ṧ: "s", + V̌: "V", + v̌: "v", + W̌: "W", + w̌: "w", + X̌: "X", + x̌: "x", + Y̌: "Y", + y̌: "y", + A̧: "A", + a̧: "a", + B̧: "B", + b̧: "b", + Ḑ: "D", + ḑ: "d", + Ȩ: "E", + ȩ: "e", + Ɛ̧: "E", + ɛ̧: "e", + Ḩ: "H", + ḩ: "h", + I̧: "I", + i̧: "i", + Ɨ̧: "I", + ɨ̧: "i", + M̧: "M", + m̧: "m", + O̧: "O", + o̧: "o", + Q̧: "Q", + q̧: "q", + U̧: "U", + u̧: "u", + X̧: "X", + x̧: "x", + Z̧: "Z", + z̧: "z", + й: "и", + Й: "И", + ё: "е", + Ё: "Е", +}; diff --git a/src/shared/index.ts b/src/shared/index.ts index 556000f2..5d216183 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,3 +1,4 @@ +import { charMap } from "./char-map"; import { Downloader } from "./constants"; export * from "./constants"; @@ -51,6 +52,12 @@ export const replaceUnderscoreWithSpace = (name: string) => name.replace(/_/g, " "); export const formatName = pipe( + (str) => + str.replace( + new RegExp(Object.keys(charMap).join("|"), "g"), + (match) => charMap[match] + ), + (str) => str.toLowerCase(), removeReleaseYearFromName, removeSpecialEditionFromName, replaceUnderscoreWithSpace, @@ -91,14 +98,14 @@ export const getDownloadersForUris = (uris: string[]) => { }; export const steamUrlBuilder = { - library: (objectID: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`, - libraryHero: (objectID: string) => - `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`, - logo: (objectID: string) => - `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`, - cover: (objectID: string) => - `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/library_600x900.jpg`, - icon: (objectID: string, clientIcon: string) => - `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`, + library: (objectId: string) => + `https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/header.jpg`, + libraryHero: (objectId: string) => + `https://steamcdn-a.akamaihd.net/steam/apps/${objectId}/library_hero.jpg`, + logo: (objectId: string) => + `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/logo.png`, + cover: (objectId: string) => + `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectId}/library_600x900.jpg`, + icon: (objectId: string, clientIcon: string) => + `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`, }; diff --git a/src/types/howlongtobeat.types.ts b/src/types/howlongtobeat.types.ts new file mode 100644 index 00000000..1ab7ee34 --- /dev/null +++ b/src/types/howlongtobeat.types.ts @@ -0,0 +1,14 @@ +export interface HowLongToBeatCategory { + title: string; + duration: string; + accuracy: string; +} + +export interface HowLongToBeatResult { + game_id: number; + game_name: string; +} + +export interface HowLongToBeatSearchResponse { + data: HowLongToBeatResult[]; +} diff --git a/src/types/index.ts b/src/types/index.ts index 43318387..303d47ae 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -39,7 +39,7 @@ export interface GameAchievement { } export type ShopDetails = SteamAppDetails & { - objectID: string; + objectId: string; }; export interface TorrentFile { @@ -49,7 +49,7 @@ export interface TorrentFile { /* Used by the catalogue */ export interface CatalogueEntry { - objectID: string; + objectId: string; shop: GameShop; title: string; /* Epic Games covers cannot be guessed with objectID */ @@ -64,6 +64,8 @@ export interface UserGame { cover: string; playTimeInSeconds: number; lastTimePlayed: Date | null; + unlockedAchievementCount: number; + achievementCount: number; } export interface DownloadQueue { @@ -128,15 +130,9 @@ export interface UserPreferences { runAtStartup: boolean; } -export interface HowLongToBeatCategory { - title: string; - duration: string; - accuracy: string; -} - export interface Steam250Game { title: string; - objectID: string; + objectId: string; } export interface SteamGame { @@ -152,7 +148,7 @@ export type AppUpdaterEvent = /* Events */ export interface StartGameDownloadPayload { repackId: number; - objectID: string; + objectId: string; title: string; shop: GameShop; uri: string; @@ -197,7 +193,7 @@ export interface UserRelation { updatedAt: string; } -export interface UserProfileCurrentGame extends Omit { +export interface UserProfileCurrentGame extends Omit { objectId: string; sessionDurationInSeconds: number; } @@ -290,5 +286,16 @@ export type GameAchievementFiles = { [id: string]: AchievementFile[]; }; +export interface GameArtifact { + id: string; + artifactLengthInBytes: number; + createdAt: string; + updatedAt: string; + hostname: string; + downloadCount: number; +} + export * from "./steam.types"; export * from "./real-debrid.types"; +export * from "./ludusavi.types"; +export * from "./howlongtobeat.types"; diff --git a/src/types/ludusavi.types.ts b/src/types/ludusavi.types.ts new file mode 100644 index 00000000..1e803e46 --- /dev/null +++ b/src/types/ludusavi.types.ts @@ -0,0 +1,27 @@ +export interface LudusaviScanChange { + change: "New" | "Different" | "Removed" | "Same" | "Unknown"; + decision: "Processed" | "Cancelled" | "Ignore"; +} + +export interface LudusaviGame extends LudusaviScanChange { + files: Record; +} + +export interface LudusaviBackup { + overall: { + totalGames: number; + totalBytes: number; + processedGames: number; + processedBytes: number; + changedGames: { + new: number; + different: number; + same: number; + }; + }; + games: Record; +} + +export interface LudusaviFindResult { + games: Record; +} diff --git a/torrent-client/torrent_downloader.py b/torrent-client/torrent_downloader.py index d59cd28b..b5280260 100644 --- a/torrent-client/torrent_downloader.py +++ b/torrent-client/torrent_downloader.py @@ -144,8 +144,8 @@ class TorrentDownloader: status = torrent_handle.status() info = torrent_handle.get_torrent_info() - - return { + + response = { 'folderName': info.name() if info else "", 'fileSize': info.total_size() if info else 0, 'gameId': self.downloading_game_id, @@ -156,3 +156,10 @@ class TorrentDownloader: 'status': status.state, 'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download, } + + if status.progress == 1: + torrent_handle.pause() + self.session.remove_torrent(torrent_handle) + self.downloading_game_id = -1 + + return response diff --git a/yarn.lock b/yarn.lock index dff8755c..2289c292 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1135,6 +1135,13 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" +"@isaacs/fs-minipass@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" + integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w== + dependencies: + minipass "^7.0.4" + "@jimp/bmp@^0.22.12": version "0.22.12" resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.22.12.tgz#0316044dc7b1a90274aef266d50349347fb864d4" @@ -2343,6 +2350,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/folder-hash@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/folder-hash/-/folder-hash-4.0.4.tgz#c3262d58a01b756ee2aae3694707fad1ef676a9f" + integrity sha512-c+PwHm51Dw3fXM8SDK+93PO3oXdk4XNouCCvV67lj4aijRkZz5g67myk+9wqWWnyv3go6q96hT6ywcd3XtoZiQ== + "@types/fs-extra@9.0.13", "@types/fs-extra@^9.0.11": version "9.0.13" resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz" @@ -3520,6 +3532,11 @@ chownr@^2.0.0: resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== +chownr@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4" + integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== + chromium-pickle-js@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz#04a106672c18b085ab774d983dfa3ea138f22205" @@ -5051,7 +5068,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@^10.3.10, glob@^10.3.12: +glob@^10.3.10: + version "10.3.15" + resolved "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz" + integrity sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw== + dependencies: + foreground-child "^3.1.0" + jackspeak "^2.3.6" + minimatch "^9.0.1" + minipass "^7.0.4" + path-scurry "^1.11.0" + +glob@^10.3.12, glob@^10.3.7: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== @@ -5770,6 +5798,15 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" +jackspeak@^2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" + integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jackspeak@^3.1.2: version "3.4.3" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" @@ -6345,7 +6382,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.3, minimatch@^9.0.4: +minimatch@^9.0.1, minimatch@^9.0.3, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -6413,7 +6450,7 @@ minipass@^5.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.4, minipass@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== @@ -6426,6 +6463,14 @@ minizlib@^2.1.1, minizlib@^2.1.2: minipass "^3.0.0" yallist "^4.0.0" +minizlib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.0.1.tgz#46d5329d1eb3c83924eff1d3b858ca0a31581012" + integrity sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg== + dependencies: + minipass "^7.0.4" + rimraf "^5.0.5" + mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" @@ -6448,6 +6493,11 @@ mkdirp@^2.1.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== +mkdirp@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" + integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== + mlly@^1.4.2, mlly@^1.7.1: version "1.7.1" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.1.tgz#e0336429bb0731b6a8e887b438cbdae522c8f32f" @@ -6780,9 +6830,9 @@ p-map@^4.0.0: aggregate-error "^3.0.0" package-json-from-dist@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" - integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== parent-module@^1.0.0: version "1.0.1" @@ -6867,7 +6917,7 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.11.1, path-scurry@^1.6.1: +path-scurry@^1.11.0, path-scurry@^1.11.1, path-scurry@^1.6.1: version "1.11.1" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== @@ -7412,6 +7462,13 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@^5.0.5: + version "5.0.10" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" + integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== + dependencies: + glob "^10.3.7" + roarr@^2.15.3: version "2.15.4" resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd" @@ -7956,6 +8013,18 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.1.2: mkdirp "^1.0.3" yallist "^4.0.0" +tar@^7.4.3: + version "7.4.3" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.4.3.tgz#88bbe9286a3fcd900e94592cda7a22b192e80571" + integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== + dependencies: + "@isaacs/fs-minipass" "^4.0.0" + chownr "^3.0.0" + minipass "^7.1.2" + minizlib "^3.0.1" + mkdirp "^3.0.1" + yallist "^5.0.0" + tarn@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693" @@ -8611,6 +8680,11 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yallist@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" + integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== + yaml@^2.4.1: version "2.5.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.1.tgz#c9772aacf62cb7494a95b0c4f1fb065b563db130"