From ed699a8deebd3c333a0c03fcd90329d001adaf82 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Sun, 9 Mar 2025 19:14:24 +0000 Subject: [PATCH] feat: adding automatic cloud sync --- src/locales/en/translation.json | 2 + src/locales/es/translation.json | 2 + src/locales/pt-BR/translation.json | 2 + src/locales/ru/translation.json | 2 + .../events/cloud-save/upload-save-game.ts | 102 ++------------- src/main/events/index.ts | 2 + .../library/toggle-automatic-cloud-sync.ts | 23 ++++ src/main/events/user/get-auth.ts | 11 ++ src/main/main.ts | 2 - src/main/services/cloud-sync.ts | 122 ++++++++++++++++++ src/main/services/download/torbox.ts | 2 + src/main/services/index.ts | 1 + src/main/services/process-watcher.ts | 27 ++++ src/preload/index.ts | 12 ++ .../checkbox-field/checkbox-field.tsx | 2 +- src/renderer/src/declaration.d.ts | 7 + .../modals/game-options-modal.scss | 14 ++ .../modals/game-options-modal.tsx | 35 ++++- src/types/level.types.ts | 1 + 19 files changed, 275 insertions(+), 96 deletions(-) create mode 100644 src/main/events/library/toggle-automatic-cloud-sync.ts create mode 100644 src/main/events/user/get-auth.ts create mode 100644 src/main/services/cloud-sync.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index f1e85019..fc64d010 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -178,6 +178,8 @@ "manage_files_description": "Manage which files will be backed up and restored", "select_folder": "Select folder", "backup_from": "Backup from {{date}}", + "automatic_backup_from": "Automatic backup from {{date}}", + "enable_automatic_cloud_sync": "Enable automatic cloud sync", "custom_backup_location_set": "Custom backup location set", "no_directory_selected": "No directory selected", "no_write_permission": "Cannot download into this directory. Click here to learn more.", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 27e18f34..1c78d435 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -174,6 +174,8 @@ "manage_files_description": "Gestiona los archivos que serán respaldados y restaurados", "select_folder": "Seleccionar carpeta", "backup_from": "Copia de seguridad de {{date}}", + "automatic_backup_from": "Copia de seguridad automática de {{date}}", + "enable_automatic_cloud_sync": "Habilitar sincronización automática en la nube", "custom_backup_location_set": "Se configuró la carpeta de copia de seguridad", "clear": "Limpiar", "no_directory_selected": "No se seleccionó un directorio", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 0cefd188..fc4dc84f 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -165,6 +165,8 @@ "max_number_of_artifacts_reached": "Número máximo de backups atingido para este jogo", "achievements_not_sync": "Veja como exibir suas conquistas no perfil", "backup_from": "Backup de {{date}}", + "automatic_backup_from": "Backup automático de {{date}}", + "enable_automatic_cloud_sync": "Habilitar sincronização automática na nuvem", "custom_backup_location_set": "Localização customizada selecionada", "select_folder": "Selecione a pasta", "manage_files_description": "Gerencie quais arquivos serão feitos backup", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index c2aa40ce..d50dc03d 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -178,6 +178,8 @@ "manage_files_description": "Управляйте файлами, которые будут сохраняться и восстанавливаться", "select_folder": "Выбрать папку", "backup_from": "Резервная копия от {{date}}", + "automatic_backup_from": "Автоматическая резервная копия от {{date}}", + "enable_automatic_cloud_sync": "Включить автоматическую синхронизацию в облаке", "custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии", "no_directory_selected": "Не выбран каталог", "no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.", diff --git a/src/main/events/cloud-save/upload-save-game.ts b/src/main/events/cloud-save/upload-save-game.ts index 0c18d0a6..2674e35a 100644 --- a/src/main/events/cloud-save/upload-save-game.ts +++ b/src/main/events/cloud-save/upload-save-game.ts @@ -1,44 +1,8 @@ -import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services"; +import { CloudSync } from "@main/services"; import { registerEvent } from "../register-event"; -import fs from "node:fs"; -import path from "node:path"; -import * as tar from "tar"; -import crypto from "node:crypto"; import type { GameShop } from "@types"; -import axios from "axios"; -import os from "node:os"; -import { backupsPath } from "@main/constants"; -import { app } from "electron"; -import { normalizePath } from "@main/helpers"; -import { gamesSublevel, levelKeys } from "@main/level"; - -const bundleBackup = async ( - shop: GameShop, - objectId: string, - winePrefix: string | null -) => { - const backupPath = path.join(backupsPath, `${shop}-${objectId}`); - - // Remove existing backup - if (fs.existsSync(backupPath)) { - fs.rmSync(backupPath, { recursive: true }); - } - - await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix); - - const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`); - - await tar.create( - { - gzip: false, - file: tarLocation, - cwd: backupPath, - }, - ["."] - ); - - return tarLocation; -}; +import { t } from "i18next"; +import { format } from "date-fns"; const uploadSaveGame = async ( _event: Electron.IpcMainInvokeEvent, @@ -46,61 +10,15 @@ const uploadSaveGame = async ( shop: GameShop, downloadOptionTitle: string | null ) => { - const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); - - const bundleLocation = await bundleBackup( - shop, + return CloudSync.uploadSaveGame( objectId, - game?.winePrefixPath ?? null + shop, + downloadOptionTitle, + t("backup_from", { + ns: "game_details", + date: format(new Date(), "dd/MM/yyyy"), + }) ); - - fs.stat(bundleLocation, async (err, stat) => { - if (err) { - logger.error("Failed to get zip file stats", err); - throw err; - } - - const { uploadUrl } = await HydraApi.post<{ - id: string; - uploadUrl: string; - }>("/profile/games/artifacts", { - artifactLengthInBytes: stat.size, - shop, - objectId, - hostname: os.hostname(), - homeDir: normalizePath(app.getPath("home")), - downloadOptionTitle, - platform: os.platform(), - }); - - fs.readFile(bundleLocation, async (err, fileBuffer) => { - if (err) { - logger.error("Failed to read zip file", err); - throw err; - } - - await axios.put(uploadUrl, fileBuffer, { - headers: { - "Content-Type": "application/tar", - }, - onUploadProgress: (progressEvent) => { - logger.log(progressEvent); - }, - }); - - WindowManager.mainWindow?.webContents.send( - `on-upload-complete-${objectId}-${shop}`, - true - ); - - fs.rm(bundleLocation, (err) => { - if (err) { - logger.error("Failed to remove tar file", err); - throw err; - } - }); - }); - }); }; registerEvent("uploadSaveGame", uploadSaveGame); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 8b8f4ebc..4976dc9d 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -88,6 +88,8 @@ import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; import "./misc/get-badges"; +import "./user/get-auth"; +import "./library/toggle-automatic-cloud-sync"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/library/toggle-automatic-cloud-sync.ts b/src/main/events/library/toggle-automatic-cloud-sync.ts new file mode 100644 index 00000000..224d82d3 --- /dev/null +++ b/src/main/events/library/toggle-automatic-cloud-sync.ts @@ -0,0 +1,23 @@ +import { registerEvent } from "../register-event"; +import { levelKeys, gamesSublevel } from "@main/level"; +import type { GameShop } from "@types"; + +const toggleAutomaticCloudSync = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string, + automaticCloudSync: boolean +) => { + const gameKey = levelKeys.game(shop, objectId); + + const game = await gamesSublevel.get(gameKey); + + if (!game) return; + + await gamesSublevel.put(gameKey, { + ...game, + automaticCloudSync, + }); +}; + +registerEvent("toggleAutomaticCloudSync", toggleAutomaticCloudSync); diff --git a/src/main/events/user/get-auth.ts b/src/main/events/user/get-auth.ts new file mode 100644 index 00000000..e828b5ee --- /dev/null +++ b/src/main/events/user/get-auth.ts @@ -0,0 +1,11 @@ +import type { Auth } from "@types"; +import { registerEvent } from "../register-event"; + +import { db, levelKeys } from "@main/level"; + +const getAuth = async (_event: Electron.IpcMainInvokeEvent) => + db.get(levelKeys.auth, { + valueEncoding: "json", + }); + +registerEvent("getAuth", getAuth); diff --git a/src/main/main.ts b/src/main/main.ts index a7484f58..84bd7732 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -62,8 +62,6 @@ export const loadState = async () => { game.uri !== null ); - console.log("downloadsToSeed", downloadsToSeed); - await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); startMainLoop(); diff --git a/src/main/services/cloud-sync.ts b/src/main/services/cloud-sync.ts new file mode 100644 index 00000000..5393ac95 --- /dev/null +++ b/src/main/services/cloud-sync.ts @@ -0,0 +1,122 @@ +import { levelKeys, gamesSublevel, db } from "@main/level"; +import { app } from "electron"; +import path from "node:path"; +import * as tar from "tar"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import type { GameShop, User } from "@types"; +import { backupsPath } from "@main/constants"; +import { HydraApi } from "./hydra-api"; +import { normalizePath } from "@main/helpers"; +import { logger } from "./logger"; +import { WindowManager } from "./window-manager"; +import axios from "axios"; +import { Ludusavi } from "./ludusavi"; +import { isFuture, isToday } from "date-fns"; +import { SubscriptionRequiredError } from "@shared"; + +export class CloudSync { + private static async bundleBackup( + shop: GameShop, + objectId: string, + winePrefix: string | null + ) { + const backupPath = path.join(backupsPath, `${shop}-${objectId}`); + + // Remove existing backup + if (fs.existsSync(backupPath)) { + fs.rmSync(backupPath, { recursive: true }); + } + + await Ludusavi.backupGame(shop, objectId, backupPath, winePrefix); + + const tarLocation = path.join(backupsPath, `${crypto.randomUUID()}.tar`); + + await tar.create( + { + gzip: false, + file: tarLocation, + cwd: backupPath, + }, + ["."] + ); + + return tarLocation; + } + + public static async uploadSaveGame( + objectId: string, + shop: GameShop, + downloadOptionTitle: string | null, + label?: string + ) { + const hasActiveSubscription = await db + .get(levelKeys.user, { valueEncoding: "json" }) + .then((user) => { + const expiresAt = user?.subscription?.expiresAt; + return expiresAt && (isFuture(expiresAt) || isToday(expiresAt)); + }); + + if (!hasActiveSubscription) { + throw new SubscriptionRequiredError(); + } + + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); + + const bundleLocation = await this.bundleBackup( + shop, + objectId, + game?.winePrefixPath ?? null + ); + + fs.stat(bundleLocation, async (err, stat) => { + if (err) { + logger.error("Failed to get zip file stats", err); + throw err; + } + + const { uploadUrl } = await HydraApi.post<{ + id: string; + uploadUrl: string; + }>("/profile/games/artifacts", { + artifactLengthInBytes: stat.size, + shop, + objectId, + hostname: os.hostname(), + homeDir: normalizePath(app.getPath("home")), + downloadOptionTitle, + platform: os.platform(), + label, + }); + + fs.readFile(bundleLocation, async (err, fileBuffer) => { + if (err) { + logger.error("Failed to read zip file", err); + throw err; + } + + await axios.put(uploadUrl, fileBuffer, { + headers: { + "Content-Type": "application/tar", + }, + onUploadProgress: (progressEvent) => { + logger.log(progressEvent); + }, + }); + + WindowManager.mainWindow?.webContents.send( + `on-upload-complete-${objectId}-${shop}`, + true + ); + + fs.rm(bundleLocation, (err) => { + if (err) { + logger.error("Failed to remove tar file", err); + throw err; + } + }); + }); + }); + } +} diff --git a/src/main/services/download/torbox.ts b/src/main/services/download/torbox.ts index 8011cae8..60155704 100644 --- a/src/main/services/download/torbox.ts +++ b/src/main/services/download/torbox.ts @@ -6,6 +6,7 @@ import type { TorBoxAddTorrentRequest, TorBoxRequestLinkRequest, } from "@types"; +import { appVersion } from "@main/constants"; export class TorBoxClient { private static instance: AxiosInstance; @@ -18,6 +19,7 @@ export class TorBoxClient { baseURL: this.baseURL, headers: { Authorization: `Bearer ${apiToken}`, + "User-Agent": `Hydra/${appVersion}`, }, }); } diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 5aaf5322..f53dae31 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -7,3 +7,4 @@ export * from "./process-watcher"; export * from "./main-loop"; export * from "./hydra-api"; export * from "./ludusavi"; +export * from "./cloud-sync"; diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts index 0b04defe..f7e30742 100644 --- a/src/main/services/process-watcher.ts +++ b/src/main/services/process-watcher.ts @@ -6,6 +6,9 @@ import axios from "axios"; import { exec } from "child_process"; import { ProcessPayload } from "./download/types"; import { gamesSublevel, levelKeys } from "@main/level"; +import { t } from "i18next"; +import { CloudSync } from "./cloud-sync"; +import { format } from "date-fns"; const commands = { findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`, @@ -225,6 +228,18 @@ function onOpenGame(game: Game) { if (game.remoteId) { updateGamePlaytime(game, 0, new Date()).catch(() => {}); + + if (game.automaticCloudSync) { + CloudSync.uploadSaveGame( + game.objectId, + game.shop, + null, + t("automatic_backup_from", { + ns: "game_details", + date: format(new Date(), "dd/MM/yyyy"), + }) + ); + } } else { createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {}); } @@ -287,6 +302,18 @@ const onCloseGame = (game: Game) => { performance.now() - gamePlaytime.lastSyncTick, game.lastTimePlayed! ).catch(() => {}); + + if (game.automaticCloudSync) { + CloudSync.uploadSaveGame( + game.objectId, + game.shop, + null, + t("automatic_backup_from", { + ns: "game_details", + date: format(new Date(), "dd/MM/yyyy"), + }) + ); + } } else { createGame(game).catch(() => {}); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7b94cda1..5249e57f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -101,6 +101,17 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("putDownloadSource", objectIds), /* Library */ + toggleAutomaticCloudSync: ( + shop: GameShop, + objectId: string, + automaticCloudSync: boolean + ) => + ipcRenderer.invoke( + "toggleAutomaticCloudSync", + shop, + objectId, + automaticCloudSync + ), addGameToLibrary: (shop: GameShop, objectId: string, title: string) => ipcRenderer.invoke("addGameToLibrary", shop, objectId, title), createGameShortcut: (shop: GameShop, objectId: string) => @@ -301,6 +312,7 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("sendFriendRequest", userId), /* User */ + getAuth: () => ipcRenderer.invoke("getAuth"), getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), diff --git a/src/renderer/src/components/checkbox-field/checkbox-field.tsx b/src/renderer/src/components/checkbox-field/checkbox-field.tsx index 61cd9fda..6c1b3186 100644 --- a/src/renderer/src/components/checkbox-field/checkbox-field.tsx +++ b/src/renderer/src/components/checkbox-field/checkbox-field.tsx @@ -7,7 +7,7 @@ export interface CheckboxFieldProps React.InputHTMLAttributes, HTMLInputElement > { - label: string; + label: string | React.ReactNode; } export function CheckboxField({ label, ...props }: CheckboxFieldProps) { diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 4700fbb1..39254463 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -31,6 +31,7 @@ import type { TorBoxUser, Theme, Badge, + Auth, } from "@types"; import type { AxiosProgressEvent } from "axios"; import type disk from "diskusage"; @@ -87,6 +88,11 @@ declare global { getDevelopers: () => Promise; /* Library */ + toggleAutomaticCloudSync: ( + shop: GameShop, + objectId: string, + automaticCloudSync: boolean + ) => Promise; addGameToLibrary: ( shop: GameShop, objectId: string, @@ -237,6 +243,7 @@ declare global { onSignOut: (cb: () => void) => () => Electron.IpcRenderer; /* User */ + getAuth: () => Promise; getUser: (userId: string) => Promise; blockUser: (userId: string) => Promise; unblockUser: (userId: string) => Promise; diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.scss b/src/renderer/src/pages/game-details/modals/game-options-modal.scss index 00113260..ebad7fac 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.scss +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.scss @@ -23,6 +23,20 @@ } } + &__cloud-sync-label { + display: flex; + gap: globals.$spacing-unit; + align-items: center; + } + + &__cloud-sync-hydra-cloud { + background: linear-gradient(270deg, #16b195 50%, #3e62c0 100%); + color: #fff; + padding: 0 globals.$spacing-unit; + border-radius: 4px; + font-size: 12px; + } + &__row { display: flex; gap: globals.$spacing-unit; diff --git a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx index d2d188db..7c625f11 100644 --- a/src/renderer/src/pages/game-details/modals/game-options-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/game-options-modal.tsx @@ -1,6 +1,6 @@ import { useContext, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Modal, TextField } from "@renderer/components"; +import { Button, CheckboxField, Modal, TextField } from "@renderer/components"; import type { LibraryGame } from "@types"; import { gameDetailsContext } from "@renderer/context"; import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal"; @@ -34,12 +34,17 @@ export function GameOptionsModal({ achievements, } = useContext(gameDetailsContext); + const { hasActiveSubscription } = useUserDetails(); + const [showDeleteModal, setShowDeleteModal] = useState(false); const [showRemoveGameModal, setShowRemoveGameModal] = useState(false); const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? ""); const [showResetAchievementsModal, setShowResetAchievementsModal] = useState(false); const [isDeletingAchievements, setIsDeletingAchievements] = useState(false); + const [automaticCloudSync, setAutomaticCloudSync] = useState( + game.automaticCloudSync ?? false + ); const { removeGameInstaller, @@ -183,6 +188,20 @@ export function GameOptionsModal({ } }; + const handleToggleAutomaticCloudSync = async ( + event: React.ChangeEvent + ) => { + setAutomaticCloudSync(event.target.checked); + + await window.electron.toggleAutomaticCloudSync( + game.shop, + game.objectId, + event.target.checked + ); + + updateGame(); + }; + return ( <> + + {t("enable_automatic_cloud_sync")} + + Hydra Cloud + + + } + checked={automaticCloudSync} + disabled={!hasActiveSubscription || !game.executablePath} + onChange={handleToggleAutomaticCloudSync} + /> + {shouldShowWinePrefixConfiguration && (
diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 2956165a..ff710367 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -43,6 +43,7 @@ export interface Game { executablePath?: string | null; launchOptions?: string | null; favorite?: boolean; + automaticCloudSync?: boolean; } export interface Download {