mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-21 01:53:57 +00:00
feat: adding cloud sync
This commit is contained in:
@@ -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();
|
||||
|
||||
14
src/main/events/cloud-sync/check-game-cloud-sync-support.ts
Normal file
14
src/main/events/cloud-sync/check-game-cloud-sync-support.ts
Normal 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);
|
||||
9
src/main/events/cloud-sync/delete-game-artifact.ts
Normal file
9
src/main/events/cloud-sync/delete-game-artifact.ts
Normal 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);
|
||||
56
src/main/events/cloud-sync/download-game-artifact.ts
Normal file
56
src/main/events/cloud-sync/download-game-artifact.ts
Normal 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);
|
||||
18
src/main/events/cloud-sync/get-game-artifacts.ts
Normal file
18
src/main/events/cloud-sync/get-game-artifacts.ts
Normal 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);
|
||||
17
src/main/events/cloud-sync/get-game-backup-preview.ts
Normal file
17
src/main/events/cloud-sync/get-game-backup-preview.ts
Normal 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);
|
||||
101
src/main/events/cloud-sync/upload-save-game.ts
Normal file
101
src/main/events/cloud-sync/upload-save-game.ts
Normal 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);
|
||||
@@ -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");
|
||||
|
||||
@@ -33,6 +33,9 @@ const getNewProfileImageUrl = async (localImageUrl: string) => {
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
console.log(progressEvent);
|
||||
},
|
||||
});
|
||||
|
||||
return profileImageUrl;
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from "./process-watcher";
|
||||
export * from "./main-loop";
|
||||
export * from "./repacks-manager";
|
||||
export * from "./hydra-api";
|
||||
export * from "./ludusavi";
|
||||
|
||||
63
src/main/services/ludusavi.ts
Normal file
63
src/main/services/ludusavi.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
61
src/main/workers/ludusavi.worker.ts
Normal file
61
src/main/workers/ludusavi.worker.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user