mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-10 21:36:17 +00:00
feat: adding cross cloud save
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,7 +7,8 @@ out
|
|||||||
*.log*
|
*.log*
|
||||||
.env
|
.env
|
||||||
.vite
|
.vite
|
||||||
ludusavi/
|
ludusavi/**
|
||||||
|
!ludusavi/config.yaml
|
||||||
hydra-python-rpc/
|
hydra-python-rpc/
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
|||||||
10
index.ts
Normal file
10
index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import vdf from "vdf-parser";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const vdfData = fs.readFileSync(
|
||||||
|
"/home/chubby/.local/share/Steam/userdata/1126196664/config/localconfig.vdf",
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
const data = vdf.parse(vdfData);
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
6
ludusavi/config.yaml
Normal file
6
ludusavi/config.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
manifest:
|
||||||
|
enable: false
|
||||||
|
secondary:
|
||||||
|
- url: https://cdn.losbroxas.org/manifest.yaml
|
||||||
|
enable: true
|
||||||
|
customGames: []
|
||||||
@@ -62,7 +62,6 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"parse-torrent": "^11.0.17",
|
"parse-torrent": "^11.0.17",
|
||||||
"piscina": "^4.7.0",
|
|
||||||
"rc-virtual-list": "^3.16.1",
|
"rc-virtual-list": "^3.16.1",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-i18next": "^14.1.0",
|
"react-i18next": "^14.1.0",
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { app } from "electron";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { SystemPath } from "./services/system-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 defaultDownloadsPath = SystemPath.getPath("downloads");
|
||||||
|
|
||||||
export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging");
|
export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging");
|
||||||
|
|||||||
@@ -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 fs from "node:fs";
|
||||||
import * as tar from "tar";
|
import * as tar from "tar";
|
||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { backupsPath } from "@main/constants";
|
import { backupsPath } from "@main/constants";
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop, LudusaviBackupMapping } from "@types";
|
||||||
|
|
||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
import { normalizePath } from "@main/helpers";
|
import { normalizePath } from "@main/helpers";
|
||||||
import { SystemPath } from "@main/services/system-path";
|
import { SystemPath } from "@main/services/system-path";
|
||||||
|
import { gamesSublevel, levelKeys } from "@main/level";
|
||||||
|
|
||||||
export interface LudusaviBackup {
|
export const transformLudusaviBackupPathIntoWindowsPath = (
|
||||||
files: {
|
backupPath: string,
|
||||||
[key: string]: {
|
winePrefixPath?: string | null
|
||||||
hash: string;
|
) => {
|
||||||
size: number;
|
return backupPath.replace(winePrefixPath ?? "", "").replace("drive_c", "C:");
|
||||||
};
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const replaceLudusaviBackupWithCurrentUser = (
|
const restoreLudusaviBackup = (
|
||||||
backupPath: string,
|
backupPath: string,
|
||||||
title: string,
|
title: string,
|
||||||
homeDir: string
|
homeDir: string,
|
||||||
|
winePrefixPath?: string | null
|
||||||
) => {
|
) => {
|
||||||
const gameBackupPath = path.join(backupPath, title);
|
const gameBackupPath = path.join(backupPath, title);
|
||||||
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
|
const mappingYamlPath = path.join(gameBackupPath, "mapping.yaml");
|
||||||
|
|
||||||
const data = fs.readFileSync(mappingYamlPath, "utf8");
|
const data = fs.readFileSync(mappingYamlPath, "utf8");
|
||||||
const manifest = YAML.parse(data) as {
|
const manifest = YAML.parse(data) as {
|
||||||
backups: LudusaviBackup[];
|
backups: LudusaviBackupMapping[];
|
||||||
drives: Record<string, string>;
|
drives: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentHomeDir = normalizePath(SystemPath.getPath("home"));
|
const { userProfilePath, publicProfilePath } =
|
||||||
|
CloudSync.getProfilePaths(winePrefixPath);
|
||||||
|
|
||||||
/* Renaming logic */
|
manifest.backups.forEach((backup) => {
|
||||||
if (os.platform() === "win32") {
|
Object.keys(backup.files).forEach((key) => {
|
||||||
const mappedHomeDir = path.join(
|
const sourcePathWithDrives = Object.entries(manifest.drives).reduce(
|
||||||
gameBackupPath,
|
(prev, [driveKey, driveValue]) => {
|
||||||
path.join("drive-C", homeDir.replace("C:", ""))
|
return prev.replace(driveValue, driveKey);
|
||||||
|
},
|
||||||
|
key
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fs.existsSync(mappedHomeDir)) {
|
const sourcePath = path.join(gameBackupPath, sourcePathWithDrives);
|
||||||
fs.renameSync(
|
|
||||||
mappedHomeDir,
|
logger.info(`Source path: ${sourcePath}`);
|
||||||
path.join(gameBackupPath, "drive-C", currentHomeDir.replace("C:", ""))
|
|
||||||
);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backups = manifest.backups.map((backup: LudusaviBackup) => {
|
fs.renameSync(sourcePath, destinationPath);
|
||||||
const files = Object.entries(backup.files).reduce((prev, [key, value]) => {
|
});
|
||||||
const updatedKey = key.replace(homeDir, currentHomeDir);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[updatedKey]: value,
|
|
||||||
};
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...backup,
|
|
||||||
files,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.writeFileSync(mappingYamlPath, YAML.stringify({ ...manifest, backups }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadGameArtifact = async (
|
const downloadGameArtifact = async (
|
||||||
@@ -78,6 +77,8 @@ const downloadGameArtifact = async (
|
|||||||
gameArtifactId: string
|
gameArtifactId: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
const game = await gamesSublevel.get(levelKeys.game(shop, objectId));
|
||||||
|
|
||||||
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
|
const { downloadUrl, objectKey, homeDir } = await HydraApi.post<{
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
objectKey: string;
|
objectKey: string;
|
||||||
@@ -109,34 +110,33 @@ const downloadGameArtifact = async (
|
|||||||
response.data.pipe(writer);
|
response.data.pipe(writer);
|
||||||
|
|
||||||
writer.on("error", (err) => {
|
writer.on("error", (err) => {
|
||||||
logger.error("Failed to write zip", err);
|
logger.error("Failed to write tar file", err);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
fs.mkdirSync(backupPath, { recursive: true });
|
fs.mkdirSync(backupPath, { recursive: true });
|
||||||
|
|
||||||
writer.on("close", () => {
|
writer.on("close", async () => {
|
||||||
tar
|
await tar.x({
|
||||||
.x({
|
|
||||||
file: zipLocation,
|
file: zipLocation,
|
||||||
cwd: backupPath,
|
cwd: backupPath,
|
||||||
})
|
});
|
||||||
.then(async () => {
|
|
||||||
replaceLudusaviBackupWithCurrentUser(
|
restoreLudusaviBackup(
|
||||||
backupPath,
|
backupPath,
|
||||||
objectId,
|
objectId,
|
||||||
normalizePath(homeDir)
|
normalizePath(homeDir),
|
||||||
|
game?.winePrefixPath
|
||||||
);
|
);
|
||||||
|
|
||||||
Ludusavi.restoreBackup(backupPath).then(() => {
|
|
||||||
WindowManager.mainWindow?.webContents.send(
|
WindowManager.mainWindow?.webContents.send(
|
||||||
`on-backup-download-complete-${objectId}-${shop}`,
|
`on-backup-download-complete-${objectId}-${shop}`,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logger.error("Failed to download game artifact", err);
|
||||||
|
|
||||||
WindowManager.mainWindow?.webContents.send(
|
WindowManager.mainWindow?.webContents.send(
|
||||||
`on-backup-download-complete-${objectId}-${shop}`,
|
`on-backup-download-complete-${objectId}-${shop}`,
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { registerEvent } from "../register-event";
|
import { registerEvent } from "../register-event";
|
||||||
import { levelKeys, gamesSublevel } from "@main/level";
|
import { levelKeys, gamesSublevel } from "@main/level";
|
||||||
|
import { Wine } from "@main/services";
|
||||||
import type { GameShop } from "@types";
|
import type { GameShop } from "@types";
|
||||||
|
|
||||||
const selectGameWinePrefix = async (
|
const selectGameWinePrefix = async (
|
||||||
@@ -8,6 +9,10 @@ const selectGameWinePrefix = async (
|
|||||||
objectId: string,
|
objectId: string,
|
||||||
winePrefixPath: string | null
|
winePrefixPath: string | null
|
||||||
) => {
|
) => {
|
||||||
|
if (winePrefixPath && !Wine.validatePrefix(winePrefixPath)) {
|
||||||
|
throw new Error("Invalid wine prefix path");
|
||||||
|
}
|
||||||
|
|
||||||
const gameKey = levelKeys.game(shop, objectId);
|
const gameKey = levelKeys.game(shop, objectId);
|
||||||
|
|
||||||
const game = await gamesSublevel.get(gameKey);
|
const game = await gamesSublevel.get(gameKey);
|
||||||
|
|||||||
@@ -32,3 +32,5 @@ export const isPortableVersion = () => {
|
|||||||
|
|
||||||
export const normalizePath = (str: string) =>
|
export const normalizePath = (str: string) =>
|
||||||
path.posix.normalize(str).replace(/\\/g, "/");
|
path.posix.normalize(str).replace(/\\/g, "/");
|
||||||
|
|
||||||
|
export * from "./reg-parser";
|
||||||
|
|||||||
58
src/main/helpers/reg-parser.ts
Normal file
58
src/main/helpers/reg-parser.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -23,7 +23,9 @@ autoUpdater.logger = logger;
|
|||||||
const gotTheLock = app.requestSingleInstanceLock();
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
if (!gotTheLock) app.quit();
|
if (!gotTheLock) app.quit();
|
||||||
|
|
||||||
app.commandLine.appendSwitch("--no-sandbox");
|
if (process.platform !== "linux") {
|
||||||
|
app.commandLine.appendSwitch("--no-sandbox");
|
||||||
|
}
|
||||||
|
|
||||||
i18n.init({
|
i18n.init({
|
||||||
resources,
|
resources,
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
RealDebridClient,
|
RealDebridClient,
|
||||||
Aria2,
|
Aria2,
|
||||||
DownloadManager,
|
DownloadManager,
|
||||||
Ludusavi,
|
|
||||||
HydraApi,
|
HydraApi,
|
||||||
uploadGamesBatch,
|
uploadGamesBatch,
|
||||||
startMainLoop,
|
startMainLoop,
|
||||||
|
Ludusavi,
|
||||||
} from "@main/services";
|
} from "@main/services";
|
||||||
|
|
||||||
export const loadState = async () => {
|
export const loadState = async () => {
|
||||||
@@ -39,7 +39,7 @@ export const loadState = async () => {
|
|||||||
TorBoxClient.authorize(userPreferences.torBoxApiToken);
|
TorBoxClient.authorize(userPreferences.torBoxApiToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ludusavi.addManifestToLudusaviConfig();
|
Ludusavi.copyConfigFileToUserData();
|
||||||
|
|
||||||
await HydraApi.setupApi().then(() => {
|
await HydraApi.setupApi().then(() => {
|
||||||
uploadGamesBatch();
|
uploadGamesBatch();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os from "node:os";
|
|||||||
import type { GameShop, User } from "@types";
|
import type { GameShop, User } from "@types";
|
||||||
import { backupsPath } from "@main/constants";
|
import { backupsPath } from "@main/constants";
|
||||||
import { HydraApi } from "./hydra-api";
|
import { HydraApi } from "./hydra-api";
|
||||||
import { normalizePath } from "@main/helpers";
|
import { normalizePath, parseRegFile } from "@main/helpers";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -17,6 +17,53 @@ import i18next, { t } from "i18next";
|
|||||||
import { SystemPath } from "./system-path";
|
import { SystemPath } from "./system-path";
|
||||||
|
|
||||||
export class CloudSync {
|
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) {
|
public static getBackupLabel(automatic: boolean) {
|
||||||
const language = i18next.language;
|
const language = i18next.language;
|
||||||
|
|
||||||
@@ -102,7 +149,9 @@ export class CloudSync {
|
|||||||
shop,
|
shop,
|
||||||
objectId,
|
objectId,
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
homeDir: normalizePath(SystemPath.getPath("home")),
|
winePrefixPath: game?.winePrefixPath ?? null,
|
||||||
|
homeDir: this.getProfilePaths(game?.winePrefixPath ?? null)
|
||||||
|
.userProfilePath,
|
||||||
downloadOptionTitle,
|
downloadOptionTitle,
|
||||||
platform: os.platform(),
|
platform: os.platform(),
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export * from "./aria2";
|
|||||||
export * from "./ws";
|
export * from "./ws";
|
||||||
export * from "./system-path";
|
export * from "./system-path";
|
||||||
export * from "./library-sync";
|
export * from "./library-sync";
|
||||||
|
export * from "./wine";
|
||||||
|
|||||||
@@ -1,70 +1,83 @@
|
|||||||
import type { GameShop, LudusaviBackup, LudusaviConfig } from "@types";
|
import type { GameShop, LudusaviBackup, LudusaviConfig } from "@types";
|
||||||
import Piscina from "piscina";
|
|
||||||
|
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
|
import cp from "node:child_process";
|
||||||
import ludusaviWorkerPath from "../workers/ludusavi.worker?modulePath";
|
|
||||||
import { LUDUSAVI_MANIFEST_URL } from "@main/constants";
|
|
||||||
import { SystemPath } from "./system-path";
|
import { SystemPath } from "./system-path";
|
||||||
|
|
||||||
export class Ludusavi {
|
export class Ludusavi {
|
||||||
private static ludusaviPath = path.join(
|
private static ludusaviPath = app.isPackaged
|
||||||
SystemPath.getPath("appData"),
|
? path.join(process.resourcesPath, "ludusavi")
|
||||||
"ludusavi"
|
: path.join(__dirname, "..", "..", "ludusavi");
|
||||||
);
|
|
||||||
private static ludusaviConfigPath = path.join(
|
private static binaryPath = path.join(this.ludusaviPath, "ludusavi");
|
||||||
this.ludusaviPath,
|
private static configPath = path.join(
|
||||||
|
SystemPath.getPath("userData"),
|
||||||
"config.yaml"
|
"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(
|
const config = YAML.parse(
|
||||||
fs.readFileSync(this.ludusaviConfigPath, "utf-8")
|
fs.readFileSync(this.configPath, "utf-8")
|
||||||
) as LudusaviConfig;
|
) as LudusaviConfig;
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async backupGame(
|
public static async copyConfigFileToUserData() {
|
||||||
_shop: GameShop,
|
fs.cpSync(path.join(this.ludusaviPath, "config.yaml"), this.configPath);
|
||||||
objectId: string,
|
|
||||||
backupPath: string,
|
|
||||||
winePrefix?: string | null
|
|
||||||
): Promise<LudusaviBackup> {
|
|
||||||
return this.worker.run(
|
|
||||||
{ title: objectId, backupPath, winePrefix },
|
|
||||||
{ name: "backupGame" }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
_shop: GameShop,
|
||||||
objectId: string,
|
objectId: string,
|
||||||
winePrefix?: string | null
|
winePrefix?: string | null
|
||||||
): Promise<LudusaviBackup | null> {
|
): Promise<LudusaviBackup | null> {
|
||||||
const config = await this.getConfig();
|
const config = await this.getConfig();
|
||||||
|
|
||||||
const backupData = await this.worker.run(
|
const backupData = await this.backupGame(
|
||||||
{ title: objectId, winePrefix, preview: true },
|
_shop,
|
||||||
{ name: "backupGame" }
|
objectId,
|
||||||
|
null,
|
||||||
|
winePrefix,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
const customGame = config.customGames.find(
|
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) {
|
static async addCustomGame(title: string, savePath: string | null) {
|
||||||
const config = await this.getConfig();
|
const config = await this.getConfig();
|
||||||
const filteredGames = config.customGames.filter(
|
const filteredGames = config.customGames.filter(
|
||||||
@@ -105,6 +105,10 @@ export class Ludusavi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config.customGames = filteredGames;
|
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
23
src/main/services/wine.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -160,7 +160,6 @@ export function GameDetailsContextProvider({
|
|||||||
|
|
||||||
setShopDetails((prev) => {
|
setShopDetails((prev) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
console.log("assets", assets);
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
assets,
|
assets,
|
||||||
|
|||||||
@@ -147,12 +147,16 @@ export function GameOptionsModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (filePaths && filePaths.length > 0) {
|
if (filePaths && filePaths.length > 0) {
|
||||||
|
try {
|
||||||
await window.electron.selectGameWinePrefix(
|
await window.electron.selectGameWinePrefix(
|
||||||
game.shop,
|
game.shop,
|
||||||
game.objectId,
|
game.objectId,
|
||||||
filePaths[0]
|
filePaths[0]
|
||||||
);
|
);
|
||||||
await updateGame();
|
await updateGame();
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(t("invalid_wine_prefix_path"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,12 @@ export interface LudusaviConfig {
|
|||||||
registry: [];
|
registry: [];
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LudusaviBackupMapping {
|
||||||
|
files: {
|
||||||
|
[key: string]: {
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
109
yarn.lock
109
yarn.lock
@@ -1850,108 +1850,6 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@monaco-editor/loader" "^1.4.0"
|
"@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":
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
|
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"
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
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:
|
plist@3.1.0, plist@^3.0.4, plist@^3.0.5, plist@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9"
|
resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9"
|
||||||
|
|||||||
Reference in New Issue
Block a user