diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1845ffd5..c54c431c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,3 +99,4 @@ jobs: dist/*.yml dist/*.blockmap dist/*.pacman + dist/*.AppImage diff --git a/.gitignore b/.gitignore index eb0592e9..ac8094b0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ out *.log* .env .vite -ludusavi/ +ludusavi/** +!ludusavi/config.yaml hydra-python-rpc/ .python-version diff --git a/binaries/aria2c b/binaries/aria2c new file mode 100755 index 00000000..129ccd4b Binary files /dev/null and b/binaries/aria2c differ diff --git a/binaries/aria2c.exe b/binaries/aria2c.exe new file mode 100755 index 00000000..cf6411fe Binary files /dev/null and b/binaries/aria2c.exe differ diff --git a/electron-builder.yml b/electron-builder.yml index dd10e81a..50fe8139 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,7 +3,6 @@ productName: Hydra directories: buildResources: build extraResources: - - aria2 - ludusavi - hydra-python-rpc - seeds @@ -21,6 +20,7 @@ asarUnpack: win: executableName: Hydra extraResources: + - from: binaries/aria2c.exe - from: binaries/7z.exe - from: binaries/7z.dll target: @@ -51,6 +51,7 @@ dmg: linux: extraResources: - from: binaries/7zzs + - from: binaries/aria2c target: - AppImage - snap diff --git a/ludusavi/config.yaml b/ludusavi/config.yaml new file mode 100644 index 00000000..9b718c85 --- /dev/null +++ b/ludusavi/config.yaml @@ -0,0 +1,6 @@ +manifest: + enable: false + secondary: + - url: https://cdn.losbroxas.org/manifest.yaml + enable: true +customGames: [] diff --git a/package.json b/package.json index 4d7b5cd4..18d28cd0 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "jsonwebtoken": "^9.0.2", "lodash-es": "^4.17.21", "parse-torrent": "^11.0.17", - "piscina": "^4.7.0", "rc-virtual-list": "^3.16.1", "react-hook-form": "^7.53.0", "react-i18next": "^14.1.0", diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs index 8deddeaa..7a7e3e43 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -3,7 +3,6 @@ const tar = require("tar"); const util = require("node:util"); const fs = require("node:fs"); const path = require("node:path"); -const { spawnSync } = require("node:child_process"); const exec = util.promisify(require("node:child_process").exec); @@ -15,8 +14,18 @@ const fileName = { darwin: `ludusavi-v${ludusaviVersion}-mac.tar.gz`, }; +const ludusaviBinaryName = { + win32: "ludusavi.exe", + linux: "ludusavi", + darwin: "ludusavi", +}; + const downloadLudusavi = async () => { - if (fs.existsSync("ludusavi")) { + if ( + fs.existsSync( + path.join(process.cwd(), "ludusavi", ludusaviBinaryName[process.platform]) + ) + ) { console.log("Ludusavi already exists, skipping download..."); return; } @@ -58,79 +67,4 @@ const downloadLudusavi = async () => { }); }; -const downloadAria2WindowsAndLinux = async () => { - const file = - process.platform === "win32" - ? "aria2-1.37.0-win-64bit-build1.zip" - : "aria2-1.37.0-1-x86_64.pkg.tar.zst"; - - const downloadUrl = - process.platform === "win32" - ? `https://github.com/aria2/aria2/releases/download/release-1.37.0/${file}` - : "https://archlinux.org/packages/extra/x86_64/aria2/download/"; - - 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...`); - - if (process.platform === "win32") { - await exec(`npx extract-zip ${file}`); - console.log("Extracted. Renaming folder..."); - - fs.mkdirSync("aria2"); - fs.copyFileSync( - path.join(file.replace(".zip", ""), "aria2c.exe"), - "aria2/aria2c.exe" - ); - fs.rmSync(file.replace(".zip", ""), { recursive: true }); - } else { - await exec(`tar --zstd -xvf ${file} usr/bin/aria2c`); - console.log("Extracted. Copying binary file..."); - fs.mkdirSync("aria2"); - fs.copyFileSync("usr/bin/aria2c", "aria2/aria2c"); - fs.rmSync("usr", { recursive: true }); - } - - console.log(`Extracted ${file}, removing compressed downloaded file...`); - fs.rmSync(file); - }); -}; - -const copyAria2Macos = async () => { - console.log("Checking if aria2 is installed..."); - - const isAria2Installed = spawnSync("which", ["aria2c"]).status; - - if (isAria2Installed != 0) { - console.log("Please install aria2"); - console.log("brew install aria2"); - return; - } - - console.log("Copying aria2 binary..."); - fs.mkdirSync("aria2"); - await exec(`cp $(which aria2c) aria2/aria2c`); -}; - -const copyAria2 = () => { - const aria2Path = - process.platform === "win32" ? "aria2/aria2c.exe" : "aria2/aria2c"; - - if (fs.existsSync(aria2Path)) { - console.log("Aria2 already exists, skipping download..."); - return; - } - if (process.platform == "darwin") { - copyAria2Macos(); - } else { - downloadAria2WindowsAndLinux(); - } -}; - -copyAria2(); downloadLudusavi(); diff --git a/scripts/upload-build.cjs b/scripts/upload-build.cjs index f950908f..fe475163 100644 --- a/scripts/upload-build.cjs +++ b/scripts/upload-build.cjs @@ -20,7 +20,7 @@ const s3 = new S3Client({ const dist = path.resolve(__dirname, "..", "dist"); -const extensionsToUpload = [".deb", ".exe", ".pacman"]; +const extensionsToUpload = [".deb", ".exe", ".pacman", ".AppImage"]; fs.readdir(dist, async (err, files) => { if (err) throw err; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 5b5579ae..d226e8e1 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -199,7 +199,10 @@ "game_removed_from_favorites": "Game removed from favorites", "game_added_to_favorites": "Game added to favorites", "automatically_extract_downloaded_files": "Automatically extract downloaded files", - "create_start_menu_shortcut": "Create Start Menu shortcut" + "create_start_menu_shortcut": "Create Start Menu shortcut", + "invalid_wine_prefix_path": "Invalid Wine prefix path", + "invalid_wine_prefix_path_description": "The path to the Wine prefix is invalid. Please check the path and try again.", + "missing_wine_prefix": "Wine prefix is required to create a backup on Linux" }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index f960b70e..82dad2ba 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -198,7 +198,10 @@ "download_error_not_cached_on_real_debrid": "Esta descarga no está disponible en Real-Debrid y el estado de descarga del sondeo de Real-Debrid aún no está disponible.", "download_error_not_cached_on_torbox": "Esta descarga no está disponible en TorBox y el estado de descarga del sondeo aún no está disponible.", "game_added_to_favorites": "Juego añadido a favoritos", - "game_removed_from_favorites": "Juego removido de favoritos" + "game_removed_from_favorites": "Juego removido de favoritos", + "invalid_wine_prefix_path": "Ruta de prefixo Wine inválida", + "invalid_wine_prefix_path_description": "La ruta al prefixo Wine es inválida. Por favor, verifica la ruta y vuelve a intentarlo.", + "missing_wine_prefix": "" }, "activation": { "title": "Activar Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index bf9c6e46..56dc8d44 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -188,7 +188,9 @@ "game_removed_from_favorites": "Jogo removido dos favoritos", "game_added_to_favorites": "Jogo adicionado aos favoritos", "automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados", - "create_start_menu_shortcut": "Criar atalho no Menu Iniciar" + "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", + "invalid_wine_prefix_path": "Caminho do prefixo Wine inválido", + "invalid_wine_prefix_path_description": "O caminho para o prefixo Wine é inválido. Por favor, verifique o caminho e tente novamente." }, "activation": { "title": "Ativação", diff --git a/src/main/constants.ts b/src/main/constants.ts index 8bc2332a..0f892af7 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -2,8 +2,6 @@ import { app } from "electron"; import path from "node:path"; import { SystemPath } from "./services/system-path"; -export const LUDUSAVI_MANIFEST_URL = "https://cdn.losbroxas.org/manifest.yaml"; - export const defaultDownloadsPath = SystemPath.getPath("downloads"); export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging"); @@ -16,6 +14,8 @@ export const windowsStartMenuPath = path.join( "Programs" ); +export const publicProfilePath = "C:/Users/Public"; + export const levelDatabasePath = path.join( SystemPath.getPath("userData"), `hydra-db${isStaging ? "-staging" : ""}` diff --git a/src/main/events/cloud-save/download-game-artifact.ts b/src/main/events/cloud-save/download-game-artifact.ts index 364e253a..99edf3df 100644 --- a/src/main/events/cloud-save/download-game-artifact.ts +++ b/src/main/events/cloud-save/download-game-artifact.ts @@ -1,74 +1,93 @@ -import { HydraApi, logger, Ludusavi, WindowManager } from "@main/services"; +import { CloudSync, HydraApi, logger, WindowManager } from "@main/services"; import fs from "node:fs"; import * as tar from "tar"; import { registerEvent } from "../register-event"; import axios from "axios"; -import os from "node:os"; import path from "node:path"; -import { backupsPath } from "@main/constants"; -import type { GameShop } from "@types"; +import { backupsPath, publicProfilePath } from "@main/constants"; +import type { GameShop, LudusaviBackupMapping } from "@types"; import YAML from "yaml"; -import { normalizePath } from "@main/helpers"; +import { addTrailingSlash, normalizePath } from "@main/helpers"; import { SystemPath } from "@main/services/system-path"; +import { gamesSublevel, levelKeys } from "@main/level"; -export interface LudusaviBackup { - files: { - [key: string]: { - hash: string; - size: number; - }; - }; -} +export const transformLudusaviBackupPathIntoWindowsPath = ( + backupPath: string, + winePrefixPath?: string | null +) => { + return backupPath + .replace(winePrefixPath ? addTrailingSlash(winePrefixPath) : "", "") + .replace("drive_c", "C:"); +}; -const replaceLudusaviBackupWithCurrentUser = ( +export const addWinePrefixToWindowsPath = ( + windowsPath: string, + winePrefixPath?: string | null +) => { + if (!winePrefixPath) { + return windowsPath; + } + + return path.join(winePrefixPath, windowsPath.replace("C:", "drive_c")); +}; + +const restoreLudusaviBackup = ( backupPath: string, title: string, - homeDir: string + homeDir: string, + winePrefixPath?: string | null, + artifactWinePrefixPath?: string | null ) => { const gameBackupPath = path.join(backupPath, title); const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml"); const data = fs.readFileSync(mappingYamlPath, "utf8"); const manifest = YAML.parse(data) as { - backups: LudusaviBackup[]; + backups: LudusaviBackupMapping[]; drives: Record; }; - const currentHomeDir = normalizePath(SystemPath.getPath("home")); + const userProfilePath = + CloudSync.getWindowsLikeUserProfilePath(winePrefixPath); - /* Renaming logic */ - if (os.platform() === "win32") { - const mappedHomeDir = path.join( - gameBackupPath, - path.join("drive-C", homeDir.replace("C:", "")) - ); - - if (fs.existsSync(mappedHomeDir)) { - fs.renameSync( - mappedHomeDir, - path.join(gameBackupPath, "drive-C", currentHomeDir.replace("C:", "")) + manifest.backups.forEach((backup) => { + Object.keys(backup.files).forEach((key) => { + const sourcePathWithDrives = Object.entries(manifest.drives).reduce( + (prev, [driveKey, driveValue]) => { + return prev.replace(driveValue, driveKey); + }, + key ); - } - } - const backups = manifest.backups.map((backup: LudusaviBackup) => { - const files = Object.entries(backup.files).reduce((prev, [key, value]) => { - const updatedKey = key.replace(homeDir, currentHomeDir); + const sourcePath = path.join(gameBackupPath, sourcePathWithDrives); - return { - ...prev, - [updatedKey]: value, - }; - }, {}); + logger.info(`Source path: ${sourcePath}`); - return { - ...backup, - files, - }; + const destinationPath = transformLudusaviBackupPathIntoWindowsPath( + key, + artifactWinePrefixPath + ) + .replace( + homeDir, + addWinePrefixToWindowsPath(userProfilePath, winePrefixPath) + ) + .replace( + publicProfilePath, + addWinePrefixToWindowsPath(publicProfilePath, winePrefixPath) + ); + + logger.info(`Moving ${sourcePath} to ${destinationPath}`); + + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + + if (fs.existsSync(destinationPath)) { + fs.unlinkSync(destinationPath); + } + + fs.renameSync(sourcePath, destinationPath); + }); }); - - fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups })); }; const downloadGameArtifact = async ( @@ -78,10 +97,18 @@ const downloadGameArtifact = async ( gameArtifactId: string ) => { try { - const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{ + const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); + + const { + downloadUrl, + objectKey, + homeDir, + winePrefixPath: artifactWinePrefixPath, + } = await HydraApi.post<{ downloadUrl: string; objectKey: string; homeDir: string; + winePrefixPath: string | null; }>(`/profile/games/artifacts/${gameArtifactId}/download`); const zipLocation = path.join(SystemPath.getPath("userData"), objectKey); @@ -109,34 +136,34 @@ const downloadGameArtifact = async ( response.data.pipe(writer); writer.on("error", (err) => { - logger.error("Failed to write zip", err); + logger.error("Failed to write tar file", err); throw err; }); fs.mkdirSync(backupPath, { recursive: true }); - writer.on("close", () => { - tar - .x({ - file: zipLocation, - cwd: backupPath, - }) - .then(async () => { - replaceLudusaviBackupWithCurrentUser( - backupPath, - objectId, - normalizePath(homeDir) - ); + writer.on("close", async () => { + await tar.x({ + file: zipLocation, + cwd: backupPath, + }); - Ludusavi.restoreBackup(backupPath).then(() => { - WindowManager.mainWindow?.webContents.send( - `on-backup-download-complete-${objectId}-${shop}`, - true - ); - }); - }); + restoreLudusaviBackup( + backupPath, + objectId, + normalizePath(homeDir), + game?.winePrefixPath, + artifactWinePrefixPath + ); + + WindowManager.mainWindow?.webContents.send( + `on-backup-download-complete-${objectId}-${shop}`, + true + ); }); } catch (err) { + logger.error("Failed to download game artifact", err); + WindowManager.mainWindow?.webContents.send( `on-backup-download-complete-${objectId}-${shop}`, false diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 171d9891..acc589f9 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -34,6 +34,7 @@ import "./library/remove-game-from-library"; import "./library/select-game-wine-prefix"; import "./library/reset-game-achievements"; import "./library/toggle-automatic-cloud-sync"; +import "./library/get-default-wine-prefix-selection-path"; import "./misc/open-checkout"; import "./misc/open-external"; import "./misc/show-open-dialog"; diff --git a/src/main/events/library/get-default-wine-prefix-selection-path.ts b/src/main/events/library/get-default-wine-prefix-selection-path.ts new file mode 100644 index 00000000..94f57d38 --- /dev/null +++ b/src/main/events/library/get-default-wine-prefix-selection-path.ts @@ -0,0 +1,34 @@ +import { logger, SystemPath } from "@main/services"; +import fs from "node:fs"; +import path from "node:path"; +import { registerEvent } from "../register-event"; + +const getDefaultWinePrefixSelectionPath = async ( + _event: Electron.IpcMainInvokeEvent +) => { + try { + const steamWinePrefixes = path.join( + SystemPath.getPath("home"), + ".local", + "share", + "Steam", + "steamapps", + "compatdata" + ); + + if (fs.existsSync(steamWinePrefixes)) { + return fs.promises.realpath(steamWinePrefixes); + } + + return null; + } catch (err) { + logger.error("Failed to get default wine prefix selection path", err); + + return null; + } +}; + +registerEvent( + "getDefaultWinePrefixSelectionPath", + getDefaultWinePrefixSelectionPath +); diff --git a/src/main/events/library/select-game-wine-prefix.ts b/src/main/events/library/select-game-wine-prefix.ts index c085dbad..a0452302 100644 --- a/src/main/events/library/select-game-wine-prefix.ts +++ b/src/main/events/library/select-game-wine-prefix.ts @@ -1,5 +1,7 @@ import { registerEvent } from "../register-event"; +import fs from "node:fs"; import { levelKeys, gamesSublevel } from "@main/level"; +import { Wine } from "@main/services"; import type { GameShop } from "@types"; const selectGameWinePrefix = async ( @@ -14,9 +16,24 @@ const selectGameWinePrefix = async ( if (!game) return; + if (!winePrefixPath) { + await gamesSublevel.put(gameKey, { + ...game, + winePrefixPath: null, + }); + + return; + } + + const realWinePrefixPath = await fs.promises.realpath(winePrefixPath); + + if (!Wine.validatePrefix(realWinePrefixPath)) { + throw new Error("Invalid wine prefix path"); + } + await gamesSublevel.put(gameKey, { ...game, - winePrefixPath: winePrefixPath, + winePrefixPath: realWinePrefixPath, }); }; diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 163fb23a..2da49a1c 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -32,3 +32,8 @@ export const isPortableVersion = () => { export const normalizePath = (str: string) => path.posix.normalize(str).replace(/\\/g, "/"); + +export const addTrailingSlash = (str: string) => + str.endsWith("/") ? str : `${str}/`; + +export * from "./reg-parser"; diff --git a/src/main/helpers/reg-parser.ts b/src/main/helpers/reg-parser.ts new file mode 100644 index 00000000..6c08b87e --- /dev/null +++ b/src/main/helpers/reg-parser.ts @@ -0,0 +1,58 @@ +type RegValue = string | number | null; + +interface RegEntry { + path: string; + timestamp?: string; + values: Record; +} + +export function parseRegFile(content: string): RegEntry[] { + const lines = content.split(/\r?\n/); + const entries: RegEntry[] = []; + + let currentPath: string | null = null; + let currentEntry: RegEntry | null = null; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith(";") || line.startsWith(";;")) continue; + + if (line.startsWith("#")) { + const match = line.match(/^#time=(\w+)/); + if (match && currentEntry) { + currentEntry.timestamp = match[1]; + } + continue; + } + + if (line.startsWith("[")) { + const match = line.match(/^\[(.+?)\](?:\s+\d+)?/); + if (match) { + if (currentEntry) entries.push(currentEntry); + currentPath = match[1]; + currentEntry = { path: currentPath, values: {} }; + } + } else if (currentEntry) { + const kvMatch = line.match(/^"?(.*?)"?=(.*)$/); + if (kvMatch) { + const [, key, rawValue] = kvMatch; + let value: RegValue; + + if (rawValue === '""') { + value = ""; + } else if (rawValue.startsWith("dword:")) { + value = parseInt(rawValue.slice(6), 16); + } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) { + value = rawValue.slice(1, -1); + } else { + value = rawValue; + } + + currentEntry.values[key || "@"] = value; + } + } + } + + if (currentEntry) entries.push(currentEntry); + return entries; +} diff --git a/src/main/index.ts b/src/main/index.ts index ebe59b36..3b223299 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,7 +23,9 @@ autoUpdater.logger = logger; const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) app.quit(); -app.commandLine.appendSwitch("--no-sandbox"); +if (process.platform !== "linux") { + app.commandLine.appendSwitch("--no-sandbox"); +} i18n.init({ resources, diff --git a/src/main/main.ts b/src/main/main.ts index 0669d6f2..b0e142fc 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,10 +11,10 @@ import { RealDebridClient, Aria2, DownloadManager, - Ludusavi, HydraApi, uploadGamesBatch, startMainLoop, + Ludusavi, } from "@main/services"; export const loadState = async () => { @@ -39,7 +39,7 @@ export const loadState = async () => { TorBoxClient.authorize(userPreferences.torBoxApiToken); } - Ludusavi.addManifestToLudusaviConfig(); + Ludusavi.copyConfigFileToUserData(); await HydraApi.setupApi().then(() => { uploadGamesBatch(); diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts index e0001b82..c6b97b9f 100644 --- a/src/main/services/aria2.ts +++ b/src/main/services/aria2.ts @@ -7,8 +7,8 @@ export class Aria2 { public static spawn() { const binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "aria2", "aria2c") - : path.join(__dirname, "..", "..", "aria2", "aria2c"); + ? path.join(process.resourcesPath, "aria2c") + : path.join(__dirname, "..", "..", "binaries", "aria2c"); this.process = cp.spawn( binaryPath, diff --git a/src/main/services/cloud-sync.ts b/src/main/services/cloud-sync.ts index 77b4ac65..6da24ce1 100644 --- a/src/main/services/cloud-sync.ts +++ b/src/main/services/cloud-sync.ts @@ -7,7 +7,7 @@ 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 { normalizePath, parseRegFile } from "@main/helpers"; import { logger } from "./logger"; import { WindowManager } from "./window-manager"; import axios from "axios"; @@ -17,6 +17,39 @@ import i18next, { t } from "i18next"; import { SystemPath } from "./system-path"; export class CloudSync { + public static getWindowsLikeUserProfilePath(winePrefixPath?: string | null) { + if (process.platform === "linux") { + if (!winePrefixPath) { + throw new Error("Wine prefix path is required"); + } + + const userReg = fs.readFileSync( + path.join(winePrefixPath, "user.reg"), + "utf8" + ); + + const entries = parseRegFile(userReg); + const volatileEnvironment = entries.find( + (entry) => entry.path === "Volatile Environment" + ); + + if (!volatileEnvironment) { + throw new Error("Volatile environment not found in user.reg"); + } + + const { values } = volatileEnvironment; + const userProfile = String(values["USERPROFILE"]); + + if (userProfile) { + return normalizePath(userProfile); + } else { + throw new Error("User profile not found in user.reg"); + } + } + + return normalizePath(SystemPath.getPath("home")); + } + public static getBackupLabel(automatic: boolean) { const language = i18next.language; @@ -102,9 +135,12 @@ export class CloudSync { shop, objectId, hostname: os.hostname(), - homeDir: normalizePath(SystemPath.getPath("home")), + winePrefixPath: game?.winePrefixPath + ? fs.realpathSync(game.winePrefixPath) + : null, + homeDir: this.getWindowsLikeUserProfilePath(game?.winePrefixPath ?? null), downloadOptionTitle, - platform: os.platform(), + platform: process.platform, label, }); diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 3d1ab69e..11d97810 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -15,3 +15,4 @@ export * from "./aria2"; export * from "./ws"; export * from "./system-path"; export * from "./library-sync"; +export * from "./wine"; diff --git a/src/main/services/ludusavi.ts b/src/main/services/ludusavi.ts index 81dae6bd..0f060ccb 100644 --- a/src/main/services/ludusavi.ts +++ b/src/main/services/ludusavi.ts @@ -1,70 +1,89 @@ import type { GameShop, LudusaviBackup, LudusaviConfig } from "@types"; -import Piscina from "piscina"; import { app } from "electron"; import fs from "node:fs"; import path from "node:path"; import YAML from "yaml"; - -import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath"; -import { LUDUSAVI_MANIFEST_URL } from "@main/constants"; +import cp from "node:child_process"; import { SystemPath } from "./system-path"; export class Ludusavi { - private static ludusaviPath = path.join( - SystemPath.getPath("appData"), + private static ludusaviPath = app.isPackaged + ? path.join(process.resourcesPath, "ludusavi") + : path.join(__dirname, "..", "..", "ludusavi"); + + private static binaryPath = path.join(this.ludusaviPath, "ludusavi"); + private static configPath = path.join( + SystemPath.getPath("userData"), "ludusavi" ); - private static ludusaviConfigPath = path.join( - this.ludusaviPath, - "config.yaml" - ); - private static binaryPath = app.isPackaged - ? path.join(process.resourcesPath, "ludusavi", "ludusavi") - : path.join(__dirname, "..", "..", "ludusavi", "ludusavi"); - - private static worker = new Piscina({ - filename: ludusaviWorkerPath, - workerData: { - binaryPath: this.binaryPath, - }, - maxThreads: 1, - }); - - static async getConfig() { - if (!fs.existsSync(this.ludusaviConfigPath)) { - await this.worker.run(undefined, { name: "generateConfig" }); - } + public static async getConfig() { const config = YAML.parse( - fs.readFileSync(this.ludusaviConfigPath, "utf-8") + fs.readFileSync(path.join(this.ludusaviPath, "config.yaml"), "utf-8") ) as LudusaviConfig; return config; } - static async backupGame( - _shop: GameShop, - objectId: string, - backupPath: string, - winePrefix?: string | null - ): Promise { - return this.worker.run( - { title: objectId, backupPath, winePrefix }, - { name: "backupGame" } - ); + public static async copyConfigFileToUserData() { + if (!fs.existsSync(this.configPath)) { + fs.mkdirSync(this.configPath, { recursive: true }); + fs.cpSync( + path.join(this.ludusaviPath, "config.yaml"), + path.join(this.configPath, "config.yaml") + ); + } } - static async getBackupPreview( + public static async backupGame( + _shop: GameShop, + objectId: string, + backupPath?: string | null, + winePrefix?: string | null, + preview?: boolean + ): Promise { + return new Promise((resolve, reject) => { + const args = [ + "--config", + this.configPath, + "backup", + objectId, + "--api", + "--force", + ]; + + if (preview) args.push("--preview"); + if (backupPath) args.push("--path", backupPath); + if (winePrefix) args.push("--wine-prefix", winePrefix); + + cp.execFile( + this.binaryPath, + args, + (err: cp.ExecFileException | null, stdout: string) => { + if (err) { + return reject(err); + } + + return resolve(JSON.parse(stdout) as LudusaviBackup); + } + ); + }); + } + + public static async getBackupPreview( _shop: GameShop, objectId: string, winePrefix?: string | null ): Promise { const config = await this.getConfig(); - const backupData = await this.worker.run( - { title: objectId, winePrefix, preview: true }, - { name: "backupGame" } + const backupData = await this.backupGame( + _shop, + objectId, + null, + winePrefix, + true ); const customGame = config.customGames.find( @@ -77,19 +96,6 @@ export class Ludusavi { }; } - static async restoreBackup(backupPath: string) { - return this.worker.run(backupPath, { name: "restoreBackup" }); - } - - static async addManifestToLudusaviConfig() { - const config = await this.getConfig(); - - config.manifest.enable = false; - config.manifest.secondary = [{ url: LUDUSAVI_MANIFEST_URL, enable: true }]; - - fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config)); - } - static async addCustomGame(title: string, savePath: string | null) { const config = await this.getConfig(); const filteredGames = config.customGames.filter( @@ -105,6 +111,10 @@ export class Ludusavi { } config.customGames = filteredGames; - fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config)); + + fs.writeFileSync( + path.join(this.configPath, "config.yaml"), + YAML.stringify(config) + ); } } diff --git a/src/main/services/python-rpc.ts b/src/main/services/python-rpc.ts index 2179ffdc..1577b1e6 100644 --- a/src/main/services/python-rpc.ts +++ b/src/main/services/python-rpc.ts @@ -7,7 +7,7 @@ import crypto from "node:crypto"; import { pythonRpcLogger } from "./logger"; import { Readable } from "node:stream"; -import { app, dialog, safeStorage } from "electron"; +import { app, dialog } from "electron"; import { db, levelKeys } from "@main/level"; interface GamePayload { @@ -49,18 +49,13 @@ export class PythonRPC { valueEncoding: "utf8", }); - if (existingPassword) - return safeStorage.decryptString(Buffer.from(existingPassword, "hex")); + if (existingPassword) return existingPassword; const newPassword = crypto.randomBytes(32).toString("hex"); - await db.put( - levelKeys.rpcPassword, - safeStorage.encryptString(newPassword).toString("hex"), - { - valueEncoding: "utf8", - } - ); + await db.put(levelKeys.rpcPassword, newPassword, { + valueEncoding: "utf8", + }); return newPassword; } diff --git a/src/main/services/wine.ts b/src/main/services/wine.ts new file mode 100644 index 00000000..f8d84986 --- /dev/null +++ b/src/main/services/wine.ts @@ -0,0 +1,30 @@ +import fs from "node:fs"; +import path from "node:path"; + +export class Wine { + public static validatePrefix(winePrefixPath: string) { + const requiredFiles = [ + { name: "system.reg", type: "file" }, + { name: "user.reg", type: "file" }, + { name: "userdef.reg", type: "file" }, + { name: "dosdevices", type: "dir" }, + { name: "drive_c", type: "dir" }, + ]; + + for (const file of requiredFiles) { + const filePath = path.join(winePrefixPath, file.name); + + if (file.type === "file" && !fs.existsSync(filePath)) { + return false; + } + + if (file.type === "dir") { + if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isDirectory()) { + return false; + } + } + } + + return true; + } +} diff --git a/src/main/workers/ludusavi.worker.ts b/src/main/workers/ludusavi.worker.ts deleted file mode 100644 index dbe16968..00000000 --- a/src/main/workers/ludusavi.worker.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { LudusaviBackup } from "@types"; -import cp from "node:child_process"; - -import { workerData } from "node:worker_threads"; - -const { binaryPath } = workerData; - -export const backupGame = ({ - title, - backupPath, - preview = false, - winePrefix, -}: { - title: string; - backupPath: string; - preview?: boolean; - winePrefix?: string; -}) => { - return new Promise((resolve, reject) => { - const args = ["backup", title, "--api", "--force"]; - - if (preview) args.push("--preview"); - if (backupPath) args.push("--path", backupPath); - if (winePrefix) args.push("--wine-prefix", winePrefix); - - cp.execFile( - binaryPath, - args, - (err: cp.ExecFileException | null, stdout: string) => { - if (err) { - return reject(err); - } - - return resolve(JSON.parse(stdout) 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; -}; - -export const generateConfig = () => { - const result = cp.execFileSync(binaryPath, ["schema", "config"]); - - return JSON.parse(result.toString("utf-8")) as LudusaviBackup; -}; diff --git a/src/preload/index.ts b/src/preload/index.ts index bcaf6510..981901d3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -189,6 +189,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("resetGameAchievements", shop, objectId), extractGameDownload: (shop: GameShop, objectId: string) => ipcRenderer.invoke("extractGameDownload", shop, objectId), + getDefaultWinePrefixSelectionPath: () => + ipcRenderer.invoke("getDefaultWinePrefixSelectionPath"), onGamesRunning: ( cb: ( gamesRunning: Pick[] diff --git a/src/renderer/src/components/button/button.tsx b/src/renderer/src/components/button/button.tsx index fd86d4b2..8d83265d 100644 --- a/src/renderer/src/components/button/button.tsx +++ b/src/renderer/src/components/button/button.tsx @@ -1,12 +1,16 @@ import cn from "classnames"; +import { PlacesType, Tooltip } from "react-tooltip"; import "./button.scss"; +import { useId } from "react"; export interface ButtonProps extends React.DetailedHTMLProps< React.ButtonHTMLAttributes, HTMLButtonElement > { + tooltip?: string; + tooltipPlace?: PlacesType; theme?: "primary" | "outline" | "dark" | "danger"; } @@ -14,15 +18,32 @@ export function Button({ children, theme = "primary", className, + tooltip, + tooltipPlace = "top", ...props }: Readonly) { + const id = useId(); + + const tooltipProps = tooltip + ? { + "data-tooltip-id": id, + "data-tooltip-place": tooltipPlace, + "data-tooltip-content": tooltip, + } + : {}; + return ( - + <> + + + {tooltip && } + ); } 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 6973f5e7..21d11b33 100644 --- a/src/renderer/src/context/game-details/game-details.context.tsx +++ b/src/renderer/src/context/game-details/game-details.context.tsx @@ -160,7 +160,6 @@ export function GameDetailsContextProvider({ setShopDetails((prev) => { if (!prev) return null; - console.log("assets", assets); return { ...prev, assets, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 0f16ae2b..0dee5767 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -179,6 +179,7 @@ declare global { onExtractionComplete: ( cb: (shop: GameShop, objectId: string) => void ) => () => Electron.IpcRenderer; + getDefaultWinePrefixSelectionPath: () => Promise; /* Download sources */ putDownloadSource: ( 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 index 3dee5087..bd70aec1 100644 --- 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 @@ -43,7 +43,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { getGameBackupPreview, } = useContext(cloudSyncContext); - const { objectId, shop, gameTitle, lastDownloadedOption } = + const { objectId, shop, gameTitle, game, lastDownloadedOption } = useContext(gameDetailsContext); const { showSuccessToast, showErrorToast } = useToast(); @@ -148,6 +148,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { ]); const disableActions = uploadingBackup || restoringBackup || deletingArtifact; + const isMissingWinePrefix = + window.electron.platform === "linux" && !game?.winePrefixPath; return ( uploadSaveGame(lastDownloadedOption?.title ?? null)} + tooltip={isMissingWinePrefix ? t("missing_wine_prefix") : undefined} + tooltipPlace="left" disabled={ disableActions || !backupPreview?.overall.totalGames || - artifacts.length >= backupsPerGameLimit + artifacts.length >= backupsPerGameLimit || + isMissingWinePrefix } > {uploadingBackup ? ( 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 343f3d5d..991f6a9d 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 @@ -144,15 +144,23 @@ export function GameOptionsModal({ const handleChangeWinePrefixPath = async () => { const { filePaths } = await window.electron.showOpenDialog({ properties: ["openDirectory"], + defaultPath: await window.electron.getDefaultWinePrefixSelectionPath(), }); if (filePaths && filePaths.length > 0) { - await window.electron.selectGameWinePrefix( - game.shop, - game.objectId, - filePaths[0] - ); - await updateGame(); + try { + await window.electron.selectGameWinePrefix( + game.shop, + game.objectId, + filePaths[0] + ); + await updateGame(); + } catch (error) { + showErrorToast( + t("invalid_wine_prefix_path"), + t("invalid_wine_prefix_path_description") + ); + } } }; diff --git a/src/types/ludusavi.types.ts b/src/types/ludusavi.types.ts index 8432b9f6..64bb90f5 100644 --- a/src/types/ludusavi.types.ts +++ b/src/types/ludusavi.types.ts @@ -40,3 +40,12 @@ export interface LudusaviConfig { registry: []; }[]; } + +export interface LudusaviBackupMapping { + files: { + [key: string]: { + hash: string; + size: number; + }; + }; +} diff --git a/yarn.lock b/yarn.lock index e6300878..5535b804 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1850,108 +1850,6 @@ dependencies: "@monaco-editor/loader" "^1.4.0" -"@napi-rs/nice-android-arm-eabi@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz#9a0cba12706ff56500df127d6f4caf28ddb94936" - integrity sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w== - -"@napi-rs/nice-android-arm64@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz#32fc32e9649bd759d2a39ad745e95766f6759d2f" - integrity sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA== - -"@napi-rs/nice-darwin-arm64@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz#d3c44c51b94b25a82d45803e2255891e833e787b" - integrity sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA== - -"@napi-rs/nice-darwin-x64@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz#f1b1365a8370c6a6957e90085a9b4873d0e6a957" - integrity sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ== - -"@napi-rs/nice-freebsd-x64@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz#4280f081efbe0b46c5165fdaea8b286e55a8f89e" - integrity sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw== - -"@napi-rs/nice-linux-arm-gnueabihf@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz#07aec23a9467ed35eb7602af5e63d42c5d7bd473" - integrity sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q== - -"@napi-rs/nice-linux-arm64-gnu@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz#038a77134cc6df3c48059d5a5e199d6f50fb9a90" - integrity sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA== - -"@napi-rs/nice-linux-arm64-musl@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz#715d0906582ba0cff025109f42e5b84ea68c2bcc" - integrity sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw== - -"@napi-rs/nice-linux-ppc64-gnu@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz#ac1c8f781c67b0559fa7a1cd4ae3ca2299dc3d06" - integrity sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q== - -"@napi-rs/nice-linux-riscv64-gnu@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz#b0a430549acfd3920ffd28ce544e2fe17833d263" - integrity sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig== - -"@napi-rs/nice-linux-s390x-gnu@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz#5b95caf411ad72a965885217db378c4d09733e97" - integrity sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg== - -"@napi-rs/nice-linux-x64-gnu@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz#a98cdef517549f8c17a83f0236a69418a90e77b7" - integrity sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA== - -"@napi-rs/nice-linux-x64-musl@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz#5e26843eafa940138aed437c870cca751c8a8957" - integrity sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ== - -"@napi-rs/nice-win32-arm64-msvc@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz#bd62617d02f04aa30ab1e9081363856715f84cd8" - integrity sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg== - -"@napi-rs/nice-win32-ia32-msvc@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz#b8b7aad552a24836027473d9b9f16edaeabecf18" - integrity sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw== - -"@napi-rs/nice-win32-x64-msvc@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz#37d8718b8f722f49067713e9f1e85540c9a3dd09" - integrity sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg== - -"@napi-rs/nice@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@napi-rs/nice/-/nice-1.0.1.tgz#483d3ff31e5661829a1efb4825591a135c3bfa7d" - integrity sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ== - optionalDependencies: - "@napi-rs/nice-android-arm-eabi" "1.0.1" - "@napi-rs/nice-android-arm64" "1.0.1" - "@napi-rs/nice-darwin-arm64" "1.0.1" - "@napi-rs/nice-darwin-x64" "1.0.1" - "@napi-rs/nice-freebsd-x64" "1.0.1" - "@napi-rs/nice-linux-arm-gnueabihf" "1.0.1" - "@napi-rs/nice-linux-arm64-gnu" "1.0.1" - "@napi-rs/nice-linux-arm64-musl" "1.0.1" - "@napi-rs/nice-linux-ppc64-gnu" "1.0.1" - "@napi-rs/nice-linux-riscv64-gnu" "1.0.1" - "@napi-rs/nice-linux-s390x-gnu" "1.0.1" - "@napi-rs/nice-linux-x64-gnu" "1.0.1" - "@napi-rs/nice-linux-x64-musl" "1.0.1" - "@napi-rs/nice-win32-arm64-msvc" "1.0.1" - "@napi-rs/nice-win32-ia32-msvc" "1.0.1" - "@napi-rs/nice-win32-x64-msvc" "1.0.1" - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -7722,13 +7620,6 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -piscina@^4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/piscina/-/piscina-4.7.0.tgz#68936fc77128db00541366531330138e366dc851" - integrity sha512-b8hvkpp9zS0zsfa939b/jXbe64Z2gZv0Ha7FYPNUiDIB1y2AtxcOZdfP8xN8HFjUaqQiT9gRlfjAsoL8vdJ1Iw== - optionalDependencies: - "@napi-rs/nice" "^1.0.1" - plist@3.1.0, plist@^3.0.4, plist@^3.0.5, plist@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9"