-
Notifications
You must be signed in to change notification settings - Fork 209
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1193 from thunderstore-io/explicit-installers
Explicit installers
- Loading branch information
Showing
11 changed files
with
441 additions
and
320 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.