Skip to content

Commit

Permalink
Merge pull request #1193 from thunderstore-io/explicit-installers
Browse files Browse the repository at this point in the history
Explicit installers
  • Loading branch information
MythicManiac authored Feb 7, 2024
2 parents 664f3ce + cf001b1 commit eedd1e1
Show file tree
Hide file tree
Showing 11 changed files with 441 additions and 320 deletions.
42 changes: 42 additions & 0 deletions src/installers/BepInExInstaller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { InstallArgs, PackageInstaller } from "./PackageInstaller";
import path from "path";
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"];

export class BepInExInstaller extends PackageInstaller {
/**
* Handles installation of BepInEx
*/
async install(args: InstallArgs) {
const {
mod,
packagePath,
profile,
} = args;

const mapping = MODLOADER_PACKAGES.find((entry) =>
entry.packageName.toLowerCase() == mod.getName().toLowerCase() &&
entry.loaderType == PackageLoader.BEPINEX,
);
const mappingRoot = mapping ? mapping.rootFolder : "";

let bepInExRoot: string;
if (mappingRoot.trim().length > 0) {
bepInExRoot = path.join(packagePath, mappingRoot);
} else {
bepInExRoot = path.join(packagePath);
}
for (const item of (await FsProvider.instance.readdir(bepInExRoot))) {
if (!basePackageFiles.includes(item.toLowerCase())) {
if ((await FsProvider.instance.stat(path.join(bepInExRoot, item))).isFile()) {
await FsProvider.instance.copyFile(path.join(bepInExRoot, item), path.join(profile.getPathOfProfile(), item));
} else {
await FsProvider.instance.copyFolder(path.join(bepInExRoot, item), path.join(profile.getPathOfProfile(), item));
}
}
}
}
}
23 changes: 23 additions & 0 deletions src/installers/GodotMLInstaller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { InstallArgs, PackageInstaller } from "./PackageInstaller";
import path from "path";
import FsProvider from "../providers/generic/file/FsProvider";

export class GodotMLInstaller extends PackageInstaller {
/**
* Handles installation of GodotML
*/
async install(args: InstallArgs) {
const { packagePath, profile } = args;

const copyFrom = path.join(packagePath, "addons", "mod_loader");
const copyTo = path.join(profile.getPathOfProfile(), "addons", "mod_loader");
const fs = FsProvider.instance;

if (await fs.exists(copyFrom)) {
if (!await fs.exists(copyTo)) {
await fs.mkdirs(copyTo);
}
await fs.copyFolder(copyFrom, copyTo);
}
}
}
273 changes: 273 additions & 0 deletions src/installers/InstallRuleInstaller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { InstallArgs, PackageInstaller } from "./PackageInstaller";
import Profile from "../model/Profile";
import FsProvider from "../providers/generic/file/FsProvider";
import path from "path";
import ManifestV2 from "../model/ManifestV2";
import R2Error from "../model/errors/R2Error";
import FileTree from "../model/file/FileTree";
import InstallationRules, { CoreRuleType, ManagedRule, RuleSubtype } from "../r2mm/installing/InstallationRules";
import FileUtils from "../utils/FileUtils";
import yaml from "yaml";
import ModFileTracker from "../model/installing/ModFileTracker";
import ConflictManagementProvider from "../providers/generic/installing/ConflictManagementProvider";
import PathResolver from "../r2mm/manager/PathResolver";
import ZipProvider from "../providers/generic/zip/ZipProvider";

const basePackageFiles = ["manifest.json", "readme.md", "icon.png"];


type InstallRuleArgs = {
profile: Profile,
coreRule: CoreRuleType,
rule: ManagedRule,
installSources: string[],
mod: ManifestV2,
};


async function installUntracked(profile: Profile, rule: ManagedRule, installSources: string[], mod: ManifestV2) {
// Functionally identical to the install method of subdir, minus the subdirectory.
const ruleDir = path.join(profile.getPathOfProfile(), rule.route);
await FileUtils.ensureDirectory(ruleDir);
for (const source of installSources) {
if ((await FsProvider.instance.lstat(source)).isFile()) {
await FsProvider.instance.copyFile(source, path.join(ruleDir, path.basename(source)));
} else {
for (const content of (await FsProvider.instance.readdir(source))) {
if ((await FsProvider.instance.lstat(path.join(source, content))).isFile()) {
await FsProvider.instance.copyFile(path.join(source, content), path.join(ruleDir, content));
} else {
await FsProvider.instance.copyFolder(path.join(source, content), path.join(ruleDir, content));
}
}
}
}
}


async function installSubDir(
profile: Profile,
rule: ManagedRule,
installSources: string[],
mod: ManifestV2,
) {
const subDir = path.join(profile.getPathOfProfile(), rule.route, mod.getName());
await FileUtils.ensureDirectory(subDir);
for (const source of installSources) {
if ((await FsProvider.instance.lstat(source)).isFile()) {
const dest = path.join(subDir, path.basename(source));
await FsProvider.instance.copyFile(source, dest);
} else {
for (const content of (await FsProvider.instance.readdir(source))) {
const cacheContentLocation = path.join(source, content);
const contentDest = path.join(subDir, content);
if ((await FsProvider.instance.lstat(cacheContentLocation)).isFile()) {
await FsProvider.instance.copyFile(cacheContentLocation, contentDest);
} else {
await FsProvider.instance.copyFolder(cacheContentLocation, contentDest);
}
}
}
}
}


async function installPackageZip(profile: Profile, rule: ManagedRule, installSources: string[], mod: ManifestV2) {
/*
This install method repackages the entire mod as-is and places it to the
destination route. Essentially the same as SUBDIR_NO_FLATTEN, but as a
zip instead of a directory. The zip name will be the mod ID.
*/
const destDir = path.join(profile.getPathOfProfile(), rule.route);
await FileUtils.ensureDirectory(destDir);
const destination = path.join(destDir, `${mod.getName()}.ts.zip`);
const cacheDirectory = path.join(PathResolver.MOD_ROOT, 'cache');
const cachedLocationOfMod: string = path.join(cacheDirectory, mod.getName(), mod.getVersionNumber().toString());
const builder = ZipProvider.instance.zipBuilder();
await builder.addFolder("", cachedLocationOfMod);
await builder.createZip(destination);
}


async function installSubDirNoFlatten(profile: Profile, rule: ManagedRule, installSources: string[], mod: ManifestV2) {
const subDir = path.join(profile.getPathOfProfile(), rule.route, mod.getName());
await FileUtils.ensureDirectory(subDir);
const cacheDirectory = path.join(PathResolver.MOD_ROOT, 'cache');
const cachedLocationOfMod: string = path.join(cacheDirectory, mod.getName(), mod.getVersionNumber().toString());
for (const source of installSources) {
const relativePath = path.relative(cachedLocationOfMod, source);
if ((await FsProvider.instance.lstat(source)).isFile()) {
const dest = path.join(subDir, relativePath);
await FileUtils.ensureDirectory(path.dirname(dest));
await FsProvider.instance.copyFile(source, dest);
} else {
for (const content of (await FsProvider.instance.readdir(source))) {
const cacheContentLocation = path.join(source, content);
const contentDest = path.join(subDir, content);
await FileUtils.ensureDirectory(path.dirname(contentDest));
if ((await FsProvider.instance.lstat(cacheContentLocation)).isFile()) {
await FsProvider.instance.copyFile(cacheContentLocation, contentDest);
} else {
await FsProvider.instance.copyFolder(cacheContentLocation, contentDest);
}
}
}
}
}

async function buildInstallForRuleSubtype(
rule: CoreRuleType,
location: string,
folderName: string,
mod: ManifestV2,
tree: FileTree
): Promise<Map<RuleSubtype, string[]>> {
const flatRules = InstallationRules.getAllManagedPaths(rule.rules);
const installationIntent = new Map<RuleSubtype, string[]>();
for (const file of tree.getFiles()) {
// Find matching rule for file based on extension name.
// If a matching extension name is longer (EG: .plugin.dll vs .dll) then assume the longer one is the correct match.
let matchingRule: ManagedRule | undefined;
try {
matchingRule = flatRules.filter(value => value.extensions.find(ext => file.toLowerCase().endsWith(ext.toLowerCase())))
.reduce((previousValue, currentValue) => {
const extA = previousValue.extensions.find(ext => file.toLowerCase().endsWith(ext.toLowerCase()));
const extB = currentValue.extensions.find(ext => file.toLowerCase().endsWith(ext.toLowerCase()));
if (extA!.length > extB!.length) {
return previousValue;
}
return currentValue;
});
} catch (e) {
// No matching rule
matchingRule = flatRules.find(value => value.isDefaultLocation)!;
}
if (matchingRule === undefined) {
continue;
}
const subType = InstallationRules.getRuleSubtypeFromManagedRule(matchingRule, rule);
const updatedArray = installationIntent.get(subType) || [];
updatedArray.push(file);
installationIntent.set(subType, updatedArray);
}
for (const file of tree.getDirectories()) {
// Only expect one (for now).
// If multiple then will need to implement a way to reverse search folder path.
let matchingRule: ManagedRule | undefined = flatRules.find(value => path.basename(value.route).toLowerCase() === file.getDirectoryName().toLowerCase());
if (matchingRule === undefined) {
const nested = await buildInstallForRuleSubtype(rule, path.join(location, file.getDirectoryName()), folderName, mod, file);
for (let [rule, files] of nested.entries()) {
const arr = installationIntent.get(rule) || [];
arr.push(...files);
installationIntent.set(rule, arr);
}
} else {
const subType = InstallationRules.getRuleSubtypeFromManagedRule(matchingRule, rule);
const arr = installationIntent.get(subType) || [];
arr.push(file.getTarget());
installationIntent.set(subType, arr);
}
}
return installationIntent;
}


export async function addToStateFile(mod: ManifestV2, files: Map<string, string>, profile: Profile) {
await FileUtils.ensureDirectory(path.join(profile.getPathOfProfile(), "_state"));
let existing: Map<string, string> = new Map();
if (await FsProvider.instance.exists(path.join(profile.getPathOfProfile(), "_state", `${mod.getName()}-state.yml`))) {
const read = await FsProvider.instance.readFile(path.join(profile.getPathOfProfile(), "_state", `${mod.getName()}-state.yml`));
const tracker = (yaml.parse(read.toString()) as ModFileTracker);
existing = new Map(tracker.files);
}
files.forEach((value, key) => {
existing.set(key, value);
})
const mft: ModFileTracker = {
modName: mod.getName(),
files: Array.from(existing.entries())
}
await FsProvider.instance.writeFile(path.join(profile.getPathOfProfile(), "_state", `${mod.getName()}-state.yml`), yaml.stringify(mft));
await ConflictManagementProvider.instance.overrideInstalledState(mod, profile);
}

async function installState(args: InstallRuleArgs) {
const { profile, coreRule, rule, installSources, mod } = args;
const fileRelocations = new Map<string, string>();
for (const source of installSources) {
if (!(coreRule.relativeFileExclusions || []).find(value => value.toLowerCase() === path.basename(source.toLowerCase()))) {
if ((await FsProvider.instance.lstat(source)).isFile()) {
fileRelocations.set(source, path.join(rule.route, path.basename(source)));
} else {
const tree = await FileTree.buildFromLocation(source);
if (tree instanceof R2Error) {
throw tree;
}
for (const subFile of tree.getRecursiveFiles()) {
if (!(coreRule.relativeFileExclusions || []).find(value => value.toLowerCase() === path.relative(source, subFile))) {
fileRelocations.set(subFile, path.join(rule.route, path.relative(source, subFile)));
}
}
}
}
}
for (let [source, relative] of fileRelocations.entries()) {
await FileUtils.ensureDirectory(path.join(profile.getPathOfProfile(), path.dirname(relative)));
await FsProvider.instance.copyFile(source, path.join(profile.getPathOfProfile(), relative));
}
await addToStateFile(mod, fileRelocations, profile);
}

export class InstallRuleInstaller extends PackageInstaller {
private readonly rule: CoreRuleType;

constructor(rules: CoreRuleType) {
super();
this.rule = rules;
}

/**
* Handles installation of packages according to the install rules defined
* for it.
*/
async install(args: InstallArgs) {
const { mod, profile, packagePath } = args;
const files: FileTree | R2Error = await FileTree.buildFromLocation(packagePath);
if (files instanceof R2Error) {
throw files;
}
const result = await this.resolveBepInExTree(
profile,
packagePath,
path.basename(packagePath),
mod,
files,
);
if (result instanceof R2Error) {
throw result;
}
}

async resolveBepInExTree(profile: Profile, location: string, folderName: string, mod: ManifestV2, tree: FileTree): Promise<R2Error | void> {
const installationIntent = await buildInstallForRuleSubtype(this.rule, location, folderName, mod, tree);
for (let [rule, files] of installationIntent.entries()) {
const managedRule = InstallationRules.getManagedRuleForSubtype(this.rule, rule);

const args: InstallRuleArgs = {
profile,
coreRule: this.rule,
rule: managedRule,
installSources: files,
mod,
}
switch (rule.trackingMethod) {
case 'STATE': await installState(args); break;
case 'SUBDIR': await installSubDir(profile, managedRule, files, mod); break;
case 'NONE': await installUntracked(profile, managedRule, files, mod); break;
case 'SUBDIR_NO_FLATTEN': await installSubDirNoFlatten(profile, managedRule, files, mod); break;
case 'PACKAGE_ZIP': await installPackageZip(profile, managedRule, files, mod); break;
}
}
return Promise.resolve(undefined);
}
}
24 changes: 24 additions & 0 deletions src/installers/MelonLoaderInstaller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { InstallArgs, PackageInstaller } from "./PackageInstaller";
import FsProvider from "../providers/generic/file/FsProvider";
import path from "path";

const basePackageFiles = ["manifest.json", "readme.md", "icon.png"];

export class MelonLoaderInstaller extends PackageInstaller {
/**
* Handles installation of MelonLoader
*/
async install(args: InstallArgs) {
const { packagePath, profile } = args;

for (const item of (await FsProvider.instance.readdir(packagePath))) {
if (!basePackageFiles.includes(item.toLowerCase())) {
if ((await FsProvider.instance.stat(path.join(packagePath, item))).isFile()) {
await FsProvider.instance.copyFile(path.join(packagePath, item), path.join(profile.getPathOfProfile(), item));
} else {
await FsProvider.instance.copyFolder(path.join(packagePath, item), path.join(profile.getPathOfProfile(), item));
}
}
}
}
}
15 changes: 15 additions & 0 deletions src/installers/PackageInstaller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Profile from "../model/Profile";
import ManifestV2 from "../model/ManifestV2";


export type InstallArgs = {
mod: ManifestV2;
profile: Profile;
packagePath: string;
};


export abstract class PackageInstaller {
abstract install(args: InstallArgs): Promise<void>;
// abstract uninstall(): Promise<void>; // TODO: Implement
}
16 changes: 16 additions & 0 deletions src/installers/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { BepInExInstaller } from "./BepInExInstaller";
import { GodotMLInstaller } from "./GodotMLInstaller";
import { MelonLoaderInstaller } from "./MelonLoaderInstaller";
import { PackageInstaller } from "./PackageInstaller";
import { InstallRuleInstaller } from "./InstallRuleInstaller";


const _PackageInstallers = {
// "legacy": new InstallRuleInstaller(), // TODO: Enable
"bepinex": new BepInExInstaller(),
"godotml": new GodotMLInstaller(),
"melonloader": new MelonLoaderInstaller(),
}

export type PackageInstallerId = keyof typeof _PackageInstallers;
export const PackageInstallers: {[key in PackageInstallerId]: PackageInstaller} = _PackageInstallers;
Loading

0 comments on commit eedd1e1

Please sign in to comment.