diff --git a/README.md b/README.md index 7128b34..902f67e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # minecraft-java-manager -Node module to check if java is installed and install it \ No newline at end of file +Node module to check if java is installed and install it + +There is two function, read index.js \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..8d8016b --- /dev/null +++ b/index.js @@ -0,0 +1,24 @@ +const installer = require("./src/installer") +const checker = require("./src/checker") +const sysinfo = require("./src/systeminfo") +const web = require("./src/web") + +async function isJavaInstalledAndCompatibleFor(mcVersion, customExec = "java") { + const mcVersionManifestUrl = await web.getVersionManifest(mcVersion) + const requiredJRE = await web.getRequiredJREFor(mcVersionManifestUrl) + const checkJRECommand = checker.getJavaVersionOutput(customExec) + const isJavaCompatible = checker.isJavaCompatible(checkJRECommand, requiredJRE.majorVersion) + return isJavaCompatible +} + +async function installJava(component, majorVersion, customInstallPath = null) { + const installationPath = await sysinfo.determineInstallationPath(component, majorVersion, customInstallPath) + const requiredComponentDetails = await web.getJREComponentDetails(component, sysinfo.getPlatformKey()) + const requiredComponentManifest = await web.getJREComponentManifest(requiredComponentDetails[0].manifest.url) + return await installer.downloadJavaComponent(requiredComponentManifest, installationPath) +} + +module.exports = { + isJavaInstalledAndCompatibleFor, + installJava +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8fb031a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "minecraft-java-manager", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minecraft-java-manager", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "adm-zip": "^0.6.0", + "node-downloader-helper": "^2.1.11" + } + }, + "node_modules/adm-zip": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, + "node_modules/node-downloader-helper": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/node-downloader-helper/-/node-downloader-helper-2.1.11.tgz", + "integrity": "sha512-882fH2C9AWdiPCwz/2beq5t8FGMZK9Dx8TJUOIxzMCbvG7XUKM5BuJwN5f0NKo4SCQK6jR4p2TPm54mYGdGchQ==", + "license": "MIT", + "bin": { + "ndh": "bin/ndh" + }, + "engines": { + "node": ">=14.18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea83bef --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "minecraft-java-manager", + "version": "0.0.1", + "description": "Node module to check if java is installed and install it", + "repository": { + "type": "git", + "url": "https://gitea.azures.fr/azures04/minecraft-java-manager" + }, + "license": "MIT", + "author": { + "name": "azures04", + "url": "https://gitea.azures.fr", + "email": "azures04@azures.fr" + }, + "type": "commonjs", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "adm-zip": "^0.6.0", + "node-downloader-helper": "^2.1.11" + } +} diff --git a/src/checker.js b/src/checker.js new file mode 100644 index 0000000..871c20f --- /dev/null +++ b/src/checker.js @@ -0,0 +1,37 @@ +const childProcess = require("node:child_process") + +function getJavaVersionOutput(defaultCommand = "java") { + try { + const command = childProcess.spawnSync(defaultCommand, ["-version"], { + encoding: "utf-8", + windowsHide: true, + }) + + if (command.error) { + return null + } + + return command.stderr || command.stdout || "" + } catch { + return null + } +} + +function parseJavaOutput(output) { + if (!output || typeof output !== "string") { + return null + } + + const match = output.match(/version\s+"(?:1\.)?(\d+)/i) + return match ? parseInt(match[1], 10) : null +} + +function isJavaCompatible(output, requiredMajor) { + return parseJavaOutput(output) === requiredMajor +} + +module.exports = { + getJavaVersionOutput, + isJavaCompatible, + parseJavaOutput, +} \ No newline at end of file diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..87e1774 --- /dev/null +++ b/src/config.js @@ -0,0 +1,31 @@ +const endpoints = { + allRuntimes: process.env.ALL_RUNTIMES || "https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json", + launcherManifest: process.env.LAUNCHER_MANIFEST || "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json", +} + +function setEndpoint(key, value) { + return endpoints[key] = value +} + +function getEndpoint(key) { + return endpoints[key] +} + +module.exports = { + allRuntimes: { + set: (value) => { + return setEndpoint("allRuntimes", value) + }, + get: () => { + return getEndpoint("allRuntimes") + } + }, + launcherManifest: { + set: (value) => { + return setEndpoint("launcherManifest", value) + }, + get: () => { + return getEndpoint("launcherManifest") + } + } +} \ No newline at end of file diff --git a/src/installer.js b/src/installer.js new file mode 100644 index 0000000..e3c77aa --- /dev/null +++ b/src/installer.js @@ -0,0 +1,247 @@ +const https = require("https") +const fs = require("fs") +const path = require("path") +const crypto = require("crypto") +const { EventEmitter } = require("events") +const web = require("./web") +const sysinfo = require("./systeminfo") + +function extractDownloadableFiles(manifest) { + const filePaths = Object.keys(manifest.files) + const entries = [] + + for (const filePath of filePaths) { + const fileEntry = manifest.files[filePath] + + if (fileEntry.type !== "file") { + continue + } + + const downloadInfo = fileEntry.downloads.raw || fileEntry.downloads.lzma + + if (!downloadInfo) { + continue + } + + entries.push({ + filePath: filePath, + url: downloadInfo.url, + sha1: downloadInfo.sha1, + size: downloadInfo.size, + executable: fileEntry.executable || false + }) + } + + return entries +} + +function requestWithRedirect(url, onResponse, maxRedirects = 5) { + return new Promise((resolve, reject) => { + + function makeRequest(currentUrl, redirectsLeft) { + https.get(currentUrl, (response) => { + + const statusCode = response.statusCode + + if (statusCode >= 300 && statusCode < 400 && response.headers.location) { + response.resume() + + if (redirectsLeft <= 0) { + reject(new Error("Too many http redirection")) + return + } + + makeRequest(response.headers.location, redirectsLeft - 1) + return + } + + if (statusCode !== 200) { + response.resume() + reject(new Error(`HTTP Failed: received status code ${statusCode} for ${currentUrl}`)) + return + } + + try { + onResponse(response) + resolve() + } catch (err) { + reject(err) + } + + }).on("error", (err) => { + reject(err) + }) + } + + makeRequest(url, maxRedirects) + }) +} +async function downloadSingleFile(fileEntry, outputRoot, onProgress) { + const outputPath = path.join(outputRoot, fileEntry.filePath) + + await fs.promises.mkdir(path.dirname(outputPath), { + recursive: true + }) + + const hasher = crypto.createHash("sha1") + const writeStream = fs.createWriteStream(outputPath) + + let downloadedBytes = 0 + + try { + await requestWithRedirect(fileEntry.url, (responseStream) => { + responseStream.on("data", (chunk) => { + downloadedBytes += chunk.length + hasher.update(chunk) + + if (onProgress) { + onProgress({ + filePath: fileEntry.filePath, + bytesDownloaded: downloadedBytes, + totalBytes: fileEntry.size + }) + } + }) + + responseStream.pipe(writeStream) + }) + + await new Promise((resolve, reject) => { + writeStream.on("finish", resolve) + writeStream.on("error", reject) + }) + + const computedSha1 = hasher.digest("hex") + + if (computedSha1 !== fileEntry.sha1) { + await fs.promises.unlink(outputPath).catch(() => {}) + throw new Error( + `Hash SHA1 invalide pour ${fileEntry.filePath}. ` + + `Attendu: ${fileEntry.sha1}, Obtenu: ${computedSha1}` + ) + } + + if (fileEntry.executable && process.platform !== "win32") { + await fs.promises.chmod(outputPath, 0o755).catch(() => {}) + } + + return { + filePath: fileEntry.filePath, + outputPath: outputPath, + sha1: computedSha1, + totalBytes: downloadedBytes + } + + } catch (err) { + if (!writeStream.destroyed) { + writeStream.destroy() + } + throw err + } +} + +async function downloadJavaComponent(manifest, outputRoot, options) { + const emitter = new EventEmitter() + const concurrency = (options && options.concurrency) || 5 + + try { + const files = extractDownloadableFiles(manifest) + const totalFiles = files.length + const totalBytesExpected = files.reduce((sum, f) => { return sum + (f.size || 0) }, 0) + + emitter.emit("start", { + totalFiles: totalFiles, + totalBytes: totalBytesExpected, + outputRoot: outputRoot + }) + + let completedFiles = 0 + let failedFiles = 0 + let totalBytesDownloaded = 0 + const errors = [] + + const perFileProgress = {} + + function reportGlobalProgress() { + const currentTotal = Object.keys(perFileProgress).reduce((sum, key) => { + return sum + perFileProgress[key] + }, 0) + + emitter.emit("progress", { + completedFiles: completedFiles, + totalFiles: totalFiles, + bytesDownloaded: currentTotal, + totalBytes: totalBytesExpected, + percent: totalBytesExpected > 0 ? + Math.round((currentTotal / totalBytesExpected) * 100) : + null + }) + } + + let cursor = 0 + + async function worker() { + while (cursor < files.length) { + const currentIndex = cursor + cursor += 1 + const fileEntry = files[currentIndex] + + try { + const result = await downloadSingleFile(fileEntry, outputRoot, (progressInfo) => { + perFileProgress[progressInfo.filePath] = progressInfo.bytesDownloaded + reportGlobalProgress() + }) + + completedFiles += 1 + totalBytesDownloaded += result.totalBytes + perFileProgress[fileEntry.filePath] = result.totalBytes + + emitter.emit("fileComplete", result) + reportGlobalProgress() + + } catch (err) { + failedFiles += 1 + errors.push({ + filePath: fileEntry.filePath, + message: err.message + }) + + emitter.emit("fileError", { + filePath: fileEntry.filePath, + message: err.message, + error: err + }) + } + } + } + + const workerCount = Math.min(concurrency, files.length) + const workers = [] + + for (let i = 0; i < workerCount; i += 1) { + workers.push(worker()) + } + + await Promise.all(workers) + + emitter.emit("complete", { + totalFiles: totalFiles, + completedFiles: completedFiles, + failedFiles: failedFiles, + totalBytes: totalBytesDownloaded, + errors: errors + }) + + } catch (err) { + emitter.emit("error", { + message: err.message, + error: err + }) + } + + return emitter +} + +module.exports = { + downloadJavaComponent +} \ No newline at end of file diff --git a/src/systeminfo.js b/src/systeminfo.js new file mode 100644 index 0000000..d16be11 --- /dev/null +++ b/src/systeminfo.js @@ -0,0 +1,65 @@ +const fs = require("node:fs") +const path = require("node:path") + +function getPlatformKey() { + const platform = process.platform + const arch = process.arch + let candidate = null + + if (platform === "win32") { + if (arch === "arm64") { + candidate = "windows-arm64" + } + else if (arch === "x64") { + candidate = "windows-x64" + } + else if (arch === "ia32") { + candidate = "windows-x86" + } + } else if (platform === "darwin") { + if (arch === "arm64") { + candidate = "mac-os-arm64" + } else { + candidate = "mac-os" + } + } else if (platform === "linux") { + if (arch === "ia32") { + candidate = "linux-i386" + } else { + candidate = "linux" + } + } + + if (candidate) { + return candidate + } + + return targets["linux"] ?? null +} + +async function determineInstallationPath(component, majorVersion, customInstallPath = null) { + if (typeof customInstallPath == "string") { + try { + await fs.promises.access(customInstallPath) + return customInstallPath + } catch (error) { + await fs.promises.mkdir(customInstallPath, { recursive: true }) + return customInstallPath + } + } + switch (process.platform) { + case "win32": + return path.join("Program files", "Java", component, majorVersion) + case "linux": + return path.join("opt", "java", component, majorVersion) + case "macos": + return path.join("Library", "Java", "JavaVirtualMachines", `${component}-${majorVersion}`) + default: + throw new Error("Unsupported OS") + } +} + +module.exports = { + getPlatformKey, + determineInstallationPath +} \ No newline at end of file diff --git a/src/web.js b/src/web.js new file mode 100644 index 0000000..e8aaeaa --- /dev/null +++ b/src/web.js @@ -0,0 +1,40 @@ +const config = require("./config") + +async function getVersionManifest(version) { + const mainifestRequest = await fetch(config.launcherManifest.get()) + const manifest = await mainifestRequest.json() + const component = manifest.versions.find($version => $version.id == version) + if (!typeof component == "object") { + throw new Error("Unknown version") + } + return component.url +} + +async function getRequiredJREFor(versionUrl) { + const mainifestRequest = await fetch(versionUrl) + const manifest = await mainifestRequest.json() + return manifest.javaVersion +} + +async function getJREComponentDetails(componentRequested, os) { + const mainifestRequest = await fetch(config.allRuntimes.get()) + const manifest = await mainifestRequest.json() + const component = manifest[os][componentRequested] + if (typeof component != "object") { + throw new Error("No JRE Component found") + } + return component +} + +async function getJREComponentManifest(url) { + const mainifestRequest = await fetch(url) + const manifest = await mainifestRequest.json() + return manifest +} + +module.exports = { + getVersionManifest, + getRequiredJREFor, + getJREComponentDetails, + getJREComponentManifest +} \ No newline at end of file diff --git a/test.js b/test.js new file mode 100644 index 0000000..370a65a --- /dev/null +++ b/test.js @@ -0,0 +1,7 @@ +const lib = require("./index") + +async function main() { + await lib.installJava("jre-legacy", 8, "./java") +} + +main() \ No newline at end of file