Files
Silk-Fly-Launcher/main.js
2026-03-10 21:40:39 +01:00

834 lines
25 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";
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 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;
const bepinexFiles = [".doorstop_version", "changelog.txt", "doorstop_config.ini", "winhttp.dll"];
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"),
},
show: false,
});
if (await fileExists(dataPath)) {
htmlFile = "index.html";
} else {
htmlFile = "welcome.html";
}
mainWindow.loadFile(path.join("renderer", htmlFile));
mainWindow.once("ready-to-show", () => {
mainWindow.show();
if (!isDev) {
verifyUpdate();
}
});
}
app.whenReady().then(() => {
if (isDev) {
app.setAsDefaultProtocolClient("nxm", process.execPath, [path.resolve(process.argv[1])]);
} else {
app.setAsDefaultProtocolClient("nxm");
sevenZipPath = path7za.replace("\\app.asar\\node_modules", "");
Menu.setApplicationMenu(null);
}
if (gotTheLock) {
createNexus(loadNexusApi());
checkInstalledMods();
createWindow();
}
app.on("activate", () => {
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);
}
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) {
if (suppr == true) {
installedModsStore.delete(String(modId));
return;
}
const modInfo = onlineCachedModList.find((mod) => mod.modId == modId);
modInfo.activated = true;
installedModsStore.set(String(modId), modInfo);
}
//////////////////////////////////////////////////////
/////////////////// 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();
const asset = release.assets.find((a) => a.name.endsWith(".zip") && a.name.toLowerCase().includes("win_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");
}
if (error.code == "ENOTFOUND") {
mainWindow.webContents.send("showToast", "Unable to communicate with Nexus servers", "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 };
}
});
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++) {
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) => {
searchThunderstoreMods(keywords, offset, count, sortFilter, sortOrder);
});
async function searchThunderstoreMods(keywords, offset = 0, count = 10, sortFilter = "downloads", sortOrder = "DESC") {
console.log("WIP");
}
//////////////////////////////////////////////////////
//////////////////////// 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 modsInfoFiltered = modsInfo.filter((mod) => mod.name.toLowerCase().includes(keywords.toLowerCase()));
const sortFactor = sortOrder == "ASC" ? 1 : -1;
let modsInfoSorted;
if (sortFilter == "name" || sortFilter == "createdAt" || sortFilter == "updatedAt") {
modsInfoSorted = modsInfoFiltered.sort((a, b) => sortFactor * a[sortFilter].localeCompare(b[sortFilter]));
} else if (sortFilter == "downloads" || sortFilter == "endorsements" || sortFilter == "size") {
if (sortFilter == "size") {
sortFilter = "fileSize";
}
modsInfoSorted = modsInfoFiltered.sort((a, b) => sortFactor * (a[sortFilter] - b[sortFilter]));
}
installedTotalModsCount = modsInfoSorted.length;
installedCachedModList = modsInfoSorted.slice(offset, offset + count);
}
async function checkInstalledMods() {
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) => {
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 });
}
}
}
});
//////////////////////////////////////////////////////
//////////////////// 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) => {
const silksongExecutablePath = path.join(loadSilksongPath(), "Hollow Knight Silksong.exe");
if (!fileExists(silksongExecutablePath)) {
mainWindow.webContents.send("showToast", "Path to the game invalid", "warning");
return;
}
if (mode === "modded") {
if (await fileExists(bepinexFolderPath)) {
await shell.openExternal(silksongExecutablePath);
} else {
await installBepinex();
await shell.openExternal(silksongExecutablePath);
}
}
if (mode === "vanilla") {
if (await fileExists(bepinexFolderPath)) {
await backupBepinex();
await shell.openExternal(silksongExecutablePath);
} else {
await shell.openExternal(silksongExecutablePath);
}
}
});
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);
}
function extractArchive(archivePath, destPath) {
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;
});