Created
December 2, 2025 12:58
-
-
Save halgari/34ebfb22b87c1592ba50e3203d169174 to your computer and use it in GitHub Desktop.
example.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| private doInstallDependencies(api: IExtensionApi, | |
| gameId: string, | |
| sourceModId: string, | |
| dependencies: IDependency[], | |
| recommended: boolean, | |
| silent: boolean): Bluebird<IDependency[]> { | |
| const state: IState = api.getState(); | |
| let downloads: { [id: string]: IDownload } = state.persistent.downloads.files; | |
| const sourceMod = state.persistent.mods[gameId][sourceModId]; | |
| const stagingPath = installPathForGame(state, gameId); | |
| if (sourceMod?.installationPath === undefined) { | |
| return Bluebird.resolve([]); | |
| } | |
| let queuedDownloads: IModReference[] = []; | |
| const clearQueued = () => { | |
| const downloadsNow = api.getState().persistent.downloads.files; | |
| // cancel in reverse order so that canceling a running download doesn't | |
| // trigger a previously pending download to start just to then be canceled too. | |
| // Obviously this is probably not a robust way of achieving that but what is? | |
| queuedDownloads.reverse().forEach(ref => { | |
| const dlId = findDownloadByRef(ref, downloadsNow); | |
| log('info', 'cancel dependency dl', { name: renderModReference(ref), dlId }); | |
| if (dlId !== undefined) { | |
| api.events.emit('pause-download', dlId); | |
| } else { | |
| api.events.emit('intercept-download', ref.tag); | |
| } | |
| }); | |
| queuedDownloads = []; | |
| delete this.mDependencyInstalls[sourceModId]; | |
| this.cleanupPendingInstalls(sourceModId, true); | |
| }; | |
| const queueDownload = (dep: IDependency): Bluebird<string> => { | |
| return this.mDependencyDownloadsLimit.do<string>(() => { | |
| if (dep.reference.tag !== undefined) { | |
| queuedDownloads.push(dep.reference); | |
| } | |
| return abort.signal.aborted | |
| ? Bluebird.reject(new UserCanceled(false)) | |
| : this.downloadDependencyAsync( | |
| dep.reference, | |
| api, | |
| dep.lookupResults[0].value, | |
| () => abort.signal.aborted, | |
| dep.extra?.fileName) | |
| .then(dlId => { | |
| const idx = queuedDownloads.indexOf(dep.reference); | |
| queuedDownloads.splice(idx, 1); | |
| return dlId; | |
| }) | |
| .catch(err => { | |
| const idx = queuedDownloads.indexOf(dep.reference); | |
| queuedDownloads.splice(idx, 1); | |
| // Check if this is a network error that might have caused the download to be paused | |
| const isNetworkError = err.message?.includes('socket hang up') | |
| || err.message?.includes('ECONNRESET') | |
| || err.message?.includes('ETIMEDOUT') | |
| || err.code === 'ECONNRESET' | |
| || err.code === 'ETIMEDOUT'; | |
| // Check if this is a "File already downloaded" error (for cases where we get a generic error message) | |
| const isAlreadyDownloaded = err instanceof AlreadyDownloaded | |
| || err.message?.includes('File already downloaded') | |
| || err.message?.includes('already downloaded'); | |
| if (isAlreadyDownloaded) { | |
| if (err.downloadId !== undefined) { | |
| log('info', 'File already downloaded, using existing download ID', { downloadId: err.downloadId }); | |
| return Bluebird.resolve(err.downloadId); | |
| } | |
| // If file is already downloaded, check if we can find the download | |
| // Try to find the download by filename | |
| const currentDownloads = api.getState().persistent.downloads.files; | |
| const downloadId = Object.keys(currentDownloads).find(dlId => | |
| currentDownloads[dlId].localPath === err.fileName || | |
| currentDownloads[dlId].modInfo?.referenceTag === dep.reference?.tag); | |
| if (downloadId) { | |
| log('info', 'Download already completed, using existing download', { downloadId }); | |
| return Bluebird.resolve(downloadId); | |
| } else { | |
| // The download file exists but we can't find its record - refresh downloads and try again | |
| return new Bluebird((resolve) => { | |
| api.events.emit('refresh-downloads', gameId, () => { | |
| const currentDownloads = api.getState().persistent.downloads.files; | |
| const downloadId = Object.keys(currentDownloads).find(dlId => | |
| currentDownloads[dlId].localPath === err.fileName); | |
| return downloadId ? resolve(downloadId) : resolve(null); | |
| }); | |
| }); | |
| } | |
| } | |
| if (isNetworkError) { | |
| // For network errors, check if the download ended up in paused state | |
| // and if so, try to resume it through the concurrent queue | |
| setTimeout(() => { | |
| const currentDownloads = api.getState().persistent.downloads.files; | |
| const downloadId = Object.keys(currentDownloads).find(dlId => | |
| currentDownloads[dlId].modInfo?.referenceTag === dep.reference?.tag); | |
| if (downloadId && currentDownloads[downloadId].state === 'paused') { | |
| log('info', 'Network error resulted in paused download, will attempt resume', { | |
| downloadId, | |
| error: err.message | |
| }); | |
| // The download will be caught by the paused download check in doDownload | |
| return; | |
| } | |
| }, 1000); | |
| } | |
| return Bluebird.reject(err); | |
| }); | |
| }); | |
| }; | |
| const resumeDownload = (dep: IDependency): Bluebird<string> => { | |
| // This function handles resuming downloads that were paused due to network issues or user action | |
| return this.mDependencyDownloadsLimit.do<string>(() => | |
| abort.signal.aborted | |
| ? Bluebird.reject(new UserCanceled(false)) | |
| : new Bluebird((resolve, reject) => { | |
| // First check current download state to avoid unnecessary resume attempts | |
| const currentDownloads = api.getState().persistent.downloads.files; | |
| let resolvedId: string = dep.download; | |
| let currentDownload = currentDownloads[resolvedId]; | |
| if (!currentDownload) { | |
| // Try to resolve the download by referenceTag if possible | |
| const tag = dep.reference?.tag; | |
| if (truthy(tag)) { | |
| const foundId = Object.keys(currentDownloads) | |
| .find(dlId => currentDownloads[dlId]?.modInfo?.referenceTag === tag); | |
| if (foundId) { | |
| log('info', 'Resolved missing download id from referenceTag', { from: dep.download, to: foundId, tag }); | |
| resolvedId = foundId; | |
| currentDownload = currentDownloads[resolvedId]; | |
| } | |
| } | |
| } | |
| if (!currentDownload) { | |
| const readableRef = renderModReference(dep.reference); | |
| log('warn', 'Download not found when trying to resume', { intendedId: dep.download, ref: readableRef }); | |
| return reject(new NotFound(`download for ${readableRef}`)); | |
| } | |
| if (currentDownload.state === 'finished') { | |
| log('info', 'Download already finished, no need to resume', { downloadId: resolvedId }); | |
| return resolve(resolvedId); | |
| } | |
| if (currentDownload.state !== 'paused') { | |
| log('info', 'Download not in paused state', { downloadId: resolvedId, state: currentDownload.state }); | |
| return resolve(resolvedId); | |
| } | |
| log('info', 'Resuming paused download', { downloadId: resolvedId, tag: dep.reference?.tag }); | |
| api.events.emit('resume-download', | |
| resolvedId, | |
| (err) => { | |
| if (err !== null) { | |
| // Handle "File already downloaded" error gracefully | |
| if (err.message?.includes('File already downloaded') || err.message?.includes('already downloaded')) { | |
| log('info', 'Download already completed during resume attempt', { downloadId: resolvedId }); | |
| return resolve(resolvedId); | |
| } | |
| reject(err); | |
| } else { | |
| resolve(resolvedId); | |
| } | |
| }, | |
| { allowInstall: false } | |
| ); | |
| }) | |
| ); | |
| }; | |
| const installDownload = (dep: IDependency, downloadId: string): Bluebird<string> => { | |
| return new Bluebird<string>((resolve, reject) => { | |
| return this.mDependencyInstallsLimit.do(async () => { | |
| return abort.signal.aborted | |
| ? reject(new UserCanceled(false)) | |
| : this.withInstructions(api, | |
| modName(sourceMod), | |
| renderModReference(dep.reference), | |
| dep.reference?.tag ?? downloadId, | |
| dep.extra?.['instructions'], | |
| recommended, () => | |
| this.installModAsync(dep.reference, api, downloadId, | |
| { choices: dep.installerChoices, patches: dep.patches }, dep.fileList, | |
| gameId, silent, sourceModId,)).then(res => resolve(res)) | |
| .catch(err => { | |
| if (err instanceof UserCanceled) { | |
| err.skipped = true; | |
| } | |
| return reject(err); | |
| }); | |
| }) | |
| }); | |
| }; | |
| const doDownload = (dep: IDependency): Bluebird<{ updatedDep: IDependency, downloadId: string }> => { | |
| let dlPromise = Bluebird.resolve(dep.download); | |
| // Alternate between ProcessCanceled and NotFound for failed download URL | |
| // if (Math.random() < 0.5) { | |
| // return Bluebird.reject(new ProcessCanceled('Failed to determine download url')); | |
| // } else { | |
| // return Bluebird.reject(new NotFound('Failed to determine download url')); | |
| // } | |
| if ((dep.download === undefined) || (downloads[dep.download] === undefined)) { | |
| if (dep.extra?.localPath !== undefined) { | |
| // the archive is shipped with the mod that has the dependency | |
| const downloadPath = downloadPathForGame(state, gameId); | |
| const fileName = path.basename(dep.extra.localPath); | |
| let targetPath = path.join(downloadPath, fileName); | |
| // backwards compatibility: during alpha testing the bundles were 7zipped inside | |
| // the collection | |
| if (path.extname(fileName) !== '.7z') { | |
| targetPath += '.7z'; | |
| } | |
| dlPromise = fs.statAsync(targetPath) | |
| .then(() => Object.keys(downloads) | |
| .find(dlId => downloads[dlId].localPath === fileName)) | |
| .catch(err => new Bluebird((resolve, reject) => { | |
| api.events.emit('import-downloads', | |
| [path.join(stagingPath, sourceMod.installationPath, dep.extra.localPath)], | |
| (dlIds: string[]) => { | |
| if (dlIds.length > 0) { | |
| api.store.dispatch(setDownloadModInfo( | |
| dlIds[0], 'referenceTag', dep.reference.tag)); | |
| resolve(dlIds[0]); | |
| } else { | |
| resolve(); | |
| } | |
| }, true); | |
| })); | |
| } else { | |
| // Always allow downloads to be queued - installations will be deferred if needed | |
| dlPromise = (dep.lookupResults[0]?.value?.sourceURI ?? '') === '' | |
| ? Bluebird.reject(new ProcessCanceled('Failed to determine download url')) | |
| : queueDownload(dep); | |
| } | |
| } else if (dep.download === null) { | |
| dlPromise = Bluebird.reject(new ProcessCanceled('Failed to determine download url')); | |
| } else if (downloads[dep.download]?.state === 'paused') { | |
| // Get fresh state to ensure accurate paused detection | |
| const freshDownloads = api.getState().persistent.downloads.files; | |
| if (freshDownloads[dep.download]?.state === 'paused') { | |
| dlPromise = resumeDownload(dep); | |
| } else { | |
| dlPromise = Bluebird.resolve(dep.download); | |
| } | |
| } | |
| return dlPromise | |
| .catch(UserCanceled, err => { | |
| if (err.skipped) { | |
| this.handleDownloadSkipped(api, sourceModId, dep); | |
| } | |
| return Bluebird.reject(err); | |
| }) | |
| .catch(AlreadyDownloaded, err => { | |
| if (err.downloadId !== undefined) { | |
| return Bluebird.resolve(err.downloadId); | |
| } else { | |
| const downloadId = Object.keys(downloads) | |
| .find(dlId => downloads[dlId].localPath === err.fileName); | |
| if (downloadId !== undefined) { | |
| return Bluebird.resolve(downloadId); | |
| } | |
| } | |
| return Bluebird.reject(new NotFound(`download for ${renderModReference(dep.reference)}`)); | |
| }) | |
| .then((downloadId: string) => { | |
| // Get fresh state before checking if download is paused | |
| const freshDownloads = api.getState().persistent.downloads.files; | |
| if ((downloadId !== undefined) && (freshDownloads[downloadId]?.state === 'paused')) { | |
| return resumeDownload(dep); | |
| } else { | |
| return Bluebird.resolve(downloadId); | |
| } | |
| }) | |
| .then((downloadId: string) => { | |
| downloads = api.getState().persistent.downloads.files; | |
| if ((downloadId === undefined) || (downloads[downloadId] === undefined)) { | |
| return Bluebird.reject( | |
| new NotFound(`download for ${renderModReference(dep.reference)}`)); | |
| } | |
| if (downloads[downloadId].state !== 'finished') { | |
| // download not actually finished, may be paused | |
| return Bluebird.reject(new UserCanceled(true)); | |
| } | |
| if ((dep.reference.tag !== undefined) | |
| && (downloads[downloadId].modInfo?.referenceTag !== undefined) | |
| && (downloads[downloadId].modInfo?.referenceTag !== dep.reference.tag)) { | |
| // we can't change the tag on the download because that might break | |
| // dependencies on the other mod | |
| // instead we update the rule in the collection. This has to happen immediately, | |
| // otherwise the installation might have weird issues around the mod | |
| // being installed having a different tag than the rule | |
| dep.reference = this.updateModRule(api, gameId, sourceModId, dep, { | |
| ...dep.reference, | |
| fileList: dep.fileList, | |
| patches: dep.patches, | |
| installerChoices: dep.installerChoices, | |
| tag: downloads[downloadId].modInfo.referenceTag, | |
| }, recommended)?.reference; | |
| dep.mod = findModByRef(dep.reference, api.getState().persistent.mods[gameId]); | |
| } else { | |
| log('info', 'downloaded as dependency', { dependency: dep.reference.logicalFileName, downloadId }); | |
| } | |
| return (dep.mod == null) | |
| ? Bluebird.resolve() | |
| .then(() => { | |
| return Bluebird.resolve({ updatedDep: dep, downloadId }); | |
| }) | |
| .catch(err => { | |
| if (dep['reresolveDownloadHint'] === undefined) { | |
| return Bluebird.reject(err); | |
| } | |
| const newState = api.getState(); | |
| const download = newState.persistent.downloads.files[downloadId]; | |
| let removeProm = Bluebird.resolve(); | |
| if (download !== undefined) { | |
| // Convert download game ID from Nexus domain ID to internal ID for path resolution | |
| const games = knownGames(newState); | |
| const convertedGameId = convertGameIdReverse(games, download.game[0]); | |
| const pathGameId = convertedGameId || download.game[0]; | |
| const fullPath: string = | |
| path.join(downloadPathForGame(newState, pathGameId), download.localPath); | |
| removeProm = fs.removeAsync(fullPath); | |
| } | |
| return removeProm | |
| .then(() => dep['reresolveDownloadHint']()) | |
| .then(() => doDownload(dep)); | |
| }) | |
| : Bluebird.resolve({ updatedDep: dep, downloadId }); | |
| }); | |
| }; | |
| const phases: { [phase: number]: IDependency[] } = {}; | |
| dependencies.forEach(dep => setdefault(phases, dep.phase ?? 0, []).push(dep)); | |
| // Initialize phase state immediately after determining what phases we have | |
| if (dependencies.length > 0) { | |
| this.ensurePhaseState(sourceModId); | |
| const phaseState = this.mInstallPhaseState.get(sourceModId); | |
| const phaseNumbers = Object.keys(phases).map(p => parseInt(p, 10)).sort((a, b) => a - b); | |
| const lowestPhase = phaseNumbers[0]; | |
| // Check collection session to determine actual current phase | |
| const activeCollectionSession = getCollectionSessionById(api.getState(), sourceModId); | |
| if (activeCollectionSession) { | |
| // Determine the highest completed phase from the collection session | |
| const mods = activeCollectionSession.mods || {}; | |
| const allMods = Object.values(mods); | |
| // Find all phases that exist in the collection | |
| const allPhases = new Set<number>(); | |
| allMods.forEach((mod: any) => { | |
| allPhases.add(mod.phase ?? 0); | |
| }); | |
| // Find the highest phase where all required mods are complete | |
| let highestCompletedPhase = -1; | |
| Array.from(allPhases).sort((a, b) => a - b).forEach(phase => { | |
| const isPhaseComplete = isCollectionPhaseComplete(api.getState(), phase); | |
| if (isPhaseComplete) { | |
| highestCompletedPhase = phase; | |
| } | |
| }); | |
| // Set allowed phase to the next phase after the highest completed one | |
| // or to the lowest phase in our current dependencies if higher | |
| const nextPhaseAfterCompleted = highestCompletedPhase + 1; | |
| const effectiveStartPhase = Math.max(lowestPhase, nextPhaseAfterCompleted); | |
| if (phaseState.allowedPhase === undefined || phaseState.allowedPhase < effectiveStartPhase) { | |
| phaseState.allowedPhase = effectiveStartPhase; | |
| // When setting allowed phase, mark all previous phases as downloads finished | |
| for (let p = 0; p < effectiveStartPhase; p++) { | |
| phaseState.downloadsFinished.add(p); | |
| } | |
| } | |
| } else if (phaseState.allowedPhase === undefined) { | |
| // No active session, use the lowest phase from dependencies | |
| phaseState.allowedPhase = lowestPhase; | |
| // When setting initial allowed phase, mark all previous phases as downloads finished | |
| for (let p = 0; p < lowestPhase; p++) { | |
| phaseState.downloadsFinished.add(p); | |
| } | |
| log('info', 'Set initial allowed phase', { sourceModId, allowedPhase: lowestPhase }); | |
| } | |
| // Mark all phases as having downloads (they will be processed) | |
| phaseNumbers.forEach(phase => { | |
| phaseState.downloadsFinished.add(phase); | |
| }); | |
| } | |
| const abort = new AbortController(); | |
| abort.signal.onabort = () => clearQueued(); | |
| const phaseList = Object.values(phases); | |
| const res: Bluebird<IDependency[]> = Bluebird.reduce(phaseList, | |
| (prev: IDependency[], depList: IDependency[], idx: number) => { | |
| if (depList.length === 0) { | |
| return prev; | |
| } | |
| return this.doInstallDependenciesPhase(api, depList, gameId, sourceModId, | |
| recommended, | |
| doDownload, abort, silent) | |
| .then((updated: IDependency[]) => { | |
| // Mark this phase's downloads as finished to allow its installers to run, | |
| // but do not wait for installations to complete before proceeding to next phase. | |
| const phaseNum = depList[0]?.phase ?? 0; | |
| this.markPhaseDownloadsFinished(sourceModId, phaseNum, api); | |
| return updated; | |
| }) | |
| .then((updated: IDependency[]) => { | |
| // Schedule a deploy for this phase once its installers settle; don't block download progression | |
| const phaseNum = depList[0]?.phase ?? 0; | |
| const phaseState = this.mInstallPhaseState.get(sourceModId); | |
| // // Only schedule deploy polling for the current allowed phase to maintain sequential processing | |
| // if (phaseState && (phaseState.allowedPhase !== undefined) && (phaseNum === phaseState.allowedPhase)) { | |
| // this.scheduleDeployOnPhaseSettled(api, sourceModId, phaseNum); | |
| // } | |
| return updated; | |
| }) | |
| .then((updated: IDependency[]) => [].concat(prev, updated)); | |
| }, []); | |
| this.mDependencyInstalls[sourceModId] = () => { | |
| abort.abort(); | |
| }; | |
| return Bluebird.resolve(res) | |
| .then((deps: IDependency[]) => { | |
| return this.pollAllPhasesComplete(api, sourceModId).then(() => deps); | |
| }) | |
| .finally(() => { | |
| this.mInstallPhaseState.delete(sourceModId); | |
| }); | |
| } | |
| private updateModRule(api: IExtensionApi, gameId: string, sourceModId: string, | |
| dep: IDependency, reference: IModReference, recommended: boolean) { | |
| const state: IState = api.store.getState(); | |
| const rules: IModRule[] = | |
| getSafe(state.persistent.mods, [gameId, sourceModId, 'rules'], []); | |
| const oldRule = rules.find(iter => referenceEqual(iter.reference, dep.reference)); | |
| if (oldRule === undefined) { | |
| return undefined; | |
| } | |
| const updatedRule: IRule = { | |
| ...(oldRule || {}), | |
| type: recommended ? 'recommends' : 'requires', | |
| reference, | |
| }; | |
| api.store.dispatch(removeModRule(gameId, sourceModId, oldRule)); | |
| api.store.dispatch(addModRule(gameId, sourceModId, updatedRule)); | |
| return updatedRule; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment