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 46f4a872..a7151ed3 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,4 +1,4 @@ -appId: site.hydralauncher.hydra +appId: gg.hydralauncher.hydra productName: Hydra directories: buildResources: build 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 b5a5e374..c97e256f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -136,14 +136,15 @@ "backups": "Backups", "install_backup": "Install", "delete_backup": "Delete", - "create_backup": "Create backup", + "create_backup": "New backup", "last_backup_date": "Last backup on {{date}}", - "no_backup_preview": "Hydra could not locate any save games for this game", - "restoring_backup": "Restoring backup…", + "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_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 24b971a2..bcbcd6ef 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -132,14 +132,15 @@ "backups": "Backups", "install_backup": "Restaurar", "delete_backup": "Apagar", - "create_backup": "Criar backup", + "create_backup": "Novo backup", "last_backup_date": "Último backup em {{date}}", - "no_backup_preview": "Hydra não encontrou nenhum save para este jogo", - "restoring_backup": "Restaurando backup…", + "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_deleted": "Backup apagado", + "backup_restored": "Backup restaurado" }, "activation": { "title": "Ativação", @@ -181,7 +182,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 85c1a3c5..3042ed9c 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -157,7 +157,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/events/catalogue/get-random-game.ts b/src/main/events/catalogue/get-random-game.ts index 57acfc30..0a3797a9 100644 --- a/src/main/events/catalogue/get-random-game.ts +++ b/src/main/events/catalogue/get-random-game.ts @@ -3,32 +3,15 @@ import { shuffle } from "lodash-es"; import { getSteam250List } from "@main/services"; import { registerEvent } from "../register-event"; -// import { getSteamGameById } from "../helpers/search-games"; import type { Steam250Game } from "@types"; const state = { games: Array(), index: 0 }; -const filterGames = async (_games: Steam250Game[]) => { - const results: Steam250Game[] = []; - - // for (const game of games) { - // const steamGame = await getSteamGameById(game.objectID); - - // if (steamGame?.repacks.length) { - // results.push(game); - // } - // } - - return results; -}; - const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => { if (state.games.length == 0) { const steam250List = await getSteam250List(); - const filteredSteam250List = await filterGames(steam250List); - - state.games = shuffle(filteredSteam250List); + state.games = shuffle(steam250List); } if (state.games.length == 0) { diff --git a/src/main/events/catalogue/get-repacks.ts b/src/main/events/catalogue/get-repacks.ts deleted file mode 100644 index db39fc7e..00000000 --- a/src/main/events/catalogue/get-repacks.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { registerEvent } from "../register-event"; -import { knexClient } from "@main/knex-client"; - -const getRepacks = (_event: Electron.IpcMainInvokeEvent) => - knexClient.select("*").from("repack"); - -registerEvent("getRepacks", getRepacks); diff --git a/src/main/events/catalogue/search-games.ts b/src/main/events/catalogue/search-games.ts index ebe601f2..8f81d40e 100644 --- a/src/main/events/catalogue/search-games.ts +++ b/src/main/events/catalogue/search-games.ts @@ -1,7 +1,7 @@ import { registerEvent } from "../register-event"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { CatalogueEntry } from "@types"; -import { HydraApi, RepacksManager } from "@main/services"; +import { HydraApi } from "@main/services"; const searchGamesEvent = async ( _event: Electron.IpcMainInvokeEvent, @@ -11,15 +11,13 @@ const searchGamesEvent = async ( { objectId: string; title: string; shop: string }[] >("/games/search", { title: query, take: 12, skip: 0 }, { needsAuth: false }); - const steamGames = games.map((game) => { + return games.map((game) => { return convertSteamGameToCatalogueEntry({ id: Number(game.objectId), name: game.title, clientIcon: null, }); }); - - return RepacksManager.findRepacksForCatalogueEntries(steamGames); }; registerEvent("searchGames", searchGamesEvent); diff --git a/src/main/events/cloud-save/download-game-artifact.ts b/src/main/events/cloud-save/download-game-artifact.ts index a1254dc3..94bc8edf 100644 --- a/src/main/events/cloud-save/download-game-artifact.ts +++ b/src/main/events/cloud-save/download-game-artifact.ts @@ -19,13 +19,19 @@ const downloadGameArtifact = async ( objectKey: string; }>(`/games/artifacts/${gameArtifactId}/download`); - const response = await axios.get(downloadUrl, { - responseType: "stream", - }); - const zipLocation = path.join(app.getPath("userData"), objectKey); const backupPath = path.join(backupsPath, `${shop}-${objectId}`); + 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); @@ -45,7 +51,7 @@ const downloadGameArtifact = async ( Ludusavi.restoreBackup(backupPath).then(() => { WindowManager.mainWindow?.webContents.send( - `on-download-complete-${objectId}-${shop}`, + `on-backup-download-complete-${objectId}-${shop}`, true ); }); diff --git a/src/main/events/cloud-save/upload-save-game.ts b/src/main/events/cloud-save/upload-save-game.ts index 0c9a4fbd..3a89b275 100644 --- a/src/main/events/cloud-save/upload-save-game.ts +++ b/src/main/events/cloud-save/upload-save-game.ts @@ -73,26 +73,31 @@ const uploadSaveGame = async ( throw err; } - axios.put(uploadUrl, fileBuffer, { + await axios.put(uploadUrl, fileBuffer, { headers: { "Content-Type": "application/zip", }, onUploadProgress: (progressEvent) => { - if (progressEvent.progress === 1) { - fs.rm(zipLocation, (err) => { - if (err) { - logger.error("Failed to remove zip file", err); - throw err; - } - - WindowManager.mainWindow?.webContents.send( - `on-upload-complete-${objectId}-${shop}`, - true - ); - }); - } + console.log(progressEvent); }, }); + + WindowManager.mainWindow?.webContents.send( + `on-upload-complete-${objectId}-${shop}`, + true + ); + + // fs.rm(zipLocation, (err) => { + // if (err) { + // logger.error("Failed to remove zip file", err); + // throw err; + // } + + // WindowManager.mainWindow?.webContents.send( + // `on-upload-complete-${objectId}-${shop}`, + // true + // ); + // }); }); }); }); diff --git a/src/main/events/download-sources/delete-download-source.ts b/src/main/events/download-sources/delete-download-source.ts new file mode 100644 index 00000000..abfbf661 --- /dev/null +++ b/src/main/events/download-sources/delete-download-source.ts @@ -0,0 +1,9 @@ +import { registerEvent } from "../register-event"; +import { knexClient } from "@main/knex-client"; + +const deleteDownloadSource = async ( + _event: Electron.IpcMainInvokeEvent, + id: number +) => knexClient("download_source").where({ id }).delete(); + +registerEvent("deleteDownloadSource", deleteDownloadSource); diff --git a/src/main/events/download-sources/sync-download-sources.ts b/src/main/events/download-sources/sync-download-sources.ts deleted file mode 100644 index 49380f30..00000000 --- a/src/main/events/download-sources/sync-download-sources.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { downloadSourcesWorker } from "@main/workers"; -import { registerEvent } from "../register-event"; -import type { DownloadSource } from "@types"; - -const syncDownloadSources = async ( - _event: Electron.IpcMainInvokeEvent, - downloadSources: DownloadSource[] -) => - downloadSourcesWorker.run(downloadSources, { - name: "getUpdatedRepacks", - }); - -registerEvent("syncDownloadSources", syncDownloadSources); diff --git a/src/main/events/download-sources/validate-download-source.ts b/src/main/events/download-sources/validate-download-source.ts deleted file mode 100644 index 4f43ca08..00000000 --- a/src/main/events/download-sources/validate-download-source.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { registerEvent } from "../register-event"; -import { downloadSourcesWorker } from "@main/workers"; - -const validateDownloadSource = async ( - _event: Electron.IpcMainInvokeEvent, - url: string -) => - downloadSourcesWorker.run(url, { - name: "validateDownloadSource", - }); - -registerEvent("validateDownloadSource", validateDownloadSource); diff --git a/src/main/events/helpers/search-games.ts b/src/main/events/helpers/search-games.ts index 58e9bc92..1f1fc756 100644 --- a/src/main/events/helpers/search-games.ts +++ b/src/main/events/helpers/search-games.ts @@ -1,7 +1,6 @@ import type { GameShop, CatalogueEntry, SteamGame } from "@types"; import { steamGamesWorker } from "@main/workers"; -import { RepacksManager } from "@main/services"; import { steamUrlBuilder } from "@shared"; export interface SearchGamesArgs { @@ -28,9 +27,5 @@ export const getSteamGameById = async ( if (!steamGame) return null; - const catalogueEntry = convertSteamGameToCatalogueEntry(steamGame); - - const result = RepacksManager.findRepacksForCatalogueEntry(catalogueEntry); - - return result; + return convertSteamGameToCatalogueEntry(steamGame); }; diff --git a/src/main/events/helpers/validators.ts b/src/main/events/helpers/validators.ts deleted file mode 100644 index ee36bb85..00000000 --- a/src/main/events/helpers/validators.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod"; - -export const downloadSourceSchema = z.object({ - name: z.string().max(255), - downloads: z.array( - z.object({ - title: z.string().max(255), - uris: z.array(z.string()), - uploadDate: z.string().max(255), - fileSize: z.string().max(255), - }) - ), -}); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 53e5611f..a3c3fd6c 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -9,7 +9,6 @@ import "./catalogue/get-random-game"; import "./catalogue/search-games"; import "./catalogue/get-game-stats"; import "./catalogue/get-trending-games"; -import "./catalogue/get-repacks"; import "./hardware/get-disk-free-space"; import "./library/add-game-to-library"; import "./library/create-game-shortcut"; @@ -37,9 +36,8 @@ import "./user-preferences/auto-launch"; import "./autoupdater/check-for-updates"; import "./autoupdater/restart-and-install-update"; import "./user-preferences/authenticate-real-debrid"; +import "./download-sources/delete-download-source"; import "./download-sources/get-download-sources"; -import "./download-sources/validate-download-source"; -import "./download-sources/sync-download-sources"; import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; @@ -64,6 +62,7 @@ import "./cloud-save/get-game-backup-preview"; import "./cloud-save/upload-save-game"; import "./cloud-save/check-game-cloud-sync-support"; import "./cloud-save/delete-game-artifact"; +import "./notifications/publish-new-repacks-notification"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/library/add-game-to-library.ts b/src/main/events/library/add-game-to-library.ts index 13a7e5e0..b5c9a5d0 100644 --- a/src/main/events/library/add-game-to-library.ts +++ b/src/main/events/library/add-game-to-library.ts @@ -3,7 +3,6 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; -import { getFileBase64 } from "@main/helpers"; import { steamGamesWorker } from "@main/workers"; import { createGame } from "@main/services/library-sync"; @@ -36,20 +35,12 @@ const addGameToLibrary = async ( ? steamUrlBuilder.icon(objectID, steamGame.clientIcon) : null; - await gameRepository - .insert({ - title, - iconUrl, - objectID, - shop, - }) - .then(() => { - if (iconUrl) { - getFileBase64(iconUrl).then((base64) => - gameRepository.update({ objectID }, { iconUrl: base64 }) - ); - } - }); + await gameRepository.insert({ + title, + iconUrl, + objectID, + shop, + }); } const game = await gameRepository.findOne({ where: { objectID } }); diff --git a/src/main/events/notifications/publish-new-repacks-notification.ts b/src/main/events/notifications/publish-new-repacks-notification.ts new file mode 100644 index 00000000..5230c209 --- /dev/null +++ b/src/main/events/notifications/publish-new-repacks-notification.ts @@ -0,0 +1,29 @@ +import { Notification } from "electron"; +import { registerEvent } from "../register-event"; +import { userPreferencesRepository } from "@main/repository"; +import { t } from "i18next"; + +const publishNewRepacksNotification = async ( + _event: Electron.IpcMainInvokeEvent, + newRepacksCount: number +) => { + if (newRepacksCount < 1) return; + + const userPreferences = await userPreferencesRepository.findOne({ + where: { id: 1 }, + }); + + if (userPreferences?.repackUpdatesNotificationsEnabled) { + new Notification({ + title: t("repack_list_updated", { + ns: "notifications", + }), + body: t("repack_count", { + ns: "notifications", + count: newRepacksCount, + }), + }).show(); + } +}; + +registerEvent("publishNewRepacksNotification", publishNewRepacksNotification); diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index 491083cb..a2c51a01 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -1,7 +1,6 @@ import { registerEvent } from "../register-event"; import type { StartGameDownloadPayload } from "@types"; -import { getFileBase64 } from "@main/helpers"; import { DownloadManager, HydraApi, logger } from "@main/services"; import { Not } from "typeorm"; @@ -60,26 +59,16 @@ const startGameDownload = async ( ? steamUrlBuilder.icon(objectID, steamGame.clientIcon) : null; - await gameRepository - .insert({ - title, - iconUrl, - objectID, - downloader, - shop, - status: "active", - downloadPath, - uri, - }) - .then((result) => { - if (iconUrl) { - getFileBase64(iconUrl).then((base64) => - gameRepository.update({ objectID }, { iconUrl: base64 }) - ); - } - - return result; - }); + await gameRepository.insert({ + title, + iconUrl, + objectID, + downloader, + shop, + status: "active", + downloadPath, + uri, + }); } const updatedGame = await gameRepository.findOne({ diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index a9dcae6c..bf29762a 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -7,16 +7,6 @@ export const getFileBuffer = async (url: string) => response.arrayBuffer().then((buffer) => Buffer.from(buffer)) ); -export const getFileBase64 = async (url: string) => - fetch(url, { method: "GET" }).then((response) => - response.arrayBuffer().then((buffer) => { - const base64 = Buffer.from(buffer).toString("base64"); - const contentType = response.headers.get("content-type"); - - return `data:${contentType};base64,${base64}`; - }) - ); - export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/main/index.ts b/src/main/index.ts index 594220c5..c9e36b2c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, net, protocol } from "electron"; +import { app, BrowserWindow, net, protocol, session } from "electron"; import { init } from "@sentry/electron/main"; import updater from "electron-updater"; import i18n from "i18next"; @@ -74,7 +74,7 @@ const runMigrations = async () => { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { - electronApp.setAppUserModelId("site.hydralauncher.hydra"); + electronApp.setAppUserModelId("gg.hydralauncher.hydra"); protocol.handle("local", (request) => { const filePath = request.url.slice("local:".length); @@ -103,6 +103,46 @@ app.whenReady().then(async () => { WindowManager.createMainWindow(); 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/main.ts b/src/main/main.ts index b71bab8c..7f3d6370 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,14 +1,10 @@ import { DownloadManager, PythonInstance, startMainLoop } from "./services"; import { downloadQueueRepository, - // repackRepository, userPreferencesRepository, } from "./repository"; import { UserPreferences } from "./entity"; import { RealDebridClient } from "./services/real-debrid"; -// import { fetchDownloadSourcesAndUpdate } from "./helpers"; -// import { publishNewRepacksNotifications } from "./services/notifications"; -// import { MoreThan } from "typeorm"; import { HydraApi } from "./services/hydra-api"; import { uploadGamesBatch } from "./services/library-sync"; @@ -39,18 +35,6 @@ const loadState = async (userPreferences: UserPreferences | null) => { } startMainLoop(); - - // const now = new Date(); - - // fetchDownloadSourcesAndUpdate().then(async () => { - // const newRepacksCount = await repackRepository.count({ - // where: { - // createdAt: MoreThan(now), - // }, - // }); - - // if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount); - // }); }; userPreferencesRepository diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 8c6e6cda..27abf579 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -7,6 +7,5 @@ export * from "./download"; export * from "./how-long-to-beat"; export * from "./process-watcher"; export * from "./main-loop"; -export * from "./repacks-manager"; export * from "./hydra-api"; export * from "./ludusavi"; diff --git a/src/main/services/notifications.ts b/src/main/services/notifications.ts index aa43571d..81d9e582 100644 --- a/src/main/services/notifications.ts +++ b/src/main/services/notifications.ts @@ -49,24 +49,6 @@ export const publishDownloadCompleteNotification = async (game: Game) => { } }; -export const publishNewRepacksNotifications = async (count: number) => { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); - - if (userPreferences?.repackUpdatesNotificationsEnabled) { - new Notification({ - title: t("repack_list_updated", { - ns: "notifications", - }), - body: t("repack_count", { - ns: "notifications", - count: count, - }), - }).show(); - } -}; - export const publishNotificationUpdateReadyToInstall = async ( version: string ) => { diff --git a/src/main/services/repacks-manager.ts b/src/main/services/repacks-manager.ts deleted file mode 100644 index 933d7431..00000000 --- a/src/main/services/repacks-manager.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { repackRepository } from "@main/repository"; -import { formatName } from "@shared"; -import { CatalogueEntry, GameRepack } from "@types"; -import flexSearch from "flexsearch"; - -export class RepacksManager { - public static repacks: GameRepack[] = []; - private static repacksIndex = new flexSearch.Index(); - - public static async updateRepacks() { - this.repacks = await repackRepository - .find({ - order: { - createdAt: "DESC", - }, - }) - .then((repacks) => - repacks.map((repack) => { - const uris: string[] = []; - const magnet = repack?.magnet; - - if (magnet) uris.push(magnet); - - return { - ...repack, - uris: [...uris, ...JSON.parse(repack.uris)], - }; - }) - ); - - for (let i = 0; i < this.repacks.length; i++) { - this.repacksIndex.remove(i); - } - - this.repacksIndex = new flexSearch.Index(); - - for (let i = 0; i < this.repacks.length; i++) { - const repack = this.repacks[i]; - - const formattedTitle = formatName(repack.title); - - this.repacksIndex.add(i, formattedTitle); - } - } - - public static search(options: flexSearch.SearchOptions) { - return this.repacksIndex - .search({ ...options, query: formatName(options.query ?? "") }) - .map((index) => this.repacks[index]); - } - - public static findRepacksForCatalogueEntry(entry: CatalogueEntry) { - const repacks = this.search({ query: formatName(entry.title) }); - return { ...entry, repacks }; - } - - public static findRepacksForCatalogueEntries(entries: CatalogueEntry[]) { - return entries.map((entry) => { - const repacks = this.search({ query: formatName(entry.title) }); - return { ...entry, repacks }; - }); - } -} diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index 905e4b65..cf904fb4 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -101,6 +101,8 @@ export class WindowManager { authWindow.removeMenu(); + if (!app.isPackaged) authWindow.webContents.openDevTools(); + const searchParams = new URLSearchParams({ lng: i18next.language, }); @@ -132,7 +134,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/download-sources.worker.ts b/src/main/workers/download-sources.worker.ts deleted file mode 100644 index c660ad00..00000000 --- a/src/main/workers/download-sources.worker.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { downloadSourceSchema } from "@main/events/helpers/validators"; -import { DownloadSourceStatus } from "@shared"; -import type { DownloadSource } from "@types"; -import axios, { AxiosError, AxiosHeaders } from "axios"; -import { z } from "zod"; - -export type DownloadSourceResponse = z.infer & { - etag: string | null; - status: DownloadSourceStatus; -}; - -export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => { - const results: DownloadSourceResponse[] = []; - - for (const downloadSource of downloadSources) { - const headers = new AxiosHeaders(); - - if (downloadSource.etag) { - headers.set("If-None-Match", downloadSource.etag); - } - - try { - const response = await axios.get(downloadSource.url, { - headers, - }); - - const source = downloadSourceSchema.parse(response.data); - - results.push({ - ...downloadSource, - downloads: source.downloads, - etag: response.headers["etag"], - status: DownloadSourceStatus.UpToDate, - }); - } catch (err: unknown) { - const isNotModified = (err as AxiosError).response?.status === 304; - - results.push({ - ...downloadSource, - downloads: [], - etag: null, - status: isNotModified - ? DownloadSourceStatus.UpToDate - : DownloadSourceStatus.Errored, - }); - } - } - - return results; -}; - -export const validateDownloadSource = async (url: string) => { - const response = await axios.get(url); - - return { - ...downloadSourceSchema.parse(response.data), - etag: response.headers["etag"], - }; -}; diff --git a/src/main/workers/index.ts b/src/main/workers/index.ts index 799ed2ef..eded03a3 100644 --- a/src/main/workers/index.ts +++ b/src/main/workers/index.ts @@ -1,6 +1,5 @@ import path from "node:path"; import steamGamesWorkerPath from "./steam-games.worker?modulePath"; -import downloadSourcesWorkerPath from "./download-sources.worker?modulePath"; import Piscina from "piscina"; @@ -13,7 +12,3 @@ export const steamGamesWorker = new Piscina({ }, maxThreads: 1, }); - -export const downloadSourcesWorker = new Piscina({ - filename: downloadSourcesWorkerPath, -}); diff --git a/src/preload/index.ts b/src/preload/index.ts index 52e671a4..f5dd0ba7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,9 +11,9 @@ import type { GameRunning, FriendRequestAction, UpdateProfileRequest, - DownloadSource, } from "@types"; import type { CatalogueCategory } from "@shared"; +import type { AxiosProgressEvent } from "axios"; contextBridge.exposeInMainWorld("electron", { /* Torrenting */ @@ -50,8 +50,6 @@ contextBridge.exposeInMainWorld("electron", { getGameStats: (objectId: string, shop: GameShop) => ipcRenderer.invoke("getGameStats", objectId, shop), getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"), - /* Meant for Dexie migration */ - getRepacks: () => ipcRenderer.invoke("getRepacks"), /* User preferences */ getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"), @@ -63,10 +61,8 @@ contextBridge.exposeInMainWorld("electron", { /* Download sources */ getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), - validateDownloadSource: (url: string) => - ipcRenderer.invoke("validateDownloadSource", url), - syncDownloadSources: (downloadSources: DownloadSource[]) => - ipcRenderer.invoke("syncDownloadSources", downloadSources), + deleteDownloadSource: (id: number) => + ipcRenderer.invoke("deleteDownloadSource", id), /* Library */ addGameToLibrary: (objectID: string, title: string, shop: GameShop) => @@ -141,12 +137,32 @@ contextBridge.exposeInMainWorld("electron", { listener ); }, - onDownloadComplete: (objectId: string, shop: GameShop, cb: () => void) => { - const listener = (_event: Electron.IpcRendererEvent) => cb(); - ipcRenderer.on(`on-download-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-download-complete-${objectId}-${shop}`, + `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 ); }, @@ -218,4 +234,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.on("on-signout", listener); return () => ipcRenderer.removeListener("on-signout", listener); }, + + /* Notifications */ + publishNewRepacksNotification: (newRepacksCount: number) => + ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount), }); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index f7a24a46..37e63154 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -26,7 +26,7 @@ import { } from "@renderer/features"; import { useTranslation } from "react-i18next"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; -// import { migrationWorker } from "./workers"; +import { downloadSourcesWorker } from "./workers"; import { repacksContext } from "./context"; export interface AppProps { @@ -39,6 +39,8 @@ export function App() { const { t } = useTranslation("app"); + const downloadSourceMigrationLock = useRef(false); + const { clearDownload, setLastPacket } = useDownload(); const { indexRepacks } = useContext(repacksContext); @@ -211,22 +213,46 @@ export function App() { }, [dispatch, draggingDisabled]); useEffect(() => { - // window.electron.getRepacks().then((repacks) => { - // migrationWorker.postMessage(["MIGRATE_REPACKS", repacks]); - // }); - // window.electron.getDownloadSources().then((downloadSources) => { - // migrationWorker.postMessage([ - // "MIGRATE_DOWNLOAD_SOURCES", - // downloadSources, - // ]); - // }); - // migrationWorker.onmessage = ( - // event: MessageEvent<"MIGRATE_REPACKS_COMPLETE"> - // ) => { - // if (event.data === "MIGRATE_REPACKS_COMPLETE") { - // indexRepacks(); - // } - // }; + if (downloadSourceMigrationLock.current) return; + + downloadSourceMigrationLock.current = true; + + window.electron.getDownloadSources().then(async (downloadSources) => { + if (!downloadSources.length) { + const id = crypto.randomUUID(); + const channel = new BroadcastChannel(`download_sources:sync:${id}`); + + channel.onmessage = (event: MessageEvent) => { + const newRepacksCount = event.data; + window.electron.publishNewRepacksNotification(newRepacksCount); + }; + + downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); + } + + for (const downloadSource of downloadSources) { + const channel = new BroadcastChannel( + `download_sources:import:${downloadSource.url}` + ); + await new Promise((resolve) => { + downloadSourcesWorker.postMessage([ + "IMPORT_DOWNLOAD_SOURCE", + downloadSource.url, + ]); + + channel.onmessage = () => { + window.electron.deleteDownloadSource(downloadSource.id).then(() => { + resolve(true); + }); + + indexRepacks(); + channel.close(); + }; + }).catch(() => channel.close()); + } + + downloadSourceMigrationLock.current = false; + }); }, [indexRepacks]); const handleToastClose = useCallback(() => { diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx index 2f0addaf..417c241a 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -8,6 +8,7 @@ import React, { useMemo, useState, } from "react"; +import { useTranslation } from "react-i18next"; export enum CloudSyncState { New, @@ -20,12 +21,14 @@ 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; } @@ -40,6 +43,8 @@ export const cloudSyncContext = createContext({ uploadSaveGame: async () => {}, artifacts: [], deleteGameArtifact: async () => {}, + showCloudSyncFilesModal: false, + setShowCloudSyncFilesModal: () => {}, restoringBackup: false, uploadingBackup: false, }); @@ -58,6 +63,8 @@ export function CloudSyncContextProvider({ objectId, shop, }: CloudSyncContextProviderProps) { + const { t } = useTranslation("game_details"); + const [supportsCloudSync, setSupportsCloudSync] = useState( null ); @@ -68,6 +75,7 @@ export function CloudSyncContextProvider({ ); const [restoringBackup, setRestoringBackup] = useState(false); const [uploadingBackup, setUploadingBackup] = useState(false); + const [showCloudSyncFilesModal, setShowCloudSyncFilesModal] = useState(false); const { showSuccessToast } = useToast(); @@ -101,7 +109,7 @@ export function CloudSyncContextProvider({ objectId, shop, () => { - showSuccessToast("backup_uploaded"); + showSuccessToast(t("backup_uploaded")); setUploadingBackup(false); gameBackupsTable.add({ @@ -114,22 +122,19 @@ export function CloudSyncContextProvider({ } ); - const removeDownloadCompleteListener = window.electron.onDownloadComplete( - objectId, - shop, - () => { - showSuccessToast("backup_restored"); + const removeDownloadCompleteListener = + window.electron.onBackupDownloadComplete(objectId, shop, () => { + showSuccessToast(t("backup_restored")); setRestoringBackup(false); getGameBackupPreview(); - } - ); + }); return () => { removeUploadCompleteListener(); removeDownloadCompleteListener(); }; - }, [objectId, shop, showSuccessToast, getGameBackupPreview]); + }, [objectId, shop, showSuccessToast, t, getGameBackupPreview]); const deleteGameArtifact = useCallback( async (gameArtifactId: string) => { @@ -181,10 +186,12 @@ export function CloudSyncContextProvider({ backupState, restoringBackup, uploadingBackup, + showCloudSyncFilesModal, setShowCloudSyncModal, uploadSaveGame, downloadGameArtifact, deleteGameArtifact, + setShowCloudSyncFilesModal, }} > {children} diff --git a/src/renderer/src/context/repacks/repacks.context.tsx b/src/renderer/src/context/repacks/repacks.context.tsx index c59d5792..cddbb209 100644 --- a/src/renderer/src/context/repacks/repacks.context.tsx +++ b/src/renderer/src/context/repacks/repacks.context.tsx @@ -33,6 +33,7 @@ export function RepacksContextProvider({ children }: RepacksContextProps) { const channel = new BroadcastChannel(`repacks:search:${channelId}`); channel.onmessage = (event: MessageEvent) => { resolve(event.data); + channel.close(); }; return []; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index df1f105f..870b592a 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -25,10 +25,10 @@ import type { UserStats, UserDetails, FriendRequestSync, - DownloadSourceValidationResult, GameArtifact, LudusaviBackup, } from "@types"; +import type { AxiosProgressEvent } from "axios"; import type { DiskSpace } from "check-disk-space"; declare global { @@ -68,8 +68,6 @@ declare global { searchGameRepacks: (query: string) => Promise; getGameStats: (objectId: string, shop: GameShop) => Promise; getTrendingGames: () => Promise; - /* Meant for Dexie migration */ - getRepacks: () => Promise; /* Library */ addGameToLibrary: ( @@ -107,10 +105,7 @@ declare global { /* Download sources */ getDownloadSources: () => Promise; - validateDownloadSource: ( - url: string - ) => Promise; - syncDownloadSources: (downloadSources: DownloadSource[]) => Promise; + deleteDownloadSource: (id: number) => Promise; /* Hardware */ getDiskFreeSpace: (path: string) => Promise; @@ -135,7 +130,7 @@ declare global { shop: GameShop ) => Promise; deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; - onDownloadComplete: ( + onBackupDownloadComplete: ( objectId: string, shop: GameShop, cb: () => void @@ -145,6 +140,11 @@ declare global { shop: GameShop, cb: () => void ) => () => Electron.IpcRenderer; + onBackupDownloadProgress: ( + objectId: string, + shop: GameShop, + cb: (progress: AxiosProgressEvent) => void + ) => () => Electron.IpcRenderer; /* Misc */ openExternal: (src: string) => Promise; @@ -205,6 +205,9 @@ declare global { action: FriendRequestAction ) => Promise; sendFriendRequest: (userId: string) => Promise; + + /* Notifications */ + publishNewRepacksNotification: (newRepacksCount: number) => Promise; } interface Window { 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..aebabda3 --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx @@ -0,0 +1,77 @@ +import { Button, Modal, ModalProps, TextField } from "@renderer/components"; +import { useContext, useMemo } from "react"; +import { cloudSyncContext } from "@renderer/context"; + +import { useTranslation } from "react-i18next"; +import { CheckCircleFillIcon } from "@primer/octicons-react"; + +export interface CloudSyncFilesModalProps + extends Omit {} + +export function CloudSyncFilesModal({ + visible, + onClose, +}: CloudSyncFilesModalProps) { + const { t } = useTranslation("game_details"); + + const { backupPreview } = useContext(cloudSyncContext); + + 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) => ( +
  • + +
  • + ))} +
+
+ ); +} 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 index bb3335fa..916b7a1f 100644 --- 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 @@ -1,7 +1,14 @@ -import { style } from "@vanilla-extract/css"; +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`, @@ -24,3 +31,10 @@ export const artifactButton = style({ 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 index 3402c627..1bd849c3 100644 --- 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 @@ -9,7 +9,7 @@ import { CheckCircleFillIcon, ClockIcon, DeviceDesktopIcon, - DownloadIcon, + HistoryIcon, SyncIcon, TrashIcon, UploadIcon, @@ -17,6 +17,9 @@ import { 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 {} @@ -24,6 +27,8 @@ export interface CloudSyncModalProps 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"); @@ -35,6 +40,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { uploadSaveGame, downloadGameArtifact, deleteGameArtifact, + setShowCloudSyncFilesModal, } = useContext(cloudSyncContext); const { objectID, shop, gameTitle } = useContext(gameDetailsContext); @@ -60,13 +66,31 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { .where({ shop: shop, objectId: 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")} ); @@ -75,8 +99,12 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { if (restoringBackup) { return ( - - {t("restoring_backup")} + + {t("restoring_backup", { + progress: formatDownloadProgress( + backupDownloadProgress?.progress ?? 0 + ), + })} ); } @@ -84,7 +112,10 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { if (lastBackup) { return ( - + + + + {t("last_backup_date", { date: format(lastBackup.createdAt, "dd/MM/yyyy HH:mm"), })} @@ -97,7 +128,14 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { } return t("no_backups"); - }, [uploadingBackup, lastBackup, backupPreview, restoringBackup, t]); + }, [ + uploadingBackup, + backupDownloadProgress?.progress, + lastBackup, + backupPreview, + restoringBackup, + t, + ]); const disableActions = uploadingBackup || restoringBackup || deletingArtifact; @@ -120,6 +158,22 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {

{gameTitle}

{backupStateLabel}

+ +
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..acfab0dd 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({ 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..2deb8436 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -272,64 +272,86 @@ export function ProfileHero() {
-
- + +
+
+ -
- {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() - ), - })} - -
- )} + })} + +
+ )} +
-
+
+ ) => { + setValidationResult(event.data); + channel.close(); + }; setUrl(values.url); }, @@ -93,16 +106,14 @@ export function AddDownloadSourceModal({ if (validationResult) { const channel = new BroadcastChannel(`download_sources:import:${url}`); - downloadSourcesWorker.postMessage([ - "IMPORT_DOWNLOAD_SOURCE", - { ...validationResult, url }, - ]); + downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]); channel.onmessage = () => { setIsLoading(false); onClose(); onAddDownloadSource(); + channel.close(); }; } }; @@ -159,9 +170,9 @@ export function AddDownloadSourceModal({

{validationResult?.name}

{t("found_download_option", { - count: validationResult?.downloads.length, + count: validationResult?.downloadCount, countFormatted: - validationResult?.downloads.length.toLocaleString(), + validationResult?.downloadCount.toLocaleString(), })}
diff --git a/src/renderer/src/pages/settings/settings-download-sources.tsx b/src/renderer/src/pages/settings/settings-download-sources.tsx index 4f70ff6b..d2f45329 100644 --- a/src/renderer/src/pages/settings/settings-download-sources.tsx +++ b/src/renderer/src/pages/settings/settings-download-sources.tsx @@ -59,6 +59,7 @@ export function SettingsDownloadSources() { getDownloadSources(); indexRepacks(); setIsRemovingDownloadSource(false); + channel.close(); }; }; @@ -71,15 +72,17 @@ export function SettingsDownloadSources() { const syncDownloadSources = async () => { setIsSyncingDownloadSources(true); - window.electron - .syncDownloadSources(downloadSources) - .then(() => { - showSuccessToast(t("download_sources_synced")); - getDownloadSources(); - }) - .finally(() => { - setIsSyncingDownloadSources(false); - }); + const id = crypto.randomUUID(); + const channel = new BroadcastChannel(`download_sources:sync:${id}`); + + downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); + + channel.onmessage = () => { + showSuccessToast(t("download_sources_synced")); + getDownloadSources(); + setIsSyncingDownloadSources(false); + channel.close(); + }; }; const statusTitle = { diff --git a/src/renderer/src/workers/download-sources.worker.ts b/src/renderer/src/workers/download-sources.worker.ts index 8ad63a04..29ab7d87 100644 --- a/src/renderer/src/workers/download-sources.worker.ts +++ b/src/renderer/src/workers/download-sources.worker.ts @@ -1,16 +1,45 @@ import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie"; + +import { z } from "zod"; +import axios, { AxiosError, AxiosHeaders } from "axios"; import { DownloadSourceStatus } from "@shared"; -import type { DownloadSourceValidationResult } from "@types"; + +export const downloadSourceSchema = z.object({ + name: z.string().max(255), + downloads: z.array( + z.object({ + title: z.string().max(255), + uris: z.array(z.string()), + uploadDate: z.string().max(255), + fileSize: z.string().max(255), + }) + ), +}); type Payload = - | ["IMPORT_DOWNLOAD_SOURCE", DownloadSourceValidationResult & { url: string }] - | ["DELETE_DOWNLOAD_SOURCE", number]; - -db.open(); + | ["IMPORT_DOWNLOAD_SOURCE", string] + | ["DELETE_DOWNLOAD_SOURCE", number] + | ["VALIDATE_DOWNLOAD_SOURCE", string] + | ["SYNC_DOWNLOAD_SOURCES", string]; self.onmessage = async (event: MessageEvent) => { const [type, data] = event.data; + if (type === "VALIDATE_DOWNLOAD_SOURCE") { + const response = + await axios.get>(data); + + const { name } = downloadSourceSchema.parse(response.data); + + const channel = new BroadcastChannel(`download_sources:validate:${data}`); + + channel.postMessage({ + name, + etag: response.headers["etag"], + downloadCount: response.data.downloads.length, + }); + } + if (type === "DELETE_DOWNLOAD_SOURCE") { await db.transaction("rw", repacksTable, downloadSourcesTable, async () => { await repacksTable.where({ downloadSourceId: data }).delete(); @@ -23,28 +52,29 @@ self.onmessage = async (event: MessageEvent) => { } if (type === "IMPORT_DOWNLOAD_SOURCE") { - const result = data; + const response = + await axios.get>(data); - await db.transaction("rw", downloadSourcesTable, repacksTable, async () => { + await db.transaction("rw", repacksTable, downloadSourcesTable, async () => { const now = new Date(); const id = await downloadSourcesTable.add({ - url: result.url, - name: result.name, - etag: result.etag, + url: data, + name: response.data.name, + etag: response.headers["etag"], status: DownloadSourceStatus.UpToDate, - downloadCount: result.downloads.length, + downloadCount: response.data.downloads.length, createdAt: now, updatedAt: now, }); const downloadSource = await downloadSourcesTable.get(id); - const repacks = result.downloads.map((download) => ({ + const repacks = response.data.downloads.map((download) => ({ title: download.title, uris: download.uris, fileSize: download.fileSize, - repacker: result.name, + repacker: response.data.name, uploadDate: download.uploadDate, downloadSourceId: downloadSource!.id, createdAt: now, @@ -54,10 +84,82 @@ self.onmessage = async (event: MessageEvent) => { await repacksTable.bulkAdd(repacks); }); - const channel = new BroadcastChannel( - `download_sources:import:${result.url}` - ); - + const channel = new BroadcastChannel(`download_sources:import:${data}`); channel.postMessage(true); } + + if (type === "SYNC_DOWNLOAD_SOURCES") { + const channel = new BroadcastChannel(`download_sources:sync:${data}`); + let newRepacksCount = 0; + + try { + const downloadSources = await downloadSourcesTable.toArray(); + const existingRepacks = await repacksTable.toArray(); + + for (const downloadSource of downloadSources) { + const headers = new AxiosHeaders(); + + if (downloadSource.etag) { + headers.set("If-None-Match", downloadSource.etag); + } + + try { + const response = await axios.get(downloadSource.url, { + headers, + }); + + const source = downloadSourceSchema.parse(response.data); + + await db.transaction( + "rw", + repacksTable, + downloadSourcesTable, + async () => { + await downloadSourcesTable.update(downloadSource.id, { + etag: response.headers["etag"], + downloadCount: source.downloads.length, + status: DownloadSourceStatus.UpToDate, + }); + + const now = new Date(); + + const repacks = source.downloads + .filter( + (download) => + !existingRepacks.some( + (repack) => repack.title === download.title + ) + ) + .map((download) => ({ + title: download.title, + uris: download.uris, + fileSize: download.fileSize, + repacker: source.name, + uploadDate: download.uploadDate, + downloadSourceId: downloadSource.id, + createdAt: now, + updatedAt: now, + })); + + newRepacksCount += repacks.length; + + await repacksTable.bulkAdd(repacks); + } + ); + } catch (err: unknown) { + const isNotModified = (err as AxiosError).response?.status === 304; + + await downloadSourcesTable.update(downloadSource.id, { + status: isNotModified + ? DownloadSourceStatus.UpToDate + : DownloadSourceStatus.Errored, + }); + } + } + + channel.postMessage(newRepacksCount); + } catch (err) { + channel.postMessage(-1); + } + } }; diff --git a/src/renderer/src/workers/index.ts b/src/renderer/src/workers/index.ts index 6cd430c6..b8141a8f 100644 --- a/src/renderer/src/workers/index.ts +++ b/src/renderer/src/workers/index.ts @@ -1,7 +1,5 @@ -import MigrationWorker from "./migration.worker?worker"; import RepacksWorker from "./repacks.worker?worker"; import DownloadSourcesWorker from "./download-sources.worker?worker"; -export const migrationWorker = new MigrationWorker(); export const repacksWorker = new RepacksWorker(); export const downloadSourcesWorker = new DownloadSourcesWorker(); diff --git a/src/renderer/src/workers/migration.worker.ts b/src/renderer/src/workers/migration.worker.ts deleted file mode 100644 index e6a66b2a..00000000 --- a/src/renderer/src/workers/migration.worker.ts +++ /dev/null @@ -1,35 +0,0 @@ -// import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie"; -import { DownloadSource, GameRepack } from "@types"; - -export type Payload = [DownloadSource[], GameRepack[]]; - -self.onmessage = async (_event: MessageEvent) => { - // const [downloadSources, gameRepacks] = event.data; - // const downloadSourcesCount = await downloadSourcesTable.count(); - // if (downloadSources.length > downloadSourcesCount) { - // await db.transaction( - // "rw", - // downloadSourcesTable, - // repacksTable, - // async () => {} - // ); - // } - // if (type === "MIGRATE_DOWNLOAD_SOURCES") { - // const dexieDownloadSources = await downloadSourcesTable.count(); - // if (data.length > dexieDownloadSources) { - // await downloadSourcesTable.clear(); - // await downloadSourcesTable.bulkAdd(data); - // } - // self.postMessage("MIGRATE_DOWNLOAD_SOURCES_COMPLETE"); - // } - // if (type === "MIGRATE_REPACKS") { - // const dexieRepacks = await repacksTable.count(); - // if (data.length > dexieRepacks) { - // await repacksTable.clear(); - // await repacksTable.bulkAdd( - // data.map((repack) => ({ ...repack, uris: JSON.stringify(repack.uris) })) - // ); - // } - // self.postMessage("MIGRATE_REPACKS_COMPLETE"); - // } -}; diff --git a/src/types/index.ts b/src/types/index.ts index caf9bdd0..cd06cfcc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -233,8 +233,8 @@ export interface DownloadSourceDownload { export interface DownloadSourceValidationResult { name: string; - downloads: DownloadSourceDownload[]; etag: string; + downloadCount: number; } export interface DownloadSource { diff --git a/src/types/ludusavi.types.ts b/src/types/ludusavi.types.ts index a2adebf9..1e803e46 100644 --- a/src/types/ludusavi.types.ts +++ b/src/types/ludusavi.types.ts @@ -3,6 +3,10 @@ export interface LudusaviScanChange { decision: "Processed" | "Cancelled" | "Ignore"; } +export interface LudusaviGame extends LudusaviScanChange { + files: Record; +} + export interface LudusaviBackup { overall: { totalGames: number; @@ -15,7 +19,7 @@ export interface LudusaviBackup { same: number; }; }; - games: Record; + games: Record; } export interface LudusaviFindResult {