docs: moving readme

This commit is contained in:
Chubby Granny Chaser 2024-09-27 23:19:39 +01:00
commit 55a92fd68a
No known key found for this signature in database
64 changed files with 771 additions and 549 deletions

View File

@ -13,20 +13,20 @@
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) [![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) [![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) [![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) [![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) [![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) [![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) [![be](https://img.shields.io/badge/lang-be-orange)](./README.be.md)
[![es](https://img.shields.io/badge/lang-es-red)](README.es.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) [![fr](https://img.shields.io/badge/lang-fr-blue)](./README.fr.md)
[![de](https://img.shields.io/badge/lang-de-black)](README.de.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) [![ita](https://img.shields.io/badge/lang-it-red)](./README.it.md)
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.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) [![da](https://img.shields.io/badge/lang-da-red)](./README.da.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](./README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Katalog](./docs/screenshot.png) ![Hydra Katalog](./screenshot.png)
</div> </div>

View File

@ -25,7 +25,7 @@
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.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) [![da](https://img.shields.io/badge/lang-da-red)](README.da.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Katalog](./docs/screenshot.png) ![Hydra Katalog](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Catalogue Hydra](./docs/screenshot.png) ![Catalogue Hydra](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -25,7 +25,7 @@
[![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md) [![cs](https://img.shields.io/badge/lang-cs-purple)](README.cs.md)
[![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -26,7 +26,7 @@
[![da](https://img.shields.io/badge/lang-da-red)](README.da.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) [![nb](https://img.shields.io/badge/lang-nb-blue)](README.nb.md)
![Hydra Catalogue](./docs/screenshot.png) ![Hydra Catalogue](./screenshot.png)
</div> </div>

View File

@ -1,4 +1,4 @@
appId: site.hydralauncher.hydra appId: gg.hydralauncher.hydra
productName: Hydra productName: Hydra
directories: directories:
buildResources: build buildResources: build

View File

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

View File

@ -136,14 +136,15 @@
"backups": "Backups", "backups": "Backups",
"install_backup": "Install", "install_backup": "Install",
"delete_backup": "Delete", "delete_backup": "Delete",
"create_backup": "Create backup", "create_backup": "New backup",
"last_backup_date": "Last backup on {{date}}", "last_backup_date": "Last backup on {{date}}",
"no_backup_preview": "Hydra could not locate any save games for this game", "no_backup_preview": "No save games were found for this title",
"restoring_backup": "Restoring backup…", "restoring_backup": "Restoring backup ({{progress}} complete)…",
"uploading_backup": "Uploading backup…", "uploading_backup": "Uploading backup…",
"no_backups": "You haven't created any backups for this game yet", "no_backups": "You haven't created any backups for this game yet",
"backup_uploaded": "Backup uploaded", "backup_uploaded": "Backup uploaded",
"backup_deleted": "Backup deleted" "backup_deleted": "Backup deleted",
"backup_restored": "Backup restored"
}, },
"activation": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",

View File

@ -132,14 +132,15 @@
"backups": "Backups", "backups": "Backups",
"install_backup": "Restaurar", "install_backup": "Restaurar",
"delete_backup": "Apagar", "delete_backup": "Apagar",
"create_backup": "Criar backup", "create_backup": "Novo backup",
"last_backup_date": "Último backup em {{date}}", "last_backup_date": "Último backup em {{date}}",
"no_backup_preview": "Hydra não encontrou nenhum save para este jogo", "no_backup_preview": "Não foi possível encontrar nenhum salvamento para este jogo",
"restoring_backup": "Restaurando backup…", "restoring_backup": "Restaurando backup ({{progress}} concluído)…",
"uploading_backup": "Criando backup…", "uploading_backup": "Criando backup…",
"no_backups": "Você ainda não fez nenhum backup deste jogo", "no_backups": "Você ainda não fez nenhum backup deste jogo",
"backup_uploaded": "Backup criado", "backup_uploaded": "Backup criado",
"backup_deleted": "Backup apagado" "backup_deleted": "Backup apagado",
"backup_restored": "Backup restaurado"
}, },
"activation": { "activation": {
"title": "Ativação", "title": "Ativação",
@ -181,7 +182,7 @@
"enable_download_notifications": "Quando um download for concluído", "enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid", "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", "launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral", "general": "Geral",
"behavior": "Comportamento", "behavior": "Comportamento",

View File

@ -157,7 +157,7 @@
"enable_download_notifications": "Quando uma transferência for concluída", "enable_download_notifications": "Quando uma transferência for concluída",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid", "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", "launch_with_system": "Iniciar o Hydra com o sistema",
"general": "Geral", "general": "Geral",
"behavior": "Comportamento", "behavior": "Comportamento",

View File

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

View File

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

View File

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

View File

@ -19,13 +19,19 @@ const downloadGameArtifact = async (
objectKey: string; objectKey: string;
}>(`/games/artifacts/${gameArtifactId}/download`); }>(`/games/artifacts/${gameArtifactId}/download`);
const response = await axios.get(downloadUrl, {
responseType: "stream",
});
const zipLocation = path.join(app.getPath("userData"), objectKey); const zipLocation = path.join(app.getPath("userData"), objectKey);
const backupPath = path.join(backupsPath, `${shop}-${objectId}`); 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); const writer = fs.createWriteStream(zipLocation);
response.data.pipe(writer); response.data.pipe(writer);
@ -45,7 +51,7 @@ const downloadGameArtifact = async (
Ludusavi.restoreBackup(backupPath).then(() => { Ludusavi.restoreBackup(backupPath).then(() => {
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
`on-download-complete-${objectId}-${shop}`, `on-backup-download-complete-${objectId}-${shop}`,
true true
); );
}); });

View File

@ -73,26 +73,31 @@ const uploadSaveGame = async (
throw err; throw err;
} }
axios.put(uploadUrl, fileBuffer, { await axios.put(uploadUrl, fileBuffer, {
headers: { headers: {
"Content-Type": "application/zip", "Content-Type": "application/zip",
}, },
onUploadProgress: (progressEvent) => { onUploadProgress: (progressEvent) => {
if (progressEvent.progress === 1) { console.log(progressEvent);
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
);
});
}
}, },
}); });
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
// );
// });
}); });
}); });
}); });

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import type { GameShop, CatalogueEntry, SteamGame } from "@types"; import type { GameShop, CatalogueEntry, SteamGame } from "@types";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { RepacksManager } from "@main/services";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
export interface SearchGamesArgs { export interface SearchGamesArgs {
@ -28,9 +27,5 @@ export const getSteamGameById = async (
if (!steamGame) return null; if (!steamGame) return null;
const catalogueEntry = convertSteamGameToCatalogueEntry(steamGame); return convertSteamGameToCatalogueEntry(steamGame);
const result = RepacksManager.findRepacksForCatalogueEntry(catalogueEntry);
return result;
}; };

View File

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

View File

@ -9,7 +9,6 @@ import "./catalogue/get-random-game";
import "./catalogue/search-games"; import "./catalogue/search-games";
import "./catalogue/get-game-stats"; import "./catalogue/get-game-stats";
import "./catalogue/get-trending-games"; import "./catalogue/get-trending-games";
import "./catalogue/get-repacks";
import "./hardware/get-disk-free-space"; import "./hardware/get-disk-free-space";
import "./library/add-game-to-library"; import "./library/add-game-to-library";
import "./library/create-game-shortcut"; import "./library/create-game-shortcut";
@ -37,9 +36,8 @@ import "./user-preferences/auto-launch";
import "./autoupdater/check-for-updates"; import "./autoupdater/check-for-updates";
import "./autoupdater/restart-and-install-update"; import "./autoupdater/restart-and-install-update";
import "./user-preferences/authenticate-real-debrid"; import "./user-preferences/authenticate-real-debrid";
import "./download-sources/delete-download-source";
import "./download-sources/get-download-sources"; import "./download-sources/get-download-sources";
import "./download-sources/validate-download-source";
import "./download-sources/sync-download-sources";
import "./auth/sign-out"; import "./auth/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; 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/upload-save-game";
import "./cloud-save/check-game-cloud-sync-support"; import "./cloud-save/check-game-cloud-sync-support";
import "./cloud-save/delete-game-artifact"; import "./cloud-save/delete-game-artifact";
import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers"; import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");

View File

@ -3,7 +3,6 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { getFileBase64 } from "@main/helpers";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
@ -36,20 +35,12 @@ const addGameToLibrary = async (
? steamUrlBuilder.icon(objectID, steamGame.clientIcon) ? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null; : null;
await gameRepository await gameRepository.insert({
.insert({ title,
title, iconUrl,
iconUrl, objectID,
objectID, shop,
shop, });
})
.then(() => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
});
} }
const game = await gameRepository.findOne({ where: { objectID } }); const game = await gameRepository.findOne({ where: { objectID } });

View File

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

View File

@ -1,7 +1,6 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { StartGameDownloadPayload } from "@types"; import type { StartGameDownloadPayload } from "@types";
import { getFileBase64 } from "@main/helpers";
import { DownloadManager, HydraApi, logger } from "@main/services"; import { DownloadManager, HydraApi, logger } from "@main/services";
import { Not } from "typeorm"; import { Not } from "typeorm";
@ -60,26 +59,16 @@ const startGameDownload = async (
? steamUrlBuilder.icon(objectID, steamGame.clientIcon) ? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
: null; : null;
await gameRepository await gameRepository.insert({
.insert({ title,
title, iconUrl,
iconUrl, objectID,
objectID, downloader,
downloader, shop,
shop, status: "active",
status: "active", downloadPath,
downloadPath, uri,
uri, });
})
.then((result) => {
if (iconUrl) {
getFileBase64(iconUrl).then((base64) =>
gameRepository.update({ objectID }, { iconUrl: base64 })
);
}
return result;
});
} }
const updatedGame = await gameRepository.findOne({ const updatedGame = await gameRepository.findOne({

View File

@ -7,16 +7,6 @@ export const getFileBuffer = async (url: string) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer)) 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) => export const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms)); new Promise((resolve) => setTimeout(resolve, ms));

View File

@ -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 { init } from "@sentry/electron/main";
import updater from "electron-updater"; import updater from "electron-updater";
import i18n from "i18next"; import i18n from "i18next";
@ -74,7 +74,7 @@ const runMigrations = async () => {
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
app.whenReady().then(async () => { app.whenReady().then(async () => {
electronApp.setAppUserModelId("site.hydralauncher.hydra"); electronApp.setAppUserModelId("gg.hydralauncher.hydra");
protocol.handle("local", (request) => { protocol.handle("local", (request) => {
const filePath = request.url.slice("local:".length); const filePath = request.url.slice("local:".length);
@ -103,6 +103,46 @@ app.whenReady().then(async () => {
WindowManager.createMainWindow(); WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en"); 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) => { app.on("browser-window-created", (_, window) => {

View File

@ -1,14 +1,10 @@
import { DownloadManager, PythonInstance, startMainLoop } from "./services"; import { DownloadManager, PythonInstance, startMainLoop } from "./services";
import { import {
downloadQueueRepository, downloadQueueRepository,
// repackRepository,
userPreferencesRepository, userPreferencesRepository,
} from "./repository"; } from "./repository";
import { UserPreferences } from "./entity"; import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/real-debrid"; 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 { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync"; import { uploadGamesBatch } from "./services/library-sync";
@ -39,18 +35,6 @@ const loadState = async (userPreferences: UserPreferences | null) => {
} }
startMainLoop(); startMainLoop();
// const now = new Date();
// fetchDownloadSourcesAndUpdate().then(async () => {
// const newRepacksCount = await repackRepository.count({
// where: {
// createdAt: MoreThan(now),
// },
// });
// if (newRepacksCount > 0) publishNewRepacksNotifications(newRepacksCount);
// });
}; };
userPreferencesRepository userPreferencesRepository

View File

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

View File

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

View File

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

View File

@ -101,6 +101,8 @@ export class WindowManager {
authWindow.removeMenu(); authWindow.removeMenu();
if (!app.isPackaged) authWindow.webContents.openDevTools();
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
lng: i18next.language, lng: i18next.language,
}); });
@ -132,7 +134,7 @@ export class WindowManager {
} }
public static createSystemTray(language: string) { public static createSystemTray(language: string) {
let tray; let tray: Tray;
if (process.platform === "darwin") { if (process.platform === "darwin") {
const macIcon = nativeImage const macIcon = nativeImage

View File

@ -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<typeof downloadSourceSchema> & {
etag: string | null;
status: DownloadSourceStatus;
};
export const getUpdatedRepacks = async (downloadSources: DownloadSource[]) => {
const results: DownloadSourceResponse[] = [];
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
results.push({
...downloadSource,
downloads: source.downloads,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
});
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
results.push({
...downloadSource,
downloads: [],
etag: null,
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
return results;
};
export const validateDownloadSource = async (url: string) => {
const response = await axios.get(url);
return {
...downloadSourceSchema.parse(response.data),
etag: response.headers["etag"],
};
};

View File

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

View File

@ -11,9 +11,9 @@ import type {
GameRunning, GameRunning,
FriendRequestAction, FriendRequestAction,
UpdateProfileRequest, UpdateProfileRequest,
DownloadSource,
} from "@types"; } from "@types";
import type { CatalogueCategory } from "@shared"; import type { CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
/* Torrenting */ /* Torrenting */
@ -50,8 +50,6 @@ contextBridge.exposeInMainWorld("electron", {
getGameStats: (objectId: string, shop: GameShop) => getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop), ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"), getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
/* Meant for Dexie migration */
getRepacks: () => ipcRenderer.invoke("getRepacks"),
/* User preferences */ /* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"), getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
@ -63,10 +61,8 @@ contextBridge.exposeInMainWorld("electron", {
/* Download sources */ /* Download sources */
getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"),
validateDownloadSource: (url: string) => deleteDownloadSource: (id: number) =>
ipcRenderer.invoke("validateDownloadSource", url), ipcRenderer.invoke("deleteDownloadSource", id),
syncDownloadSources: (downloadSources: DownloadSource[]) =>
ipcRenderer.invoke("syncDownloadSources", downloadSources),
/* Library */ /* Library */
addGameToLibrary: (objectID: string, title: string, shop: GameShop) => addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
@ -141,12 +137,32 @@ contextBridge.exposeInMainWorld("electron", {
listener listener
); );
}, },
onDownloadComplete: (objectId: string, shop: GameShop, cb: () => void) => { onBackupDownloadProgress: (
const listener = (_event: Electron.IpcRendererEvent) => cb(); objectId: string,
ipcRenderer.on(`on-download-complete-${objectId}-${shop}`, listener); 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 () => return () =>
ipcRenderer.removeListener( 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 listener
); );
}, },
@ -218,4 +234,8 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-signout", listener); ipcRenderer.on("on-signout", listener);
return () => ipcRenderer.removeListener("on-signout", listener); return () => ipcRenderer.removeListener("on-signout", listener);
}, },
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) =>
ipcRenderer.invoke("publishNewRepacksNotification", newRepacksCount),
}); });

View File

@ -26,7 +26,7 @@ import {
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal"; import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
// import { migrationWorker } from "./workers"; import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context"; import { repacksContext } from "./context";
export interface AppProps { export interface AppProps {
@ -39,6 +39,8 @@ export function App() {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const downloadSourceMigrationLock = useRef(false);
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const { indexRepacks } = useContext(repacksContext); const { indexRepacks } = useContext(repacksContext);
@ -211,22 +213,46 @@ export function App() {
}, [dispatch, draggingDisabled]); }, [dispatch, draggingDisabled]);
useEffect(() => { useEffect(() => {
// window.electron.getRepacks().then((repacks) => { if (downloadSourceMigrationLock.current) return;
// migrationWorker.postMessage(["MIGRATE_REPACKS", repacks]);
// }); downloadSourceMigrationLock.current = true;
// window.electron.getDownloadSources().then((downloadSources) => {
// migrationWorker.postMessage([ window.electron.getDownloadSources().then(async (downloadSources) => {
// "MIGRATE_DOWNLOAD_SOURCES", if (!downloadSources.length) {
// downloadSources, const id = crypto.randomUUID();
// ]); const channel = new BroadcastChannel(`download_sources:sync:${id}`);
// });
// migrationWorker.onmessage = ( channel.onmessage = (event: MessageEvent<number>) => {
// event: MessageEvent<"MIGRATE_REPACKS_COMPLETE"> const newRepacksCount = event.data;
// ) => { window.electron.publishNewRepacksNotification(newRepacksCount);
// if (event.data === "MIGRATE_REPACKS_COMPLETE") { };
// indexRepacks();
// } 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]); }, [indexRepacks]);
const handleToastClose = useCallback(() => { const handleToastClose = useCallback(() => {

View File

@ -8,6 +8,7 @@ import React, {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
export enum CloudSyncState { export enum CloudSyncState {
New, New,
@ -20,12 +21,14 @@ export interface CloudSyncContext {
backupPreview: LudusaviBackup | null; backupPreview: LudusaviBackup | null;
artifacts: GameArtifact[]; artifacts: GameArtifact[];
showCloudSyncModal: boolean; showCloudSyncModal: boolean;
showCloudSyncFilesModal: boolean;
supportsCloudSync: boolean | null; supportsCloudSync: boolean | null;
backupState: CloudSyncState; backupState: CloudSyncState;
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>; setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
downloadGameArtifact: (gameArtifactId: string) => Promise<void>; downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
uploadSaveGame: () => Promise<void>; uploadSaveGame: () => Promise<void>;
deleteGameArtifact: (gameArtifactId: string) => Promise<void>; deleteGameArtifact: (gameArtifactId: string) => Promise<void>;
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
restoringBackup: boolean; restoringBackup: boolean;
uploadingBackup: boolean; uploadingBackup: boolean;
} }
@ -40,6 +43,8 @@ export const cloudSyncContext = createContext<CloudSyncContext>({
uploadSaveGame: async () => {}, uploadSaveGame: async () => {},
artifacts: [], artifacts: [],
deleteGameArtifact: async () => {}, deleteGameArtifact: async () => {},
showCloudSyncFilesModal: false,
setShowCloudSyncFilesModal: () => {},
restoringBackup: false, restoringBackup: false,
uploadingBackup: false, uploadingBackup: false,
}); });
@ -58,6 +63,8 @@ export function CloudSyncContextProvider({
objectId, objectId,
shop, shop,
}: CloudSyncContextProviderProps) { }: CloudSyncContextProviderProps) {
const { t } = useTranslation("game_details");
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>( const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
null null
); );
@ -68,6 +75,7 @@ export function CloudSyncContextProvider({
); );
const [restoringBackup, setRestoringBackup] = useState(false); const [restoringBackup, setRestoringBackup] = useState(false);
const [uploadingBackup, setUploadingBackup] = useState(false); const [uploadingBackup, setUploadingBackup] = useState(false);
const [showCloudSyncFilesModal, setShowCloudSyncFilesModal] = useState(false);
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
@ -101,7 +109,7 @@ export function CloudSyncContextProvider({
objectId, objectId,
shop, shop,
() => { () => {
showSuccessToast("backup_uploaded"); showSuccessToast(t("backup_uploaded"));
setUploadingBackup(false); setUploadingBackup(false);
gameBackupsTable.add({ gameBackupsTable.add({
@ -114,22 +122,19 @@ export function CloudSyncContextProvider({
} }
); );
const removeDownloadCompleteListener = window.electron.onDownloadComplete( const removeDownloadCompleteListener =
objectId, window.electron.onBackupDownloadComplete(objectId, shop, () => {
shop, showSuccessToast(t("backup_restored"));
() => {
showSuccessToast("backup_restored");
setRestoringBackup(false); setRestoringBackup(false);
getGameBackupPreview(); getGameBackupPreview();
} });
);
return () => { return () => {
removeUploadCompleteListener(); removeUploadCompleteListener();
removeDownloadCompleteListener(); removeDownloadCompleteListener();
}; };
}, [objectId, shop, showSuccessToast, getGameBackupPreview]); }, [objectId, shop, showSuccessToast, t, getGameBackupPreview]);
const deleteGameArtifact = useCallback( const deleteGameArtifact = useCallback(
async (gameArtifactId: string) => { async (gameArtifactId: string) => {
@ -181,10 +186,12 @@ export function CloudSyncContextProvider({
backupState, backupState,
restoringBackup, restoringBackup,
uploadingBackup, uploadingBackup,
showCloudSyncFilesModal,
setShowCloudSyncModal, setShowCloudSyncModal,
uploadSaveGame, uploadSaveGame,
downloadGameArtifact, downloadGameArtifact,
deleteGameArtifact, deleteGameArtifact,
setShowCloudSyncFilesModal,
}} }}
> >
{children} {children}

View File

@ -33,6 +33,7 @@ export function RepacksContextProvider({ children }: RepacksContextProps) {
const channel = new BroadcastChannel(`repacks:search:${channelId}`); const channel = new BroadcastChannel(`repacks:search:${channelId}`);
channel.onmessage = (event: MessageEvent<GameRepack[]>) => { channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
resolve(event.data); resolve(event.data);
channel.close();
}; };
return []; return [];

View File

@ -25,10 +25,10 @@ import type {
UserStats, UserStats,
UserDetails, UserDetails,
FriendRequestSync, FriendRequestSync,
DownloadSourceValidationResult,
GameArtifact, GameArtifact,
LudusaviBackup, LudusaviBackup,
} from "@types"; } from "@types";
import type { AxiosProgressEvent } from "axios";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
declare global { declare global {
@ -68,8 +68,6 @@ declare global {
searchGameRepacks: (query: string) => Promise<GameRepack[]>; searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>; getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>; getTrendingGames: () => Promise<TrendingGame[]>;
/* Meant for Dexie migration */
getRepacks: () => Promise<GameRepack[]>;
/* Library */ /* Library */
addGameToLibrary: ( addGameToLibrary: (
@ -107,10 +105,7 @@ declare global {
/* Download sources */ /* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>; getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: ( deleteDownloadSource: (id: number) => Promise<void>;
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: (downloadSources: DownloadSource[]) => Promise<void>;
/* Hardware */ /* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>; getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
@ -135,7 +130,7 @@ declare global {
shop: GameShop shop: GameShop
) => Promise<boolean>; ) => Promise<boolean>;
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
onDownloadComplete: ( onBackupDownloadComplete: (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
cb: () => void cb: () => void
@ -145,6 +140,11 @@ declare global {
shop: GameShop, shop: GameShop,
cb: () => void cb: () => void
) => () => Electron.IpcRenderer; ) => () => Electron.IpcRenderer;
onBackupDownloadProgress: (
objectId: string,
shop: GameShop,
cb: (progress: AxiosProgressEvent) => void
) => () => Electron.IpcRenderer;
/* Misc */ /* Misc */
openExternal: (src: string) => Promise<void>; openExternal: (src: string) => Promise<void>;
@ -205,6 +205,9 @@ declare global {
action: FriendRequestAction action: FriendRequestAction
) => Promise<void>; ) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>; sendFriendRequest: (userId: string) => Promise<void>;
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
} }
interface Window { interface Window {

View File

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

View File

@ -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<ModalProps, "children" | "title"> {}
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 (
<Modal
visible={visible}
title="Gerenciar arquivos"
description="Escolha quais diretórios serão sincronizados"
onClose={onClose}
>
{/* <div className={styles.downloaders}>
{["AUTOMATIC", "CUSTOM"].map((downloader) => (
<Button
key={downloader}
className={styles.downloaderOption}
theme={selectedDownloader === downloader ? "primary" : "outline"}
disabled={
downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className={styles.downloaderIcon} />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
))}
</div> */}
<ul
style={{
margin: 0,
padding: 0,
listStyle: "none",
gap: 16,
display: "flex",
flexDirection: "column",
}}
>
{files.map((file) => (
<li key={file.path}>
<TextField value={file.path} readOnly />
</li>
))}
</ul>
</Modal>
);
}

View File

@ -1,7 +1,14 @@
import { style } from "@vanilla-extract/css"; import { keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css"; import { SPACING_UNIT, vars } from "../../../theme.css";
export const rotate = keyframes({
"0%": { transform: "rotate(0deg)" },
"100%": {
transform: "rotate(360deg)",
},
});
export const artifacts = style({ export const artifacts = style({
display: "flex", display: "flex",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
@ -24,3 +31,10 @@ export const artifactButton = style({
borderRadius: "4px", borderRadius: "4px",
justifyContent: "space-between", justifyContent: "space-between",
}); });
export const syncIcon = style({
animationName: rotate,
animationDuration: "1s",
animationIterationCount: "infinite",
animationTimingFunction: "linear",
});

View File

@ -9,7 +9,7 @@ import {
CheckCircleFillIcon, CheckCircleFillIcon,
ClockIcon, ClockIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
DownloadIcon, HistoryIcon,
SyncIcon, SyncIcon,
TrashIcon, TrashIcon,
UploadIcon, UploadIcon,
@ -17,6 +17,9 @@ import {
import { useToast } from "@renderer/hooks"; import { useToast } from "@renderer/hooks";
import { GameBackup, gameBackupsTable } from "@renderer/dexie"; import { GameBackup, gameBackupsTable } from "@renderer/dexie";
import { useTranslation } from "react-i18next"; 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 export interface CloudSyncModalProps
extends Omit<ModalProps, "children" | "title"> {} extends Omit<ModalProps, "children" | "title"> {}
@ -24,6 +27,8 @@ export interface CloudSyncModalProps
export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const [deletingArtifact, setDeletingArtifact] = useState(false); const [deletingArtifact, setDeletingArtifact] = useState(false);
const [lastBackup, setLastBackup] = useState<GameBackup | null>(null); const [lastBackup, setLastBackup] = useState<GameBackup | null>(null);
const [backupDownloadProgress, setBackupDownloadProgress] =
useState<AxiosProgressEvent | null>(null);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
@ -35,6 +40,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
uploadSaveGame, uploadSaveGame,
downloadGameArtifact, downloadGameArtifact,
deleteGameArtifact, deleteGameArtifact,
setShowCloudSyncFilesModal,
} = useContext(cloudSyncContext); } = useContext(cloudSyncContext);
const { objectID, shop, gameTitle } = useContext(gameDetailsContext); const { objectID, shop, gameTitle } = useContext(gameDetailsContext);
@ -60,13 +66,31 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
.where({ shop: shop, objectId: objectID }) .where({ shop: shop, objectId: objectID })
.last() .last()
.then((lastBackup) => setLastBackup(lastBackup || null)); .then((lastBackup) => setLastBackup(lastBackup || null));
const removeBackupDownloadProgressListener =
window.electron.onBackupDownloadProgress(
objectID!,
shop,
(progressEvent) => {
setBackupDownloadProgress(progressEvent);
}
);
return () => {
removeBackupDownloadProgressListener();
};
}, [backupPreview, objectID, shop]); }, [backupPreview, objectID, shop]);
const handleBackupInstallClick = async (artifactId: string) => {
setBackupDownloadProgress(null);
downloadGameArtifact(artifactId);
};
const backupStateLabel = useMemo(() => { const backupStateLabel = useMemo(() => {
if (uploadingBackup) { if (uploadingBackup) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon /> <SyncIcon className={styles.syncIcon} />
{t("uploading_backup")} {t("uploading_backup")}
</span> </span>
); );
@ -75,8 +99,12 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (restoringBackup) { if (restoringBackup) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon /> <SyncIcon className={styles.syncIcon} />
{t("restoring_backup")} {t("restoring_backup", {
progress: formatDownloadProgress(
backupDownloadProgress?.progress ?? 0
),
})}
</span> </span>
); );
} }
@ -84,7 +112,10 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (lastBackup) { if (lastBackup) {
return ( return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}> <span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<CheckCircleFillIcon /> <i style={{ color: vars.color.success }}>
<CheckCircleFillIcon />
</i>
{t("last_backup_date", { {t("last_backup_date", {
date: format(lastBackup.createdAt, "dd/MM/yyyy HH:mm"), date: format(lastBackup.createdAt, "dd/MM/yyyy HH:mm"),
})} })}
@ -97,7 +128,14 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
} }
return t("no_backups"); return t("no_backups");
}, [uploadingBackup, lastBackup, backupPreview, restoringBackup, t]); }, [
uploadingBackup,
backupDownloadProgress?.progress,
lastBackup,
backupPreview,
restoringBackup,
t,
]);
const disableActions = uploadingBackup || restoringBackup || deletingArtifact; const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
@ -120,6 +158,22 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}> <div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<h2>{gameTitle}</h2> <h2>{gameTitle}</h2>
<p>{backupStateLabel}</p> <p>{backupStateLabel}</p>
<button
type="button"
style={{
margin: 0,
padding: 0,
alignSelf: "flex-start",
fontSize: 14,
cursor: "pointer",
textDecoration: "underline",
color: vars.color.body,
}}
onClick={() => setShowCloudSyncFilesModal(true)}
>
Gerenciar arquivos
</button>
</div> </div>
<Button <Button
@ -132,7 +186,17 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
</Button> </Button>
</div> </div>
<h2 style={{ marginBottom: 16 }}>{t("backups")}</h2> <div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: SPACING_UNIT,
}}
>
<h2>{t("backups")}</h2>
<small>2 / 2</small>
</div>
<ul className={styles.artifacts}> <ul className={styles.artifacts}>
{artifacts.map((artifact) => ( {artifacts.map((artifact) => (
@ -163,10 +227,10 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<div style={{ display: "flex", gap: 8, alignItems: "center" }}> <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button <Button
type="button" type="button"
onClick={() => downloadGameArtifact(artifact.id)} onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions} disabled={disableActions}
> >
<DownloadIcon /> <HistoryIcon />
{t("install_backup")} {t("install_backup")}
</Button> </Button>
<Button <Button

View File

@ -14,6 +14,7 @@ import { steamUrlBuilder } from "@shared";
import Lottie from "lottie-react"; import Lottie from "lottie-react";
import downloadingAnimation from "@renderer/assets/lottie/cloud.json"; import downloadingAnimation from "@renderer/assets/lottie/cloud.json";
import { useUserDetails } from "@renderer/hooks";
const HERO_ANIMATION_THRESHOLD = 25; const HERO_ANIMATION_THRESHOLD = 25;
@ -33,6 +34,8 @@ export function GameDetailsContent() {
hasNSFWContentBlocked, hasNSFWContentBlocked,
} = useContext(gameDetailsContext); } = useContext(gameDetailsContext);
const { userDetails } = useUserDetails();
const { supportsCloudSync, setShowCloudSyncModal } = const { supportsCloudSync, setShowCloudSyncModal } =
useContext(cloudSyncContext); useContext(cloudSyncContext);
@ -75,6 +78,15 @@ export function GameDetailsContent() {
setBackdropOpacity(opacity); setBackdropOpacity(opacity);
}; };
const handleCloudSaveButtonClick = () => {
if (!userDetails) {
window.electron.openAuthWindow();
return;
}
setShowCloudSyncModal(true);
};
return ( return (
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}> <div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
<img <img
@ -113,7 +125,7 @@ export function GameDetailsContent() {
<button <button
type="button" type="button"
className={styles.cloudSyncButton} className={styles.cloudSyncButton}
onClick={() => setShowCloudSyncModal(true)} onClick={handleCloudSaveButtonClick}
> >
<div <div
style={{ style={{

View File

@ -27,6 +27,7 @@ import { useDownload } from "@renderer/hooks";
import { GameOptionsModal, RepacksModal } from "./modals"; import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader, getDownloadersForUri } from "@shared"; import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal"; import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
export function GameDetails() { export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null); const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@ -128,11 +129,23 @@ export function GameDetails() {
shop={shop! as GameShop} shop={shop! as GameShop}
> >
<CloudSyncContextConsumer> <CloudSyncContextConsumer>
{({ showCloudSyncModal, setShowCloudSyncModal }) => ( {({
<CloudSyncModal showCloudSyncModal,
onClose={() => setShowCloudSyncModal(false)} setShowCloudSyncModal,
visible={showCloudSyncModal} showCloudSyncFilesModal,
/> setShowCloudSyncFilesModal,
}) => (
<>
<CloudSyncModal
onClose={() => setShowCloudSyncModal(false)}
visible={showCloudSyncModal}
/>
<CloudSyncFilesModal
onClose={() => setShowCloudSyncFilesModal(false)}
visible={showCloudSyncFilesModal}
/>
</>
)} )}
</CloudSyncContextConsumer> </CloudSyncContextConsumer>

View File

@ -1,6 +1,5 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent";
import { Badge, Button, Modal, TextField } from "@renderer/components"; import { Badge, Button, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types"; import type { GameRepack } from "@types";
@ -33,8 +32,6 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null); const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false); const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const [infoHash, setInfoHash] = useState<string | null>(null);
const { repacks, game } = useContext(gameDetailsContext); const { repacks, game } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
@ -43,18 +40,9 @@ export function RepacksModal({
return orderBy(repacks, (repack) => repack.uploadDate, "desc"); return orderBy(repacks, (repack) => repack.uploadDate, "desc");
}, [repacks]); }, [repacks]);
const getInfoHash = useCallback(async () => {
if (game?.uri?.startsWith("magnet:")) {
const torrent = await parseTorrent(game?.uri ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash);
}
}, [game]);
useEffect(() => { useEffect(() => {
setFilteredRepacks(sortedRepacks); setFilteredRepacks(sortedRepacks);
}, [sortedRepacks, visible, game]);
if (game?.uri) getInfoHash();
}, [sortedRepacks, visible, game, getInfoHash]);
const handleRepackClick = (repack: GameRepack) => { const handleRepackClick = (repack: GameRepack) => {
setRepack(repack); setRepack(repack);
@ -77,10 +65,8 @@ export function RepacksModal({
}; };
const checkIfLastDownloadedOption = (repack: GameRepack) => { const checkIfLastDownloadedOption = (repack: GameRepack) => {
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash)); if (!game) return false;
if (!game?.uri) return false; return repack.uris.some((uri) => uri.includes(game.uri!));
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
}; };
return ( return (

View File

@ -6,6 +6,7 @@ export const gameCover = style({
transition: "all ease 0.2s", transition: "all ease 0.2s",
boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)", boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)",
width: "100%", width: "100%",
position: "relative",
":before": { ":before": {
content: "", content: "",
top: "0", top: "0",

View File

@ -1,13 +1,13 @@
import { userProfileContext } from "@renderer/context"; 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 { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks"; import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { steamUrlBuilder } from "@shared"; 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 * as styles from "./profile-content.css";
import { TelescopeIcon } from "@primer/octicons-react"; import { ClockIcon, TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile"; import { LockedProfile } from "./locked-profile";
@ -16,6 +16,7 @@ import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box"; import { RecentGamesBox } from "./recent-games-box";
import { UserGame } from "@types"; import { UserGame } from "@types";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
export function ProfileContent() { export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext); const { userProfile, isMe, userStats } = useContext(userProfileContext);
@ -46,6 +47,22 @@ export function ProfileContent() {
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(() => { const content = useMemo(() => {
if (!userProfile) return null; if (!userProfile) return null;
@ -109,13 +126,31 @@ export function ProfileContent() {
className={styles.gameCover} className={styles.gameCover}
onClick={() => navigate(buildUserGameDetailsPath(game))} onClick={() => navigate(buildUserGameDetailsPath(game))}
> >
<div style={{ position: "absolute", padding: 4 }}>
<small
style={{
backgroundColor: vars.color.background,
color: vars.color.muted,
// border: `solid 1px ${vars.color.border}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 4px",
}}
>
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
</div>
<img <img
src={steamUrlBuilder.cover(game.objectId)} src={steamUrlBuilder.cover(game.objectId)}
alt={game.title} alt={game.title}
style={{ style={{
width: "100%",
objectFit: "cover", objectFit: "cover",
borderRadius: 4, borderRadius: 4,
width: "100%",
height: "100%",
}} }}
/> />
</button> </button>

View File

@ -4,6 +4,7 @@ import { style } from "@vanilla-extract/css";
export const profileContentBox = style({ export const profileContentBox = style({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
position: "relative",
}); });
export const profileAvatarButton = style({ export const profileAvatarButton = style({

View File

@ -272,64 +272,86 @@ export function ProfileHero() {
<section <section
className={styles.profileContentBox} className={styles.profileContentBox}
style={{ background: heroBackground }} // style={{ background: heroBackground }}
> >
<div className={styles.userInformation}> <img
<button src="https://wallpapers.com/images/featured/cyberpunk-anime-dfyw8eb7bqkw278u.jpg"
type="button" alt=""
className={styles.profileAvatarButton} style={{
onClick={handleAvatarClick} position: "absolute",
> width: "100%",
{userProfile?.profileImageUrl ? ( height: "100%",
<img objectFit: "cover",
className={styles.profileAvatar} }}
alt={userProfile?.displayName} />
src={userProfile?.profileImageUrl} <div
/> style={{
) : ( background: heroBackground,
<PersonIcon size={72} /> width: "100%",
)} height: "100%",
</button> zIndex: 1,
}}
>
<div className={styles.userInformation}>
<button
type="button"
className={styles.profileAvatarButton}
onClick={handleAvatarClick}
>
{userProfile?.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile?.displayName}
src={userProfile?.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</button>
<div className={styles.profileInformation}> <div className={styles.profileInformation}>
{userProfile ? ( {userProfile ? (
<h2 className={styles.profileDisplayName}> <h2 className={styles.profileDisplayName}>
{userProfile?.displayName} {userProfile?.displayName}
</h2> </h2>
) : ( ) : (
<Skeleton width={150} height={28} /> <Skeleton width={150} height={28} />
)} )}
{currentGame && ( {currentGame && (
<div className={styles.currentGameWrapper}> <div className={styles.currentGameWrapper}>
<div className={styles.currentGameDetails}> <div className={styles.currentGameDetails}>
<Link <Link
to={buildGameDetailsPath({ to={buildGameDetailsPath({
...currentGame, ...currentGame,
objectID: currentGame.objectId, objectID: currentGame.objectId,
})} })}
> >
{currentGame.title} {currentGame.title}
</Link> </Link>
</div> </div>
<small> <small>
{t("playing_for", { {t("playing_for", {
amount: formatDistance( amount: formatDistance(
addSeconds( addSeconds(
new Date(), new Date(),
-currentGame.sessionDurationInSeconds -currentGame.sessionDurationInSeconds
),
new Date()
), ),
new Date() })}
), </small>
})} </div>
</small> )}
</div> </div>
)}
</div> </div>
</div> </div>
<div className={styles.heroPanel}> <div
className={styles.heroPanel}
style={{ background: heroBackground }}
>
<div <div
style={{ style={{
display: "flex", display: "flex",

View File

@ -67,8 +67,21 @@ export function AddDownloadSourceModal({
return; return;
} }
const result = await window.electron.validateDownloadSource(values.url); downloadSourcesWorker.postMessage([
setValidationResult(result); "VALIDATE_DOWNLOAD_SOURCE",
values.url,
]);
const channel = new BroadcastChannel(
`download_sources:validate:${values.url}`
);
channel.onmessage = (
event: MessageEvent<DownloadSourceValidationResult>
) => {
setValidationResult(event.data);
channel.close();
};
setUrl(values.url); setUrl(values.url);
}, },
@ -93,16 +106,14 @@ export function AddDownloadSourceModal({
if (validationResult) { if (validationResult) {
const channel = new BroadcastChannel(`download_sources:import:${url}`); const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage([ downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
"IMPORT_DOWNLOAD_SOURCE",
{ ...validationResult, url },
]);
channel.onmessage = () => { channel.onmessage = () => {
setIsLoading(false); setIsLoading(false);
onClose(); onClose();
onAddDownloadSource(); onAddDownloadSource();
channel.close();
}; };
} }
}; };
@ -159,9 +170,9 @@ export function AddDownloadSourceModal({
<h4>{validationResult?.name}</h4> <h4>{validationResult?.name}</h4>
<small> <small>
{t("found_download_option", { {t("found_download_option", {
count: validationResult?.downloads.length, count: validationResult?.downloadCount,
countFormatted: countFormatted:
validationResult?.downloads.length.toLocaleString(), validationResult?.downloadCount.toLocaleString(),
})} })}
</small> </small>
</div> </div>

View File

@ -59,6 +59,7 @@ export function SettingsDownloadSources() {
getDownloadSources(); getDownloadSources();
indexRepacks(); indexRepacks();
setIsRemovingDownloadSource(false); setIsRemovingDownloadSource(false);
channel.close();
}; };
}; };
@ -71,15 +72,17 @@ export function SettingsDownloadSources() {
const syncDownloadSources = async () => { const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true); setIsSyncingDownloadSources(true);
window.electron const id = crypto.randomUUID();
.syncDownloadSources(downloadSources) const channel = new BroadcastChannel(`download_sources:sync:${id}`);
.then(() => {
showSuccessToast(t("download_sources_synced")); downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
getDownloadSources();
}) channel.onmessage = () => {
.finally(() => { showSuccessToast(t("download_sources_synced"));
setIsSyncingDownloadSources(false); getDownloadSources();
}); setIsSyncingDownloadSources(false);
channel.close();
};
}; };
const statusTitle = { const statusTitle = {

View File

@ -1,16 +1,45 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie"; import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus } from "@shared"; 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 = type Payload =
| ["IMPORT_DOWNLOAD_SOURCE", DownloadSourceValidationResult & { url: string }] | ["IMPORT_DOWNLOAD_SOURCE", string]
| ["DELETE_DOWNLOAD_SOURCE", number]; | ["DELETE_DOWNLOAD_SOURCE", number]
| ["VALIDATE_DOWNLOAD_SOURCE", string]
db.open(); | ["SYNC_DOWNLOAD_SOURCES", string];
self.onmessage = async (event: MessageEvent<Payload>) => { self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data; const [type, data] = event.data;
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(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") { if (type === "DELETE_DOWNLOAD_SOURCE") {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => { await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: data }).delete(); await repacksTable.where({ downloadSourceId: data }).delete();
@ -23,28 +52,29 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
} }
if (type === "IMPORT_DOWNLOAD_SOURCE") { if (type === "IMPORT_DOWNLOAD_SOURCE") {
const result = data; const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
await db.transaction("rw", downloadSourcesTable, repacksTable, async () => { await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date(); const now = new Date();
const id = await downloadSourcesTable.add({ const id = await downloadSourcesTable.add({
url: result.url, url: data,
name: result.name, name: response.data.name,
etag: result.etag, etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate, status: DownloadSourceStatus.UpToDate,
downloadCount: result.downloads.length, downloadCount: response.data.downloads.length,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
const downloadSource = await downloadSourcesTable.get(id); const downloadSource = await downloadSourcesTable.get(id);
const repacks = result.downloads.map((download) => ({ const repacks = response.data.downloads.map((download) => ({
title: download.title, title: download.title,
uris: download.uris, uris: download.uris,
fileSize: download.fileSize, fileSize: download.fileSize,
repacker: result.name, repacker: response.data.name,
uploadDate: download.uploadDate, uploadDate: download.uploadDate,
downloadSourceId: downloadSource!.id, downloadSourceId: downloadSource!.id,
createdAt: now, createdAt: now,
@ -54,10 +84,82 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
await repacksTable.bulkAdd(repacks); await repacksTable.bulkAdd(repacks);
}); });
const channel = new BroadcastChannel( const channel = new BroadcastChannel(`download_sources:import:${data}`);
`download_sources:import:${result.url}`
);
channel.postMessage(true); 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);
}
}
}; };

View File

@ -1,7 +1,5 @@
import MigrationWorker from "./migration.worker?worker";
import RepacksWorker from "./repacks.worker?worker"; import RepacksWorker from "./repacks.worker?worker";
import DownloadSourcesWorker from "./download-sources.worker?worker"; import DownloadSourcesWorker from "./download-sources.worker?worker";
export const migrationWorker = new MigrationWorker();
export const repacksWorker = new RepacksWorker(); export const repacksWorker = new RepacksWorker();
export const downloadSourcesWorker = new DownloadSourcesWorker(); export const downloadSourcesWorker = new DownloadSourcesWorker();

View File

@ -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<Payload>) => {
// 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");
// }
};

View File

@ -233,8 +233,8 @@ export interface DownloadSourceDownload {
export interface DownloadSourceValidationResult { export interface DownloadSourceValidationResult {
name: string; name: string;
downloads: DownloadSourceDownload[];
etag: string; etag: string;
downloadCount: number;
} }
export interface DownloadSource { export interface DownloadSource {

View File

@ -3,6 +3,10 @@ export interface LudusaviScanChange {
decision: "Processed" | "Cancelled" | "Ignore"; decision: "Processed" | "Cancelled" | "Ignore";
} }
export interface LudusaviGame extends LudusaviScanChange {
files: Record<string, LudusaviScanChange>;
}
export interface LudusaviBackup { export interface LudusaviBackup {
overall: { overall: {
totalGames: number; totalGames: number;
@ -15,7 +19,7 @@ export interface LudusaviBackup {
same: number; same: number;
}; };
}; };
games: Record<string, LudusaviScanChange>; games: Record<string, LudusaviGame>;
} }
export interface LudusaviFindResult { export interface LudusaviFindResult {