Merge pull request #1679 from hydralauncher/feat/cross-cloud-save

feat: adding cross cloud save
This commit is contained in:
Chubby Granny Chaser
2025-05-13 19:58:22 +01:00
committed by GitHub
36 changed files with 452 additions and 406 deletions

View File

@@ -99,3 +99,4 @@ jobs:
dist/*.yml
dist/*.blockmap
dist/*.pacman
dist/*.AppImage

3
.gitignore vendored
View File

@@ -7,7 +7,8 @@ out
*.log*
.env
.vite
ludusavi/
ludusavi/**
!ludusavi/config.yaml
hydra-python-rpc/
.python-version

BIN
binaries/aria2c Executable file

Binary file not shown.

BIN
binaries/aria2c.exe Executable file

Binary file not shown.

View File

@@ -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

6
ludusavi/config.yaml Normal file
View File

@@ -0,0 +1,6 @@
manifest:
enable: false
secondary:
- url: https://cdn.losbroxas.org/manifest.yaml
enable: true
customGames: []

View File

@@ -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",

View File

@@ -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();

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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" : ""}`

View File

@@ -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<string, string>;
};
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

View File

@@ -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";

View File

@@ -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
);

View File

@@ -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,
});
};

View File

@@ -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";

View File

@@ -0,0 +1,58 @@
type RegValue = string | number | null;
interface RegEntry {
path: string;
timestamp?: string;
values: Record<string, RegValue>;
}
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;
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -15,3 +15,4 @@ export * from "./aria2";
export * from "./ws";
export * from "./system-path";
export * from "./library-sync";
export * from "./wine";

View File

@@ -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<LudusaviBackup> {
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<LudusaviBackup> {
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<LudusaviBackup | null> {
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)
);
}
}

View File

@@ -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;
}

30
src/main/services/wine.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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;
};

View File

@@ -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<GameRunning, "id" | "sessionDurationInMillis">[]

View File

@@ -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>,
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<ButtonProps>) {
const id = useId();
const tooltipProps = tooltip
? {
"data-tooltip-id": id,
"data-tooltip-place": tooltipPlace,
"data-tooltip-content": tooltip,
}
: {};
return (
<button
type="button"
className={cn("button", `button--${theme}`, className)}
{...props}
>
{children}
</button>
<>
<button
type="button"
className={cn("button", `button--${theme}`, className)}
{...props}
{...tooltipProps}
>
{children}
</button>
{tooltip && <Tooltip id={id} />}
</>
);
}

View File

@@ -160,7 +160,6 @@ export function GameDetailsContextProvider({
setShopDetails((prev) => {
if (!prev) return null;
console.log("assets", assets);
return {
...prev,
assets,

View File

@@ -179,6 +179,7 @@ declare global {
onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
/* Download sources */
putDownloadSource: (

View File

@@ -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 (
<Modal
@@ -175,10 +177,13 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<Button
type="button"
onClick={() => 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 ? (

View File

@@ -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")
);
}
}
};

View File

@@ -40,3 +40,12 @@ export interface LudusaviConfig {
registry: [];
}[];
}
export interface LudusaviBackupMapping {
files: {
[key: string]: {
hash: string;
size: number;
};
};
}

109
yarn.lock
View File

@@ -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"