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

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>