Merge pull request #1087 from hydralauncher/feature/cloud-sync-improvements

feat: improving cloud sync manual mapping
This commit is contained in:
Zamitto 2024-10-22 17:47:14 -03:00 committed by GitHub
commit a774b107f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 171 additions and 131 deletions

View File

@ -10,7 +10,8 @@
"no_results": "No results found",
"start_typing": "Starting typing to search...",
"hot": "Hot now",
"weekly": "📅 Top games of the week"
"weekly": "📅 Top games of the week",
"achievements": "🏆 Good with achievements"
},
"sidebar": {
"catalogue": "Catalogue",
@ -161,7 +162,9 @@
"no_download_option_info": "No information available",
"backup_deletion_failed": "Failed to delete backup",
"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": {
"title": "Activate Hydra",

View File

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

View File

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

View File

@ -1,17 +1,21 @@
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { Ludusavi } from "@main/services";
import path from "node:path";
import { backupsPath } from "@main/constants";
import { gameRepository } from "@main/repository";
const getGameBackupPreview = async (
_event: Electron.IpcMainInvokeEvent,
objectId: string,
shop: GameShop
) => {
const backupPath = path.join(backupsPath, `${shop}-${objectId}`);
const game = await gameRepository.findOne({
where: {
objectID: objectId,
shop,
},
});
return Ludusavi.getBackupPreview(shop, objectId, backupPath);
return Ludusavi.getBackupPreview(shop, objectId, game?.winePrefixPath);
};
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/upload-save-game";
import "./cloud-save/delete-game-artifact";
import "./cloud-save/select-game-backup-path";
import "./notifications/publish-new-repacks-notification";
import { isPortableVersion } from "@main/helpers";
import "./misc/show-item-in-folder";

View File

@ -7,6 +7,7 @@ import path from "node:path";
import YAML from "yaml";
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
import { LUDUSAVI_MANIFEST_URL } from "@main/constants";
export class 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() {
if (!fs.existsSync(this.ludusaviConfigPath)) {
await this.worker.run(undefined, { name: "generateConfig" });
@ -47,36 +39,37 @@ export class Ludusavi {
}
static async backupGame(
shop: GameShop,
_shop: GameShop,
objectId: string,
backupPath: string,
winePrefix?: string | null
): 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, winePrefix },
{ title: objectId, backupPath, winePrefix },
{ name: "backupGame" }
);
}
static async getBackupPreview(
shop: GameShop,
_shop: GameShop,
objectId: string,
backupPath: string
winePrefix?: string | null
): Promise<LudusaviBackup | null> {
const games = await this.findGames(shop, objectId);
if (!games.length) return null;
const [game] = games;
const config = await this.getConfig();
const backupData = await this.worker.run(
{ title: game, backupPath, preview: true },
{ title: objectId, winePrefix, preview: true },
{ 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) {
@ -87,24 +80,24 @@ export class Ludusavi {
const config = await this.getConfig();
config.manifest.enable = false;
config.manifest.secondary = [
{ url: "https://cdn.losbroxas.org/manifest.yaml", enable: true },
];
config.manifest.secondary = [{ url: LUDUSAVI_MANIFEST_URL, enable: true }];
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 filteredGames = config.customGames.filter(
(game) => game.name !== title
);
filteredGames.push({
name: title,
files: [savePath],
registry: [],
});
if (savePath) {
filteredGames.push({
name: title,
files: [savePath],
registry: [],
});
}
config.customGames = filteredGames;
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));

View File

@ -63,7 +63,7 @@ export class WindowManager {
minWidth: 1024,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: "hidden",
titleBarStyle: process.platform === "win32" ? "hidden" : "default",
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {

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 { 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,
@ -35,7 +16,7 @@ export const backupGame = ({
preview?: boolean;
winePrefix?: string;
}) => {
const args = ["backup", `"${title}"`, "--api", "--force"];
const args = ["backup", title, "--api", "--force"];
if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath);

View File

@ -178,6 +178,11 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("getGameBackupPreview", objectId, shop),
deleteGameArtifact: (gameArtifactId: string) =>
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) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener);

View File

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

View File

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

View File

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

View File

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