feat: improving cloud sync manual mapping

This commit is contained in:
Chubby Granny Chaser 2024-10-22 13:34:34 +01:00
parent 7de6e96f63
commit bfcf8178d8
No known key found for this signature in database
15 changed files with 164 additions and 132 deletions

View file

@ -10,7 +10,8 @@
"no_results": "No results found", "no_results": "No results found",
"start_typing": "Starting typing to search...", "start_typing": "Starting typing to search...",
"hot": "Hot now", "hot": "Hot now",
"weekly": "📅 Top games of the week" "weekly": "📅 Top games of the week",
"achievements": "🏆 Good with achievements"
}, },
"sidebar": { "sidebar": {
"catalogue": "Catalogue", "catalogue": "Catalogue",
@ -161,7 +162,9 @@
"no_download_option_info": "No information available", "no_download_option_info": "No information available",
"backup_deletion_failed": "Failed to delete backup", "backup_deletion_failed": "Failed to delete backup",
"max_number_of_artifacts_reached": "Maximum number of backups reached for this game", "max_number_of_artifacts_reached": "Maximum number of backups reached for this game",
"achievements_not_sync": "Your achievements are not synchronized" "achievements_not_sync": "Your achievements are not synchronized",
"manage_files_description": "Manage which files will be backed up and restored",
"select_folder": "Select folder"
}, },
"activation": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",

View file

@ -1,6 +1,8 @@
import { app } from "electron"; import { app } from "electron";
import path from "node:path"; import path from "node:path";
export const LUDUSAVI_MANIFEST_URL = "https://cdn.losbroxas.org/manifest.yaml";
export const defaultDownloadsPath = app.getPath("downloads"); export const defaultDownloadsPath = app.getPath("downloads");
export const databaseDirectory = path.join(app.getPath("appData"), "hydra"); export const databaseDirectory = path.join(app.getPath("appData"), "hydra");

View file

@ -122,12 +122,9 @@ const downloadGameArtifact = async (
cwd: backupPath, cwd: backupPath,
}) })
.then(async () => { .then(async () => {
const [game] = await Ludusavi.findGames(shop, objectId);
if (!game) throw new Error("Game not found in Ludusavi manifest");
replaceLudusaviBackupWithCurrentUser( replaceLudusaviBackupWithCurrentUser(
backupPath, backupPath,
game.replaceAll(":", "_"), objectId,
normalizePath(homeDir) normalizePath(homeDir)
); );

View file

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

View file

@ -0,0 +1,14 @@
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { Ludusavi } from "@main/services";
const selectGameBackupPath = async (
_event: Electron.IpcMainInvokeEvent,
_shop: GameShop,
objectId: string,
backupPath: string | null
) => {
return Ludusavi.addCustomGame(objectId, backupPath);
};
registerEvent("selectGameBackupPath", selectGameBackupPath);

View file

@ -65,6 +65,7 @@ import "./cloud-save/get-game-artifacts";
import "./cloud-save/get-game-backup-preview"; import "./cloud-save/get-game-backup-preview";
import "./cloud-save/upload-save-game"; import "./cloud-save/upload-save-game";
import "./cloud-save/delete-game-artifact"; import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification"; import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers"; import { isPortableVersion } from "@main/helpers";
import "./misc/show-item-in-folder"; import "./misc/show-item-in-folder";

View file

@ -7,6 +7,7 @@ import path from "node:path";
import YAML from "yaml"; import YAML from "yaml";
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath"; import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
import { LUDUSAVI_MANIFEST_URL } from "@main/constants";
export class Ludusavi { export class Ludusavi {
private static ludusaviPath = path.join(app.getPath("appData"), "ludusavi"); private static ludusaviPath = path.join(app.getPath("appData"), "ludusavi");
@ -25,15 +26,6 @@ export class Ludusavi {
}, },
}); });
static async findGames(shop: GameShop, objectId: string): Promise<string[]> {
const games = await this.worker.run(
{ objectId, shop },
{ name: "findGames" }
);
return games;
}
static async getConfig() { static async getConfig() {
if (!fs.existsSync(this.ludusaviConfigPath)) { if (!fs.existsSync(this.ludusaviConfigPath)) {
await this.worker.run(undefined, { name: "generateConfig" }); await this.worker.run(undefined, { name: "generateConfig" });
@ -47,36 +39,36 @@ export class Ludusavi {
} }
static async backupGame( static async backupGame(
shop: GameShop, _shop: GameShop,
objectId: string, objectId: string,
backupPath: string, backupPath: string,
winePrefix?: string | null winePrefix?: string | null
): Promise<LudusaviBackup> { ): Promise<LudusaviBackup> {
const games = await this.findGames(shop, objectId);
if (!games.length) throw new Error("Game not found");
return this.worker.run( return this.worker.run(
{ title: games[0], backupPath, winePrefix }, { title: objectId, backupPath, winePrefix },
{ name: "backupGame" } { name: "backupGame" }
); );
} }
static async getBackupPreview( static async getBackupPreview(
shop: GameShop, _shop: GameShop,
objectId: string, objectId: string
backupPath: string
): Promise<LudusaviBackup | null> { ): Promise<LudusaviBackup | null> {
const games = await this.findGames(shop, objectId); const config = await this.getConfig();
if (!games.length) return null;
const [game] = games;
const backupData = await this.worker.run( const backupData = await this.worker.run(
{ title: game, backupPath, preview: true }, { title: objectId, preview: true },
{ name: "backupGame" } { name: "backupGame" }
); );
return backupData; const customGame = config.customGames.find(
(game) => game.name === objectId
);
return {
...backupData,
customBackupPath: customGame?.files[0] || null,
};
} }
static async restoreBackup(backupPath: string) { static async restoreBackup(backupPath: string) {
@ -87,24 +79,24 @@ export class Ludusavi {
const config = await this.getConfig(); const config = await this.getConfig();
config.manifest.enable = false; config.manifest.enable = false;
config.manifest.secondary = [ config.manifest.secondary = [{ url: LUDUSAVI_MANIFEST_URL, enable: true }];
{ url: "https://cdn.losbroxas.org/manifest.yaml", enable: true },
];
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config)); fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));
} }
static async addCustomGame(title: string, savePath: string) { static async addCustomGame(title: string, savePath: string | null) {
const config = await this.getConfig(); const config = await this.getConfig();
const filteredGames = config.customGames.filter( const filteredGames = config.customGames.filter(
(game) => game.name !== title (game) => game.name !== title
); );
filteredGames.push({ if (savePath) {
name: title, filteredGames.push({
files: [savePath], name: title,
registry: [], files: [savePath],
}); registry: [],
});
}
config.customGames = filteredGames; config.customGames = filteredGames;
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config)); fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));

View file

@ -1,29 +1,10 @@
import type { GameShop, LudusaviBackup, LudusaviFindResult } from "@types"; import type { LudusaviBackup } from "@types";
import cp from "node:child_process"; import cp from "node:child_process";
import { workerData } from "node:worker_threads"; import { workerData } from "node:worker_threads";
const { binaryPath } = workerData; 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 = ({ export const backupGame = ({
title, title,
backupPath, backupPath,
@ -35,7 +16,7 @@ export const backupGame = ({
preview?: boolean; preview?: boolean;
winePrefix?: string; winePrefix?: string;
}) => { }) => {
const args = ["backup", `"${title}"`, "--api", "--force"]; const args = ["backup", title, "--api", "--force"];
if (preview) args.push("--preview"); if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath); if (backupPath) args.push("--path", backupPath);

View file

@ -178,6 +178,11 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameBackupPreview", objectId, shop), ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) => deleteGameArtifact: (gameArtifactId: string) =>
ipcRenderer.invoke("deleteGameArtifact", gameArtifactId), ipcRenderer.invoke("deleteGameArtifact", gameArtifactId),
selectGameBackupPath: (
shop: GameShop,
objectId: string,
backupPath: string | null
) => ipcRenderer.invoke("selectGameBackupPath", shop, objectId, backupPath),
onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => { onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb(); const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener); ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener);

View file

@ -169,6 +169,7 @@ export function CloudSyncContextProvider({
setShowCloudSyncModal(false); setShowCloudSyncModal(false);
setRestoringBackup(false); setRestoringBackup(false);
setUploadingBackup(false); setUploadingBackup(false);
setLoadingPreview(false);
}, [objectId, shop]); }, [objectId, shop]);
const backupState = useMemo(() => { const backupState = useMemo(() => {

View file

@ -144,6 +144,11 @@ declare global {
shop: GameShop shop: GameShop
) => Promise<LudusaviBackup | null>; ) => Promise<LudusaviBackup | null>;
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
selectGameBackupPath: (
shop: GameShop,
objectId: string,
backupPath: string | null
) => Promise<void>;
onBackupDownloadComplete: ( onBackupDownloadComplete: (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,

View file

@ -1,9 +1,27 @@
import { style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../../theme.css"; import { SPACING_UNIT, vars } from "../../../theme.css";
export const mappingMethods = style({ export const mappingMethods = style({
display: "grid", display: "grid",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
gridTemplateColumns: "repeat(2, 1fr)", gridTemplateColumns: "repeat(2, 1fr)",
}); });
export const fileList = style({
listStyle: "none",
margin: "0",
padding: "0",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
marginTop: `${SPACING_UNIT * 2}px`,
});
export const fileItem = style({
flex: 1,
color: vars.color.muted,
textDecoration: "underline",
display: "flex",
cursor: "pointer",
});

View file

@ -1,13 +1,13 @@
import { Button, Modal, ModalProps } from "@renderer/components"; import { Button, Modal, ModalProps, TextField } from "@renderer/components";
import { useContext, useMemo, useState } from "react"; import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { cloudSyncContext } from "@renderer/context"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircleFillIcon } from "@primer/octicons-react"; import { CheckCircleFillIcon, FileDirectoryIcon } from "@primer/octicons-react";
import * as styles from "./cloud-sync-files-modal.css"; import * as styles from "./cloud-sync-files-modal.css";
import { formatBytes } from "@shared"; import { formatBytes } from "@shared";
import { vars } from "@renderer/theme.css"; import { useToast } from "@renderer/hooks";
// import { useToast } from "@renderer/hooks"; import { useForm } from "react-hook-form";
export interface CloudSyncFilesModalProps export interface CloudSyncFilesModalProps
extends Omit<ModalProps, "children" | "title"> {} extends Omit<ModalProps, "children" | "title"> {}
@ -23,12 +23,30 @@ export function CloudSyncFilesModal({
}: CloudSyncFilesModalProps) { }: CloudSyncFilesModalProps) {
const [selectedFileMappingMethod, setSelectedFileMappingMethod] = const [selectedFileMappingMethod, setSelectedFileMappingMethod] =
useState<FileMappingMethod>(FileMappingMethod.Automatic); useState<FileMappingMethod>(FileMappingMethod.Automatic);
const { backupPreview } = useContext(cloudSyncContext); const { backupPreview, getGameBackupPreview } = useContext(cloudSyncContext);
// const { gameTitle } = useContext(gameDetailsContext); const { shop, objectId } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
// const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const { register, setValue } = useForm<{
customBackupPath: string | null;
}>({
defaultValues: {
customBackupPath: null,
},
});
useEffect(() => {
if (backupPreview?.customBackupPath) {
setSelectedFileMappingMethod(FileMappingMethod.Manual);
} else {
setSelectedFileMappingMethod(FileMappingMethod.Automatic);
}
setValue("customBackupPath", backupPreview?.customBackupPath ?? null);
}, [visible, backupPreview]);
const files = useMemo(() => { const files = useMemo(() => {
if (!backupPreview) { if (!backupPreview) {
@ -44,28 +62,42 @@ export function CloudSyncFilesModal({
}); });
}, [backupPreview]); }, [backupPreview]);
// const handleAddCustomPathClick = useCallback(async () => { const handleAddCustomPathClick = useCallback(async () => {
// const { filePaths } = await window.electron.showOpenDialog({ const { filePaths } = await window.electron.showOpenDialog({
// properties: ["openDirectory"], properties: ["openDirectory"],
// }); });
// if (filePaths && filePaths.length > 0) { if (filePaths && filePaths.length > 0) {
// const path = filePaths[0]; const path = filePaths[0];
// await window.electron.selectGameBackupDirectory(gameTitle, path); setValue("customBackupPath", path);
// showSuccessToast("custom_backup_location_set");
// getGameBackupPreview(); await window.electron.selectGameBackupPath(shop, objectId!, path);
// } showSuccessToast("custom_backup_location_set");
// }, [gameTitle, showSuccessToast, getGameBackupPreview]); getGameBackupPreview();
}
}, [objectId, showSuccessToast, getGameBackupPreview]);
const handleFileMappingMethodClick = useCallback(
(mappingOption: FileMappingMethod) => {
if (mappingOption === FileMappingMethod.Automatic) {
getGameBackupPreview();
window.electron.selectGameBackupPath(shop, objectId!, null);
}
setSelectedFileMappingMethod(mappingOption);
},
[]
);
return ( return (
<Modal <Modal
visible={visible} visible={visible}
title="Gerenciar arquivos" title={t("manage_files")}
description="Escolha quais diretórios serão sincronizados" description={t("manage_files_description")}
onClose={onClose} onClose={onClose}
> >
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span>{t("mapping_method_label")}</span> <span style={{ marginBottom: 8 }}>{t("mapping_method_label")}</span>
<div className={styles.mappingMethods}> <div className={styles.mappingMethods}>
{Object.values(FileMappingMethod).map((mappingMethod) => ( {Object.values(FileMappingMethod).map((mappingMethod) => (
@ -76,8 +108,7 @@ export function CloudSyncFilesModal({
? "primary" ? "primary"
: "outline" : "outline"
} }
onClick={() => setSelectedFileMappingMethod(mappingMethod)} onClick={() => handleFileMappingMethodClick(mappingMethod)}
disabled={mappingMethod === FileMappingMethod.Manual}
> >
{selectedFileMappingMethod === mappingMethod && ( {selectedFileMappingMethod === mappingMethod && (
<CheckCircleFillIcon /> <CheckCircleFillIcon />
@ -89,46 +120,33 @@ export function CloudSyncFilesModal({
</div> </div>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
{/* <TextField {selectedFileMappingMethod === FileMappingMethod.Automatic ? (
readOnly <p>{t("files_automatically_mapped")}</p>
theme="dark" ) : (
disabled <TextField
placeholder={t("select_folder")} {...register("customBackupPath")}
rightContent={ readOnly
<Button theme="dark"
type="button" disabled
theme="outline" placeholder={t("select_folder")}
onClick={handleAddCustomPathClick} rightContent={
> <Button
<FileDirectoryIcon /> type="button"
{t("select_executable")} theme="outline"
</Button> onClick={handleAddCustomPathClick}
} >
/> */} <FileDirectoryIcon />
{t("select_executable")}
</Button>
}
/>
)}
<p>{t("files_automatically_mapped")}</p> <ul className={styles.fileList}>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 8,
marginTop: 16,
}}
>
{files.map((file) => ( {files.map((file) => (
<li key={file.path} style={{ display: "flex" }}> <li key={file.path} style={{ display: "flex" }}>
<button <button
style={{ className={styles.fileItem}
flex: 1,
color: vars.color.muted,
textDecoration: "underline",
display: "flex",
cursor: "pointer",
}}
onClick={() => window.electron.showItemInFolder(file.path)} onClick={() => window.electron.showItemInFolder(file.path)}
> >
{file.path.split("/").at(-1)} {file.path.split("/").at(-1)}

View file

@ -163,7 +163,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
type="button" type="button"
className={styles.manageFilesButton} className={styles.manageFilesButton}
onClick={() => setShowCloudSyncFilesModal(true)} onClick={() => setShowCloudSyncFilesModal(true)}
disabled={disableActions || !backupPreview?.overall.totalGames} disabled={disableActions}
> >
{t("manage_files")} {t("manage_files")}
</button> </button>
@ -199,7 +199,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
{artifacts.length > 0 ? ( {artifacts.length > 0 ? (
<ul className={styles.artifacts}> <ul className={styles.artifacts}>
{artifacts.map((artifact, index) => ( {artifacts.map((artifact) => (
<li key={artifact.id} className={styles.artifactButton}> <li key={artifact.id} className={styles.artifactButton}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}> <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<div <div
@ -210,7 +210,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
marginBottom: 4, marginBottom: 4,
}} }}
> >
<h3>{t("backup_title", { number: index + 1 })}</h3> <h3>Backup from 22/10</h3>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small> <small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div> </div>

View file

@ -21,10 +21,9 @@ export interface LudusaviBackup {
}; };
}; };
games: Record<string, LudusaviGame>; games: Record<string, LudusaviGame>;
}
export interface LudusaviFindResult { // Custom path for the backup, extracted from the config
games: Record<string, unknown>; customBackupPath?: string | null;
} }
export interface LudusaviConfig { export interface LudusaviConfig {