From 8f21386562e9fcf45ff149ee8ab48467a0eacb76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Tue, 11 Jun 2024 16:52:16 +0300 Subject: [PATCH 01/28] Extract reusable code from Shimloader tests to helper functions --- .../install_logic/Shimloader.Tests.spec.ts | 168 +++++------------- test/jest/__utils__/InstallLogicUtils.ts | 148 +++++++++++++++ 2 files changed, 189 insertions(+), 127 deletions(-) create mode 100644 test/jest/__utils__/InstallLogicUtils.ts diff --git a/test/jest/__tests__/impl/install_logic/Shimloader.Tests.spec.ts b/test/jest/__tests__/impl/install_logic/Shimloader.Tests.spec.ts index a193e2ac5..fc71a75d2 100644 --- a/test/jest/__tests__/impl/install_logic/Shimloader.Tests.spec.ts +++ b/test/jest/__tests__/impl/install_logic/Shimloader.Tests.spec.ts @@ -1,131 +1,45 @@ -import FsProvider from '../../../../../src/providers/generic/file/FsProvider'; -import InMemoryFsProvider from '../../stubs/providers/InMemory.FsProvider'; -import PathResolver from '../../../../../src/r2mm/manager/PathResolver'; -import * as path from 'path'; -import VersionNumber from '../../../../../src/model/VersionNumber'; -import ManifestV2 from '../../../../../src/model/ManifestV2'; +import { + createManifest, + createPackageFilesIntoCache, + expectFilesToBeCopied, + expectFilesToBeRemoved, + installLogicBeforeEach +} from '../../../__utils__/InstallLogicUtils'; +import R2Error from '../../../../../src/model/errors/R2Error'; import Profile from '../../../../../src/model/Profile'; -import ProfileProvider from '../../../../../src/providers/ror2/model_implementation/ProfileProvider'; import ProfileInstallerProvider from '../../../../../src/providers/ror2/installing/ProfileInstallerProvider'; -import GameManager from 'src/model/game/GameManager'; -import GenericProfileInstaller from 'src/r2mm/installing/profile_installers/GenericProfileInstaller'; -import InstallationRuleApplicator from 'src/r2mm/installing/default_installation_rules/InstallationRuleApplicator'; -import ConflictManagementProvider from 'src/providers/generic/installing/ConflictManagementProvider'; -import ConflictManagementProviderImpl from 'src/r2mm/installing/ConflictManagementProviderImpl'; -import R2Error from 'src/model/errors/R2Error'; - -class ProfileProviderImpl extends ProfileProvider { - ensureProfileDirectory(directory: string, profile: string): void { - FsProvider.instance.mkdirs(path.join(directory, profile)); - } -} - -const createManifest = (name: string, author: string, version: VersionNumber): ManifestV2 => { - return new ManifestV2().make({ - ManifestVersion: 2, - AuthorName: author, - Name: `${author}-${name}`, - DisplayName: name, - Version: version.toString(), - }) as ManifestV2; -}; - -describe('Installer Tests', () => { - - describe('Shimloader Package', () => { - - beforeEach(() => { - const inMemoryFs = new InMemoryFsProvider(); - FsProvider.provide(() => inMemoryFs); - InMemoryFsProvider.clear(); - PathResolver.MOD_ROOT = 'MODS'; - inMemoryFs.mkdirs(PathResolver.MOD_ROOT); - ProfileProvider.provide(() => new ProfileProviderImpl()); - new Profile('TestProfile'); - inMemoryFs.mkdirs(Profile.getActiveProfile().getPathOfProfile()); - GameManager.activeGame = GameManager.gameList.find(value => value.internalFolderName === "Palworld")!; - InstallationRuleApplicator.apply(); - ConflictManagementProvider.provide(() => new ConflictManagementProviderImpl()); - InMemoryFsProvider.setMatchMode("CASE_SENSITIVE"); - }); - - test('Shimloader Package', async () => { - const fs = FsProvider.instance; - const pkg = createManifest("test_mod", "auth", new VersionNumber("1.0.0")); - const name = pkg.getName(); - - const sourceToExpectedDestination = { - "README.md": `shimloader/mod/${name}/README.md`, - "manifest.json": `shimloader/mod/${name}/manifest.json`, - "icon.png": `shimloader/mod/${name}/icon.png`, - "pak/blueprint.pak": `shimloader/pak/${name}/blueprint.pak`, - "mod/scripts/main.lua": `shimloader/mod/${name}/scripts/main.lua`, - "mod/scripts/other.lua": `shimloader/mod/${name}/scripts/other.lua`, - "mod/dll/mod.dll": `shimloader/mod/${name}/dll/mod.dll`, - "cfg/package.cfg": `shimloader/cfg/package.cfg`, - }; - const expectedAfterUninstall = [ - "shimloader/cfg/package.cfg", - ]; - - const cachePkgRoot = path.join(PathResolver.MOD_ROOT, "cache", pkg.getName(), pkg.getVersionNumber().toString()); - await fs.mkdirs(cachePkgRoot); - - for (const sourcePath in sourceToExpectedDestination) { - const destPath = path.join(cachePkgRoot, sourcePath); - await fs.mkdirs(path.dirname(destPath)); - await fs.writeFile(destPath, ""); - expect(await fs.exists(destPath)); - } - - ProfileInstallerProvider.provide(() => new GenericProfileInstaller()); - await ProfileInstallerProvider.instance.installMod(pkg, Profile.getActiveProfile()); - const profilePath = Profile.getActiveProfile().getPathOfProfile(); - - // Purely to make debugging easier if & when the test fails - async function getTree(target: string) { - const result: string[] = []; - for (const entry of (await fs.readdir(target))) { - const fullPath = path.join(target, entry); - if ((await fs.stat(fullPath)).isDirectory()) { - for (const subPath of (await getTree(fullPath))) { - result.push(subPath); - } - } else { - result.push(fullPath); - } - } - return result; - } - - for (const destPath of Object.values(sourceToExpectedDestination)) { - const fullPath = path.join(profilePath, destPath); - const result = await fs.exists(fullPath); - if (!result) { - console.log(`Expected ${fullPath} to exist but it did't! All files:`); - console.log(JSON.stringify(await getTree(profilePath), null, 2)); - } - expect(result).toBeTruthy(); - } - - const result = await ProfileInstallerProvider.instance.uninstallMod(pkg, Profile.getActiveProfile()); - expect(result instanceof R2Error).toBeFalsy(); - - for (const destPath of Object.values(sourceToExpectedDestination)) { - const fullPath = path.join(profilePath, destPath); - const doesExist = await fs.exists(fullPath); - const shouldExist = expectedAfterUninstall.includes(destPath); - - if (doesExist && !shouldExist) { - console.log(`Expected ${fullPath} to NOT exist but IT DOES! All files:`); - console.log(JSON.stringify(await getTree(profilePath), null, 2)); - } else if (shouldExist && !doesExist) { - console.log(`Expected ${fullPath} to exist but IT DOESN'T! All files:`); - console.log(JSON.stringify(await getTree(profilePath), null, 2)); - } - - expect(doesExist).toEqual(shouldExist); - } - }); +import GenericProfileInstaller from '../../../../../src/r2mm/installing/profile_installers/GenericProfileInstaller'; + +describe('Shimloader Installer Tests', () => { + + beforeEach( + () => installLogicBeforeEach("Palworld") + ); + + test('Installs and uninstalls a package', async () => { + const pkg = createManifest("test_mod", "auth"); + const name = pkg.getName(); + const sourceToExpectedDestination = { + "README.md": `shimloader/mod/${name}/README.md`, + "manifest.json": `shimloader/mod/${name}/manifest.json`, + "icon.png": `shimloader/mod/${name}/icon.png`, + "pak/blueprint.pak": `shimloader/pak/${name}/blueprint.pak`, + "mod/scripts/main.lua": `shimloader/mod/${name}/scripts/main.lua`, + "mod/scripts/other.lua": `shimloader/mod/${name}/scripts/other.lua`, + "mod/dll/mod.dll": `shimloader/mod/${name}/dll/mod.dll`, + "cfg/package.cfg": `shimloader/cfg/package.cfg`, + }; + const expectedAfterUninstall = [ + "shimloader/cfg/package.cfg", + ]; + await createPackageFilesIntoCache(pkg, Object.keys(sourceToExpectedDestination)); + + ProfileInstallerProvider.provide(() => new GenericProfileInstaller()); + await ProfileInstallerProvider.instance.installMod(pkg, Profile.getActiveProfile()); + await expectFilesToBeCopied(sourceToExpectedDestination); + + const result = await ProfileInstallerProvider.instance.uninstallMod(pkg, Profile.getActiveProfile()); + expect(result instanceof R2Error).toBeFalsy(); + expectFilesToBeRemoved(sourceToExpectedDestination, expectedAfterUninstall) }); }); diff --git a/test/jest/__utils__/InstallLogicUtils.ts b/test/jest/__utils__/InstallLogicUtils.ts new file mode 100644 index 000000000..66e542779 --- /dev/null +++ b/test/jest/__utils__/InstallLogicUtils.ts @@ -0,0 +1,148 @@ +import * as path from 'path'; + +import InMemoryFsProvider from '../__tests__/stubs/providers/InMemory.FsProvider'; +import GameManager from '../../../src/model/game/GameManager'; +import ManifestV2 from '../../../src/model/ManifestV2'; +import Profile from '../../../src/model/Profile'; +import VersionNumber from '../../../src/model/VersionNumber'; +import FsProvider from '../../../src/providers/generic/file/FsProvider'; +import ConflictManagementProvider from '../../../src/providers/generic/installing/ConflictManagementProvider'; +import ProfileInstallerProvider from '../../../src/providers/ror2/installing/ProfileInstallerProvider'; +import ProfileProvider from '../../../src/providers/ror2/model_implementation/ProfileProvider'; +import ConflictManagementProviderImpl from '../../../src/r2mm/installing/ConflictManagementProviderImpl'; +import InstallationRuleApplicator from '../../../src/r2mm/installing/default_installation_rules/InstallationRuleApplicator'; +import GenericProfileInstaller from '../../../src/r2mm/installing/profile_installers/GenericProfileInstaller'; +import PathResolver from '../../../src/r2mm/manager/PathResolver'; + +class ProfileProviderImpl extends ProfileProvider { + ensureProfileDirectory(directory: string, profile: string): void { + FsProvider.instance.mkdirs(path.join(directory, profile)); + } +} + +/** + * Tasks required before each test, including: + * - Setup providers + * - Activate game based on internalFolderName + * - Create a profile + */ +export async function installLogicBeforeEach(internalFolderName: string) { + const inMemoryFs = new InMemoryFsProvider(); + FsProvider.provide(() => inMemoryFs); + InMemoryFsProvider.clear(); + PathResolver.MOD_ROOT = 'MODS'; + inMemoryFs.mkdirs(PathResolver.MOD_ROOT); + + const game = GameManager.findByFolderName(internalFolderName); + if (game === undefined) { + throw new Error(`GameManager has no record of ${internalFolderName}`); + } + GameManager.activeGame = game; + + ProfileProvider.provide(() => new ProfileProviderImpl()); + new Profile('TestProfile'); + inMemoryFs.mkdirs(Profile.getActiveProfile().getPathOfProfile()); + + ProfileInstallerProvider.provide(() => new GenericProfileInstaller()); + InstallationRuleApplicator.apply(); + ConflictManagementProvider.provide(() => new ConflictManagementProviderImpl()); + InMemoryFsProvider.setMatchMode("CASE_SENSITIVE"); +} + +/** + * Return a minimal fake package + */ +export function createManifest(name: string, author: string, version?: VersionNumber): ManifestV2 { + return new ManifestV2().make({ + ManifestVersion: 2, + AuthorName: author, + Name: `${author}-${name}`, + DisplayName: name, + Version: version ? version.toString() : new VersionNumber("1.0.0").toString(), + }) as ManifestV2; +}; + +/** + * Use FsProvider to write fake package files into cache dir + */ +export async function createPackageFilesIntoCache(pkg: ManifestV2, filePaths: string[]) { + const fs = FsProvider.instance; + const cachePkgRoot = path.join(PathResolver.MOD_ROOT, "cache", pkg.getName(), pkg.getVersionNumber().toString()); + await fs.mkdirs(cachePkgRoot); + + for (const sourcePath of filePaths) { + const destPath = path.join(cachePkgRoot, sourcePath); + await fs.mkdirs(path.dirname(destPath)); + await fs.writeFile(destPath, ""); + expect(await fs.exists(destPath)); + } +} + +/** + * Return file tree of the target path as an array. + * + * Purely to make debugging easier if & when the test fails. + */ +async function getTree(target: string) { + const fs = FsProvider.instance; + const result: string[] = []; + + for (const entry of (await fs.readdir(target))) { + const fullPath = path.join(target, entry); + if ((await fs.stat(fullPath)).isDirectory()) { + for (const subPath of (await getTree(fullPath))) { + result.push(subPath); + } + } else { + result.push(fullPath); + } + } + return result; +} + +/** + * Assert all files exists on the expected destination paths after + * package installation. + */ +export async function expectFilesToBeCopied(sourceToExpectedDestination: Record) { + const fs = FsProvider.instance; + const profilePath = Profile.getActiveProfile().getPathOfProfile(); + + for (const destPath of Object.values(sourceToExpectedDestination)) { + const fullPath = path.join(profilePath, destPath); + const result = await fs.exists(fullPath); + if (!result) { + console.log(`Expected ${fullPath} to exist but it DOES NOT! All files:`); + console.log(JSON.stringify(await getTree(profilePath), null, 2)); + } + expect(result).toBeTruthy(); + } +} + +/** + * Assert intendend and only intended files are removed from + * destionation paths after package uninstallation. + */ +export async function expectFilesToBeRemoved( + sourceToExpectedDestination: Record, + expectedAfterUninstall: string[] +) { + const fs = FsProvider.instance; + const profilePath = Profile.getActiveProfile().getPathOfProfile(); + + for (const destPath of Object.values(sourceToExpectedDestination)) { + const fullPath = path.join(profilePath, destPath); + const doesExist = await fs.exists(fullPath); + const shouldExist = expectedAfterUninstall.includes(destPath); + + if (doesExist && !shouldExist) { + console.log(`Expected ${fullPath} to NOT exist but it DOES! All files:`); + console.log(JSON.stringify(await getTree(profilePath), null, 2)); + } else if (shouldExist && !doesExist) { + console.log(`Expected ${fullPath} to exist but it DOES NOT! All files:`); + console.log(JSON.stringify(await getTree(profilePath), null, 2)); + } + + expect(doesExist).toEqual(shouldExist); + } +} From 27129645827549a8ddefed67927c5447f7315cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Tue, 11 Jun 2024 16:55:41 +0300 Subject: [PATCH 02/28] Add unit tests for ReturnOfModding Test the installations and uninstallations of the package loader and a package ("plugin"). --- .../ReturnOfModding.Tests.spec.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts diff --git a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts new file mode 100644 index 000000000..72dadc2af --- /dev/null +++ b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts @@ -0,0 +1,61 @@ +import { + createManifest, + createPackageFilesIntoCache, + expectFilesToBeCopied, + expectFilesToBeRemoved, + installLogicBeforeEach +} from '../../../__utils__/InstallLogicUtils'; +import R2Error from '../../../../../src/model/errors/R2Error'; +import Profile from '../../../../../src/model/Profile'; +import ProfileInstallerProvider from '../../../../../src/providers/ror2/installing/ProfileInstallerProvider'; + + +describe('ReturnOfModding Installer Tests', () => { + beforeEach( + () => installLogicBeforeEach("RiskofRainReturns") + ); + + test('Installs and uninstalls the package loader', async () => { + const pkg = createManifest("ReturnOfModding", "ReturnOfModding"); + const sourceToExpectedDestination = { + "ReturnOfModdingPack/version.dll": "version.dll", + }; + const expectedAfterUninstall: string[] = []; + await createPackageFilesIntoCache(pkg, Object.keys(sourceToExpectedDestination)); + + await ProfileInstallerProvider.instance.installMod(pkg, Profile.getActiveProfile()); + await expectFilesToBeCopied(sourceToExpectedDestination); + + const result = await ProfileInstallerProvider.instance.uninstallMod(pkg, Profile.getActiveProfile()); + expect(result instanceof R2Error).toBeFalsy(); + await expectFilesToBeRemoved(sourceToExpectedDestination, expectedAfterUninstall); + }); + + test('Installs and uninstalls a package', async () => { + const pkg = createManifest("HelperFunctions", "Klehrik"); + const name = pkg.getName(); + const sourceToExpectedDestination = { + "README.md": `ReturnOfModding/plugins/${name}/README.md`, + "CHANGELOG.md": `ReturnOfModding/plugins/${name}/CHANGELOG.md`, + "manifest.json": `ReturnOfModding/plugins/${name}/manifest.json`, + "icon.png": `ReturnOfModding/plugins/${name}/icon.png`, + "main.lua": `ReturnOfModding/plugins/${name}/main.lua`, + "custom-path-gets-flattened/path.txt": `ReturnOfModding/plugins/${name}/path.txt`, + + // TODO: These are based on the current install rules but the mod loader's + // docs aren't clear if they are actually intended to be distributed with + // the mods or automatically created by them. + "plugins_data/data.dat": `ReturnOfModding/plugins_data/${name}/data.dat`, + "config/config.cfg": `ReturnOfModding/config/${name}/config.cfg`, + }; + const expectedAfterUninstall: string[] = []; + await createPackageFilesIntoCache(pkg, Object.keys(sourceToExpectedDestination)); + + await ProfileInstallerProvider.instance.installMod(pkg, Profile.getActiveProfile()); + await expectFilesToBeCopied(sourceToExpectedDestination); + + const result = await ProfileInstallerProvider.instance.uninstallMod(pkg, Profile.getActiveProfile()); + expect(result instanceof R2Error).toBeFalsy(); + await expectFilesToBeRemoved(sourceToExpectedDestination, expectedAfterUninstall); + }); +}); From ca12ddbd9009899a8a9248646744f528cec30699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Tue, 11 Jun 2024 17:11:52 +0300 Subject: [PATCH 03/28] Fix mod uninstalls for Risk of Rain Returns The custom folder name of the mod loader was not listed in the paths where the uninstallation logic looks for files. As a result, files belonging to uninstalled mods were left in the profile directory. Also improve some comments and misleading variable names. --- src/installers/ReturnOfModdingInstaller.ts | 2 +- .../GenericProfileInstaller.ts | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/installers/ReturnOfModdingInstaller.ts b/src/installers/ReturnOfModdingInstaller.ts index 5d29cd5c5..5eed33f8d 100644 --- a/src/installers/ReturnOfModdingInstaller.ts +++ b/src/installers/ReturnOfModdingInstaller.ts @@ -8,7 +8,7 @@ const basePackageFiles = ["manifest.json", "readme.md", "icon.png"]; export class ReturnOfModdingInstaller extends PackageInstaller { /** - * Handles installation of BepInEx + * Handles installation of ReturnOfModding mod loader */ async install(args: InstallArgs) { const { diff --git a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts index 9d37c9d34..47599bf4a 100644 --- a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts +++ b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts @@ -151,8 +151,10 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { const cachedLocationOfMod: string = path.join(cacheDirectory, mod.getName(), mod.getVersionNumber().toString()); const activeGame = GameManager.activeGame; - const bepInExVariant = MOD_LOADER_VARIANTS[activeGame.internalFolderName]; - const variant = bepInExVariant.find(value => value.packageName.toLowerCase() === mod.getName().toLowerCase()); + + // Installation logic for mod loaders. + const modLoaders = MOD_LOADER_VARIANTS[activeGame.internalFolderName]; + const variant = modLoaders.find(loader => loader.packageName.toLowerCase() === mod.getName().toLowerCase()); const args: InstallArgs = { mod: mod, @@ -164,6 +166,8 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { return this.installModLoader(variant, args); } + // Installation logic for mods for games that use "plugins", + // i.e. the newer approach for defining installation logic. const pluginInstaller = GetInstallerIdForPlugin(activeGame.packageLoader); if (pluginInstaller !== null) { @@ -209,8 +213,10 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { private async uninstallSubDir(mod: ManifestV2, profile: Profile): Promise { const activeGame = GameManager.activeGame; const fs = FsProvider.instance; - const bepInExVariant = MOD_LOADER_VARIANTS[activeGame.internalFolderName]; - if (bepInExVariant.find(value => value.packageName.toLowerCase() === mod.getName().toLowerCase())) { + + // Uninstallation logic for mod loaders. + const modLoaders = MOD_LOADER_VARIANTS[activeGame.internalFolderName]; + if (modLoaders.find(loader => loader.packageName.toLowerCase() === mod.getName().toLowerCase())) { try { for (const file of (await fs.readdir(profile.getPathOfProfile()))) { const filePath = path.join(profile.getPathOfProfile(), file); @@ -227,10 +233,10 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { } } - // BepInEx & shimloader plugin uninstall logic + // Uninstallation logic for regular mods. // TODO: Move to work through the installer interface const profilePath = profile.getPathOfProfile(); - const searchLocations = ["BepInEx", "shimloader"]; + const searchLocations = ["BepInEx", "shimloader", "ReturnOfModding"]; for (const searchLocation of searchLocations) { const bepInExLocation: string = path.join(profilePath, searchLocation); if (!(await fs.exists(bepInExLocation))) { From b46df64a6eb03e6bceb20580c565e7b60497d68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Wed, 12 Jun 2024 17:37:59 +0300 Subject: [PATCH 04/28] Add ReturnOfModdingPluginInstaller Use the new approach for defining how mods should be installed instead of the legacy way. I.e. mostly just movingthings around. It's worth noting that this doesn't actually change what the mod installation or uninstallation does, and what it currently does doesn't necessary reflect how RoRR/Hades 2 communities want this specific mod loader to work in the future. --- src/installers/ReturnOfModdingInstaller.ts | 40 ++++++++++++++++++- src/installers/registry.ts | 3 +- src/model/installing/PackageLoader.ts | 1 + .../installing/ProfileInstallerProvider.ts | 1 - .../InstallationRuleApplicator.ts | 2 - .../InstallRules_ReturnOfModding.ts | 32 --------------- 6 files changed, 41 insertions(+), 38 deletions(-) delete mode 100644 src/r2mm/installing/default_installation_rules/game_rules/InstallRules_ReturnOfModding.ts diff --git a/src/installers/ReturnOfModdingInstaller.ts b/src/installers/ReturnOfModdingInstaller.ts index 5eed33f8d..1ad70365b 100644 --- a/src/installers/ReturnOfModdingInstaller.ts +++ b/src/installers/ReturnOfModdingInstaller.ts @@ -1,8 +1,10 @@ -import { InstallArgs, PackageInstaller } from "./PackageInstaller"; import path from "path"; + +import { InstallRuleInstaller } from "./InstallRuleInstaller"; +import { InstallArgs, PackageInstaller } from "./PackageInstaller"; +import { PackageLoader } from "../model/installing/PackageLoader"; import FsProvider from "../providers/generic/file/FsProvider"; import { MODLOADER_PACKAGES } from "../r2mm/installing/profile_installers/ModLoaderVariantRecord"; -import { PackageLoader } from "../model/installing/PackageLoader"; const basePackageFiles = ["manifest.json", "readme.md", "icon.png"]; @@ -40,3 +42,37 @@ export class ReturnOfModdingInstaller extends PackageInstaller { } } } + +export class ReturnOfModdingPluginInstaller extends PackageInstaller { + readonly installer = new InstallRuleInstaller({ + gameName: "none" as any, // This isn't acutally used for actual installation but needs some value + rules: [ + { + route: path.join("ReturnOfModding", "plugins"), + isDefaultLocation: true, + defaultFileExtensions: [], + trackingMethod: "SUBDIR", + subRoutes: [], + }, + { + route: path.join("ReturnOfModding", "plugins_data"), + defaultFileExtensions: [], + trackingMethod: "SUBDIR", + subRoutes: [], + }, + { + route: path.join("ReturnOfModding", "config"), + defaultFileExtensions: [], + trackingMethod: "SUBDIR", + subRoutes: [], + } + ] + }); + + /** + * Handles installation of mods that use ReturnOfModding mod loader + */ + async install(args: InstallArgs) { + await this.installer.install(args); + } +} diff --git a/src/installers/registry.ts b/src/installers/registry.ts index f4cdc981e..b665f989d 100644 --- a/src/installers/registry.ts +++ b/src/installers/registry.ts @@ -5,7 +5,7 @@ import { PackageInstaller } from './PackageInstaller'; import { ShimloaderInstaller, ShimloaderPluginInstaller } from './ShimloaderInstaller'; import { LovelyInstaller, LovelyPluginInstaller } from './LovelyInstaller'; import { NorthstarInstaller } from './NorthstarInstaller'; -import { ReturnOfModdingInstaller } from './ReturnOfModdingInstaller'; +import { ReturnOfModdingInstaller, ReturnOfModdingPluginInstaller } from './ReturnOfModdingInstaller'; const _PackageInstallers = { @@ -19,6 +19,7 @@ const _PackageInstallers = { "lovely": new LovelyInstaller(), "lovely-plugin": new LovelyPluginInstaller(), "returnofmodding": new ReturnOfModdingInstaller(), + "returnofmodding-plugin": new ReturnOfModdingPluginInstaller(), } export type PackageInstallerId = keyof typeof _PackageInstallers; diff --git a/src/model/installing/PackageLoader.ts b/src/model/installing/PackageLoader.ts index b39f08108..0d90c3b75 100644 --- a/src/model/installing/PackageLoader.ts +++ b/src/model/installing/PackageLoader.ts @@ -30,6 +30,7 @@ export function GetInstallerIdForPlugin(loader: PackageLoader): PackageInstaller switch (loader) { case PackageLoader.SHIMLOADER: return "shimloader-plugin"; case PackageLoader.LOVELY: return "lovely-plugin"; + case PackageLoader.RETURN_OF_MODDING: return "returnofmodding-plugin"; } return null; diff --git a/src/providers/ror2/installing/ProfileInstallerProvider.ts b/src/providers/ror2/installing/ProfileInstallerProvider.ts index 63e9c1797..9e1f8ebd6 100644 --- a/src/providers/ror2/installing/ProfileInstallerProvider.ts +++ b/src/providers/ror2/installing/ProfileInstallerProvider.ts @@ -2,7 +2,6 @@ import ProviderUtils from '../../generic/ProviderUtils'; import ManifestV2 from '../../../model/ManifestV2'; import R2Error from '../../../model/errors/R2Error'; import FileTree from '../../../model/file/FileTree'; -import ModLoaderPackageMapping from '../../../model/installing/ModLoaderPackageMapping'; import Profile from '../../../model/Profile'; export default abstract class ProfileInstallerProvider { diff --git a/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts b/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts index edd17a224..daf08d6b1 100644 --- a/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts +++ b/src/r2mm/installing/default_installation_rules/InstallationRuleApplicator.ts @@ -19,7 +19,6 @@ import { import { buildMelonLoaderRules } from "../default_installation_rules/game_rules/InstallRules_MelonLoader"; -import { buildReturnOfModdingRules } from './game_rules/InstallRules_ReturnOfModding'; export default class InstallationRuleApplicator { @@ -127,7 +126,6 @@ export default class InstallationRuleApplicator { buildBepInExRules("AgainstTheStorm"), buildBepInExRules("Lycans"), buildBepInExRules("CastleStory"), - buildReturnOfModdingRules("RiskofRainReturns"), buildBepInExRules("Magicraft"), buildBepInExRules("AnotherCrabsTreasure"), buildBepInExRules("GladioMori"), diff --git a/src/r2mm/installing/default_installation_rules/game_rules/InstallRules_ReturnOfModding.ts b/src/r2mm/installing/default_installation_rules/game_rules/InstallRules_ReturnOfModding.ts deleted file mode 100644 index 6f015a639..000000000 --- a/src/r2mm/installing/default_installation_rules/game_rules/InstallRules_ReturnOfModding.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CoreRuleType } from '../../InstallationRules'; -import * as path from 'path'; -import { GAME_NAME } from '../../profile_installers/ModLoaderVariantRecord'; -import { RuleSubtype } from "../../InstallationRules"; - -export function buildReturnOfModdingRules(gameName: GAME_NAME, extraRules?: RuleSubtype[]): CoreRuleType { - return { - gameName: gameName, - rules: [ - { - route: path.join("ReturnOfModding", "plugins"), - isDefaultLocation: true, - defaultFileExtensions: [".lua"], - trackingMethod: "SUBDIR", - subRoutes: [] - }, - { - route: path.join("ReturnOfModding", "plugins_data"), - defaultFileExtensions: [], - trackingMethod: "SUBDIR", - subRoutes: [] - }, - { - route: path.join("ReturnOfModding", "config"), - defaultFileExtensions: [], - trackingMethod: "SUBDIR", - subRoutes: [] - }, - ...(extraRules ? extraRules : []), - ], - } -} From 881a785f88991dc51696223f56926c21909b41a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Wed, 12 Jun 2024 17:54:15 +0300 Subject: [PATCH 05/28] ReturnOfModding: don't flatten folders when mods are installed --- src/installers/ReturnOfModdingInstaller.ts | 6 +++--- .../impl/install_logic/ReturnOfModding.Tests.spec.ts | 6 +----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/installers/ReturnOfModdingInstaller.ts b/src/installers/ReturnOfModdingInstaller.ts index 1ad70365b..3b39b209d 100644 --- a/src/installers/ReturnOfModdingInstaller.ts +++ b/src/installers/ReturnOfModdingInstaller.ts @@ -51,19 +51,19 @@ export class ReturnOfModdingPluginInstaller extends PackageInstaller { route: path.join("ReturnOfModding", "plugins"), isDefaultLocation: true, defaultFileExtensions: [], - trackingMethod: "SUBDIR", + trackingMethod: "SUBDIR_NO_FLATTEN", subRoutes: [], }, { route: path.join("ReturnOfModding", "plugins_data"), defaultFileExtensions: [], - trackingMethod: "SUBDIR", + trackingMethod: "SUBDIR_NO_FLATTEN", subRoutes: [], }, { route: path.join("ReturnOfModding", "config"), defaultFileExtensions: [], - trackingMethod: "SUBDIR", + trackingMethod: "SUBDIR_NO_FLATTEN", subRoutes: [], } ] diff --git a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts index 72dadc2af..b4ea589af 100644 --- a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts +++ b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts @@ -40,11 +40,7 @@ describe('ReturnOfModding Installer Tests', () => { "manifest.json": `ReturnOfModding/plugins/${name}/manifest.json`, "icon.png": `ReturnOfModding/plugins/${name}/icon.png`, "main.lua": `ReturnOfModding/plugins/${name}/main.lua`, - "custom-path-gets-flattened/path.txt": `ReturnOfModding/plugins/${name}/path.txt`, - - // TODO: These are based on the current install rules but the mod loader's - // docs aren't clear if they are actually intended to be distributed with - // the mods or automatically created by them. + "custom-folder/file.txt": `ReturnOfModding/plugins/${name}/custom-folder/file.txt`, "plugins_data/data.dat": `ReturnOfModding/plugins_data/${name}/data.dat`, "config/config.cfg": `ReturnOfModding/config/${name}/config.cfg`, }; From e6647e6dfd8ca43609776fb20090fdbe55dff72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 13 Jun 2024 17:01:54 +0300 Subject: [PATCH 06/28] Add PackageInstallerV2 V2 extends the original PackageInstaller by adding support for installer specific uninstallation logic. Having two separate versions is a bit hacky and requires the profile installer provider to do some extra checks. However, this allows us to add support for one installer at the time, instead of having to implement them all at once. --- src/installers/PackageInstaller.ts | 6 +- .../GenericProfileInstaller.ts | 85 +++++++++++++++---- 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/src/installers/PackageInstaller.ts b/src/installers/PackageInstaller.ts index d7b56589e..9bc721f75 100644 --- a/src/installers/PackageInstaller.ts +++ b/src/installers/PackageInstaller.ts @@ -12,5 +12,9 @@ export type InstallArgs = { export abstract class PackageInstaller { abstract install(args: InstallArgs): Promise; // abstract disable(args: InstallArgs): Promise; // TODO: Implement - // abstract uninstall(): Promise; // TODO: Implement +} + + +export abstract class PackageInstallerV2 extends PackageInstaller { + abstract uninstall(args: InstallArgs): Promise; } diff --git a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts index 47599bf4a..b57b90e8f 100644 --- a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts +++ b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts @@ -17,8 +17,8 @@ import { MOD_LOADER_VARIANTS } from '../../installing/profile_installers/ModLoad import FileWriteError from '../../../model/errors/FileWriteError'; import FileUtils from '../../../utils/FileUtils'; import { GetInstallerIdForLoader, GetInstallerIdForPlugin } from '../../../model/installing/PackageLoader'; -import { PackageInstallers } from "../../../installers/registry"; -import { InstallArgs } from "../../../installers/PackageInstaller"; +import { PackageInstallerId, PackageInstallers } from "../../../installers/registry"; +import { InstallArgs, PackageInstallerV2 } from "../../../installers/PackageInstaller"; import { InstallRuleInstaller } from "../../../installers/InstallRuleInstaller"; import { ShimloaderPluginInstaller } from "../../../installers/ShimloaderInstaller"; @@ -147,28 +147,18 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { } async installMod(mod: ManifestV2, profile: Profile): Promise { - const cacheDirectory = path.join(PathResolver.MOD_ROOT, 'cache'); - const cachedLocationOfMod: string = path.join(cacheDirectory, mod.getName(), mod.getVersionNumber().toString()); - - const activeGame = GameManager.activeGame; + const args = this.getInstallArgs(mod, profile); // Installation logic for mod loaders. - const modLoaders = MOD_LOADER_VARIANTS[activeGame.internalFolderName]; - const variant = modLoaders.find(loader => loader.packageName.toLowerCase() === mod.getName().toLowerCase()); + const modLoader = this.getModLoader(mod); - const args: InstallArgs = { - mod: mod, - profile: profile, - packagePath: cachedLocationOfMod, - } - - if (variant !== undefined) { - return this.installModLoader(variant, args); + if (modLoader !== undefined) { + return this.installModLoader(modLoader, args); } // Installation logic for mods for games that use "plugins", - // i.e. the newer approach for defining installation logic. - const pluginInstaller = GetInstallerIdForPlugin(activeGame.packageLoader); + // i.e. the newer approach for defining installation logic. + const pluginInstaller = GetInstallerIdForPlugin(GameManager.activeGame.packageLoader); if (pluginInstaller !== null) { await PackageInstallers[pluginInstaller].install(args); @@ -179,6 +169,17 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { return this.installForManifestV2(args); } + private getInstallArgs(mod: ManifestV2, profile: Profile): InstallArgs { + const cacheDirectory = path.join(PathResolver.MOD_ROOT, 'cache'); + const packagePath = path.join(cacheDirectory, mod.getName(), mod.getVersionNumber().toString()); + return {mod, profile, packagePath}; + } + + private getModLoader(mod: ManifestV2): ModLoaderPackageMapping|undefined { + const modLoaders = MOD_LOADER_VARIANTS[GameManager.activeGame.internalFolderName]; + return modLoaders.find(loader => loader.packageName.toLowerCase() === mod.getName().toLowerCase()); + } + async installModLoader(mapping: ModLoaderPackageMapping, args: InstallArgs): Promise { const installerId = GetInstallerIdForLoader(mapping.loaderType); if (installerId) { @@ -283,6 +284,19 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { } async uninstallMod(mod: ManifestV2, profile: Profile): Promise { + // Implementations of PackageInstallerV2 define their own uninstallation logic. + try { + if ( + await this.uninstallModLoaderWithInstaller(mod, profile) || + await this.uninstallModWithInstaller(mod, profile) + ) { + return null; + } + } catch (e) { + return R2Error.fromThrownValue(e); + } + + // Fallback to legacy uninstallation. const uninstallState = await this.uninstallState(mod, profile); if (uninstallState instanceof R2Error) { return uninstallState; @@ -298,4 +312,39 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { } return null; } + + /** + * Uninstall mod if it's a registered mod loader with PackageInstallerV2 implementation + * @return true if mod loader was uninstalled + */ + async uninstallModLoaderWithInstaller(mod: ManifestV2, profile: Profile): Promise { + const modLoader = this.getModLoader(mod); + const installerId = modLoader ? GetInstallerIdForLoader(modLoader.loaderType) : null; + return this.uninstallWithInstaller(installerId, mod, profile); + } + + /** + * Uninstall mod if its registered installer implements PackageInstallerV2 + * @return true if mod was uninstalled + */ + async uninstallModWithInstaller(mod: ManifestV2, profile: Profile): Promise { + const installerId = GetInstallerIdForPlugin(GameManager.activeGame.packageLoader); + return this.uninstallWithInstaller(installerId, mod, profile); + } + + private async uninstallWithInstaller( + installerId: PackageInstallerId | null, + mod: ManifestV2, + profile: Profile + ): Promise { + const installer = installerId ? PackageInstallers[installerId] : undefined; + + if (installer && installer instanceof PackageInstallerV2) { + const args = this.getInstallArgs(mod, profile); + await installer.uninstall(args); + return true; + } + + return false; + } } From f65d86f38a2180c69192478e9a1634ddfc58bb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 14 Jun 2024 09:50:14 +0300 Subject: [PATCH 07/28] Add uninstallation logic for ReturnOfModding installers --- src/installers/ReturnOfModdingInstaller.ts | 73 +++++++++++++++---- src/utils/FileUtils.ts | 11 +++ .../ReturnOfModding.Tests.spec.ts | 4 +- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/installers/ReturnOfModdingInstaller.ts b/src/installers/ReturnOfModdingInstaller.ts index 3b39b209d..8993629d9 100644 --- a/src/installers/ReturnOfModdingInstaller.ts +++ b/src/installers/ReturnOfModdingInstaller.ts @@ -1,17 +1,19 @@ import path from "path"; import { InstallRuleInstaller } from "./InstallRuleInstaller"; -import { InstallArgs, PackageInstaller } from "./PackageInstaller"; +import { InstallArgs, PackageInstallerV2 } from "./PackageInstaller"; +import FileWriteError from "../model/errors/FileWriteError"; import { PackageLoader } from "../model/installing/PackageLoader"; import FsProvider from "../providers/generic/file/FsProvider"; import { MODLOADER_PACKAGES } from "../r2mm/installing/profile_installers/ModLoaderVariantRecord"; +import FileUtils from "../utils/FileUtils"; const basePackageFiles = ["manifest.json", "readme.md", "icon.png"]; -export class ReturnOfModdingInstaller extends PackageInstaller { - /** - * Handles installation of ReturnOfModding mod loader - */ +/** + * Handles (un)installation of ReturnOfModding mod loader + */ +export class ReturnOfModdingInstaller extends PackageInstallerV2 { async install(args: InstallArgs) { const { mod, @@ -41,27 +43,56 @@ export class ReturnOfModdingInstaller extends PackageInstaller { } } } + + async uninstall(args: InstallArgs): Promise { + const fs = FsProvider.instance; + const {profile} = args; + + try { + // Delete all files except mods.yml from profile root. Ignore directories. + for (const file of (await fs.readdir(profile.getPathOfProfile()))) { + const filePath = path.join(profile.getPathOfProfile(), file); + if ((await fs.lstat(filePath)).isFile()) { + if (file.toLowerCase() !== 'mods.yml') { + await fs.unlink(filePath); + } + } + } + } catch(e) { + const name = "Failed to delete ReturnOfModding files from profile root"; + const solution = "Is the game still running?"; + throw FileWriteError.fromThrownValue(e, name, solution); + } + }; } -export class ReturnOfModdingPluginInstaller extends PackageInstaller { +/** + * Handles (un)installation of mods that use ReturnOfModding mod loader + */ +export class ReturnOfModdingPluginInstaller extends PackageInstallerV2 { + _ROOT = "ReturnOfModding"; + _PLUGINS = "plugins"; + _DATA = "plugins_data"; + _CONFIG = "config" + readonly installer = new InstallRuleInstaller({ - gameName: "none" as any, // This isn't acutally used for actual installation but needs some value + gameName: "none" as any, // This isn't actually used for actual installation but needs some value rules: [ { - route: path.join("ReturnOfModding", "plugins"), + route: path.join(this._ROOT, this._PLUGINS), isDefaultLocation: true, defaultFileExtensions: [], trackingMethod: "SUBDIR_NO_FLATTEN", subRoutes: [], }, { - route: path.join("ReturnOfModding", "plugins_data"), + route: path.join(this._ROOT, this._DATA), defaultFileExtensions: [], trackingMethod: "SUBDIR_NO_FLATTEN", subRoutes: [], }, { - route: path.join("ReturnOfModding", "config"), + route: path.join(this._ROOT, this._CONFIG), defaultFileExtensions: [], trackingMethod: "SUBDIR_NO_FLATTEN", subRoutes: [], @@ -69,10 +100,26 @@ export class ReturnOfModdingPluginInstaller extends PackageInstaller { ] }); - /** - * Handles installation of mods that use ReturnOfModding mod loader - */ + async install(args: InstallArgs) { await this.installer.install(args); } + + async uninstall(args: InstallArgs): Promise { + const {mod, profile} = args; + + try { + // Persist config dir, remove the rest. + await FileUtils.recursiveRemoveDirectoryIfExists( + path.join(profile.getPathOfProfile(), this._ROOT, this._PLUGINS, mod.getName()) + ); + await FileUtils.recursiveRemoveDirectoryIfExists( + path.join(profile.getPathOfProfile(), this._ROOT, this._DATA, mod.getName()) + ); + } catch(e) { + const name = `Failed to delete ${mod.getName()} files from profile`; + const solution = "Is the game still running?"; + throw FileWriteError.fromThrownValue(e, name, solution); + } + }; } diff --git a/src/utils/FileUtils.ts b/src/utils/FileUtils.ts index 5eb9dc9ff..304864241 100644 --- a/src/utils/FileUtils.ts +++ b/src/utils/FileUtils.ts @@ -39,4 +39,15 @@ export default class FileUtils { unitDisplay: "narrow", }).format(bytes); }; + + public static async recursiveRemoveDirectoryIfExists(dir: string) { + const fs = FsProvider.instance; + + if (!(await fs.exists(dir)) || !(await fs.lstat(dir)).isDirectory()) { + return; + } + + await FileUtils.emptyDirectory(dir); + await fs.rmdir(dir); + } } diff --git a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts index b4ea589af..d06c118c9 100644 --- a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts +++ b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts @@ -44,7 +44,9 @@ describe('ReturnOfModding Installer Tests', () => { "plugins_data/data.dat": `ReturnOfModding/plugins_data/${name}/data.dat`, "config/config.cfg": `ReturnOfModding/config/${name}/config.cfg`, }; - const expectedAfterUninstall: string[] = []; + const expectedAfterUninstall: string[] = [ + `ReturnOfModding/config/${name}/config.cfg`, + ]; await createPackageFilesIntoCache(pkg, Object.keys(sourceToExpectedDestination)); await ProfileInstallerProvider.instance.installMod(pkg, Profile.getActiveProfile()); From 6e22a6231c858a02961acb06ae99decc171edfa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 14 Jun 2024 10:13:47 +0300 Subject: [PATCH 08/28] Try to improve the readability of ReturnOfModdingInstaller.install --- src/installers/ReturnOfModdingInstaller.ts | 32 ++++++++----------- src/utils/FileUtils.ts | 8 +++++ .../ReturnOfModding.Tests.spec.ts | 3 +- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/installers/ReturnOfModdingInstaller.ts b/src/installers/ReturnOfModdingInstaller.ts index 8993629d9..7365a8b73 100644 --- a/src/installers/ReturnOfModdingInstaller.ts +++ b/src/installers/ReturnOfModdingInstaller.ts @@ -15,32 +15,26 @@ const basePackageFiles = ["manifest.json", "readme.md", "icon.png"]; */ export class ReturnOfModdingInstaller extends PackageInstallerV2 { async install(args: InstallArgs) { - const { - mod, - packagePath, - profile, - } = args; + const {mod, packagePath, profile} = args; const mapping = MODLOADER_PACKAGES.find((entry) => entry.packageName.toLowerCase() == mod.getName().toLowerCase() && entry.loaderType == PackageLoader.RETURN_OF_MODDING, ); - const mappingRoot = mapping ? mapping.rootFolder : ""; - let root: string; - if (mappingRoot.trim().length > 0) { - root = path.join(packagePath, mappingRoot); - } else { - root = path.join(packagePath); + if (mapping === undefined) { + throw new Error(`ReturnOfModdingInstaller found no loader for ${mod.getName()}`); } - for (const item of (await FsProvider.instance.readdir(root))) { - if (!basePackageFiles.includes(item.toLowerCase())) { - if ((await FsProvider.instance.stat(path.join(root, item))).isFile()) { - await FsProvider.instance.copyFile(path.join(root, item), path.join(profile.getPathOfProfile(), item)); - } else { - await FsProvider.instance.copyFolder(path.join(root, item), path.join(profile.getPathOfProfile(), item)); - } - } + + const root = path.join(packagePath, mapping.rootFolder); + const allContents = await FsProvider.instance.readdir(root); + const toCopy = allContents.filter((x) => !basePackageFiles.includes(x)); + + for (const fileOrFolder of toCopy) { + await FileUtils.copyFileOrFolder( + path.join(root, fileOrFolder), + path.join(profile.getPathOfProfile(), fileOrFolder) + ); } } diff --git a/src/utils/FileUtils.ts b/src/utils/FileUtils.ts index 304864241..2be531fdd 100644 --- a/src/utils/FileUtils.ts +++ b/src/utils/FileUtils.ts @@ -3,6 +3,14 @@ import path from 'path'; export default class FileUtils { + public static async copyFileOrFolder(source: string, target: string) { + if ((await FsProvider.instance.stat(source)).isFile()) { + await FsProvider.instance.copyFile(source, target); + } else { + await FsProvider.instance.copyFolder(source, target); + } + } + public static async ensureDirectory(dir: string) { const fs = FsProvider.instance; await fs.mkdirs(dir); diff --git a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts index d06c118c9..b3c9a0b66 100644 --- a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts +++ b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts @@ -18,7 +18,8 @@ describe('ReturnOfModding Installer Tests', () => { test('Installs and uninstalls the package loader', async () => { const pkg = createManifest("ReturnOfModding", "ReturnOfModding"); const sourceToExpectedDestination = { - "ReturnOfModdingPack/version.dll": "version.dll", + "ReturnOfModdingPack/version.dll": "version.dll", // RoRR + "ReturnOfModdingPack/d3d12.dll": "d3d12.dll", // Hades 2 }; const expectedAfterUninstall: string[] = []; await createPackageFilesIntoCache(pkg, Object.keys(sourceToExpectedDestination)); From 097930ee544fbd57617158f138dd1e50a66b9401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 14 Jun 2024 10:21:42 +0300 Subject: [PATCH 09/28] Move if-check to avoid an unnecessary file operation --- src/installers/ReturnOfModdingInstaller.ts | 7 ++++--- .../profile_installers/GenericProfileInstaller.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/installers/ReturnOfModdingInstaller.ts b/src/installers/ReturnOfModdingInstaller.ts index 7365a8b73..1c2b7949b 100644 --- a/src/installers/ReturnOfModdingInstaller.ts +++ b/src/installers/ReturnOfModdingInstaller.ts @@ -45,11 +45,12 @@ export class ReturnOfModdingInstaller extends PackageInstallerV2 { try { // Delete all files except mods.yml from profile root. Ignore directories. for (const file of (await fs.readdir(profile.getPathOfProfile()))) { + if (file.toLowerCase() === 'mods.yml') { + continue; + } const filePath = path.join(profile.getPathOfProfile(), file); if ((await fs.lstat(filePath)).isFile()) { - if (file.toLowerCase() !== 'mods.yml') { - await fs.unlink(filePath); - } + await fs.unlink(filePath); } } } catch(e) { diff --git a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts index b57b90e8f..5b68a85d7 100644 --- a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts +++ b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts @@ -220,11 +220,12 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { if (modLoaders.find(loader => loader.packageName.toLowerCase() === mod.getName().toLowerCase())) { try { for (const file of (await fs.readdir(profile.getPathOfProfile()))) { + if (file.toLowerCase() === 'mods.yml') { + continue; + } const filePath = path.join(profile.getPathOfProfile(), file); if ((await fs.lstat(filePath)).isFile()) { - if (file.toLowerCase() !== 'mods.yml') { - await fs.unlink(filePath); - } + await fs.unlink(filePath); } } } catch(e) { From 415f7e07ca9d0c7934248e91af31fd3788e90f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 17 Jun 2024 16:30:29 +0300 Subject: [PATCH 10/28] Change ReturnOfModding plugin uninstallation Uninstalling a mod using ReturnofModding should remove the plugins_data subdir. However, if it contains a cache subdir, the contents of that subdir are kept, while rest of the content is removed. This is done as a way to persist some of the data created by the plugin when the mod is updated, since the update process uninstalls the old version before the new one is installed. The behaviour is documented at https://github.com/xiaoxiao921/ReturnOfModdingBase/tree/master?tab=readme-ov-file#plugins_data --- src/installers/ReturnOfModdingInstaller.ts | 37 +++++++++++++++---- .../ReturnOfModding.Tests.spec.ts | 19 ++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/installers/ReturnOfModdingInstaller.ts b/src/installers/ReturnOfModdingInstaller.ts index 1c2b7949b..ac4085352 100644 --- a/src/installers/ReturnOfModdingInstaller.ts +++ b/src/installers/ReturnOfModdingInstaller.ts @@ -101,16 +101,39 @@ export class ReturnOfModdingPluginInstaller extends PackageInstallerV2 { } async uninstall(args: InstallArgs): Promise { + const fs = FsProvider.instance; const {mod, profile} = args; + // Remove the plugin dir. + // Remove the data dir, but keep the cache subdir if it exists. + // Leave the config dir alone. try { - // Persist config dir, remove the rest. - await FileUtils.recursiveRemoveDirectoryIfExists( - path.join(profile.getPathOfProfile(), this._ROOT, this._PLUGINS, mod.getName()) - ); - await FileUtils.recursiveRemoveDirectoryIfExists( - path.join(profile.getPathOfProfile(), this._ROOT, this._DATA, mod.getName()) - ); + const pluginPath = path.join(profile.getPathOfProfile(), this._ROOT, this._PLUGINS, mod.getName()) + const dataPath = path.join(profile.getPathOfProfile(), this._ROOT, this._DATA, mod.getName()); + + await FileUtils.recursiveRemoveDirectoryIfExists(pluginPath); + + if (await fs.exists(dataPath)) { + let hasCache = false; + + for (const file of (await fs.readdir(dataPath))) { + const filePath = path.join(dataPath, file); + + if ((await fs.lstat(filePath)).isDirectory()) { + if (file.toLowerCase() === "cache") { + hasCache = true; + } else { + await FileUtils.recursiveRemoveDirectoryIfExists(filePath); + } + } else { + await fs.unlink(filePath); + } + } + + if (!hasCache) { + await FileUtils.recursiveRemoveDirectoryIfExists(dataPath); + } + } } catch(e) { const name = `Failed to delete ${mod.getName()} files from profile`; const solution = "Is the game still running?"; diff --git a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts index b3c9a0b66..307cd3c02 100644 --- a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts +++ b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts @@ -57,4 +57,23 @@ describe('ReturnOfModding Installer Tests', () => { expect(result instanceof R2Error).toBeFalsy(); await expectFilesToBeRemoved(sourceToExpectedDestination, expectedAfterUninstall); }); + + test('Package uninstall keeps plugins_data if it contains cache', async () => { + const pkg = createManifest("HelperFunctions", "Klehrik"); + const name = pkg.getName(); + const sourceToExpectedDestination = { + "plugins_data/cache/data.dat": `ReturnOfModding/plugins_data/${name}/cache/data.dat`, + }; + const expectedAfterUninstall: string[] = [ + `ReturnOfModding/plugins_data/${name}/cache/data.dat`, + ]; + await createPackageFilesIntoCache(pkg, Object.keys(sourceToExpectedDestination)); + + await ProfileInstallerProvider.instance.installMod(pkg, Profile.getActiveProfile()); + await expectFilesToBeCopied(sourceToExpectedDestination); + + const result = await ProfileInstallerProvider.instance.uninstallMod(pkg, Profile.getActiveProfile()); + expect(result instanceof R2Error).toBeFalsy(); + await expectFilesToBeRemoved(sourceToExpectedDestination, expectedAfterUninstall); + }); }); From 54c794a1b858f3f17f4a5dfeffdfe0d81369d0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Tue, 18 Jun 2024 15:43:21 +0300 Subject: [PATCH 11/28] Fix InMemoryFsProvider.rename and add unit tests - Actually rename the leaf item of the path - Throw error if source file/dir and target path don't exists to be in line with Node's rename() - Throw error if attempting to rename an item at the root of mocked file system. Rename (and other methods) assume there's some form of dir structure. Fixing this would be too much work now, so just document the "feature" --- .../stub_tests/InMemory.FsProvider.spec.ts | 137 ++++++++++++++++++ .../stubs/providers/InMemory.FsProvider.ts | 21 ++- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/test/jest/__tests__/stub_tests/InMemory.FsProvider.spec.ts b/test/jest/__tests__/stub_tests/InMemory.FsProvider.spec.ts index 96d746113..9bc0488b5 100644 --- a/test/jest/__tests__/stub_tests/InMemory.FsProvider.spec.ts +++ b/test/jest/__tests__/stub_tests/InMemory.FsProvider.spec.ts @@ -45,6 +45,143 @@ describe("InMemoryFsProvider", () => { expect(content.toString()).toBe("test_content"); }); + test("Rename throws for missing source", async() => { + await expect( + async () => await FsProvider.instance.rename("foo", "bar") + ).rejects.toThrowError("ENOENT: no such file or directory, rename 'foo' -> 'bar'"); + }); + + test("Rename throws for missing target path", async() => { + const sourcePath = path.join("original", "file.txt"); + const targetPath = path.join("new", "file.txt") + await FsProvider.instance.mkdirs(path.dirname(sourcePath)); + await FsProvider.instance.writeFile(sourcePath, "content"); + + await expect( + async () => await FsProvider.instance.rename(sourcePath, targetPath) + ).rejects.toThrowError(`ENOENT: no such file or directory, rename '${sourcePath}' -> '${targetPath}'`); + }); + + test("Rename a file in same dir", async() => { + const sourcePath = path.join("dir", "file.txt"); + const targetPath = path.join("dir", "file.txt.old"); + await FsProvider.instance.mkdirs(path.dirname(sourcePath)); + await FsProvider.instance.writeFile(sourcePath, "content"); + expect(await FsProvider.instance.exists(sourcePath)).toBeTruthy(); + expect(await FsProvider.instance.exists(targetPath)).toBeFalsy(); + expect( + (await FsProvider.instance.lstat(sourcePath)).isFile() + ).toBeTruthy(); + + await FsProvider.instance.rename(sourcePath, targetPath); + + expect(await FsProvider.instance.exists(sourcePath)).toBeFalsy(); + expect(await FsProvider.instance.exists(targetPath)).toBeTruthy(); + expect( + (await FsProvider.instance.lstat(targetPath)).isFile() + ).toBeTruthy(); + }); + + test("Rename a file into another dir", async() => { + const sourcePath = path.join("dir", "file.txt"); + const targetPath = path.join("landaa", "file.txt"); + await FsProvider.instance.mkdirs(path.dirname(sourcePath)); + await FsProvider.instance.writeFile(sourcePath, "content"); + await FsProvider.instance.mkdirs(path.dirname(targetPath)); + expect(await FsProvider.instance.exists(sourcePath)).toBeTruthy(); + expect(await FsProvider.instance.exists(targetPath)).toBeFalsy(); + expect( + (await FsProvider.instance.lstat(sourcePath)).isFile() + ).toBeTruthy(); + + await FsProvider.instance.rename(sourcePath, targetPath); + + expect(await FsProvider.instance.exists(sourcePath)).toBeFalsy(); + expect(await FsProvider.instance.exists(targetPath)).toBeTruthy(); + expect( + (await FsProvider.instance.lstat(targetPath)).isFile() + ).toBeTruthy(); + }); + + test("Rename a file with relative path", async() => { + const sourcePath = path.join("root", "middle", "file.txt"); + const targetPath = path.join(path.dirname(sourcePath), "..", "file.txt"); + await FsProvider.instance.mkdirs(path.dirname(sourcePath)); + await FsProvider.instance.writeFile(sourcePath, "content"); + expect(await FsProvider.instance.exists(sourcePath)).toBeTruthy(); + expect(await FsProvider.instance.exists(targetPath)).toBeFalsy(); + expect( + (await FsProvider.instance.lstat(sourcePath)).isFile() + ).toBeTruthy(); + + await FsProvider.instance.rename(sourcePath, targetPath); + + expect(await FsProvider.instance.exists(sourcePath)).toBeFalsy(); + expect(await FsProvider.instance.exists(targetPath)).toBeTruthy(); + expect( + (await FsProvider.instance.lstat(targetPath)).isFile() + ).toBeTruthy(); + }); + + test("Rename a dir in same dir", async() => { + const sourcePath = path.join("dir", "subdir"); + const targetPath = path.join("dir", "newdir"); + await FsProvider.instance.mkdirs(sourcePath); + expect(await FsProvider.instance.exists(sourcePath)).toBeTruthy(); + expect(await FsProvider.instance.exists(targetPath)).toBeFalsy(); + expect( + (await FsProvider.instance.lstat(sourcePath)).isDirectory() + ).toBeTruthy(); + + await FsProvider.instance.rename(sourcePath, targetPath); + + expect(await FsProvider.instance.exists(sourcePath)).toBeFalsy(); + expect(await FsProvider.instance.exists(targetPath)).toBeTruthy(); + expect( + (await FsProvider.instance.lstat(targetPath)).isDirectory() + ).toBeTruthy(); + }); + + test("Rename a dir into another dir", async() => { + const sourcePath = path.join("dir", "lan", "daa"); + const targetPath = path.join("dar", "lan", "daa"); + await FsProvider.instance.mkdirs(sourcePath); + await FsProvider.instance.mkdirs(path.dirname(targetPath)); + expect(await FsProvider.instance.exists(sourcePath)).toBeTruthy(); + expect(await FsProvider.instance.exists(targetPath)).toBeFalsy(); + expect( + (await FsProvider.instance.lstat(sourcePath)).isDirectory() + ).toBeTruthy(); + + await FsProvider.instance.rename(sourcePath, targetPath); + + expect(await FsProvider.instance.exists(sourcePath)).toBeFalsy(); + expect(await FsProvider.instance.exists(targetPath)).toBeTruthy(); + expect( + (await FsProvider.instance.lstat(targetPath)).isDirectory() + ).toBeTruthy(); + }); + + test("Rename a dir with relative path", async() => { + const sourcePath = path.join("root", "middle", "leaf"); + const targetPath = path.join("root", "middle", "..", "leaf"); + await FsProvider.instance.mkdirs(sourcePath); + await FsProvider.instance.mkdirs(path.dirname(targetPath)); + expect(await FsProvider.instance.exists(sourcePath)).toBeTruthy(); + expect(await FsProvider.instance.exists(targetPath)).toBeFalsy(); + expect( + (await FsProvider.instance.lstat(sourcePath)).isDirectory() + ).toBeTruthy(); + + await FsProvider.instance.rename(sourcePath, targetPath); + + expect(await FsProvider.instance.exists(sourcePath)).toBeFalsy(); + expect(await FsProvider.instance.exists(targetPath)).toBeTruthy(); + expect( + (await FsProvider.instance.lstat(targetPath)).isDirectory() + ).toBeTruthy(); + }); + test("SetModifiedTime", async () => { const testFilePath = path.join("Test", "TestFile"); await FsProvider.instance.mkdirs(path.dirname(testFilePath)); diff --git a/test/jest/__tests__/stubs/providers/InMemory.FsProvider.ts b/test/jest/__tests__/stubs/providers/InMemory.FsProvider.ts index 0e96b6e70..48af4a311 100644 --- a/test/jest/__tests__/stubs/providers/InMemory.FsProvider.ts +++ b/test/jest/__tests__/stubs/providers/InMemory.FsProvider.ts @@ -8,6 +8,9 @@ type FileType = {name: string, type: "FILE" | "DIR", nodes: FileType[] | undefin /** * (Poor) dummy implementation of the node FS library intended to be used for realistic tests. * Saves files from having to be written to disk during testing. + * + * Note that for all purposes, the path must contain a folder. + * E.g. writing a file to the root of the file system won't work as expected. */ export default class InMemoryFsProvider extends FsProvider { @@ -166,11 +169,27 @@ export default class InMemoryFsProvider extends FsProvider { } async rename(oldPath: string, newPath: string): Promise { + if ( + !(await this.exists(oldPath)) || + !(await this.exists(path.dirname(newPath))) + ) { + throw new Error(`ENOENT: no such file or directory, rename '${oldPath}' -> '${newPath}'`); + } + + if (path.dirname(oldPath) === "." || path.dirname(newPath) === ".") { + throw new Error("InMemoryFsProvider doesn't support operations on root dir") + } + const copyOf = this.deepCopyDirFileType(oldPath); + + // Remove the target file/dir from the old parent directory. const parent = this.findFileType(path.dirname(oldPath), "DIR"); - const newParent = this.findFileType(path.dirname(newPath), "DIR"); parent.nodes = (parent.nodes || []).filter(value => value.name !== copyOf.name); + + // Rename the file/dir and place it in the new parent directory. + const newParent = this.findFileType(path.dirname(newPath), "DIR"); newParent.nodes = (newParent.nodes || []); + copyOf.name = path.basename(newPath); newParent.nodes.push(copyOf); } From de5c00308375a2ecec3d7b52d8de8025a0d0f053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Tue, 18 Jun 2024 15:49:30 +0300 Subject: [PATCH 12/28] Fix disable/enable for ReturnOfModding mod loader and mods - Changing the state of the mod loader does nothing, which is in line with what BepInEx does, so I guess it's okay - Disabling a mod renames the plugin's files to have .old file extension and enabling a mod undoes this - The change also affects games using InstallRuleInstaller with the SUBDIR_NO_FLATTEN tracking method, making it match the functionality SUBDIR tracking method already had --- .../GenericProfileInstaller.ts | 8 +++-- .../ReturnOfModding.Tests.spec.ts | 36 +++++++++++++++++++ test/jest/__utils__/InstallLogicUtils.ts | 29 +++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts index 5b68a85d7..31f5cec4a 100644 --- a/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts +++ b/src/r2mm/installing/profile_installers/GenericProfileInstaller.ts @@ -21,6 +21,7 @@ import { PackageInstallerId, PackageInstallers } from "../../../installers/regis import { InstallArgs, PackageInstallerV2 } from "../../../installers/PackageInstaller"; import { InstallRuleInstaller } from "../../../installers/InstallRuleInstaller"; import { ShimloaderPluginInstaller } from "../../../installers/ShimloaderInstaller"; +import { ReturnOfModdingPluginInstaller } from "../../../installers/ReturnOfModdingInstaller"; export default class GenericProfileInstaller extends ProfileInstallerProvider { @@ -45,7 +46,10 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { const installerId = GetInstallerIdForPlugin(GameManager.activeGame.packageLoader); if (installerId) { const installer = PackageInstallers[installerId]; - if (installer instanceof ShimloaderPluginInstaller) { + if ( + installer instanceof ShimloaderPluginInstaller || + installer instanceof ReturnOfModdingPluginInstaller + ) { rule = installer.installer.rule; } } @@ -54,7 +58,7 @@ export default class GenericProfileInstaller extends ProfileInstallerProvider { } const subDirPaths = InstallationRules.getAllManagedPaths(rule.rules) - .filter(value => value.trackingMethod === "SUBDIR"); + .filter(value => ["SUBDIR", "SUBDIR_NO_FLATTEN"].includes(value.trackingMethod)); for (const dir of subDirPaths) { if (await FsProvider.instance.exists(path.join(profile.getPathOfProfile(), dir.route))) { diff --git a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts index 307cd3c02..47e36261d 100644 --- a/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts +++ b/test/jest/__tests__/impl/install_logic/ReturnOfModding.Tests.spec.ts @@ -1,8 +1,10 @@ import { + createFilesIntoProfile, createManifest, createPackageFilesIntoCache, expectFilesToBeCopied, expectFilesToBeRemoved, + expectFilesToExistInProfile, installLogicBeforeEach } from '../../../__utils__/InstallLogicUtils'; import R2Error from '../../../../../src/model/errors/R2Error'; @@ -32,6 +34,20 @@ describe('ReturnOfModding Installer Tests', () => { await expectFilesToBeRemoved(sourceToExpectedDestination, expectedAfterUninstall); }); + test('Disabling/enabling the mod loader does nothing', async () => { + const pkg = createManifest("ReturnOfModding", "ReturnOfModding"); + const loaders = ["version.dll", "d3d12.dll"]; + await createFilesIntoProfile(loaders); + + await ProfileInstallerProvider.instance.disableMod(pkg, Profile.getActiveProfile()); + await expectFilesToExistInProfile(loaders); + + pkg.disable(); + + await ProfileInstallerProvider.instance.enableMod(pkg, Profile.getActiveProfile()); + await expectFilesToExistInProfile(loaders); + }); + test('Installs and uninstalls a package', async () => { const pkg = createManifest("HelperFunctions", "Klehrik"); const name = pkg.getName(); @@ -76,4 +92,24 @@ describe('ReturnOfModding Installer Tests', () => { expect(result instanceof R2Error).toBeFalsy(); await expectFilesToBeRemoved(sourceToExpectedDestination, expectedAfterUninstall); }); + + test('Disabling/enabling a mod renames files', async () => { + const pkg = createManifest("HelperFunctions", "Klehrik"); + const name = pkg.getName(); + const files = [ + `ReturnOfModding/plugins/${name}/main.lua`, + `ReturnOfModding/plugins_data/${name}/data.dat`, + `ReturnOfModding/plugins_data/${name}/cache/cache.dat`, + `ReturnOfModding/config/${name}/config.cfg`, + ]; + await createFilesIntoProfile(files); + + await ProfileInstallerProvider.instance.disableMod(pkg, Profile.getActiveProfile()); + await expectFilesToExistInProfile(files.map((fileName) => `${fileName}.old`)); + + pkg.disable(); + + await ProfileInstallerProvider.instance.enableMod(pkg, Profile.getActiveProfile()); + await expectFilesToExistInProfile(files); + }); }); diff --git a/test/jest/__utils__/InstallLogicUtils.ts b/test/jest/__utils__/InstallLogicUtils.ts index 66e542779..36902eb20 100644 --- a/test/jest/__utils__/InstallLogicUtils.ts +++ b/test/jest/__utils__/InstallLogicUtils.ts @@ -146,3 +146,32 @@ export async function expectFilesToBeRemoved( expect(doesExist).toEqual(shouldExist); } } + +export async function createFilesIntoProfile(filePaths: string[]) { + const fs = FsProvider.instance; + const profilePath = Profile.getActiveProfile().getPathOfProfile(); + + for (const filePath of filePaths) { + const destPath = path.join(profilePath, ...filePath.split('/')); + await fs.mkdirs(path.dirname(destPath)); + await fs.writeFile(destPath, ""); + expect(await fs.exists(destPath)); + } +} + +export async function expectFilesToExistInProfile(filePaths: string[]) { + const fs = FsProvider.instance; + const profilePath = Profile.getActiveProfile().getPathOfProfile(); + + for (const filePath of filePaths) { + const fullPath = path.join(profilePath, filePath); + const doesExist = await fs.exists(fullPath); + + if (!doesExist) { + console.log(`Expected ${fullPath} to exist but it DOES NOT! All files:`); + console.log(JSON.stringify(await getTree(profilePath), null, 2)); + } + + expect(doesExist).toBeTruthy(); + } +} From 2769c278240be7c6784ae9d7ff232975649dfa1b Mon Sep 17 00:00:00 2001 From: VilppeRiskidev Date: Thu, 6 Jun 2024 12:29:30 +0300 Subject: [PATCH 13/28] Separate Create Profile modal from Profiles.vue - TODO: clean up old create profile stuff from Profiles.vue, which is easier to do with the refactoring of the Import module as the two were somewhat convoluted. --- .../profiles-modals/CreateProfileModal.vue | 89 +++++++++++++++++++ src/pages/Profiles.vue | 9 +- src/store/modules/ModalsModule.ts | 10 +++ src/store/modules/ProfilesModule.ts | 9 ++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/components/profiles-modals/CreateProfileModal.vue diff --git a/src/components/profiles-modals/CreateProfileModal.vue b/src/components/profiles-modals/CreateProfileModal.vue new file mode 100644 index 000000000..65123383f --- /dev/null +++ b/src/components/profiles-modals/CreateProfileModal.vue @@ -0,0 +1,89 @@ + + diff --git a/src/pages/Profiles.vue b/src/pages/Profiles.vue index b4b44cd17..42928a99b 100644 --- a/src/pages/Profiles.vue +++ b/src/pages/Profiles.vue @@ -1,5 +1,6 @@