mirror of
https://github.com/Gabi-Zar/Silk-Fly-Launcher.git
synced 2026-04-17 05:26:04 +02:00
1162 lines
36 KiB
JavaScript
1162 lines
36 KiB
JavaScript
import { app, BrowserWindow, ipcMain, dialog, shell, Menu, safeStorage } from "electron";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import Store from "electron-store";
|
|
import fs from "fs/promises";
|
|
import { createWriteStream } from "fs";
|
|
import { pipeline } from "stream/promises";
|
|
import NexusModule from "@nexusmods/nexus-api";
|
|
import { gql, GraphQLClient } from "graphql-request";
|
|
import { path7za } from "7zip-bin";
|
|
import node7z from "node-7z";
|
|
const { extractFull } = node7z;
|
|
import packageJson from "./package.json" with { type: "json" };
|
|
import semverGt from "semver/functions/gt.js";
|
|
import { randomUUID } from "crypto";
|
|
import { spawn } from "child_process";
|
|
import vdf from "vdf-parser";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const gotTheLock = app.requestSingleInstanceLock();
|
|
const isDev = !app.isPackaged;
|
|
const VERSION = packageJson.version;
|
|
const NAME = packageJson.productName;
|
|
const userAgent = `${NAME}/${VERSION}`;
|
|
|
|
const store = new Store();
|
|
const bepinexStore = new Store({ name: "bepinex-version" });
|
|
const installedModsStore = new Store({ name: "installed-mods-list" });
|
|
const NexusAPIStore = new Store({ name: "nexus-api", encryptionKey: packageJson["AES-key-nexus-api"], fileExtension: "encrypted", clearInvalidConfig: true });
|
|
|
|
const userSavePath = app.getPath("userData");
|
|
const tempPath = app.getPath("temp");
|
|
const modSavePath = path.join(userSavePath, "mods");
|
|
const dataPath = path.join(userSavePath, "config.json");
|
|
let sevenZipPath = path7za;
|
|
|
|
const Nexus = NexusModule.default;
|
|
let nexus;
|
|
let installedCachedModList;
|
|
let installedTotalModsCount;
|
|
let onlineCachedModList;
|
|
let onlineTotalModsCount;
|
|
let thunderstoreCachedModList;
|
|
let thunderstoreTotalModsCount;
|
|
|
|
const bepinexFiles = [".doorstop_version", "changelog.txt", "doorstop_config.ini", "winhttp.dll", "libdoorstop.so", "run_bepinex.sh"];
|
|
let bepinexVersion = bepinexStore.get("bepinex-version");
|
|
let bepinexBackupVersion;
|
|
|
|
let mainWindow;
|
|
let nexusWindow;
|
|
let htmlFile;
|
|
|
|
//////////////////////////////////////////////////////
|
|
////////////////////// STARTUP ///////////////////////
|
|
|
|
if (!gotTheLock) {
|
|
app.quit();
|
|
} else {
|
|
app.on("second-instance", (event, argv) => {
|
|
const nxmUrl = argv.find((arg) => arg.startsWith("nxm://"));
|
|
if (nxmUrl) {
|
|
handleNxmUrl(nxmUrl);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1280,
|
|
height: 720,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.js"),
|
|
},
|
|
backgroundColor: "#000",
|
|
show: false,
|
|
});
|
|
|
|
if (await fileExists(dataPath)) {
|
|
htmlFile = "index.html";
|
|
} else {
|
|
htmlFile = "welcome.html";
|
|
}
|
|
|
|
mainWindow.loadFile(path.join("renderer", htmlFile));
|
|
|
|
mainWindow.webContents.once("did-finish-load", () => {
|
|
mainWindow.show();
|
|
if (!isDev) {
|
|
verifyUpdate();
|
|
}
|
|
});
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
if (isDev) {
|
|
app.setAsDefaultProtocolClient("nxm", process.execPath, [path.resolve(process.argv[1])]);
|
|
} else {
|
|
app.setAsDefaultProtocolClient("nxm");
|
|
sevenZipPath = path7za.replace(path.join("app.asar", "node_modules"), "");
|
|
Menu.setApplicationMenu(null);
|
|
}
|
|
|
|
if (gotTheLock) {
|
|
createNexus(loadNexusApi());
|
|
checkInstalledMods();
|
|
createWindow();
|
|
}
|
|
|
|
app.on("activate", async () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
}
|
|
});
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("open-url", (event, url) => {
|
|
event.preventDefault();
|
|
handleNxmUrl(url);
|
|
});
|
|
|
|
async function verifyUpdate() {
|
|
const GITHUB_URL = "https://api.github.com/repos/Gabi-Zar/Silk-Fly-Launcher/releases";
|
|
|
|
const res = await fetch(GITHUB_URL, {
|
|
headers: {
|
|
"User-Agent": userAgent,
|
|
Accept: "application/vnd.github+json",
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status == 403) {
|
|
mainWindow.webContents.send("showToast", "Github has blocked the application. Please try again later.", "error");
|
|
}
|
|
throw new Error(`GitHub API error: ${res.status}`);
|
|
}
|
|
|
|
const releases = await res.json();
|
|
const prerelease = releases.find((r) => r.prerelease);
|
|
const release = releases.find((r) => !r.prerelease && !r.draft);
|
|
|
|
let prereleaseVersion;
|
|
let releaseVersion;
|
|
let latestVersion;
|
|
|
|
if (prerelease) {
|
|
prereleaseVersion = prerelease.tag_name.replace(/^v/, "");
|
|
latestVersion = prereleaseVersion;
|
|
}
|
|
if (release) {
|
|
releaseVersion = release.tag_name.replace(/^v/, "");
|
|
latestVersion = releaseVersion;
|
|
}
|
|
|
|
if (prereleaseVersion && releaseVersion) {
|
|
latestVersion = semverGt(prereleaseVersion, releaseVersion) ? prereleaseVersion : releaseVersion;
|
|
}
|
|
if (latestVersion != VERSION) {
|
|
mainWindow.webContents.send(
|
|
"showBanner",
|
|
`Update v${latestVersion} is available on <a href="" class="link" onclick="electronAPI.openExternalLink('https://github.com/Gabi-Zar/Silk-Fly-Launcher/releases/tag/v${latestVersion}')">GitHub</a>! Your current version is ${VERSION}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
//////////////////////////////////////////////////////
|
|
///////////////// SAVING AND LOADING /////////////////
|
|
ipcMain.handle("save-path", (event, path) => {
|
|
saveSilksongPath(path);
|
|
});
|
|
|
|
function saveSilksongPath(path) {
|
|
store.set("silksong-path", path);
|
|
checkInstalledMods();
|
|
}
|
|
|
|
function loadSilksongPath() {
|
|
const silksongPath = store.get("silksong-path");
|
|
if (silksongPath == undefined) {
|
|
return "";
|
|
}
|
|
return silksongPath;
|
|
}
|
|
|
|
ipcMain.handle("load-path", () => {
|
|
return loadSilksongPath();
|
|
});
|
|
|
|
function saveBepinexVersion(version) {
|
|
bepinexVersion = version;
|
|
if (bepinexVersion == undefined) {
|
|
bepinexStore.delete("bepinex-version");
|
|
return;
|
|
}
|
|
bepinexStore.set("bepinex-version", version);
|
|
}
|
|
|
|
ipcMain.handle("load-bepinex-version", () => {
|
|
bepinexVersion = bepinexStore.get("bepinex-version");
|
|
return bepinexVersion;
|
|
});
|
|
|
|
function saveBepinexBackupVersion(version) {
|
|
bepinexBackupVersion = version;
|
|
if (bepinexBackupVersion == undefined) {
|
|
bepinexStore.delete("bepinex-backup-version");
|
|
return;
|
|
}
|
|
bepinexStore.set("bepinex-backup-version", version);
|
|
}
|
|
|
|
ipcMain.handle("load-bepinex-backup-version", () => {
|
|
bepinexBackupVersion = bepinexStore.get("bepinex-backup-version");
|
|
return bepinexBackupVersion;
|
|
});
|
|
|
|
ipcMain.handle("save-nexus-api", async (event, api) => {
|
|
if (api) {
|
|
const encryptedAPI = safeStorage.encryptString(api);
|
|
NexusAPIStore.set("nexus-api", encryptedAPI.toString("base64"));
|
|
} else {
|
|
NexusAPIStore.delete("nexus-api");
|
|
}
|
|
await createNexus(api);
|
|
});
|
|
|
|
function loadNexusApi() {
|
|
const encryptedAPI = NexusAPIStore.get("nexus-api");
|
|
if (encryptedAPI) {
|
|
return safeStorage.decryptString(Buffer.from(encryptedAPI, "base64"));
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("load-nexus-api", () => {
|
|
if (loadNexusApi()) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
ipcMain.handle("save-theme", (event, theme, lacePinState) => {
|
|
store.set("theme.theme", theme);
|
|
store.set("theme.lacePinState", lacePinState);
|
|
});
|
|
|
|
ipcMain.handle("load-theme", () => {
|
|
const theme = [store.get("theme.theme"), store.get("theme.lacePinState")];
|
|
if (theme[0] == undefined) {
|
|
return ["Silksong", false];
|
|
}
|
|
return theme;
|
|
});
|
|
|
|
async function saveModInfo(modId, suppr = false, optionalModInfo = {}) {
|
|
if (suppr == true) {
|
|
installedModsStore.delete(String(modId));
|
|
return;
|
|
}
|
|
|
|
let modInfo;
|
|
if (onlineCachedModList) {
|
|
modInfo = onlineCachedModList.find((mod) => mod.modId == modId);
|
|
}
|
|
if (!modInfo) {
|
|
if (thunderstoreCachedModList) {
|
|
modInfo = thunderstoreCachedModList.find((mod) => mod.modId == modId);
|
|
}
|
|
}
|
|
if (!modInfo) {
|
|
modInfo = optionalModInfo;
|
|
}
|
|
modInfo.activated = true;
|
|
|
|
const modFiles = await fs.readdir(path.join(modSavePath, modId));
|
|
modInfo.fileSize = 0;
|
|
for (const file of modFiles) {
|
|
const fileStats = await fs.stat(path.join(modSavePath, modId, file));
|
|
modInfo.fileSize += fileStats.size;
|
|
}
|
|
|
|
installedModsStore.set(String(modId), modInfo);
|
|
}
|
|
|
|
ipcMain.handle("save-linux-steam", (event, state) => {
|
|
store.set("linux.steam", state);
|
|
});
|
|
|
|
function loadLinuxSteam() {
|
|
return store.get("linux.steam");
|
|
}
|
|
|
|
ipcMain.handle("load-linux-steam", () => {
|
|
return loadLinuxSteam();
|
|
});
|
|
|
|
//////////////////////////////////////////////////////
|
|
/////////////////// DATA HANDLING ////////////////////
|
|
|
|
async function fileExists(filePath) {
|
|
try {
|
|
await fs.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("delete-data", async () => {
|
|
if (await fileExists(dataPath)) {
|
|
await fs.unlink(dataPath);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("export-data", async () => {
|
|
if (!(await fileExists(dataPath))) {
|
|
return;
|
|
}
|
|
|
|
const { canceled, filePath } = await dialog.showSaveDialog({
|
|
title: "Export Data",
|
|
defaultPath: "config.json",
|
|
filters: [{ name: "JSON", extensions: ["json"] }],
|
|
});
|
|
|
|
if (canceled || !filePath) return;
|
|
|
|
await fs.copyFile(dataPath, filePath);
|
|
});
|
|
|
|
ipcMain.handle("import-data", async () => {
|
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
|
title: "Import Data",
|
|
properties: ["openFile"],
|
|
filters: [{ name: "JSON", extensions: ["json"] }],
|
|
});
|
|
|
|
if (canceled || !filePaths) return false;
|
|
|
|
if (await fileExists(dataPath)) {
|
|
await fs.unlink(dataPath);
|
|
}
|
|
await fs.copyFile(filePaths[0], dataPath, fs.constants.COPYFILE_EXCL);
|
|
return true;
|
|
});
|
|
|
|
//////////////////////////////////////////////////////
|
|
////////////////////// BEPINEX ///////////////////////
|
|
|
|
async function installBepinex() {
|
|
const silksongPath = loadSilksongPath();
|
|
const bepinexBackupPath = path.join(silksongPath, "BepInEx-Backup");
|
|
|
|
if (!(await fileExists(silksongPath))) {
|
|
mainWindow.webContents.send("showToast", "Path to the game invalid", "warning");
|
|
return;
|
|
}
|
|
|
|
if (await fileExists(bepinexBackupPath)) {
|
|
mainWindow.webContents.send("showToast", "Installing Bepinex from Backup");
|
|
|
|
await fs.cp(bepinexBackupPath, silksongPath, { recursive: true });
|
|
await fs.rm(bepinexBackupPath, { recursive: true });
|
|
|
|
bepinexBackupVersion = bepinexStore.get("bepinex-backup-version");
|
|
saveBepinexVersion(bepinexBackupVersion);
|
|
saveBepinexBackupVersion(undefined);
|
|
} else {
|
|
mainWindow.webContents.send("showToast", "Installing Bepinex from Github");
|
|
|
|
const GITHUB_URL = "https://api.github.com/repos/bepinex/bepinex/releases/latest";
|
|
|
|
const res = await fetch(GITHUB_URL, {
|
|
headers: {
|
|
"User-Agent": userAgent,
|
|
Accept: "application/vnd.github+json",
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status == 403) {
|
|
mainWindow.webContents.send("showToast", "Github has blocked the application. Please try again later.", "error");
|
|
}
|
|
throw new Error(`GitHub API error: ${res.status}`);
|
|
}
|
|
|
|
const release = await res.json();
|
|
|
|
let asset;
|
|
if (process.platform === "win32") {
|
|
asset = release.assets.find((a) => a.name.endsWith(".zip") && a.name.toLowerCase().includes("win_x64"));
|
|
} else if (process.platform === "linux") {
|
|
asset = release.assets.find((a) => a.name.endsWith(".zip") && a.name.toLowerCase().includes("linux_x64"));
|
|
}
|
|
|
|
await downloadAndUnzip(asset.browser_download_url, silksongPath);
|
|
|
|
saveBepinexVersion(release.tag_name);
|
|
}
|
|
|
|
checkInstalledMods();
|
|
}
|
|
|
|
ipcMain.handle("install-bepinex", async () => {
|
|
await installBepinex();
|
|
});
|
|
|
|
async function uninstallBepinex() {
|
|
const silksongPath = loadSilksongPath();
|
|
const bepinexFolderPath = path.join(silksongPath, "BepInEx");
|
|
|
|
if (!(await fileExists(silksongPath))) {
|
|
mainWindow.webContents.send("showToast", "Path to the game invalid", "warning");
|
|
return;
|
|
}
|
|
|
|
if (await fileExists(bepinexFolderPath)) {
|
|
await fs.rm(bepinexFolderPath, { recursive: true });
|
|
}
|
|
|
|
for (const file of bepinexFiles) {
|
|
const filePath = path.join(silksongPath, file);
|
|
if (await fileExists(filePath)) {
|
|
await fs.unlink(filePath);
|
|
}
|
|
}
|
|
saveBepinexVersion(undefined);
|
|
}
|
|
|
|
ipcMain.handle("uninstall-bepinex", async () => {
|
|
await uninstallBepinex();
|
|
});
|
|
|
|
async function backupBepinex() {
|
|
const silksongPath = loadSilksongPath();
|
|
const bepinexFolderPath = path.join(silksongPath, "BepInEx");
|
|
const bepinexBackupPath = path.join(silksongPath, "BepInEx-Backup");
|
|
const BepinexPluginsPath = path.join(silksongPath, "BepInEx", "plugins");
|
|
|
|
if (!(await fileExists(silksongPath))) {
|
|
mainWindow.webContents.send("showToast", "Path to the game invalid", "warning");
|
|
return;
|
|
}
|
|
|
|
if ((await fileExists(bepinexBackupPath)) == false) {
|
|
await fs.mkdir(bepinexBackupPath);
|
|
}
|
|
|
|
if (fileExists(BepinexPluginsPath)) {
|
|
await fs.rm(BepinexPluginsPath, { recursive: true });
|
|
}
|
|
|
|
if (await fileExists(bepinexFolderPath)) {
|
|
await fs.cp(bepinexFolderPath, path.join(bepinexBackupPath, "BepInEx"), {
|
|
recursive: true,
|
|
});
|
|
}
|
|
|
|
for (const file of bepinexFiles) {
|
|
const filePath = path.join(silksongPath, file);
|
|
if (await fileExists(filePath)) {
|
|
await fs.copyFile(filePath, path.join(bepinexBackupPath, file));
|
|
}
|
|
}
|
|
|
|
saveBepinexBackupVersion(bepinexVersion);
|
|
await uninstallBepinex();
|
|
}
|
|
|
|
ipcMain.handle("backup-bepinex", async () => {
|
|
await backupBepinex();
|
|
});
|
|
|
|
ipcMain.handle("delete-bepinex-backup", async () => {
|
|
const silksongPath = loadSilksongPath();
|
|
const bepinexBackupPath = path.join(silksongPath, "BepInEx-Backup");
|
|
|
|
if (!(await fileExists(silksongPath))) {
|
|
mainWindow.webContents.send("showToast", "Path to the game invalid", "warning");
|
|
return;
|
|
}
|
|
|
|
if (await fileExists(bepinexBackupPath)) {
|
|
await fs.rm(bepinexBackupPath, { recursive: true });
|
|
saveBepinexBackupVersion(undefined);
|
|
}
|
|
});
|
|
|
|
//////////////////////////////////////////////////////
|
|
//////////////// NEXUS / THUNDERSTORE ////////////////
|
|
|
|
async function createNexus(api) {
|
|
if (api == undefined) {
|
|
nexus = undefined;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
nexus = await Nexus.create(api, NAME, VERSION, "hollowknightsilksong");
|
|
} catch (error) {
|
|
if (error.mStatusCode == 401) {
|
|
mainWindow.webContents.send("showToast", "Invalid Nexus API key", "error");
|
|
} else if (error.code == "ENOTFOUND") {
|
|
mainWindow.webContents.send("showToast", "Unable to communicate with Nexus servers", "error");
|
|
} else {
|
|
mainWindow.webContents.send("showToast", "Unable to create Nexus API ", "error");
|
|
}
|
|
nexus = undefined;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("verify-nexus-api", async () => {
|
|
return await verifyNexusAPI();
|
|
});
|
|
|
|
async function verifyNexusAPI() {
|
|
if (nexus == undefined) {
|
|
return false;
|
|
}
|
|
if (await nexus.getValidationResult()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("get-mods", async (event, type) => {
|
|
if (type == "mods-installed") {
|
|
if (!installedCachedModList) {
|
|
await searchInstalledMods("");
|
|
}
|
|
return { installedModsInfo: installedCachedModList, installedTotalCount: installedTotalModsCount };
|
|
} else if (type == "mods-online") {
|
|
if (!onlineCachedModList) {
|
|
await searchNexusMods("");
|
|
}
|
|
return { onlineModsInfo: onlineCachedModList, onlineTotalCount: onlineTotalModsCount };
|
|
} else if (type == "mods-thunderstore") {
|
|
if (!thunderstoreCachedModList) {
|
|
await searchThunderstoreMods("");
|
|
}
|
|
return { thunderstoreModsInfo: thunderstoreCachedModList, thunderstoreTotalCount: thunderstoreTotalModsCount };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("open-download", async (event, link) => {
|
|
if (!(await verifyNexusAPI())) {
|
|
mainWindow.webContents.send("showToast", "Unable to download.", "error");
|
|
return;
|
|
}
|
|
|
|
nexusWindow = new BrowserWindow({
|
|
width: 1080,
|
|
height: 720,
|
|
modal: true,
|
|
parent: mainWindow,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
},
|
|
backgroundColor: "#000000",
|
|
});
|
|
|
|
nexusWindow.loadURL(link);
|
|
});
|
|
|
|
function handleNxmUrl(url) {
|
|
if (nexusWindow) {
|
|
nexusWindow.close();
|
|
}
|
|
|
|
const parsedUrl = new URL(url);
|
|
|
|
const key = parsedUrl.searchParams.get("key");
|
|
const expires = Number(parsedUrl.searchParams.get("expires"));
|
|
|
|
const [, , modId, , fileId] = parsedUrl.pathname.split("/");
|
|
|
|
startDownload(Number(modId), Number(fileId), key, expires);
|
|
}
|
|
|
|
async function startDownload(modId, fileId, key, expires) {
|
|
modId = String(modId);
|
|
const bepinexFolderPath = path.join(loadSilksongPath(), "BepInEx");
|
|
|
|
if (!(await verifyNexusAPI())) {
|
|
mainWindow.webContents.send("showToast", "Unable to download.", "error");
|
|
return;
|
|
}
|
|
|
|
const url = await nexus.getDownloadURLs(modId, fileId, key, expires);
|
|
const download_url = url[0].URI;
|
|
|
|
if (!(await fileExists(modSavePath))) {
|
|
await fs.mkdir(modSavePath);
|
|
}
|
|
|
|
await downloadAndUnzip(download_url, path.join(modSavePath, modId));
|
|
if (await fileExists(bepinexFolderPath)) {
|
|
await fs.cp(path.join(modSavePath, modId), path.join(bepinexFolderPath, "plugins", modId), { recursive: true });
|
|
}
|
|
|
|
saveModInfo(modId);
|
|
mainWindow.webContents.send("showToast", "Mod downloaded successfully.");
|
|
installedCachedModList = undefined;
|
|
}
|
|
|
|
ipcMain.handle("search-nexus-mods", async (event, keywords, offset, count, sortFilter, sortOrder) => {
|
|
await searchNexusMods(keywords, offset, count, sortFilter, sortOrder);
|
|
});
|
|
|
|
async function searchNexusMods(keywords, offset = 0, count = 10, sortFilter = "downloads", sortOrder = "DESC") {
|
|
if (keywords.length == 1) {
|
|
mainWindow.webContents.send("showToast", "Your query must contain at least 2 characters.", "warning");
|
|
return;
|
|
}
|
|
|
|
const endpoint = "https://api.nexusmods.com/v2/graphql";
|
|
const client = new GraphQLClient(endpoint, {
|
|
headers: {
|
|
"User-Agent": userAgent,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
const query = gql`
|
|
query Mods($filter: ModsFilter, $offset: Int, $count: Int, $sort: [ModsSort!]) {
|
|
mods(filter: $filter, offset: $offset, count: $count, sort: $sort) {
|
|
nodes {
|
|
author
|
|
endorsements
|
|
modId
|
|
name
|
|
pictureUrl
|
|
summary
|
|
updatedAt
|
|
createdAt
|
|
version
|
|
downloads
|
|
fileSize
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
`;
|
|
|
|
let variables = {
|
|
filter: {
|
|
op: "AND",
|
|
gameDomainName: [{ value: "hollowknightsilksong" }],
|
|
name: [{ value: keywords, op: "WILDCARD" }],
|
|
},
|
|
offset: offset,
|
|
count: count,
|
|
sort: [{ [sortFilter]: { direction: sortOrder } }],
|
|
};
|
|
if (!keywords) {
|
|
delete variables.filter.name;
|
|
}
|
|
|
|
const data = await client.request(query, variables);
|
|
onlineCachedModList = data.mods.nodes;
|
|
|
|
for (let i = 0; i < onlineCachedModList.length; i++) {
|
|
onlineCachedModList[i].source = "nexusmods";
|
|
if (onlineCachedModList[i].modId == 26) {
|
|
onlineCachedModList.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
onlineTotalModsCount = data.mods.totalCount;
|
|
}
|
|
|
|
ipcMain.handle("search-thunderstore-mods", async (event, keywords, offset, count, sortFilter, sortOrder) => {
|
|
await searchThunderstoreMods(keywords, offset, count, sortFilter, sortOrder);
|
|
});
|
|
|
|
async function searchThunderstoreMods(keywords, offset = 0, count = 10, sortFilter = "downloads", sortOrder = "DESC") {
|
|
const res = await fetch("https://thunderstore.io/c/hollow-knight-silksong/api/v1/package/", {
|
|
headers: {
|
|
"User-Agent": userAgent,
|
|
},
|
|
});
|
|
let modsInfo = await res.json();
|
|
|
|
const modsToRemove = [
|
|
"f21c391c-0bc5-431d-a233-95323b95e01b",
|
|
"58c9e43a-549d-4d49-a576-4eed36775a84",
|
|
"0fc63a3b-3c69-4be5-85f8-bb127eec81b3",
|
|
"d5419c5d-c22a-4a47-b73d-ba4101f28635",
|
|
"d5b65a03-1217-4496-8af6-8dda4d763676",
|
|
"42f76853-d2a4-4520-949b-13a02fdbbbcb",
|
|
"34eac80c-5497-470e-b98c-f53421b828c0",
|
|
];
|
|
let reMappedModsInfo = [];
|
|
for (let i = 0; i < modsInfo.length; i++) {
|
|
modsInfo[i].source = "thunderstore";
|
|
if (modsToRemove.includes(modsInfo[i].uuid4)) {
|
|
modsInfo.splice(i, 1);
|
|
i--;
|
|
continue;
|
|
}
|
|
reMappedModsInfo.push(reMapThunderstoreModsInfo(modsInfo[i]));
|
|
}
|
|
|
|
const result = sortAndFilterModsList(reMappedModsInfo, keywords, offset, count, sortFilter, sortOrder);
|
|
thunderstoreCachedModList = result.list;
|
|
thunderstoreTotalModsCount = result.totalCount;
|
|
}
|
|
|
|
function reMapThunderstoreModsInfo(modInfo) {
|
|
let totalDownloads = 0;
|
|
for (const version of modInfo.versions) {
|
|
totalDownloads += version.downloads;
|
|
}
|
|
|
|
return {
|
|
author: modInfo.owner,
|
|
endorsements: modInfo.rating_score,
|
|
modId: modInfo.uuid4,
|
|
name: modInfo.name,
|
|
pictureUrl: modInfo.versions[0].icon,
|
|
summary: modInfo.versions[0].description,
|
|
updatedAt: modInfo.date_updated,
|
|
createdAt: modInfo.date_created,
|
|
version: modInfo.versions[0].version_number,
|
|
downloads: totalDownloads,
|
|
fileSize: modInfo.versions[0].file_size,
|
|
source: modInfo.source,
|
|
};
|
|
}
|
|
|
|
ipcMain.handle("download-thunderstore-mods", async (event, url, modId) => {
|
|
const bepinexFolderPath = path.join(loadSilksongPath(), "BepInEx");
|
|
if (!(await fileExists(loadSilksongPath()))) {
|
|
mainWindow.webContents.send("showToast", "Path to the game invalid", "warning");
|
|
return;
|
|
}
|
|
|
|
if (!(await fileExists(modSavePath))) {
|
|
await fs.mkdir(modSavePath);
|
|
}
|
|
|
|
await downloadAndUnzip(url, path.join(modSavePath, modId));
|
|
if (await fileExists(bepinexFolderPath)) {
|
|
await fs.cp(path.join(modSavePath, modId), path.join(bepinexFolderPath, "plugins", modId), { recursive: true });
|
|
}
|
|
|
|
saveModInfo(modId);
|
|
mainWindow.webContents.send("showToast", "Mod downloaded successfully.");
|
|
installedCachedModList = undefined;
|
|
});
|
|
|
|
//////////////////////////////////////////////////////
|
|
//////////////////////// MODS ////////////////////////
|
|
|
|
ipcMain.handle("search-installed-mods", async (event, keywords, offset, count, sortFilter, sortOrder) => {
|
|
await searchInstalledMods(keywords, offset, count, sortFilter, sortOrder);
|
|
});
|
|
|
|
async function searchInstalledMods(keywords, offset = 0, count = 10, sortFilter = "name", sortOrder = "ASC") {
|
|
let modsInfo = [];
|
|
for (const [key, modInfo] of Object.entries(installedModsStore.store)) {
|
|
modsInfo.push(modInfo);
|
|
}
|
|
|
|
const result = sortAndFilterModsList(modsInfo, keywords, offset, count, sortFilter, sortOrder);
|
|
installedCachedModList = result.list;
|
|
installedTotalModsCount = result.totalCount;
|
|
}
|
|
|
|
async function checkInstalledMods() {
|
|
if (!loadSilksongPath()) {
|
|
return;
|
|
}
|
|
const bepinexPluginsPath = path.join(loadSilksongPath(), "BepInEx", "plugins");
|
|
|
|
for (const [key, modInfo] of Object.entries(installedModsStore.store)) {
|
|
modInfo.modId = String(modInfo.modId);
|
|
if (!(await fileExists(path.join(modSavePath, modInfo.modId)))) {
|
|
saveModInfo(key, true);
|
|
if (await fileExists(path.join(bepinexPluginsPath, modInfo.modId))) {
|
|
await fs.rm(path.join(bepinexPluginsPath, modInfo.modId), { recursive: true });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (modInfo.activated) {
|
|
await fs.cp(path.join(modSavePath, modInfo.modId), path.join(bepinexPluginsPath, modInfo.modId), { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("uninstall-mod", async (event, modId) => {
|
|
modId = String(modId);
|
|
const BepinexPluginsPath = path.join(loadSilksongPath(), "BepInEx", "plugins");
|
|
const modPath = path.join(BepinexPluginsPath, modId);
|
|
if (await fileExists(path.join(modSavePath, modId))) {
|
|
await fs.rm(path.join(modSavePath, modId), { recursive: true });
|
|
}
|
|
if (await fileExists(modPath)) {
|
|
await fs.rm(modPath, { recursive: true });
|
|
}
|
|
|
|
for (let i = 0; i < installedCachedModList.length; i++) {
|
|
if (installedCachedModList[i].modId == modId) {
|
|
installedCachedModList.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
saveModInfo(modId, true);
|
|
});
|
|
|
|
ipcMain.handle("activate-mod", async (event, modId) => {
|
|
await activateMod(modId);
|
|
});
|
|
|
|
async function activateMod(modId) {
|
|
if (!loadSilksongPath()) {
|
|
return;
|
|
}
|
|
|
|
const BepinexPluginsPath = path.join(loadSilksongPath(), "BepInEx", "plugins");
|
|
|
|
if (!installedModsStore.get(`${modId}.activated`)) {
|
|
installedModsStore.set(`${modId}.activated`, true);
|
|
}
|
|
|
|
if (bepinexVersion) {
|
|
if (!(await fileExists(path.join(BepinexPluginsPath, String(modId))))) {
|
|
await fs.cp(path.join(modSavePath, String(modId)), path.join(BepinexPluginsPath, String(modId)), { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("deactivate-mod", async (event, modId) => {
|
|
const BepinexPluginsPath = path.join(loadSilksongPath(), "BepInEx", "plugins");
|
|
|
|
if (installedModsStore.get(`${modId}.activated`)) {
|
|
installedModsStore.set(`${modId}.activated`, false);
|
|
|
|
if (bepinexVersion) {
|
|
if (await fileExists(path.join(BepinexPluginsPath, String(modId)))) {
|
|
await fs.rm(path.join(BepinexPluginsPath, String(modId)), { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function sortAndFilterModsList(list, keywords, offset, count, sortFilter, sortOrder) {
|
|
const listFiltered = list.filter((element) => element.name.toLowerCase().includes(keywords.toLowerCase()));
|
|
const sortFactor = sortOrder == "ASC" ? 1 : -1;
|
|
|
|
let listSorted;
|
|
if (sortFilter == "name" || sortFilter == "createdAt" || sortFilter == "updatedAt") {
|
|
listSorted = listFiltered.sort((a, b) => sortFactor * a[sortFilter].localeCompare(b[sortFilter]));
|
|
} else if (sortFilter == "downloads" || sortFilter == "endorsements" || sortFilter == "size") {
|
|
if (sortFilter == "size") {
|
|
sortFilter = "fileSize";
|
|
}
|
|
listSorted = listFiltered.sort((a, b) => sortFactor * (a[sortFilter] - b[sortFilter]));
|
|
}
|
|
|
|
return { list: listSorted.slice(offset, offset + count), totalCount: listSorted.length };
|
|
}
|
|
|
|
ipcMain.handle("add-offline-mod", async () => {
|
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
|
title: "Add mod",
|
|
properties: ["openFile"],
|
|
filters: [{ name: "mod", extensions: ["dll", "zip", "7z"] }],
|
|
});
|
|
|
|
if (canceled || !filePaths) return false;
|
|
|
|
const fileName = path.basename(filePaths[0]);
|
|
const extension = path.extname(fileName).toLowerCase();
|
|
let uuid;
|
|
do {
|
|
uuid = randomUUID();
|
|
} while (installedModsStore.get(uuid));
|
|
|
|
if (extension === ".dll") {
|
|
await fs.mkdir(path.join(modSavePath, uuid));
|
|
await fs.copyFile(filePaths[0], path.join(modSavePath, uuid, fileName));
|
|
} else if ([".zip", ".7z"].includes(extension)) {
|
|
await extractArchive(filePaths[0], path.join(modSavePath, uuid));
|
|
} else {
|
|
mainWindow.webContents.send("showToast", `Unsupported file type: ${extension}`, "error");
|
|
return false;
|
|
}
|
|
|
|
activateMod(uuid);
|
|
|
|
const time = new Date().toLocaleDateString();
|
|
const splitedTime = time.split("/");
|
|
let newTime = "";
|
|
for (let i = 2; i >= 0; i--) {
|
|
newTime = newTime.concat(splitedTime[i]);
|
|
if (i > 0) {
|
|
newTime = newTime.concat("-");
|
|
}
|
|
}
|
|
|
|
await saveModInfo(uuid, false, {
|
|
modId: uuid,
|
|
name: fileName.split(".").shift(),
|
|
summary: "Local mod",
|
|
updatedAt: newTime,
|
|
createdAt: newTime,
|
|
source: "local",
|
|
});
|
|
|
|
return true;
|
|
});
|
|
|
|
//////////////////////////////////////////////////////
|
|
//////////////////// UNCATEGORIZE ////////////////////
|
|
|
|
ipcMain.handle("auto-detect-game-path", async () => {
|
|
const defaultsSilksongPaths = [":/Program Files (x86)/Steam/steamapps/common/Hollow Knight Silksong", ":/SteamLibrary/steamapps/common/Hollow Knight Silksong"];
|
|
for (const path of defaultsSilksongPaths) {
|
|
for (let i = "A".charCodeAt(0); i <= "Z".charCodeAt(0); i++) {
|
|
const fullPath = `${String.fromCharCode(i)}${path}`;
|
|
if (await fileExists(fullPath)) {
|
|
saveSilksongPath(fullPath);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("load-main-page", () => {
|
|
htmlFile = "index.html";
|
|
mainWindow.loadFile(path.join("renderer", htmlFile));
|
|
});
|
|
|
|
ipcMain.handle("get-page", () => {
|
|
return htmlFile;
|
|
});
|
|
|
|
ipcMain.handle("open-link", async (event, link) => {
|
|
await shell.openExternal(link);
|
|
});
|
|
|
|
ipcMain.handle("open-window", async (event, file) => {
|
|
const win = new BrowserWindow({
|
|
width: 600,
|
|
height: 720,
|
|
modal: true,
|
|
parent: mainWindow,
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
},
|
|
});
|
|
|
|
win.title = file;
|
|
win.loadFile(file);
|
|
});
|
|
|
|
ipcMain.handle("launch-game", async (event, mode) => {
|
|
let silksongExecutable;
|
|
if (process.platform === "win32") {
|
|
silksongExecutable = "Hollow Knight Silksong.exe";
|
|
} else if (process.platform === "linux") {
|
|
silksongExecutable = "Hollow Knight Silksong";
|
|
}
|
|
|
|
const silksongExecutablePath = path.join(loadSilksongPath(), silksongExecutable);
|
|
const silksongScriptPath = path.join(loadSilksongPath(), "run_bepinex.sh");
|
|
const bepinexFolderPath = path.join(loadSilksongPath(), "BepInEx");
|
|
if (!fileExists(silksongExecutablePath)) {
|
|
mainWindow.webContents.send("showToast", "Path to the game invalid", "warning");
|
|
return;
|
|
}
|
|
|
|
if (mode === "modded") {
|
|
if (await fileExists(bepinexFolderPath)) {
|
|
if (process.platform === "win32") {
|
|
executeGame(silksongExecutablePath);
|
|
}
|
|
if (process.platform === "linux") {
|
|
if (loadLinuxSteam()) {
|
|
if (!(await getSteamLinuxState())) {
|
|
mainWindow.webContents.send("showToast", "Preparing Steam");
|
|
await prepareSteamLinux(true);
|
|
}
|
|
const game = spawn("steam", ["-applaunch", "1030300"], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
game.unref();
|
|
return;
|
|
}
|
|
executeGame(silksongScriptPath, [silksongExecutable]);
|
|
}
|
|
} else {
|
|
await installBepinex();
|
|
if (process.platform === "win32") {
|
|
executeGame(silksongExecutablePath);
|
|
}
|
|
if (process.platform === "linux") {
|
|
if (loadLinuxSteam()) {
|
|
if (!(await getSteamLinuxState())) {
|
|
mainWindow.webContents.send("showToast", "Preparing Steam");
|
|
await prepareSteamLinux(true);
|
|
}
|
|
const game = spawn("steam", ["-applaunch", "1030300"], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
game.unref();
|
|
return;
|
|
}
|
|
executeGame(silksongScriptPath, [silksongExecutable]);
|
|
}
|
|
}
|
|
}
|
|
if (mode === "vanilla") {
|
|
if (await fileExists(bepinexFolderPath)) {
|
|
await backupBepinex();
|
|
if (process.platform === "linux" && loadLinuxSteam() && (await getSteamLinuxState())) {
|
|
mainWindow.webContents.send("showToast", "Preparing Steam");
|
|
await prepareSteamLinux(false);
|
|
}
|
|
executeGame(silksongExecutablePath);
|
|
} else {
|
|
if (process.platform === "linux" && loadLinuxSteam() && (await getSteamLinuxState())) {
|
|
mainWindow.webContents.send("showToast", "Preparing Steam");
|
|
await prepareSteamLinux(false);
|
|
}
|
|
executeGame(silksongExecutablePath);
|
|
}
|
|
}
|
|
});
|
|
|
|
async function executeGame(path, args = []) {
|
|
await fs.chmod(path, 0o755);
|
|
const game = spawn(path, args, {
|
|
cwd: loadSilksongPath(),
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
game.unref();
|
|
}
|
|
|
|
async function downloadAndUnzip(url, toPath) {
|
|
url = new URL(url);
|
|
const fileName = url.pathname.split("/").pop();
|
|
const extension = fileName.split(".").pop().toLowerCase();
|
|
|
|
const download = await fetch(url.href);
|
|
if (!download.ok) {
|
|
mainWindow.webContents.send("showToast", "Error during download.", "error");
|
|
return;
|
|
}
|
|
|
|
const tempPath = path.join(userSavePath, `tempArchive.${extension}`);
|
|
await pipeline(download.body, createWriteStream(tempPath));
|
|
await extractArchive(tempPath, toPath);
|
|
await fs.unlink(tempPath);
|
|
}
|
|
|
|
async function extractArchive(archivePath, destPath) {
|
|
if (process.platform === "linux") {
|
|
await prepareSevenZipLinux();
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const stream = extractFull(archivePath, destPath, {
|
|
$bin: sevenZipPath,
|
|
});
|
|
|
|
stream.on("end", resolve);
|
|
stream.on("error", reject);
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("get-version", () => {
|
|
return VERSION;
|
|
});
|
|
|
|
async function prepareSevenZipLinux() {
|
|
const targetPath = path.join(tempPath, "7za");
|
|
if (await fileExists(targetPath)) {
|
|
sevenZipPath = targetPath;
|
|
return;
|
|
}
|
|
|
|
await fs.copyFile(sevenZipPath, targetPath);
|
|
await fs.chmod(targetPath, 0o755);
|
|
|
|
sevenZipPath = targetPath;
|
|
}
|
|
|
|
async function prepareSteamLinux(isModded) {
|
|
const kill = spawn("killall", ["steam"]);
|
|
kill.unref();
|
|
await waitForSteamToClose();
|
|
|
|
const steamUserDataPath = path.join(process.env.HOME, ".local", "share", "Steam", "userdata");
|
|
const usersId = await fs.readdir(steamUserDataPath);
|
|
|
|
for (const userId of usersId) {
|
|
const steamConfigPath = path.join(steamUserDataPath, userId, "config", "localconfig.vdf");
|
|
const rawConfig = await fs.readFile(steamConfigPath, { encoding: "utf8" });
|
|
let parsedConfig = vdf.parse(rawConfig);
|
|
if (isModded) {
|
|
parsedConfig.UserLocalConfigStore.Software.Valve.Steam.apps[1030300].LaunchOptions = "./run_bepinex.sh %command%";
|
|
} else {
|
|
parsedConfig.UserLocalConfigStore.Software.Valve.Steam.apps[1030300].LaunchOptions = "";
|
|
}
|
|
|
|
const config = vdf.stringify(parsedConfig);
|
|
await fs.writeFile(steamConfigPath, config, { encoding: "utf8" });
|
|
}
|
|
|
|
const steam = spawn("steam", [], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
steam.unref();
|
|
}
|
|
|
|
async function getSteamLinuxState() {
|
|
const steamUserDataPath = path.join(process.env.HOME, ".local", "share", "Steam", "userdata");
|
|
const usersId = await fs.readdir(steamUserDataPath);
|
|
|
|
let result = [];
|
|
for (const userId of usersId) {
|
|
const steamConfigPath = path.join(steamUserDataPath, userId, "config", "localconfig.vdf");
|
|
const rawConfig = await fs.readFile(steamConfigPath, { encoding: "utf8" });
|
|
let parsedConfig = vdf.parse(rawConfig);
|
|
|
|
result.push(parsedConfig.UserLocalConfigStore.Software.Valve.Steam.apps[1030300].LaunchOptions === "./run_bepinex.sh %command%");
|
|
}
|
|
return result.every(Boolean);
|
|
}
|
|
|
|
function waitForSteamToClose() {
|
|
return new Promise((resolve) => {
|
|
function check() {
|
|
const pgrep = spawn("pgrep", ["-x", "steam"]);
|
|
|
|
pgrep.on("close", (code) => {
|
|
if (code == 1) {
|
|
resolve();
|
|
} else {
|
|
setTimeout(check, 500);
|
|
}
|
|
});
|
|
}
|
|
|
|
check();
|
|
});
|
|
}
|