Skip to content

Commit

Permalink
Basic integration with Nexus Mods API for auto-updating mods
Browse files Browse the repository at this point in the history
  • Loading branch information
olegbl committed Jul 1, 2024
1 parent 84516e0 commit e015560
Show file tree
Hide file tree
Showing 17 changed files with 761 additions and 78 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
"history": "^5.2.0",
"json5": "^2.2.0",
"mui-color-input": "^2.0.3",
"mui-nested-menu": "^3.4.0",
"node-addon-api": "^4.3.0",
"react": "^18.0.0",
"react-beautiful-dnd": "^13.1.1",
Expand Down
24 changes: 24 additions & 0 deletions src/bridge/ModUpdaterAPI.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export type ModUpdaterNexusDownload = {
type: 'nexus';
version: string;
modID: string;
fileID: number;
};

export type ModUpdaterDownload = ModUpdaterNexusDownload;

export type IModUpdaterAPI = {
getDownloadsViaNexus: (
nexusApiKey: string,
nexusModID: string,
) => Promise<ModUpdaterDownload[]>;
installModViaNexus: (
modID: string,
nexusApiKey: string,
nexusModID: string,
nexusFileID: number,
// when installing from a .nxm link
key?: string,
expires?: number,
) => Promise<void>;
};
84 changes: 84 additions & 0 deletions src/bridge/NexusModsAPI.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// /v1/games/{game_domain_name}/mods/{id}.json
// Retrieve specified mod, from a specified game
export type Mod = {
name: string;
summary: string;
description: string;
picture_url: string;
mod_downloads: number;
mod_unique_downloads: number;
uid: number;
mod_id: number;
game_id: number;
allow_rating: boolean;
domain_name: string;
category_id: number;
version: string;
endorsement_count: number;
created_timestamp: number;
created_time: string;
updated_timestamp: number;
updated_time: string;
author: string;
uploaded_by: string;
uploaded_users_profile_url: string;
contains_adult_content: boolean;
status: string;
available: boolean;
user: {
member_id: number;
member_group_id: number;
name: string;
};
endorsement: {
endorse_status: string;
timestamp: number | null;
version: string | null;
};
};

export type File = {
category_id: number;
category_name: string;
changelog_html: string;
content_preview_link: string;
description: string;
external_virus_scan_url: string;
file_id: number;
file_name: string;
id: number[];
is_primary: boolean;
mod_version: string;
name: string;
size: number;
size_in_bytes: number;
size_kb: number;
uid: number;
uploaded_time: string;
uploaded_timestamp: number;
version: string;
};

export type FileUpdate = {
new_file_id: number;
new_file_name: string;
old_file_id: number;
old_file_name: string;
uploaded_time: string;
uploaded_timestamp: number;
};

// /v1/games/{game_domain_name}/mods/{mod_id}/files.json
// List files for specified mod
export type Files = {
files: File[];
file_updates: FileUpdate[];
};

// /v1/games/{game_domain_name}/mods/{mod_id}/files/{id}/download_link.json
// Generate download link for mod file
export type DownloadLink = {
name: string;
short_name: string;
URI: string;
}[];
16 changes: 13 additions & 3 deletions src/bridge/RequestAPI.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
export type RequestHeaders = Record<string, string>;

export type ResponseHeaders = Record<string, string | string[]>;

export type IRequestAPI = {
download: (
url: string,
fileName?: string | null,
eventID?: string | null,
) => Promise<string>;
options?: {
fileName?: string | null;
eventID?: string | null;
headers?: RequestHeaders | null;
} | null,
) => Promise<{
filePath: string;
headers: ResponseHeaders;
}>;
};
16 changes: 10 additions & 6 deletions src/main/RequestAPI.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { app, net } from 'electron';
import { createWriteStream, mkdirSync } from 'fs';
import { createWriteStream, mkdirSync, rmSync } from 'fs';
import path from 'path';
import type { IRequestAPI } from 'bridge/RequestAPI';
import { EventAPI } from './EventAPI';
Expand All @@ -12,21 +12,25 @@ export async function initRequestAPI(): Promise<void> {
provideAPI('RequestAPI', {
// splitting the API into 2 parts allows requestors to
// set up event listeners for a request before sending it
async download(url, fileName, eventID) {
async download(url, options) {
return new Promise((resolve, reject) => {
const filePath = path.join(
app.getPath('temp'),
'D2RMM',
'RequestAPI',
fileName ?? `${REQUEST_ID++}.dat`,
options?.fileName ?? `${REQUEST_ID++}.dat`,
);
mkdirSync(path.dirname(filePath), { recursive: true });
rmSync(filePath, { force: true });
const file = createWriteStream(filePath);
let bytesTotal = 0;
let bytesDownloaded = 0;
let lastEventTime = 0;

const request = net.request(url);
for (const [key, value] of Object.entries(options?.headers ?? {})) {
request.setHeader(key, value);
}
request.on('response', (response) => {
bytesTotal = parseInt(
response.headers['content-length'] as string,
Expand All @@ -35,18 +39,18 @@ export async function initRequestAPI(): Promise<void> {
response.on('error', reject);
response.on('end', () => {
file.end();
resolve(filePath);
resolve({ filePath, headers: response.headers });
});
response.on('data', (buffer: Buffer) => {
bytesDownloaded += buffer.length;
file.write(buffer);

if (
eventID != null &&
options?.eventID != null &&
Date.now() - lastEventTime > THROTTLE_TIME_MS
) {
lastEventTime = Date.now();
EventAPI.send(eventID, {
EventAPI.send(options?.eventID, {
// IPC has trouble with Buffer so send it as number[]
bytesDownloaded,
bytesTotal,
Expand Down
153 changes: 153 additions & 0 deletions src/main/worker/ModUpdaterAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import decompress from 'decompress';
import { cpSync, existsSync, mkdirSync, rmSync } from 'fs';
import path from 'path';
import type { IModUpdaterAPI } from 'bridge/ModUpdaterAPI';
import type { DownloadLink, Files } from 'bridge/NexusModsAPI';
import type { ResponseHeaders } from 'bridge/RequestAPI';
import { getAppPath } from './AppInfoAPI';
import { EventAPI } from './EventAPI';
import { provideAPI } from './IPC';
import { RequestAPI } from './RequestAPI';

// TODO: publish status of update checking / downloading / installing for nice UX

const NexusAPI = {
publishStatus: async (headers: ResponseHeaders): Promise<void> => {
// TODO: add Nexus API rate limiting status to the UI somewhere
await EventAPI.send('nexus-mods-api-status', {
dailyLimit: headers['x-rl-daily-limit'],
dailyRemaining: headers['x-rl-daily-remaining'],
dailyReset: headers['x-rl-daily-reset'],
hourlyLimit: headers['x-rl-hourly-limit'],
hourlyRemaining: headers['x-rl-hourly-remaining'],
hourlyReset: headers['x-rl-hourly-reset'],
});
},
getFiles: async (nexusApiKey: string, nexusModID: string): Promise<Files> => {
const { response, headers } = await RequestAPI.downloadToBuffer(
`https://api.nexusmods.com/v1/games/diablo2resurrected/mods/${nexusModID}/files.json`,
{
headers: {
accept: 'application/json',
apikey: nexusApiKey,
},
},
);
await NexusAPI.publishStatus(headers);
return JSON.parse(response.toString()) as Files;
},
getDownloadLink: async (
nexusApiKey: string,
nexusModID: string,
nexusFileID: number,
key?: string,
expires?: number,
): Promise<DownloadLink> => {
const args =
key != null && expires != null ? `?key=${key}&expires=${expires}` : '';
const { response, headers } = await RequestAPI.downloadToBuffer(
`https://api.nexusmods.com/v1/games/diablo2resurrected/mods/${nexusModID}/files/${nexusFileID}/download_link.json${args}`,
{
headers: {
accept: 'application/json',
apikey: nexusApiKey,
},
},
);
await NexusAPI.publishStatus(headers);
return JSON.parse(response.toString()) as DownloadLink;
},
};

export async function initModUpdaterAPI(): Promise<void> {
provideAPI('ModUpdaterAPI', {
getDownloadsViaNexus: async (nexusApiKey, nexusModID) => {
const result = await NexusAPI.getFiles(nexusApiKey, nexusModID);
return result.files
.filter(
(file) =>
file.category_name === 'MAIN' ||
file.category_name === 'OLD_VERSION',
)
.map((file) => ({
type: 'nexus',
version: file.version,
modID: nexusModID,
fileID: file.file_id,
}));
},
installModViaNexus: async (
modID,
nexusApiKey,
nexusModID,
nexusFileID,
key,
expires,
) => {
// get link to the zip file on Nexus Mods CDN
const downloadLink = await NexusAPI.getDownloadLink(
nexusApiKey,
nexusModID,
nexusFileID,
key,
expires,
);
const downloadUrl = downloadLink[0]?.URI;
if (downloadUrl == null) {
throw new Error(
`No download links found for Nexus mod ${nexusModID} file ${nexusFileID}.`,
);
}

// download the zip file
const fileName = `${modID}.zip`;
const { filePath } = await RequestAPI.downloadToFile(downloadUrl, {
fileName,
});
console.log('update downloaded', nexusModID, nexusFileID, filePath);

// extract the zip file
process.noAsar = true;
await decompress(filePath, path.dirname(filePath));
process.noAsar = false;
rmSync(filePath);

// delete all mod files except mod.config
const updateDirPath = path.join(path.dirname(filePath), modID);
console.log('update extracted', nexusModID, nexusFileID, updateDirPath);
if (!existsSync(updateDirPath)) {
throw new Error(
`Mod has an unexpected file structure. Expected to find directory ${modID} in downloaded .zip file.`,
);
}
const modDirPath = path.join(getAppPath(), 'mods', modID);
console.log(
'update ready',
nexusModID,
nexusFileID,
updateDirPath,
modDirPath,
);
const configFilePath = path.join(modDirPath, 'config.json');
if (existsSync(configFilePath)) {
cpSync(configFilePath, path.join(updateDirPath, 'config.json'));
}
rmSync(modDirPath, { force: true, recursive: true });
console.log(
'update prepped',
nexusModID,
nexusFileID,
updateDirPath,
modDirPath,
);

// copy the new extracted files to the mod directory
mkdirSync(modDirPath, { recursive: true });
cpSync(updateDirPath, modDirPath, { recursive: true });
console.log('update applied', nexusModID, nexusFileID, modDirPath);

// clean up
rmSync(updateDirPath, { force: true, recursive: true });
},
} as IModUpdaterAPI);
}
Loading

0 comments on commit e015560

Please sign in to comment.