diff --git a/Млаумчерб.Клиент/LauncherLogger.cs b/Млаумчерб.Клиент/LauncherLogger.cs index a76eb34..bb91442 100644 --- a/Млаумчерб.Клиент/LauncherLogger.cs +++ b/Млаумчерб.Клиент/LauncherLogger.cs @@ -2,9 +2,12 @@ public class LauncherLogger : FileLogger { - public static readonly IOPath LogsDirectory = "launcher-logs"; + public static readonly IOPath LogsDirectory = "launcher_logs"; public LauncherLogger() : base(LogsDirectory, "млаумчерб") { +#if DEBUG + DebugLogEnabled = true; +#endif } } \ No newline at end of file diff --git a/Млаумчерб.Клиент/Network.cs b/Млаумчерб.Клиент/Network.cs index 47133e1..e0da061 100644 --- a/Млаумчерб.Клиент/Network.cs +++ b/Млаумчерб.Клиент/Network.cs @@ -1,72 +1,191 @@ -using System.Net.Http; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; +using System.Buffers; +using System.Net.Http; +using Млаумчерб.Клиент.классы; +using Timer = DTLib.Timer; namespace Млаумчерб.Клиент; -public record struct NetworkTransferResult(long BytesTotal, long BytesTransferred, long BytesPerSecond) -{ - public override string ToString() - { - return $"transferred {BytesTransferred}/{BytesTotal} bytes ({BytesPerSecond}) bps"; - } -} - -public class NetworkTransferTask -{ - public Task Task { get; private set; } - public Progress Progress { get; private set; } - - private Stream _src; - private Stream _dst; - private CancellationTokenSource _cts; - private DTLib.Timer _timer; - - public NetworkTransferTask(Stream src, Stream dst) - { - _src = src; - _dst = dst; - _cts = new CancellationTokenSource(); - Progress = new Progress(); - _timer = new(true, 1000, ReportProgress); - Task = Task.CompletedTask; - } - - public void Start() - { - _timer.Start(); - Task = _src.CopyToAsync(_dst); - _timer.Stop(); - } - - public void Stop() - { - _cts.Cancel(); - _timer.Stop(); - } - - private long previousBytesTransferred; - private void ReportProgress() - { - long bytesTotal = _src.Length, bytesTransferred = _src.Position; - long bytesPerSecond = bytesTransferred - previousBytesTransferred; - previousBytesTransferred = bytesTransferred; - ((IProgress)Progress).Report(new NetworkTransferResult(bytesTotal, bytesTransferred, bytesPerSecond)); - } - - public TaskAwaiter GetAwaiter() => Task.GetAwaiter(); -} - -public static class NetworkHelper +public static class Network { private static HttpClient http = new(); + private const string ASSET_SERVER_URL = "https://resources.download.minecraft.net/"; + private static readonly string[] VERSION_MANIFEST_URLS = { + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" + }; - public static NetworkTransferTask DownloadHTTPFileAsync(string url, Stream destinationStream) + public static async Task DownloadFileHTTP(string url, IOPath outPath, + Action>? transformFunc = null, CancellationToken ct = default) { - var sourceStream = http.GetStreamAsync(url).GetAwaiter().GetResult(); - NetworkTransferTask task = new(sourceStream, destinationStream); - task.Start(); - return task; + await using var src = await http.GetStreamAsync(url, ct); + await using var dst = File.OpenWrite(outPath); + + await src.CopyTransformAsync(dst, transformFunc, ct).ConfigureAwait(false); + } + + public static async Task CopyTransformAsync(this Stream src, Stream dst, + Action>? transformFunc = null, CancellationToken ct = default) + { + // default dotnet runtime buffer size + int bufferSize = 81920; + byte[] readBuffer = ArrayPool.Shared.Rent(bufferSize); + byte[] writeBuffer = ArrayPool.Shared.Rent(bufferSize); + try + { + var readTask = src.ReadAsync(readBuffer, 0, bufferSize, ct).ConfigureAwait(false); + while (true) + { + int readCount = await readTask; + if (readCount == 0) + break; + (readBuffer, writeBuffer) = (writeBuffer, readBuffer); + readTask = src.ReadAsync(readBuffer, 0, bufferSize, ct).ConfigureAwait(false); + transformFunc?.Invoke(new ArraySegment(writeBuffer, 0, readCount)); + dst.Write(writeBuffer, 0, readCount); + } + } + catch (OperationCanceledException) + { } + finally + { + ArrayPool.Shared.Return(readBuffer); + ArrayPool.Shared.Return(writeBuffer); + } + } + + public static async Task DownloadAssets(AssetIndexProperties assetIndexProperties, CancellationToken ct, bool force) + { + IOPath indexFilePath = Пролетариат.GetAssetIndexFilePath(assetIndexProperties.id); + if (File.Exists(indexFilePath) && !force) + return; + + IOPath indexFilePathTmp = indexFilePath + ".tmp"; + if(File.Exists(indexFilePathTmp)) + File.Delete(indexFilePathTmp); + + // TODO: add something to Downloads ScrollList + Приложение.Логгер.LogInfo(nameof(DownloadAssets), $"started downloading asset index to '{indexFilePathTmp}'"); + await DownloadFileHTTP(assetIndexProperties.url, indexFilePathTmp, null, ct); + Приложение.Логгер.LogInfo(nameof(DownloadAssets), "finished downloading asset index"); + + string indexFileText = File.ReadAllText(indexFilePathTmp); + AssetIndex assetIndex = JsonConvert.DeserializeObject(indexFileText) + ?? throw new Exception($"can't deserialize asset index file '{indexFilePathTmp}'"); + + var assets = new List>(); + HashSet assetHashes = new HashSet(); + long totalSize = 0; + long currentSize = 0; + foreach (var pair in assetIndex.objects) + { + if (!assetHashes.Contains(pair.Value.hash)) + { + assets.Add(pair); + assetHashes.Add(pair.Value.hash); + totalSize += pair.Value.size; + } + } + + void AddBytesCountAtomic(ArraySegment chunk) + { + long chunkSize = chunk.Count; + Interlocked.Add(ref currentSize, chunkSize); + } + + long prevSize = 0; + int timerDelay = 2000; + void ReportProgress() + { + // TODO: add something to Downloads ScrollList + long totalSizeM = totalSize/(1024*1024); + long currentSizeM = currentSize/(1024*1024); + long bytesPerSec = (currentSize - prevSize) / (timerDelay / 1000); + float KbytesPerSec = bytesPerSec / 1024f; + prevSize = currentSize; + Приложение.Логгер.LogDebug(nameof(DownloadAssets), + $"download progress {currentSizeM}Mb/{totalSizeM}Mb ({KbytesPerSec}Kb/s)"); + } + using Timer timer = new Timer(true, timerDelay, ReportProgress); + timer.Start(); + + Приложение.Логгер.LogInfo(nameof(DownloadAssets), "started downloading assets"); + int parallelDownloads = 32; + var tasks = new Task[parallelDownloads]; + var currentlyDownloadingFileHashes = new string[parallelDownloads]; + int i = 0; + foreach (var a in assets) + { + string hash = a.Value.hash; + string hashStart = hash.Substring(0, 2); + var assetUrl = $"{ASSET_SERVER_URL}/{hashStart}/{hash}"; + IOPath assetFilePath = Path.Concat("assets", "objects", hashStart, hash); + Приложение.Логгер.LogDebug(nameof(DownloadAssets), $"downloading asset '{a.Key}' {hash}"); + tasks[i] = DownloadFileHTTP(assetUrl, assetFilePath, AddBytesCountAtomic, ct); + currentlyDownloadingFileHashes[i] = hash; + if (++i == parallelDownloads) + { + await Task.WhenAll(tasks); + i = 0; + } + } + + await Task.WhenAll(tasks); + timer.Stop(); + timer.InvokeAction(); + File.Move(indexFilePathTmp, indexFilePath, true); + Приложение.Логгер.LogInfo(nameof(DownloadAssets), "finished downloading assets"); + } + + private static async Task> GetRemoteVersionDescriptorsAsync() + { + List descriptors = new(); + foreach (var url in VERSION_MANIFEST_URLS) + { + try + { + var manifestText = await http.GetStringAsync(url); + var catalog = JsonConvert.DeserializeObject(manifestText); + if (catalog != null) + descriptors.AddRange(catalog.versions); + } + catch (Exception ex) + { + Приложение.Логгер.LogWarn(nameof(Network), ex); + } + } + return descriptors; + } + + private static List? _versionPropsList; + /// empty list if couldn't find any remote versions + public static async Task> GetDownloadableVersions() + { + if(_versionPropsList == null) + { + _versionPropsList = new(); + var rvdlist = await GetRemoteVersionDescriptorsAsync(); + foreach (var r in rvdlist) + { + if(r.type == "release") + _versionPropsList.Add(new GameVersionProps(r.id, r.url)); + } + } + return _versionPropsList; + } + + public static async Task DownloadVersionFile(string url, IOPath filePath, bool force) + { + if(File.Exists(filePath) && !force) + return; + await DownloadFileHTTP(url, filePath); + } + + public static async Task DownloadJava(JavaVersion javaVersion, IOPath path, bool force) + { + + } + + public static async Task DownloadLibraries(List libraries, IOPath librariesDir, bool force) + { + } } \ No newline at end of file diff --git a/Млаумчерб.Клиент/VersionItemView.axaml b/Млаумчерб.Клиент/VersionItemView.axaml new file mode 100644 index 0000000..772efbb --- /dev/null +++ b/Млаумчерб.Клиент/VersionItemView.axaml @@ -0,0 +1,9 @@ + + + diff --git a/Млаумчерб.Клиент/VersionItemView.axaml.cs b/Млаумчерб.Клиент/VersionItemView.axaml.cs new file mode 100644 index 0000000..b7eaef1 --- /dev/null +++ b/Млаумчерб.Клиент/VersionItemView.axaml.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; +using Avalonia.Media; +using Млаумчерб.Клиент.классы; + +namespace Млаумчерб.Клиент; + +public partial class VersionItemView : ListBoxItem +{ + public GameVersionProps Props { get; } + private SolidColorBrush _avaliableColor = new(Color.FromRgb(30, 130, 40)); + private SolidColorBrush _unavaliableColor = new(Color.FromRgb(170, 70, 70)); + + public VersionItemView() + { + throw new NotImplementedException(); + } + + public VersionItemView(GameVersionProps props) + { + Props = props; + InitializeComponent(); + text.Text = props.Name; + props.DownloadCompleted += UpdateBackground; + UpdateBackground(); + } + + private void UpdateBackground() + { + Background = Props.IsDownloaded ? _avaliableColor : _unavaliableColor; + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/Главне.cs b/Млаумчерб.Клиент/Главне.cs index a46a181..801b097 100644 --- a/Млаумчерб.Клиент/Главне.cs +++ b/Млаумчерб.Клиент/Главне.cs @@ -1,7 +1,11 @@ global using System; +global using System.Collections; global using System.Collections.Generic; global using System.IO; global using System.Text; +global using System.Threading; +global using System.Threading.Tasks; +global using Newtonsoft.Json; global using DTLib.Logging; global using DTLib.Filesystem; global using File = DTLib.Filesystem.File; @@ -17,9 +21,16 @@ public class Главне [STAThread] public static void Main(string[] args) { - CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; - BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + try + { + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + Приложение.Логгер.LogError(nameof(Главне), ex); + } } // Avalonia configuration, don't remove; also used by visual designer. diff --git a/Млаумчерб.Клиент/Игра.cs b/Млаумчерб.Клиент/Игра.cs index 646470f..3513378 100644 --- a/Млаумчерб.Клиент/Игра.cs +++ b/Млаумчерб.Клиент/Игра.cs @@ -1,56 +1,85 @@ -using System.Threading; -using System.Threading.Tasks; -using CliWrap; +using CliWrap; using DTLib.Extensions; -using Newtonsoft.Json; using Млаумчерб.Клиент.классы; +using static Млаумчерб.Клиент.классы.Пролетариат; namespace Млаумчерб.Клиент; -public interface IGame +public class GameVersionDescriptor { - string Name { get; } - IOPath InstallationDirectory { get; } - Progress BeginUpdate(); - void CancelUpdate(); - Task Launch(); - void Close(); -} - -public class MinecraftVersion : IGame -{ - public string Name { get; } - public IOPath InstallationDirectory { get; } + private readonly GameVersionProps _props; + public string Name => _props.Name; + public IOPath WorkingDirectory { get; } private IOPath JavaExecutableFilePath; private MinecraftVersionDescriptor descriptor; private JavaArguments javaArgs; private GameArguments gameArgs; - private CancellationTokenSource? cts; + private CancellationTokenSource? gameCts; + private CancellationTokenSource? downloadCts; private CommandTask? commandTask; - public MinecraftVersion(IOPath descriptorFilePath) + public static async Task> GetAllVersionsAsync() { - Name = descriptorFilePath.LastName().ToString().Replace(".json", ""); - InstallationDirectory = Path.Concat(Приложение.Настройки.путь_к_кубачу, Name); - string descriptorText = File.ReadAllText(descriptorFilePath); + var propsList = new List(); + foreach (IOPath f in Directory.GetFiles(GetVersionDescriptorDir())) + { + string name = GetVersionDescriptorName(f); + propsList.Add(new GameVersionProps(name, null, f)); + } + var remoteVersions = await Network.GetDownloadableVersions(); + propsList.AddRange(remoteVersions); + return propsList; + } + + public static async Task CreateFromPropsAsync(GameVersionProps props) + { + if(!File.Exists(props.LocalDescriptorPath)) + { + if (props.RemoteDescriptorUrl is null) + throw new NullReferenceException("can't download game version descriptor '" + + props.Name + "', because RemoteDescriptorUrl is null"); + await Network.DownloadFileHTTP(props.RemoteDescriptorUrl, props.LocalDescriptorPath); + } + return new GameVersionDescriptor(props); + } + + private GameVersionDescriptor(GameVersionProps props) + { + _props = props; + WorkingDirectory = Path.Concat(Приложение.Настройки.путь_к_кубачу, Name); + string descriptorText = File.ReadAllText(props.LocalDescriptorPath); descriptor = JsonConvert.DeserializeObject(descriptorText) - ?? throw new Exception($"can't parse descriptor file '{descriptorFilePath}'"); + ?? throw new Exception($"can't parse descriptor file '{props.LocalDescriptorPath}'"); javaArgs = new JavaArguments(descriptor); gameArgs = new GameArguments(descriptor); JavaExecutableFilePath = Path.Concat(Приложение.Настройки.путь_к_жабе, "bin", OperatingSystem.IsWindows() ? "javaw.exe" : "javaw"); } - public Progress BeginUpdate() + public async void BeginUpdate(bool force) { - throw new NotImplementedException(); + try + { + downloadCts = new CancellationTokenSource(); + if(Приложение.Настройки.скачать_жабу) + await Network.DownloadJava(descriptor.javaVersion, Приложение.Настройки.путь_к_жабе, force); + await Network.DownloadAssets(descriptor.assetIndex, downloadCts.Token, force); + await Network.DownloadVersionFile(descriptor.downloads.client.url, GetVersionJarFilePath(Name), force); + await Network.DownloadLibraries(descriptor.libraries, GetLibrariesDir(), force); + // await Network.DownloadModpack(modpack, WorkingDirectory, force); + _props.IsDownloaded = true; + } + catch (Exception ex) + { + Ошибки.ПоказатьСообщение("GameUpdate", ex); + } } public void CancelUpdate() { - throw new NotImplementedException(); + downloadCts?.Cancel(); } public async Task Launch() @@ -58,24 +87,24 @@ public class MinecraftVersion : IGame var javaArgsList = javaArgs.FillPlaceholders([]); var gameArgsList = gameArgs.FillPlaceholders([]); var command = Cli.Wrap(JavaExecutableFilePath.ToString()) - .WithWorkingDirectory(InstallationDirectory.ToString()) + .WithWorkingDirectory(WorkingDirectory.ToString()) .WithArguments(javaArgsList) .WithArguments(gameArgsList); - Приложение.Логгер.LogInfo(nameof(MinecraftVersion), + Приложение.Логгер.LogInfo(nameof(GameVersionDescriptor), $"launching the game" + "\njava: " + command.TargetFilePath + "\nworking_dir: " + command.WorkingDirPath + "\njava_arguments: \n\t" + javaArgsList.MergeToString("\n\t") + "\ngame_arguments: \n\t" + gameArgsList.MergeToString("\n\t")); - cts = new(); - commandTask = command.ExecuteAsync(cts.Token); + gameCts = new(); + commandTask = command.ExecuteAsync(gameCts.Token); var result = await commandTask; - Приложение.Логгер.LogInfo(nameof(MinecraftVersion), $"game exited with code {result.ExitCode}"); + Приложение.Логгер.LogInfo(nameof(GameVersionDescriptor), $"game exited with code {result.ExitCode}"); } public void Close() { - cts?.Cancel(); + gameCts?.Cancel(); } public override string ToString() => Name; diff --git a/Млаумчерб.Клиент/Млаумчерб.Клиент.csproj b/Млаумчерб.Клиент/Млаумчерб.Клиент.csproj index 35d6ae5..b1b5add 100644 --- a/Млаумчерб.Клиент/Млаумчерб.Клиент.csproj +++ b/Млаумчерб.Клиент/Млаумчерб.Клиент.csproj @@ -10,6 +10,8 @@ true капитал\кубе.ico млаумчерб + Release;Debug + x64 @@ -19,7 +21,7 @@ - + diff --git a/Млаумчерб.Клиент/Настройки.cs b/Млаумчерб.Клиент/Настройки.cs index 5659b0d..da6ea44 100644 --- a/Млаумчерб.Клиент/Настройки.cs +++ b/Млаумчерб.Клиент/Настройки.cs @@ -1,6 +1,4 @@ -using Newtonsoft.Json; - -namespace Млаумчерб.Клиент; +namespace Млаумчерб.Клиент; public record Настройки { @@ -10,6 +8,7 @@ public record Настройки public string путь_к_кубачу { get; set; } = "."; public string путь_к_жабе { get; set; } = "java"; public bool скачать_жабу { get; set; } = true; + public string? последняя_запущенная_версия; public static Настройки ЗагрузитьИзФайла(string имя_файла = "млаумчерб.настройки") { @@ -27,7 +26,7 @@ public record Настройки File.Move(имя_файла, имя_файла + ".старые", true); н = new Настройки(); н.СохранитьВФайл(); - Ошибки.ПоказатьСообщение($"Не удалось прочитать настройки.\n" + + Ошибки.ПоказатьСообщение("Настройки", $"Не удалось прочитать настройки.\n" + $"Сломанный файл настроек переименован в '{имя_файла}.старые'.\n" + $"Создан новый файл '{имя_файла}'."); } diff --git a/Млаумчерб.Клиент/Окне.axaml b/Млаумчерб.Клиент/Окне.axaml index 6163c95..01cb904 100644 --- a/Млаумчерб.Клиент/Окне.axaml +++ b/Млаумчерб.Клиент/Окне.axaml @@ -61,8 +61,8 @@ Fullscreen - - Update game files + + Force update game files