From 1ae8ee00dbdf2cc612823abbc567b1d842f66cd8 Mon Sep 17 00:00:00 2001 From: azures04 Date: Sun, 25 Jan 2026 08:59:22 +0100 Subject: [PATCH] Add Minecraft game launch and asset management Introduces core game logic for launching Minecraft, including asset, library, and Java runtime management. Adds Forge mod support, OS utilities, and file helpers. Updates frontend to handle game launch, log streaming, and UI feedback. Minor bugfixes and style improvements are also included. --- .gitignore | 2 + src/main/Program.cs | 22 ++- src/main/core/game/Assets.cs | 94 +++++++++++ src/main/core/game/Game.cs | 124 +++++++++++++++ src/main/core/game/Java.cs | 96 +++++++++++ src/main/core/game/Libraries.cs | 90 +++++++++++ src/main/core/game/Versions.cs | 255 ++++++++++++++++++++++++++++++ src/main/core/game/extra/Forge.cs | 152 ++++++++++++++++++ src/main/core/utils/File.cs | 24 +++ src/main/core/utils/Os.Cs | 18 +++ src/main/utils/Settings.cs | 4 +- wwwroot/assets/css/logged.css | 9 ++ wwwroot/assets/js/logged.js | 45 +++++- wwwroot/logged.html | 10 +- 14 files changed, 938 insertions(+), 7 deletions(-) create mode 100644 src/main/core/game/Assets.cs create mode 100644 src/main/core/game/Game.cs create mode 100644 src/main/core/game/Java.cs create mode 100644 src/main/core/game/Libraries.cs create mode 100644 src/main/core/game/Versions.cs create mode 100644 src/main/core/game/extra/Forge.cs create mode 100644 src/main/core/utils/File.cs create mode 100644 src/main/core/utils/Os.Cs diff --git a/.gitignore b/.gitignore index 7282dbf..06f9cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ CodeCoverage/ *.VisualState.xml TestResult.xml nunit-*.xml + +temp \ No newline at end of file diff --git a/src/main/Program.cs b/src/main/Program.cs index 32c519d..4c0c235 100644 --- a/src/main/Program.cs +++ b/src/main/Program.cs @@ -1,5 +1,7 @@ using Lentia.Core.Auth.OAuth2; using Lentia.Core.Constants; +using Lentia.Core.Game; +using Lentia.Core.Game.Extra; using Lentia.Utils; using Photino.NET; using System.Text.Json; @@ -10,11 +12,12 @@ namespace Lentia; class Program { private static LentRules.AuthenticateResponse? _authenticatedPlayer; private static readonly string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + private static readonly string gameRoot = Path.Combine(appData, ".lentia2"); private static readonly LentRules.Authenticator _ygg = new(); [STAThread] static void Main(string[] args) { - SettingsManager.InitSettings(Path.Combine(appData, ".lentia2")); + SettingsManager.InitSettings(gameRoot); CreateLoginWindow().WaitForClose(); } @@ -206,6 +209,23 @@ class Program { payload = new { success = false, error = discordLoginResult.Error }; } break; + + case "launcher::game": + try { + MinecraftVersion version = await GameHelper.PrepareGame("1.12.2", gameRoot, true); + GameHelper.Launch(version, _authenticatedPlayer!, gameRoot, (logLine) => { + var logMessage = new { + requestId = "game::log", + payload = new { message = logLine.ToString() } + }; + window.SendWebMessage(JsonSerializer.Serialize(logMessage, LauncherConstants._jsonOptions)); + }); + payload = new { success = true }; + } catch (Exception) { + payload = new { success = false }; + throw; + } + break; } var response = new { requestId, payload }; diff --git a/src/main/core/game/Assets.cs b/src/main/core/game/Assets.cs new file mode 100644 index 0000000..e67d565 --- /dev/null +++ b/src/main/core/game/Assets.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Lentia.Core.Constants; +using Lentia.Core.Utils; + +namespace Lentia.Core.Game; +public class AssetsIndex { + [JsonPropertyName("objects")] + public Dictionary? Objects { get; set; } +} + +public class AssetObject { + [JsonPropertyName("hash")] + public string? Hash { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } +} + +public class AssetsHelper { + + public static string GetAssetLocalPath(string hash, string assetsDir) { + string subFolder = hash.Substring(0, 2); + return Path.Combine(assetsDir, subFolder, hash); + } + + public static async Task GetAssetsIndexAsync(string url) { + var response = await LauncherConstants.Http.GetAsync(url); + + if (response.IsSuccessStatusCode) { + string json = await response.Content.ReadAsStringAsync(); + var index = JsonSerializer.Deserialize(json, LauncherConstants._jsonOptions); + + return index ?? throw new Exception("L'index des assets est vide ou invalide."); + } + + throw new Exception($"Impossible de récupérer l'index des assets : {response.StatusCode}"); + } + + public static async Task DownloadAssets(AssetsIndex index, string rootDir) { + string objectsDir = Path.Combine(rootDir, "assets", "objects"); + + foreach (var entry in index.Objects!) { + string hash = entry.Value.Hash!; + long expectedSize = entry.Value.Size; + string localPath = GetAssetLocalPath(hash, objectsDir); + + bool needsDownload = !File.Exists(localPath); + + if (!needsDownload) { + var fileInfo = new FileInfo(localPath); + if (fileInfo.Length != expectedSize) { + needsDownload = true; + } + } + + if (needsDownload) { + string url = $"https://resources.download.minecraft.net/{hash.Substring(0, 2)}/{hash}"; + await FileHelper.DownloadFileAsync(url, localPath); + } + } + } + + public static async Task DownloadAssetsParallel(AssetsIndex index, string rootDir) { + string objectsDir = Path.Combine(rootDir, "assets", "objects"); + + var uniqueAssets = index.Objects! + .GroupBy(x => x.Value.Hash) + .Select(g => g.First().Value) + .ToList(); + + var options = new ParallelOptions { MaxDegreeOfParallelism = 8 }; + + await Parallel.ForEachAsync(uniqueAssets, options, async (asset, token) => { + string hash = asset.Hash!; + long expectedSize = asset.Size; + string localPath = GetAssetLocalPath(hash, objectsDir); + + if (File.Exists(localPath)) { + if (new FileInfo(localPath).Length == expectedSize) return; + } + + string prefix = hash.Substring(0, 2); + string url = $"https://resources.download.minecraft.net/{prefix}/{hash}"; + + try { + await FileHelper.DownloadFileAsync(url, localPath); + } catch (Exception ex) { + Console.WriteLine($"Échec : {hash} - {ex.Message}"); + } + }); + } + +} \ No newline at end of file diff --git a/src/main/core/game/Game.cs b/src/main/core/game/Game.cs new file mode 100644 index 0000000..fc012d2 --- /dev/null +++ b/src/main/core/game/Game.cs @@ -0,0 +1,124 @@ +using System.Diagnostics; +using Lentia.Core.Auth.Yggdrasil; +using Lentia.Core.Game.Extra; +using Lentia.Core.Utils; +using Lentia.Utils; + +namespace Lentia.Core.Game; + +public static class GameHelper { + + public static async Task PrepareGame(string targetVersion, string gameRoot, bool installForge) { + try { + string versionUrl = await VersionsHelper.GetVersionUrlAsync(targetVersion); + MinecraftVersion version = await VersionsHelper.GetVersionDetailsAsync(versionUrl); + MinecraftVersion workingVersion = version; + + if (installForge) { + MinecraftVersion? forgeMeta = await ForgeHelper.ProcessForge(version, gameRoot); + if (forgeMeta != null) { + workingVersion = version; + } + } + + AssetsIndex assetsIndex = await AssetsHelper.GetAssetsIndexAsync(version.AssetIndex!.Url!); + await AssetsHelper.DownloadAssetsParallel(assetsIndex, gameRoot); + await JavaHelper.GetJavaExecutablePath(version, gameRoot); + await VersionsHelper.DownloadClientJar(version, gameRoot); + await LibrariesHelper.DownloadLibraries(workingVersion.Libraries!, gameRoot); + LibrariesHelper.ExtractNatives(workingVersion, gameRoot); + await VersionsHelper.DownloadLoggingConfig(version, gameRoot); + + return workingVersion; + } catch (Exception) { + throw; + } + } + + public static string BuildClasspath(MinecraftVersion version, string gameRoot) { + var paths = new List(); + string libRoot = Path.Combine(gameRoot, "libraries"); + + paths.Add(Path.Combine(gameRoot, "versions", version.Id!, $"{version.Id}.jar")); + + foreach (var lib in version.Libraries!) { + if (LibrariesHelper.IsAllowed(lib.Rules, OsHelper.GetOSName())) { + string relPath = ForgeHelper.GeneratePathFromArtifactName(lib.Name!); + paths.Add(Path.Combine(libRoot, relPath)); + } + } + + string separator = Path.PathSeparator.ToString(); + return string.Join(separator, paths); + } + + public static List GenerateGameArguments(MinecraftVersion version, AuthenticateResponse auth, string gameRoot) { + string? rawArgs = version.MinecraftArguments; + + if (string.IsNullOrEmpty(rawArgs)) { + return new List(); + } + + rawArgs = rawArgs.Replace("${auth_player_name}", auth.User.Username); + rawArgs = rawArgs.Replace("${version_name}", version.Id ?? "Lentia"); + rawArgs = rawArgs.Replace("${game_directory}", gameRoot); + rawArgs = rawArgs.Replace("${assets_root}", Path.Combine(gameRoot, "assets")); + rawArgs = rawArgs.Replace("${assets_index_name}", version.AssetIndex?.Id ?? "legacy"); + rawArgs = rawArgs.Replace("${auth_uuid}", auth.SelectedProfile.Id); + rawArgs = rawArgs.Replace("${auth_access_token}", auth.AccessToken); + rawArgs = rawArgs.Replace("${user_type}", "mojang"); + + rawArgs = rawArgs.Replace("${user_properties}", "{}"); + + return rawArgs.Split(" ", StringSplitOptions.RemoveEmptyEntries).ToList(); + } + + public static void Launch(MinecraftVersion version, AuthenticateResponse auth, string gameRoot, Action logHandler) { + string javaPath = SettingsManager.ReadSettings().JavaPath; + + string nativesDir = Path.Combine(gameRoot, "versions", version.Id!, "natives"); + + var jvmArgs = new List { + $"-Xmx{SettingsManager.ReadSettings().Ram.Max}M", + $"-Djava.library.path={nativesDir}", + "-Dminecraft.launcher.brand=Lentia", + "-Dminecraft.launcher.version=1.0.0" + }; + + // if (version.Logging?.Client?.Argument != null) { + // string logConfig = Path.Combine(gameRoot, "assets", "log_configs", version.Logging.Client.File?.Id ?? "client-log4j2.xml"); + // jvmArgs.Add(version.Logging.Client.Argument.Replace("${path}", logConfig)); + // } + + string classpath = BuildClasspath(version, gameRoot); + jvmArgs.Add("-cp"); + jvmArgs.Add(classpath); + + jvmArgs.Add(version.MainClass ?? "net.minecraft.client.main.Main"); + + var gameArgs = GenerateGameArguments(version, auth, gameRoot); + jvmArgs.AddRange(gameArgs); + + var startInfo = new ProcessStartInfo(javaPath) { + WorkingDirectory = gameRoot, + Arguments = string.Join(" ", jvmArgs.Select(a => a.Contains(" ") ? $"\"{a}\"" : a)), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + + process.OutputDataReceived += (s, e) => { if (e.Data != null) logHandler(e.Data); }; + process.ErrorDataReceived += (s, e) => { if (e.Data != null) logHandler($"[ERROR] {e.Data}"); }; + process.Exited += (sender, e) => { + logHandler("GAME_CLOSED"); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + +} \ No newline at end of file diff --git a/src/main/core/game/Java.cs b/src/main/core/game/Java.cs new file mode 100644 index 0000000..d80e2e1 --- /dev/null +++ b/src/main/core/game/Java.cs @@ -0,0 +1,96 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Net.Http.Json; +using System.Text.Json; +using Lentia.Core.Constants; +using Lentia.Core.Utils; +using Lentia.Utils; +using System.Text.Json.Serialization; + +namespace Lentia.Core.Game; + +public class JavaRuntimeManifest { + [JsonPropertyName("files")] + public Dictionary? Files { get; set; } +} + +public class JavaFileEntry { + [JsonPropertyName("type")] + public string? Type { get; set; } + [JsonPropertyName("downloads")] + public JavaDownloads? Downloads { get; set; } +} + +public class JavaDownloads { + [JsonPropertyName("raw")] + public DownloadInfo? Raw { get; set; } +} + +public static class JavaHelper { + + public static async Task GetJavaExecutablePath(MinecraftVersion version, string root) { + string component = version.JavaVersion?.Component ?? "jre-legacy"; + int majorVersion = version.JavaVersion?.MajorVersion ?? 8; + + string configuredJava = SettingsManager.ReadSettings().JavaPath; + + if (IsJavaCompatible(configuredJava, majorVersion)) { + return configuredJava; + } + + string platform = OsHelper.GetPlatformString(); + string localJavaPath = Path.Combine(root, "runtime", component, platform, "bin", + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "java.exe" : "java"); + + if (File.Exists(localJavaPath)) { + return localJavaPath; + } + + await DownloadJavaRuntime(root, platform, component); + + return localJavaPath; + } + + private static bool IsJavaCompatible(string javaPath, int requiredMajor) { + try { + var psi = new ProcessStartInfo(javaPath, "-version") { + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = Process.Start(psi); + string output = process?.StandardError.ReadToEnd() ?? ""; + + string search = requiredMajor <= 8 ? $"1.{requiredMajor}" : $"{requiredMajor}."; + return output.Contains(search); + } catch { + return false; + } + } + + private static async Task DownloadJavaRuntime(string root, string platform, string component) { + string allJsonUrl = "https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc6c0dba9f635e09f3900331038590632291/all.json"; + var allRuntimes = await LauncherConstants.Http.GetFromJsonAsync(allJsonUrl); + + if (!allRuntimes.TryGetProperty(platform, out var platformData) || + !platformData.TryGetProperty(component, out var componentVersions)) { + throw new Exception($"Le runtime {component} n'est pas disponible pour {platform}"); + } + + string manifestUrl = componentVersions[0].GetProperty("manifest").GetProperty("url").GetString()!; + var manifest = await LauncherConstants.Http.GetFromJsonAsync(manifestUrl); + + string javaDir = Path.Combine(root, "runtime", component, platform); + await Parallel.ForEachAsync(manifest!.Files!, new ParallelOptions { MaxDegreeOfParallelism = 8 }, async (entry, token) => { + string localPath = Path.Combine(javaDir, entry.Key); + if (entry.Value.Type == "directory") { + Directory.CreateDirectory(localPath); + } else if (entry.Value.Downloads?.Raw != null) { + var download = entry.Value.Downloads.Raw; + if (!File.Exists(localPath) || new FileInfo(localPath).Length != download.Size) { + await FileHelper.DownloadFileAsync(download.Url!, localPath); + } + } + }); + } +} \ No newline at end of file diff --git a/src/main/core/game/Libraries.cs b/src/main/core/game/Libraries.cs new file mode 100644 index 0000000..816a196 --- /dev/null +++ b/src/main/core/game/Libraries.cs @@ -0,0 +1,90 @@ +using System.IO.Compression; +using Lentia.Core.Utils; + +namespace Lentia.Core.Game; + +public static class LibrariesHelper { + + public static async Task DownloadLibraries(List libraries, string root) { + string osName = OsHelper.GetOSName(); + string librariesDir = Path.Combine(root, "libraries"); + + var downloadsToProcess = new List(); + + foreach (var library in libraries) { + if (!IsAllowed(library.Rules, osName)) continue; + + if (library.Downloads?.Artifact != null) { + downloadsToProcess.Add(library.Downloads.Artifact); + } + + if (library.Natives != null && library.Natives.TryGetValue(osName, out string? classifierKey)) { + if (library.Downloads?.Classifiers != null && library.Downloads.Classifiers.TryGetValue(classifierKey, out var nativeInfo)) { + downloadsToProcess.Add(nativeInfo); + } + } + } + + var uniqueDownloads = downloadsToProcess + .GroupBy(d => d.Path) + .Select(g => g.First()) + .ToList(); + + var options = new ParallelOptions { MaxDegreeOfParallelism = 8 }; + await Parallel.ForEachAsync(uniqueDownloads, options, async (info, token) => { + await DownloadDownloadInfo(info, librariesDir); + }); +} + + private static async Task DownloadDownloadInfo(DownloadInfo info, string baseDir) { + if (string.IsNullOrEmpty(info.Url) || string.IsNullOrEmpty(info.Path)) return; + + string localPath = Path.Combine(baseDir, info.Path); + + if (File.Exists(localPath) && new FileInfo(localPath).Length == info.Size) return; + + await FileHelper.DownloadFileAsync(info.Url, localPath); + } + + public static void ExtractNatives(MinecraftVersion version, string root) { + string osName = OsHelper.GetOSName(); + string librariesDir = Path.Combine(root, "libraries"); + string nativesDir = Path.Combine(root, "versions", version.Id!, "natives"); + + if (Directory.Exists(nativesDir)) Directory.Delete(nativesDir, true); + Directory.CreateDirectory(nativesDir); + + foreach (var library in version.Libraries!) { + if (!IsAllowed(library.Rules, osName) || library.Natives == null) continue; + + if (library.Natives.TryGetValue(osName, out string? classifierKey)) { + if (library.Downloads?.Classifiers != null && library.Downloads.Classifiers.TryGetValue(classifierKey, out var nativeInfo)) { + + string jarPath = Path.Combine(librariesDir, nativeInfo.Path!); + if (!File.Exists(jarPath)) continue; + + using (ZipArchive archive = ZipFile.OpenRead(jarPath)) { + foreach (ZipArchiveEntry entry in archive.Entries) { + if (entry.FullName.EndsWith("/") || entry.FullName.StartsWith("META-INF")) continue; + string destinationPath = Path.Combine(nativesDir, entry.Name); + entry.ExtractToFile(destinationPath, true); + } + } + } + } + } + } + + public static bool IsAllowed(List? rules, string osName) { + if (rules == null || rules.Count == 0) return true; + + bool allowed = false; + foreach (var rule in rules) { + if (rule.Os == null || rule.Os.Name == osName) { + allowed = rule.Action == "allow"; + } + } + return allowed; + } + +} \ No newline at end of file diff --git a/src/main/core/game/Versions.cs b/src/main/core/game/Versions.cs new file mode 100644 index 0000000..dd49711 --- /dev/null +++ b/src/main/core/game/Versions.cs @@ -0,0 +1,255 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Lentia.Core.Constants; +using Lentia.Core.Utils; + +namespace Lentia.Core.Game; + +public class Arguments { + [JsonPropertyName("game")] + public List? Game { get; set; } + + [JsonPropertyName("jvm")] + public List? Jvm { get; set; } +} + +public class ConditionalArgument { + [JsonPropertyName("rules")] + public List? Rules { get; set; } + + [JsonPropertyName("value")] + public JsonElement Value { get; set; } +} + +public class MinecraftVersion { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("arguments")] + public Arguments? Arguments { get; set; } + + [JsonPropertyName("assetIndex")] + public AssetIndexInfo? AssetIndex { get; set; } + + [JsonPropertyName("downloads")] + public Dictionary? Downloads { get; set; } + + [JsonPropertyName("libraries")] + public List? Libraries { get; set; } + + [JsonPropertyName("mainClass")] + public string? MainClass { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("logging")] + public LoggingInfo? Logging { get; set; } + + [JsonPropertyName("javaVersion")] + public JavaVersionInfo? JavaVersion { get; set; } + + [JsonPropertyName("minecraftArguments")] + public string? MinecraftArguments { get; set; } +} + +public class AssetIndexInfo { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("sha1")] + public string? Sha1 { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("totalSize")] + public long TotalSize { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +public class Library { + [JsonPropertyName("downloads")] + public LibraryDownloads? Downloads { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("rules")] + public List? Rules { get; set; } + + [JsonPropertyName("natives")] + public Dictionary? Natives { get; set; } +} + +public class LibraryDownloads { + [JsonPropertyName("artifact")] + public DownloadInfo? Artifact { get; set; } + + [JsonPropertyName("classifiers")] + public Dictionary? Classifiers { get; set; } +} + +public class DownloadInfo { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("sha1")] + public string? Sha1 { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } +} + +public class Rule { + [JsonPropertyName("action")] + public string? Action { get; set; } + + [JsonPropertyName("os")] + public OsRestriction? Os { get; set; } +} + +public class OsRestriction { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } +} + +public class VersionManifestV2 +{ + [JsonPropertyName("latest")] + public LatestVersion? Latest { get; set; } + + [JsonPropertyName("versions")] + public List? Versions { get; set; } +} + +public class LatestVersion +{ + [JsonPropertyName("release")] + public string? Release { get; set; } + + [JsonPropertyName("snapshot")] + public string? Snapshot { get; set; } +} + +public class VersionEntry +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("time")] + public DateTime Time { get; set; } + + [JsonPropertyName("releaseTime")] + public DateTime ReleaseTime { get; set; } + + [JsonPropertyName("sha1")] + public string? Sha1 { get; set; } + + [JsonPropertyName("complianceLevel")] + public int ComplianceLevel { get; set; } +} + +public class LoggingInfo { + [JsonPropertyName("client")] + public LoggingClient? Client { get; set; } +} + +public class LoggingClient { + + [JsonPropertyName("argument")] + public string? Argument { get; set; } + + [JsonPropertyName("file")] + public DownloadInfo? File { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } +} + +public class JavaVersionInfo { + [JsonPropertyName("component")] + public string? Component { get; set; } + + [JsonPropertyName("majorVersion")] + public int MajorVersion { get; set; } +} + +public static class VersionsHelper { + + public static async Task GetVersionUrlAsync(string targetVersion) { + var response = await LauncherConstants.Http.GetAsync("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"); + + var manifest = await response.Content.ReadFromJsonAsync(); + var versionEntry = manifest?.Versions?.FirstOrDefault(v => v.Id == targetVersion); + + + return versionEntry?.Url ?? throw new Exception("Version introuvable dans le manifest Mojang."); + } + + public static async Task GetVersionDetailsAsync(string versionUrl) { + var response = await LauncherConstants.Http.GetAsync(versionUrl); + + if (response.IsSuccessStatusCode) { + string jsonResponse = await response.Content.ReadAsStringAsync(); + var version = JsonSerializer.Deserialize(jsonResponse, LauncherConstants._jsonOptions); + + return version ?? throw new Exception("Impossible de lire les détails de la version."); + } else { + throw new Exception($"Erreur lors de la récupération des détails : {response.StatusCode}"); + } + } + + public static async Task DownloadClientJar(MinecraftVersion version, string root) { + string jarVersionPath = Path.Combine(root, "versions", version.Id!, $"{version.Id}.jar"); + try { + bool needsDownload = !File.Exists(jarVersionPath); + + if (!needsDownload) { + var fileInfo = new FileInfo(jarVersionPath); + long expectedSize = version.Downloads!["client"].Size; + if (fileInfo.Length != expectedSize) { + needsDownload = true; + } + } + + if (needsDownload) { + await FileHelper.DownloadFileAsync(version.Downloads!["client"].Url!, jarVersionPath); + } + } catch (Exception) { + throw; + } + } + + public static async Task DownloadLoggingConfig(MinecraftVersion version, string root) { + if (version.Logging?.Client?.File == null) return; + + var fileInfo = version.Logging.Client.File; + string logConfigDir = Path.Combine(root, "assets", "log_configs"); + string destinationPath = Path.Combine(logConfigDir, fileInfo.Id ?? "client-log4j2.xml"); + + if (File.Exists(destinationPath) && new FileInfo(destinationPath).Length == fileInfo.Size) { + return; + } + + await FileHelper.DownloadFileAsync(fileInfo.Url!, destinationPath); + } +} \ No newline at end of file diff --git a/src/main/core/game/extra/Forge.cs b/src/main/core/game/extra/Forge.cs new file mode 100644 index 0000000..fee7b22 --- /dev/null +++ b/src/main/core/game/extra/Forge.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using System.IO.Compression; +using Lentia.Core.Constants; +using System.Text.Json.Serialization; +using Lentia.Core.Utils; +using System.Net.Http.Json; + +namespace Lentia.Core.Game.Extra; + +public static class ForgeHelper { + public class ForgePromotions { + [JsonPropertyName("promos")] + public Dictionary? Promos { get; set; } + } + + public static async Task FetchAndDownloadForge(string mcVersion, string root, bool recommended = true) { + string promoUrl = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"; + + LauncherConstants.Http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"); + + var promoData = await LauncherConstants.Http.GetFromJsonAsync(promoUrl); + string promoKey = $"{mcVersion}-{(recommended ? "recommended" : "latest")}"; + + if (promoData?.Promos == null || !promoData.Promos.TryGetValue(promoKey, out string? buildVersion)) { + throw new Exception($"Version Forge introuvable pour {promoKey}"); + } + + bool isModern = IsVersionModern(mcVersion); + string fullVersion = $"{mcVersion}-{buildVersion}"; + + string classifier = isModern ? "installer" : "universal"; + string downloadUrl = $"https://maven.minecraftforge.net/net/minecraftforge/forge/{fullVersion}/forge-{fullVersion}-{classifier}.jar"; + + string destination = Path.Combine(root, "temp", $"forge-{fullVersion}-{classifier}.jar"); + + await FileHelper.DownloadFileAsync(downloadUrl, destination); + + return destination; + } + + private static bool IsVersionModern(string mcVersion) { + if (string.IsNullOrEmpty(mcVersion)) return false; + var parts = mcVersion.Split("."); + if (parts.Length < 2) return false; + + if (int.TryParse(parts[1], out int minor)) { + return minor >= 12; + } + return false; + } + + public static void MergeForge(MinecraftVersion baseVersion, string forgeJsonContent) { + var forgeVersion = JsonSerializer.Deserialize(forgeJsonContent, LauncherConstants._jsonOptions); + if (forgeVersion == null) return; + + baseVersion.MainClass = forgeVersion.MainClass; + baseVersion.MinecraftArguments = forgeVersion.MinecraftArguments; + + if (forgeVersion.Libraries != null) { + foreach (var lib in forgeVersion.Libraries) { + if (lib.Downloads?.Artifact == null) { + TranslateToModernLibrary(lib); + } + + if (lib.Name!.Contains("net.minecraftforge:forge") && string.IsNullOrEmpty(lib.Downloads?.Artifact?.Url)) { + FixForgeUniversalUrl(lib); + } + + baseVersion.Libraries?.Add(lib); + } + } + } + + private static void TranslateToModernLibrary(Library lib) { + if (string.IsNullOrEmpty(lib.Name)) return; + + var parts = lib.Name.Split(":"); + if (parts.Length < 3) return; + + string group = parts[0].Replace(".", "/"); + string artifact = parts[1]; + string version = parts[2]; + string path = $"{group}/{artifact}/{version}/{artifact}-{version}.jar"; + + string baseUrl = "https://libraries.minecraft.net/"; + if (!baseUrl.EndsWith("/")) baseUrl += "/"; + + lib.Downloads = new LibraryDownloads { + Artifact = new DownloadInfo { + Path = path, + Url = baseUrl + path + } + }; + } + + private static void FixForgeUniversalUrl(Library lib) { + var parts = lib.Name!.Split(":"); + string version = parts[2]; + lib.Downloads!.Artifact!.Url = $"https://maven.minecraftforge.net/net/minecraftforge/forge/{version}/forge-{version}-universal.jar"; + } + + public static async Task ExtractForgeJson(string jarPath) { + using (ZipArchive archive = ZipFile.OpenRead(jarPath)) { + var entry = archive.Entries.FirstOrDefault(e => e.Name == "version.json"); + + if (entry == null) { + throw new Exception("Fichier version.json introuvable dans l'archive Forge."); + } + + using var reader = new StreamReader(entry.Open()); + string jsonContent = await reader.ReadToEndAsync(); + + return JsonSerializer.Deserialize(jsonContent, LauncherConstants._jsonOptions); + } + } + public static async Task ProcessForge(MinecraftVersion baseVersion, string root) { + if (baseVersion.Id == null) throw new ArgumentNullException(nameof(baseVersion.Id)); + + string downloadedJarPath = await FetchAndDownloadForge(baseVersion.Id, root, false); + + var forgeMeta = await ExtractForgeJson(downloadedJarPath); + if (forgeMeta == null) return null; + + var forgeLib = forgeMeta.Libraries?.FirstOrDefault(l => l.Name!.Contains("net.minecraftforge:forge")); + + if (forgeLib != null) { + string relPath = GeneratePathFromArtifactName(forgeLib.Name!); + string finalDestination = Path.Combine(root, "libraries", relPath); + + Directory.CreateDirectory(Path.GetDirectoryName(finalDestination)!); + + if (File.Exists(finalDestination)) File.Delete(finalDestination); + File.Move(downloadedJarPath, finalDestination); + } + + MergeForge(baseVersion, JsonSerializer.Serialize(forgeMeta, LauncherConstants._jsonOptions)); + + return forgeMeta; + } + public static string GeneratePathFromArtifactName(string name) { + if (string.IsNullOrEmpty(name) || !name.Contains(":")) return name; + + var parts = name.Split(":"); + + string group = parts[0].Replace(".", "/"); + string artifact = parts[1]; + string version = parts[2]; + + return $"{group}/{artifact}/{version}/{artifact}-{version}.jar"; + } + +} \ No newline at end of file diff --git a/src/main/core/utils/File.cs b/src/main/core/utils/File.cs new file mode 100644 index 0000000..1ac73dc --- /dev/null +++ b/src/main/core/utils/File.cs @@ -0,0 +1,24 @@ +using Lentia.Core.Constants; + +namespace Lentia.Core.Utils; + +public static class FileHelper { + public static async Task DownloadFileAsync(string url, string destinationPath) { + string? directory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(directory)) { + Directory.CreateDirectory(directory); + } + + if (File.Exists(destinationPath)) { + File.Delete(destinationPath); + } + + using var response = await LauncherConstants.Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + using var streamToReadFrom = await response.Content.ReadAsStreamAsync(); + using var streamToWriteTo = File.Create(destinationPath); + + await streamToReadFrom.CopyToAsync(streamToWriteTo); + } +} \ No newline at end of file diff --git a/src/main/core/utils/Os.Cs b/src/main/core/utils/Os.Cs new file mode 100644 index 0000000..5f036c8 --- /dev/null +++ b/src/main/core/utils/Os.Cs @@ -0,0 +1,18 @@ +using System.Runtime.InteropServices; + +namespace Lentia.Core.Utils; + +public static class OsHelper { + public static string GetPlatformString() { + string os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "mac-os" : "linux"; + string arch = RuntimeInformation.OSArchitecture == Architecture.X64 ? "x64" : "x86"; + return $"{os}-{arch}"; + } + + public static string GetOSName() { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "windows"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "osx"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "linux"; + return "unknown"; + } +} \ No newline at end of file diff --git a/src/main/utils/Settings.cs b/src/main/utils/Settings.cs index fcd6956..4a76bc1 100644 --- a/src/main/utils/Settings.cs +++ b/src/main/utils/Settings.cs @@ -92,10 +92,10 @@ public static class SettingsManager { next = Activator.CreateInstance(prop.PropertyType); prop.SetValue(current, next); } - current = next; + current = next!; } - var lastProp = current.GetType().GetProperty(properties[^1], + var lastProp = current!.GetType().GetProperty(properties[^1], System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase); if (lastProp == null || !lastProp.CanWrite) diff --git a/wwwroot/assets/css/logged.css b/wwwroot/assets/css/logged.css index bdc0ac5..1841468 100644 --- a/wwwroot/assets/css/logged.css +++ b/wwwroot/assets/css/logged.css @@ -372,4 +372,13 @@ div.profile > section.cosmectics > article.capes > div.capes > div.cape:hover { div.profile > section.cosmectics > article.capes > div.capes > div.cape.active, div.profile > section.cosmectics > article.capes > div.capes > div.cape.active:hover { filter: brightness(1); +} + + +div.loader > div.full > div.loading { + background-color: #E89032; +} + +div.loader > div.full { + background-color: transparent; } \ No newline at end of file diff --git a/wwwroot/assets/js/logged.js b/wwwroot/assets/js/logged.js index 13748ca..436d561 100644 --- a/wwwroot/assets/js/logged.js +++ b/wwwroot/assets/js/logged.js @@ -2,6 +2,7 @@ const buttons = document.querySelectorAll("button[frame]") const dynmapFrame = document.querySelector("article.frame.dynmap > iframe") const capesSelector = document.querySelector("article.capes > div.capes") const logsContainer = document.querySelector("div.container.logs") +const playButton = document.querySelector("button.play") let viewerInstance = new skinview3d.SkinViewer({ canvas: document.getElementById("skin"), @@ -231,17 +232,55 @@ window.changeUsername = async function changeUsername(newName) { window.gamelog = {} -window.gamelog.put = async function put(log) { +window.gamelog.put = function put(log) { const logElement = document.createElement("p") logElement.innerText = log - logsContainer.appendChild(log) + logsContainer.appendChild(logElement) } -window.gamelog.clear = async function clear() { +window.gamelog.clear = function clear() { logsContainer.innerHTML = "" } +window.play = async function play() { + gamelog.clear() + showLoadingBar() + playButton.setAttribute("disabled", "") + const gameLaunch = await system.call("launcher::game") + if (gameLaunch.success) { + hideLoadingBar() + } else { + hideLoadingBar() + playButton.removeAttribute("disabled") + } +} + +window.external.receiveMessage(message => { + try { + const data = JSON.parse(message) + + switch (data.requestId) { + case "game::log": + const logMessage = data.payload.message + if (logMessage != "GAME_CLOSED") { + console.log("MC:", logMessage) + gamelog.put(logMessage) + logsContainer.scrollTop = logsContainer.scrollHeight + } else { + playButton.removeAttribute("disabled") + } + break; + + default: + break; + } + } catch (e) { + console.error("Erreur réception message Photino:", e) + } +}) + await initSkin() await initSettings() await initCapesSelector() +hideLoadingBar() showFrame("game") \ No newline at end of file diff --git a/wwwroot/logged.html b/wwwroot/logged.html index f0de786..0638f1f 100644 --- a/wwwroot/logged.html +++ b/wwwroot/logged.html @@ -85,9 +85,17 @@

Lentia

- +
+
+
+
+
+
+
+