diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 49460f4c..21082e59 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/main/constants.ts b/src/main/constants.ts index 4e2f1c00..fd5c279a 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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"); diff --git a/src/main/events/cloud-save/download-game-artifact.ts b/src/main/events/cloud-save/download-game-artifact.ts index ef00629b..4fd31b44 100644 --- a/src/main/events/cloud-save/download-game-artifact.ts +++ b/src/main/events/cloud-save/download-game-artifact.ts @@ -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) ); diff --git a/src/main/events/cloud-save/get-game-backup-preview.ts b/src/main/events/cloud-save/get-game-backup-preview.ts index d2d9cbc8..daffa487 100644 --- a/src/main/events/cloud-save/get-game-backup-preview.ts +++ b/src/main/events/cloud-save/get-game-backup-preview.ts @@ -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); diff --git a/src/main/events/cloud-save/select-game-backup-path.ts b/src/main/events/cloud-save/select-game-backup-path.ts new file mode 100644 index 00000000..37b3be55 --- /dev/null +++ b/src/main/events/cloud-save/select-game-backup-path.ts @@ -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); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index ba20a467..932b80e4 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -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"; diff --git a/src/main/services/ludusavi.ts b/src/main/services/ludusavi.ts index 8b64ba11..746c6a22 100644 --- a/src/main/services/ludusavi.ts +++ b/src/main/services/ludusavi.ts @@ -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 { - 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 { - 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 { - 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)); diff --git a/src/main/services/window-manager.ts b/src/main/services/window-manager.ts index ecdf64f5..e1dc4dfc 100644 --- a/src/main/services/window-manager.ts +++ b/src/main/services/window-manager.ts @@ -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: { diff --git a/src/main/workers/ludusavi.worker.ts b/src/main/workers/ludusavi.worker.ts index 855d20bf..4b1f339a 100644 --- a/src/main/workers/ludusavi.worker.ts +++ b/src/main/workers/ludusavi.worker.ts @@ -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); diff --git a/src/preload/index.ts b/src/preload/index.ts index 4ef62554..90c50763 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index e6620f24..929b00e9 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -144,6 +144,11 @@ declare global { shop: GameShop ) => Promise; deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; + selectGameBackupPath: ( + shop: GameShop, + objectId: string, + backupPath: string | null + ) => Promise; onBackupDownloadComplete: ( objectId: string, shop: GameShop, diff --git a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.css.ts b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.css.ts index f6a46a08..0dbab770 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.css.ts +++ b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.css.ts @@ -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", +}); diff --git a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx index 3bcba8a7..38f20273 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-files-modal/cloud-sync-files-modal.tsx @@ -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 {} @@ -23,12 +23,30 @@ export function CloudSyncFilesModal({ }: CloudSyncFilesModalProps) { const [selectedFileMappingMethod, setSelectedFileMappingMethod] = useState(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 (
- {t("mapping_method_label")} + {t("mapping_method_label")}
{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 && ( @@ -89,46 +120,33 @@ export function CloudSyncFilesModal({
- {/* - - {t("select_executable")} - - } - /> */} + {selectedFileMappingMethod === FileMappingMethod.Automatic ? ( +

{t("files_automatically_mapped")}

+ ) : ( + + + {t("select_executable")} + + } + /> + )} -

{t("files_automatically_mapped")}

- -
    +
      {files.map((file) => (
    • @@ -199,7 +199,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { {artifacts.length > 0 ? (
        - {artifacts.map((artifact, index) => ( + {artifacts.map((artifact) => (
      • -

        {t("backup_title", { number: index + 1 })}

        +

        Backup from 22/10

        {formatBytes(artifact.artifactLengthInBytes)}
        diff --git a/src/types/ludusavi.types.ts b/src/types/ludusavi.types.ts index 55f3f506..8432b9f6 100644 --- a/src/types/ludusavi.types.ts +++ b/src/types/ludusavi.types.ts @@ -21,10 +21,9 @@ export interface LudusaviBackup { }; }; games: Record; -} -export interface LudusaviFindResult { - games: Record; + // Custom path for the backup, extracted from the config + customBackupPath?: string | null; } export interface LudusaviConfig {