feat: adding cross cloud save

This commit is contained in:
Chubby Granny Chaser
2025-05-11 19:07:30 +01:00
parent 6c55d667bd
commit 592ac45740
20 changed files with 303 additions and 298 deletions

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

View File

@@ -1,74 +1,73 @@
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 type { GameShop, LudusaviBackupMapping } from "@types";
import YAML from "yaml";
import { 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 ?? "", "").replace("drive_c", "C:");
};
const replaceLudusaviBackupWithCurrentUser = (
const restoreLudusaviBackup = (
backupPath: string,
title: string,
homeDir: string
homeDir: string,
winePrefixPath?: 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, publicProfilePath } =
CloudSync.getProfilePaths(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,
null
)
.replace(homeDir, userProfilePath)
.replace("C:/Users/Public", publicProfilePath);
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,6 +77,8 @@ const downloadGameArtifact = async (
gameArtifactId: string
) => {
try {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
downloadUrl: string;
objectKey: string;
@@ -109,34 +110,33 @@ 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
);
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

@@ -1,5 +1,6 @@
import { registerEvent } from "../register-event";
import { levelKeys, gamesSublevel } from "@main/level";
import { Wine } from "@main/services";
import type { GameShop } from "@types";
const selectGameWinePrefix = async (
@@ -8,6 +9,10 @@ const selectGameWinePrefix = async (
objectId: string,
winePrefixPath: string | null
) => {
if (winePrefixPath && !Wine.validatePrefix(winePrefixPath)) {
throw new Error("Invalid wine prefix path");
}
const gameKey = levelKeys.game(shop, objectId);
const game = await gamesSublevel.get(gameKey);

View File

@@ -32,3 +32,5 @@ export const isPortableVersion = () => {
export const normalizePath = (str: string) =>
path.posix.normalize(str).replace(/\\/g, "/");
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,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,53 @@ import i18next, { t } from "i18next";
import { SystemPath } from "./system-path";
export class CloudSync {
public static getProfilePaths(winePrefixPath?: string | null) {
const currentHomeDir = normalizePath(SystemPath.getPath("home"));
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 {
userProfilePath: path.join(
winePrefixPath,
normalizePath(userProfile.replace("C:", "drive_c"))
),
publicProfilePath: path.join(
winePrefixPath,
"drive_c",
"users",
"Public"
),
};
}
}
return {
userProfilePath: currentHomeDir,
publicProfilePath: path.join("C:", "Users", "Public"),
};
}
public static getBackupLabel(automatic: boolean) {
const language = i18next.language;
@@ -102,7 +149,9 @@ export class CloudSync {
shop,
objectId,
hostname: os.hostname(),
homeDir: normalizePath(SystemPath.getPath("home")),
winePrefixPath: game?.winePrefixPath ?? null,
homeDir: this.getProfilePaths(game?.winePrefixPath ?? null)
.userProfilePath,
downloadOptionTitle,
platform: os.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,83 @@
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"),
"ludusavi"
);
private static ludusaviConfigPath = path.join(
this.ludusaviPath,
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"),
"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(this.configPath, "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() {
fs.cpSync(path.join(this.ludusaviPath, "config.yaml"), this.configPath);
}
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.ludusaviPath,
"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 +90,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 +105,10 @@ export class Ludusavi {
}
config.customGames = filteredGames;
fs.writeFileSync(this.ludusaviConfigPath, YAML.stringify(config));
fs.writeFileSync(
path.join(this.ludusaviPath, "config.yaml"),
YAML.stringify(config)
);
}
}

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

@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
export class Wine {
public static validatePrefix(winePrefixPath: string) {
const requiredFiles = [
"system.reg",
"user.reg",
"userdef.reg",
"dosdevices",
"drive_c",
];
for (const file of requiredFiles) {
const filePath = path.join(winePrefixPath, file);
if (!fs.existsSync(filePath)) {
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

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

View File

@@ -147,12 +147,16 @@ export function GameOptionsModal({
});
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"));
}
}
};

View File

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