mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-02-02 16:23:48 +03:00
Merge pull request #1087 from hydralauncher/feature/cloud-sync-improvements
feat: improving cloud sync manual mapping
This commit is contained in:
commit
a774b107f3
@ -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",
|
||||
|
@ -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");
|
||||
|
@ -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)
|
||||
);
|
||||
|
||||
|
@ -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);
|
||||
|
14
src/main/events/cloud-save/select-game-backup-path.ts
Normal file
14
src/main/events/cloud-save/select-game-backup-path.ts
Normal 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);
|
@ -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";
|
||||
|
@ -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));
|
||||
|
@ -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: {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
5
src/renderer/src/declaration.d.ts
vendored
5
src/renderer/src/declaration.d.ts
vendored
@ -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,
|
||||
|
@ -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",
|
||||
});
|
||||
|
@ -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)}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user