From e64a414309c3d3ff43dffaf411c7bca546111a13 Mon Sep 17 00:00:00 2001 From: Chubby Granny Chaser Date: Wed, 25 Sep 2024 19:37:28 +0100 Subject: [PATCH] feat: adding cloud sync --- .gitignore | 5 +- electron-builder.yml | 1 + package.json | 7 +- postinstall.cjs | 44 +++ src/main/constants.ts | 2 + .../check-game-cloud-sync-support.ts | 14 + .../events/cloud-sync/delete-game-artifact.ts | 9 + .../cloud-sync/download-game-artifact.ts | 56 ++++ .../events/cloud-sync/get-game-artifacts.ts | 18 ++ .../cloud-sync/get-game-backup-preview.ts | 17 ++ .../events/cloud-sync/upload-save-game.ts | 101 +++++++ src/main/events/index.ts | 6 + src/main/events/profile/update-profile.ts | 3 + src/main/services/index.ts | 1 + src/main/services/ludusavi.ts | 63 ++++ src/main/services/steam-grid.ts | 3 +- src/main/workers/ludusavi.worker.ts | 61 ++++ src/preload/index.ts | 36 +++ src/renderer/src/assets/lottie/cloud.json | 1 + .../context/cloud-sync/cloud-sync.context.tsx | 187 ++++++++++++ .../game-details/game-details.context.tsx | 27 +- src/renderer/src/context/index.ts | 1 + src/renderer/src/declaration.d.ts | 33 +++ src/renderer/src/dexie.ts | 12 +- .../cloud-sync-modal/cloud-sync-modal.css.ts | 26 ++ .../cloud-sync-modal/cloud-sync-modal.tsx | 178 ++++++++++++ .../game-details/game-details-content.tsx | 35 ++- .../pages/game-details/game-details.css.ts | 36 ++- .../src/pages/game-details/game-details.tsx | 134 +++++---- src/types/index.ts | 10 + src/types/ludusavi.types.ts | 23 ++ torrent-client/torrent_downloader.py | 11 +- yarn.lock | 275 +++++++++++++++++- 33 files changed, 1352 insertions(+), 84 deletions(-) create mode 100644 postinstall.cjs create mode 100644 src/main/events/cloud-sync/check-game-cloud-sync-support.ts create mode 100644 src/main/events/cloud-sync/delete-game-artifact.ts create mode 100644 src/main/events/cloud-sync/download-game-artifact.ts create mode 100644 src/main/events/cloud-sync/get-game-artifacts.ts create mode 100644 src/main/events/cloud-sync/get-game-backup-preview.ts create mode 100644 src/main/events/cloud-sync/upload-save-game.ts create mode 100644 src/main/services/ludusavi.ts create mode 100644 src/main/workers/ludusavi.worker.ts create mode 100644 src/renderer/src/assets/lottie/cloud.json create mode 100644 src/renderer/src/context/cloud-sync/cloud-sync.context.tsx create mode 100644 src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts create mode 100644 src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx create mode 100644 src/types/ludusavi.types.ts diff --git a/.gitignore b/.gitignore index fb4badd7..b9dcfecb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -.vscode -node_modules +.vscode/ +node_modules/ hydra-download-manager/ fastlist.exe __pycache__ @@ -10,3 +10,4 @@ out .env .vite sentry.properties +ludusavi/ \ No newline at end of file diff --git a/electron-builder.yml b/electron-builder.yml index a085b1e9..46f4a872 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,6 +3,7 @@ productName: Hydra directories: buildResources: build extraResources: + - ludusavi - hydra-download-manager - seeds - from: node_modules/create-desktop-shortcuts/src/windows.vbs diff --git a/package.json b/package.json index 6fd3f905..08f096f7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", - "postinstall": "electron-builder install-app-deps", + "postinstall": "electron-builder install-app-deps && node ./postinstall.cjs", "build:unpack": "npm run build && electron-builder --dir", "build:win": "electron-vite build && electron-builder --win", "build:mac": "electron-vite build && electron-builder --mac", @@ -42,6 +42,8 @@ "@vanilla-extract/css": "^1.14.2", "@vanilla-extract/dynamic": "^2.1.1", "@vanilla-extract/recipes": "^0.5.2", + "adm-zip": "^0.5.16", + "archiver": "^7.0.1", "auto-launch": "^5.0.6", "axios": "^1.7.7", "better-sqlite3": "^11.2.1", @@ -86,8 +88,11 @@ "@electron-toolkit/tsconfig": "^1.0.1", "@sentry/vite-plugin": "^2.20.1", "@swc/core": "^1.4.16", + "@types/adm-zip": "^0.5.5", + "@types/archiver": "^6.0.2", "@types/auto-launch": "^5.0.5", "@types/color": "^3.0.6", + "@types/folder-hash": "^4.0.4", "@types/jsdom": "^21.1.6", "@types/jsonwebtoken": "^9.0.6", "@types/lodash-es": "^4.17.12", diff --git a/postinstall.cjs b/postinstall.cjs new file mode 100644 index 00000000..ce9c5909 --- /dev/null +++ b/postinstall.cjs @@ -0,0 +1,44 @@ +const { default: axios } = require("axios"); +const util = require("node:util"); +const fs = require("node:fs"); +const path = require("node:path"); + +const exec = util.promisify(require("node:child_process").exec); + +const fileName = { + win32: "ludusavi-v0.25.0-win64.zip", + linux: "ludusavi-v0.25.0-linux.zip", + darwin: "ludusavi-v0.25.0-mac.zip", +}; + +const downloadLudusavi = async () => { + if (fs.existsSync("ludusavi")) { + console.log("Ludusavi already exists, skipping download..."); + return; + } + + const file = fileName[process.platform]; + const downloadUrl = `https://github.com/mtkennerly/ludusavi/releases/download/v0.25.0/${file}`; + + console.log(`Downloading ${file}...`); + + const response = await axios.get(downloadUrl, { responseType: "stream" }); + + const stream = response.data.pipe(fs.createWriteStream(file)); + + stream.on("finish", async () => { + console.log(`Downloaded ${file}, extracting...`); + + const pwd = process.cwd(); + const targetPath = path.join(pwd, "ludusavi"); + + await exec(`npx extract-zip ${file} ${targetPath}`); + fs.chmodSync(path.join(targetPath, "ludusavi"), 0o755); + console.log("Extracted. Renaming folder..."); + + console.log(`Extracted ${file}, removing compressed downloaded file...`); + fs.rmSync(file); + }); +}; + +downloadLudusavi(); diff --git a/src/main/constants.ts b/src/main/constants.ts index 92973118..8af17a44 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -12,4 +12,6 @@ export const seedsPath = app.isPackaged ? path.join(process.resourcesPath, "seeds") : path.join(__dirname, "..", "..", "seeds"); +export const backupsPath = path.join(app.getPath("userData"), "Backups"); + export const appVersion = app.getVersion(); diff --git a/src/main/events/cloud-sync/check-game-cloud-sync-support.ts b/src/main/events/cloud-sync/check-game-cloud-sync-support.ts new file mode 100644 index 00000000..4054d430 --- /dev/null +++ b/src/main/events/cloud-sync/check-game-cloud-sync-support.ts @@ -0,0 +1,14 @@ +import { registerEvent } from "../register-event"; +import { GameShop } from "@types"; +import { Ludusavi } from "@main/services"; + +const checkGameCloudSyncSupport = async ( + _event: Electron.IpcMainInvokeEvent, + objectId: string, + shop: GameShop +) => { + const games = await Ludusavi.findGames(shop, objectId); + return games.length === 1; +}; + +registerEvent("checkGameCloudSyncSupport", checkGameCloudSyncSupport); diff --git a/src/main/events/cloud-sync/delete-game-artifact.ts b/src/main/events/cloud-sync/delete-game-artifact.ts new file mode 100644 index 00000000..fa869896 --- /dev/null +++ b/src/main/events/cloud-sync/delete-game-artifact.ts @@ -0,0 +1,9 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; + +const deleteGameArtifact = async ( + _event: Electron.IpcMainInvokeEvent, + gameArtifactId: string +) => HydraApi.delete<{ ok: boolean }>(`/games/artifacts/${gameArtifactId}`); + +registerEvent("deleteGameArtifact", deleteGameArtifact); diff --git a/src/main/events/cloud-sync/download-game-artifact.ts b/src/main/events/cloud-sync/download-game-artifact.ts new file mode 100644 index 00000000..a1254dc3 --- /dev/null +++ b/src/main/events/cloud-sync/download-game-artifact.ts @@ -0,0 +1,56 @@ +import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services"; +import fs from "node:fs"; +import AdmZip from "adm-zip"; +import { registerEvent } from "../register-event"; +import axios from "axios"; +import { app } from "electron"; +import path from "node:path"; +import { backupsPath } from "@main/constants"; +import type { GameShop } from "@types"; + +const downloadGameArtifact = async ( + _event: Electron.IpcMainInvokeEvent, + objectId: string, + shop: GameShop, + gameArtifactId: string +) => { + const { downloadUrl, objectKey } = await HydraApi.post<{ + downloadUrl: string; + objectKey: string; + }>(`/games/artifacts/${gameArtifactId}/download`); + + const response = await axios.get(downloadUrl, { + responseType: "stream", + }); + + const zipLocation = path.join(app.getPath("userData"), objectKey); + const backupPath = path.join(backupsPath, `${shop}-${objectId}`); + + const writer = fs.createWriteStream(zipLocation); + + response.data.pipe(writer); + + writer.on("error", (err) => { + logger.error("Failed to write zip", err); + throw err; + }); + + writer.on("close", () => { + const zip = new AdmZip(zipLocation); + zip.extractAllToAsync(backupPath, true, true, (err) => { + if (err) { + logger.error("Failed to extract zip", err); + throw err; + } + + Ludusavi.restoreBackup(backupPath).then(() => { + WindowManager.mainWindow?.webContents.send( + `on-download-complete-${objectId}-${shop}`, + true + ); + }); + }); + }); +}; + +registerEvent("downloadGameArtifact", downloadGameArtifact); diff --git a/src/main/events/cloud-sync/get-game-artifacts.ts b/src/main/events/cloud-sync/get-game-artifacts.ts new file mode 100644 index 00000000..b32dfd79 --- /dev/null +++ b/src/main/events/cloud-sync/get-game-artifacts.ts @@ -0,0 +1,18 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; +import type { GameArtifact, GameShop } from "@types"; + +const getGameArtifacts = async ( + _event: Electron.IpcMainInvokeEvent, + objectId: string, + shop: GameShop +) => { + const params = new URLSearchParams({ + objectId, + shop, + }); + + return HydraApi.get(`/games/artifacts?${params.toString()}`); +}; + +registerEvent("getGameArtifacts", getGameArtifacts); diff --git a/src/main/events/cloud-sync/get-game-backup-preview.ts b/src/main/events/cloud-sync/get-game-backup-preview.ts new file mode 100644 index 00000000..433fccc4 --- /dev/null +++ b/src/main/events/cloud-sync/get-game-backup-preview.ts @@ -0,0 +1,17 @@ +import { registerEvent } from "../register-event"; +import { GameShop } from "@types"; +import { Ludusavi } from "@main/services"; +import path from "node:path"; +import { backupsPath } from "@main/constants"; + +const getGameBackupPreview = async ( + _event: Electron.IpcMainInvokeEvent, + objectId: string, + shop: GameShop +) => { + const backupPath = path.join(backupsPath, `${shop}-${objectId}`); + + return Ludusavi.getBackupPreview(shop, objectId, backupPath); +}; + +registerEvent("getGameBackupPreview", getGameBackupPreview); diff --git a/src/main/events/cloud-sync/upload-save-game.ts b/src/main/events/cloud-sync/upload-save-game.ts new file mode 100644 index 00000000..0c9a4fbd --- /dev/null +++ b/src/main/events/cloud-sync/upload-save-game.ts @@ -0,0 +1,101 @@ +import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services"; +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import archiver from "archiver"; +import crypto from "node:crypto"; +import { GameShop } from "@types"; +import axios from "axios"; +import os from "node:os"; +import { app } from "electron"; +import { backupsPath } from "@main/constants"; + +const compressBackupToArtifact = async ( + shop: GameShop, + objectId: string, + cb: (zipLocation: string) => void +) => { + const backupPath = path.join(backupsPath, `${shop}-${objectId}`); + + await Ludusavi.backupGame(shop, objectId, backupPath); + + const archive = archiver("zip", { + zlib: { level: 9 }, + }); + + const zipLocation = path.join( + app.getPath("userData"), + `${crypto.randomUUID()}.zip` + ); + + const output = fs.createWriteStream(zipLocation); + + output.on("close", () => { + cb(zipLocation); + }); + + output.on("error", (err) => { + logger.error("Failed to compress folder", err); + throw err; + }); + + archive.pipe(output); + + archive.directory(backupPath, false); + archive.finalize(); +}; + +const uploadSaveGame = async ( + _event: Electron.IpcMainInvokeEvent, + objectId: string, + shop: GameShop +) => { + compressBackupToArtifact(shop, objectId, (zipLocation) => { + fs.stat(zipLocation, 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; + }>("/games/artifacts", { + artifactLengthInBytes: stat.size, + shop, + objectId, + hostname: os.hostname(), + }); + + fs.readFile(zipLocation, async (err, fileBuffer) => { + if (err) { + logger.error("Failed to read zip file", err); + throw err; + } + + axios.put(uploadUrl, fileBuffer, { + headers: { + "Content-Type": "application/zip", + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.progress === 1) { + fs.rm(zipLocation, (err) => { + if (err) { + logger.error("Failed to remove zip file", err); + throw err; + } + + WindowManager.mainWindow?.webContents.send( + `on-upload-complete-${objectId}-${shop}`, + true + ); + }); + } + }, + }); + }); + }); + }); +}; + +registerEvent("uploadSaveGame", uploadSaveGame); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 73bf38f4..4caa577c 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -58,6 +58,12 @@ import "./profile/update-profile"; import "./profile/process-profile-image"; import "./profile/send-friend-request"; import "./profile/sync-friend-requests"; +import "./cloud-sync/download-game-artifact"; +import "./cloud-sync//get-game-artifacts"; +import "./cloud-sync/get-game-backup-preview"; +import "./cloud-sync/upload-save-game"; +import "./cloud-sync/check-game-cloud-sync-support"; +import "./cloud-sync/delete-game-artifact"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index eb80bc47..4135aae5 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -33,6 +33,9 @@ const getNewProfileImageUrl = async (localImageUrl: string) => { headers: { "Content-Type": mimeType, }, + onUploadProgress: (progressEvent) => { + console.log(progressEvent); + }, }); return profileImageUrl; diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 255b3871..8c6e6cda 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -9,3 +9,4 @@ export * from "./process-watcher"; export * from "./main-loop"; export * from "./repacks-manager"; export * from "./hydra-api"; +export * from "./ludusavi"; diff --git a/src/main/services/ludusavi.ts b/src/main/services/ludusavi.ts new file mode 100644 index 00000000..838b5f9b --- /dev/null +++ b/src/main/services/ludusavi.ts @@ -0,0 +1,63 @@ +import { GameShop, LudusaviBackup } from "@types"; +import Piscina from "piscina"; + +import { app } from "electron"; +import path from "node:path"; + +import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath"; + +const binaryPath = app.isPackaged + ? path.join(process.resourcesPath, "ludusavi", "ludusavi") + : path.join(__dirname, "..", "..", "ludusavi", "ludusavi"); + +export class Ludusavi { + private static worker = new Piscina({ + filename: ludusaviWorkerPath, + workerData: { + binaryPath, + }, + }); + + static async findGames(shop: GameShop, objectId: string): Promise { + const games = await this.worker.run( + { objectId, shop }, + { name: "findGames" } + ); + + return games; + } + + static async backupGame( + shop: GameShop, + objectId: string, + backupPath: string + ): 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 }, + { name: "backupGame" } + ); + } + + static async getBackupPreview( + shop: GameShop, + objectId: string, + backupPath: string + ): Promise { + const games = await this.findGames(shop, objectId); + if (!games.length) return null; + + const backupData = await this.worker.run( + { title: games[0], backupPath, preview: true }, + { name: "backupGame" } + ); + + return backupData; + } + + static async restoreBackup(backupPath: string) { + return this.worker.run(backupPath, { name: "restoreBackup" }); + } +} diff --git a/src/main/services/steam-grid.ts b/src/main/services/steam-grid.ts index c762eaf6..2bdee28d 100644 --- a/src/main/services/steam-grid.ts +++ b/src/main/services/steam-grid.ts @@ -1,3 +1,4 @@ +import type { GameShop } from "@types"; import axios from "axios"; export interface SteamGridResponse { @@ -22,7 +23,7 @@ export interface SteamGridGameResponse { export const getSteamGridData = async ( objectID: string, path: string, - shop: string, + shop: GameShop, params: Record = {} ): Promise => { const searchParams = new URLSearchParams(params); diff --git a/src/main/workers/ludusavi.worker.ts b/src/main/workers/ludusavi.worker.ts new file mode 100644 index 00000000..2a1d266c --- /dev/null +++ b/src/main/workers/ludusavi.worker.ts @@ -0,0 +1,61 @@ +import type { GameShop, LudusaviBackup, LudusaviFindResult } 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, + preview = false, +}: { + title: string; + backupPath: string; + preview?: boolean; +}) => { + const args = ["backup", title, "--api", "--force"]; + + if (preview) { + args.push("--preview"); + } + + if (backupPath) { + args.push("--path", backupPath); + } + + const result = cp.execFileSync(binaryPath, args); + + return JSON.parse(result.toString("utf-8")) as LudusaviBackup; +}; + +export const restoreBackup = (backupPath: string) => { + const result = cp.execFileSync(binaryPath, [ + "restore", + "--path", + backupPath, + "--api", + "--force", + ]); + + return JSON.parse(result.toString("utf-8")) as LudusaviBackup; +}; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4d7b7183..32a747a5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -115,6 +115,42 @@ contextBridge.exposeInMainWorld("electron", { getDiskFreeSpace: (path: string) => ipcRenderer.invoke("getDiskFreeSpace", path), + /* Cloud sync */ + uploadSaveGame: (objectId: string, shop: GameShop) => + ipcRenderer.invoke("uploadSaveGame", objectId, shop), + downloadGameArtifact: ( + objectId: string, + shop: GameShop, + gameArtifactId: string + ) => + ipcRenderer.invoke("downloadGameArtifact", objectId, shop, gameArtifactId), + getGameArtifacts: (objectId: string, shop: GameShop) => + ipcRenderer.invoke("getGameArtifacts", objectId, shop), + getGameBackupPreview: (objectId: string, shop: GameShop) => + ipcRenderer.invoke("getGameBackupPreview", objectId, shop), + checkGameCloudSyncSupport: (objectId: string, shop: GameShop) => + ipcRenderer.invoke("checkGameCloudSyncSupport", objectId, shop), + deleteGameArtifact: (gameArtifactId: string) => + ipcRenderer.invoke("deleteGameArtifact", gameArtifactId), + onUploadComplete: (objectId: string, shop: GameShop, cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on(`on-upload-complete-${objectId}-${shop}`, listener); + return () => + ipcRenderer.removeListener( + `on-upload-complete-${objectId}-${shop}`, + listener + ); + }, + onDownloadComplete: (objectId: string, shop: GameShop, cb: () => void) => { + const listener = (_event: Electron.IpcRendererEvent) => cb(); + ipcRenderer.on(`on-download-complete-${objectId}-${shop}`, listener); + return () => + ipcRenderer.removeListener( + `on-download-complete-${objectId}-${shop}`, + listener + ); + }, + /* Misc */ ping: () => ipcRenderer.invoke("ping"), getVersion: () => ipcRenderer.invoke("getVersion"), diff --git a/src/renderer/src/assets/lottie/cloud.json b/src/renderer/src/assets/lottie/cloud.json new file mode 100644 index 00000000..9df1e119 --- /dev/null +++ b/src/renderer/src/assets/lottie/cloud.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":30,"ip":0,"op":60,"w":400,"h":400,"nm":"Cloud","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":2,"ty":4,"nm":"Layer 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[322.789,202.565,0],"to":[-1.5,-0.167,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[313.789,201.565,0],"to":[0,0,0],"ti":[-1.5,-0.167,0]},{"t":60,"s":[322.789,202.565,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-38.564],[38.564,0],[0,38.564],[-38.564,0]],"o":[[0,38.564],[-38.564,0],[0,-38.564],[38.564,0]],"v":[[69.827,0],[0,69.827],[-69.827,0],[0,-69.827]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215686275,0.854901960784,0.933333333333,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":270,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Layer 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[243.704,202.565,0],"to":[-1.667,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[233.704,202.565,0],"to":[0,0,0],"ti":[-1.667,0,0]},{"t":60,"s":[243.704,202.565,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-38.564],[38.564,0],[0,38.564],[-38.564,0]],"o":[[0,38.564],[-38.564,0],[0,-38.564],[38.564,0]],"v":[[69.827,0],[0,69.827],[-69.827,0],[0,-69.827]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215686275,0.854901960784,0.933333333333,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":270,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[260.681,151.053,0],"to":[1.333,-1.333,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[268.681,143.053,0],"to":[0,0,0],"ti":[1.333,-1.333,0]},{"t":60,"s":[260.681,151.053,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-38.564],[38.564,0],[0,38.564],[-38.564,0]],"o":[[0,38.564],[-38.564,0],[0,-38.564],[38.564,0]],"v":[[69.827,0],[0,69.827],[-69.827,0],[0,-69.827]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215686275,0.854901960784,0.933333333333,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":270,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[162.135,206.563,0],"to":[-0.833,-0.167,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[157.135,205.563,0],"to":[0,0,0],"ti":[-0.833,-0.167,0]},{"t":60,"s":[162.135,206.563,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-36.66],[36.66,0],[0,36.66],[-36.66,0]],"o":[[0,36.66],[-36.66,0],[0,-36.66],[36.66,0]],"v":[[66.378,0],[0,66.378],[-66.378,0],[0,-66.378]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215686275,0.854901960784,0.933333333333,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":270,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[180.178,132.225,0],"to":[-0.5,-2.333,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[177.178,118.225,0],"to":[0,0,0],"ti":[-0.5,-2.333,0]},{"t":60,"s":[180.178,132.225,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-50.068],[50.068,0],[0,50.068],[-50.068,0]],"o":[[0,50.068],[-50.068,0],[0,-50.068],[50.068,0]],"v":[[90.655,0],[0,90.655],[-90.655,0],[0,-90.655]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215686275,0.854901960784,0.933333333333,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":270,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[95.756,208.288,0],"to":[-1.167,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[88.756,208.288,0],"to":[0,0,0],"ti":[-1.167,0,0]},{"t":60,"s":[95.756,208.288,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-35.403],[35.403,0],[0,35.403],[-35.403,0]],"o":[[0,35.403],[-35.403,0],[0,-35.403],[35.403,0]],"v":[[64.103,0],[0,64.103],[-64.103,0],[0,-64.103]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215686275,0.854901960784,0.933333333333,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":270,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":3,"nm":"Null 1","parent":6,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[19.822,67.775,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":270,"st":0,"bm":0}],"markers":[],"props":{}} \ No newline at end of file diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx new file mode 100644 index 00000000..38bbdb40 --- /dev/null +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -0,0 +1,187 @@ +import { gameBackupsTable } from "@renderer/dexie"; +import { useToast } from "@renderer/hooks"; +import type { LudusaviBackup, GameArtifact, GameShop } from "@types"; +import React, { + createContext, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +export enum CloudSyncState { + New, + Different, + Same, + Unknown, +} + +export interface CloudSyncContext { + backupPreview: LudusaviBackup | null; + artifacts: GameArtifact[]; + showCloudSyncModal: boolean; + supportsCloudSync: boolean | null; + backupState: CloudSyncState; + setShowCloudSyncModal: React.Dispatch>; + downloadGameArtifact: (gameArtifactId: string) => Promise; + uploadSaveGame: () => Promise; + deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; + restoringBackup: boolean; + uploadingBackup: boolean; +} + +export const cloudSyncContext = createContext({ + backupPreview: null, + showCloudSyncModal: false, + supportsCloudSync: null, + backupState: CloudSyncState.Unknown, + setShowCloudSyncModal: () => {}, + downloadGameArtifact: async () => {}, + uploadSaveGame: async () => {}, + artifacts: [], + deleteGameArtifact: async () => ({ ok: false }), + restoringBackup: false, + uploadingBackup: false, +}); + +const { Provider } = cloudSyncContext; +export const { Consumer: CloudSyncContextConsumer } = cloudSyncContext; + +export interface CloudSyncContextProviderProps { + children: React.ReactNode; + objectId: string; + shop: GameShop; +} + +export function CloudSyncContextProvider({ + children, + objectId, + shop, +}: CloudSyncContextProviderProps) { + const [supportsCloudSync, setSupportsCloudSync] = useState( + null + ); + const [artifacts, setArtifacts] = useState([]); + const [showCloudSyncModal, setShowCloudSyncModal] = useState(false); + const [backupPreview, setBackupPreview] = useState( + null + ); + const [restoringBackup, setRestoringBackup] = useState(false); + const [uploadingBackup, setUploadingBackup] = useState(false); + + const { showSuccessToast } = useToast(); + + const downloadGameArtifact = useCallback( + async (gameArtifactId: string) => { + setRestoringBackup(true); + window.electron.downloadGameArtifact(objectId, shop, gameArtifactId); + }, + [objectId, shop] + ); + + const getGameBackupPreview = useCallback(async () => { + window.electron.getGameArtifacts(objectId, shop).then((results) => { + setArtifacts(results); + }); + + window.electron.getGameBackupPreview(objectId, shop).then((preview) => { + if (preview && Object.keys(preview.games).length) { + setBackupPreview(preview); + } + }); + }, [objectId, shop]); + + const uploadSaveGame = useCallback(async () => { + setUploadingBackup(true); + window.electron.uploadSaveGame(objectId, shop); + }, [objectId, shop]); + + useEffect(() => { + const removeUploadCompleteListener = window.electron.onUploadComplete( + objectId, + shop, + () => { + showSuccessToast("backup_uploaded"); + + setUploadingBackup(false); + gameBackupsTable.add({ + objectId, + shop, + createdAt: new Date(), + }); + + getGameBackupPreview(); + } + ); + + const removeDownloadCompleteListener = window.electron.onDownloadComplete( + objectId, + shop, + () => { + showSuccessToast("backup_restored"); + + setRestoringBackup(false); + getGameBackupPreview(); + } + ); + + return () => { + removeUploadCompleteListener(); + removeDownloadCompleteListener(); + }; + }, [objectId, shop, showSuccessToast, getGameBackupPreview]); + + const deleteGameArtifact = useCallback( + async (gameArtifactId: string) => { + return window.electron.deleteGameArtifact(gameArtifactId).then(() => { + getGameBackupPreview(); + return { ok: true }; + }); + }, + [getGameBackupPreview] + ); + + useEffect(() => { + getGameBackupPreview(); + + window.electron.checkGameCloudSyncSupport(objectId, shop).then((result) => { + setSupportsCloudSync(result); + }); + }, [objectId, shop, getGameBackupPreview]); + + useEffect(() => { + if (showCloudSyncModal) { + getGameBackupPreview(); + } + }, [getGameBackupPreview, showCloudSyncModal]); + + const backupState = useMemo(() => { + if (!backupPreview) return CloudSyncState.Unknown; + if (backupPreview.overall.changedGames.new) return CloudSyncState.New; + if (backupPreview.overall.changedGames.different) + return CloudSyncState.Different; + if (backupPreview.overall.changedGames.same) return CloudSyncState.Same; + + return CloudSyncState.Unknown; + }, [backupPreview]); + + return ( + + {children} + + ); +} diff --git a/src/renderer/src/context/game-details/game-details.context.tsx b/src/renderer/src/context/game-details/game-details.context.tsx index 120728b1..82984d9a 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -5,7 +5,6 @@ import { useEffect, useState, } from "react"; -import { useParams, useSearchParams } from "react-router-dom"; import { setHeaderTitle } from "@renderer/features"; import { getSteamLanguage } from "@renderer/helpers"; @@ -51,13 +50,17 @@ export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext; export interface GameDetailsContextProps { children: React.ReactNode; + objectId: string; + gameTitle: string; + shop: GameShop; } export function GameDetailsContextProvider({ children, + objectId, + gameTitle, + shop, }: GameDetailsContextProps) { - const { objectID, shop } = useParams(); - const [shopDetails, setShopDetails] = useState(null); const [game, setGame] = useState(null); const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false); @@ -72,10 +75,6 @@ export function GameDetailsContextProvider({ const [repacks, setRepacks] = useState([]); - const [searchParams] = useSearchParams(); - - const gameTitle = searchParams.get("title")!; - const { searchRepacks, isIndexingRepacks } = useContext(repacksContext); useEffect(() => { @@ -98,9 +97,9 @@ export function GameDetailsContextProvider({ const updateGame = useCallback(async () => { return window.electron - .getGameByObjectID(objectID!) + .getGameByObjectID(objectId!) .then((result) => setGame(result)); - }, [setGame, objectID]); + }, [setGame, objectId]); const isGameDownloading = lastPacket?.game.id === game?.id; @@ -111,7 +110,7 @@ export function GameDetailsContextProvider({ useEffect(() => { window.electron .getGameShopDetails( - objectID!, + objectId!, shop as GameShop, getSteamLanguage(i18n.language) ) @@ -130,12 +129,12 @@ export function GameDetailsContextProvider({ setIsLoading(false); }); - window.electron.getGameStats(objectID!, shop as GameShop).then((result) => { + window.electron.getGameStats(objectId!, shop as GameShop).then((result) => { setStats(result); }); updateGame(); - }, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]); + }, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]); useEffect(() => { setShopDetails(null); @@ -143,7 +142,7 @@ export function GameDetailsContextProvider({ setIsLoading(true); setisGameRunning(false); dispatch(setHeaderTitle(gameTitle)); - }, [objectID, gameTitle, dispatch]); + }, [objectId, gameTitle, dispatch]); useEffect(() => { const unsubscribe = window.electron.onGamesRunning((gamesIds) => { @@ -200,7 +199,7 @@ export function GameDetailsContextProvider({ gameTitle, isGameRunning, isLoading, - objectID, + objectID: objectId, gameColor, showGameOptionsModal, showRepacksModal, diff --git a/src/renderer/src/context/index.ts b/src/renderer/src/context/index.ts index 8d8b9223..948b90b2 100644 --- a/src/renderer/src/context/index.ts +++ b/src/renderer/src/context/index.ts @@ -2,3 +2,4 @@ export * from "./game-details/game-details.context"; export * from "./settings/settings.context"; export * from "./user-profile/user-profile.context"; export * from "./repacks/repacks.context"; +export * from "./cloud-sync/cloud-sync.context"; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 28c5caf7..6fab054a 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -26,6 +26,8 @@ import type { UserDetails, FriendRequestSync, DownloadSourceValidationResult, + GameArtifact, + LudusaviBackup, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -113,6 +115,37 @@ declare global { /* Hardware */ getDiskFreeSpace: (path: string) => Promise; + /* Cloud sync */ + uploadSaveGame: (objectId: string, shop: GameShop) => Promise; + downloadGameArtifact: ( + objectId: string, + shop: GameShop, + gameArtifactId: string + ) => Promise; + getGameArtifacts: ( + objectId: string, + shop: GameShop + ) => Promise; + getGameBackupPreview: ( + objectId: string, + shop: GameShop + ) => Promise; + checkGameCloudSyncSupport: ( + objectId: string, + shop: GameShop + ) => Promise; + deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>; + onDownloadComplete: ( + objectId: string, + shop: GameShop, + cb: () => void + ) => () => Electron.IpcRenderer; + onUploadComplete: ( + objectId: string, + shop: GameShop, + cb: () => void + ) => () => Electron.IpcRenderer; + /* Misc */ openExternal: (src: string) => Promise; getVersion: () => Promise; diff --git a/src/renderer/src/dexie.ts b/src/renderer/src/dexie.ts index 23f0bf83..75dc6079 100644 --- a/src/renderer/src/dexie.ts +++ b/src/renderer/src/dexie.ts @@ -1,13 +1,23 @@ +import { GameShop } from "@types"; import { Dexie } from "dexie"; +export interface GameBackup { + id?: number; + shop: GameShop; + objectId: string; + createdAt: Date; +} + export const db = new Dexie("Hydra"); -db.version(1).stores({ +db.version(3).stores({ repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`, downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`, + gameBackups: `++id, [shop+objectId], createdAt`, }); export const downloadSourcesTable = db.table("downloadSources"); export const repacksTable = db.table("repacks"); +export const gameBackupsTable = db.table("gameBackups"); db.open(); diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts new file mode 100644 index 00000000..bb3335fa --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.css.ts @@ -0,0 +1,26 @@ +import { style } from "@vanilla-extract/css"; + +import { SPACING_UNIT, vars } from "../../../theme.css"; + +export const artifacts = style({ + display: "flex", + gap: `${SPACING_UNIT}px`, + flexDirection: "column", + listStyle: "none", + margin: "0", + padding: "0", +}); + +export const artifactButton = style({ + display: "flex", + textAlign: "left", + flexDirection: "row", + alignItems: "center", + gap: `${SPACING_UNIT}px`, + color: vars.color.body, + padding: `${SPACING_UNIT * 2}px`, + backgroundColor: vars.color.darkBackground, + border: `1px solid ${vars.color.border}`, + borderRadius: "4px", + justifyContent: "space-between", +}); diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx new file mode 100644 index 00000000..fd38eb76 --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx @@ -0,0 +1,178 @@ +import { Button, Modal, ModalProps } from "@renderer/components"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; + +import * as styles from "./cloud-sync-modal.css"; +import { formatBytes } from "@shared"; +import { format } from "date-fns"; +import { + CheckCircleFillIcon, + ClockIcon, + DeviceDesktopIcon, + DownloadIcon, + SyncIcon, + TrashIcon, + UploadIcon, +} from "@primer/octicons-react"; +import { useToast } from "@renderer/hooks"; +import { GameBackup, gameBackupsTable } from "@renderer/dexie"; + +export interface CloudSyncModalProps + extends Omit {} + +export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { + const [deletingArtifact, setDeletingArtifact] = useState(false); + const [lastBackup, setLastBackup] = useState(null); + + const { + artifacts, + backupPreview, + uploadingBackup, + restoringBackup, + uploadSaveGame, + downloadGameArtifact, + deleteGameArtifact, + } = useContext(cloudSyncContext); + + const { objectID, shop, gameTitle } = useContext(gameDetailsContext); + + const { showSuccessToast, showErrorToast } = useToast(); + + const handleDeleteArtifactClick = async (gameArtifactId: string) => { + setDeletingArtifact(true); + + try { + await deleteGameArtifact(gameArtifactId); + + showSuccessToast("backup_successfully_deleted"); + } catch (err) { + showErrorToast("backup_deletion_failed"); + } finally { + setDeletingArtifact(false); + } + }; + + useEffect(() => { + gameBackupsTable + .where({ shop: shop, objectId: objectID }) + .last() + .then((lastBackup) => setLastBackup(lastBackup || null)); + }, [backupPreview, objectID, shop]); + + const backupStateLabel = useMemo(() => { + if (uploadingBackup) { + return ( + + + creating_backup + + ); + } + + if (restoringBackup) { + return ( + + + restoring_backup + + ); + } + + if (lastBackup) { + return ( +

+ + Ăšltimo backup em {format(lastBackup.createdAt, "dd/MM/yyyy HH:mm")} +

+ ); + } + + return "no_backups"; + }, [uploadingBackup, lastBackup, restoringBackup]); + + const disableActions = uploadingBackup || restoringBackup || deletingArtifact; + + return ( + +
+
+

{gameTitle}

+ {backupStateLabel} +
+ + +
+ +

backups

+ +
    + {artifacts.map((artifact) => ( +
  • +
    +
    +

    Backup do dia {format(artifact.createdAt, "dd/MM")}

    + {formatBytes(artifact.artifactLengthInBytes)} +
    + + + + {artifact.hostname} + + + + + {format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")} + +
    + +
    + + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/renderer/src/pages/game-details/game-details-content.tsx b/src/renderer/src/pages/game-details/game-details-content.tsx index 2ba19246..80974a14 100644 --- a/src/renderer/src/pages/game-details/game-details-content.tsx +++ b/src/renderer/src/pages/game-details/game-details-content.tsx @@ -9,8 +9,11 @@ import { Sidebar } from "./sidebar/sidebar"; import * as styles from "./game-details.css"; import { useTranslation } from "react-i18next"; -import { gameDetailsContext } from "@renderer/context"; +import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; import { steamUrlBuilder } from "@shared"; +import Lottie from "lottie-react"; + +import downloadingAnimation from "@renderer/assets/lottie/cloud.json"; const HERO_ANIMATION_THRESHOLD = 25; @@ -30,6 +33,9 @@ export function GameDetailsContent() { hasNSFWContentBlocked, } = useContext(gameDetailsContext); + const { supportsCloudSync, setShowCloudSyncModal } = + useContext(cloudSyncContext); + const [backdropOpactiy, setBackdropOpacity] = useState(1); const handleHeroLoad = async () => { @@ -102,6 +108,33 @@ export function GameDetailsContent() { className={styles.gameLogo} alt={game?.title} /> + + {supportsCloudSync && ( + + )} diff --git a/src/renderer/src/pages/game-details/game-details.css.ts b/src/renderer/src/pages/game-details/game-details.css.ts index 2de28f1f..228b2aeb 100644 --- a/src/renderer/src/pages/game-details/game-details.css.ts +++ b/src/renderer/src/pages/game-details/game-details.css.ts @@ -6,8 +6,8 @@ import { recipe } from "@vanilla-extract/recipes"; export const HERO_HEIGHT = 300; export const slideIn = keyframes({ - "0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)` }, - "100%": { transform: "translateY(0)" }, + "0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)`, opacity: "0px" }, + "100%": { transform: "translateY(0)", opacity: "1" }, }); export const wrapper = recipe({ @@ -49,6 +49,8 @@ export const heroContent = style({ height: "100%", width: "100%", display: "flex", + justifyContent: "space-between", + alignItems: "flex-end", }); export const heroLogoBackdrop = style({ @@ -200,3 +202,33 @@ globalStyle(`${description} img`, { globalStyle(`${description} a`, { color: vars.color.body, }); + +export const cloudSyncButton = style({ + padding: `${SPACING_UNIT * 1.5}px ${SPACING_UNIT * 2}px`, + backgroundColor: "rgba(0, 0, 0, 0.6)", + backdropFilter: "blur(20px)", + borderRadius: "8px", + transition: "all ease 0.2s", + cursor: "pointer", + minHeight: "40px", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: `${SPACING_UNIT}px`, + color: vars.color.muted, + fontSize: "14px", + border: `solid 1px ${vars.color.border}`, + boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.8)", + animation: `${slideIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`, + animationDuration: "0.3s", + ":active": { + opacity: "0.9", + }, + ":disabled": { + opacity: vars.opacity.disabled, + cursor: "not-allowed", + }, + ":hover": { + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, +}); diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index 54fe75ac..fbd59488 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -18,21 +18,25 @@ import { vars } from "@renderer/theme.css"; import { GameDetailsContent } from "./game-details-content"; import { + CloudSyncContextConsumer, + CloudSyncContextProvider, GameDetailsContextConsumer, GameDetailsContextProvider, } from "@renderer/context"; import { useDownload } from "@renderer/hooks"; import { GameOptionsModal, RepacksModal } from "./modals"; import { Downloader, getDownloadersForUri } from "@shared"; +import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal"; export function GameDetails() { const [randomGame, setRandomGame] = useState(null); const [randomizerLocked, setRandomizerLocked] = useState(false); - const { objectID } = useParams(); + const { objectID, shop } = useParams(); const [searchParams] = useSearchParams(); const fromRandomizer = searchParams.get("fromRandomizer"); + const gameTitle = searchParams.get("title"); const { startDownload } = useDownload(); @@ -74,7 +78,11 @@ export function GameDetails() { repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!; return ( - + {({ isLoading, @@ -115,64 +123,80 @@ export function GameDetails() { }; return ( - - {isLoading ? : } + + {({ showCloudSyncModal, setShowCloudSyncModal }) => ( + setShowCloudSyncModal(false)} + visible={showCloudSyncModal} + /> + )} + - setShowRepacksModal(false)} - /> + + {isLoading ? : } - setHasNSFWContentBlocked(false)} - clickOutsideToClose={false} - /> - - {game && ( - { - setShowGameOptionsModal(false); - }} + setShowRepacksModal(false)} /> - )} - {fromRandomizer && ( - - )} - + setHasNSFWContentBlocked(false)} + clickOutsideToClose={false} + /> + + {game && ( + { + setShowGameOptionsModal(false); + }} + /> + )} + + {fromRandomizer && ( + + )} + + ); }} diff --git a/src/types/index.ts b/src/types/index.ts index 9e6f7def..caf9bdd0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -266,5 +266,15 @@ export interface UserStats { friendsCount: number; } +export interface GameArtifact { + id: string; + artifactLengthInBytes: number; + createdAt: string; + updatedAt: string; + hostname: string; + downloadCount: number; +} + export * from "./steam.types"; export * from "./real-debrid.types"; +export * from "./ludusavi.types"; diff --git a/src/types/ludusavi.types.ts b/src/types/ludusavi.types.ts new file mode 100644 index 00000000..a2adebf9 --- /dev/null +++ b/src/types/ludusavi.types.ts @@ -0,0 +1,23 @@ +export interface LudusaviScanChange { + change: "New" | "Different" | "Removed" | "Same" | "Unknown"; + decision: "Processed" | "Cancelled" | "Ignore"; +} + +export interface LudusaviBackup { + overall: { + totalGames: number; + totalBytes: number; + processedGames: number; + processedBytes: number; + changedGames: { + new: number; + different: number; + same: number; + }; + }; + games: Record; +} + +export interface LudusaviFindResult { + games: Record; +} diff --git a/torrent-client/torrent_downloader.py b/torrent-client/torrent_downloader.py index d59cd28b..b5280260 100644 --- a/torrent-client/torrent_downloader.py +++ b/torrent-client/torrent_downloader.py @@ -144,8 +144,8 @@ class TorrentDownloader: status = torrent_handle.status() info = torrent_handle.get_torrent_info() - - return { + + response = { 'folderName': info.name() if info else "", 'fileSize': info.total_size() if info else 0, 'gameId': self.downloading_game_id, @@ -156,3 +156,10 @@ class TorrentDownloader: 'status': status.state, 'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download, } + + if status.progress == 1: + torrent_handle.pause() + self.session.remove_torrent(torrent_handle) + self.downloading_game_id = -1 + + return response diff --git a/yarn.lock b/yarn.lock index 14651b4b..75b9a7d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1923,6 +1923,20 @@ dependencies: "@types/node" "*" +"@types/adm-zip@^0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@types/adm-zip/-/adm-zip-0.5.5.tgz#4588042726aa5f351d7ea88232e4a952f60e7c1a" + integrity sha512-YCGstVMjc4LTY5uK9/obvxBya93axZOVOyf2GSUulADzmLhYE45u2nAssCs/fWBs1Ifq5Vat75JTPwd5XZoPJw== + dependencies: + "@types/node" "*" + +"@types/archiver@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-6.0.2.tgz#0daf8c83359cbde69de1e4b33dcade6a48a929e2" + integrity sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw== + dependencies: + "@types/readdir-glob" "*" + "@types/auto-launch@^5.0.5": version "5.0.5" resolved "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz" @@ -2066,6 +2080,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/folder-hash@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/folder-hash/-/folder-hash-4.0.4.tgz#c3262d58a01b756ee2aae3694707fad1ef676a9f" + integrity sha512-c+PwHm51Dw3fXM8SDK+93PO3oXdk4XNouCCvV67lj4aijRkZz5g67myk+9wqWWnyv3go6q96hT6ywcd3XtoZiQ== + "@types/fs-extra@9.0.13", "@types/fs-extra@^9.0.11": version "9.0.13" resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz" @@ -2292,6 +2311,13 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/readdir-glob@*": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@types/readdir-glob/-/readdir-glob-1.1.5.tgz#21a4a98898fc606cb568ad815f2a0eedc24d412a" + integrity sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg== + dependencies: + "@types/node" "*" + "@types/responselike@^1.0.0": version "1.0.3" resolved "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz" @@ -2583,6 +2609,11 @@ acorn@^8.8.1, acorn@^8.8.2: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c" integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== +adm-zip@^0.5.16: + version "0.5.16" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909" + integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ== + agent-base@6: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -2712,6 +2743,32 @@ applescript@^1.0.0: resolved "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz" integrity sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ== +archiver-utils@^5.0.0, archiver-utils@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d" + integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== + dependencies: + glob "^10.0.0" + graceful-fs "^4.2.0" + is-stream "^2.0.1" + lazystream "^1.0.0" + lodash "^4.17.15" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + +archiver@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61" + integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== + dependencies: + archiver-utils "^5.0.2" + async "^3.2.4" + buffer-crc32 "^1.0.0" + readable-stream "^4.0.0" + readdir-glob "^1.1.2" + tar-stream "^3.0.0" + zip-stream "^6.0.1" + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -2851,6 +2908,11 @@ async@^3.2.3: resolved "https://registry.npmjs.org/async/-/async-3.2.5.tgz" integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg== +async@^3.2.4: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" @@ -2900,11 +2962,21 @@ axobject-query@^3.2.1: dependencies: dequal "^2.0.3" +b4a@^1.6.4: + version "1.6.6" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.6.tgz#a4cc349a3851987c3c4ac2d7785c18744f6da9ba" + integrity sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bare-events@^2.2.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.5.0.tgz#305b511e262ffd8b9d5616b056464f8e1b3329cc" + integrity sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A== + base64-arraybuffer@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz" @@ -3017,6 +3089,11 @@ browserslist@^4.22.2: node-releases "^2.0.14" update-browserslist-db "^1.0.13" +buffer-crc32@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz#a10993b9055081d55304bd9feb4a072de179f405" + integrity sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w== + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz" @@ -3337,6 +3414,17 @@ compare-version@^0.1.2: resolved "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz" integrity sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A== +compress-commons@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-6.0.2.tgz#26d31251a66b9d6ba23a84064ecd3a6a71d2609e" + integrity sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg== + dependencies: + crc-32 "^1.2.0" + crc32-stream "^6.0.0" + is-stream "^2.0.1" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" @@ -3389,6 +3477,11 @@ core-util-is@1.0.2: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cosmiconfig-typescript-loader@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.0.0.tgz" @@ -3416,6 +3509,19 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" +crc-32@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + +crc32-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-6.0.0.tgz#8529a3868f8b27abb915f6c3617c0fadedbf9430" + integrity sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g== + dependencies: + crc-32 "^1.2.0" + readable-stream "^4.0.0" + crc@^3.8.0: version "3.8.0" resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6" @@ -4239,6 +4345,11 @@ event-target-shim@^5.0.0: resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@^8.0.1: version "8.0.1" resolved "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz" @@ -4285,6 +4396,11 @@ fast-diff@^1.1.2: resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@^3.2.9: version "3.3.2" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz" @@ -4605,6 +4721,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@^10.0.0: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^10.3.10: version "10.3.15" resolved "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz" @@ -4973,7 +5101,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -5170,6 +5298,11 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: dependencies: call-bind "^1.0.7" +is-stream@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + is-stream@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz" @@ -5228,6 +5361,11 @@ isarray@^2.0.5: resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isbinaryfile@^4.0.8: version "4.0.10" resolved "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz" @@ -5263,6 +5401,15 @@ jackspeak@^2.3.6: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jake@^10.8.5: version "10.9.1" resolved "https://registry.npmjs.org/jake/-/jake-10.9.1.tgz" @@ -5475,6 +5622,13 @@ lazy-val@^1.0.4, lazy-val@^1.0.5: resolved "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz" integrity sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q== +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + levn@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" @@ -5763,7 +5917,7 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1, minimatch@^5.1.1: +minimatch@^5.0.1, minimatch@^5.1.0, minimatch@^5.1.1: version "5.1.6" resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz" integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== @@ -5784,6 +5938,13 @@ minimatch@^9.0.1: dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" @@ -5811,6 +5972,11 @@ minipass@^5.0.0: resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz" integrity sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA== +minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + minizlib@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz" @@ -6116,6 +6282,11 @@ p-locate@^6.0.0: dependencies: p-limit "^4.0.0" +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" @@ -6199,7 +6370,7 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.11.0, path-scurry@^1.6.1: +path-scurry@^1.11.0, path-scurry@^1.11.1, path-scurry@^1.6.1: version "1.11.1" resolved "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== @@ -6409,6 +6580,16 @@ prettier@^3.2.4: resolved "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz" integrity sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A== +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + progress@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz" @@ -6469,6 +6650,11 @@ queue-microtask@^1.2.2, queue-microtask@^1.2.3: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue-tick@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142" + integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== + quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" @@ -6562,6 +6748,19 @@ read-config-file@6.3.2: json5 "^2.2.0" lazy-val "^1.0.4" +readable-stream@^2.0.5: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" @@ -6571,6 +6770,17 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^4.0.0: + version "4.5.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" + integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + readable-web-to-node-stream@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz" @@ -6578,6 +6788,13 @@ readable-web-to-node-stream@^3.0.2: dependencies: readable-stream "^3.6.0" +readdir-glob@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.3.tgz#c3d831f51f5e7bfa62fa2ffbe4b508c640f09584" + integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== + dependencies: + minimatch "^5.1.0" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -6795,6 +7012,11 @@ safe-buffer@^5.0.1, safe-buffer@~5.2.0: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + safe-regex-test@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz" @@ -7012,6 +7234,17 @@ stat-mode@^1.0.0: resolved "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz" integrity sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg== +streamx@^2.15.0: + version "2.20.1" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.20.1.tgz#471c4f8b860f7b696feb83d5b125caab2fdbb93c" + integrity sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA== + dependencies: + fast-fifo "^1.3.2" + queue-tick "^1.0.1" + text-decoder "^1.1.0" + optionalDependencies: + bare-events "^2.2.0" + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -7085,13 +7318,20 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== dependencies: safe-buffer "~5.2.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -7206,6 +7446,15 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" +tar-stream@^3.0.0: + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + tar@^6.1.12: version "6.2.1" resolved "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz" @@ -7231,6 +7480,13 @@ temp-file@^3.4.0: async-exit-hook "^2.0.1" fs-extra "^10.0.0" +text-decoder@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.0.tgz#85f19d4d5088e0b45cd841bdfaeac458dbffeefc" + integrity sha512-n1yg1mOj9DNpk3NeZOx7T6jchTbyJS3i3cucbNN6FcdPriMZx7NsgrGpWWdWZZGxD7ES1XB+3uoqHMgOKaN+fg== + dependencies: + b4a "^1.6.4" + text-extensions@^2.0.0: version "2.4.0" resolved "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz" @@ -7581,7 +7837,7 @@ utf8-byte-length@^1.0.1: resolved "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz" integrity sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA== -util-deprecate@^1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -7914,6 +8170,15 @@ yup@^1.4.0: toposort "^2.0.2" type-fest "^2.19.0" +zip-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-6.0.1.tgz#e141b930ed60ccaf5d7fa9c8260e0d1748a2bbfb" + integrity sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA== + dependencies: + archiver-utils "^5.0.0" + compress-commons "^6.0.2" + readable-stream "^4.0.0" + zod@^3.23.8: version "3.23.8" resolved "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz"