diff --git a/README.md b/README.md index dff7a8d..4573e40 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ # OpenModLauncher-Base -A small Minecraft launcher codebase \ No newline at end of file +A small Minecraft launcher codebase + +This is currently a work in progress for another project of mine but it can be used in other projects too as I try to be the least project-specific as I can be. + +Please note I reused and rearranged a lot of code from a launcher I've programmed earlier this year. + +## Features + +At the time of writing this README, this launcher can only download a Minecraft version specified in meta.json into a given directory. +Launching Minecraft, installing a mod loader on top of many versions and downloading custom files are the main planned features. diff --git a/lib/checker.js b/lib/checker.js new file mode 100644 index 0000000..e6e4833 --- /dev/null +++ b/lib/checker.js @@ -0,0 +1,10 @@ +const path = require("node:path"); +const fs = require("node:fs"); + +module.exports = { + "check_install": function (directory) { + return new Promise(function (resolve, reject) { + resolve(fs.existsSync(path.join(directory, "versions"))); + }); + } +}; diff --git a/lib/downloader.js b/lib/downloader.js new file mode 100644 index 0000000..91b238e --- /dev/null +++ b/lib/downloader.js @@ -0,0 +1,146 @@ +const path = require("node:path"); +const fs = require("node:fs"); +const fsPromises = require("node:fs/promises"); + +const httpsWrapper = require("./util/https.js"); +const utilities = require("./util/functions.js"); + +let maxConcurrentDownloads = 16; +let downloadQueue = []; +let totalDownloads = 0; +let isDownloading = false; +let ongoingDownloads = 0; + +function waitUntilDownloadsFinish() { + return new Promise(async function (resolve, reject) { + var downloadCountChecker = setInterval(function () { + if (ongoingDownloads < 1 && downloadQueue.length < 1 && !isDownloading) { + resolve(clearInterval(downloadCountChecker)); + } + }, 256); + }); +} + +module.exports = { + "download_file": function (url, filePath) { + return new Promise(async function (resolve, reject) { + var directory = path.dirname(filePath); + if (!(await fs.existsSync(directory))) await fsPromises.mkdir(directory, {"recursive": true}); + + var writeStream = fs.createWriteStream(filePath); + writeStream.on("finish", resolve); + + httpsWrapper.getStream(url).then(function (response) { + response.pipe(writeStream); + }).catch(reject); + }); + }, + "process_queue": function (progressCB) { + return new Promise(async function (resolve, reject) { + totalDownloads = downloadQueue.length; + if (downloadQueue.length > 0) isDownloading = true; + + if (progressCB) { + progressCB(0.0, 0, downloadQueue.length, totalDownloads); + } + + while (downloadQueue.length > 0) { + if (ongoingDownloads < maxConcurrentDownloads) { + setImmediate(function (downloadData) { + module.exports.download_file(downloadData.url, downloadData.path).then(function () { + ongoingDownloads--; + if (downloadQueue.length < 1) { + isDownloading = false; + } + }).catch(function (error) { + ongoingDownloads--; + downloadQueue.push(downloadData); + }); + }, downloadQueue.shift()); + ongoingDownloads++; + let progress = (totalDownloads - (downloadQueue.length + ongoingDownloads)) / totalDownloads; + if (progressCB) { + progressCB(progress, ongoingDownloads, downloadQueue.length, totalDownloads); + } + } else { + await utilities.sleep(256); + } + } + + waitUntilDownloadsFinish().then(function () { + if (progressCB) { + progressCB(1.0, 0, 0, totalDownloads); + } + resolve(); + }).catch(reject); + }); + }, + "download_minecraft": function (directory, meta, manifestSource, progressCB) { + return new Promise(async function (resolve, reject) { + let gameManifest = JSON.parse(await httpsWrapper.get(manifestSource["versions_manifest"])); + + let versionInManifest = gameManifest.versions.find(ver => ver.id == meta["version"]); + + if (!versionInManifest) { + return reject(new Error("Minecraft version \"" + meta["version"] + "\" could not be found in given source. ")); + } + + if (!fs.existsSync(directory)) await fsPromises.mkdir(directory, {"recursive": true}); + + let versionDirectory = path.join(directory, "versions", meta["version"]); + + if (!fs.existsSync(versionDirectory)) await fsPromises.mkdir(versionDirectory, {"recursive": true}); + let versionManifest = JSON.parse(await httpsWrapper.get(versionInManifest.url)); + fs.writeFileSync(path.join(versionDirectory, meta["version"] + ".json"), JSON.stringify(versionManifest, null, "\t")); + + let librariesDirectory = path.join(directory, "libraries"); + + for (let l = 0; l < versionManifest.libraries.length; l++) { + let library = versionManifest.libraries[l]; + + if ("classifiers" in library.downloads) continue; // Attempt at skipping platform-specific code, hoping the game still starts. + + let libraryFilePath = path.join(librariesDirectory, ...(library.downloads.artifact.path.split("/"))); + + downloadQueue.push({ + "url": library.downloads.artifact.url, + "path": libraryFilePath + }); + } + + let assetsDirectory = path.join(directory, "assets"); + + let assetIndexesDirectory = path.join(assetsDirectory, "indexes"); + if (!fs.existsSync(assetIndexesDirectory)) await fsPromises.mkdir(assetIndexesDirectory, {"recursive": true}); + + let assetIndexPath = path.join(assetIndexesDirectory, versionManifest.assetIndex.id + ".json"); + let assetIndex = JSON.parse(await httpsWrapper.get(versionManifest.assetIndex.url)); + fs.writeFileSync(assetIndexPath, JSON.stringify(assetIndex, null, "\t")); + + let assetObjectsDirectory = path.join(assetsDirectory, "objects"); + for (const assetLocation in assetIndex.objects) { + if (assetIndex.objects.hasOwnProperty(assetLocation)) { + let asset = assetIndex.objects[assetLocation]; + + let assetPath = [ + asset.hash.substring(0, 2), asset.hash + ]; + + downloadQueue.push({ + "url": manifestSource["asset_base"] + assetPath.join("/"), + "path": path.join(assetObjectsDirectory, ...assetPath) + }); + } + } + + downloadQueue.push({ + "url": versionManifest.downloads.client.url, + "path": path.join(versionDirectory, meta["version"] + ".jar") + }); + + if (!isDownloading) await module.exports.process_queue(progressCB); + + resolve(true); + }); + } +}; diff --git a/lib/util/functions.js b/lib/util/functions.js new file mode 100644 index 0000000..a46a9e1 --- /dev/null +++ b/lib/util/functions.js @@ -0,0 +1,7 @@ +module.exports = { + "sleep": function (delay) { + return new Promise(function (resolve, reject) { + setTimeout(resolve, delay); + }); + } +}; diff --git a/lib/util/https.js b/lib/util/https.js new file mode 100644 index 0000000..ce1677d --- /dev/null +++ b/lib/util/https.js @@ -0,0 +1,35 @@ +const https = require("https"); + +module.exports = { + "get": function (url) { + return new Promise(function (resolve, reject) { + var request = https.get(url, function (response) { + if (response.statusCode != 200) return reject(new Error("Response status code wasn't 200. ")); + + response.setEncoding("utf-8"); + let body = ""; + + response.on("data", function (chunk) { + body += chunk; + }); + + response.on("end", function () { + resolve(body); + }); + }); + + request.on("error", reject); + }); + }, + "getStream": function (url) { + return new Promise(function (resolve, reject) { + var request = https.get(url, function (response) { + if (response.statusCode != 200) return reject(new Error("Response status code wasn't 200")); + + resolve(response); + }); + + request.on("error", reject); + }); + } +}; diff --git a/main.js b/main.js index 2550ab1..5245ac7 100644 --- a/main.js +++ b/main.js @@ -1,8 +1,49 @@ const path = require("node:path"); -const child_process = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); const electron = require("electron"); +const downloader = require("./lib/downloader.js"); +const checker = require("./lib/checker.js"); + +let meta = JSON.parse(fs.readFileSync(path.join(__dirname, "meta.json"), "utf-8")); +let sources = JSON.parse(fs.readFileSync(path.join(__dirname, "sources.json"), "utf-8")); + +let osPlatform = os.platform(); +let userHome = os.homedir(); + +let gameDirectory; +switch (osPlatform) { + case "win32": + gameDirectory = path.join(userHome, "AppData", "Roaming", ...(meta["install_directory"]).split("/")); + break; + case "darwin": + gameDirectory = path.join(userHome, "Library", "Application Support", ...(meta["install_directory"]).split("/")); + break; + case "linux": + gameDirectory = path.join(userHome, ...(meta["install_directory"]).split("/")); + break; + default: + gameDirectory = path.join(userHome, ...(meta["install_directory"]).split("/")); + break; +} + +let window; + +function showProgress(progress, ongoing, remaining, total) { + window.setProgressBar(progress); + + window.webContents.send("event", { + "type": "download-progress", + "data": { + "ongoing": ongoing, + "remaining": remaining, + "total": total + } + }); +} + electron.app.on("ready", function () { window = new electron.BrowserWindow({ "width": 960, @@ -14,7 +55,7 @@ electron.app.on("ready", function () { }, "title": "OpenModLauncher", "icon": path.join(__dirname, "res", "img", "logo.png"), - "frame": true, + "frame": false, "transparent": true, "resizable": false, "show": false @@ -25,6 +66,21 @@ electron.app.on("ready", function () { window.on("ready-to-show", window.show); }); +electron.ipcMain.handle("minimize", function (event) { + return window.minimize(); +}); + +electron.ipcMain.handle("close", function (event) { + return window.close(); +}); + +electron.ipcMain.handle("is-installed", function (event) { + return checker.check_install(gameDirectory); +}); +electron.ipcMain.handle("install", function (event) { + return downloader.download_minecraft(gameDirectory, meta["minecraft"], sources["minecraft"], showProgress); +}); + electron.app.on("window-all-closed", function () { electron.app.quit(); }); diff --git a/meta.json b/meta.json index cbb34f5..5458477 100644 --- a/meta.json +++ b/meta.json @@ -1,5 +1,6 @@ { + "install_directory": "games/.openmod", "minecraft": { - "version": "${MINECRAFT_VERSION}" + "version": "1.7.10" } } diff --git a/preload.js b/preload.js index e69de29..c5e373f 100644 --- a/preload.js +++ b/preload.js @@ -0,0 +1,18 @@ +const electron = require("electron"); + +electron.contextBridge.exposeInMainWorld("minimizeWindow", function () { + return electron.ipcRenderer.invoke("minimize"); +}); + +electron.contextBridge.exposeInMainWorld("closeWindow", function () { + return electron.ipcRenderer.invoke("close"); +}); + +electron.contextBridge.exposeInMainWorld("game", { + "isInstalled": function () { + return electron.ipcRenderer.invoke("is-installed"); + }, + "install": function () { + return electron.ipcRenderer.invoke("install"); + } +}); diff --git a/res/css/main.css b/res/css/main.css new file mode 100644 index 0000000..df198f0 --- /dev/null +++ b/res/css/main.css @@ -0,0 +1,47 @@ +:root { + color: rgba(248, 248, 248, 1.0); +} + +body { + margin: 0px; +} + +#titlebar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + -webkit-app-region: drag; + width: 100%; + height: 24px; + position: absolute; + top: 0px; + left: 0px; + background-color: rgba(16, 16, 16, 1.0); +} + +#window-actions { + display: flex; + flex-direction: row; + height: 100%; + -webkit-app-region: no-drag; + pointer-events: all; +} + +.window-action { + background-color: rgba(24, 24, 32, 1.0); + width: 32px; + height: 100%; +} + +.window-action:hover { + background-color: rgba(48, 48, 64, 1.0); +} + +#window-close { + background-color: rgba(128, 0, 0, 1.0); +} + +#window-close:hover { + background-color: rgba(248, 0, 0, 1.0); +} diff --git a/sources.json b/sources.json index 51218a7..cc81e4c 100644 --- a/sources.json +++ b/sources.json @@ -1,5 +1,9 @@ { "minecraft": { - "versions_manifest": "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json" + "versions_manifest": "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json", + "asset_base": "https://resources.download.minecraft.net/" + }, + "custom": { + "files_manifest": "${CUSTOM_MANIFEST_URL}" } } diff --git a/views/main.html b/views/main.html index 381c543..c4b7086 100644 --- a/views/main.html +++ b/views/main.html @@ -3,10 +3,20 @@