From 5f668203e59d476dd49422a7217a7ac681f671cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Wed, 10 Jan 2024 17:30:24 +0200 Subject: [PATCH 01/93] Use caching to improve performance of large profiles When the local mod list is browsed vue seems to render the whole list again on some user actions. This causes three methods of the component to get called repeatedly: - getMissingDependencies, which uses modifiableModList property, which is based on the mod list stored in VueX store. modifiableModList is used by other parts of the component as well, so I thought better not touch it at this time since figuring out unintended side effects would be a lot of work - getThunderstoreModFromMod, which used the Thunderstore mod list stored in VueX, but now uses the list stored in the ThunderstorePackages. As far as I can tell both are updated in the Splash view and by the scheduled background process in UtilityMixin. Ergo this shouldn't break things, but this is the most significant functional change in this commit, and therefore most likely culprit should problems arise - isLatestVersion, which did and still does use ThunderstorePackages for its shenanigans So while this commit doesn't reduce the incessant function calls, it caches the results to a simple object to reduce required calculations. Effects were tested with a profile containing a mod pack with 109 mods. Completing the following tasks were timed (roughly and manually), with the accompanying results (original vs. cached): - Initial rendering of the local mod list when moving from profile selection view: 7.0s vs. 5.4s - Opening modal to disable a mod with two dependants: 4.4s vs. 1,2s - Closing the modal without disabling the mod: 4.4s vs. 1.2s - Opening modal to uninstall the same mod: 4.5s vs. 1.0s - Uninstalling the mod: 15.8s vs 6.0s (There might be further changes for optimizing the uninstall process, since it seems some stuff is done after each dependant is uninstalled, while it MIGHT be enough to do it just once in the end.) For a small profile with 3 mods there's no noticeable difference between the performance of the old and new implementation. --- src/components/views/LocalModList.vue | 6 +-- src/model/VersionNumber.ts | 4 ++ src/r2mm/data/ThunderstorePackages.ts | 3 ++ src/r2mm/mods/ModBridge.ts | 58 ++++++++++++++++++++++----- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/components/views/LocalModList.vue b/src/components/views/LocalModList.vue index 63c8a05d9..b97a3dd38 100644 --- a/src/components/views/LocalModList.vue +++ b/src/components/views/LocalModList.vue @@ -327,7 +327,7 @@ import SearchUtils from '../../utils/SearchUtils'; } getThunderstoreModFromMod(mod: ManifestV2) { - return ModBridge.getThunderstoreModFromMod(mod, this.thunderstorePackages); + return ModBridge.getCachedThunderstoreModFromMod(mod); } async moveUp(vueMod: any) { @@ -360,8 +360,8 @@ import SearchUtils from '../../utils/SearchUtils'; this.filterModList(); } - isLatest(vueMod: any): boolean { - return ModBridge.isLatestVersion(vueMod); + isLatest(mod: ManifestV2): boolean { + return ModBridge.isCachedLatestVersion(mod); } getMissingDependencies(vueMod: any): string[] { diff --git a/src/model/VersionNumber.ts b/src/model/VersionNumber.ts index c95218682..ccf263b54 100644 --- a/src/model/VersionNumber.ts +++ b/src/model/VersionNumber.ts @@ -64,4 +64,8 @@ export default class VersionNumber implements ReactiveObjectConverterInterface { const patchCompare = Math.sign(this.patch - version.patch); return (majorCompare === 0 && minorCompare === 0 && patchCompare === 0); } + + public isEqualOrNewerThan(version: VersionNumber): boolean { + return this.isEqualTo(version) || this.isNewerThan(version); + } } diff --git a/src/r2mm/data/ThunderstorePackages.ts b/src/r2mm/data/ThunderstorePackages.ts index 37afed591..f61aa9169 100644 --- a/src/r2mm/data/ThunderstorePackages.ts +++ b/src/r2mm/data/ThunderstorePackages.ts @@ -2,6 +2,7 @@ import ThunderstoreMod from '../../model/ThunderstoreMod'; import Game from '../../model/game/Game'; import ApiResponse from '../../model/api/ApiResponse'; import ConnectionProvider from '../../providers/generic/connection/ConnectionProvider'; +import ModBridge from '../mods/ModBridge'; export default class ThunderstorePackages { @@ -29,6 +30,8 @@ export default class ThunderstorePackages { .map(ThunderstoreMod.parseFromThunderstoreData) .filter((mod) => !ThunderstorePackages.EXCLUSIONS.includes(mod.getFullName())); + ModBridge.clearCache(); + ThunderstorePackages.PACKAGES_MAP = ThunderstorePackages.PACKAGES.reduce((map, pkg) => { map.set(pkg.getFullName(), pkg); return map; diff --git a/src/r2mm/mods/ModBridge.ts b/src/r2mm/mods/ModBridge.ts index cd1cafaec..f275682d5 100644 --- a/src/r2mm/mods/ModBridge.ts +++ b/src/r2mm/mods/ModBridge.ts @@ -3,20 +3,25 @@ import ThunderstoreVersion from '../../model/ThunderstoreVersion'; import ManifestV2 from '../../model/ManifestV2'; import ThunderstorePackages from '../data/ThunderstorePackages'; +interface CachedMod { + tsMod: ThunderstoreMod | undefined; + isLatest: boolean; +} + +interface ModCache { + [key: string]: CachedMod; +} + export default class ModBridge { + private static CACHE: ModCache = {} - public static getLatestVersion(mod: ManifestV2, modList: ThunderstoreMod[]): ThunderstoreVersion | void { + public static getLatestVersion(mod: ManifestV2, modList: ThunderstoreMod[]): ThunderstoreVersion | undefined { const matchingMod: ThunderstoreMod | undefined = modList.find((tsMod: ThunderstoreMod) => tsMod.getFullName() === mod.getName()); if (matchingMod === undefined) { return; } // Compare version numbers and reduce. - return matchingMod.getVersions().reduce((v1: ThunderstoreVersion, v2: ThunderstoreVersion) => { - if (v1.getVersionNumber().isNewerThan(v2.getVersionNumber())) { - return v1; - } - return v2; - }); + return matchingMod.getVersions().reduce(reduceToNewestVersion); } public static getThunderstoreModFromMod(mod: ManifestV2, modList: ThunderstoreMod[]): ThunderstoreMod | undefined { @@ -27,10 +32,45 @@ export default class ModBridge { const mod: ManifestV2 = new ManifestV2().fromReactive(vueMod); const latestVersion: ThunderstoreVersion | void = ModBridge.getLatestVersion(mod, ThunderstorePackages.PACKAGES); if (latestVersion instanceof ThunderstoreVersion) { - return mod.getVersionNumber() - .isEqualTo(latestVersion.getVersionNumber()) || mod.getVersionNumber().isNewerThan(latestVersion.getVersionNumber()); + return mod.getVersionNumber().isEqualOrNewerThan(latestVersion.getVersionNumber()); } return true; } + private static getCached(mod: ManifestV2): CachedMod { + const cacheKey = `${mod.getName()}-${mod.getVersionNumber()}`; + + if (ModBridge.CACHE[cacheKey] === undefined) { + const tsMod = ThunderstorePackages.PACKAGES.find((tsMod) => tsMod.getFullName() === mod.getName()); + + if (tsMod === undefined) { + ModBridge.CACHE[cacheKey] = { tsMod: undefined, isLatest: true }; + } else { + const latestVersion = tsMod.getVersions().reduce(reduceToNewestVersion); + const isLatest = mod.getVersionNumber().isEqualOrNewerThan(latestVersion.getVersionNumber()); + ModBridge.CACHE[cacheKey] = { tsMod, isLatest }; + } + } + + return ModBridge.CACHE[cacheKey]; + } + + public static getCachedThunderstoreModFromMod(mod: ManifestV2): ThunderstoreMod | undefined { + return ModBridge.getCached(mod).tsMod; + } + + public static isCachedLatestVersion(mod: ManifestV2): boolean { + return ModBridge.getCached(mod).isLatest; + } + + public static clearCache() { + ModBridge.CACHE = {}; + } } + +const reduceToNewestVersion = (v1: ThunderstoreVersion, v2: ThunderstoreVersion) => { + if (v1.getVersionNumber().isNewerThan(v2.getVersionNumber())) { + return v1; + } + return v2; +}; From cf950e75473a40315d502e534e539a39e528096e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 11 Jan 2024 12:35:31 +0200 Subject: [PATCH 02/93] Add LocalModList.updateModListAfterChange to reduce code duplication --- src/components/views/LocalModList.vue | 46 ++++++++++----------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/src/components/views/LocalModList.vue b/src/components/views/LocalModList.vue index b97a3dd38..f07ce6f90 100644 --- a/src/components/views/LocalModList.vue +++ b/src/components/views/LocalModList.vue @@ -330,6 +330,17 @@ import SearchUtils from '../../utils/SearchUtils'; return ModBridge.getCachedThunderstoreModFromMod(mod); } + async updateModListAfterChange(updatedList: ManifestV2[]) { + await this.$store.dispatch("updateModList", updatedList); + + const err = await ConflictManagementProvider.instance.resolveConflicts(updatedList, this.contextProfile!); + if (err instanceof R2Error) { + this.$emit('error', err); + } + + this.filterModList(); + } + async moveUp(vueMod: any) { const mod: ManifestV2 = new ManifestV2().fromReactive(vueMod); const updatedList = await ProfileModList.shiftModEntryUp(mod, this.contextProfile!); @@ -337,12 +348,7 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', updatedList); return; } - await this.$store.dispatch("updateModList",updatedList); - const err = await ConflictManagementProvider.instance.resolveConflicts(updatedList, this.contextProfile!); - if (err instanceof R2Error) { - this.$emit('error', err); - } - this.filterModList(); + await this.updateModListAfterChange(updatedList); } async moveDown(vueMod: any) { @@ -352,12 +358,7 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', updatedList); return; } - await this.$store.dispatch("updateModList", updatedList); - const err = await ConflictManagementProvider.instance.resolveConflicts(updatedList, this.contextProfile!); - if (err instanceof R2Error) { - this.$emit('error', err); - } - this.filterModList(); + await this.updateModListAfterChange(updatedList); } isLatest(mod: ManifestV2): boolean { @@ -438,12 +439,7 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', updatedList); return updatedList; } - await this.$store.dispatch("updateModList", updatedList); - const err = await ConflictManagementProvider.instance.resolveConflicts(updatedList, this.contextProfile!); - if (err instanceof R2Error) { - this.$emit('error', err); - } - this.filterModList(); + await this.updateModListAfterChange(updatedList); } async uninstallMod(vueMod: any) { @@ -473,12 +469,7 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', result); return; } - await this.$store.dispatch("updateModList", result); - const err = await ConflictManagementProvider.instance.resolveConflicts(result, this.contextProfile!); - if (err instanceof R2Error) { - this.$emit('error', err); - } - this.filterModList(); + await this.updateModListAfterChange(result); } showDependencyList(vueMod: any, displayType: string) { @@ -552,12 +543,7 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', updatedList); return updatedList; } - await this.$store.dispatch("updateModList",updatedList); - const err = await ConflictManagementProvider.instance.resolveConflicts(updatedList, this.contextProfile!); - if (err instanceof R2Error) { - this.$emit('error', err); - } - this.filterModList(); + await this.updateModListAfterChange(updatedList); } updateMod(vueMod: any) { From f716d11e0e8013cf51ae65bc7c06fb063440904f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 11 Jan 2024 13:10:08 +0200 Subject: [PATCH 03/93] Postpone updating local mod list to improve uninstall performance There's three methods that control how mods are uninstalled: uninstallModRequireConfirmation, which calls performUninstallMod directly if the mod has no dependants, and uninstallMod if the mod has dependencies. uninstallMod in turn calls performUninstallMod for the mod and all the dependants separately. This commit does the following changes: - By default, performUninstallMod now calls updateModListAfterChange helper, which differs from the original implementation in that it also calls filterModList method - Since uninstallModRequireConfirmation calls performUninstallMod, it no longer needs to call filterModList directly - performUninstallMod also accepts a new boolean parameter which can be used to prevent it from calling updateModListAfterChange - uninstallMod uses the said parameter to prevent the updateModListAfterChange getting called after each individual mod is uninstalled. It calls updateModListAfterChange itself directly in the end As far as I can tell this has no ill effects to the end result, i.e. the profile files that remain on the disk appear to be identical. The only downside I'm aware of is that the modal that shows the list of mods no longer gets updated during the process, so it might appear to the user that nothing happens. The benefit is that the process takes significantly less time when uninstalling a mod with lots of dependants. Prior to changes uninstalling BepInEx from a profile that contained 109 mods took 147 seconds, while after the changes it "only" takes 24 seconds. --- src/components/views/LocalModList.vue | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/views/LocalModList.vue b/src/components/views/LocalModList.vue index f07ce6f90..41fccde4c 100644 --- a/src/components/views/LocalModList.vue +++ b/src/components/views/LocalModList.vue @@ -381,7 +381,7 @@ import SearchUtils from '../../utils/SearchUtils'; return Dependants.getDependencyList(mod, this.modifiableModList); } - async performUninstallMod(mod: ManifestV2): Promise { + async performUninstallMod(mod: ManifestV2, updateModList=true): Promise { const uninstallError: R2Error | null = await ProfileInstallerProvider.instance.uninstallMod(mod, this.contextProfile!); if (uninstallError instanceof R2Error) { // Uninstall failed @@ -396,10 +396,8 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', modList); return modList; } - await this.$store.dispatch("updateModList",modList); - const err = await ConflictManagementProvider.instance.resolveConflicts(modList, this.contextProfile!); - if (err instanceof R2Error) { - this.$emit('error', err); + if (updateModList) { + await this.updateModListAfterChange(modList); } } @@ -446,13 +444,13 @@ import SearchUtils from '../../utils/SearchUtils'; let mod: ManifestV2 = new ManifestV2().fromReactive(vueMod); try { for (const dependant of Dependants.getDependantList(mod, this.modifiableModList)) { - const result = await this.performUninstallMod(dependant); + const result = await this.performUninstallMod(dependant, false); if (result instanceof R2Error) { this.$emit('error', result); return; } } - const result = await this.performUninstallMod(mod); + const result = await this.performUninstallMod(mod, false); if (result instanceof R2Error) { this.$emit('error', result); return; @@ -483,7 +481,6 @@ import SearchUtils from '../../utils/SearchUtils'; const mod: ManifestV2 = new ManifestV2().fromReactive(vueMod); if (this.getDependantList(mod).size === 0) { this.performUninstallMod(mod); - this.filterModList(); } else { this.showDependencyList(mod, DependencyListDisplayType.UNINSTALL); } From 40206020c4e527caa8848c046b8a575ead7cab5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 11 Jan 2024 16:59:47 +0200 Subject: [PATCH 04/93] Show the mod being currently uninstalled in the LocalModList modal This works as an indicator to the user that something is happening in case the uninstallation of multiple mods takes a bit longer. --- src/components/views/LocalModList.vue | 10 ++++++++++ src/css/custom.scss | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/components/views/LocalModList.vue b/src/components/views/LocalModList.vue index 41fccde4c..3d915d97a 100644 --- a/src/components/views/LocalModList.vue +++ b/src/components/views/LocalModList.vue @@ -100,6 +100,9 @@ @click="uninstallMod(selectedManifestMod)"> Uninstall + + Uninstalling {{ modBeingUninstalled }} + - From 3865d71ef22f173b759ba529287650ac22692a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 18 Jan 2024 13:03:27 +0200 Subject: [PATCH 06/93] Disable package list updates on game selection and splash screens Previously the package list was loaded for the default game while the user was in the game selection screen. Now that so many games are supported, the odds are that downloading RoR2 package list is a waste of bandwidth. Since the splash screen actively downloads the package list for the selected game, triggering the background download would only slow down both. --- src/components/mixins/UtilityMixin.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/mixins/UtilityMixin.vue b/src/components/mixins/UtilityMixin.vue index 420b48944..f40317c14 100644 --- a/src/components/mixins/UtilityMixin.vue +++ b/src/components/mixins/UtilityMixin.vue @@ -56,6 +56,15 @@ export default class UtilityMixin extends Vue { } async refreshThunderstoreModList() { + // Don't do background update on index route since the game + // isn't really chosen yet, nor in the splash screen since it + // proactively updates the package list. + const exemptRoutes = ["index", "splash"]; + + if (this.$route.name && exemptRoutes.includes(this.$route.name)) { + return; + } + const response = await ThunderstorePackages.update(GameManager.activeGame); await ApiCacheUtils.storeLastRequest(response.data); await this.$store.dispatch("updateThunderstoreModList", ThunderstorePackages.PACKAGES); From f6bfaf81d612ac79fd43b23c2e838883c318ec9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 18 Jan 2024 13:27:26 +0200 Subject: [PATCH 07/93] Use more lenient timeouts when downloading package lists The package list for Lethal Company is nearing 10MB gzipped. Users with slower connections start failing to download it within the 30s timeout, which in practice means they can't use the mod manager. To remedy the situation, following changes are done to timeouts: - There's initial timeout of 30 seconds. We'd like this to be just for checking that the connection can be formed, there seems to be no way to that, so instead we check that a small amount of data has been transferred - After the initial timeout, there's a timeout of 60 seconds that checks that data is still transferred. This timeout gets reset each time a download progress event fires - Finally there's a total timeout of five minutes, which acts as a sanity check to prevent requests hanging forever The solution still has many limitations: - The download always begins from the start, so e.g. if the server closes the connection, reattempting the download is likely to fail again - There's no check to prevent multiple package list downloads running simultaneously. Since the background update runs every 5 minutes, and the timeout is also 5 minutes, they shouldn't overlap too badly. However, if a user moves from a manager screen to the game selection screen when the background download is in progress, and then selects a game and gets transferred to the splash screen, it will start a second download which is more likely to fail because the bandwidth is now shared between two requests - The UI on splash screen doesn't show any progress indicator. It seems the download percentage can't be shown since the total size of the file is unknown. I'm not sure if this is due just to the response headers or because the data is gzipped so the length of the actual content is unknown to the browser - User has no option to manually cancel the slow download on splash screen. Since there's three attempts to download the package list, this means they might be stuck on the splash screen for 15 minutes - From users perspective there's no difference between being offline (=no connection to the server) or having a slow connection, the UI always says they're offline if the download fails Despite the shortcomings this should be an improvement, which gives us more time to solve the shortcomings. --- src/r2mm/connection/ConnectionProviderImpl.ts | 13 ++- src/utils/HttpUtils.ts | 82 ++++++++++++++++++- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/r2mm/connection/ConnectionProviderImpl.ts b/src/r2mm/connection/ConnectionProviderImpl.ts index ed4aac4b3..aa1fbc285 100644 --- a/src/r2mm/connection/ConnectionProviderImpl.ts +++ b/src/r2mm/connection/ConnectionProviderImpl.ts @@ -6,6 +6,7 @@ import GameManager from '../../model/game/GameManager'; import ConnectionProvider, { DownloadProgressed } from '../../providers/generic/connection/ConnectionProvider'; import LoggerProvider, { LogSeverity } from '../../providers/ror2/logging/LoggerProvider'; import { sleep } from '../../utils/Common'; +import { makeLongRunningGetRequest } from '../../utils/HttpUtils'; export default class ConnectionProviderImpl extends ConnectionProvider { @@ -37,14 +38,10 @@ export default class ConnectionProviderImpl extends ConnectionProvider { } private async getPackagesFromRemote(game: Game, downloadProgressed?: DownloadProgressed) { - const response = await axios.get(game.thunderstoreUrl, { - onDownloadProgress: progress => { - if (downloadProgressed !== undefined) { - downloadProgressed((progress.loaded / progress.total) * 100); - } - }, - timeout: 30000 - }); + const response = await makeLongRunningGetRequest( + game.thunderstoreUrl, + {downloadProgressed} + ) if (isApiResonse(response)) { return response as ApiResponse; diff --git a/src/utils/HttpUtils.ts b/src/utils/HttpUtils.ts index 73d177426..68c93da79 100644 --- a/src/utils/HttpUtils.ts +++ b/src/utils/HttpUtils.ts @@ -1,5 +1,7 @@ import axios from "axios"; +import { DownloadProgressed } from "../providers/generic/connection/ConnectionProvider"; + const newAbortSignal = (timeoutMs: number) => { const abortController = new AbortController(); setTimeout(() => abortController.abort(), timeoutMs); @@ -10,24 +12,98 @@ const newAbortSignal = (timeoutMs: number) => { * Return Axios instance with timeouts enabled. * @param responseTimeout Time (in ms) the server has to generate a * response once a connection is established. Defaults to 5 seconds. - * @param connectionTimeout Time (in ms) the request has in total, + * @param totalTimeout Time (in ms) the request has in total, * including opening the connection and receiving the response. * Defaults to 10 seconds. * @returns AxiosInstance */ -export const getAxiosWithTimeouts = (responseTimeout = 5000, connectionTimeout = 10000) => { +export const getAxiosWithTimeouts = (responseTimeout = 5000, totalTimeout = 10000) => { const instance = axios.create({timeout: responseTimeout}); // Use interceptors to have a fresh abort signal for each request, // so the instance can be shared by multiple requests. instance.interceptors.request.use((config) => { - config.signal = newAbortSignal(connectionTimeout); + config.signal = newAbortSignal(totalTimeout); return config; }); return instance; }; +interface LongRunningRequestOptions { + /** + * Custom function to be called when progress is made. Doesn't work + * properly currently, since the progress percentage can't be + * calculated because the total length of the content isn't known. + */ + downloadProgressed?: DownloadProgressed; + /** + * Time (in ms) the request has to trigger the first download + * progress event. This can be used to timeout early if a connection + * can't be formed at all. Defaults to 30 seconds. + */ + initialTimeout?: number; + /** + * Time (in ms) the request has in total to complete. This can be + * used as a sanity check to prevent infinite requests. Defaults to + * five minutes. + */ + totalTimeout?: number; + /** + * Time (in ms) the request has to trigger subsequent download + * progress events. This can be used to timeout the request if data + * isn't transferred fast enough or at all. Defaults to one minute. + */ + transmissionTimeout?: number; +} + +/** + * Make a GET request with extended timeouts. + * + * Since Axios's support lacks granularity, request timeouts are + * controlled with AbortController and JavaScript timeouts instead. + */ +export const makeLongRunningGetRequest = async ( + url: string, + options: Partial = {} +) => { + const { + downloadProgressed = () => null, + initialTimeout = 30 * 1000, + totalTimeout = 5 * 60 * 1000, + transmissionTimeout = 60 * 1000 + } = options; + + const abortController = new AbortController(); + const abort = () => abortController.abort(); // Set valid this. + const sanityTimeout = setTimeout(abort, totalTimeout); + let rollingTimeout = setTimeout(abort, initialTimeout); + + const onDownloadProgress = (progress: ProgressEvent) => { + clearTimeout(rollingTimeout); + rollingTimeout = setTimeout(abort, transmissionTimeout); + + if (typeof downloadProgressed === "function") { + // TODO: Progress percentage can't be calculated since the + // content length is unknown. Looks like this hasn't worked + // in a while. + downloadProgressed(0); + } + } + + const instance = axios.create({ + onDownloadProgress, + signal: abortController.signal, + }); + + try { + return await instance.get(url); + } finally { + clearTimeout(sanityTimeout); + clearTimeout(rollingTimeout); + } +} + export const isNetworkError = (responseOrError: unknown) => responseOrError instanceof Error && responseOrError.message === "Network Error"; From 9ee86b6fb1285a08b1b4e22ead209b4e90057785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 18 Jan 2024 14:28:41 +0200 Subject: [PATCH 08/93] Fix "try to reconnect" on splash screen Old approach of reloading the page reset the selected game to the default RoR2. --- src/pages/Splash.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/Splash.vue b/src/pages/Splash.vue index 2b0b57897..56ea90ce7 100644 --- a/src/pages/Splash.vue +++ b/src/pages/Splash.vue @@ -194,7 +194,8 @@ export default class Splash extends mixins(SplashMixin) { } retryConnection() { - this.$router.go(0); + this.isOffline = false; + this.checkForUpdates(); } private async ensureWrapperInGameFolder() { From f7e62c03c91c1d3e0d8a683f910092bfe320bc06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 19 Jan 2024 13:31:26 +0200 Subject: [PATCH 09/93] Add Epic Games Store support for 20 Minutes Till Dawn --- src/model/game/GameManager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/model/game/GameManager.ts b/src/model/game/GameManager.ts index 10107ad28..808afb6bc 100644 --- a/src/model/game/GameManager.ts +++ b/src/model/game/GameManager.ts @@ -251,10 +251,14 @@ export default class GameManager { "https://thunderstore.io/c/hard-bullet/api/v1/package/", "https://raw.githubusercontent.com/ebkr/r2modmanPlus/master/modExclusions.md", [new StorePlatformMetadata(StorePlatform.STEAM, "1294760")], "HardBullet.jpg", GameSelectionDisplayMode.VISIBLE, GameInstanceType.GAME, PackageLoader.MELON_LOADER, ["hb"]), + new Game("20 Minutes Till Dawn", "20MinutesTillDawn", "20MinutesTillDawn", "20MinuteTillDawn", ["MinutesTillDawn.exe"], "MinutesTillDawn_Data", "https://thunderstore.io/c/20-minutes-till-dawn/api/v1/package/", "https://raw.githubusercontent.com/ebkr/r2modmanPlus/master/modExclusions.md", - [new StorePlatformMetadata(StorePlatform.STEAM, "1966900")], "20MinutesTillDawn.jpg", + [ + new StorePlatformMetadata(StorePlatform.STEAM, "1966900"), + new StorePlatformMetadata(StorePlatform.EPIC_GAMES_STORE, "4656facc740742a39e265b026e13d075") + ], "20MinutesTillDawn.jpg", GameSelectionDisplayMode.VISIBLE, GameInstanceType.GAME, PackageLoader.BEPINEX, ["mtd", "20mtd"]), new Game("Green Hell VR", "GreenHellVR", "GreenHellVR", From 014ab10ef805d05b1bb022bf22d079e36bd633d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 22 Jan 2024 11:47:28 +0200 Subject: [PATCH 10/93] Improve Splash screen download indicators - Calculate package list download progress percentage if possible. This requires that the response headers contain the total length of the content, which is not guaranteed. - Set exclusion list download progress to 100 when done. Previously this would be left to 0 if the response headers didn't contain the content length. - Set package list download progress to 100 when done, even if the download fails. Strictly speaking this wouldn't be necessary since the progress bar is hidden after this step, but it might prevent issues in the future if the UI is changed. - Reset all progress to 0 if user attempts to reconnect after some part of the process has failed. --- src/components/mixins/SplashMixin.vue | 3 +++ src/pages/Splash.vue | 3 +++ src/utils/HttpUtils.ts | 13 ++++--------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/mixins/SplashMixin.vue b/src/components/mixins/SplashMixin.vue index 352b2e124..66bef26b1 100644 --- a/src/components/mixins/SplashMixin.vue +++ b/src/components/mixins/SplashMixin.vue @@ -44,6 +44,7 @@ export default class SplashMixin extends Vue { }; ThunderstorePackages.EXCLUSIONS = await ConnectionProvider.instance.getExclusions(showProgress); + this.getRequestItem('ExclusionsList').setProgress(100); } // Get the list of Thunderstore mods from API. @@ -62,6 +63,8 @@ export default class SplashMixin extends Vue { this.isOffline = true; this.heroTitle = 'Failed to get mods from Thunderstore'; this.loadingText = 'You may be offline or Thunderstore is unavailabe. Checking cache.'; + } finally { + this.getRequestItem('ThunderstoreDownload').setProgress(100); } if (response) { diff --git a/src/pages/Splash.vue b/src/pages/Splash.vue index 56ea90ce7..807e5ed06 100644 --- a/src/pages/Splash.vue +++ b/src/pages/Splash.vue @@ -194,6 +194,9 @@ export default class Splash extends mixins(SplashMixin) { } retryConnection() { + this.getRequestItem('UpdateCheck').setProgress(0); + this.getRequestItem('ExclusionsList').setProgress(0); + this.getRequestItem('ThunderstoreDownload').setProgress(0); this.isOffline = false; this.checkForUpdates(); } diff --git a/src/utils/HttpUtils.ts b/src/utils/HttpUtils.ts index 68c93da79..1be34a0ad 100644 --- a/src/utils/HttpUtils.ts +++ b/src/utils/HttpUtils.ts @@ -31,11 +31,7 @@ export const getAxiosWithTimeouts = (responseTimeout = 5000, totalTimeout = 1000 }; interface LongRunningRequestOptions { - /** - * Custom function to be called when progress is made. Doesn't work - * properly currently, since the progress percentage can't be - * calculated because the total length of the content isn't known. - */ + /** Custom function to be called when progress is made. */ downloadProgressed?: DownloadProgressed; /** * Time (in ms) the request has to trigger the first download @@ -84,10 +80,9 @@ export const makeLongRunningGetRequest = async ( rollingTimeout = setTimeout(abort, transmissionTimeout); if (typeof downloadProgressed === "function") { - // TODO: Progress percentage can't be calculated since the - // content length is unknown. Looks like this hasn't worked - // in a while. - downloadProgressed(0); + // Backend might not provided total content length. + const percent = progress.total ? (progress.loaded / progress.total) * 100 : 0; + downloadProgressed(percent); } } From b64c89cf306488d50b3e846aae32bbd9a5ae32a7 Mon Sep 17 00:00:00 2001 From: Mythic Date: Tue, 23 Jan 2024 00:25:04 +0200 Subject: [PATCH 11/93] Reduce the likelihood of state management bugs Make sure the profile mod list is refreshed with the latest information even if the uninstall logic exits midway. --- src/components/views/LocalModList.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/views/LocalModList.vue b/src/components/views/LocalModList.vue index ab92a06d8..347aff97c 100644 --- a/src/components/views/LocalModList.vue +++ b/src/components/views/LocalModList.vue @@ -387,7 +387,7 @@ import SearchUtils from '../../utils/SearchUtils'; return Dependants.getDependencyList(mod, this.modifiableModList); } - async performUninstallMod(mod: ManifestV2, updateModList=true): Promise { + async performUninstallMod(mod: ManifestV2, updateModList=true): Promise { const uninstallError: R2Error | null = await ProfileInstallerProvider.instance.uninstallMod(mod, this.contextProfile!); if (uninstallError instanceof R2Error) { // Uninstall failed @@ -405,6 +405,7 @@ import SearchUtils from '../../utils/SearchUtils'; if (updateModList) { await this.updateModListAfterChange(modList); } + return modList; } async disableMod(vueMod: any) { @@ -448,6 +449,7 @@ import SearchUtils from '../../utils/SearchUtils'; async uninstallMod(vueMod: any) { let mod: ManifestV2 = new ManifestV2().fromReactive(vueMod); + let lastSuccess: ManifestV2[] | null = null; try { for (const dependant of Dependants.getDependantList(mod, this.modifiableModList)) { this.modBeingUninstalled = dependant.getName(); @@ -456,6 +458,8 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', result); this.modBeingUninstalled = null; return; + } else { + lastSuccess = result; } } this.modBeingUninstalled = mod.getName(); @@ -464,16 +468,21 @@ import SearchUtils from '../../utils/SearchUtils'; this.$emit('error', result); this.modBeingUninstalled = null; return; + } else { + lastSuccess = result; } } catch (e) { // Failed to uninstall mod. const err: Error = e as Error; this.$emit('error', err); - this.modBeingUninstalled = null; LoggerProvider.instance.Log(LogSeverity.ACTION_STOPPED, `${err.name}\n-> ${err.message}`); + } finally { + this.modBeingUninstalled = null; + if (lastSuccess) { + await this.updateModListAfterChange(lastSuccess); + } } this.selectedManifestMod = null; - this.modBeingUninstalled = null; const result: ManifestV2[] | R2Error = await ProfileModList.getModList(this.contextProfile!); if (result instanceof R2Error) { this.$emit('error', result); From 29b83bdb8126bc66ffad11a3793cfc97c7b166da Mon Sep 17 00:00:00 2001 From: Mythic Date: Tue, 23 Jan 2024 03:09:58 +0200 Subject: [PATCH 12/93] Create dedicated components for mod modals Create dedicated components for mod management modals (uninstall, disable, view assocaited). This commit does not move the state management anywhere yet, but ideally it too would be separated from the LocalModList.vue component. While this commit does increase the amount of boilerplate in the interim, it makes it easier to understand the state flow of the modals (especially inside the modal component itself) and leaves room for future changes that don't directly impact all of the modals. --- src/components/views/LocalModList.vue | 125 ++++++------------ .../LocalModList/AssociatedModsModal.vue | 62 +++++++++ .../views/LocalModList/DisableModModal.vue | 77 +++++++++++ .../views/LocalModList/UninstallModModal.vue | 79 +++++++++++ 4 files changed, 260 insertions(+), 83 deletions(-) create mode 100644 src/components/views/LocalModList/AssociatedModsModal.vue create mode 100644 src/components/views/LocalModList/DisableModModal.vue create mode 100644 src/components/views/LocalModList/UninstallModModal.vue diff --git a/src/components/views/LocalModList.vue b/src/components/views/LocalModList.vue index 7f21172cd..97f4fa9aa 100644 --- a/src/components/views/LocalModList.vue +++ b/src/components/views/LocalModList.vue @@ -37,88 +37,33 @@ - - - - - + + + @@ -250,6 +195,9 @@ import ConflictManagementProvider from '../../providers/generic/installing/Confl import Draggable from 'vuedraggable'; import DonateButton from '../../components/buttons/DonateButton.vue'; import SearchUtils from '../../utils/SearchUtils'; +import AssociatedModsModal from './LocalModList/AssociatedModsModal.vue'; +import DisableModModal from './LocalModList/DisableModModal.vue'; +import UninstallModModal from './LocalModList/UninstallModModal.vue'; @Component({ components: { @@ -258,7 +206,10 @@ import SearchUtils from '../../utils/SearchUtils'; Link, ExpandableCard, Modal, - Draggable + Draggable, + AssociatedModsModal, + DisableModModal, + UninstallModModal, } }) export default class LocalModList extends Vue { @@ -282,6 +233,7 @@ import SearchUtils from '../../utils/SearchUtils'; private selectedManifestMod: ManifestV2 | null = null; private dependencyListDisplayType: string = 'view'; private modBeingUninstalled: string | null = null; + private modBeingDisabled: string | null = null; // Filtering private sortDisabledPosition: SortLocalDisabledMods = this.settings.getInstalledDisablePosition(); @@ -459,14 +411,18 @@ import SearchUtils from '../../utils/SearchUtils'; LoggerProvider.instance.Log(LogSeverity.ACTION_STOPPED, `${err.name}\n-> ${err.message}`); } this.selectedManifestMod = null; + this.modBeingDisabled = null; } async performDisable(mods: ManifestV2[]): Promise { + this.modBeingDisabled = null; for (let mod of mods) { + this.modBeingDisabled = mod.getName(); const disableErr: R2Error | void = await ProfileInstallerProvider.instance.disableMod(mod, this.contextProfile!); if (disableErr instanceof R2Error) { // Failed to disable this.showingDependencyList = false; + this.modBeingDisabled = null; this.$emit('error', disableErr); return disableErr; } @@ -477,9 +433,11 @@ import SearchUtils from '../../utils/SearchUtils'; if (updatedList instanceof R2Error) { // Failed to update mod list. this.showingDependencyList = false; + this.modBeingDisabled = null; this.$emit('error', updatedList); return updatedList; } + this.modBeingDisabled = null; await this.updateModListAfterChange(updatedList); } @@ -497,6 +455,7 @@ import SearchUtils from '../../utils/SearchUtils'; let lastSuccess: ManifestV2[] | null = null; try { for (const mod of modsToUninstall) { + this.modBeingUninstalled = mod.getName(); const result = await this.performUninstallMod(mod, false); if (result instanceof R2Error) { this.$emit('error', result); diff --git a/src/components/views/LocalModList/AssociatedModsModal.vue b/src/components/views/LocalModList/AssociatedModsModal.vue new file mode 100644 index 000000000..e5fbee4f1 --- /dev/null +++ b/src/components/views/LocalModList/AssociatedModsModal.vue @@ -0,0 +1,62 @@ + + + + diff --git a/src/components/views/LocalModList/DisableModModal.vue b/src/components/views/LocalModList/DisableModModal.vue new file mode 100644 index 000000000..5f68d1a57 --- /dev/null +++ b/src/components/views/LocalModList/DisableModModal.vue @@ -0,0 +1,77 @@ + + + + diff --git a/src/components/views/LocalModList/UninstallModModal.vue b/src/components/views/LocalModList/UninstallModModal.vue new file mode 100644 index 000000000..b0b205280 --- /dev/null +++ b/src/components/views/LocalModList/UninstallModModal.vue @@ -0,0 +1,79 @@ + + + + From e4d18ffea00e53473ada401a244c15e50d7621f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Tue, 23 Jan 2024 12:06:11 +0200 Subject: [PATCH 13/93] Use cached getters in LocalModList These two methods get called for each installed mod in the profile. The methods themselves are fast enough that caching them don't change the performance noticeably one way or another though. --- src/components/views/LocalModList.vue | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/views/LocalModList.vue b/src/components/views/LocalModList.vue index 7f21172cd..c8858d8b5 100644 --- a/src/components/views/LocalModList.vue +++ b/src/components/views/LocalModList.vue @@ -123,19 +123,19 @@