feat: adding cloud sync

This commit is contained in:
Chubby Granny Chaser
2024-09-25 19:37:28 +01:00
parent d88e06e289
commit e64a414309
33 changed files with 1352 additions and 84 deletions

View File

@@ -12,4 +12,6 @@ export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds")
: path.join(__dirname, "..", "..", "seeds");
export const backupsPath = path.join(app.getPath("userData"), "Backups");
export const appVersion = app.getVersion();

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,9 @@
import { HydraApi } from "@main/services";
import { registerEvent } from "../register-event";
const deleteGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
gameArtifactId: string
) => HydraApi.delete<{ ok: boolean }>(`/games/artifacts/${gameArtifactId}`);
registerEvent("deleteGameArtifact", deleteGameArtifact);

View File

@@ -0,0 +1,56 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import fs from "node:fs";
import AdmZip from "adm-zip";
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";
const downloadGameArtifact = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop,
gameArtifactId: string
) => {
const { downloadUrl, objectKey } = await HydraApi.post<{
downloadUrl: string;
objectKey: string;
}>(`/games/artifacts/${gameArtifactId}/download`);
const response = await axios.get(downloadUrl, {
responseType: "stream",
});
const zipLocation = path.join(app.getPath("userData"), objectKey);
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
const writer = fs.createWriteStream(zipLocation);
response.data.pipe(writer);
writer.on("error", (err) => {
logger.error("Failed to write zip", err);
throw err;
});
writer.on("close", () => {
const zip = new AdmZip(zipLocation);
zip.extractAllToAsync(backupPath, true, true, (err) => {
if (err) {
logger.error("Failed to extract zip", err);
throw err;
}
Ludusavi.restoreBackup(backupPath).then(() => {
WindowManager.mainWindow?.webContents.send(
`on-download-complete-${objectId}-${shop}`,
true
);
});
});
});
};
registerEvent("downloadGameArtifact", downloadGameArtifact);

View File

@@ -0,0 +1,18 @@
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[]>(`/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,101 @@
import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
import fs from "node:fs";
import path from "node:path";
import archiver from "archiver";
import crypto from "node:crypto";
import { GameShop } from "@types";
import axios from "axios";
import os from "node:os";
import { app } from "electron";
import { backupsPath } from "@main/constants";
const compressBackupToArtifact = async (
shop: GameShop,
objectId: string,
cb: (zipLocation: string) => void
) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
await Ludusavi.backupGame(shop, objectId, backupPath);
const archive = archiver("zip", {
zlib: { level: 9 },
});
const zipLocation = path.join(
app.getPath("userData"),
`${crypto.randomUUID()}.zip`
);
const output = fs.createWriteStream(zipLocation);
output.on("close", () => {
cb(zipLocation);
});
output.on("error", (err) => {
logger.error("Failed to compress folder", err);
throw err;
});
archive.pipe(output);
archive.directory(backupPath, false);
archive.finalize();
};
const uploadSaveGame = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
compressBackupToArtifact(shop, objectId, (zipLocation) => {
fs.stat(zipLocation, 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;
}>("/games/artifacts", {
artifactLengthInBytes: stat.size,
shop,
objectId,
hostname: os.hostname(),
});
fs.readFile(zipLocation, async (err, fileBuffer) => {
if (err) {
logger.error("Failed to read zip file", err);
throw err;
}
axios.put(uploadUrl, fileBuffer, {
headers: {
"Content-Type": "application/zip",
},
onUploadProgress: (progressEvent) => {
if (progressEvent.progress === 1) {
fs.rm(zipLocation, (err) => {
if (err) {
logger.error("Failed to remove zip file", err);
throw err;
}
WindowManager.mainWindow?.webContents.send(
`on-upload-complete-${objectId}-${shop}`,
true
);
});
}
},
});
});
});
});
};
registerEvent("uploadSaveGame", uploadSaveGame);

View File

@@ -58,6 +58,12 @@ import "./profile/update-profile";
import "./profile/process-profile-image";
import "./profile/send-friend-request";
import "./profile/sync-friend-requests";
import "./cloud-sync/download-game-artifact";
import "./cloud-sync//get-game-artifacts";
import "./cloud-sync/get-game-backup-preview";
import "./cloud-sync/upload-save-game";
import "./cloud-sync/check-game-cloud-sync-support";
import "./cloud-sync/delete-game-artifact";
import { isPortableVersion } from "@main/helpers";
ipcMain.handle("ping", () => "pong");

View File

@@ -33,6 +33,9 @@ const getNewProfileImageUrl = async (localImageUrl: string) => {
headers: {
"Content-Type": mimeType,
},
onUploadProgress: (progressEvent) => {
console.log(progressEvent);
},
});
return profileImageUrl;

View File

@@ -9,3 +9,4 @@ export * from "./process-watcher";
export * from "./main-loop";
export * from "./repacks-manager";
export * from "./hydra-api";
export * from "./ludusavi";

View File

@@ -0,0 +1,63 @@
import { GameShop, LudusaviBackup } from "@types";
import Piscina from "piscina";
import { app } from "electron";
import path from "node:path";
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
const binaryPath = app.isPackaged
? path.join(process.resourcesPath, "ludusavi", "ludusavi")
: path.join(__dirname, "..", "..", "ludusavi", "ludusavi");
export class Ludusavi {
private static worker = new Piscina({
filename: ludusaviWorkerPath,
workerData: {
binaryPath,
},
});
static async findGames(shop: GameShop, objectId: string): Promise<string[]> {
const games = await this.worker.run(
{ objectId, shop },
{ name: "findGames" }
);
return games;
}
static async backupGame(
shop: GameShop,
objectId: string,
backupPath: string
): Promise<LudusaviBackup> {
const games = await this.findGames(shop, objectId);
if (!games.length) throw new Error("Game not found");
return this.worker.run(
{ title: games[0], backupPath },
{ name: "backupGame" }
);
}
static async getBackupPreview(
shop: GameShop,
objectId: string,
backupPath: string
): Promise<LudusaviBackup | null> {
const games = await this.findGames(shop, objectId);
if (!games.length) return null;
const backupData = await this.worker.run(
{ title: games[0], backupPath, preview: true },
{ name: "backupGame" }
);
return backupData;
}
static async restoreBackup(backupPath: string) {
return this.worker.run(backupPath, { name: "restoreBackup" });
}
}

View File

@@ -1,3 +1,4 @@
import type { GameShop } from "@types";
import axios from "axios";
export interface SteamGridResponse {
@@ -22,7 +23,7 @@ export interface SteamGridGameResponse {
export const getSteamGridData = async (
objectID: string,
path: string,
shop: string,
shop: GameShop,
params: Record<string, string> = {}
): Promise<SteamGridResponse> => {
const searchParams = new URLSearchParams(params);

View File

@@ -0,0 +1,61 @@
import type { GameShop, LudusaviBackup, LudusaviFindResult } from "@types";
import cp from "node:child_process";
import { workerData } from "node:worker_threads";
const { binaryPath } = workerData;
export const findGames = ({
shop,
objectId,
}: {
shop: GameShop;
objectId: string;
}) => {
const args = ["find", "--api"];
if (shop === "steam") {
args.push("--steam-id", objectId);
}
const result = cp.execFileSync(binaryPath, args);
const games = JSON.parse(result.toString("utf-8")) as LudusaviFindResult;
return Object.keys(games.games);
};
export const backupGame = ({
title,
backupPath,
preview = false,
}: {
title: string;
backupPath: string;
preview?: boolean;
}) => {
const args = ["backup", title, "--api", "--force"];
if (preview) {
args.push("--preview");
}
if (backupPath) {
args.push("--path", backupPath);
}
const result = cp.execFileSync(binaryPath, args);
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};
export const restoreBackup = (backupPath: string) => {
const result = cp.execFileSync(binaryPath, [
"restore",
"--path",
backupPath,
"--api",
"--force",
]);
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};