Merge branch 'feature/cloud-sync' into feature/game-achievements

# Conflicts:
#	src/locales/en/translation.json
#	src/locales/pt-BR/translation.json
#	src/main/events/library/add-game-to-library.ts
#	src/renderer/src/pages/game-details/sidebar/sidebar.css.ts
#	src/renderer/src/pages/game-details/sidebar/sidebar.tsx
This commit is contained in:
Zamitto
2024-10-07 19:12:57 -03:00
103 changed files with 3548 additions and 584 deletions

View File

@@ -30,7 +30,7 @@ const getCatalogue = async (
title: steamGame.name,
shop: game.shop,
cover: steamUrlBuilder.library(game.objectId),
objectID: game.objectId,
objectId: game.objectId,
};
})
);

View File

@@ -7,16 +7,16 @@ import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
const getLocalizedSteamAppDetails = async (
objectID: string,
objectId: string,
language: string
): Promise<ShopDetails | null> => {
if (language === "english") {
return getSteamAppDetails(objectID, language);
return getSteamAppDetails(objectId, language);
}
return getSteamAppDetails(objectID, language).then(
return getSteamAppDetails(objectId, language).then(
async (localizedAppDetails) => {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
@@ -34,21 +34,21 @@ const getLocalizedSteamAppDetails = async (
const getGameShopDetails = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
shop: GameShop,
language: string
): Promise<ShopDetails | null> => {
if (shop === "steam") {
const cachedData = await gameShopCacheRepository.findOne({
where: { objectID, language },
where: { objectID: objectId, language },
});
const appDetails = getLocalizedSteamAppDetails(objectID, language).then(
const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
(result) => {
if (result) {
gameShopCacheRepository.upsert(
{
objectID,
objectID: objectId,
shop: "steam",
language,
serializedData: JSON.stringify(result),
@@ -68,7 +68,7 @@ const getGameShopDetails = async (
if (cachedGame) {
return {
...cachedGame,
objectID,
objectId,
} as ShopDetails;
}

View File

@@ -1,28 +1,29 @@
import type { CatalogueEntry } from "@types";
import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers";
import { HydraApi } from "@main/services";
import { steamUrlBuilder } from "@shared";
const getGames = async (
_event: Electron.IpcMainInvokeEvent,
take = 12,
cursor = 0
): Promise<{ results: CatalogueEntry[]; cursor: number }> => {
const steamGames = await steamGamesWorker.run(
{ limit: take, offset: cursor },
{ name: "list" }
skip = 0
): Promise<CatalogueEntry[]> => {
const searchParams = new URLSearchParams({
take: take.toString(),
skip: skip.toString(),
});
const games = await HydraApi.get<CatalogueEntry[]>(
`/games/catalogue?${searchParams.toString()}`,
undefined,
{ needsAuth: false }
);
return {
results: steamGames.map((steamGame) => ({
title: steamGame.name,
shop: "steam",
cover: steamUrlBuilder.library(steamGame.id),
objectID: steamGame.id,
})),
cursor: cursor + steamGames.length,
};
return games.map((game) => ({
...game,
cover: steamUrlBuilder.library(game.objectId),
}));
};
registerEvent("getGames", getGames);

View File

@@ -1,45 +1,23 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import type { HowLongToBeatCategory } from "@types";
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
import { registerEvent } from "../register-event";
import { gameShopCacheRepository } from "@main/repository";
import { formatName } from "@shared";
const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
shop: GameShop,
title: string
): Promise<HowLongToBeatCategory[] | null> => {
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
const response = await searchHowLongToBeat(title);
const gameShopCache = await gameShopCacheRepository.findOne({
where: { objectID, shop },
const game = response.data.find((game) => {
return formatName(game.game_name) === formatName(title);
});
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
? JSON.parse(gameShopCache?.howLongToBeatSerializedData)
: null;
if (howLongToBeatCachedData) return howLongToBeatCachedData;
if (!game) return null;
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
return searchHowLongToBeatPromise.then(async (response) => {
const game = response.data.find(
(game) => game.profile_steam === Number(objectID)
);
if (!game) return null;
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
gameShopCacheRepository.upsert(
{
objectID,
shop,
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
},
["objectID"]
);
return howLongToBeat;
});
return howLongToBeat;
};
registerEvent("getHowLongToBeat", getHowLongToBeat);

View File

@@ -0,0 +1,14 @@
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { Ludusavi } from "@main/services";
const checkGameCloudSyncSupport = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const games = await Ludusavi.findGames(shop, objectId);
return games.length === 1;
};
registerEvent("checkGameCloudSyncSupport", checkGameCloudSyncSupport);

View File

@@ -0,0 +1,12 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const deleteGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string
) =>
HydraApi.delete<{ ok: boolean }>(
`/profile/games/artifacts/${gameArtifactId}`
);
registerEvent("deleteGameArtifact", deleteGameArtifact);

View File

@@ -0,0 +1,139 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import fs from "node:fs";
import * as tar from "tar";
import { registerEvent } from "../register-event";
import axios from "axios";
import { app } from "electron";
import path from "node:path";
import { backupsPath } from "@main/constants";
import type { GameShop } from "@types";
import YAML from "yaml";
export interface LudusaviBackup {
files: {
[key: string]: {
hash: string;
size: number;
};
};
}
const replaceLudusaviBackupWithCurrentUser = (
gameBackupPath: string,
backupHomeDir: string
) => {
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
const data = fs.readFileSync(mappingYamlPath, "utf8");
const manifest = YAML.parse(data) as {
backups: LudusaviBackup[];
drives: Record<string, string>;
};
const currentHomeDir = app.getPath("home");
// TODO: Only works on Windows
const usersDirPath = path.join(gameBackupPath, "drive-C", "Users");
const oldPath = path.join(usersDirPath, path.basename(backupHomeDir));
const newPath = path.join(usersDirPath, path.basename(currentHomeDir));
// Directories are different, rename
if (backupHomeDir !== currentHomeDir) {
if (fs.existsSync(newPath)) {
fs.rmSync(newPath, {
recursive: true,
force: true,
});
}
fs.renameSync(oldPath, newPath);
}
const backups = manifest.backups.map((backup: LudusaviBackup) => {
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
return {
...prev,
[key.replace(backupHomeDir, currentHomeDir)]: value,
};
}, {});
return {
...backup,
files,
};
});
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
};
const downloadGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
gameArtifactId: string
) => {
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
downloadUrl: string;
objectKey: string;
homeDir: string;
}>(`/profile/games/artifacts/${gameArtifactId}/download`);
const zipLocation = path.join(app.getPath("userData"), objectKey);
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
if (fs.existsSync(backupPath)) {
fs.rmSync(backupPath, {
recursive: true,
force: true,
});
}
const response = await axios.get(downloadUrl, {
responseType: "stream",
onDownloadProgress: (progressEvent) => {
WindowManager.mainWindow?.webContents.send(
`on-backup-download-progress-${objectId}-${shop}`,
progressEvent
);
},
});
const writer = fs.createWriteStream(zipLocation);
response.data.pipe(writer);
writer.on("error", (err) => {
logger.error("Failed to write zip", err);
throw err;
});
fs.mkdirSync(backupPath, { recursive: true });
writer.on("close", () => {
tar
.x({
file: zipLocation,
cwd: backupPath,
})
.then(async () => {
const [game] = await Ludusavi.findGames(shop, objectId);
if (!game) throw new Error("Game not found in Ludusavi manifest");
replaceLudusaviBackupWithCurrentUser(
path.join(backupPath, game),
path.normalize(homeDir).replace(/\\/g, "/")
);
Ludusavi.restoreBackup(backupPath).then(() => {
WindowManager.mainWindow?.webContents.send(
`on-backup-download-complete-${objectId}-${shop}`,
true
);
});
});
});
};
registerEvent("downloadGameArtifact", downloadGameArtifact);

View File

@@ -0,0 +1,20 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
import type { GameArtifact, GameShop } from "@types";
const getGameArtifacts = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const params = new URLSearchParams({
objectId,
shop,
});
return HydraApi.get<GameArtifact[]>(
`/profile/games/artifacts?${params.toString()}`
);
};
registerEvent("getGameArtifacts", getGameArtifacts);

View File

@@ -0,0 +1,17 @@
import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { Ludusavi } from "@main/services";
import path from "node:path";
import { backupsPath } from "@main/constants";
const getGameBackupPreview = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
return Ludusavi.getBackupPreview(shop, objectId, backupPath);
};
registerEvent("getGameBackupPreview", getGameBackupPreview);

View File

@@ -0,0 +1,87 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import * as tar from "tar";
import crypto from "node:crypto";
import { GameShop } from "@types";
import axios from "axios";
import os from "node:os";
import { backupsPath } from "@main/constants";
import { app } from "electron";
const bundleBackup = async (shop: GameShop, objectId: string) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
await Ludusavi.backupGame(shop, objectId, backupPath);
const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.zip`);
await tar.create(
{
gzip: false,
file: tarLocation,
cwd: backupPath,
},
["."]
);
return tarLocation;
};
const uploadSaveGame = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const bundleLocation = await bundleBackup(shop, objectId);
fs.stat(bundleLocation, async (err, stat) => {
if (err) {
logger.error("Failed to get zip file stats", err);
throw err;
}
const { uploadUrl } = await HydraApi.post<{
id: string;
uploadUrl: string;
}>("/profile/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
homeDir: path.normalize(app.getPath("home")).replace(/\\/g, "/"),
platform: os.platform(),
});
fs.readFile(bundleLocation, async (err, fileBuffer) => {
if (err) {
logger.error("Failed to read zip file", err);
throw err;
}
await axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/tar",
},
onUploadProgress: (progressEvent) => {
console.log(progressEvent);
},
});
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
fs.rm(bundleLocation, (err) => {
if (err) {
logger.error("Failed to remove tar file", err);
throw err;
}
});
});
});
};
registerEvent("uploadSaveGame", uploadSaveGame);

View File

@@ -12,7 +12,7 @@ export interface SearchGamesArgs {
export const convertSteamGameToCatalogueEntry = (
game: SteamGame
): CatalogueEntry => ({
objectID: String(game.id),
objectId: String(game.id),
title: game.name,
shop: "steam" as GameShop,
cover: steamUrlBuilder.library(String(game.id)),

View File

@@ -57,6 +57,12 @@ import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/send-friend-request";
import "./profile/sync-friend-requests";
import "./cloud-save/download-game-artifact";
import "./cloud-save/get-game-artifacts";
import "./cloud-save/get-game-backup-preview";
import "./cloud-save/upload-save-game";
import "./cloud-save/check-game-cloud-sync-support";
import "./cloud-save/delete-game-artifact";
import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers";

View File

@@ -11,14 +11,14 @@ import { updateLocalUnlockedAchivements } from "@main/services/achievements/upda
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
objectId: string,
title: string,
shop: GameShop
) => {
return gameRepository
.update(
{
objectID,
objectID: objectId,
},
{
shop,
@@ -28,23 +28,25 @@ const addGameToLibrary = async (
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gameRepository.insert({
title,
iconUrl,
objectID,
objectID: objectId,
shop,
});
}
const game = await gameRepository.findOne({ where: { objectID } });
const game = await gameRepository.findOne({
where: { objectID: objectId },
});
updateLocalUnlockedAchivements(game!);

View File

@@ -2,15 +2,15 @@ import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getGameByObjectID = async (
const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string
objectId: string
) =>
gameRepository.findOne({
where: {
objectID,
objectID: objectId,
isDeleted: false,
},
});
registerEvent("getGameByObjectID", getGameByObjectID);
registerEvent("getGameByObjectId", getGameByObjectId);

View File

@@ -14,7 +14,7 @@ const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
payload: StartGameDownloadPayload
) => {
const { objectID, title, shop, downloadPath, downloader, uri } = payload;
const { objectId, title, shop, downloadPath, downloader, uri } = payload;
return dataSource.transaction(async (transactionalEntityManager) => {
const gameRepository = transactionalEntityManager.getRepository(Game);
@@ -23,7 +23,7 @@ const startGameDownload = async (
const game = await gameRepository.findOne({
where: {
objectID,
objectID: objectId,
shop,
},
});
@@ -51,18 +51,18 @@ const startGameDownload = async (
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(objectID), {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectID, steamGame.clientIcon)
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gameRepository.insert({
title,
iconUrl,
objectID,
objectID: objectId,
downloader,
shop,
status: "active",
@@ -73,7 +73,7 @@ const startGameDownload = async (
const updatedGame = await gameRepository.findOne({
where: {
objectID,
objectID: objectId,
},
});