Add mod search via Nexus GraphQL API and user toast feedback

This commit is contained in:
2026-02-19 15:36:12 +01:00
parent 72ff22861b
commit 492227f6cb
9 changed files with 1970 additions and 1746 deletions

3
.gitignore vendored
View File

@@ -139,3 +139,6 @@ dist
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
.vite/ .vite/
#prettier
.prettierignore

View File

@@ -727,3 +727,54 @@ copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS END OF TERMS AND CONDITIONS
================================= =================================
=================================
GRAPHQL-REQUEST
=================================
MIT License
Copyright (c) 2022 Jason Kuhrt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
=================================
=================================
GRAPHQL
=================================
MIT License
Copyright (c) GraphQL Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

84
main.js
View File

@@ -7,7 +7,7 @@ import { createWriteStream } from "fs";
import { pipeline } from "stream/promises"; import { pipeline } from "stream/promises";
import extract from "extract-zip"; import extract from "extract-zip";
import NexusModule from "@nexusmods/nexus-api"; import NexusModule from "@nexusmods/nexus-api";
const Nexus = NexusModule.default; import { gql, GraphQLClient } from "graphql-request";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -23,9 +23,12 @@ const modSavePath = `${userSavePath}\\mods`;
const dataPath = `${userSavePath}\\config.json`; const dataPath = `${userSavePath}\\config.json`;
let silksongPath = store.get("silksong-path"); let silksongPath = store.get("silksong-path");
const Nexus = NexusModule.default;
let nexusAPI = store.get("nexus-api"); let nexusAPI = store.get("nexus-api");
let nexus = undefined; let nexus = undefined;
createNexus(); createNexus();
let cachedModList = undefined;
let query = "";
let bepinexFolderPath = `${silksongPath}/BepInEx`; let bepinexFolderPath = `${silksongPath}/BepInEx`;
let bepinexBackupPath = `${silksongPath}/BepInEx-Backup`; let bepinexBackupPath = `${silksongPath}/BepInEx-Backup`;
@@ -154,12 +157,16 @@ ipcMain.handle("save-nexus-api", (event, api) => {
store.set("nexus-api", nexusAPI); store.set("nexus-api", nexusAPI);
}); });
ipcMain.handle("load-nexus-api", () => { function loadNexusApi() {
nexusAPI = store.get("nexus-api"); nexusAPI = store.get("nexus-api");
if (nexusAPI == undefined) { if (nexusAPI == undefined) {
return ""; return "";
} }
return nexusAPI; return nexusAPI;
}
ipcMain.handle("load-nexus-api", () => {
return loadNexusApi();
}); });
ipcMain.handle("save-theme", (event, theme, lacePinState) => { ipcMain.handle("save-theme", (event, theme, lacePinState) => {
@@ -197,6 +204,10 @@ ipcMain.handle("load-installed-mods-info", () => {
for (const [key, modInfo] of Object.entries(installedModsStore.store)) { for (const [key, modInfo] of Object.entries(installedModsStore.store)) {
modsInfo.push(modInfo); modsInfo.push(modInfo);
} }
modsInfo.sort((a, b) => a.name.localeCompare(b.name));
modsInfo = modsInfo.filter((mod) => mod.name.toLowerCase().includes(query.toLowerCase())).sort((a, b) => a.name.localeCompare(b.name));
return modsInfo; return modsInfo;
}); });
@@ -386,17 +397,21 @@ async function verifyNexusAPI() {
} }
} }
ipcMain.handle("get-latest-mods", async () => { ipcMain.handle("get-mods", async () => {
if (!cachedModList) {
if (!(await verifyNexusAPI())) { if (!(await verifyNexusAPI())) {
mainWindow.webContents.send("showToast", "Unable to fetch mods.", "error");
return; return;
} }
cachedModList = await nexus.getLatestAdded();
}
const mods = await nexus.getLatestAdded(); return cachedModList;
return mods;
}); });
ipcMain.handle("open-download", async (event, link) => { ipcMain.handle("open-download", async (event, link) => {
if (!(await verifyNexusAPI())) { if (!(await verifyNexusAPI())) {
mainWindow.webContents.send("showToast", "Unable to download.", "error");
return; return;
} }
@@ -429,6 +444,7 @@ function handleNxmUrl(url) {
async function startDownload(modId, fileId, key, expires) { async function startDownload(modId, fileId, key, expires) {
if (!(await verifyNexusAPI())) { if (!(await verifyNexusAPI())) {
mainWindow.webContents.send("showToast", "Unable to download.", "error");
return; return;
} }
@@ -445,6 +461,7 @@ async function startDownload(modId, fileId, key, expires) {
} }
saveModInfo(modId); saveModInfo(modId);
mainWindow.webContents.send("showToast", "Mod downloaded successfully.");
} }
async function checkInstalledMods() { async function checkInstalledMods() {
@@ -468,6 +485,60 @@ ipcMain.handle("uninstall-mod", async (event, modId) => {
saveModInfo(modId, true); saveModInfo(modId, true);
}); });
ipcMain.handle("search-nexus-mods", async (event, keywords) => {
const count = 10;
const endpoint = "https://api.nexusmods.com/v2/graphql";
const client = new GraphQLClient(endpoint, {
headers: {
"Content-Type": "application/json",
},
});
const query = gql`
query Mods($filter: ModsFilter, $offset: Int, $count: Int) {
mods(filter: $filter, offset: $offset, count: $count) {
nodes {
author
endorsements
modId
name
pictureUrl
summary
updatedAt
version
}
}
}
`;
const variables = {
filter: {
op: "AND",
gameDomainName: [{ value: "hollowknightsilksong" }],
name: [{ value: keywords, op: "WILDCARD" }],
},
offset: 0,
count: count,
};
const data = await client.request(query, variables);
for (let i = 0; i < data.mods.nodes.length; i++) {
data.mods.nodes[i].mod_id = data.mods.nodes[i].modId;
delete data.mods.nodes[i].modId;
data.mods.nodes[i].picture_url = data.mods.nodes[i].pictureUrl;
delete data.mods.nodes[i].pictureUrl;
data.mods.nodes[i].endorsement_count = data.mods.nodes[i].endorsements;
delete data.mods.nodes[i].endorsements;
data.mods.nodes[i].updated_time = data.mods.nodes[i].updatedAt;
delete data.mods.nodes[i].updatedAt;
}
cachedModList = data.mods.nodes;
});
ipcMain.handle("search-installed-mods", async (event, keywords) => {
query = keywords;
});
////////////////////////////////////////////////////// //////////////////////////////////////////////////////
//////////////////// UNCATEGORIZE //////////////////// //////////////////// UNCATEGORIZE ////////////////////
@@ -536,7 +607,8 @@ ipcMain.handle("launch-game", async (event, mode) => {
async function downloadAndUnzip(url, path) { async function downloadAndUnzip(url, path) {
const download = await fetch(url); const download = await fetch(url);
if (!download.ok) { if (!download.ok) {
throw new Error("Download error"); mainWindow.webContents.send("showToast", "Error during download.", "error");
return;
} }
const tempPath = `${userSavePath}\\tempZip.zip`; const tempPath = `${userSavePath}\\tempZip.zip`;

36
package-lock.json generated
View File

@@ -7,11 +7,13 @@
"": { "": {
"name": "silkflylauncher", "name": "silkflylauncher",
"version": "1.0.0", "version": "1.0.0",
"license": "SEE LICENSE IN LICENSE", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@nexusmods/nexus-api": "^1.1.5", "@nexusmods/nexus-api": "^1.1.5",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
"extract-zip": "^2.0.1" "extract-zip": "^2.0.1",
"graphql": "^16.12.0",
"graphql-request": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^39.2.7" "electron": "^39.2.7"
@@ -39,6 +41,15 @@
"global-agent": "^3.0.0" "global-agent": "^3.0.0"
} }
}, },
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@nexusmods/nexus-api": { "node_modules/@nexusmods/nexus-api": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@nexusmods/nexus-api/-/nexus-api-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@nexusmods/nexus-api/-/nexus-api-1.1.5.tgz",
@@ -898,6 +909,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphql": {
"version": "16.12.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz",
"integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/graphql-request": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-7.4.0.tgz",
"integrity": "sha512-xfr+zFb/QYbs4l4ty0dltqiXIp07U6sl+tOKAb0t50/EnQek6CVVBLjETXi+FghElytvgaAWtIOt3EV7zLzIAQ==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0"
},
"peerDependencies": {
"graphql": "14 - 16"
}
},
"node_modules/har-schema": { "node_modules/har-schema": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",

View File

@@ -16,6 +16,8 @@
"dependencies": { "dependencies": {
"@nexusmods/nexus-api": "^1.1.5", "@nexusmods/nexus-api": "^1.1.5",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
"extract-zip": "^2.0.1" "extract-zip": "^2.0.1",
"graphql": "^16.12.0",
"graphql-request": "^7.4.0"
} }
} }

View File

@@ -32,6 +32,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
launchGame: (mode) => ipcRenderer.invoke("launch-game", mode), launchGame: (mode) => ipcRenderer.invoke("launch-game", mode),
loadMainPage: () => ipcRenderer.invoke("load-main-page"), loadMainPage: () => ipcRenderer.invoke("load-main-page"),
getPage: () => ipcRenderer.invoke("get-page"), getPage: () => ipcRenderer.invoke("get-page"),
onShowToast: (callback) => {
ipcRenderer.on("showToast", (event, message, type, duration) => {
callback(message, type, duration);
});
},
}); });
contextBridge.exposeInMainWorld("bepinex", { contextBridge.exposeInMainWorld("bepinex", {
@@ -43,7 +48,9 @@ contextBridge.exposeInMainWorld("bepinex", {
contextBridge.exposeInMainWorld("nexus", { contextBridge.exposeInMainWorld("nexus", {
verifyAPI: () => ipcRenderer.invoke("verify-nexus-api"), verifyAPI: () => ipcRenderer.invoke("verify-nexus-api"),
getLatestMods: () => ipcRenderer.invoke("get-latest-mods"), getMods: () => ipcRenderer.invoke("get-mods"),
download: (link) => ipcRenderer.invoke("open-download", link), download: (link) => ipcRenderer.invoke("open-download", link),
uninstall: (modId) => ipcRenderer.invoke("uninstall-mod", modId), uninstall: (modId) => ipcRenderer.invoke("uninstall-mod", modId),
search: (keywords) => ipcRenderer.invoke("search-nexus-mods", keywords),
searchInstalled: (keywords) => ipcRenderer.invoke("search-installed-mods", keywords),
}); });

View File

@@ -56,6 +56,7 @@
<main class="content"> <main class="content">
<h1 id="title">Silk Fly Launcher</h1> <h1 id="title">Silk Fly Launcher</h1>
<div class="view" id="view"></div> <div class="view" id="view"></div>
<div class="toast-div" id="toast-div"></div>
</main> </main>
</div> </div>

View File

@@ -139,7 +139,7 @@ async function navigate(page) {
view.appendChild(onlineModsTemplateCopy); view.appendChild(onlineModsTemplateCopy);
mods = await nexus.getLatestMods(); mods = await nexus.getMods();
if (mods == undefined) { if (mods == undefined) {
break; break;
} }
@@ -172,7 +172,7 @@ async function navigate(page) {
const modPicture = modTemplateCopy.getElementById("mod-icon"); const modPicture = modTemplateCopy.getElementById("mod-icon");
modPicture.src = mod.picture_url; modPicture.src = mod.picture_url;
} }
if (mod.version && mod.updated_timestamp) { if (mod.version && mod.updated_time) {
const modVersionText = modTemplateCopy.getElementById("mod-version"); const modVersionText = modTemplateCopy.getElementById("mod-version");
modVersionText.innerText = `V${mod.version} last updated on ${mod.updated_time.slice(0, 10)}`; modVersionText.innerText = `V${mod.version} last updated on ${mod.updated_time.slice(0, 10)}`;
} }
@@ -397,7 +397,8 @@ async function setBepinexVersion() {
async function searchInstalledMods() { async function searchInstalledMods() {
const searchInput = document.getElementById("search-input"); const searchInput = document.getElementById("search-input");
console.log(searchInput.value); await nexus.searchInstalled(searchInput.value);
navigate("refresh");
} }
////////////////////////////////////////////////////// //////////////////////////////////////////////////////
@@ -420,7 +421,8 @@ async function verifyNexusAPI() {
async function searchNexusMods() { async function searchNexusMods() {
const searchInput = document.getElementById("search-input"); const searchInput = document.getElementById("search-input");
console.log(searchInput.value); await nexus.search(searchInput.value);
navigate("refresh");
} }
////////////////////////////////////////////////////// //////////////////////////////////////////////////////
@@ -501,3 +503,20 @@ async function autoDetectGamePath() {
document.getElementById("silksong-path-input").value = await files.loadSilksongPath(); document.getElementById("silksong-path-input").value = await files.loadSilksongPath();
} }
} }
function showToast(message, type = "info", duration = 3000) {
const toastDiv = document.getElementById("toast-div");
const toast = document.createElement("p");
toast.classList.add("toast", type);
toast.innerText = message;
toastDiv.appendChild(toast);
toast.classList.add("show");
setTimeout(() => {
toast.classList.remove("show");
toast.addEventListener("transitionend", () => toast.remove());
}, duration);
}
electronAPI.onShowToast(showToast);

View File

@@ -235,7 +235,7 @@ body {
} }
.mods-container { .mods-container {
height: 80%; height: 70%;
transform: translateY(50px); transform: translateY(50px);
padding: 20px; padding: 20px;
overflow: auto; overflow: auto;
@@ -382,3 +382,40 @@ body {
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
transform: rotate(45deg); transform: rotate(45deg);
} }
.toast-div {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 10;
}
.toast {
width: 300px;
background: var(--transparent-black);
padding: 10px 20px;
border: 1px solid var(--secondary-color);
border-radius: 32px;
opacity: 0;
transform: translateY(20px);
transition: all 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.error {
color: #f44336;
}
.toast.info {
color: var(--text-color);
}
.toast.warning {
color: #ff9800;
}