From e015560df3d15aef274a200185e04448aa3ba48c Mon Sep 17 00:00:00 2001 From: Oleg Lokhvitsky Date: Sun, 30 Jun 2024 19:25:04 -0700 Subject: [PATCH] Basic integration with Nexus Mods API for auto-updating mods --- package.json | 1 + src/bridge/ModUpdaterAPI.d.ts | 24 +++ src/bridge/NexusModsAPI.d.ts | 84 ++++++++++ src/bridge/RequestAPI.d.ts | 16 +- src/main/RequestAPI.ts | 16 +- src/main/worker/ModUpdaterAPI.ts | 153 +++++++++++++++++ src/main/worker/RequestAPI.ts | 68 +++++--- src/main/worker/UpdaterAPI.ts | 14 +- src/main/worker/worker.ts | 3 + src/renderer/index.ejs | 4 +- src/renderer/react/App.tsx | 18 +- .../react/context/NexusModsContext.tsx | 60 +++++++ src/renderer/react/context/UpdatesContext.tsx | 71 ++++++++ src/renderer/react/modlist/ModListItem.tsx | 157 ++++++++++++++---- src/renderer/react/modlist/ModUpdater.tsx | 114 +++++++++++++ src/renderer/utils/version.ts | 31 ++++ yarn.lock | 5 + 17 files changed, 761 insertions(+), 78 deletions(-) create mode 100644 src/bridge/ModUpdaterAPI.d.ts create mode 100644 src/bridge/NexusModsAPI.d.ts create mode 100644 src/main/worker/ModUpdaterAPI.ts create mode 100644 src/renderer/react/context/NexusModsContext.tsx create mode 100644 src/renderer/react/context/UpdatesContext.tsx create mode 100644 src/renderer/react/modlist/ModUpdater.tsx create mode 100644 src/renderer/utils/version.ts diff --git a/package.json b/package.json index 9b49180..d98cc80 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/bridge/ModUpdaterAPI.d.ts b/src/bridge/ModUpdaterAPI.d.ts new file mode 100644 index 0000000..9bd9b19 --- /dev/null +++ b/src/bridge/ModUpdaterAPI.d.ts @@ -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; + installModViaNexus: ( + modID: string, + nexusApiKey: string, + nexusModID: string, + nexusFileID: number, + // when installing from a .nxm link + key?: string, + expires?: number, + ) => Promise; +}; diff --git a/src/bridge/NexusModsAPI.d.ts b/src/bridge/NexusModsAPI.d.ts new file mode 100644 index 0000000..9a664e6 --- /dev/null +++ b/src/bridge/NexusModsAPI.d.ts @@ -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; +}[]; diff --git a/src/bridge/RequestAPI.d.ts b/src/bridge/RequestAPI.d.ts index 4c8b31b..2b53cdb 100644 --- a/src/bridge/RequestAPI.d.ts +++ b/src/bridge/RequestAPI.d.ts @@ -1,7 +1,17 @@ +export type RequestHeaders = Record; + +export type ResponseHeaders = Record; + export type IRequestAPI = { download: ( url: string, - fileName?: string | null, - eventID?: string | null, - ) => Promise; + options?: { + fileName?: string | null; + eventID?: string | null; + headers?: RequestHeaders | null; + } | null, + ) => Promise<{ + filePath: string; + headers: ResponseHeaders; + }>; }; diff --git a/src/main/RequestAPI.ts b/src/main/RequestAPI.ts index 4b04df1..3928c56 100644 --- a/src/main/RequestAPI.ts +++ b/src/main/RequestAPI.ts @@ -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'; @@ -12,21 +12,25 @@ export async function initRequestAPI(): Promise { 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, @@ -35,18 +39,18 @@ export async function initRequestAPI(): Promise { 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, diff --git a/src/main/worker/ModUpdaterAPI.ts b/src/main/worker/ModUpdaterAPI.ts new file mode 100644 index 0000000..7bee655 --- /dev/null +++ b/src/main/worker/ModUpdaterAPI.ts @@ -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 => { + // 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 => { + 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 => { + 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 { + 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); +} diff --git a/src/main/worker/RequestAPI.ts b/src/main/worker/RequestAPI.ts index 8d96df5..b161d47 100644 --- a/src/main/worker/RequestAPI.ts +++ b/src/main/worker/RequestAPI.ts @@ -1,12 +1,16 @@ import { readFileSync, rmSync } from 'fs'; -import uuid from 'uuid'; -import type { IRequestAPI } from 'bridge/RequestAPI'; +import { v4 as uuidv4 } from 'uuid'; +import type { + IRequestAPI, + RequestHeaders, + ResponseHeaders, +} from 'bridge/RequestAPI'; import { EventAPI } from './EventAPI'; import { consumeAPI } from './IPC'; const NetworkedRequestAPI = consumeAPI('RequestAPI', {}); -type OnProgress = (progress: { +export type OnProgress = (progress: { bytesDownloaded: number; bytesTotal: number; }) => Promise; @@ -14,36 +18,48 @@ type OnProgress = (progress: { type ILocalRequestAPI = { downloadToFile( url: string, - fileName?: string, - onProgress?: OnProgress, - ): Promise; - downloadToBuffer(url: string, onProgress?: OnProgress): Promise; + options?: { + fileName?: string | null; + headers?: RequestHeaders | null; + onProgress?: OnProgress | null; + } | null, + ): Promise<{ + filePath: string; + headers: ResponseHeaders; + }>; + downloadToBuffer( + url: string, + options?: { + headers?: RequestHeaders | null; + onProgress?: OnProgress | null; + } | null, + ): Promise<{ + response: Buffer; + headers: ResponseHeaders; + }>; }; export const RequestAPI = { - async downloadToFile( - url: string, - fileName?: string, - onProgress?: OnProgress, - ): Promise { - const eventID = onProgress == null ? null : uuid.v4(); - if (eventID != null && onProgress != null) { - EventAPI.addListener(eventID, onProgress); + async downloadToFile(url, options) { + const eventID = options?.onProgress == null ? null : uuidv4(); + if (eventID != null && options?.onProgress != null) { + EventAPI.addListener(eventID, options?.onProgress); } - const filePath = await NetworkedRequestAPI.download(url, fileName, eventID); - if (eventID != null && onProgress != null) { - EventAPI.removeListener(eventID, onProgress); + const { filePath, headers } = await NetworkedRequestAPI.download(url, { + eventID, + fileName: options?.fileName, + headers: options?.headers, + }); + if (eventID != null && options?.onProgress != null) { + EventAPI.removeListener(eventID, options?.onProgress); } - return filePath; + return { filePath, headers }; }, - async downloadToBuffer( - url: string, - onProgress?: OnProgress, - ): Promise { - const filePath = await this.downloadToFile(url, undefined, onProgress); - const fileData = readFileSync(filePath, { encoding: null }); + async downloadToBuffer(url, options) { + const { filePath, headers } = await RequestAPI.downloadToFile(url, options); + const response = readFileSync(filePath, { encoding: null }); rmSync(filePath); - return fileData; + return { response, headers }; }, } as ILocalRequestAPI; diff --git a/src/main/worker/UpdaterAPI.ts b/src/main/worker/UpdaterAPI.ts index a1d967c..998a950 100644 --- a/src/main/worker/UpdaterAPI.ts +++ b/src/main/worker/UpdaterAPI.ts @@ -73,14 +73,14 @@ async function getUpdate(): Promise { } async function getLatestRelease(): Promise { - const response = await RequestAPI.downloadToBuffer( + const { response } = await RequestAPI.downloadToBuffer( 'https://api.github.com/repos/olegbl/d2rmm/releases/latest', ); return JSON.parse(response.toString()) as Release; } async function getLatestPrerelease(): Promise { - const response = await RequestAPI.downloadToBuffer( + const { response } = await RequestAPI.downloadToBuffer( 'https://api.github.com/repos/olegbl/d2rmm/releases', ); const releases = JSON.parse(response.toString()) as Release[]; @@ -125,17 +125,17 @@ async function cleanupUpdate({ tempDirPath }: Config): Promise { async function downloadUpdate(config: Config, update: Update): Promise { console.log('[Updater] Downloading update'); await EventAPI.send('updater', { event: 'download' }); - config.updateZipPath = await RequestAPI.downloadToFile( - update.url, - 'update.zip', - async ({ bytesDownloaded, bytesTotal }) => { + const { filePath } = await RequestAPI.downloadToFile(update.url, { + fileName: 'update.zip', + onProgress: async ({ bytesDownloaded, bytesTotal }) => { await EventAPI.send('updater', { event: 'download-progress', bytesDownloaded, bytesTotal, }); }, - ); + }); + config.updateZipPath = filePath; console.log(`[Updater] Downloaded update to ${config.updateZipPath}`); } diff --git a/src/main/worker/worker.ts b/src/main/worker/worker.ts index 88f43ed..dab6432 100644 --- a/src/main/worker/worker.ts +++ b/src/main/worker/worker.ts @@ -4,6 +4,7 @@ import { initCascLib } from './CascLib'; import { initConsoleAPI } from './ConsoleAPI'; import { initEventAPI } from './EventAPI'; import { initIPC } from './IPC'; +import { initModUpdaterAPI } from './ModUpdaterAPI'; import { initUpdaterAPI } from './UpdaterAPI'; import { initAsar } from './asar'; import { initQuickJS } from './quickjs'; @@ -28,6 +29,8 @@ async function start(): Promise { await initBridgeAPI(); console.debug('[worker] Initializing UpdaterAPI...'); await initUpdaterAPI(); + console.debug('[worker] Initializing ModUpdaterAPI...'); + await initModUpdaterAPI(); console.debug('[worker] Initialized'); } diff --git a/src/renderer/index.ejs b/src/renderer/index.ejs index ece9c52..5cc7f3a 100644 --- a/src/renderer/index.ejs +++ b/src/renderer/index.ejs @@ -1,11 +1,11 @@ - + - - - } path="/" /> - - - + + + + + } path="/" /> + + + + + diff --git a/src/renderer/react/context/NexusModsContext.tsx b/src/renderer/react/context/NexusModsContext.tsx new file mode 100644 index 0000000..c535604 --- /dev/null +++ b/src/renderer/react/context/NexusModsContext.tsx @@ -0,0 +1,60 @@ +import React, { useCallback, useContext, useMemo } from 'react'; +import useSavedState from '../hooks/useSavedState'; + +type IApiKey = string | null; + +type INexusAuthState = { + apiKey: IApiKey; +}; + +type ISetNexusAuthState = React.Dispatch>; + +export type INexusModsContext = { + authState: INexusAuthState; + setAuthState: ISetNexusAuthState; +}; + +export const Context = React.createContext(null); + +export function NexusModsContextProvider({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + const [authState, setAuthState] = useSavedState( + 'nexus-auth', + {} as INexusAuthState, + (map) => JSON.stringify(map), + (str) => JSON.parse(str), + ); + + // TODO: validate cached api key on start + // /v1/users/validate.json + // https://app.swaggerhub.com/apis-docs/NexusMods/nexus-mods_public_api_params_in_form_data/1.0#/User/post_v1_users_validate.json + + const context = useMemo( + (): INexusModsContext => ({ + authState, + setAuthState, + }), + [authState, setAuthState], + ); + + return {children}; +} + +export function useNexusAuthState(): [ + INexusAuthState, + ISetNexusAuthState, + authenticate: () => void, +] { + const context = useContext(Context); + if (context == null) { + throw new Error('No nexus mods context available.'); + } + const authenticate = useCallback((): void => { + // TODO: Nexus Mods SSO + // https://github.com/Nexus-Mods/sso-integration-demo + }, []); + return [context.authState, context.setAuthState, authenticate]; +} diff --git a/src/renderer/react/context/UpdatesContext.tsx b/src/renderer/react/context/UpdatesContext.tsx new file mode 100644 index 0000000..bdf327b --- /dev/null +++ b/src/renderer/react/context/UpdatesContext.tsx @@ -0,0 +1,71 @@ +import React, { useCallback, useContext, useMemo, useState } from 'react'; +import type { ModUpdaterDownload } from 'bridge/ModUpdaterAPI'; + +type IUpdateState = { + isUpdateChecked: boolean; + isUpdateAvailable: boolean; + nexusUpdates: ModUpdaterDownload[]; + nexusDownloads: ModUpdaterDownload[]; +}; +type ISetUpdateState = React.Dispatch>; + +type IUpdates = Map; +type ISetUpdates = React.Dispatch>; + +const DEFAULT_UPDATE_STATE = { + isUpdateChecked: false, + isUpdateAvailable: false, + nexusUpdates: [], + nexusDownloads: [], +}; + +export type IUpdatesContext = { + updates: IUpdates; + setUpdates: ISetUpdates; +}; + +export const Context = React.createContext(null); + +export function UpdatesContextProvider({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + const [updates, setUpdates] = useState(new Map()); + + const context = useMemo( + (): IUpdatesContext => ({ + updates, + setUpdates, + }), + [updates, setUpdates], + ); + + return {children}; +} + +export function useModUpdates(): [IUpdates, ISetUpdates] { + const context = useContext(Context); + if (context == null) { + throw new Error('No updates context available.'); + } + return [context.updates, context.setUpdates]; +} + +export function useModUpdate(modID: string): [IUpdateState, ISetUpdateState] { + const [updates, setUpdates] = useModUpdates(); + const updateState = updates.get(modID) ?? DEFAULT_UPDATE_STATE; + const setUpdateState = useCallback( + (arg: React.SetStateAction) => { + setUpdates((oldUpdates) => { + const newUpdates = new Map(oldUpdates); + const oldState = oldUpdates.get(modID) ?? DEFAULT_UPDATE_STATE; + const newState = typeof arg === 'function' ? arg(oldState) : arg; + newUpdates.set(modID, newState); + return newUpdates; + }); + }, + [modID, setUpdates], + ); + return [updateState, setUpdateState]; +} diff --git a/src/renderer/react/modlist/ModListItem.tsx b/src/renderer/react/modlist/ModListItem.tsx index ab90c91..940fa2d 100644 --- a/src/renderer/react/modlist/ModListItem.tsx +++ b/src/renderer/react/modlist/ModListItem.tsx @@ -1,12 +1,14 @@ +import { NestedMenuItem } from 'mui-nested-menu'; import { useCallback, useMemo, useState } from 'react'; import { Draggable } from 'react-beautiful-dnd'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import FaceIcon from '@mui/icons-material/Face'; -import HelpIcon from '@mui/icons-material/Help'; -import LinkIcon from '@mui/icons-material/Link'; -import SettingsIcon from '@mui/icons-material/Settings'; -import UpdateIcon from '@mui/icons-material/Update'; -import WarningIcon from '@mui/icons-material/Warning'; +import { Download } from '@mui/icons-material'; +import DragIndicator from '@mui/icons-material/DragIndicator'; +import Face from '@mui/icons-material/Face'; +import Help from '@mui/icons-material/Help'; +import Link from '@mui/icons-material/Link'; +import Settings from '@mui/icons-material/Settings'; +import Update from '@mui/icons-material/Update'; +import Warning from '@mui/icons-material/Warning'; import { Box, Checkbox, @@ -21,9 +23,10 @@ import { Typography, } from '@mui/material'; import type { Mod } from 'bridge/BridgeAPI'; -import { IShellAPI } from 'bridge/ShellAPI'; +import type { IShellAPI } from 'bridge/ShellAPI'; import { consumeAPI } from '../../IPC'; import { useSelectedMod, useToggleMod } from '../context/ModsContext'; +import { useModUpdater } from './ModUpdater'; const ShellAPI = consumeAPI('ShellAPI'); @@ -112,13 +115,14 @@ function ListChip({ } type Action = { + children?: Action[]; + color?: React.ComponentProps['color'] | null; + icon?: React.ComponentProps['icon'] | null; id: string; - icon: React.ComponentProps['icon']; - shortLabel?: string | null; longLabel?: string | null; - color?: React.ComponentProps['color'] | null; - tooltip?: string | null; onClick?: (() => void) | null; + shortLabel?: string | null; + tooltip?: string | null; }; type Props = { @@ -147,6 +151,22 @@ export default function ModListItem({ } }, [mod]); + const { + isUpdatePossible, + isUpdateChecked, + isUpdateAvailable, + latestUpdate, + downloads, + onCheckForUpdates, + onDownloadVersion, + } = useModUpdater(mod); + + const onDownloadLatestUpdate = useCallback(async () => { + if (latestUpdate != null) { + await onDownloadVersion(latestUpdate); + } + }, [latestUpdate, onDownloadVersion]); + const [contextMenuAnchor, onOpenContextMenu, onCloseContextMenu] = useContextMenu(); @@ -157,7 +177,7 @@ export default function ModListItem({ id: 'site', shortLabel: 'site', longLabel: 'Visit Website', - icon: , + icon: , onClick: onOpenWebsite, } as Action), mod.info.author == null @@ -165,15 +185,53 @@ export default function ModListItem({ : ({ id: 'author', shortLabel: mod.info.author, - icon: , + icon: , } as Action), mod.info.version == null ? null : ({ id: 'version', + color: isUpdateAvailable + ? 'warning' + : isUpdateChecked + ? 'success' + : undefined, shortLabel: `v${mod.info.version}`, - icon: , + longLabel: !isUpdatePossible + ? null + : isUpdateAvailable + ? null + : isUpdateChecked + ? 'Recheck for Updates' + : 'Check for Updates', + tooltip: !isUpdatePossible + ? null + : isUpdateAvailable + ? `Download Version ${latestUpdate?.version}` + : isUpdateChecked + ? 'Recheck for Updates' + : 'Check for Updates', + icon: , + onClick: !isUpdatePossible + ? null + : isUpdateAvailable + ? onDownloadLatestUpdate + : onCheckForUpdates, } as Action), + mod.info.version == null || downloads.length === 0 + ? null + : { + id: 'download', + longLabel: 'Download', + icon: , + children: downloads.map((download) => ({ + id: `download-${download.version}`, + longLabel: `Version ${download.version}`, + onClick: async () => { + await onDownloadVersion(download); + }, + })), + }, mod.info.config == null ? null : ({ @@ -181,7 +239,7 @@ export default function ModListItem({ shortLabel: 'settings', longLabel: 'Open Settings', color: isEnabled ? 'primary' : undefined, - icon: , + icon: , onClick: onConfigureMod, } as Action), mod.info.type !== 'data' @@ -192,7 +250,7 @@ export default function ModListItem({ color: 'warning', tooltip: 'This mod is a non-D2RMM data mod and may conflict with other mods or game updates.', - icon: , + icon: , onClick: onOpenWebsite, } as Action), ].filter((action: Action | null): action is Action => action != null); @@ -229,7 +287,7 @@ export default function ModListItem({ {mod.info.name} {mod.info.description == null ? null : ( - + )} @@ -237,7 +295,7 @@ export default function ModListItem({ - {isReorderEnabled ? : null} + {isReorderEnabled ? : null} ); @@ -276,18 +334,61 @@ export default function ModListItem({ open={contextMenuAnchor != null} > {contextActions.map((action) => ( - { - onCloseContextMenu(); - action.onClick?.(); - }} - > - {action.longLabel} - + action={action} + contextMenuAnchor={contextMenuAnchor} + onCloseContextMenu={onCloseContextMenu} + /> ))} )} ); } + +function ActionMenuItem({ + action, + contextMenuAnchor, + onCloseContextMenu, +}: { + action: Action; + contextMenuAnchor: ContextMenuAnchor | null; + onCloseContextMenu: () => void; +}) { + if (action.children == null) { + return ( + { + onCloseContextMenu(); + action.onClick?.(); + }} + > + {action.icon && {action.icon}} + {action.longLabel} + + ); + } + + return ( + {action.icon} + } + parentMenuOpen={contextMenuAnchor != null} + renderLabel={() => {action.longLabel}} + > + {action.children.map((childAction) => ( + + ))} + + ); +} diff --git a/src/renderer/react/modlist/ModUpdater.tsx b/src/renderer/react/modlist/ModUpdater.tsx new file mode 100644 index 0000000..817f385 --- /dev/null +++ b/src/renderer/react/modlist/ModUpdater.tsx @@ -0,0 +1,114 @@ +import { useCallback } from 'react'; +import type { Mod } from 'bridge/BridgeAPI'; +import type { IModUpdaterAPI, ModUpdaterDownload } from 'bridge/ModUpdaterAPI'; +import { consumeAPI } from 'renderer/IPC'; +import { compareVersions } from 'renderer/utils/version'; +import { useMods } from '../context/ModsContext'; +import { useNexusAuthState } from '../context/NexusModsContext'; +import { useModUpdate } from '../context/UpdatesContext'; + +const ModUpdaterAPI = consumeAPI('ModUpdaterAPI'); + +function getNexusModID(mod: Mod): string | null { + return ( + mod.info.website?.match(/\/diablo2resurrected\/mods\/(\d+)/)?.[1] ?? null + ); +} + +function getUpdatesFromDownloads( + currentVersion: string, + downloads: ModUpdaterDownload[], +): ModUpdaterDownload[] { + return downloads.filter( + (download) => compareVersions(download.version, currentVersion) < 0, + ); +} + +export function useModUpdater(mod: Mod): { + isUpdatePossible: boolean; + isUpdateChecked: boolean; + isUpdateAvailable: boolean; + latestUpdate: ModUpdaterDownload | null; + downloads: ModUpdaterDownload[]; + onCheckForUpdates: () => Promise; + onDownloadVersion: (download: ModUpdaterDownload) => Promise; +} { + const [, onRefreshMods] = useMods(); + const [nexusAuthState] = useNexusAuthState(); + const nexusModID = getNexusModID(mod); + const isUpdatePossible = + nexusAuthState != null && mod.info.website != null && nexusModID != null; + const [updateState, setUpdateState] = useModUpdate(mod.id); + const latestUpdate = updateState.nexusUpdates[0]; + const isUpdateChecked = updateState.isUpdateChecked; + const isUpdateAvailable = updateState.isUpdateAvailable; + // TODO: handle non-premium Nexus Mods users + + const onCheckForUpdates = useCallback(async () => { + if (nexusAuthState.apiKey == null || nexusModID == null) { + return; + } + const currentVersion = mod.info.version ?? '0'; + + const nexusDownloads = ( + await ModUpdaterAPI.getDownloadsViaNexus( + nexusAuthState.apiKey, + nexusModID, + ) + ).sort((a, b) => compareVersions(a.version, b.version)); + + const nexusUpdates = getUpdatesFromDownloads( + currentVersion, + nexusDownloads, + ); + + setUpdateState({ + isUpdateChecked: true, + isUpdateAvailable: nexusUpdates.length > 0, + nexusUpdates, + nexusDownloads, + }); + }, [mod.info.version, nexusAuthState.apiKey, nexusModID, setUpdateState]); + + const onDownloadVersion = useCallback( + async (download: ModUpdaterDownload) => { + if (nexusAuthState.apiKey == null || nexusModID == null) { + return; + } + + await ModUpdaterAPI.installModViaNexus( + mod.id, + nexusAuthState.apiKey, + nexusModID, + download.fileID, + ); + + const newVersion = download.version; + setUpdateState((oldUpdateState) => { + const nexusUpdates = getUpdatesFromDownloads( + newVersion, + oldUpdateState.nexusDownloads, + ); + return { + isUpdateChecked: true, + isUpdateAvailable: nexusUpdates.length > 0, + nexusUpdates, + nexusDownloads: oldUpdateState.nexusDownloads, + }; + }); + + await onRefreshMods(); + }, + [nexusAuthState.apiKey, nexusModID, mod.id, setUpdateState, onRefreshMods], + ); + + return { + isUpdatePossible, + isUpdateChecked, + isUpdateAvailable, + latestUpdate, + downloads: updateState.nexusDownloads, + onCheckForUpdates, + onDownloadVersion, + }; +} diff --git a/src/renderer/utils/version.ts b/src/renderer/utils/version.ts new file mode 100644 index 0000000..068bc03 --- /dev/null +++ b/src/renderer/utils/version.ts @@ -0,0 +1,31 @@ +/** + * Splits a semver version string into its parts. + * @param version The version string to split. + * @returns An array containing the major, minor, and patch version numbers. + */ +export function getVersionParts(version: string): [number, number, number] { + const parts = version.split('.').map((v) => parseInt(v, 10)); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +/** + * Compares two versions to determine which is newer. + * @param a The first version to compare. + * @param b The second version to compare. + * @returns -1 if `a` is newer than `b`, 1 if `b` is newer than `a`, and 0 if they are equal. + */ +export function compareVersions(a: string, b: string): number { + const aParts = getVersionParts(a); + const bParts = getVersionParts(b); + + for (let i = 0; i < 3; i++) { + if (aParts[i] > bParts[i]) { + return -1; + } + if (aParts[i] < bParts[i]) { + return 1; + } + } + + return 0; +} diff --git a/yarn.lock b/yarn.lock index 8145a28..46c3d9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7666,6 +7666,11 @@ mui-color-input@^2.0.3: dependencies: "@ctrl/tinycolor" "^4.0.3" +mui-nested-menu@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/mui-nested-menu/-/mui-nested-menu-3.4.0.tgz#e104b1b492e0a9ef95820e97827851310c3a56ba" + integrity sha512-QUZqsCKW4tX1GwcbemBvf++ZvKsRXvHdZ+CT6GvnHvusucmEB18WKuJjAZVD8L5trXS98+9psie11Ev3FTez2A== + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901"