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.
This commit is contained in:
Gilles Lazures 2026-01-25 08:59:22 +01:00
parent 26e78a82c0
commit 1ae8ee00db
14 changed files with 938 additions and 7 deletions

2
.gitignore vendored
View File

@ -55,3 +55,5 @@ CodeCoverage/
*.VisualState.xml
TestResult.xml
nunit-*.xml
temp

View File

@ -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 };

View File

@ -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<string, AssetObject>? 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<AssetsIndex> GetAssetsIndexAsync(string url) {
var response = await LauncherConstants.Http.GetAsync(url);
if (response.IsSuccessStatusCode) {
string json = await response.Content.ReadAsStringAsync();
var index = JsonSerializer.Deserialize<AssetsIndex>(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}");
}
});
}
}

124
src/main/core/game/Game.cs Normal file
View File

@ -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<MinecraftVersion> 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>();
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<string> GenerateGameArguments(MinecraftVersion version, AuthenticateResponse auth, string gameRoot) {
string? rawArgs = version.MinecraftArguments;
if (string.IsNullOrEmpty(rawArgs)) {
return new List<string>();
}
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<string> logHandler) {
string javaPath = SettingsManager.ReadSettings().JavaPath;
string nativesDir = Path.Combine(gameRoot, "versions", version.Id!, "natives");
var jvmArgs = new List<string> {
$"-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();
}
}

View File

@ -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<string, JavaFileEntry>? 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<string> 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<JsonElement>(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<JavaRuntimeManifest>(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);
}
}
});
}
}

View File

@ -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<Library> libraries, string root) {
string osName = OsHelper.GetOSName();
string librariesDir = Path.Combine(root, "libraries");
var downloadsToProcess = new List<DownloadInfo>();
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<Rule>? 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;
}
}

View File

@ -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<JsonElement>? Game { get; set; }
[JsonPropertyName("jvm")]
public List<JsonElement>? Jvm { get; set; }
}
public class ConditionalArgument {
[JsonPropertyName("rules")]
public List<Rule>? 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<string, DownloadInfo>? Downloads { get; set; }
[JsonPropertyName("libraries")]
public List<Library>? 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<Rule>? Rules { get; set; }
[JsonPropertyName("natives")]
public Dictionary<string, string>? Natives { get; set; }
}
public class LibraryDownloads {
[JsonPropertyName("artifact")]
public DownloadInfo? Artifact { get; set; }
[JsonPropertyName("classifiers")]
public Dictionary<string, DownloadInfo>? 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<VersionEntry>? 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<string> 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<VersionManifestV2>();
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<MinecraftVersion> GetVersionDetailsAsync(string versionUrl) {
var response = await LauncherConstants.Http.GetAsync(versionUrl);
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
var version = JsonSerializer.Deserialize<MinecraftVersion>(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);
}
}

View File

@ -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<string, string>? Promos { get; set; }
}
public static async Task<string> 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<ForgePromotions>(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<MinecraftVersion>(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<MinecraftVersion?> 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<MinecraftVersion>(jsonContent, LauncherConstants._jsonOptions);
}
}
public static async Task<MinecraftVersion?> 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";
}
}

View File

@ -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);
}
}

18
src/main/core/utils/Os.Cs Normal file
View File

@ -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";
}
}

View File

@ -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)

View File

@ -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;
}

View File

@ -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")

View File

@ -85,9 +85,17 @@
<h1>
Lentia
</h1>
<button class="play">
<button class="play" onclick="play()">
<i class="fas fa-play-circle"></i> Jouer
</button>
<footer>
<div class="loader">
<div class="full">
<div class="loading">
</div>
</div>
</div>
</footer>
</article>
<article class="frame dynmap" hidden>
<iframe src="" frameborder="0"></iframe>