feat: adding cloud sync

This commit is contained in:
Chubby Granny Chaser 2024-09-25 19:37:28 +01:00
parent d88e06e289
commit e64a414309
No known key found for this signature in database
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;
};

View file

@ -115,6 +115,42 @@ contextBridge.exposeInMainWorld("electron", {
getDiskFreeSpace: (path: string) =>
ipcRenderer.invoke("getDiskFreeSpace", path),
/* Cloud sync */
uploadSaveGame: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("uploadSaveGame", objectId, shop),
downloadGameArtifact: (
objectId: string,
shop: GameShop,
gameArtifactId: string
) =>
ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId),
getGameArtifacts: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameArtifacts", objectId, shop),
getGameBackupPreview: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
checkGameCloudSyncSupport: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) =>
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener);
return () =>
ipcRenderer.removeListener(
`on-upload-complete-${objectId}-${shop}`,
listener
);
},
onDownloadComplete: (objectId: string, shop: GameShop, cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on(`on-download-complete-${objectId}-${shop}`, listener);
return () =>
ipcRenderer.removeListener(
`on-download-complete-${objectId}-${shop}`,
listener
);
},
/* Misc */
ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"),

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,187 @@
import { gameBackupsTable } from "@renderer/dexie";
import { useToast } from "@renderer/hooks";
import type { LudusaviBackup, GameArtifact, GameShop } from "@types";
import React, {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
export enum CloudSyncState {
New,
Different,
Same,
Unknown,
}
export interface CloudSyncContext {
backupPreview: LudusaviBackup | null;
artifacts: GameArtifact[];
showCloudSyncModal: boolean;
supportsCloudSync: boolean | null;
backupState: CloudSyncState;
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
uploadSaveGame: () => Promise<void>;
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
restoringBackup: boolean;
uploadingBackup: boolean;
}
export const cloudSyncContext = createContext<CloudSyncContext>({
backupPreview: null,
showCloudSyncModal: false,
supportsCloudSync: null,
backupState: CloudSyncState.Unknown,
setShowCloudSyncModal: () => {},
downloadGameArtifact: async () => {},
uploadSaveGame: async () => {},
artifacts: [],
deleteGameArtifact: async () => ({ ok: false }),
restoringBackup: false,
uploadingBackup: false,
});
const { Provider } = cloudSyncContext;
export const { Consumer: CloudSyncContextConsumer } = cloudSyncContext;
export interface CloudSyncContextProviderProps {
children: React.ReactNode;
objectId: string;
shop: GameShop;
}
export function CloudSyncContextProvider({
children,
objectId,
shop,
}: CloudSyncContextProviderProps) {
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
null
);
const [artifacts, setArtifacts] = useState<GameArtifact[]>([]);
const [showCloudSyncModal, setShowCloudSyncModal] = useState(false);
const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>(
null
);
const [restoringBackup, setRestoringBackup] = useState(false);
const [uploadingBackup, setUploadingBackup] = useState(false);
const { showSuccessToast } = useToast();
const downloadGameArtifact = useCallback(
async (gameArtifactId: string) => {
setRestoringBackup(true);
window.electron.downloadGameArtifact(objectId, shop, gameArtifactId);
},
[objectId, shop]
);
const getGameBackupPreview = useCallback(async () => {
window.electron.getGameArtifacts(objectId, shop).then((results) => {
setArtifacts(results);
});
window.electron.getGameBackupPreview(objectId, shop).then((preview) => {
if (preview && Object.keys(preview.games).length) {
setBackupPreview(preview);
}
});
}, [objectId, shop]);
const uploadSaveGame = useCallback(async () => {
setUploadingBackup(true);
window.electron.uploadSaveGame(objectId, shop);
}, [objectId, shop]);
useEffect(() => {
const removeUploadCompleteListener = window.electron.onUploadComplete(
objectId,
shop,
() => {
showSuccessToast("backup_uploaded");
setUploadingBackup(false);
gameBackupsTable.add({
objectId,
shop,
createdAt: new Date(),
});
getGameBackupPreview();
}
);
const removeDownloadCompleteListener = window.electron.onDownloadComplete(
objectId,
shop,
() => {
showSuccessToast("backup_restored");
setRestoringBackup(false);
getGameBackupPreview();
}
);
return () => {
removeUploadCompleteListener();
removeDownloadCompleteListener();
};
}, [objectId, shop, showSuccessToast, getGameBackupPreview]);
const deleteGameArtifact = useCallback(
async (gameArtifactId: string) => {
return window.electron.deleteGameArtifact(gameArtifactId).then(() => {
getGameBackupPreview();
return { ok: true };
});
},
[getGameBackupPreview]
);
useEffect(() => {
getGameBackupPreview();
window.electron.checkGameCloudSyncSupport(objectId, shop).then((result) => {
setSupportsCloudSync(result);
});
}, [objectId, shop, getGameBackupPreview]);
useEffect(() => {
if (showCloudSyncModal) {
getGameBackupPreview();
}
}, [getGameBackupPreview, showCloudSyncModal]);
const backupState = useMemo(() => {
if (!backupPreview) return CloudSyncState.Unknown;
if (backupPreview.overall.changedGames.new) return CloudSyncState.New;
if (backupPreview.overall.changedGames.different)
return CloudSyncState.Different;
if (backupPreview.overall.changedGames.same) return CloudSyncState.Same;
return CloudSyncState.Unknown;
}, [backupPreview]);
return (
<Provider
value={{
supportsCloudSync,
backupPreview,
showCloudSyncModal,
artifacts,
backupState,
restoringBackup,
uploadingBackup,
setShowCloudSyncModal,
uploadSaveGame,
downloadGameArtifact,
deleteGameArtifact,
}}
>
{children}
</Provider>
);
}

View file

@ -5,7 +5,6 @@ import {
useEffect,
useState,
} from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
@ -51,13 +50,17 @@ export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
export interface GameDetailsContextProps {
children: React.ReactNode;
objectId: string;
gameTitle: string;
shop: GameShop;
}
export function GameDetailsContextProvider({
children,
objectId,
gameTitle,
shop,
}: GameDetailsContextProps) {
const { objectID, shop } = useParams();
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
@ -72,10 +75,6 @@ export function GameDetailsContextProvider({
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [searchParams] = useSearchParams();
const gameTitle = searchParams.get("title")!;
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
useEffect(() => {
@ -98,9 +97,9 @@ export function GameDetailsContextProvider({
const updateGame = useCallback(async () => {
return window.electron
.getGameByObjectID(objectID!)
.getGameByObjectID(objectId!)
.then((result) => setGame(result));
}, [setGame, objectID]);
}, [setGame, objectId]);
const isGameDownloading = lastPacket?.game.id === game?.id;
@ -111,7 +110,7 @@ export function GameDetailsContextProvider({
useEffect(() => {
window.electron
.getGameShopDetails(
objectID!,
objectId!,
shop as GameShop,
getSteamLanguage(i18n.language)
)
@ -130,12 +129,12 @@ export function GameDetailsContextProvider({
setIsLoading(false);
});
window.electron.getGameStats(objectID!, shop as GameShop).then((result) => {
window.electron.getGameStats(objectId!, shop as GameShop).then((result) => {
setStats(result);
});
updateGame();
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
useEffect(() => {
setShopDetails(null);
@ -143,7 +142,7 @@ export function GameDetailsContextProvider({
setIsLoading(true);
setisGameRunning(false);
dispatch(setHeaderTitle(gameTitle));
}, [objectID, gameTitle, dispatch]);
}, [objectId, gameTitle, dispatch]);
useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
@ -200,7 +199,7 @@ export function GameDetailsContextProvider({
gameTitle,
isGameRunning,
isLoading,
objectID,
objectID: objectId,
gameColor,
showGameOptionsModal,
showRepacksModal,

View file

@ -2,3 +2,4 @@ export * from "./game-details/game-details.context";
export * from "./settings/settings.context";
export * from "./user-profile/user-profile.context";
export * from "./repacks/repacks.context";
export * from "./cloud-sync/cloud-sync.context";

View file

@ -26,6 +26,8 @@ import type {
UserDetails,
FriendRequestSync,
DownloadSourceValidationResult,
GameArtifact,
LudusaviBackup,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -113,6 +115,37 @@ declare global {
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
/* Cloud sync */
uploadSaveGame: (objectId: string, shop: GameShop) => Promise<void>;
downloadGameArtifact: (
objectId: string,
shop: GameShop,
gameArtifactId: string
) => Promise<void>;
getGameArtifacts: (
objectId: string,
shop: GameShop
) => Promise<GameArtifact[]>;
getGameBackupPreview: (
objectId: string,
shop: GameShop
) => Promise<LudusaviBackup | null>;
checkGameCloudSyncSupport: (
objectId: string,
shop: GameShop
) => Promise<boolean>;
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
onDownloadComplete: (
objectId: string,
shop: GameShop,
cb: () => void
) => () => Electron.IpcRenderer;
onUploadComplete: (
objectId: string,
shop: GameShop,
cb: () => void
) => () => Electron.IpcRenderer;
/* Misc */
openExternal: (src: string) => Promise<void>;
getVersion: () => Promise<string>;

View file

@ -1,13 +1,23 @@
import { GameShop } from "@types";
import { Dexie } from "dexie";
export interface GameBackup {
id?: number;
shop: GameShop;
objectId: string;
createdAt: Date;
}
export const db = new Dexie("Hydra");
db.version(1).stores({
db.version(3).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
gameBackups: `++id, [shop+objectId], createdAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
export const repacksTable = db.table("repacks");
export const gameBackupsTable = db.table<GameBackup>("gameBackups");
db.open();

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,178 @@
import { Button, Modal, ModalProps } from "@renderer/components";
import { useContext, useEffect, useMemo, useState } from "react";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import * as styles from "./cloud-sync-modal.css";
import { formatBytes } from "@shared";
import { format } from "date-fns";
import {
CheckCircleFillIcon,
ClockIcon,
DeviceDesktopIcon,
DownloadIcon,
SyncIcon,
TrashIcon,
UploadIcon,
} from "@primer/octicons-react";
import { useToast } from "@renderer/hooks";
import { GameBackup, gameBackupsTable } from "@renderer/dexie";
export interface CloudSyncModalProps
extends Omit<ModalProps, "children" | "title"> {}
export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const [deletingArtifact, setDeletingArtifact] = useState(false);
const [lastBackup, setLastBackup] = useState<GameBackup | null>(null);
const {
artifacts,
backupPreview,
uploadingBackup,
restoringBackup,
uploadSaveGame,
downloadGameArtifact,
deleteGameArtifact,
} = useContext(cloudSyncContext);
const { objectID, shop, gameTitle } = useContext(gameDetailsContext);
const { showSuccessToast, showErrorToast } = useToast();
const handleDeleteArtifactClick = async (gameArtifactId: string) => {
setDeletingArtifact(true);
try {
await deleteGameArtifact(gameArtifactId);
showSuccessToast("backup_successfully_deleted");
} catch (err) {
showErrorToast("backup_deletion_failed");
} finally {
setDeletingArtifact(false);
}
};
useEffect(() => {
gameBackupsTable
.where({ shop: shop, objectId: objectID })
.last()
.then((lastBackup) => setLastBackup(lastBackup || null));
}, [backupPreview, objectID, shop]);
const backupStateLabel = useMemo(() => {
if (uploadingBackup) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon />
creating_backup
</span>
);
}
if (restoringBackup) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon />
restoring_backup
</span>
);
}
if (lastBackup) {
return (
<p style={{ display: "flex", alignItems: "center", gap: 8 }}>
<CheckCircleFillIcon />
Último backup em {format(lastBackup.createdAt, "dd/MM/yyyy HH:mm")}
</p>
);
}
return "no_backups";
}, [uploadingBackup, lastBackup, restoringBackup]);
const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
return (
<Modal
visible={visible}
title="cloud_sync"
description="cloud_sync_description"
onClose={onClose}
large
>
<div
style={{
marginBottom: 24,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<h2>{gameTitle}</h2>
{backupStateLabel}
</div>
<Button
type="button"
onClick={uploadSaveGame}
disabled={disableActions}
>
<UploadIcon />
create_backup
</Button>
</div>
<h2 style={{ marginBottom: 16 }}>backups</h2>
<ul className={styles.artifacts}>
{artifacts.map((artifact) => (
<li key={artifact.id} className={styles.artifactButton}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<h3>Backup do dia {format(artifact.createdAt, "dd/MM")}</h3>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<DeviceDesktopIcon size={14} />
{artifact.hostname}
</span>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ClockIcon size={14} />
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
</span>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button
type="button"
onClick={() => downloadGameArtifact(artifact.id)}
disabled={disableActions}
>
<DownloadIcon />
install_artifact
</Button>
<Button
type="button"
onClick={() => handleDeleteArtifactClick(artifact.id)}
theme="danger"
disabled={disableActions}
>
<TrashIcon />
delete_artifact
</Button>
</div>
</li>
))}
</ul>
</Modal>
);
}

View file

@ -9,8 +9,11 @@ import { Sidebar } from "./sidebar/sidebar";
import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { steamUrlBuilder } from "@shared";
import Lottie from "lottie-react";
import downloadingAnimation from "@renderer/assets/lottie/cloud.json";
const HERO_ANIMATION_THRESHOLD = 25;
@ -30,6 +33,9 @@ export function GameDetailsContent() {
hasNSFWContentBlocked,
} = useContext(gameDetailsContext);
const { supportsCloudSync, setShowCloudSyncModal } =
useContext(cloudSyncContext);
const [backdropOpactiy, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => {
@ -102,6 +108,33 @@ export function GameDetailsContent() {
className={styles.gameLogo}
alt={game?.title}
/>
{supportsCloudSync && (
<button
type="button"
className={styles.cloudSyncButton}
onClick={() => setShowCloudSyncModal(true)}
>
<div
style={{
width: 16 + 4,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
}}
>
<Lottie
animationData={downloadingAnimation}
loop
autoplay
style={{ width: 26, position: "absolute", top: -3 }}
/>
</div>
cloud_sync
</button>
)}
</div>
</div>
</div>

View file

@ -6,8 +6,8 @@ import { recipe } from "@vanilla-extract/recipes";
export const HERO_HEIGHT = 300;
export const slideIn = keyframes({
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)` },
"100%": { transform: "translateY(0)" },
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)`, opacity: "0px" },
"100%": { transform: "translateY(0)", opacity: "1" },
});
export const wrapper = recipe({
@ -49,6 +49,8 @@ export const heroContent = style({
height: "100%",
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
});
export const heroLogoBackdrop = style({
@ -200,3 +202,33 @@ globalStyle(`${description} img`, {
globalStyle(`${description} a`, {
color: vars.color.body,
});
export const cloudSyncButton = style({
padding: `${SPACING_UNIT * 1.5}px ${SPACING_UNIT * 2}px`,
backgroundColor: "rgba(0, 0, 0, 0.6)",
backdropFilter: "blur(20px)",
borderRadius: "8px",
transition: "all ease 0.2s",
cursor: "pointer",
minHeight: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
color: vars.color.muted,
fontSize: "14px",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.8)",
animation: `${slideIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
animationDuration: "0.3s",
":active": {
opacity: "0.9",
},
":disabled": {
opacity: vars.opacity.disabled,
cursor: "not-allowed",
},
":hover": {
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
});

View file

@ -18,21 +18,25 @@ import { vars } from "@renderer/theme.css";
import { GameDetailsContent } from "./game-details-content";
import {
CloudSyncContextConsumer,
CloudSyncContextProvider,
GameDetailsContextConsumer,
GameDetailsContextProvider,
} from "@renderer/context";
import { useDownload } from "@renderer/hooks";
import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [randomizerLocked, setRandomizerLocked] = useState(false);
const { objectID } = useParams();
const { objectID, shop } = useParams();
const [searchParams] = useSearchParams();
const fromRandomizer = searchParams.get("fromRandomizer");
const gameTitle = searchParams.get("title");
const { startDownload } = useDownload();
@ -74,7 +78,11 @@ export function GameDetails() {
repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!;
return (
<GameDetailsContextProvider>
<GameDetailsContextProvider
gameTitle={gameTitle!}
shop={shop! as GameShop}
objectId={objectID!}
>
<GameDetailsContextConsumer>
{({
isLoading,
@ -115,64 +123,80 @@ export function GameDetails() {
};
return (
<SkeletonTheme
baseColor={vars.color.background}
highlightColor="#444"
<CloudSyncContextProvider
objectId={objectID!}
shop={shop! as GameShop}
>
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
<CloudSyncContextConsumer>
{({ showCloudSyncModal, setShowCloudSyncModal }) => (
<CloudSyncModal
onClose={() => setShowCloudSyncModal(false)}
visible={showCloudSyncModal}
/>
)}
</CloudSyncContextConsumer>
<RepacksModal
visible={showRepacksModal}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
<SkeletonTheme
baseColor={vars.color.background}
highlightColor="#444"
>
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
<ConfirmationModal
visible={hasNSFWContentBlocked}
onClose={handleNSFWContentRefuse}
title={t("nsfw_content_title")}
descriptionText={t("nsfw_content_description", {
title: gameTitle,
})}
confirmButtonLabel={t("allow_nsfw_content")}
cancelButtonLabel={t("refuse_nsfw_content")}
onConfirm={() => setHasNSFWContentBlocked(false)}
clickOutsideToClose={false}
/>
{game && (
<GameOptionsModal
visible={showGameOptionsModal}
game={game}
onClose={() => {
setShowGameOptionsModal(false);
}}
<RepacksModal
visible={showRepacksModal}
startDownload={handleStartDownload}
onClose={() => setShowRepacksModal(false)}
/>
)}
{fromRandomizer && (
<Button
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame || randomizerLocked}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<Lottie
animationData={starsAnimation}
style={{
width: 70,
position: "absolute",
top: -28,
left: -27,
}}
loop
/>
</div>
{t("next_suggestion")}
</Button>
)}
</SkeletonTheme>
<ConfirmationModal
visible={hasNSFWContentBlocked}
onClose={handleNSFWContentRefuse}
title={t("nsfw_content_title")}
descriptionText={t("nsfw_content_description", {
title: gameTitle,
})}
confirmButtonLabel={t("allow_nsfw_content")}
cancelButtonLabel={t("refuse_nsfw_content")}
onConfirm={() => setHasNSFWContentBlocked(false)}
clickOutsideToClose={false}
/>
{game && (
<GameOptionsModal
visible={showGameOptionsModal}
game={game}
onClose={() => {
setShowGameOptionsModal(false);
}}
/>
)}
{fromRandomizer && (
<Button
className={styles.randomizerButton}
onClick={handleRandomizerClick}
theme="outline"
disabled={!randomGame || randomizerLocked}
>
<div
style={{ width: 16, height: 16, position: "relative" }}
>
<Lottie
animationData={starsAnimation}
style={{
width: 70,
position: "absolute",
top: -28,
left: -27,
}}
loop
/>
</div>
{t("next_suggestion")}
</Button>
)}
</SkeletonTheme>
</CloudSyncContextProvider>
);
}}
</GameDetailsContextConsumer>

View file

@ -266,5 +266,15 @@ export interface UserStats {
friendsCount: number;
}
export interface GameArtifact {
id: string;
artifactLengthInBytes: number;
createdAt: string;
updatedAt: string;
hostname: string;
downloadCount: number;
}
export * from "./steam.types";
export * from "./real-debrid.types";
export * from "./ludusavi.types";

View file

@ -0,0 +1,23 @@
export interface LudusaviScanChange {
change: "New" | "Different" | "Removed" | "Same" | "Unknown";
decision: "Processed" | "Cancelled" | "Ignore";
}
export interface LudusaviBackup {
overall: {
totalGames: number;
totalBytes: number;
processedGames: number;
processedBytes: number;
changedGames: {
new: number;
different: number;
same: number;
};
};
games: Record<string, LudusaviScanChange>;
}
export interface LudusaviFindResult {
games: Record<string, unknown>;
}