UNPKG

silver-mc-java-core

Version:

A library starting minecraft game NW.js and Electron.js

360 lines (316 loc) 12.2 kB
/** * This code is distributed under the CC-BY-NC 4.0 license: * https://creativecommons.org/licenses/by-nc/4.0/ * * Original author: Luuxis */ import os from 'os'; import nodeFetch from 'node-fetch'; import path from 'path'; import fs from 'fs'; import EventEmitter from 'events'; import Seven from 'node-7z'; import sevenBin from '7zip-bin'; import { getFileHash } from '../utils/Index.js'; import Downloader from '../utils/Downloader.js'; /** * Represents the Java-specific options a user might pass to the downloader. */ export interface JavaDownloaderOptions { path: string; // Base path to store the downloaded Java runtime java: { version?: string; // Force a specific Java version (e.g., "17") type: string; // Image type for Adoptium (e.g., "jdk" or "jre") }; intelEnabledMac?: boolean; // If `true`, allows using Intel-based Java on Apple Silicon } /** * A generic JSON structure for the Minecraft version, which may include * a javaVersion property. Adjust as needed to fit your actual data. */ export interface MinecraftVersionJSON { javaVersion?: { component?: string; // e.g., "jre-legacy" or "java-runtime-alpha" majorVersion?: number; // e.g., 8, 17, 19 }; } /** * Structure returned by getJavaFiles() and getJavaOther(). */ export interface JavaDownloadResult { files: JavaFileItem[]; path: string; // Local path to the java executable error?: boolean; // Indicate an error if any message?: string; // Error message if error is true } /** * Represents a single Java file entry that might need downloading. */ export interface JavaFileItem { path: string; // Relative path to store the file under the runtime directory executable?: boolean; sha1?: string; size?: number; url?: string; type?: string; // "Java" or other type } /** * Manages the download and extraction of the correct Java runtime for Minecraft. * It supports both Mojang's curated list of Java runtimes and the Adoptium fallback. */ export default class JavaDownloader extends EventEmitter { private options: JavaDownloaderOptions; constructor(options: JavaDownloaderOptions) { super(); this.options = options; } /** * Retrieves Java files from Mojang's runtime metadata if possible, * otherwise falls back to getJavaOther(). * * @param jsonversion A JSON object describing the Minecraft version (with optional javaVersion). * @returns An object containing a list of JavaFileItems and the final path to "java". */ public async getJavaFiles(jsonversion: MinecraftVersionJSON): Promise<JavaDownloadResult> { // If a specific version is forced, delegate to getJavaOther() immediately if (this.options.java.version) { return this.getJavaOther(jsonversion, this.options.java.version); } // OS-to-architecture mapping for Mojang's curated Java. const archMapping: Record<string, Record<string, string>> = { win32: { x64: 'windows-x64', ia32: 'windows-x86', arm64: 'windows-arm64' }, darwin: { x64: 'mac-os', arm64: this.options.intelEnabledMac ? 'mac-os' : 'mac-os-arm64' }, linux: { x64: 'linux', ia32: 'linux-i386' } }; const osPlatform = os.platform(); // "win32", "darwin", "linux", ... const arch = os.arch(); // "x64", "arm64", "ia32", ... const javaVersionName = jsonversion.javaVersion?.component || 'jre-legacy'; const osArchMapping = archMapping[osPlatform]; const files: JavaFileItem[] = []; // If we don't have a valid mapping for the current OS, fallback to Adoptium if (!osArchMapping) { return this.getJavaOther(jsonversion); } // Determine the OS-specific identifier const archOs = osArchMapping[arch]; if (!archOs) { // If we can't match the arch in the sub-object, fallback return this.getJavaOther(jsonversion); } // Fetch Mojang's Java runtime metadata const javaVersionsJson = await nodeFetch( 'https://launchermeta.mojang.com/v1/products/java-runtime/' + '2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json' ).then(res => res.json()); const versionName = javaVersionsJson[archOs]?.[javaVersionName]?.[0]?.version?.name; if (!versionName) { return this.getJavaOther(jsonversion); } // Fetch the runtime manifest which lists individual files const manifestUrl = javaVersionsJson[archOs][javaVersionName][0]?.manifest?.url; const manifest = await nodeFetch(manifestUrl).then(res => res.json()); const manifestEntries: Array<[string, any]> = Object.entries(manifest.files); // Identify the Java executable in the manifest const javaExeKey = process.platform === 'win32' ? 'bin/javaw.exe' : 'bin/java'; const javaEntry = manifestEntries.find(([relPath]) => relPath.endsWith(javaExeKey)); if (!javaEntry) { // If we can't find the executable, fallback return this.getJavaOther(jsonversion); } const toDelete = javaEntry[0].replace(javaExeKey, ''); for (const [relPath, info] of manifestEntries) { if (info.type === 'directory') continue; if (!info.downloads) continue; files.push({ path: `runtime/jre-${versionName}-${archOs}/${relPath.replace(toDelete, '')}`, executable: info.executable, sha1: info.downloads.raw.sha1, size: info.downloads.raw.size, url: info.downloads.raw.url, type: 'Java' }); } return { files, path: path.resolve( this.options.path, `runtime/jre-${versionName}-${archOs}`, 'bin', process.platform === 'win32' ? 'javaw.exe' : 'java' ) }; } /** * Fallback method to download Java from Adoptium if Mojang's metadata is unavailable * or doesn't have the appropriate runtime for the user's platform/arch. * * @param jsonversion A Minecraft version JSON (with optional javaVersion). * @param versionDownload A forced Java version (string) if provided by the user. */ public async getJavaOther(jsonversion: MinecraftVersionJSON, versionDownload?: string): Promise<JavaDownloadResult> { // Determine which major version of Java we need const majorVersion = versionDownload || jsonversion.javaVersion?.majorVersion || 8; const { platform, arch } = this.getPlatformArch(); // Build the Adoptium API URL const queryParams = new URLSearchParams({ image_type: this.options.java.type, // e.g. "jdk" or "jre" architecture: arch, os: platform }); const javaVersionURL = `https://api.adoptium.net/v3/assets/latest/${majorVersion}/hotspot?${queryParams.toString()}`; const javaVersions = await nodeFetch(javaVersionURL).then(res => res.json()); // If no valid version is found, return an error const java = javaVersions[0]; if (!java) { return { files: [], path: '', error: true, message: 'No Java found' }; } const { checksum, link: url, name: fileName } = java.binary.package; const pathFolder = path.resolve(this.options.path, `runtime/jre-${majorVersion}`); const filePath = path.join(pathFolder, fileName); // Determine the final path to the java executable after extraction let javaExePath = path.join(pathFolder, 'bin', 'java'); if (platform === 'mac') { javaExePath = path.join(pathFolder, 'Contents', 'Home', 'bin', 'java'); } // Download and extract if needed if (!fs.existsSync(javaExePath)) { await this.verifyAndDownloadFile({ filePath, pathFolder, fileName, url, checksum }); // Extract the downloaded archive await this.extract(filePath, pathFolder); fs.unlinkSync(filePath); // For .tar.gz files, we may need a second extraction step if (filePath.endsWith('.tar.gz')) { const tarFilePath = filePath.replace('.gz', ''); await this.extract(tarFilePath, pathFolder); if (fs.existsSync(tarFilePath)) { fs.unlinkSync(tarFilePath); } } // If there's only one folder extracted, move its contents up const extractedItems = fs.readdirSync(pathFolder); if (extractedItems.length === 1) { const singleFolder = path.join(pathFolder, extractedItems[0]); const stat = fs.statSync(singleFolder); if (stat.isDirectory()) { const subItems = fs.readdirSync(singleFolder); for (const item of subItems) { const srcPath = path.join(singleFolder, item); const destPath = path.join(pathFolder, item); fs.renameSync(srcPath, destPath); } fs.rmdirSync(singleFolder); } } // Ensure the Java executable is marked as executable on non-Windows systems if (platform !== 'windows') { fs.chmodSync(javaExePath, 0o755); } } return { files: [], path: javaExePath }; } /** * Maps the Node `os.platform()` and `os.arch()` to Adoptium's expected format. * Apple Silicon can optionally download x64 if `intelEnabledMac` is true. */ private getPlatformArch(): { platform: string; arch: string } { const platformMap: Record<string, string> = { win32: 'windows', darwin: 'mac', linux: 'linux' }; const archMap: Record<string, string> = { x64: 'x64', ia32: 'x32', arm64: 'aarch64', arm: 'arm' }; const mappedPlatform = platformMap[os.platform()] || os.platform(); let mappedArch = archMap[os.arch()] || os.arch(); // Force x64 if Apple Silicon but user wants to use Intel-based Java if (os.platform() === 'darwin' && os.arch() === 'arm64' && this.options.intelEnabledMac) { mappedArch = 'x64'; } return { platform: mappedPlatform, arch: mappedArch }; } /** * Verifies if the Java archive already exists and matches the expected checksum. * If it doesn't exist or fails the hash check, it downloads from the given URL. * * @param params.filePath The local file path * @param params.pathFolder The folder to place the file in * @param params.fileName The name of the file * @param params.url The remote download URL * @param params.checksum Expected SHA-256 hash */ private async verifyAndDownloadFile({ filePath, pathFolder, fileName, url, checksum }: { filePath: string; pathFolder: string; fileName: string; url: string; checksum: string; }): Promise<void> { // If the file already exists, check its integrity if (fs.existsSync(filePath)) { const existingChecksum = await getFileHash(filePath, 'sha256'); if (existingChecksum !== checksum) { fs.unlinkSync(filePath); fs.rmSync(pathFolder, { recursive: true, force: true }); } } // If not found or failed checksum, download anew if (!fs.existsSync(filePath)) { fs.mkdirSync(pathFolder, { recursive: true }); const download = new Downloader(); // Relay progress events download.on('progress', (downloaded: number, size: number) => { this.emit('progress', downloaded, size, fileName); }); // Start download await download.downloadFile(url, pathFolder, fileName); } // Final verification of the downloaded file const downloadedChecksum = await getFileHash(filePath, 'sha256'); if (downloadedChecksum !== checksum) { throw new Error('Java checksum failed'); } } /** * Extracts the given archive (ZIP or 7Z), using the `node-7z` library and the system's 7z binary. * Emits an "extract" event with the extraction progress (percent). * * @param filePath Path to the archive file * @param destPath Destination folder to extract into */ private async extract(filePath: string, destPath: string): Promise<void> { // Ensure the 7z binary is executable on Unix-like OSes if (os.platform() !== 'win32') { fs.chmodSync(sevenBin.path7za, 0o755); } return new Promise<void>((resolve, reject) => { const extractor = Seven.extractFull(filePath, destPath, { $bin: sevenBin.path7za, recursive: true, $progress: true }); extractor.on('end', () => resolve()); extractor.on('error', (err) => reject(err)); extractor.on('progress', (progress) => { if (progress.percent > 0) { this.emit('extract', progress.percent); } }); }); } }