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; let allThunderstoreCachedModList; let allThunderstoreCachedModListNeedRefresh = true; 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()); await checkInstalledMods(); await checkForCoreAndPatcherMods(); 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 GitHub! Your current version is ${VERSION}.`, ); } } ////////////////////////////////////////////////////// ///////////////// SAVING AND LOADING ///////////////// ipcMain.handle("save-path", async (event, path) => { await saveSilksongPath(path); }); async function saveSilksongPath(path) { store.set("silksong-path", path); await checkInstalledMods(); await checkForCoreAndPatcherMods(); } 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); } await checkInstalledMods(); await checkForCoreAndPatcherMods(); } 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"); const bepinexCorePath = path.join(silksongPath, "BepInEx", "core", "custom"); const bepinexPatcherPath = path.join(silksongPath, "BepInEx", "patchers", "custom"); 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 (await fileExists(BepinexPluginsPath)) { await fs.rm(BepinexPluginsPath, { recursive: true }); } if (await fileExists(bepinexCorePath)) { await fs.rm(bepinexCorePath, { recursive: true }); } if (await fileExists(bepinexPatcherPath)) { await fs.rm(bepinexPatcherPath, { 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") { if (allThunderstoreCachedModListNeedRefresh) { 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", "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])); } allThunderstoreCachedModList = reMappedModsInfo; allThunderstoreCachedModListNeedRefresh = false; setTimeout( () => { allThunderstoreCachedModListNeedRefresh = true; }, 10 * 60 * 1000, ); } const result = sortAndFilterModsList(allThunderstoreCachedModList, 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, dependencies: modInfo.versions[0].dependencies, }; } ipcMain.handle("download-thunderstore-mods", async (event, url, modId) => { await downloadThunderstoreMods(url, modId); }); async function downloadThunderstoreMods(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); await downloadThunderstoreModsDependencies(modId); await checkForCoreAndPatcherMods(); mainWindow.webContents.send("showToast", "Mod downloaded successfully."); installedCachedModList = undefined; } async function downloadThunderstoreModsDependencies(modId) { const dependencies = allThunderstoreCachedModList.find((mod) => mod.modId == modId).dependencies; for (const dependency of dependencies) { const dependencyArray = dependency.split("-"); const modInfo = allThunderstoreCachedModList.find((mod) => mod.author === dependencyArray[0] && mod.name === dependencyArray[1]); if (modInfo) { const bepinexFolderPath = path.join(loadSilksongPath(), "BepInEx"); const url = `https://thunderstore.io/package/download/${dependencyArray[0]}/${dependencyArray[1]}/${dependencyArray[2]}`; await downloadAndUnzip(url, path.join(modSavePath, modInfo.modId)); if (await fileExists(bepinexFolderPath)) { await fs.cp(path.join(modSavePath, modInfo.modId), path.join(bepinexFolderPath, "plugins", modInfo.modId), { recursive: true }); } saveModInfo(modInfo.modId, undefined, modInfo); await downloadThunderstoreModsDependencies(modInfo.modId); } } } ////////////////////////////////////////////////////// //////////////////////// 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); checkForCoreAndPatcherMods(); }); 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 }); } } checkForCoreAndPatcherMods(); } 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 }); } } checkForCoreAndPatcherMods(); } }); 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; }); async function checkForCoreAndPatcherMods() { const bepinexFolderPath = path.join(loadSilksongPath(), "BepInEx"); const bepinexPluginsPath = path.join(bepinexFolderPath, "Plugins"); const bepinexCorePath = path.join(bepinexFolderPath, "core", "custom"); const bepinexPatcherPath = path.join(bepinexFolderPath, "patchers", "custom"); if (await fileExists(bepinexCorePath)) { await fs.rm(bepinexCorePath, { recursive: true }); } if (await fileExists(bepinexPatcherPath)) { await fs.rm(bepinexPatcherPath, { recursive: true }); } await fs.mkdir(bepinexCorePath, { recursive: true }); await fs.mkdir(bepinexPatcherPath, { recursive: true }); async function scanDir(dirPath, relativePath = "") { let entries; try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory()) continue; const currentRelative = path.join(relativePath, entry.name); if (entry.name === "core") { await copyContents(path.join(dirPath, entry.name), bepinexCorePath); if (await fileExists(path.join(bepinexPluginsPath, currentRelative))) { await fs.rm(path.join(bepinexPluginsPath, currentRelative), { recursive: true }); } } else if (entry.name === "patchers") { await copyContents(path.join(dirPath, entry.name), bepinexPatcherPath); if (await fileExists(path.join(bepinexPluginsPath, currentRelative))) { await fs.rm(path.join(bepinexPluginsPath, currentRelative), { recursive: true }); } } else { if (!(await fileExists(path.join(bepinexPluginsPath, currentRelative)))) continue; await scanDir(path.join(dirPath, entry.name), currentRelative); } } } async function copyContents(srcDir, destDir) { let files; try { files = await fs.readdir(srcDir); } catch { return; } for (const file of files) { const src = path.join(srcDir, file); const dest = path.join(destDir, file); await fs.copyFile(src, dest); } } await scanDir(modSavePath); } ////////////////////////////////////////////////////// //////////////////// 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(); }); }