import { open , openEntryReadStream , walkEntriesGenerator } from "@xmcl/unzip" ; import { ChildProcess , spawn , SpawnOptions } from "child_process" ; import { EventEmitter } from "events" ; import { createWriteStream , existsSync } from "fs" ; import { EOL } from "os" ; import { delimiter , dirname , isAbsolute , join , resolve } from "path" ; import { pipeline } from "stream" ; import { promisify } from "util" ; import { v4 } from "uuid" ; import { MinecraftFolder } from "./folder" ; import { getPlatform , Platform } from "./platform" ; import { checksum , link , mkdir , readFile , validateSha1 , writeFile } from "./utils" ; import { ResolvedLibrary , ResolvedNative , ResolvedVersion , Version } from "./version" ; function format ( template , args ) { return template . replace ( /\$\{(.*?)}/g , ( key ) => { const value = args [ key . substring ( 2 ) . substring ( 0 , key . length - 3 ) ] ; return value ? value : key ; } ) ; } export const DEFAULT_EXTRA_JVM_ARGS = Object . freeze ( [ "-Xmx2G" , "-XX:+UnlockExperimentalVMOptions" , "-XX:+UseG1GC" , "-XX:G1NewSizePercent=20" , "-XX:G1ReservePercent=20" , "-XX:MaxGCPauseMillis=50" , "-XX:G1HeapRegionSize=32M" ] ) ; export interface EnabledFeatures { [ featureName ] : object | boolean | undefined ; has_custom_resolution ? : { resolution_width : string ; resolution_height : string ; } ; is_demo_user ? : boolean ; } see generateArguments see launch export interface LaunchOption { /**
     * User selected game profile. For game display name &
     */ gameProfile ? : { name : string ; id : string ; } ; accessToken ? : string ; userType ? : "mojang" | "legacy" ; properties ? : object ; launcherName ? : string ; launcherBrand ? : string ; /**
     * Overwrite the version name of the current version.
     * If this is absent, it will use version name from resolved version.
     */ versionName ? : string ; /**
     * Overwrite the version type of the current version.
     * If this is absent, it will use version type from resolved version.
     *
     * Some people use this to show fantastic message on the welcome screen.
     */ versionType ? : string ; /**
     * The full path of launched game icon
     * Currently, this only supported on MacOS
     */ gameIcon ? : string ; /**
     * The launched game name
     * Currently, this only supported on MacOS
     */ gameName ? : string ; /**
     * The path of parent directory of saves/logs/configs/mods/resourcepacks
     */ gamePath : string ; /**
     * The path of parent directory of assets/libraries
     */ resourcePath ? : string ; /**
     * The java executable file path. (Not the java home direcotry!)
     */ javaPath : string ; /**
     * Min memory, this will add a jvm flag -Xms to the command result
     */ minMemory ? : number ; /**
     * Min memory, this will add a jvm flag -Xmx to the command result
     */ maxMemory ? : number ; /**
     * The version of launched Minecraft. Can be either resolved version or version string
     */ version : string | ResolvedVersion ; /**
     * Directly launch to a server
     */ server ? : { ip : string ; port ? : number ; } ; /**
     * Resolution. This will add --height & --width or --fullscreen to the java arguments
     */ resolution ? : { width ? : number ; height ? : number ; fullscreen ? : true ; } ; /**
     * Extra jvm options. This will append after to generated options.
     * If this is empty, the `DEFAULT_EXTRA_JVM_ARGS` will be used.
     */ extraJVMArgs ? : string [ ] ; /**
     * Extra program arguments. This will append after to generated options.
     */ extraMCArgs ? : string [ ] ; /**
     * Assign the spawn options to the process.
     *
     * If you try to set `{ shell: true }`, you might want to make all argument rounded with "".
     * The `launch` function will do it for you, but if you want to spawn process by yourself, remember to do that.
     */ extraExecOption ? : SpawnOptions ; isDemo ? : boolean ; /**
     * Native directory. It's .minecraft/versions/<version>/<version>-natives by default.
     * You can replace this by your self.
     */ nativeRoot ? : string ; /**
     * Enable features. Not really in used...
     */ features ? : EnabledFeatures ; /**
     * Support yushi's yggdrasil agent https://github.com/to2mbn/authlib-injector/wiki
     */ yggdrasilAgent ? : { /**
         * The jar file path of the authlib-injector
         */ jar : string ; /**
         * The auth server host
         */ server : string ; /**
         * The prefetched base64
         */ prefetched ? : string ; } ; /**
     * Add `-Dfml.ignoreInvalidMinecraftCertificates=true` to jvm argument
     */ ignoreInvalidMinecraftCertificates ? : boolean ; /**
     * Add `-Dfml.ignorePatchDiscrepancies=true` to jvm argument
     */ ignorePatchDiscrepancies ? : boolean ; /**
     * Add extra classpaths
     */ extraClassPaths ? : string [ ] ; /**
     * The platform of this launch will run. By default, it will fetch the current machine info if this is absent.
     */ platform ? : Platform ; see launch see generateArguments prechecks ? : LaunchPrecheck [ ] ; } see launch export interface LaunchPrecheck { ( resourcePath , version , option ) : Promise < void > ; } see LaunchPrecheck . checkVersion export interface CorruptedVersionJarError { error : "CorruptedVersionJar" ; version : string ; } see LaunchPrecheck . checkLibraries export interface MissingLibrariesError { error : "MissingLibraries" ; libraries : ResolvedLibrary [ ] ; version : ResolvedVersion ; } export namespace LaunchPrecheck { /**
     * The default launch precheck. It will check version jar, libraries and natives.
     */ export const DEFAULT_PRECHECKS = Object . freeze ( [ checkVersion , checkLibraries , checkNatives , linkAssets ] ) ; deprecated export const Default = LaunchPrecheck . DEFAULT_PRECHECKS ; /**
     * Link assets to the assets/virtual/legacy.
     */ export async function linkAssets ( resource , version , option ) { if ( version . assets !== "legacy" ) { return ; } let assetsIndexPath = resource . getAssetsIndex ( version . assets ) ; let buf = await readFile ( assetsIndexPath ) ; let assetsIndex = JSON . parse ( buf . toString ( ) ) ; let virtualPath = resource . getPath ( "assets/virtual/" + version . assets ) ; await mkdir ( virtualPath , { recursive : true } ) . catch ( ( ) => { } ) ; let dirs = Object . keys ( assetsIndex . objects ) . map ( ( path ) => dirname ( join ( virtualPath , path ) ) ) . reduce ( ( a , b ) => a . add ( b ) , new Set < string > ( ) ) ; await Promise . all ( [ ... dirs ] . map ( ( dir ) => mkdir ( dir , { recursive : true } ) ) ) ; for ( let [ path , { hash } ] of Object . entries ( assetsIndex . objects ) ) { let assetPath = resource . getAsset ( hash ) ; let targetPath = join ( virtualPath , path ) ; await link ( assetPath , targetPath ) . catch ( ( e ) => { if ( e . code !== "EEXIST" ) { throw e ; } } ) ; } } throws CorruptedVersionJarError export async function checkVersion ( resource , version , option ) { const jarPath = resource . getVersionJar ( version . minecraftVersion ) ; if ( ! await validateSha1 ( jarPath , version . downloads . client . sha1 ) ) { throw Object . assign ( new Error ( `Corrupted Version jar ${ jarPath }. Either the file not reachable or the file sha1 not matched!` ) , { error : "CorruptedVersionJar" , version : version . minecraftVersion , } as CorruptedVersionJarError ) ; } } ; throws MissingLibrariesError export async function checkLibraries ( resource , version , option ) { const validMask = await Promise . all ( version . libraries . map ( ( lib ) => validateSha1 ( resource . getLibraryByPath ( lib . download . path ) , lib . download . sha1 ) ) ) ; const corruptedLibs = version . libraries . filter ( ( _ , index ) => ! validMask [ index ] ) ; if ( corruptedLibs . length > 0 ) { throw Object . assign ( new Error ( `Missing ${ corruptedLibs . length } libraries! Either the file not reachable or the file sha1 not matched!` ) , { error : "MissingLibraries" , libraries : corruptedLibs , version , } as MissingLibrariesError ) ; } } ; /**
     * Ensure the native are correctly extracted in place.
     *
     * It will check native root located in  LaunchOption . nativeRoot if it's presented.
     * Or, it will use the `<version-id>-native` under version folder as native root to check.
     *
     * This will automatically extract native if there is not native extracted.
     *
     *  param resource param option export async function checkNatives ( resource , version , option ) { const native = option . nativeRoot || resource . getNativesRoot ( version . id ) ; await mkdir ( native , { recursive : true } ) . catch ( ( e ) => { if ( e . code !== "EEXIST" ) { throw e ; } } ) ; const natives = version . libraries . filter ( ( lib ) => lib instanceof ResolvedNative ) as ResolvedNative [ ] ; const checksumFile = join ( native , ".json" ) ; const includedLibs = natives . map ( ( n ) => n . name ) . sort ( ) ; interface ChecksumFile { entries : CheckEntry [ ] ; libraries : string [ ] ; } interface CheckEntry { file : string ; sha1 : string ; name : string ; } const checksumFileObject = await readFile ( checksumFile , "utf-8" ) . then ( JSON . parse ) . catch ( ( e ) => undefined ) ; let shaEntries ; if ( checksumFileObject && checksumFileObject . libraries ) { if ( checksumFileObject . libraries . sort ( ) . every ( ( v , i ) => v === includedLibs [ i ] ) ) { shaEntries = checksumFileObject . entries ; } } const extractedNatives = [ ] ; async function extractJar ( n ) { if ( ! n ) { return ; } const excluded = n . extractExclude || [ ] ; const containsExcludes = ( p ) => excluded . filter ( ( s ) => p . startsWith ( s ) ) . length === 0 ; const notInMetaInf = ( p ) => p . indexOf ( "META-INF/" ) === - 1 ; const notSha1AndNotGit = ( p ) => ! ( p . endsWith ( ".sha1" ) || p . endsWith ( ".git" ) ) ; const from = resource . getLibraryByPath ( n . download . path ) ; const promises = [ ] ; const zip = await open ( from , { lazyEntries : true , autoClose : false } ) ; for await ( const entry of walkEntriesGenerator ( zip ) ) { if ( containsExcludes ( entry . fileName ) && notInMetaInf ( entry . fileName ) && notSha1AndNotGit ( entry . fileName ) ) { const dest = join ( native , entry . fileName ) ; extractedNatives . push ( { file : entry . fileName , name : n . name , sha1 : "" } ) ; promises . push ( promisify ( pipeline ) ( await openEntryReadStream ( zip , entry ) , createWriteStream ( dest ) ) ) ; } } await Promise . all ( promises ) ; } if ( shaEntries ) { const validEntries = { } ; for ( const entry of shaEntries ) { if ( typeof entry . file !== "string" ) { continue ; } const file = join ( native , entry . file ) ; const valid = await validateSha1 ( file , entry . sha1 , true ) ; if ( valid ) { validEntries [ entry . name ] = true ; } } const missingNatives = natives . filter ( ( n ) => ! validEntries [ n . name ] ) ; if ( missingNatives . length !== 0 ) { await Promise . all ( missingNatives . map ( extractJar ) ) ; } } else { await Promise . all ( natives . map ( extractJar ) ) ; const entries = await Promise . all ( extractedNatives . map ( async ( n ) => ( { ... n , sha1 : await checksum ( join ( native , n . file ) , "sha1" ) } ) ) ) ; const fileContent = JSON . stringify ( { entries , libraries : includedLibs , } ) ; await writeFile ( checksumFile , fileContent ) ; } } } export interface BaseServerOptions { /**
     * Java executable.
     */ javaPath : string ; /**
     * Current working directory. Default is the same with the path.
     */ cwd ? : string ; /**
     * No gui for the server launch
     */ nogui ? : boolean ; minMemory ? : number ; maxMemory ? : number ; extraJVMArgs ? : string [ ] ; extraMCArgs ? : string [ ] ; extraExecOption ? : SpawnOptions ; } export interface MinecraftServerOptions extends BaseServerOptions { /**
     * Minecraft location.
     */ path : string ; /**
     * The version id.
     */ version : string | ResolvedVersion ; } /**
 * This is the case you provide the server jar execution path.
 */ export interface ServerOptions extends BaseServerOptions { /**
     * The minecraft server exectuable jar file.
     *
     * This is the case like you are launching forge server.
     */ serverExectuableJarPath : string ; } ; export async function launchServer ( options ) { const args = await generateArgumentsServer ( options ) ; let cwd = options . cwd ; if ( "path" in options ) { cwd = options . path ; } else { cwd = dirname ( options . serverExectuableJarPath ) ; } const spawnOption = { cwd , env : process . env , ... ( options . extraExecOption || { } ) } ; return spawn ( args [ 0 ] , args . slice ( 1 ) , spawnOption ) ; } /**
 * The Minecraft process watcher. You can inspect Minecraft launch state by this.
 *
 * Generally, there are several cases after you call `launch` and get `ChildProcess` object
 *
 * 1. child process fire an error, no real process start.
 * 2. child process started, but game crash (code is not 0).
 * 3. cihld process started, game normally exit (code is 0).
 */ export interface MinecraftProcessWatcher extends EventEmitter { /**
     * Fire when the process DOESN'T start at all, like "java not found".
     *
     * The minecraft-kill or minecraft-exit will NOT fire after this fired.
     */ on ( event , listener ) : this ; /**
     * Fire after Minecraft process exit.
     */ on ( event , listener ) : this ; /**
     * Fire around the time when Minecraft window appeared in screen.
     *
     * Since the Minecraft window will take time to init, this event fire when it capture some keywords from stdout.
     */ on ( event , listener ) : this ; } param process param emitter export function createMinecraftProcessWatcher ( process , emitter = new EventEmitter ( ) ) { let crashReport = "" ; let crashReportLocation = "" ; let waitForReady = true ; process . on ( "error" , ( e ) => { emitter . emit ( "error" , e ) ; } ) ; process . on ( "exit" , ( code , signal ) => { emitter . emit ( "minecraft-exit" , { code , signal , crashReport , crashReportLocation , } ) ; } ) ; process . stdout ?. on ( "data" , ( s ) => { const string = s . toString ( ) ; if ( string . indexOf ( "---- Minecraft Crash Report ----" ) !== - 1 ) { crashReport = string ; } else if ( string . indexOf ( "Crash report saved to:" ) !== - 1 ) { crashReportLocation = string . substring ( string . indexOf ( "Crash report saved to:" ) + "Crash report saved to: #@!@# " . length ) ; crashReportLocation = crashReportLocation . replace ( EOL , "" ) . trim ( ) ; } else if ( waitForReady && string . indexOf ( "Reloading ResourceManager" ) !== - 1 || string . indexOf ( "LWJGL Version: " ) !== - 1 || string . indexOf ( "OpenAL initialized." ) !== - 1 ) { waitForReady = false ; emitter . emit ( "minecraft-window-ready" ) ; } } ) ; return emitter ; } param options see see see generateArguments see createMinecraftProcessWatcher throws CorruptedVersionJarError throws MissingLibrariesError export async function launch ( options ) { const gamePath = ! isAbsolute ( options . gamePath ) ? resolve ( options . gamePath ) : options . gamePath ; const resourcePath = options . resourcePath || gamePath ; const version = typeof options . version === "string" ? await Version . parse ( resourcePath , options . version ) : options . version ; let args = await generateArguments ( { ... options , version , gamePath , resourcePath } ) ; const minecraftFolder = MinecraftFolder . from ( resourcePath ) ; const prechecks = options . prechecks || LaunchPrecheck . DEFAULT_PRECHECKS ; await Promise . all ( prechecks . map ( ( f ) => f ( minecraftFolder , version , options ) ) ) ; const spawnOption = { cwd : options . gamePath , ... ( options . extraExecOption || { } ) } ; if ( options . extraExecOption ?. shell ) { args = args . map ( ( a ) => `"${ a }"` ) ; } if ( ! existsSync ( gamePath ) ) { await mkdir ( gamePath ) ; } return spawn ( args [ 0 ] , args . slice ( 1 ) , spawnOption ) ; } /**
 * Generate the argument for server
 */ export async function generateArgumentsServer ( options ) { const { javaPath , minMemory = 1024 , maxMemory = 1024 , extraJVMArgs = [ ] , extraMCArgs = [ ] , extraExecOption = { } } = options ; const cmd = [ javaPath , `-Xms${ ( minMemory ) }M` , `-Xmx${ ( maxMemory ) }M` , ... extraJVMArgs , ] ; if ( "path" in options ) { let mc = MinecraftFolder . from ( options . path ) ; let version = options . version ; let resolvedVersion = typeof version === "string" ? await Version . parse ( mc , version ) : version ; cmd . push ( "-jar" , mc . getVersionJar ( resolvedVersion . minecraftVersion , "server" ) ) ; } else { cmd . push ( "-jar" , options . serverExectuableJarPath ) ; } cmd . push ( ... extraMCArgs ) ; if ( options . nogui ) { cmd . push ( "nogui" ) ; } return cmd ; } /**
 * Generate the arguments array by options. This function is useful if you want to launch the process by yourself.
 *
 * This function will **NOT** check if the runtime libs are completed, and **WONT'T** check or extract native libs.
 *
 * If you want to ensure native. Please see  LaunchPrecheck . checkNatives .
 *
 *  param options throws export async function generateArguments ( options ) { if ( ! options . version ) { throw new TypeError ( "Version cannot be null!" ) ; } if ( ! options . isDemo ) { options . isDemo = false ; } const currentPlatform = options . platform ?? getPlatform ( ) ; const gamePath = ! isAbsolute ( options . gamePath ) ? resolve ( options . gamePath ) : options . gamePath ; const resourcePath = options . resourcePath || gamePath ; const version = typeof options . version === "string" ? await Version . parse ( resourcePath , options . version ) : options . version ; const mc = MinecraftFolder . from ( resourcePath ) ; const cmd = [ ] ; const { id = v4 ( ) . replace ( /-/g , "" ) , name = "Steve" } = options . gameProfile || { } ; const accessToken = options . accessToken || v4 ( ) . replace ( /-/g , "" ) ; const properties = options . properties || { } ; const userType = options . userType || "Mojang" ; const features = options . features || { } ; const jvmArguments = normalizeArguments ( version . arguments . jvm , currentPlatform , features ) ; const gameArguments = normalizeArguments ( version . arguments . game , currentPlatform , features ) ; const featureValues = Object . values ( features ) . filter ( ( f ) => typeof f === "object" ) . reduce ( ( a , b ) => ( { ... a , ... b } ) , { } ) ; const launcherName = options . launcherName || "Launcher" ; const launcherBrand = options . launcherBrand || "0.0.1" ; const nativeRoot = options . nativeRoot || mc . getNativesRoot ( version . id ) ; let gameIcon = options . gameIcon ; if ( ! gameIcon ) { const index = mc . getAssetsIndex ( version . assetIndex . id ) ; const indexContent = await readFile ( index , { encoding : "utf-8" } ) . then ( ( b ) => JSON . parse ( b . toString ( ) ) , ( ) => ( { } ) ) ; if ( "icons/minecraft.icns" in indexContent ) { gameIcon = mc . getAsset ( indexContent [ "icons/minecraft.icns" ] . hash ) ; } else if ( "minecraft/icons/minecraft.icns" in indexContent ) { gameIcon = mc . getAsset ( indexContent [ "minecraft/icons/minecraft.icns" ] . hash ) ; } else { gameIcon = "" ; } } const gameName = options . gameName || "Minecraft" ; cmd . push ( options . javaPath ) ; if ( currentPlatform . name === "osx" ) { cmd . push ( `-Xdock:name=${ gameName }` ) ; if ( gameIcon ) { cmd . push ( `-Xdock:icon=${ gameIcon }` ) ; } } if ( options . minMemory ) { cmd . push ( `-Xms${ ( options . minMemory ) }M` ) ; } if ( options . maxMemory ) { cmd . push ( `-Xmx${ ( options . maxMemory ) }M` ) ; } if ( options . ignoreInvalidMinecraftCertificates ) { cmd . push ( "-Dfml.ignoreInvalidMinecraftCertificates=true" ) ; } if ( options . ignorePatchDiscrepancies ) { cmd . push ( "-Dfml.ignorePatchDiscrepancies=true" ) ; } if ( options . yggdrasilAgent ) { cmd . push ( `-javaagent:${ options . yggdrasilAgent . jar }=${ options . yggdrasilAgent . server }` ) ; cmd . push ( "-Dauthlibinjector.side=client" ) ; if ( options . yggdrasilAgent . prefetched ) { cmd . push ( `-Dauthlibinjector.yggdrasil.prefetched=${ options . yggdrasilAgent . prefetched }` ) ; } } const jvmOptions = { natives_directory : nativeRoot , launcher_name : launcherName , launcher_version : launcherBrand , classpath : [ ... version . libraries . filter ( ( lib ) => ! ( lib instanceof ResolvedNative ) ) . map ( ( lib ) => mc . getLibraryByPath ( lib . download . path ) ) , mc . getVersionJar ( version . minecraftVersion ) , ... ( options . extraClassPaths || [ ] ) , ] . join ( delimiter ) , library_directory : mc . getPath ( "libraries" ) , classpath_separator : delimiter , version_name : version . minecraftVersion , ... featureValues , } ; cmd . push ( ... jvmArguments . map ( ( arg ) => format ( arg , jvmOptions ) ) ) ; if ( options . extraJVMArgs instanceof Array ) { if ( options . extraJVMArgs . some ( ( v ) => typeof v !== "string" ) ) { throw new TypeError ( "Require extraJVMArgs be all string!" ) ; } cmd . push ( ... options . extraJVMArgs ) ; } else { if ( options . maxMemory ) { cmd . push ( ... DEFAULT_EXTRA_JVM_ARGS . filter ( ( v ) => v !== "-Xmx2G" ) ) ; } else { cmd . push ( ... DEFAULT_EXTRA_JVM_ARGS ) ; } } cmd . push ( version . mainClass ) ; const assetsDir = join ( resourcePath , "assets" ) ; const resolution = options . resolution ; const versionName = options . versionName || version . id ; const versionType = options . versionType || version . type ; const mcOptions = { version_name : versionName , version_type : versionType , assets_root : assetsDir , game_assets : join ( assetsDir , "virtual" , version . assets ) , assets_index_name : version . assets , game_directory : gamePath , auth_player_name : name , auth_uuid : id , auth_access_token : accessToken , user_properties : JSON . stringify ( properties ) , user_type : userType , resolution_width : - 1 , resolution_height : - 1 , ... featureValues , } ; if ( resolution ) { mcOptions . resolution_width = resolution . width ; mcOptions . resolution_height = resolution . height ; } cmd . push ( ... gameArguments . map ( ( arg ) => format ( arg , mcOptions ) ) ) ; if ( options . extraMCArgs ) { cmd . push ( ... options . extraMCArgs ) ; } if ( options . server ) { cmd . push ( "--server" , options . server . ip ) ; if ( options . server . port ) { cmd . push ( "--port" , options . server . port . toString ( ) ) ; } } if ( options . resolution && ! cmd . find ( ( a ) => a === "--width" ) ) { if ( options . resolution . fullscreen ) { cmd . push ( "--fullscreen" ) ; } else { if ( options . resolution . height ) { cmd . push ( "--height" , options . resolution . height . toString ( ) ) ; } if ( options . resolution . width ) { cmd . push ( "--width" , options . resolution . width . toString ( ) ) ; } } } return cmd ; } /**
 * Truely normalize the launch argument.
 */ function normalizeArguments ( args , platform , features ) { return args . map ( ( arg ) => { if ( typeof arg === "string" ) { return arg ; } if ( ! Version . checkAllowed ( arg . rules , platform , Object . keys ( features ) ) ) { return "" ; } return arg . value ; } ) . reduce < string [ ] > ( ( result , cur ) => { if ( cur instanceof Array ) { result . push ( ... cur ) ; } else if ( cur ) { result . push ( cur ) ; } return result ; } , [ ] ) ; }