diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c0737d7..3a6a08d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### 3.1.53 +#### Games added +- WEBFISHING + +#### Other changes +- BONELAB support should now function correctly + - Assemblies will no longer be regenerated on every launch +- "Preloader fix" has been replaced with "Reset \ installation" + - This will delete the entire game directory and prompt Steam to re-validate files + - The preloader fix was originally a solution for old Risk of Rain 2 modded installations using SeikoML failing to + launch + ### 3.1.52 #### Small quality of life changes - Upgraded from Electron v11 to v24 @@ -6,6 +18,10 @@ - Fixed sorting by download count - Reduced time taken to install when a mod has a large number of dependencies +#### Games added +- Old Market Simulator +- Subterranauts + ### 3.1.51 #### Memory and performance improvements The TS team have been working hard to improve the following: diff --git a/docs/Adding a game.md b/docs/Adding a game.md index 619bc1ed5..f4bf17927 100644 --- a/docs/Adding a game.md +++ b/docs/Adding a game.md @@ -35,7 +35,6 @@ - An array of different executable names (for all platforms). For `StorePlatform` types such as `OTHER` and `STEAM_DIRECT` then the first found executable is used and called when the game is launched. - **DataFolderName** - Required for Unreal games relying on unreal-shimloader. - - Relevant for Mono (C#) Unity games, which use it for the `Preloader Fix` in the manager settings. - **TsUrl** - The Thunderstore API endpoint for the listing. - **ExclusionsUrl** diff --git a/modExclusions.json b/modExclusions.json index a42acf96f..0a4ca40a2 100644 --- a/modExclusions.json +++ b/modExclusions.json @@ -23,6 +23,8 @@ "L4rs-QuickFSR", "MPModTeam-Boneworks_MP", "MADH95Mods-JSONRenameUtility", - "GamefaceGamers-Mod_Sync" + "GamefaceGamers-Mod_Sync", + "Pyoid-Hook_Line_and_Sinker", + "GardenGals-Hatchery" ] } diff --git a/modExclusions.md b/modExclusions.md index 2b8302a5f..7d31408e6 100644 --- a/modExclusions.md +++ b/modExclusions.md @@ -51,3 +51,7 @@ codengine-SOTFEdit DevLeon-SonsOfTheForestMap Kesomannen-GaleModManager + +Pyoid-Hook_Line_and_Sinker + +GardenGals-Hatchery diff --git a/package.json b/package.json index d5150a19f..69797535d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "r2modman", - "version": "3.1.52", + "version": "3.1.53", "description": "A simple and easy to use mod manager for many games using Thunderstore.", "productName": "r2modman", "author": "ebkr", @@ -109,6 +109,7 @@ "sinon": "^11.1.1", "ts-node": "^8.10.2", "typescript": "^4.5.5", + "vue": "2.7.16", "vue-jest": "^3.0.0", "wallaby-vue-compiler": "^1.0.3" }, diff --git a/src/App.vue b/src/App.vue index bff31f2db..b7f47824c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -100,6 +100,8 @@ export default class App extends mixins(UtilityMixin) { this.$watch('$q.dark.isActive', () => { document.documentElement.classList.toggle('html--dark', this.$q.dark.isActive); }); + + this.$store.commit('updateModLoaderPackageNames'); } beforeCreate() { diff --git a/src/_managerinf/ManagerInformation.ts b/src/_managerinf/ManagerInformation.ts index 6450fe194..b68d2ee9a 100644 --- a/src/_managerinf/ManagerInformation.ts +++ b/src/_managerinf/ManagerInformation.ts @@ -1,7 +1,7 @@ import VersionNumber from '../model/VersionNumber'; export default class ManagerInformation { - public static VERSION: VersionNumber = new VersionNumber('3.1.52'); + public static VERSION: VersionNumber = new VersionNumber('3.1.53'); public static IS_PORTABLE: boolean = false; public static APP_NAME: string = "r2modman"; } diff --git a/src/assets/images/game_selection/SULFUR.png b/src/assets/images/game_selection/SULFUR.png new file mode 100644 index 000000000..fb97a40ff Binary files /dev/null and b/src/assets/images/game_selection/SULFUR.png differ diff --git a/src/assets/images/game_selection/WEBFISHING.png b/src/assets/images/game_selection/WEBFISHING.png new file mode 100644 index 000000000..5c947a411 Binary files /dev/null and b/src/assets/images/game_selection/WEBFISHING.png differ diff --git a/src/components/profiles-modals/CreateProfileModal.vue b/src/components/profiles-modals/CreateProfileModal.vue index 97cfd7997..488c6191f 100644 --- a/src/components/profiles-modals/CreateProfileModal.vue +++ b/src/components/profiles-modals/CreateProfileModal.vue @@ -9,6 +9,7 @@ import ProfilesMixin from "../../components/mixins/ProfilesMixin.vue"; }) export default class CreateProfileModal extends ProfilesMixin { + private creatingInProgress: boolean = false; private newProfileName = ''; get isOpen(): boolean { @@ -17,17 +18,23 @@ export default class CreateProfileModal extends ProfilesMixin { closeModal() { this.newProfileName = ''; + this.creatingInProgress = false; this.$store.commit('closeCreateProfileModal'); } // User confirmed creation of a new profile with a name that didn't exist before. async createProfile() { + if (this.creatingInProgress) { + return; + } const safeName = this.makeProfileNameSafe(this.newProfileName); if (safeName !== '') { try { + this.creatingInProgress = true; await this.$store.dispatch('profiles/addProfile', safeName); this.closeModal(); } catch (e) { + this.creatingInProgress = false; const err = R2Error.fromThrownValue(e, 'Error whilst creating a profile'); this.$store.commit('error/handleError', err); } @@ -49,7 +56,7 @@ export default class CreateProfileModal extends ProfilesMixin { @@ -67,7 +74,7 @@ export default class CreateProfileModal extends ProfilesMixin { diff --git a/src/components/profiles-modals/DeleteProfileModal.vue b/src/components/profiles-modals/DeleteProfileModal.vue index d4f2dfc08..b0080afca 100644 --- a/src/components/profiles-modals/DeleteProfileModal.vue +++ b/src/components/profiles-modals/DeleteProfileModal.vue @@ -8,16 +8,23 @@ import ProfilesMixin from "../../components/mixins/ProfilesMixin.vue"; components: {ModalCard} }) export default class DeleteProfileModal extends ProfilesMixin { + private deletingInProgress: boolean = false; + get isOpen(): boolean { return this.$store.state.modals.isDeleteProfileModalOpen; } closeDeleteProfileModal() { + this.deletingInProgress = false; this.$store.commit('closeDeleteProfileModal'); } async removeProfile() { + if (this.deletingInProgress) { + return; + } try { + this.deletingInProgress = true; await this.$store.dispatch('profiles/removeSelectedProfile'); } catch (e) { const err = R2Error.fromThrownValue(e, 'Error whilst deleting profile'); @@ -41,6 +48,7 @@ export default class DeleteProfileModal extends ProfilesMixin { diff --git a/src/components/settings-components/SettingsView.vue b/src/components/settings-components/SettingsView.vue index 34e1f5d38..2800fda6e 100644 --- a/src/components/settings-components/SettingsView.vue +++ b/src/components/settings-components/SettingsView.vue @@ -142,7 +142,7 @@ import CdnProvider from '../../providers/generic/connection/CdnProvider'; ), new SettingsRow( 'Locations', - 'Change data folder folder', + 'Change data folder', 'Change the folder where mods are stored for all games and profiles. The folder will not be deleted, and existing profiles will not carry across.', async () => { return PathResolver.ROOT; @@ -170,14 +170,6 @@ import CdnProvider from '../../providers/generic/connection/CdnProvider'; 'fa-exchange-alt', () => this.emitInvoke('ToggleDownloadCache') ), - new SettingsRow( - 'Debugging', - 'Run preloader fix', - 'Run this to fix most errors mentioning the preloader, or about duplicate assemblies.', - async () => `This will delete the ${this.activeGame.dataFolderName}/Managed folder, and verify the files through Steam`, - 'fa-wrench', - () => this.emitInvoke('RunPreloaderFix') - ), new SettingsRow( 'Debugging', 'Set launch parameters', @@ -388,6 +380,14 @@ import CdnProvider from '../../providers/generic/connection/CdnProvider'; }, 'fa-folder-open', () => this.emitInvoke('ChangeSteamDirectory') + ), + new SettingsRow( + 'Debugging', + `Reset ${this.activeGame.displayName} installation`, + 'Fix problems caused by corrupted files or files left over from manual modding attempts.', + async () => `This will delete all contents of the ${this.activeGame.steamFolderName} folder, and verify the files through Steam`, + 'fa-wrench', + () => this.emitInvoke('ValidateSteamInstallation') ) ) } diff --git a/src/components/v2/Hero.vue b/src/components/v2/Hero.vue new file mode 100644 index 000000000..ea82efcb7 --- /dev/null +++ b/src/components/v2/Hero.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/views/DownloadModModal.vue b/src/components/views/DownloadModModal.vue index a7390c19b..c4be2850f 100644 --- a/src/components/views/DownloadModModal.vue +++ b/src/components/views/DownloadModModal.vue @@ -174,7 +174,7 @@ let assignId = 0; const existing = DownloadModModal.allVersions[assignIndex] existing[1].failed = true; DownloadModModal.allVersions[assignIndex] = [currentAssignId, existing[1]]; - DownloadModModal.addCdnSolutionToError(err); + DownloadModModal.addSolutionsToError(err); return reject(err); } } else if (status === StatusEnum.PENDING) { @@ -306,7 +306,7 @@ let assignId = 0; const existing = DownloadModModal.allVersions[assignIndex] existing[1].failed = true; this.$set(DownloadModModal.allVersions, assignIndex, [currentAssignId, existing[1]]); - DownloadModModal.addCdnSolutionToError(err); + DownloadModModal.addSolutionsToError(err); this.$store.commit('error/handleError', err); return; } @@ -348,7 +348,7 @@ let assignId = 0; const existing = DownloadModModal.allVersions[assignIndex] existing[1].failed = true; this.$set(DownloadModModal.allVersions, assignIndex, [currentAssignId, existing[1]]); - DownloadModModal.addCdnSolutionToError(err); + DownloadModModal.addSolutionsToError(err); this.$store.commit('error/handleError', err); return; } @@ -432,13 +432,22 @@ let assignId = 0; }); } - static addCdnSolutionToError(err: R2Error): void { + static addSolutionsToError(err: R2Error): void { + // Sanity check typing. + if (!(err instanceof R2Error)) { + return; + } + if ( err.name.includes("Failed to download mod") || err.name.includes("System.Net.WebException") ) { err.solution = "Try toggling the preferred Thunderstore CDN in the settings"; } + + if (err.message.includes("System.IO.PathTooLongException")) { + err.solution = 'Using "Change data folder" option in the settings to select a shorter path might solve the issue'; + } } } diff --git a/src/components/views/LocalModList/LocalModCard.vue b/src/components/views/LocalModList/LocalModCard.vue index dfe29b9ba..f2b149d8c 100644 --- a/src/components/views/LocalModList/LocalModCard.vue +++ b/src/components/views/LocalModList/LocalModCard.vue @@ -25,6 +25,12 @@ export default class LocalModCard extends Vue { missingDependencies: string[] = []; disableChangePending = false; + get canBeDisabled(): boolean { + // Mod loader packages can't be disabled as it's hard to define + // what that should even do in all cases. + return !this.$store.getters['isModLoader'](this.mod.getName()); + } + get donationLink() { return this.tsMod ? this.tsMod.getDonationLink() : undefined; } @@ -246,7 +252,8 @@ function dependencyStringToModName(x: string) { class='fas fa-exclamation-circle' > -
- + Disable - + Enable diff --git a/src/installers/GDWeaveInstaller.ts b/src/installers/GDWeaveInstaller.ts new file mode 100644 index 000000000..7c859ad2e --- /dev/null +++ b/src/installers/GDWeaveInstaller.ts @@ -0,0 +1,160 @@ +import { + disableModByRenamingFiles, + enableModByRenamingFiles, + InstallArgs, + PackageInstaller +} from './PackageInstaller'; +import path from 'path'; +import FsProvider from '../providers/generic/file/FsProvider'; +import FileUtils from '../utils/FileUtils'; +import { MODLOADER_PACKAGES } from '../r2mm/installing/profile_installers/ModLoaderVariantRecord'; +import { PackageLoader } from '../model/installing/PackageLoader'; +import FileWriteError from '../model/errors/FileWriteError'; +import R2Error from '../model/errors/R2Error'; + +export class GDWeaveInstaller implements PackageInstaller { + async install(args: InstallArgs) { + const { mod, packagePath, profile } = args; + + const mapping = MODLOADER_PACKAGES.find( + (entry) => + entry.packageName.toLowerCase() == + mod.getName().toLowerCase() && + entry.loaderType == PackageLoader.GDWEAVE + ); + + if (!mapping) { + throw new Error(`Missing modloader for ${mod.getName()}`); + } + + const root = path.join(packagePath, mapping.rootFolder); + const toCopy = ['winmm.dll', 'GDWeave']; + + for (const fileOrFolder of toCopy) { + await FileUtils.copyFileOrFolder( + path.join(root, fileOrFolder), + profile.joinToProfilePath(fileOrFolder) + ); + } + } + + async uninstall(args: InstallArgs): Promise { + const { profile } = args; + + try { + // Remove GDWeave/core, but keep mods and config subfolder and the log file. + const toDelete = [ + profile.joinToProfilePath('winmm.dll'), + profile.joinToProfilePath('GDWeave', 'core'), + ]; + for (const fileOrFolder of toDelete) { + if (!(await FsProvider.instance.exists(fileOrFolder))) { + continue; + } + + if ((await FsProvider.instance.lstat(fileOrFolder)).isFile()) { + await FsProvider.instance.unlink(fileOrFolder); + } else { + await FileUtils.recursiveRemoveDirectoryIfExists( + fileOrFolder + ); + } + } + } catch (e) { + const name = 'Failed to delete GDWeave files from profile root'; + const solution = 'Is the game still running?'; + throw FileWriteError.fromThrownValue(e, name, solution); + } + } +} + +async function searchForManifest( + dir: string, + topLevel: boolean = true +): Promise { + // Ignore `manifest.json` at the top level - that's Thunderstore's + if ( + !topLevel && + (await FsProvider.instance.exists(path.join(dir, 'manifest.json'))) + ) { + return dir; + } + + for (const item of await FsProvider.instance.readdir(dir)) { + if ( + (await FsProvider.instance.stat(path.join(dir, item))).isDirectory() + ) { + const result = await searchForManifest(path.join(dir, item), false); + if (result) return result; + } + } + + return null; +} + +export class GDWeavePluginInstaller implements PackageInstaller { + getModFolderInProfile(args: InstallArgs): string { + return args.profile.joinToProfilePath( + 'GDWeave', + 'mods', + args.mod.getName() + ); + } + + async install(args: InstallArgs) { + // Packaging is all over the place. Find a folder with a manifest.json + // not at the top level (because the top level is Thunderstore's packaging) + const modFolderInCache = await searchForManifest(args.packagePath); + if (!modFolderInCache) { + throw new R2Error( + 'Could not find mod folder', + 'Either the mod package is malformed, or the files extracted to cache are corrupted' + ); + } + + const modFolderInProfile = this.getModFolderInProfile(args); + + try { + await FsProvider.instance.copyFolder(modFolderInCache, modFolderInProfile); + } catch (e) { + const name = 'Failed to copy mod to profile'; + throw FileWriteError.fromThrownValue(e, name); + } + } + + async uninstall(args: InstallArgs): Promise { + try { + await FileUtils.recursiveRemoveDirectoryIfExists( + this.getModFolderInProfile(args) + ); + } catch (e) { + const name = 'Failed to delete mod from profile root'; + const solution = 'Is the game still running?'; + throw FileWriteError.fromThrownValue(e, name, solution); + } + } + + async enable(args: InstallArgs): Promise { + try { + await enableModByRenamingFiles( + this.getModFolderInProfile(args) + ); + } catch (e) { + const name = 'Failed to enable mod'; + const solution = 'Is the game still running?'; + throw FileWriteError.fromThrownValue(e, name, solution); + } + } + + async disable(args: InstallArgs): Promise { + try { + await disableModByRenamingFiles( + this.getModFolderInProfile(args) + ); + } catch (e) { + const name = 'Failed to disable mod'; + const solution = 'Is the game still running?'; + throw FileWriteError.fromThrownValue(e, name, solution); + } + } +} diff --git a/src/installers/PackageInstaller.ts b/src/installers/PackageInstaller.ts index ccca7d3e8..96aae3ab5 100644 --- a/src/installers/PackageInstaller.ts +++ b/src/installers/PackageInstaller.ts @@ -1,5 +1,8 @@ +import R2Error from "../model/errors/R2Error"; +import FileTree from "../model/file/FileTree"; import { ImmutableProfile } from "../model/Profile"; import ManifestV2 from "../model/ManifestV2"; +import FsProvider from "../providers/generic/file/FsProvider"; export type InstallArgs = { mod: ManifestV2; @@ -10,4 +13,35 @@ export type InstallArgs = { export interface PackageInstaller { install(args: InstallArgs): Promise; uninstall?(args: InstallArgs): Promise; + enable?(args: InstallArgs): Promise; + disable?(args: InstallArgs): Promise; +} + +export async function disableModByRenamingFiles(folderName: string) { + const tree = await FileTree.buildFromLocation(folderName); + if (tree instanceof R2Error) { + throw tree; + } + + for (const filePath of tree.getRecursiveFiles()) { + if (!filePath.toLowerCase().endsWith(".old")) { + await FsProvider.instance.rename(filePath, `${filePath}.old`); + } + } +} + +export async function enableModByRenamingFiles(folderName: string) { + const tree = await FileTree.buildFromLocation(folderName); + if (tree instanceof R2Error) { + throw tree; + } + + for (const filePath of tree.getRecursiveFiles()) { + if (filePath.toLowerCase().endsWith(".old")) { + await FsProvider.instance.rename( + filePath, + filePath.substring(0, filePath.length - ('.old').length) + ); + } + } } diff --git a/src/installers/registry.ts b/src/installers/registry.ts index b665f989d..e0c3dc816 100644 --- a/src/installers/registry.ts +++ b/src/installers/registry.ts @@ -6,7 +6,7 @@ import { ShimloaderInstaller, ShimloaderPluginInstaller } from './ShimloaderInst import { LovelyInstaller, LovelyPluginInstaller } from './LovelyInstaller'; import { NorthstarInstaller } from './NorthstarInstaller'; import { ReturnOfModdingInstaller, ReturnOfModdingPluginInstaller } from './ReturnOfModdingInstaller'; - +import { GDWeaveInstaller, GDWeavePluginInstaller } from './GDWeaveInstaller'; const _PackageInstallers = { // "legacy": new InstallRuleInstaller(), // TODO: Enable @@ -20,6 +20,8 @@ const _PackageInstallers = { "lovely-plugin": new LovelyPluginInstaller(), "returnofmodding": new ReturnOfModdingInstaller(), "returnofmodding-plugin": new ReturnOfModdingPluginInstaller(), + "gdweave": new GDWeaveInstaller(), + "gdweave-plugin": new GDWeavePluginInstaller(), } export type PackageInstallerId = keyof typeof _PackageInstallers; diff --git a/src/model/enums/DependencyListDisplayType.ts b/src/model/enums/DependencyListDisplayType.ts deleted file mode 100644 index 9f4be9dae..000000000 --- a/src/model/enums/DependencyListDisplayType.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default { - DISABLE: 'disable', - UNINSTALL: 'uninstall', - VIEW: 'view', -}; \ No newline at end of file diff --git a/src/model/game/GameManager.ts b/src/model/game/GameManager.ts index e6c4c4106..d1c8beecb 100644 --- a/src/model/game/GameManager.ts +++ b/src/model/game/GameManager.ts @@ -812,6 +812,18 @@ export default class GameManager { "https://thunderstore.io/c/subterranauts/api/v1/package-listing-index/", EXCLUSIONS, [new StorePlatformMetadata(StorePlatform.STEAM, "3075800")], "Subterranauts.png", GameSelectionDisplayMode.VISIBLE, GameInstanceType.GAME, PackageLoader.BEPINEX, [""]), + + new Game("SULFUR", "SULFUR", "SULFUR", + "Sulfur", ["Sulfur.exe"], "Sulfur_Data", + "https://thunderstore.io/c/sulfur/api/v1/package-listing-index/", EXCLUSIONS, + [new StorePlatformMetadata(StorePlatform.STEAM, "2124120")], "SULFUR.png", + GameSelectionDisplayMode.VISIBLE, GameInstanceType.GAME, PackageLoader.BEPINEX, [""]), + + new Game("WEBFISHING", "WEBFISHING", "WEBFISHING", + "WEBFISHING", ["webfishing.exe"], "", + "https://thunderstore.io/c/webfishing/api/v1/package-listing-index/", EXCLUSIONS, + [new StorePlatformMetadata(StorePlatform.STEAM, "3146520")], "WEBFISHING.png", + GameSelectionDisplayMode.VISIBLE, GameInstanceType.GAME, PackageLoader.GDWEAVE, [""]), ]; static get activeGame(): Game { diff --git a/src/model/installing/PackageLoader.ts b/src/model/installing/PackageLoader.ts index 0d90c3b75..f984f3d5f 100644 --- a/src/model/installing/PackageLoader.ts +++ b/src/model/installing/PackageLoader.ts @@ -9,6 +9,7 @@ export enum PackageLoader { SHIMLOADER, LOVELY, RETURN_OF_MODDING, + GDWEAVE, } export function GetInstallerIdForLoader(loader: PackageLoader): PackageInstallerId | null { @@ -22,6 +23,7 @@ export function GetInstallerIdForLoader(loader: PackageLoader): PackageInstaller case PackageLoader.SHIMLOADER: return "shimloader"; case PackageLoader.LOVELY: return "lovely"; case PackageLoader.RETURN_OF_MODDING: return "returnofmodding"; + case PackageLoader.GDWEAVE: return "gdweave"; case PackageLoader.ANCIENT_DUNGEON_VR: return null; } } @@ -31,6 +33,7 @@ export function GetInstallerIdForPlugin(loader: PackageLoader): PackageInstaller case PackageLoader.SHIMLOADER: return "shimloader-plugin"; case PackageLoader.LOVELY: return "lovely-plugin"; case PackageLoader.RETURN_OF_MODDING: return "returnofmodding-plugin"; + case PackageLoader.GDWEAVE: return "gdweave-plugin"; } return null; diff --git a/src/pages/GameSelectionScreen.vue b/src/pages/GameSelectionScreen.vue index 2fb4bd95e..7263af503 100644 --- a/src/pages/GameSelectionScreen.vue +++ b/src/pages/GameSelectionScreen.vue @@ -164,7 +164,7 @@ import { Component, Vue } from 'vue-property-decorator'; import Game from '../model/game/Game'; import GameManager from '../model/game/GameManager'; -import Hero from '../components/Hero.vue'; +import Hero from '../components/v2/Hero.vue'; import * as ManagerUtils from '../utils/ManagerUtils'; import ManagerSettings from '../r2mm/manager/ManagerSettings'; import { StorePlatform } from '../model/game/StorePlatform'; diff --git a/src/pages/Help.vue b/src/pages/Help.vue index c53cc10b1..74cef80cc 100644 --- a/src/pages/Help.vue +++ b/src/pages/Help.vue @@ -71,7 +71,7 @@

That's because you don't legally own the game. The manager only supports legal copies.


A text window appears and closes immediately.

-

Try running the preloader fix on the Settings screen.

+

Try running "Reset {{$store.state.activeGame.displayName}} installation" on the Settings screen.

If it persists, force exit Steam and start modded with Steam closed.

diff --git a/src/pages/Manager.vue b/src/pages/Manager.vue index 8f4366499..459999cd7 100644 --- a/src/pages/Manager.vue +++ b/src/pages/Manager.vue @@ -35,25 +35,29 @@
- + @@ -121,7 +125,7 @@

@@ -144,13 +148,12 @@ import { Hero, Link, Modal, Progress } from '../components/all'; import ThunderstoreCombo from '../model/ThunderstoreCombo'; import ProfileModList from '../r2mm/mods/ProfileModList'; import PathResolver from '../r2mm/manager/PathResolver'; -import PreloaderFixer from '../r2mm/manager/PreloaderFixer'; +import { SteamInstallationValidator} from '../r2mm/manager/SteamInstallationValidator'; import { LogSeverity } from '../providers/ror2/logging/LoggerProvider'; import Profile from '../model/Profile'; import VersionNumber from '../model/VersionNumber'; -import DependencyListDisplayType from '../model/enums/DependencyListDisplayType'; import R2Error from '../model/errors/R2Error'; import ManifestV2 from '../model/ManifestV2'; import ManagerSettings from '../r2mm/manager/ManagerSettings'; @@ -187,10 +190,9 @@ import ModalCard from '../components/ModalCard.vue'; } }) export default class Manager extends Vue { - dependencyListDisplayType: string = DependencyListDisplayType.DISABLE; portableUpdateAvailable: boolean = false; updateTagName: string = ''; - fixingPreloader: boolean = false; + isValidatingSteamInstallation: boolean = false; exportCode: string = ''; showSteamIncorrectDirectoryModal: boolean = false; showRor2IncorrectDirectoryModal: boolean = false; @@ -217,16 +219,16 @@ import ModalCard from '../components/ModalCard.vue'; return this.$store.state.profile.modList; } - closePreloaderFixModal() { - this.fixingPreloader = false; + closeSteamInstallationValidationModal() { + this.isValidatingSteamInstallation = false; } - async fixPreloader() { - const res = await PreloaderFixer.fix(this.activeGame); + async validateSteamInstallation() { + const res = await SteamInstallationValidator.validateInstallation(this.activeGame); if (res instanceof R2Error) { this.$store.commit('error/handleError', res); } else { - this.fixingPreloader = true; + this.isValidatingSteamInstallation = true; } } @@ -490,9 +492,12 @@ import ModalCard from '../components/ModalCard.vue'; case PackageLoader.MELON_LOADER: logOutputPath = path.join(this.profile.getProfilePath(), "MelonLoader", "Latest.log"); break; - case PackageLoader.RETURN_OF_MODDING: + case PackageLoader.RETURN_OF_MODDING: logOutputPath = path.join(this.profile.getProfilePath(), "ReturnOfModding", "LogOutput.log"); break; + case PackageLoader.GDWEAVE: + logOutputPath = path.join(this.profile.getProfilePath(), "GDWeave", "GDWeave.log"); + break; } const text = (await fs.readFile(logOutputPath)).toString(); if (text.length >= 1992) { @@ -543,8 +548,8 @@ import ModalCard from '../components/ModalCard.vue'; case "ToggleDownloadCache": this.toggleIgnoreCache(); break; - case "RunPreloaderFix": - this.fixPreloader(); + case "ValidateSteamInstallation": + this.validateSteamInstallation(); break; case "SetLaunchParameters": this.showLaunchParameters(); diff --git a/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts b/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts index f85900f39..5ecfc5483 100644 --- a/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts +++ b/src/providers/generic/game/platform_interceptor/PlatformInterceptorImpl.ts @@ -66,6 +66,7 @@ function buildRunners(runners: PlatformRunnersType): LoaderRunnersType { [PackageLoader.SHIMLOADER]: runners, [PackageLoader.LOVELY]: runners, [PackageLoader.RETURN_OF_MODDING]: runners, + [PackageLoader.GDWEAVE]: runners, } } diff --git a/src/providers/generic/zip/AdmZipProvider.ts b/src/providers/generic/zip/AdmZipProvider.ts index d2bb96139..d12d14f99 100644 --- a/src/providers/generic/zip/AdmZipProvider.ts +++ b/src/providers/generic/zip/AdmZipProvider.ts @@ -1,18 +1,15 @@ import ZipProvider from './ZipProvider'; -import AdmZip, { IZipEntry } from 'adm-zip'; +import AdmZip from 'adm-zip'; import * as path from 'path'; import ZipBuilder from './ZipBuilder'; import ZipEntryInterface from './ZipEntryInterface'; -import FileUtils from '../../../utils/FileUtils'; -import FsProvider from '../file/FsProvider'; export default class AdmZipProvider extends ZipProvider { async extractAllTo(zip: string | Buffer, outputFolder: string): Promise { const adm = new AdmZip(zip); - for (let entry of adm.getEntries()) { - await this.sanitizedExtraction(entry, outputFolder); - } + outputFolder = outputFolder.replace(/\\/g, '/'); + adm.extractAllTo(outputFolder, true); } async readFile(zip: string | Buffer, file: string): Promise { @@ -27,16 +24,14 @@ export default class AdmZipProvider extends ZipProvider { async extractEntryTo(zip: string | Buffer, target: string, outputPath: string): Promise { const adm = new AdmZip(zip); - return this.sanitizedExtraction(adm.getEntry(target)!, outputPath); - } - - private async sanitizedExtraction(entry: IZipEntry, outputPath: string): Promise { - const sanitizedTargetName = entry.entryName.split('\\').join('/'); - await FileUtils.ensureDirectory(path.dirname(path.join(outputPath, sanitizedTargetName))); - if (entry.isDirectory) - await FileUtils.ensureDirectory(path.join(outputPath, sanitizedTargetName)); - else - await FsProvider.instance.writeFile(path.join(outputPath, sanitizedTargetName), entry.getData()); + target = target.replace(/\\/g, '/'); + outputPath = outputPath.replace(/\\/g, '/'); + var fullPath = path.join(outputPath, target).replace(/\\/g, '/'); + if(!path.posix.normalize(fullPath).startsWith(outputPath)) + { + throw Error("Entry " + target + " would extract outside of expected folder"); + } + adm.extractEntryTo(target, outputPath, true, true); } zipBuilder(): ZipBuilder { diff --git a/src/r2mm/data/LogOutput.ts b/src/r2mm/data/LogOutput.ts index 475d28b31..6f831b323 100644 --- a/src/r2mm/data/LogOutput.ts +++ b/src/r2mm/data/LogOutput.ts @@ -46,6 +46,10 @@ export default class LogOutput { fs.exists(Profile.getActiveProfile().joinToProfilePath('MelonLoader', 'Latest.log')) .then(value => this._exists = value); break; + case PackageLoader.GDWEAVE: + fs.exists(Profile.getActiveProfile().joinToProfilePath('GDWeave', 'GDWeave.log')) + .then(value => this._exists = value); + break; } } diff --git a/src/r2mm/installing/ZipExtract.ts b/src/r2mm/installing/ZipExtract.ts index e595e2d00..cfb735ec4 100644 --- a/src/r2mm/installing/ZipExtract.ts +++ b/src/r2mm/installing/ZipExtract.ts @@ -36,16 +36,20 @@ export default class ZipExtract { } } } catch (e) { + // Cleanup might also fail e.g. for too long file paths on TSMM. + // Show the original error instead of the one caused by the cleanup, + // as the former is probably more informative for debugging. + if (err) { + callback(false, FileWriteError.fromThrownValue(err)); + return; + } + callback(result, new FileWriteError( 'Failed to extract zip', (e as Error).message, 'Try to re-download the mod. If the issue persists, ask for help in the Thunderstore modding discord.' )); } - // TODO: Is this needed? - // finally { - // callback(result); - // } } }); } diff --git a/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts b/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts index b05dd4c47..4e1bbdf78 100644 --- a/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts +++ b/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts @@ -152,6 +152,7 @@ export default class InstallationRuleApplicator { buildBepInExRules("TCGCardShopSimulator"), buildBepInExRules("OldMarketSimulator"), buildBepInExRules("Subterranauts"), + buildBepInExRules("SULFUR"), ] } } diff --git a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts index 518629288..35936215f 100644 --- a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts +++ b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts @@ -118,10 +118,32 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { } async disableMod(mod: ManifestV2, profile: ImmutableProfile): Promise { + // Support for installer specific disable methods are rolled out + // gradually and therefore might not be defined yet. Disabling + // mod loader packages are intentionally not supported. + try { + if (await this.disableModWithInstaller(mod, profile)) { + return; + } + } catch (e) { + return R2Error.fromThrownValue(e); + } + return this.applyModMode(mod, profile, ModMode.DISABLED); } async enableMod(mod: ManifestV2, profile: ImmutableProfile): Promise { + // Support for installer specific enable methods are rolled out + // gradually and therefore might not be defined yet. Enabling + // mod loader packages are intentionally not supported. + try { + if (await this.enableModWithInstaller(mod, profile)) { + return; + } + } catch (e) { + return R2Error.fromThrownValue(e); + } + return this.applyModMode(mod, profile, ModMode.ENABLED); } @@ -149,8 +171,12 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { const pluginInstaller = GetInstallerIdForPlugin(GameManager.activeGame.packageLoader); if (pluginInstaller !== null) { - await PackageInstallers[pluginInstaller].install(args); - return Promise.resolve(null); + try { + await PackageInstallers[pluginInstaller].install(args); + return Promise.resolve(null); + } catch (e) { + return Promise.resolve(R2Error.fromThrownValue(e)); + } } // Revert to legacy install behavior. @@ -308,7 +334,7 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { * implements a custom uninstallation method. * @return true if mod loader was uninstalled */ - async uninstallModLoaderWithInstaller(mod: ManifestV2, profile: ImmutableProfile): Promise { + private async uninstallModLoaderWithInstaller(mod: ManifestV2, profile: ImmutableProfile): Promise { const modLoader = this.getModLoader(mod); const installerId = modLoader ? GetInstallerIdForLoader(modLoader.loaderType) : null; return this.uninstallWithInstaller(installerId, mod, profile); @@ -319,7 +345,7 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { * uninstallation method. * @return true if mod was uninstalled */ - async uninstallModWithInstaller(mod: ManifestV2, profile: ImmutableProfile): Promise { + private async uninstallModWithInstaller(mod: ManifestV2, profile: ImmutableProfile): Promise { const installerId = GetInstallerIdForPlugin(GameManager.activeGame.packageLoader); return this.uninstallWithInstaller(installerId, mod, profile); } @@ -339,4 +365,52 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { return false; } + + /** + * Enable mod if its registered installer implements a custom + * enable method. + * @return true if mod was enable + */ + private async enableModWithInstaller(mod: ManifestV2, profile: ImmutableProfile): Promise { + const modLoader = this.getModLoader(mod); + + if (modLoader) { + return false; // Don't process mod loader with plugin installer. + } + + const installerId = GetInstallerIdForPlugin(GameManager.activeGame.packageLoader); + const installer = installerId ? PackageInstallers[installerId] : undefined; + + if (installer && installer.enable) { + const args = this.getInstallArgs(mod, profile); + await installer.enable(args); + return true; + } + + return false; + } + + /** + * Disable mod if its registered installer implements a custom + * disable method. + * @return true if mod was disabled + */ + private async disableModWithInstaller(mod: ManifestV2, profile: ImmutableProfile): Promise { + const modLoader = this.getModLoader(mod); + + if (modLoader) { + return false; // Don't process mod loader with plugin installer. + } + + const installerId = GetInstallerIdForPlugin(GameManager.activeGame.packageLoader); + const installer = installerId ? PackageInstallers[installerId] : undefined; + + if (installer && installer.disable) { + const args = this.getInstallArgs(mod, profile); + await installer.disable(args); + return true; + } + + return false; + } } diff --git a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts index 31e4bd606..71c87a178 100644 --- a/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts +++ b/src/r2mm/installing/profile_installers/ModLoaderVariantRecord.ts @@ -70,6 +70,7 @@ export const MODLOADER_PACKAGES = [ new ModLoaderPackageMapping("Thunderstore-lovely", "", PackageLoader.LOVELY), new ModLoaderPackageMapping("ReturnOfModding-ReturnOfModding", "ReturnOfModdingPack", PackageLoader.RETURN_OF_MODDING), new ModLoaderPackageMapping("Hell2Modding-Hell2Modding", "ReturnOfModdingPack", PackageLoader.RETURN_OF_MODDING), + new ModLoaderPackageMapping("NotNet-GDWeave", "", PackageLoader.GDWEAVE), ]; @@ -123,7 +124,7 @@ const VARIANTS = { Aloft: MODLOADER_PACKAGES, COTL: MODLOADER_PACKAGES, ChronoArk: MODLOADER_PACKAGES, - BONELAB: [new ModLoaderPackageMapping("LavaGang-MelonLoader", "", PackageLoader.MELON_LOADER, new VersionNumber("0.5.7"))], + BONELAB: [new ModLoaderPackageMapping("LavaGang-MelonLoader", "", PackageLoader.MELON_LOADER)], TromboneChamp: MODLOADER_PACKAGES, RogueGenesia: MODLOADER_PACKAGES, AcrossTheObelisk: MODLOADER_PACKAGES, @@ -203,9 +204,22 @@ const VARIANTS = { TCGCardShopSimulator: MODLOADER_PACKAGES, OldMarketSimulator: MODLOADER_PACKAGES, Subterranauts: MODLOADER_PACKAGES, + SULFUR: MODLOADER_PACKAGES, + WEBFISHING: MODLOADER_PACKAGES, }; // Exported separately from the definition in order to preserve the key names in the type definition. // Otherwise this would become [key: string] and we couldn't use the game names for type hinting elsewhere. // Casting is done here to ensure the values are ModLoaderPackageMapping[] export type GAME_NAME = keyof typeof VARIANTS; export const MOD_LOADER_VARIANTS: {[key in GAME_NAME]: ModLoaderPackageMapping[]} = VARIANTS; + +export function getModLoaderPackageNames() { + const names = MODLOADER_PACKAGES.map((mapping) => mapping.packageName); + + // Hard code MelonLoader to avoid having to iterate over MODLOADER_PACKAGES + // for each game separately. Hopefully we'll get rid of this once ML v0.6.6 + // is released, as it's supposed to fix a bug that forces some games to + // currently use the older versions. + names.push("LavaGang-MelonLoader"); + return names; +} diff --git a/src/r2mm/launching/instructions/DynamicGameInstruction.ts b/src/r2mm/launching/instructions/DynamicGameInstruction.ts index 93d79f8f5..25d35a871 100644 --- a/src/r2mm/launching/instructions/DynamicGameInstruction.ts +++ b/src/r2mm/launching/instructions/DynamicGameInstruction.ts @@ -4,4 +4,5 @@ export enum DynamicGameInstruction { BEPINEX_CORLIBS = "@bepInExCorlibs", PROFILE_NAME = "@profileName", NORTHSTAR_DIRECTORY = "@northstarDirectory", + GDWEAVE_FOLDER = "@gdweaveFolder" } diff --git a/src/r2mm/launching/instructions/GameInstructionParser.ts b/src/r2mm/launching/instructions/GameInstructionParser.ts index fc72ba2b3..60962eadf 100644 --- a/src/r2mm/launching/instructions/GameInstructionParser.ts +++ b/src/r2mm/launching/instructions/GameInstructionParser.ts @@ -15,7 +15,8 @@ export default class GameInstructionParser { [DynamicGameInstruction.PROFILE_DIRECTORY, GameInstructionParser.profileDirectoryResolver], [DynamicGameInstruction.BEPINEX_CORLIBS, GameInstructionParser.bepInExCorelibsPathResolver], [DynamicGameInstruction.PROFILE_NAME, GameInstructionParser.profileNameResolver], - [DynamicGameInstruction.NORTHSTAR_DIRECTORY, GameInstructionParser.northstarDirectoryResolver] + [DynamicGameInstruction.NORTHSTAR_DIRECTORY, GameInstructionParser.northstarDirectoryResolver], + [DynamicGameInstruction.GDWEAVE_FOLDER, GameInstructionParser.gdweaveFolderResolver] ]); public static async parse(launchString: string, game: Game, profile: Profile): Promise { @@ -75,4 +76,7 @@ export default class GameInstructionParser { return profile.joinToProfilePath("R2Northstar"); } + private static async gdweaveFolderResolver(game: Game, profile: Profile): Promise { + return profile.joinToProfilePath("GDWeave"); + } } diff --git a/src/r2mm/launching/instructions/GameInstructions.ts b/src/r2mm/launching/instructions/GameInstructions.ts index 43b7ae233..db3561b1d 100644 --- a/src/r2mm/launching/instructions/GameInstructions.ts +++ b/src/r2mm/launching/instructions/GameInstructions.ts @@ -10,6 +10,7 @@ import { AncientVRGameInstructions } from "../../launching/instructions/instruct import ShimloaderGameInstructions from './instructions/loader/ShimloaderGameInstructions'; import LovelyGameInstructions from './instructions/loader/LovelyGameInstructions'; import ReturnOfModdingGameInstructions from './instructions/loader/ReturnOfModdingGameInstructions'; +import GDWeaveGameInstructions from './instructions/loader/GDWeaveGameInstructions'; export interface GameInstruction { moddedParameters: string, @@ -28,6 +29,7 @@ export default class GameInstructions { [PackageLoader.SHIMLOADER, new ShimloaderGameInstructions()], [PackageLoader.LOVELY, new LovelyGameInstructions()], [PackageLoader.RETURN_OF_MODDING, new ReturnOfModdingGameInstructions()], + [PackageLoader.GDWEAVE, new GDWeaveGameInstructions()] ]); public static async getInstructionsForGame(game: Game, profile: Profile): Promise { diff --git a/src/r2mm/launching/instructions/instructions/loader/GDWeaveGameInstructions.ts b/src/r2mm/launching/instructions/instructions/loader/GDWeaveGameInstructions.ts new file mode 100644 index 000000000..911ed214a --- /dev/null +++ b/src/r2mm/launching/instructions/instructions/loader/GDWeaveGameInstructions.ts @@ -0,0 +1,14 @@ +import GameInstructionGenerator from '../GameInstructionGenerator'; +import { GameInstruction } from '../../GameInstructions'; +import Game from '../../../../../model/game/Game'; +import Profile from '../../../../../model/Profile'; +import { DynamicGameInstruction } from '../../DynamicGameInstruction'; + +export default class GDWeaveGameInstructions extends GameInstructionGenerator { + public async generate(game: Game, profile: Profile): Promise { + return { + moddedParameters: `--gdweave-folder-override="${DynamicGameInstruction.GDWEAVE_FOLDER}"`, + vanillaParameters: "--gdweave-disable" + }; + } +} diff --git a/src/r2mm/launching/instructions/instructions/loader/MelonLoaderGameInstructions.ts b/src/r2mm/launching/instructions/instructions/loader/MelonLoaderGameInstructions.ts index 3b8914813..33196c343 100644 --- a/src/r2mm/launching/instructions/instructions/loader/MelonLoaderGameInstructions.ts +++ b/src/r2mm/launching/instructions/instructions/loader/MelonLoaderGameInstructions.ts @@ -9,9 +9,12 @@ export default class MelonLoaderGameInstructions extends GameInstructionGenerato public async generate(game: Game, profile: Profile): Promise { let moddedParameters = `--melonloader.basedir "${DynamicGameInstruction.PROFILE_DIRECTORY}"`; - if (!await FsProvider.instance.exists(profile.joinToProfilePath('MelonLoader', 'Managed', 'Assembly-CSharp.dll'))) { - console.log("Regenerating AGF") - moddedParameters += " --melonloader.agfregenerate" + + const mlZeroPointFiveAssemblyExists = await FsProvider.instance.exists(profile.joinToProfilePath('MelonLoader', 'Managed', 'Assembly-CSharp.dll')); + const mlZeroPointSixAssemblyExists = await FsProvider.instance.exists(profile.joinToProfilePath('MelonLoader', 'Il2CppAssemblies', 'Assembly-CSharp.dll')); + + if (!mlZeroPointFiveAssemblyExists && !mlZeroPointSixAssemblyExists) { + moddedParameters += ' --melonloader.agfregenerate'; } return { moddedParameters: moddedParameters, diff --git a/src/r2mm/launching/runners/linux/SteamGameRunner_Linux.ts b/src/r2mm/launching/runners/linux/SteamGameRunner_Linux.ts index 08e273a0d..81cba2839 100644 --- a/src/r2mm/launching/runners/linux/SteamGameRunner_Linux.ts +++ b/src/r2mm/launching/runners/linux/SteamGameRunner_Linux.ts @@ -11,6 +11,7 @@ import LoggerProvider, { LogSeverity } from '../../../../providers/ror2/logging/ import { exec } from 'child_process'; import GameInstructions from '../../instructions/GameInstructions'; import GameInstructionParser from '../../instructions/GameInstructionParser'; +import { PackageLoader } from '../../../../model/installing/PackageLoader'; export default class SteamGameRunner_Linux extends GameRunnerProvider { @@ -27,7 +28,9 @@ export default class SteamGameRunner_Linux extends GameRunnerProvider { } if (isProton) { - const promise = await this.ensureWineWillLoadBepInEx(game); + // BepInEx uses winhttp, GDWeave uses winmm. More can be added later. + const proxyDll = game.packageLoader == PackageLoader.GDWEAVE ? "winmm" : "winhttp"; + const promise = await this.ensureWineWillLoadDllOverride(game, proxyDll); if (promise instanceof R2Error) { return promise; } @@ -80,7 +83,7 @@ export default class SteamGameRunner_Linux extends GameRunnerProvider { } - private async ensureWineWillLoadBepInEx(game: Game): Promise{ + private async ensureWineWillLoadDllOverride(game: Game, proxyDll: string): Promise{ const fs = FsProvider.instance; const compatDataDir = await (GameDirectoryResolverProvider.instance as LinuxGameDirectoryResolver).getCompatDataDirectory(game); if(compatDataDir instanceof R2Error) @@ -90,7 +93,7 @@ export default class SteamGameRunner_Linux extends GameRunnerProvider { const ensuredUserRegData = this.regAddInSection( userRegData, "[Software\\\\Wine\\\\DllOverrides]", - "winhttp", + proxyDll, "native,builtin" ); diff --git a/src/r2mm/manager/ModLinker.ts b/src/r2mm/manager/ModLinker.ts index 2f276e21d..cd0b45aca 100644 --- a/src/r2mm/manager/ModLinker.ts +++ b/src/r2mm/manager/ModLinker.ts @@ -128,7 +128,7 @@ export default class ModLinker { "bepinex", "bepinex_server", "mods", "melonloader", "plugins", "userdata", "_state", "userlibs", "qmods", "shimloader", - "returnofmodding" + "returnofmodding", "gdweave" ]; if (!exclusionsList.includes(file.toLowerCase())) { diff --git a/src/r2mm/manager/PreloaderFixer.ts b/src/r2mm/manager/PreloaderFixer.ts deleted file mode 100644 index 1250f1cba..000000000 --- a/src/r2mm/manager/PreloaderFixer.ts +++ /dev/null @@ -1,53 +0,0 @@ -import GameDirectoryResolverProvider from '../../providers/ror2/game/GameDirectoryResolverProvider'; -import R2Error from '../../model/errors/R2Error'; -import * as path from 'path'; -import FsProvider from '../../providers/generic/file/FsProvider'; -import ManagerInformation from '../../_managerinf/ManagerInformation'; -import LinkProvider from '../../providers/components/LinkProvider'; -import FileUtils from '../../utils/FileUtils'; -import Game from '../../model/game/Game'; -import { StorePlatform } from '../../model/game/StorePlatform'; - -export default class PreloaderFixer { - - public static async fix(game: Game): Promise { - if (![StorePlatform.STEAM, StorePlatform.STEAM_DIRECT].includes(game.activePlatform.storePlatform)) { - return new R2Error( - "PreloaderFix is not available on non-Steam platforms.", - `The preloader fix deletes the ${path.join(game.dataFolderName, 'Managed')} folder and verifies files. You can do the same manually.`, - null - ); - } - const fs = FsProvider.instance; - const dirResult = await GameDirectoryResolverProvider.instance.getDirectory(game); - if (dirResult instanceof R2Error) - return dirResult; - - let exeFound = false; - for(let exeName of game.exeName) { - if (await fs.exists(path.join(dirResult, exeName))) { - exeFound = true; - break; - } - } - - if(!exeFound) { - return new R2Error(`${game.displayName} folder is invalid`, `could not find either of "${game.exeName.join('", "')}"`, - `Set the ${game.displayName} folder in the settings section`); - } - - try { - await FileUtils.emptyDirectory(path.join(dirResult, game.dataFolderName, 'Managed')); - await fs.rmdir(path.join(dirResult, game.dataFolderName, 'Managed')); - } catch(e) { - const err: Error = e as Error; - return new R2Error('Failed to remove Managed folder', err.message, `Try launching ${ManagerInformation.APP_NAME} as an administrator`); - } - try { - LinkProvider.instance.openLink(`steam://validate/${game.activePlatform.storeIdentifier}`); - } catch(e) { - const err: Error = e as Error; - return new R2Error('Failed to start steam://validate', err.message, null); - } - } -} diff --git a/src/r2mm/manager/SteamInstallationValidator.ts b/src/r2mm/manager/SteamInstallationValidator.ts new file mode 100644 index 000000000..055c0a342 --- /dev/null +++ b/src/r2mm/manager/SteamInstallationValidator.ts @@ -0,0 +1,64 @@ +import GameDirectoryResolverProvider from '../../providers/ror2/game/GameDirectoryResolverProvider'; +import R2Error from '../../model/errors/R2Error'; +import * as path from 'path'; +import FsProvider from '../../providers/generic/file/FsProvider'; +import ManagerInformation from '../../_managerinf/ManagerInformation'; +import LinkProvider from '../../providers/components/LinkProvider'; +import FileUtils from '../../utils/FileUtils'; +import Game from '../../model/game/Game'; +import { StorePlatform } from '../../model/game/StorePlatform'; + + +export class SteamInstallationValidator { + + public static async validateInstallation(game: Game): Promise { + if (![StorePlatform.STEAM, StorePlatform.STEAM_DIRECT].includes(game.activePlatform.storePlatform)) { + return new R2Error( + "This feature is not available on non-Steam platforms.", + "The feature deletes the contents of the game folder and verifies files. You can do the same manually." + ); + } + + const gameFolder = await GameDirectoryResolverProvider.instance.getDirectory(game); + if (gameFolder instanceof R2Error) { + return gameFolder; + } + + // Sanity check we're about to delete something resembling a valid game folder. + let exeFound = false; + for (let exeName of game.exeName) { + if (await FsProvider.instance.exists(path.join(gameFolder, exeName))) { + exeFound = true; + break; + } + } + + if (!exeFound) { + const message = game.exeName.length > 1 ? + `Could not find any of "${game.exeName.join('", "')}"` : + `Could not find "${game.exeName[0]}"`; + + return new R2Error( + `${game.displayName} folder is invalid`, + message, + `Set the ${game.displayName} folder in the settings section` + ); + } + + try { + await FileUtils.emptyDirectory(gameFolder); + } catch(e) { + return R2Error.fromThrownValue( + e, + 'Failed to empty the game folder', + `Try launching ${ManagerInformation.APP_NAME} as an administrator` + ); + } + + try { + LinkProvider.instance.openLink(`steam://validate/${game.activePlatform.storeIdentifier}`); + } catch(e) { + return R2Error.fromThrownValue(e, 'Failed to start steam://validate'); + } + } +} diff --git a/src/r2mm/mods/ProfileModList.ts b/src/r2mm/mods/ProfileModList.ts index 9e9a6ee51..517797017 100644 --- a/src/r2mm/mods/ProfileModList.ts +++ b/src/r2mm/mods/ProfileModList.ts @@ -191,6 +191,11 @@ export default class ProfileModList { }, "plugins"); }, "BepInEx"); tree.removeDirectories("MelonLoader"); + tree.navigateAndPerform(gdWeaveDir => { + gdWeaveDir.removeDirectories("core"); + gdWeaveDir.removeDirectories("mods"); + gdWeaveDir.removeFiles("GDWeave.log"); + }, "GDWeave"); // Add all tree contents to buffer. for (const file of tree.getRecursiveFiles()) { const fileLower = file.toLowerCase(); diff --git a/src/store/index.ts b/src/store/index.ts index f3c69dd19..adb94f05e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -11,6 +11,7 @@ import { FolderMigration } from '../migrations/FolderMigration'; import Game from '../model/game/Game'; import GameManager from '../model/game/GameManager'; import R2Error from '../model/errors/R2Error'; +import { getModLoaderPackageNames } from '../r2mm/installing/profile_installers/ModLoaderVariantRecord'; import ManagerSettings from '../r2mm/manager/ManagerSettings'; Vue.use(Vuex); @@ -18,6 +19,7 @@ Vue.use(Vuex); export interface State { activeGame: Game; isMigrationChecked: boolean; + modLoaderPackageNames: string[]; _settings: ManagerSettings | null; } @@ -32,6 +34,7 @@ export const store = { state: { activeGame: GameManager.defaultGame, isMigrationChecked: false, + modLoaderPackageNames: [], // Access through getters to ensure the settings are loaded. _settings: null, @@ -87,9 +90,19 @@ export const store = { }, setSettings(state: State, settings: ManagerSettings) { state._settings = settings; + }, + updateModLoaderPackageNames(state: State) { + // The list is static and doesn't change during runtime. + if (!state.modLoaderPackageNames.length) { + state.modLoaderPackageNames = getModLoaderPackageNames(); + } } }, getters: { + isModLoader: (state: State) => (packageName: string): boolean => { + return state.modLoaderPackageNames.includes(packageName); + }, + settings(state: State): ManagerSettings { if (state._settings === null) { throw new R2Error( diff --git a/src/utils/ProfileUtils.ts b/src/utils/ProfileUtils.ts index e76d5e38a..7700227c1 100644 --- a/src/utils/ProfileUtils.ts +++ b/src/utils/ProfileUtils.ts @@ -125,6 +125,27 @@ export async function installModsToProfile( export async function parseYamlToExportFormat(yamlContent: string) { const parsedYaml = await yaml.parse(yamlContent); + if (!parsedYaml) { + throw new R2Error( + 'Failed to parse yaml contents.', + 'Yaml parsing failed when trying to import profile via file (The contents of export.r2x file are invalid).', + 'Ensure that the profile import file isn\'t corrupted.' + ) + } + if (typeof parsedYaml.profileName !== 'string') { + throw new R2Error( + 'Failed to read profile name.', + 'Reading the profile name after parsing the yaml failed (export.r2x is missing the profileName field).', + 'Ensure that the profile import file isn\'t corrupted.' + ) + } + if (!Array.isArray(parsedYaml.mods)) { + throw new R2Error( + 'Failed to read mod list.', + 'Reading mods list after parsing the yaml failed (Mod list of export.r2x is invalid).', + 'Ensure that the profile import file isn\'t corrupted.' + ) + } return new ExportFormat( parsedYaml.profileName, parsedYaml.mods.map((mod: any) => { @@ -180,17 +201,21 @@ export async function populateImportedProfile( } } -//TODO: Check if instead of returning null/empty strings, there's some errors that should be handled -export async function readProfileFile(file: string) { - let read = ''; +export async function readProfileFile(file: string): Promise { + let read: string | null | undefined; if (file.endsWith('.r2x')) { read = (await FsProvider.instance.readFile(file)).toString(); } else if (file.endsWith('.r2z')) { - const result: Buffer | null = await ZipProvider.instance.readFile(file, "export.r2x"); - if (result === null) { - return null; - } - read = result.toString(); + await ZipProvider.instance.readFile(file, "export.r2x") + .then((value) => { read = value ? value.toString() : null; }) + .catch(() => { read = null }); + } + + if (!read) { + throw new R2Error( + 'Error when reading file contents', + 'Reading the .r2x file contents failed. The contents might be empty or corrupted.', + ); } return read; } diff --git a/yarn.lock b/yarn.lock index 7594bdbac..8999f52b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13910,7 +13910,7 @@ vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0: resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== -vue@^2.7.1: +vue@2.7.16, vue@^2.7.1: version "2.7.16" resolved "https://registry.yarnpkg.com/vue/-/vue-2.7.16.tgz#98c60de9def99c0e3da8dae59b304ead43b967c9" integrity sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==