Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mod installation progress tracking #1625

Merged
merged 9 commits into from
Feb 24, 2025
53 changes: 48 additions & 5 deletions src/components/mixins/DownloadMixin.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
<script lang='ts'>
import Vue from 'vue';
import Component from 'vue-class-component';
import { Store } from "vuex";

import R2Error from "../../model/errors/R2Error";
import StatusEnum from "../../model/enums/StatusEnum";
import R2Error, { throwForR2Error } from "../../model/errors/R2Error";
import Game from "../../model/game/Game";
import Profile from "../../model/Profile";
import ManifestV2 from "../../model/ManifestV2";
import Profile, { ImmutableProfile } from "../../model/Profile";
import ThunderstoreCombo from "../../model/ThunderstoreCombo";
import ThunderstoreMod from "../../model/ThunderstoreMod";
import { installModsAndResolveConflicts } from "../../utils/ProfileUtils";
import ConflictManagementProvider from "../../providers/generic/installing/ConflictManagementProvider";
import ProfileModList from "../../r2mm/mods/ProfileModList";
import { installModsToProfile } from "../../utils/ProfileUtils";


@Component
Expand Down Expand Up @@ -42,14 +47,52 @@ export default class DownloadMixin extends Vue {
return this.$store.getters['profile/activeProfile'];
}

async downloadCompletedCallback(downloadedMods: ThunderstoreCombo[]): Promise<void> {
async downloadCompletedCallback(downloadedMods: ThunderstoreCombo[], assignId: number): Promise<void> {
try {
await installModsAndResolveConflicts(downloadedMods, this.profile.asImmutableProfile(), this.$store);
await this.installModsAndResolveConflicts(downloadedMods, this.profile.asImmutableProfile(), assignId);
} catch (e) {
this.$store.commit('download/updateDownload', {assignId, failed: true});
this.$store.commit('error/handleError', R2Error.fromThrownValue(e));
}
}

static downloadProgressCallback(
store: Store<any>,
assignId: number,
downloadProgress: number,
modName: string,
status: number,
err: R2Error | null,
closeModProgressModal?: () => void,
) {
if (status === StatusEnum.FAILURE) {
if (closeModProgressModal !== undefined) {
closeModProgressModal();
}
store.commit('download/updateDownload', {assignId, failed: true});
if (err !== null) {
DownloadMixin.addSolutionsToError(err);
throw err;
}
} else if (status === StatusEnum.PENDING || status === StatusEnum.SUCCESS) {
store.commit('download/updateDownload', {assignId, modName, downloadProgress});
}
}

async installModsAndResolveConflicts(
downloadedMods: ThunderstoreCombo[],
profile: ImmutableProfile,
assignId: number
): Promise<void> {
await ProfileModList.requestLock(async () => {
const modList: ManifestV2[] = await installModsToProfile(downloadedMods, profile, undefined,(status, modName, installProgress) => {
this.$store.commit('download/updateDownload', {assignId, modName, installProgress});
});
await this.$store.dispatch('profile/updateModList', modList);
throwForR2Error(await ConflictManagementProvider.instance.resolveConflicts(modList, profile));
});
}

static addSolutionsToError(err: R2Error): void {
// Sanity check typing.
if (!(err instanceof R2Error)) {
Expand Down
92 changes: 48 additions & 44 deletions src/components/views/DownloadModModal.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
<template>
<div>
<div id='downloadProgressModal' :class="['modal', {'is-active':$store.state.download.isModProgressModalOpen}]" v-if="$store.getters['download/currentDownload'] !== null">
<div
id='downloadProgressModal'
:class="['modal', {'is-active':$store.state.download.isModProgressModalOpen}]"
v-if="$store.getters['download/currentDownload'] !== null"
>
<div class="modal-background" @click="setIsModProgressModalOpen(false);"></div>
<div class='modal-content'>
<div class='notification is-info'>
<h3 class='title'>Downloading {{$store.getters['download/currentDownload'].modName}}</h3>
<p>{{Math.floor($store.getters['download/currentDownload'].progress)}}% complete</p>

<h3 v-if="$store.getters['download/currentDownload'].downloadProgress < 100" class='title'>
Downloading {{$store.getters['download/currentDownload'].modName}}
</h3>
<h3 v-else class='title'>
Installing {{$store.getters['download/currentDownload'].modName}}
</h3>

<p>Downloading: {{Math.floor($store.getters['download/currentDownload'].downloadProgress)}}% complete</p>

<Progress
:max='100'
:value="$store.getters['download/currentDownload'].downloadProgress"
:className="['is-dark']"
/>

<p v-if="$store.getters['download/currentDownload'].installProgress">
Installing: {{Math.floor($store.getters['download/currentDownload'].installProgress)}}% complete
</p>
<p v-else>Installing: waiting for download to finish</p>

<Progress
:max='100'
:value="$store.getters['download/currentDownload'].progress"
:value="$store.getters['download/currentDownload'].installProgress"
:className="['is-dark']"
/>
</div>
Expand All @@ -31,7 +54,6 @@ import ModalCard from '../ModalCard.vue';
import DownloadModVersionSelectModal from "../../components/views/DownloadModVersionSelectModal.vue";
import UpdateAllInstalledModsModal from "../../components/views/UpdateAllInstalledModsModal.vue";
import DownloadMixin from "../mixins/DownloadMixin.vue";
import StatusEnum from '../../model/enums/StatusEnum';
import R2Error from '../../model/errors/R2Error';
import ManifestV2 from '../../model/ManifestV2';
import Profile from '../../model/Profile';
Expand Down Expand Up @@ -73,9 +95,9 @@ import ProfileModList from '../../r2mm/mods/ProfileModList';
profile.asImmutableProfile(),
combo,
ignoreCache,
(progress: number, modName: string, status: number, err: R2Error | null) => {
(downloadProgress: number, modName: string, status: number, err: R2Error | null) => {
try {
DownloadModModal.downloadProgressCallback(store, assignId, progress, modName, status, err);
DownloadMixin.downloadProgressCallback(store, assignId, downloadProgress, modName, status, err);
} catch (e) {
reject(e);
}
Expand All @@ -95,30 +117,19 @@ import ProfileModList from '../../r2mm/mods/ProfileModList';
}
}
const modList = await ProfileModList.getModList(profile.asImmutableProfile());
if (!(modList instanceof R2Error)) {
const err = await ConflictManagementProvider.instance.resolveConflicts(modList, profile.asImmutableProfile());
if (err instanceof R2Error) {
return reject(err);
}
if (modList instanceof R2Error) {
return reject(modList);
}
const err = await ConflictManagementProvider.instance.resolveConflicts(modList, profile.asImmutableProfile());
if (err instanceof R2Error) {
return reject(err);
}
return resolve();
});
}, 1);
});
}

static downloadProgressCallback(store: Store<any>, assignId: number, progress: number, modName: string, status: number, err: R2Error | null) {
if (status === StatusEnum.FAILURE) {
store.commit('download/updateDownload', {assignId, failed: true});
if (err !== null) {
DownloadMixin.addSolutionsToError(err);
throw err;
}
} else if (status === StatusEnum.PENDING) {
store.commit('download/updateDownload', {assignId, progress, modName});
}
}

async downloadHandler(tsMod: ThunderstoreMod, tsVersion: ThunderstoreVersion) {
this.closeModal();

Expand All @@ -140,36 +151,29 @@ import ProfileModList from '../../r2mm/mods/ProfileModList';
this.profile.asImmutableProfile(),
tsCombo,
this.ignoreCache,
(progress, modName, status, err) => { this.downloadProgressCallback(assignId, progress, modName, status, err); }
(downloadProgress, modName, status, err) => {
DownloadMixin.downloadProgressCallback(
this.$store,
assignId,
downloadProgress,
modName,
status,
err,
() => this.setIsModProgressModalOpen(false)
);
}
);
} catch (e) {
this.setIsModProgressModalOpen(false);
this.$store.commit('download/updateDownload', { assignId, failed: true });
this.$store.commit('error/handleError', R2Error.fromThrownValue(e));
return;
}
await this.downloadCompletedCallback(downloadedMods);
await this.downloadCompletedCallback(downloadedMods, assignId);
this.setIsModProgressModalOpen(false);
}, 1);

}

downloadProgressCallback(assignId: number, progress: number, modName: string, status: number, err: R2Error | null) {
try {
if (status === StatusEnum.FAILURE) {
this.setIsModProgressModalOpen(false);
this.$store.commit('download/updateDownload', {assignId, failed: true});
if (err !== null) {
DownloadMixin.addSolutionsToError(err);
throw err;
}
} else if (status === StatusEnum.PENDING) {
this.$store.commit('download/updateDownload', {assignId, progress, modName});
}
} catch (e) {
this.$store.commit('error/handleError', R2Error.fromThrownValue(e));
}
};

static async installModAfterDownload(profile: Profile, mod: ThunderstoreMod, version: ThunderstoreVersion): Promise<R2Error | void> {
return new Promise(async (resolve, reject) => {
const manifestMod: ManifestV2 = new ManifestV2().fromThunderstoreMod(mod, version);
Expand Down
6 changes: 3 additions & 3 deletions src/components/views/UpdateAllInstalledModsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default class UpdateAllInstalledModsModal extends mixins(DownloadMixin)
);

this.setIsModProgressModalOpen(true);
ThunderstoreDownloaderProvider.instance.downloadLatestOfAll(modsWithUpdates, this.ignoreCache, (progress: number, modName: string, status: number, err: R2Error | null) => {
ThunderstoreDownloaderProvider.instance.downloadLatestOfAll(modsWithUpdates, this.ignoreCache, (downloadProgress: number, modName: string, status: number, err: R2Error | null) => {
try {
if (status === StatusEnum.FAILURE) {
this.setIsModProgressModalOpen(false);
Expand All @@ -59,13 +59,13 @@ export default class UpdateAllInstalledModsModal extends mixins(DownloadMixin)
throw err;
}
} else if (status === StatusEnum.PENDING) {
this.$store.commit('download/updateDownload', {assignId, progress, modName});
this.$store.commit('download/updateDownload', {assignId, modName, downloadProgress});
}
} catch (e) {
this.$store.commit('error/handleError', R2Error.fromThrownValue(e));
}
}, async (downloadedMods) => {
await this.downloadCompletedCallback(downloadedMods);
await this.downloadCompletedCallback(downloadedMods, assignId);
this.setIsModProgressModalOpen(false);
});
}
Expand Down
64 changes: 52 additions & 12 deletions src/pages/DownloadMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,58 @@
<div class="border-at-bottom pad pad--sides">
<div class="card is-shadowless">
<p><strong>{{ downloadObject.initialMods.join(", ") }}</strong></p>
<p v-if="downloadObject.progress < 100">Downloading: {{ downloadObject.modName }}</p>
<p>{{Math.min(Math.floor(downloadObject.progress), 100)}}% complete</p>
<Progress v-if="!downloadObject.failed"
:max='100'
:value='downloadObject.progress'
:className="['is-info']"
/>
<Progress v-else-if="downloadObject.failed"
:max='100'
:value='100'
:className="['is-danger']"
/>

<div v-if="downloadObject.failed">
<p>Download failed</p>
<Progress
:max='100'
:value='100'
:className="['is-danger']"
/>
</div>

<div v-else-if="downloadObject.downloadProgress === 100 && downloadObject.installProgress === 100">
<p>Download complete</p>
<Progress
:max='100'
:value='100'
:className="['is-success']"
/>
</div>

<div v-else class="row">

<div class="col">
<p v-if="downloadObject.downloadProgress < 100">Downloading: {{ downloadObject.modName }}</p>
<p v-else>Downloading:</p>
<p>{{Math.min(Math.floor(downloadObject.downloadProgress), 100)}}% complete</p>
<Progress
:max='100'
:value='downloadObject.downloadProgress'
:className="['is-info']"
/>
</div>

<div v-if="downloadObject.downloadProgress < 100" class="col">
<p>Installing:</p>
<p>Waiting for download to finish</p>
<Progress
:max='100'
:value='0'
:className="['is-info']"
/>
</div>
<div v-else class="col">
<p>Installing: {{ downloadObject.modName }}</p>
<p>{{Math.min(Math.floor(downloadObject.installProgress), 100)}}% complete</p>
<Progress
:max='100'
:value='downloadObject.installProgress'
:className="['is-info']"
/>
</div>

</div>
</div>
</div>
</div>
Expand Down
28 changes: 16 additions & 12 deletions src/r2mm/downloading/BetterThunderstoreDownloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,38 +96,42 @@ export default class BetterThunderstoreDownloader extends ThunderstoreDownloader
}

const dependencies = await this.getDependenciesWithCorrectVersions(combo, modList);
const allModsToDownload = [...dependencies, combo];
const allModsToDownload = [combo, ...dependencies];

let modInProgressName = combo.getMod().getName();
let downloadCount = 0;
const singleModProgressCallback = (progress: number, status: number, err: R2Error | null) => {
// Mark the mod 80% processed when the download completes, save the remaining 20% for extracting.
const singleModProgressCallback = (downloadProgress: number, status: number, err: R2Error | null) => {
if (status === StatusEnum.FAILURE) {
throw err;
}

let totalProgress: number;
let totalDownloadProgress: number;
if (status === StatusEnum.PENDING) {
totalProgress = this.generateProgressPercentage(progress, downloadCount, allModsToDownload.length);
totalDownloadProgress = this.generateProgressPercentage(downloadProgress * 0.8, downloadCount, allModsToDownload.length);
} else if (status === StatusEnum.SUCCESS) {
totalProgress = this.generateProgressPercentage(100, downloadCount, allModsToDownload.length);
totalDownloadProgress = this.generateProgressPercentage(100, downloadCount, allModsToDownload.length);
downloadCount += 1;
} else {
console.error(`Ignore unknown status code "${status}"`);
return;
}
totalProgressCallback(totalProgress, combo.getMod().getName(), status, err);
totalProgressCallback(Math.round(totalDownloadProgress), modInProgressName, status, err);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think other places use Math.floor, any reason to use .round instead here? (If changed, should be changed to TSMM sibling PR as well.)

Copy link
Collaborator Author

@VilppeRiskidev VilppeRiskidev Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason was that the generateProgressPercentage() sometimes returns like 99.9999999 instead of 100 (floating point number calculations are sometimes a tiny bit inaccurate) and therefore the download progress got stuck at 99%. Should the generateProgressPercentage actually only return integers (round the number before returning)? (I implemented that as a part of the changes)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the change is OK, should/could the rounding of the progress percentage be removed from the UI components or is it a best practice to round just in case?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In hindsight this whole thing should probably be designed so that it's the Vuex module that receives updates of each mods progress and internally keeps tracks how many mods is completed out of how many mods total, and what's the process of currently downloading mod. And it should then be the one that provides a getter or some other mechanic for providing the total progress to UI (rounded in one place and in one consistent way). That would also allow us to show it as percentage for progress bars, or as "Downloading mod 5 out of 6" for other status indicators.

Anyway, I'd be careful in changing generateProgressPercentage() as it's called from elsewhere too. If you think this change improves the overall situation, feel free to go ahead with it, but do test it thoroughly (all functions that call generateProgressPercentage() and related UI parts).

}

for (const combo of allModsToDownload) {
if (!ignoreCache && await this.isVersionAlreadyDownloaded(combo)) {
totalProgressCallback(100, combo.getMod().getName(), StatusEnum.SUCCESS, null);
for (const comboInProgress of allModsToDownload) {
modInProgressName = comboInProgress.getMod().getName();

if (!ignoreCache && await this.isVersionAlreadyDownloaded(comboInProgress)) {
totalProgressCallback(100, modInProgressName, StatusEnum.SUCCESS, null);
continue;
}

try {
const response = await this._downloadCombo(combo, singleModProgressCallback);
await this._saveDownloadResponse(response, combo, singleModProgressCallback);
const response = await this._downloadCombo(comboInProgress, singleModProgressCallback);
await this._saveDownloadResponse(response, comboInProgress, singleModProgressCallback);
} catch(e) {
throw R2Error.fromThrownValue(e, `Failed to download mod ${combo.getVersion().getFullName()}`);
throw R2Error.fromThrownValue(e, `Failed to download mod ${comboInProgress.getVersion().getFullName()}`);
}
}
return allModsToDownload;
Expand Down
Loading
Loading