feat: support Fabric (#66)

* Initial scaffolding for Fabric.

* refactor: extract common ModStructure

* feat: add FabricModStructure

* refactor: add name field to VersionRepoStructure

* feat: FabricResolver

* feat: support Fabric

* docs: update README

* Small changes.

* Add additional note.

* Upgrade helios-distribution-types.

---------

Co-authored-by: Daniel Scalzi <d_scalzi@yahoo.com>
This commit is contained in:
jebibot
2023-12-04 07:37:14 +09:00
committed by GitHub
parent 8b528959bc
commit 76ab09a010
22 changed files with 564 additions and 181 deletions

View File

@@ -6,14 +6,16 @@ import { URL } from 'url'
import { VersionSegmentedRegistry } from '../../util/VersionSegmentedRegistry.js'
import { ServerMeta, getDefaultServerMeta, ServerMetaOptions, UntrackedFilesOption } from '../../model/nebula/ServerMeta.js'
import { BaseModelStructure } from './BaseModel.struct.js'
import { FabricModStructure } from './module/FabricMod.struct.js'
import { MiscFileStructure } from './module/File.struct.js'
import { LibraryStructure } from './module/Library.struct.js'
import { MinecraftVersion } from '../../util/MinecraftVersion.js'
import { addSchemaToObject, SchemaTypes } from '../../util/SchemaUtil.js'
import { isValidUrl } from '../../util/StringUtils.js'
import { FabricResolver } from '../../resolver/fabric/Fabric.resolver.js'
export interface CreateServerResult {
forgeModContainer?: string
modContainer?: string
libraryContainer: string
miscFileContainer: string
}
@@ -53,6 +55,7 @@ export class ServerStructure extends BaseModelStructure<Server> {
options: {
version?: string
forgeVersion?: string
fabricVersion?: string
}
): Promise<CreateServerResult | null> {
const effectiveId = ServerStructure.getEffectiveId(id, minecraftVersion)
@@ -69,7 +72,7 @@ export class ServerStructure extends BaseModelStructure<Server> {
const serverMetaOpts: ServerMetaOptions = {
version: options.version
}
let forgeModContainer: string | undefined = undefined
let modContainer: string | undefined = undefined
if (options.forgeVersion != null) {
const fms = VersionSegmentedRegistry.getForgeModStruct(
@@ -81,10 +84,23 @@ export class ServerStructure extends BaseModelStructure<Server> {
[]
)
await fms.init()
forgeModContainer = fms.getContainerDirectory()
modContainer = fms.getContainerDirectory()
serverMetaOpts.forgeVersion = options.forgeVersion
}
if (options.fabricVersion != null) {
const fms = new FabricModStructure(
absoluteServerRoot,
relativeServerRoot,
this.baseUrl,
minecraftVersion,
[]
)
await fms.init()
modContainer = fms.getContainerDirectory()
serverMetaOpts.fabricVersion = options.fabricVersion
}
const serverMeta: ServerMeta = addSchemaToObject(
getDefaultServerMeta(id, minecraftVersion.toString(), serverMetaOpts),
SchemaTypes.ServerMetaSchema,
@@ -99,7 +115,7 @@ export class ServerStructure extends BaseModelStructure<Server> {
await mfs.init()
return {
forgeModContainer,
modContainer,
libraryContainer: libS.getContainerDirectory(),
miscFileContainer: mfs.getContainerDirectory()
}
@@ -184,6 +200,27 @@ export class ServerStructure extends BaseModelStructure<Server> {
modules.push(...forgeModModules)
}
if(serverMeta.fabric) {
const fabricResolver = new FabricResolver(dirname(this.containerDirectory), '', this.baseUrl, serverMeta.fabric.version, minecraftVersion)
if (!fabricResolver.isForVersion(minecraftVersion, serverMeta.fabric.version)) {
throw new Error(`Fabric resolver does not support Fabric ${serverMeta.fabric.version}!`)
}
const fabricModule = await fabricResolver.getModule()
modules.push(fabricModule)
const fabricModStruct = new FabricModStructure(
absoluteServerRoot,
relativeServerRoot,
this.baseUrl,
minecraftVersion,
untrackedFiles
)
const fabricModModules = await fabricModStruct.getSpecModel()
modules.push(...fabricModModules)
}
const libraryStruct = new LibraryStructure(absoluteServerRoot, relativeServerRoot, this.baseUrl, minecraftVersion, untrackedFiles)
const libraryModules = await libraryStruct.getSpecModel()
modules.push(...libraryModules)

View File

@@ -0,0 +1,91 @@
import StreamZip from 'node-stream-zip'
import { Type } from 'helios-distribution-types'
import { capitalize } from '../../../util/StringUtils.js'
import { FabricModJson } from '../../../model/fabric/FabricModJson.js'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { BaseModStructure } from './Mod.struct.js'
import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js'
export class FabricModStructure extends BaseModStructure<FabricModJson> {
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
untrackedFiles: UntrackedFilesOption[]
) {
super(absoluteRoot, relativeRoot, 'fabricmods', baseUrl, minecraftVersion, Type.FabricMod, untrackedFiles)
}
public getLoggerName(): string {
return 'FabricModStructure'
}
protected async getModuleId(name: string, path: string): Promise<string> {
const fmData = await this.getModMetadata(name, path)
let group
if (fmData.entrypoints != null) {
for (const t of ['main', 'client', 'server']) {
if (fmData.entrypoints[t] != null && fmData.entrypoints[t].length > 0) {
const entrypoint = fmData.entrypoints[t][0]
group = typeof entrypoint === 'string' ? entrypoint : entrypoint.value
break
}
}
// adapted from https://github.com/dscalzi/Claritas/blob/master/src/main/java/com/dscalzi/claritas/util/DataUtil.java
if (group != null) {
const packageBits = group.split('.')
const blacklist = ['common', 'util', 'internal', 'tweaker', 'tweak', 'client', ...['forge', 'fabric', 'bukkit', 'sponge'].filter(t => t !== fmData.id)]
// Note: Entry point is a fully qualified class name, hence why this adaptation pops immediately (drop class name).
while (packageBits.length > 0) {
packageBits.pop()
const term = packageBits[packageBits.length - 1]
if ((term !== fmData.id && !blacklist.includes(term)) || packageBits.length === 1 || (packageBits.length === 2 && term === fmData.id)) {
break
}
}
group = packageBits.join('.')
}
}
return this.generateMavenIdentifier(group || this.getDefaultGroup(), fmData.id, fmData.version)
}
protected async getModuleName(name: string, path: string): Promise<string> {
const fmData = await this.getModMetadata(name, path)
return capitalize(fmData.name || fmData.id)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected processZip(zip: StreamZip, name: string, path: string): FabricModJson {
let raw: Buffer | undefined
try {
raw = zip.entryDataSync('fabric.mod.json')
} catch(err) {
// ignored
}
if (raw) {
try {
const parsed = JSON.parse(raw.toString()) as FabricModJson
this.modMetadata[name] = parsed
} catch (err) {
this.logger.error(`FabricMod ${name} contains an invalid fabric.mod.json file.`)
}
} else {
this.logger.error(`FabricMod ${name} does not contain fabric.mod.json file.`)
}
const crudeInference = this.attemptCrudeInference(name)
if(this.modMetadata[name] == null) {
this.modMetadata[name] = ({
id: crudeInference.name.toLowerCase(),
name: crudeInference.name,
version: crudeInference.version
})
}
return this.modMetadata[name]!
}
}

View File

@@ -1,15 +1,12 @@
import { Stats } from 'fs'
import { Type, Module } from 'helios-distribution-types'
import { join } from 'path'
import { URL } from 'url'
import { Type } from 'helios-distribution-types'
import { VersionSegmented } from '../../../util/VersionSegmented.js'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { ToggleableModuleStructure } from './ToggleableModule.struct.js'
import { BaseModStructure } from './Mod.struct.js'
import { LibraryType } from '../../../model/claritas/ClaritasLibraryType.js'
import { ClaritasException } from './Module.struct.js'
import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js'
export abstract class BaseForgeModStructure extends ToggleableModuleStructure implements VersionSegmented {
export abstract class BaseForgeModStructure<T> extends BaseModStructure<T> implements VersionSegmented {
protected readonly EXAMPLE_MOD_ID = 'examplemod'
@@ -23,26 +20,8 @@ export abstract class BaseForgeModStructure extends ToggleableModuleStructure im
super(absoluteRoot, relativeRoot, 'forgemods', baseUrl, minecraftVersion, Type.ForgeMod, untrackedFiles)
}
public async getSpecModel(): Promise<Module[]> {
// Sort by file name to allow control of load order.
return (await super.getSpecModel()).sort((a, b) => {
const aFileName = a.artifact.url.substring(a.artifact.url.lastIndexOf('/')+1)
const bFileName = b.artifact.url.substring(b.artifact.url.lastIndexOf('/')+1)
return aFileName.localeCompare(bFileName)
})
}
public abstract isForVersion(version: MinecraftVersion, libraryVersion: string): boolean
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModuleUrl(name: string, path: string, stats: Stats): Promise<string> {
return new URL(join(this.relativeRoot, this.getActiveNamespace(), name), this.baseUrl).toString()
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModulePath(name: string, path: string, stats: Stats): Promise<string | null> {
return null
}
protected getClaritasExceptions(): ClaritasException[] {
return [{
exceptionName: 'optifine',

View File

@@ -0,0 +1,66 @@
import { Stats } from 'fs'
import { Module } from 'helios-distribution-types'
import StreamZip from 'node-stream-zip'
import { join } from 'path'
import { URL } from 'url'
import { ToggleableModuleStructure } from './ToggleableModule.struct.js'
export abstract class BaseModStructure<T> extends ToggleableModuleStructure {
protected modMetadata: {[property: string]: T | undefined} = {}
public async getSpecModel(): Promise<Module[]> {
// Sort by file name to allow control of load order.
return (await super.getSpecModel()).sort((a, b) => {
const aFileName = a.artifact.url.substring(a.artifact.url.lastIndexOf('/')+1)
const bFileName = b.artifact.url.substring(b.artifact.url.lastIndexOf('/')+1)
return aFileName.localeCompare(bFileName)
})
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModuleUrl(name: string, path: string, stats: Stats): Promise<string> {
return new URL(join(this.relativeRoot, this.getActiveNamespace(), name), this.baseUrl).toString()
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModulePath(name: string, path: string, stats: Stats): Promise<string | null> {
return null
}
protected getModMetadata(name: string, path: string): Promise<T> {
return new Promise((resolve, reject) => {
if (!Object.prototype.hasOwnProperty.call(this.modMetadata, name)) {
const zip = new StreamZip({
file: path,
storeEntries: true
})
zip.on('error', err => {
this.logger.error(`Failure while processing ${path}`)
reject(err)
})
zip.on('ready', () => {
try {
const res = this.processZip(zip, name, path)
zip.close()
resolve(res)
return
} catch(err) {
zip.close()
reject(err)
return
}
})
} else {
resolve(this.modMetadata[name]!)
return
}
})
}
protected abstract processZip(zip: StreamZip, name: string, path: string): T
}

View File

@@ -7,7 +7,7 @@ import { BaseForgeModStructure } from '../ForgeMod.struct.js'
import { MinecraftVersion } from '../../../../util/MinecraftVersion.js'
import { UntrackedFilesOption } from '../../../../model/nebula/ServerMeta.js'
export class ForgeModStructure113 extends BaseForgeModStructure {
export class ForgeModStructure113 extends BaseForgeModStructure<ModsToml> {
public static readonly IMPLEMENTATION_VERSION_REGEX = /^Implementation-Version: (.+)[\r\n]/
@@ -16,8 +16,6 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
return VersionUtil.isVersionAcceptable(version, [13, 14, 15, 16, 17, 18, 19, 20])
}
private forgeModMetadata: {[property: string]: ModsToml | undefined} = {}
constructor(
absoluteRoot: string,
relativeRoot: string,
@@ -37,48 +35,14 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
}
protected async getModuleId(name: string, path: string): Promise<string> {
const fmData = await this.getForgeModMetadata(name, path)
const fmData = await this.getModMetadata(name, path)
return this.generateMavenIdentifier(this.getClaritasGroup(path), fmData.mods[0].modId, fmData.mods[0].version)
}
protected async getModuleName(name: string, path: string): Promise<string> {
return capitalize((await this.getForgeModMetadata(name, path)).mods[0].displayName)
return capitalize((await this.getModMetadata(name, path)).mods[0].displayName)
}
private getForgeModMetadata(name: string, path: string): Promise<ModsToml> {
return new Promise((resolve, reject) => {
if (!Object.prototype.hasOwnProperty.call(this.forgeModMetadata, name)) {
const zip = new StreamZip({
file: path,
storeEntries: true
})
zip.on('error', err => {
this.logger.error(`Failure while processing ${path}`)
reject(err)
})
zip.on('ready', () => {
try {
const res = this.processZip(zip, name, path)
zip.close()
resolve(res)
return
} catch(err) {
zip.close()
reject(err)
return
}
})
} else {
resolve(this.forgeModMetadata[name]!)
return
}
})
}
private processZip(zip: StreamZip, name: string, path: string): ModsToml {
protected processZip(zip: StreamZip, name: string, path: string): ModsToml {
// Optifine is a tweak that can be loaded as a forge mod. It does not
// appear to contain a mcmod.info class. This a special case we will
@@ -96,7 +60,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
const info = changelogBuf.toString().split('\n')[0].trim()
const version = info.split(' ')[1]
this.forgeModMetadata[name] = ({
this.modMetadata[name] = ({
modLoader: 'javafml',
loaderVersion: '',
mods: [{
@@ -108,7 +72,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
}]
})
return this.forgeModMetadata[name]!
return this.modMetadata[name]!
}
let raw: Buffer | undefined
@@ -121,7 +85,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
if (raw) {
try {
const parsed = toml.parse(raw.toString()) as ModsToml
this.forgeModMetadata[name] = parsed
this.modMetadata[name] = parsed
} catch (err) {
this.logger.error(`ForgeMod ${name} contains an invalid mods.toml file.`)
}
@@ -133,16 +97,16 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
if(cRes == null) {
this.logger.error(`Claritas failed to yield metadata for ForgeMod ${name}!`)
this.logger.error('Is this mod malformated or does Claritas need an update?')
this.logger.error('Is this mod malformatted or does Claritas need an update?')
}
const claritasId = cRes?.id
const crudeInference = this.attemptCrudeInference(name)
if(this.forgeModMetadata[name] != null) {
if(this.modMetadata[name] != null) {
const x = this.forgeModMetadata[name]!
const x = this.modMetadata[name]!
for(const entry of x.mods) {
if(entry.modId === this.EXAMPLE_MOD_ID) {
@@ -171,7 +135,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
}
} else {
this.forgeModMetadata[name] = ({
this.modMetadata[name] = ({
modLoader: 'javafml',
loaderVersion: '',
mods: [{
@@ -183,7 +147,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure {
})
}
return this.forgeModMetadata[name]!
return this.modMetadata[name]!
}
}

View File

@@ -8,15 +8,13 @@ import { MinecraftVersion } from '../../../../util/MinecraftVersion.js'
import { ForgeModType_1_7 } from '../../../../model/claritas/ClaritasResult.js'
import { UntrackedFilesOption } from '../../../../model/nebula/ServerMeta.js'
export class ForgeModStructure17 extends BaseForgeModStructure {
export class ForgeModStructure17 extends BaseForgeModStructure<McModInfo> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public static isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
return VersionUtil.isVersionAcceptable(version, [7, 8, 9, 10, 11, 12])
}
private forgeModMetadata: {[property: string]: McModInfo | undefined} = {}
constructor(
absoluteRoot: string,
relativeRoot: string,
@@ -36,45 +34,11 @@ export class ForgeModStructure17 extends BaseForgeModStructure {
}
protected async getModuleId(name: string, path: string): Promise<string> {
const fmData = await this.getForgeModMetadata(name, path)
const fmData = await this.getModMetadata(name, path)
return this.generateMavenIdentifier(this.getClaritasGroup(path), fmData.modid, fmData.version)
}
protected async getModuleName(name: string, path: string): Promise<string> {
return capitalize((await this.getForgeModMetadata(name, path)).name)
}
private getForgeModMetadata(name: string, path: string): Promise<McModInfo> {
return new Promise((resolve, reject) => {
if (!Object.prototype.hasOwnProperty.call(this.forgeModMetadata, name)) {
const zip = new StreamZip({
file: path,
storeEntries: true
})
zip.on('error', err => {
this.logger.error(`Failure while processing ${path}`)
reject(err)
})
zip.on('ready', () => {
try {
const res = this.processZip(zip, name, path)
zip.close()
resolve(res)
return
} catch(err) {
zip.close()
reject(err)
return
}
})
} else {
resolve(this.forgeModMetadata[name]!)
return
}
})
return capitalize((await this.getModMetadata(name, path)).name)
}
private isMalformedVersion(version: string): boolean {
@@ -82,7 +46,7 @@ export class ForgeModStructure17 extends BaseForgeModStructure {
return version.trim().length === 0 || version.indexOf('@') > -1 || version.indexOf('$') > -1
}
private processZip(zip: StreamZip, name: string, path: string): McModInfo {
protected processZip(zip: StreamZip, name: string, path: string): McModInfo {
// Optifine is a tweak that can be loaded as a forge mod. It does not
// appear to contain a mcmod.info class. This a special case we will
// account for.
@@ -98,13 +62,13 @@ export class ForgeModStructure17 extends BaseForgeModStructure {
const info = changelogBuf.toString().split('\n')[0].trim()
const version = info.split(' ')[1]
this.forgeModMetadata[name] = ({
this.modMetadata[name] = ({
modid: 'optifine',
name: info,
version,
mcversion: version.substring(0, version.indexOf('_'))
}) as McModInfo
return this.forgeModMetadata[name]!
return this.modMetadata[name]!
}
let raw: Buffer | undefined
@@ -120,9 +84,9 @@ export class ForgeModStructure17 extends BaseForgeModStructure {
const resolved = JSON.parse(raw.toString()) as (McModInfoList | McModInfo[])
if (Object.prototype.hasOwnProperty.call(resolved, 'modListVersion')) {
this.forgeModMetadata[name] = (resolved as McModInfoList).modList[0]
this.modMetadata[name] = (resolved as McModInfoList).modList[0]
} else {
this.forgeModMetadata[name] = (resolved as McModInfo[])[0]
this.modMetadata[name] = (resolved as McModInfo[])[0]
}
} catch (err) {
@@ -158,16 +122,16 @@ export class ForgeModStructure17 extends BaseForgeModStructure {
// Validate
const crudeInference = this.attemptCrudeInference(name)
if(this.forgeModMetadata[name] != null) {
if(this.modMetadata[name] != null) {
const x = this.forgeModMetadata[name]!
const x = this.modMetadata[name]!
if(x.modid == null || x.modid === '' || x.modid === this.EXAMPLE_MOD_ID) {
x.modid = this.discernResult(claritasId, crudeInference.name.toLowerCase())
x.name = this.discernResult(claritasName, crudeInference.name)
}
if(this.forgeModMetadata[name]!.version != null) {
const isMalformedVersion = this.isMalformedVersion(this.forgeModMetadata[name]!.version)
if(this.modMetadata[name]!.version != null) {
const isMalformedVersion = this.isMalformedVersion(this.modMetadata[name]!.version)
if(isMalformedVersion) {
x.version = this.discernResult(claritasVersion, crudeInference.version)
}
@@ -177,14 +141,14 @@ export class ForgeModStructure17 extends BaseForgeModStructure {
} else {
this.forgeModMetadata[name] = ({
this.modMetadata[name] = ({
modid: this.discernResult(claritasId, crudeInference.name.toLowerCase()),
name: this.discernResult(claritasName, crudeInference.name),
version: this.discernResult(claritasVersion, crudeInference.version)
}) as McModInfo
}
return this.forgeModMetadata[name]!
return this.modMetadata[name]!
}
}