diff --git a/Млаумчерб.Клиент/Игра.cs b/Млаумчерб.Клиент/Игра.cs index 19b3c84..0ec8c9f 100644 --- a/Млаумчерб.Клиент/Игра.cs +++ b/Млаумчерб.Клиент/Игра.cs @@ -2,11 +2,13 @@ using DTLib.Extensions; using Млаумчерб.Клиент.видимое; using Млаумчерб.Клиент.классы; +using Млаумчерб.Клиент.сеть; +using Млаумчерб.Клиент.сеть.NetworkTaskFactories; using static Млаумчерб.Клиент.классы.Пути; namespace Млаумчерб.Клиент; -public class GameVersionDescriptor +public class GameVersion { private readonly GameVersionProps _props; public string Name => _props.Name; @@ -14,13 +16,13 @@ public class GameVersionDescriptor private IOPath JavaExecutableFilePath; - private MinecraftVersionDescriptor descriptor; + private VersionDescriptor descriptor; private JavaArguments javaArgs; private GameArguments gameArgs; + private Libraries libraries; private CancellationTokenSource? gameCts; - private CancellationTokenSource? downloadCts; private CommandTask? commandTask; - + public static async Task> GetAllVersionsAsync() { var propsList = new List(); @@ -29,58 +31,76 @@ public class GameVersionDescriptor string name = GetVersionDescriptorName(f); propsList.Add(new GameVersionProps(name, null, f)); } + var remoteVersions = await Сеть.GetDownloadableVersions(); propsList.AddRange(remoteVersions); return propsList; - } - - public static async Task CreateFromPropsAsync(GameVersionProps props) + } + + public static async Task CreateFromPropsAsync(GameVersionProps props) { - if(!File.Exists(props.LocalDescriptorPath)) + 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"); + + props.Name + "', because RemoteDescriptorUrl is null"); await Сеть.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 '{props.LocalDescriptorPath}'"); - javaArgs = new JavaArguments(descriptor); - gameArgs = new GameArguments(descriptor); - JavaExecutableFilePath = Path.Concat(Приложение.Настройки.путь_к_жабе, "bin", - OperatingSystem.IsWindows() ? "javaw.exe" : "javaw"); - } - - public async void BeginUpdate(bool force) - { - try - { - downloadCts = new CancellationTokenSource(); - if(Приложение.Настройки.скачать_жабу) - await Сеть.DownloadJava(descriptor.javaVersion, Приложение.Настройки.путь_к_жабе, force); - await Сеть.DownloadAssets(descriptor.assetIndex, downloadCts.Token, force); - await Сеть.DownloadVersionFile(descriptor.downloads.client.url, GetVersionJarFilePath(Name), force); - await Сеть.DownloadLibraries(descriptor.libraries, GetLibrariesDir(), force); - // await Network.DownloadModpack(modpack, WorkingDirectory, force); - _props.IsDownloaded = true; - } - catch (Exception ex) - { - Ошибки.ПоказатьСообщение("GameUpdate", ex); - } + + return new GameVersion(props); } - public void CancelUpdate() + private GameVersion(GameVersionProps props) { - downloadCts?.Cancel(); + _props = props; + string descriptorText = File.ReadAllText(props.LocalDescriptorPath); + descriptor = JsonConvert.DeserializeObject(descriptorText) + ?? throw new Exception($"can't parse descriptor file '{props.LocalDescriptorPath}'"); + javaArgs = new JavaArguments(descriptor); + gameArgs = new GameArguments(descriptor); + libraries = new Libraries(descriptor); + WorkingDirectory = GetVersionDir(descriptor.id); + JavaExecutableFilePath = GetJavaExecutablePath(descriptor.javaVersion.component); + } + + public async Task> CreateUpdateTasksAsync(bool checkHashes) + { + List taskFactories = + [ + new AssetsDownloadTaskFactory(descriptor), + new LibrariesDownloadTaskFactory(descriptor, libraries), + new VersionFileDownloadTaskFactory(descriptor), + ]; + /*if(Приложение.Настройки.скачать_жабу) + { + taskFactories.Add(new JavaDownloadTaskFactory(descriptor)); + }*/ + /*if (modpack != null) + { + taskFactories.Add(new ModpackDownloadTaskFactory(modpack)); + }*/ + + List tasks = new(); + for (int i = 0; i < taskFactories.Count; i++) + { + var nt = await taskFactories[i].CreateAsync(checkHashes); + if (nt != null) + tasks.Add(nt); + } + + if (tasks.Count == 0) + { + _props.IsDownloaded = true; + } + else + { + tasks[^1].OnStop += status => + { + if (status == NetworkTask.Status.Completed) + _props.IsDownloaded = true; + }; + } + return tasks; } public async Task Launch() @@ -91,7 +111,7 @@ public class GameVersionDescriptor .WithWorkingDirectory(WorkingDirectory.ToString()) .WithArguments(javaArgsList) .WithArguments(gameArgsList); - Приложение.Логгер.LogInfo(nameof(GameVersionDescriptor), + Приложение.Логгер.LogInfo(nameof(GameVersion), $"launching the game" + "\njava: " + command.TargetFilePath + "\nworking_dir: " + command.WorkingDirPath + @@ -100,7 +120,7 @@ public class GameVersionDescriptor gameCts = new(); commandTask = command.ExecuteAsync(gameCts.Token); var result = await commandTask; - Приложение.Логгер.LogInfo(nameof(GameVersionDescriptor), $"game exited with code {result.ExitCode}"); + Приложение.Логгер.LogInfo(nameof(GameVersion), $"game exited with code {result.ExitCode}"); } public void Close() diff --git a/Млаумчерб.Клиент/Настройки.cs b/Млаумчерб.Клиент/Настройки.cs index 3c6097d..b302fdf 100644 --- a/Млаумчерб.Клиент/Настройки.cs +++ b/Млаумчерб.Клиент/Настройки.cs @@ -1,4 +1,5 @@ -using Млаумчерб.Клиент.видимое; +using DTLib.Extensions; +using Млаумчерб.Клиент.видимое; namespace Млаумчерб.Клиент; @@ -8,13 +9,14 @@ public record Настройки public int выделенная_память_мб { get; set; } = 4096; public bool открывать_на_весь_экран { get; set; } public string путь_к_кубачу { get; set; } = "."; - public string путь_к_жабе { get; set; } = "java"; public bool скачать_жабу { get; set; } = true; - public string? последняя_запущенная_версия; + public string? последняя_запущенная_версия { get; set; } + + [JsonIgnore] private Stream? fileWriteStream; public static Настройки ЗагрузитьИзФайла(string имя_файла = "млаумчерб.настройки") { - Приложение.Логгер.LogInfo(nameof(Настройки), $"попытка загрузить настройки из файла '{имя_файла}'"); + Приложение.Логгер.LogInfo(nameof(Настройки), $"загружаются настройки из файла '{имя_файла}'"); if(!File.Exists(имя_файла)) { Приложение.Логгер.LogInfo(nameof(Настройки), "файл не существует"); @@ -26,11 +28,11 @@ public record Настройки if (н == null) { File.Move(имя_файла, имя_файла + ".старые", true); - н = new Настройки(); - н.СохранитьВФайл(); Ошибки.ПоказатьСообщение("Настройки", $"Не удалось прочитать настройки.\n" + $"Сломанный файл настроек переименован в '{имя_файла}.старые'.\n" + - $"Создан новый файл '{имя_файла}'."); + $"Создаётся новый файл '{имя_файла}'."); + н = new Настройки(); + н.СохранитьВФайл(); } Приложение.Логгер.LogInfo(nameof(Настройки), $"настройки загружены: {н}"); @@ -39,9 +41,12 @@ public record Настройки public void СохранитьВФайл(string имя_файла = "млаумчерб.настройки") { - Приложение.Логгер.LogInfo(nameof(Настройки), $"попытка сохранить настройки в файл '{имя_файла}'"); + //TODO: file backup and restore + Приложение.Логгер.LogDebug(nameof(Настройки), $"настройки сохраняются в файл '{имя_файла}'"); + fileWriteStream ??= File.OpenWrite(имя_файла); var текст = JsonConvert.SerializeObject(this, Formatting.Indented); - File.WriteAllText(имя_файла, текст); - Приложение.Логгер.LogInfo(nameof(Настройки), $"настройки сохранены: {текст}"); + fileWriteStream.Seek(0, SeekOrigin.Begin); + fileWriteStream.FluentWriteString(текст).Flush(); + Приложение.Логгер.LogDebug(nameof(Настройки), $"настройки сохранены: {текст}"); } } \ No newline at end of file diff --git a/Млаумчерб.Клиент/Сеть.cs b/Млаумчерб.Клиент/Сеть.cs deleted file mode 100644 index e102068..0000000 --- a/Млаумчерб.Клиент/Сеть.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System.Buffers; -using System.Net.Http; -using Млаумчерб.Клиент.видимое; -using Млаумчерб.Клиент.классы; -using Timer = DTLib.Timer; - -namespace Млаумчерб.Клиент; - -public static class Сеть -{ - 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 async Task DownloadFileHTTP(string url, IOPath outPath, - Action>? transformFunc = null, CancellationToken ct = default) - { - 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(Сеть), 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/Млаумчерб.Клиент/видимое/DownloadItemView.axaml b/Млаумчерб.Клиент/видимое/DownloadItemView.axaml new file mode 100644 index 0000000..a5c5088 --- /dev/null +++ b/Млаумчерб.Клиент/видимое/DownloadItemView.axaml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/Млаумчерб.Клиент/видимое/DownloadItemView.axaml.cs b/Млаумчерб.Клиент/видимое/DownloadItemView.axaml.cs new file mode 100644 index 0000000..39bf4d9 --- /dev/null +++ b/Млаумчерб.Клиент/видимое/DownloadItemView.axaml.cs @@ -0,0 +1,42 @@ +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Млаумчерб.Клиент.сеть; + +namespace Млаумчерб.Клиент.видимое; + +public partial class DownloadTaskView : UserControl +{ + private readonly NetworkTask _task; + private readonly Action _removeFromList; + + + public DownloadTaskView() + { + throw new NotImplementedException(); + } + + public DownloadTaskView(NetworkTask task, Action removeFromList) + { + _task = task; + _removeFromList = removeFromList; + InitializeComponent(); + NameText.Text = task.Name; + task.OnProgress += ReportProgress; + } + + + void ReportProgress(DownloadProgress progress) + { + Dispatcher.UIThread.Invoke(() => + { + DownloadedProgressText.Text = progress.ToString(); + }); + } + + private void RemoveFromList(object? sender, RoutedEventArgs e) + { + _task.Cancel(); + Dispatcher.UIThread.Invoke(() => _removeFromList.Invoke(this)); + } +} diff --git a/Млаумчерб.Клиент/видимое/VersionItemView.axaml.cs b/Млаумчерб.Клиент/видимое/VersionItemView.axaml.cs index a8553d1..3aa36be 100644 --- a/Млаумчерб.Клиент/видимое/VersionItemView.axaml.cs +++ b/Млаумчерб.Клиент/видимое/VersionItemView.axaml.cs @@ -20,7 +20,7 @@ public partial class VersionItemView : ListBoxItem Props = props; InitializeComponent(); text.Text = props.Name; - props.DownloadCompleted += UpdateBackground; + props.OnDownloadCompleted += UpdateBackground; UpdateBackground(); } diff --git a/Млаумчерб.Клиент/видимое/Окне.axaml b/Млаумчерб.Клиент/видимое/Окне.axaml index c9e5c73..6b5f528 100644 --- a/Млаумчерб.Клиент/видимое/Окне.axaml +++ b/Млаумчерб.Клиент/видимое/Окне.axaml @@ -33,8 +33,9 @@ VerticalScrollBarVisibility="Visible" Background="Transparent"> @@ -92,10 +93,13 @@ Загрузки - + Background="Transparent" + Padding="1"> + + diff --git a/Млаумчерб.Клиент/видимое/Окне.axaml.cs b/Млаумчерб.Клиент/видимое/Окне.axaml.cs index 9921217..e4dc2cb 100644 --- a/Млаумчерб.Клиент/видимое/Окне.axaml.cs +++ b/Млаумчерб.Клиент/видимое/Окне.axaml.cs @@ -1,11 +1,9 @@ using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Presenters; using Avalonia.Data; using Avalonia.Interactivity; using Avalonia.Platform.Storage; using Avalonia.Threading; -using Avalonia.VisualTree; using Млаумчерб.Клиент.классы; namespace Млаумчерб.Клиент.видимое; @@ -59,19 +57,25 @@ public partial class Окне : Window { Приложение.Логгер.OnLogMessage += (context, severity, message, format) => { + if(severity == LogSeverity.Debug) + return; + StringBuilder b = new(); b.Append(DateTime.Now.ToString("[HH:mm:ss][")); b.Append(severity); b.Append("]: "); b.Append(message); b.Append('\n'); - double offsetFromBottom = LogScrollViewer.Extent.Height - - LogScrollViewer.Offset.Y - - LogScrollViewer.Viewport.Height; - bool is_scrolled_to_end = offsetFromBottom < 20.0; // scrolled less then one line up - LogTextBox.Text += b.ToString(); - if(is_scrolled_to_end) - LogScrollViewer.ScrollToEnd(); + Dispatcher.UIThread.Invoke(() => + { + double offsetFromBottom = LogScrollViewer.Extent.Height + - LogScrollViewer.Offset.Y + - LogScrollViewer.Viewport.Height; + bool is_scrolled_to_end = offsetFromBottom < 20.0; // scrolled less then one line up + LogTextBox.Text += b.ToString(); + if (is_scrolled_to_end) + LogScrollViewer.ScrollToEnd(); + }); }; Username = Приложение.Настройки.имя_пользователя; @@ -81,7 +85,7 @@ public partial class Окне : Window Directory.Create(Пути.GetVersionDescriptorDir()); VersionComboBox.SelectedIndex = 0; VersionComboBox.IsEnabled = false; - var versions = await GameVersionDescriptor.GetAllVersionsAsync(); + var versions = await GameVersion.GetAllVersionsAsync(); Dispatcher.UIThread.Invoke(() => { foreach (var p in versions) @@ -114,8 +118,18 @@ public partial class Окне : Window if (selectedVersion == null) return; - var v = await GameVersionDescriptor.CreateFromPropsAsync(selectedVersion); - v.BeginUpdate(CheckGameFiles); + var v = await GameVersion.CreateFromPropsAsync(selectedVersion); + var updateTasks = await v.CreateUpdateTasksAsync(CheckGameFiles); + foreach (var t in updateTasks) + { + var updateTask = t.StartAsync(); + Dispatcher.UIThread.Invoke(() => + { + var view = new DownloadTaskView(t, view => DownloadsPanel.Children.Remove(view)); + DownloadsPanel.Children.Add(view); + }); + await updateTask; + } Dispatcher.UIThread.Invoke(() => CheckGameFiles = false); } catch (Exception ex) diff --git a/Млаумчерб.Клиент/классы/GameArguments.cs b/Млаумчерб.Клиент/классы/GameArguments.cs index a2ba602..ecb0392 100644 --- a/Млаумчерб.Клиент/классы/GameArguments.cs +++ b/Млаумчерб.Клиент/классы/GameArguments.cs @@ -9,7 +9,7 @@ public class GameArguments : ArgumentsWithPlaceholders "has_custom_resolution" ]; - public GameArguments(MinecraftVersionDescriptor d) + public GameArguments(VersionDescriptor d) { if (d.minecraftArguments is not null) { diff --git a/Млаумчерб.Клиент/классы/GameVersionProps.cs b/Млаумчерб.Клиент/классы/GameVersionProps.cs index 6c57495..a508f16 100644 --- a/Млаумчерб.Клиент/классы/GameVersionProps.cs +++ b/Млаумчерб.Клиент/классы/GameVersionProps.cs @@ -11,11 +11,13 @@ public class GameVersionProps get => _isDownloaded; set { + bool downloadCompleted = value && !_isDownloaded; _isDownloaded = value; - DownloadCompleted?.Invoke(); + if(downloadCompleted) + OnDownloadCompleted?.Invoke(); } } - public event Action? DownloadCompleted; + public event Action? OnDownloadCompleted; public GameVersionProps(string name, string? url, IOPath descriptorPath) { diff --git a/Млаумчерб.Клиент/классы/JavaArguments.cs b/Млаумчерб.Клиент/классы/JavaArguments.cs index 45bc17d..1aa093c 100644 --- a/Млаумчерб.Клиент/классы/JavaArguments.cs +++ b/Млаумчерб.Клиент/классы/JavaArguments.cs @@ -12,7 +12,7 @@ public class JavaArguments : ArgumentsWithPlaceholders ]; - public JavaArguments(MinecraftVersionDescriptor d) + public JavaArguments(VersionDescriptor d) { raw_args.AddRange(_initial_arguments); if (d.arguments is not null) diff --git a/Млаумчерб.Клиент/классы/Libraries.cs b/Млаумчерб.Клиент/классы/Libraries.cs new file mode 100644 index 0000000..e846d48 --- /dev/null +++ b/Млаумчерб.Клиент/классы/Libraries.cs @@ -0,0 +1,67 @@ +using DTLib.Extensions; + +namespace Млаумчерб.Клиент.классы; + +public class Libraries +{ + private static readonly string[] enabled_features = []; + + public record JarLib(string name, IOPath jarFilePath, Artifact artifact); + public record NativeLib(string name, IOPath jarFilePath, Artifact artifact, IOPath nativeFilesDir, Extract? extractionOptions) + : JarLib(name, jarFilePath, artifact); + + public IReadOnlyCollection Libs { get; } + + public Libraries(VersionDescriptor descriptor) + { + List libs = new(); + HashSet libHashes = new(); + + foreach (var l in descriptor.libraries) + { + if (l.rules != null && !Буржуазия.CheckRules(l.rules, enabled_features)) + continue; + + if (l.natives != null) + { + string? nativesKey; + if (OperatingSystem.IsWindows()) + nativesKey = l.natives.windows; + else if (OperatingSystem.IsLinux()) + nativesKey = l.natives.linux; + else if (OperatingSystem.IsMacOS()) + nativesKey = l.natives.osx; + else throw new PlatformNotSupportedException(); + if(nativesKey is null) + throw new Exception($"nativesKey for '{l.name}' is null"); + + Artifact artifact = null!; + if(l.downloads.classifiers != null && !l.downloads.classifiers.TryGetValue(nativesKey, out artifact!)) + throw new Exception($"can't find artifact for '{l.name}' with nativesKey '{nativesKey}'"); + + // skipping duplicates (WHO THE HELL CREATES THIS DISCRIPTORS AAAAAAAAA) + if(!libHashes.Add(artifact.sha1)) + continue; + + IOPath dir = Пути.GetVersionDir(descriptor.id); + IOPath jarPath = Path.Concat(dir, Path.ReplaceRestrictedChars(l.name)); + libs.Add(new NativeLib(l.name, jarPath, artifact, dir, l.extract)); + } + else + { + Artifact? artifact = l.downloads.artifact; + if (artifact == null) + throw new NullReferenceException($"artifact for '{l.name}' is null"); + + // skipping duplicates + if(!libHashes.Add(artifact.sha1)) + continue; + + IOPath path = artifact.url.AsSpan().After("://").After('/').ToString(); + libs.Add(new JarLib(l.name, Path.Concat(Пути.GetLibrariesDir(), path), artifact)); + } + } + + Libs = libs; + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/классы/MinecraftVersionDescriptor.cs b/Млаумчерб.Клиент/классы/VersionDescriptor.cs similarity index 87% rename from Млаумчерб.Клиент/классы/MinecraftVersionDescriptor.cs rename to Млаумчерб.Клиент/классы/VersionDescriptor.cs index bbb1e4e..de0eff5 100644 --- a/Млаумчерб.Клиент/классы/MinecraftVersionDescriptor.cs +++ b/Млаумчерб.Клиент/классы/VersionDescriptor.cs @@ -3,7 +3,7 @@ namespace Млаумчерб.Клиент.классы; -public class MinecraftVersionDescriptor +public class VersionDescriptor { [JsonRequired] public string id { get; set; } = ""; [JsonRequired] public DateTime time { get; set; } @@ -39,22 +39,10 @@ public class Rule public Dictionary? features { get; set; } } -public class Classifiers -{ - [JsonProperty("natives-linux")] - public Artifact? nativeslinux { get; set; } - - [JsonProperty("natives-osx")] - public Artifact? nativesosx { get; set; } - - [JsonProperty("natives-windows")] - public Artifact? nativeswindows { get; set; } -} - public class LibraryDownloads { public Artifact? artifact { get; set; } - public Classifiers? classifiers { get; set; } + public Dictionary? classifiers { get; set; } } public class Extract diff --git a/Млаумчерб.Клиент/классы/Буржуазия.cs b/Млаумчерб.Клиент/классы/Буржуазия.cs index 04a9e12..35cb6b1 100644 --- a/Млаумчерб.Клиент/классы/Буржуазия.cs +++ b/Млаумчерб.Клиент/классы/Буржуазия.cs @@ -9,7 +9,7 @@ public static class Буржуазия os.name switch { null => true, - "osx" => OperatingSystem.IsWindows(), + "osx" => OperatingSystem.IsMacOS(), "linux" => OperatingSystem.IsLinux(), "windows" => OperatingSystem.IsWindows(), _ => throw new ArgumentOutOfRangeException(os.name) diff --git a/Млаумчерб.Клиент/классы/Пути.cs b/Млаумчерб.Клиент/классы/Пути.cs index 7315924..8891eed 100644 --- a/Млаумчерб.Клиент/классы/Пути.cs +++ b/Млаумчерб.Клиент/классы/Пути.cs @@ -16,12 +16,23 @@ public static class Пути public static IOPath GetVersionDescriptorPath(string name) => Path.Concat(GetVersionDescriptorDir(), Path.ReplaceRestrictedChars(name) + ".json"); - public static IOPath GetVersionDir() => - Path.Concat(Приложение.Настройки.путь_к_кубачу, "versions"); + public static IOPath GetVersionDir(string id) => + Path.Concat(Приложение.Настройки.путь_к_кубачу, "versions", id); - public static IOPath GetVersionJarFilePath(string name) => - Path.Concat(GetVersionDir(), name + ".jar"); + public static IOPath GetVersionJarFilePath(string id) => + Path.Concat(GetVersionDir(id), id + ".jar"); public static IOPath GetLibrariesDir() => Path.Concat(Приложение.Настройки.путь_к_кубачу, "libraries"); + + public static IOPath GetJavaRuntimesDir() => + Path.Concat(Приложение.Настройки.путь_к_кубачу, "java"); + + + public static IOPath GetJavaRuntimeDir(string id) => + Path.Concat(GetJavaRuntimesDir(), id); + + public static IOPath GetJavaExecutablePath(string id) => + Path.Concat(GetJavaRuntimeDir(id), "bin", + OperatingSystem.IsWindows() ? "javaw.exe" : "javaw"); } diff --git a/Млаумчерб.Клиент/сеть/DataSize.cs b/Млаумчерб.Клиент/сеть/DataSize.cs new file mode 100644 index 0000000..34de3cf --- /dev/null +++ b/Млаумчерб.Клиент/сеть/DataSize.cs @@ -0,0 +1,23 @@ +namespace Млаумчерб.Клиент.сеть; + +public record struct DataSize(long Bytes) +{ + static string BytesToHumanReadable(long bytes) + { + long K = bytes / 1024; + if (K == 0) + return $"{bytes}b"; + float M = K / 1024f; + if (M < 1) + return $"{K:N1}Kb"; + float G = M / 1024f; + if (G < 1) + return $"{M:N1}Mb"; + return $"{G:N1}Gb"; + } + + public override string ToString() => BytesToHumanReadable(Bytes); + + public static implicit operator DataSize(long bytes) => new DataSize(bytes); +} + diff --git a/Млаумчерб.Клиент/сеть/NetworkProgressReporter.cs b/Млаумчерб.Клиент/сеть/NetworkProgressReporter.cs new file mode 100644 index 0000000..cfe29a8 --- /dev/null +++ b/Млаумчерб.Клиент/сеть/NetworkProgressReporter.cs @@ -0,0 +1,62 @@ +using Млаумчерб.Клиент.видимое; +using Timer = DTLib.Timer; + +namespace Млаумчерб.Клиент.сеть; + +public record struct DownloadProgress(DataSize Downloaded, DataSize Total, DataSize PerSecond) +{ + public override string ToString() + { + return $"{Downloaded}/{Total} ({PerSecond}/s)"; + } +} + +public class NetworkProgressReporter : IDisposable +{ + private readonly Action _reportProgressDelegate; + private long _totalSize; + private long _curSize; + private long _prevSize; + private int _timerDelay = 1000; + private Timer _timer; + + public NetworkProgressReporter(long totalSize, Action reportProgressDelegate) + { + _totalSize = totalSize; + _reportProgressDelegate = reportProgressDelegate; + _timer = new Timer(true, _timerDelay, ReportProgress); + } + + // atomic add + public void AddBytesCount(ArraySegment chunk) + { + long chunkSize = chunk.Count; + Interlocked.Add(ref _curSize, chunkSize); + } + + public void Start() + { + _timer.Start(); + } + + public void Stop() + { + _timer.Stop(); + _timer.InvokeAction(); + } + + public void Dispose() + { + Stop(); + } + + void ReportProgress() + { + long bytesPerSec = (_curSize - _prevSize) / (_timerDelay / 1000); + _prevSize = _curSize; + var p = new DownloadProgress(_curSize, _totalSize, bytesPerSec); + Приложение.Логгер.LogDebug(nameof(ReportProgress), + $"download progress {p}"); + _reportProgressDelegate(p); + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/сеть/NetworkTask.cs b/Млаумчерб.Клиент/сеть/NetworkTask.cs new file mode 100644 index 0000000..c2e724c --- /dev/null +++ b/Млаумчерб.Клиент/сеть/NetworkTask.cs @@ -0,0 +1,71 @@ +namespace Млаумчерб.Клиент.сеть; + +public class NetworkTask : IDisposable +{ + public readonly string Name; + + public enum Status + { + Initialized, + Running, + Completed, + Cancelled, + Failed + }; + + public Status DownloadStatus { get; private set; } = Status.Initialized; + + public event Action? OnProgress; + public event Action? OnStop; + + public delegate Task DownloadAction(NetworkProgressReporter progressReporter, CancellationToken ct); + + private readonly DownloadAction _downloadAction; + private CancellationTokenSource _cts = new(); + private NetworkProgressReporter _progressReporter; + + public NetworkTask(string name, long dataSize, DownloadAction downloadAction) + { + Name = name; + _downloadAction = downloadAction; + _progressReporter = new NetworkProgressReporter(dataSize, ReportProgress); + } + + private void ReportProgress(DownloadProgress p) => OnProgress?.Invoke(p); + + public async Task StartAsync() + { + if(DownloadStatus == Status.Running || DownloadStatus == Status.Completed) + return; + DownloadStatus = Status.Running; + try + { + _progressReporter.Start(); + await _downloadAction(_progressReporter, _cts.Token); + DownloadStatus = Status.Completed; + } + catch + { + DownloadStatus = Status.Failed; + throw; + } + finally + { + _progressReporter.Stop(); + OnStop?.Invoke(DownloadStatus); + } + } + + public void Cancel() + { + DownloadStatus = Status.Cancelled; + _cts.Cancel(); + _cts = new CancellationTokenSource(); + } + + public void Dispose() + { + _cts.Dispose(); + _progressReporter.Dispose(); + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/сеть/NetworkTaskFactories/AssetsDownloadTaskFactory.cs b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/AssetsDownloadTaskFactory.cs new file mode 100644 index 0000000..662627d --- /dev/null +++ b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/AssetsDownloadTaskFactory.cs @@ -0,0 +1,112 @@ +using System.Security.Cryptography; +using DTLib.Extensions; +using Млаумчерб.Клиент.видимое; +using Млаумчерб.Клиент.классы; +using static Млаумчерб.Клиент.сеть.Сеть; + +namespace Млаумчерб.Клиент.сеть.NetworkTaskFactories; + +public class AssetsDownloadTaskFactory : INetworkTaskFactory +{ + private const string ASSET_SERVER_URL = "https://resources.download.minecraft.net/"; + private VersionDescriptor _descriptor; + private SHA1 _hasher; + private IOPath _indexFilePath; + List _assetsToDownload = new(); + + public AssetsDownloadTaskFactory(VersionDescriptor descriptor) + { + _descriptor = descriptor; + _hasher = SHA1.Create(); + _indexFilePath = Пути.GetAssetIndexFilePath(_descriptor.assetIndex.id); + } + + public async Task CreateAsync(bool checkHashes) + { + if (!await CheckFilesAsync(checkHashes)) + return new NetworkTask( + $"assets '{_descriptor.assetIndex.id}'", + GetTotalSize(), + Download + ); + + return null; + } + private async Task CheckFilesAsync(bool checkHashes) + { + if(!File.Exists(_indexFilePath)) + { + Приложение.Логгер.LogInfo(nameof(Сеть), $"started downloading asset index to '{_indexFilePath}'"); + await DownloadFileHTTP(_descriptor.assetIndex.url, _indexFilePath); + Приложение.Логгер.LogInfo(nameof(Сеть), "finished downloading asset index"); + } + + string indexFileText = File.ReadAllText(_indexFilePath); + var assetIndex = JsonConvert.DeserializeObject(indexFileText) + ?? throw new Exception($"can't deserialize asset index file '{_indexFilePath}'"); + + _assetsToDownload.Clear(); + // removing duplicates for Dictionary (idk how can it be possible, but Newtonsoft.Json creates them) + HashSet assetHashes = new HashSet(); + foreach (var pair in assetIndex.objects) + { + if (assetHashes.Add(pair.Value.hash)) + { + var a = new AssetDownloadProperties(pair.Key, pair.Value); + if (!File.Exists(a.filePath)) + { + _assetsToDownload.Add(a); + } + else if(checkHashes) + { + await using var fs = File.OpenRead(a.filePath); + string hash = _hasher.ComputeHash(fs).HashToString(); + if (hash != a.hash) + _assetsToDownload.Add(a); + } + } + } + + return _assetsToDownload.Count == 0; + } + + private long GetTotalSize() + { + long totalSize = 0; + foreach (var a in _assetsToDownload) + totalSize += a.size; + return totalSize; + } + + private class AssetDownloadProperties + { + public string name; + public string hash; + public long size; + public string url; + public IOPath filePath; + + public AssetDownloadProperties(string key, AssetProperties p) + { + name = key; + hash = p.hash; + size = p.size; + string hashStart = hash.Substring(0, 2); + url = $"{ASSET_SERVER_URL}/{hashStart}/{hash}"; + filePath = Path.Concat(IOPath.ArrayCast(["assets", "objects", hashStart, hash], true)); + } + } + + private async Task Download(NetworkProgressReporter pr, CancellationToken ct) + { + Приложение.Логгер.LogInfo(nameof(Сеть), "started downloading assets"); + ParallelOptions opt = new() { MaxDegreeOfParallelism = ParallelDownloadsN, CancellationToken = ct }; + await Parallel.ForEachAsync(_assetsToDownload, opt, + async (a, _ct) => + { + Приложение.Логгер.LogDebug(nameof(Сеть), $"downloading asset '{a.name}' {a.hash}"); + await DownloadFileHTTP(a.url, a.filePath, _ct, pr.AddBytesCount); + }); + Приложение.Логгер.LogInfo(nameof(Сеть), "finished downloading assets"); + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/сеть/NetworkTaskFactories/INetworkTaskFactory.cs b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/INetworkTaskFactory.cs new file mode 100644 index 0000000..184d985 --- /dev/null +++ b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/INetworkTaskFactory.cs @@ -0,0 +1,7 @@ +namespace Млаумчерб.Клиент.сеть.NetworkTaskFactories; + +public interface INetworkTaskFactory +{ + /// unstarted network task or null if there is nothing to download + Task CreateAsync(bool checkHashes); +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/сеть/NetworkTaskFactories/JavaDownloadTaskFactory.cs b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/JavaDownloadTaskFactory.cs new file mode 100644 index 0000000..58cc118 --- /dev/null +++ b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/JavaDownloadTaskFactory.cs @@ -0,0 +1,46 @@ +using System.Security.Cryptography; +using Млаумчерб.Клиент.классы; +using static Млаумчерб.Клиент.сеть.Сеть; + +namespace Млаумчерб.Клиент.сеть.NetworkTaskFactories; + +public class JavaDownloadTaskFactory : INetworkTaskFactory +{ + private VersionDescriptor _descriptor; + private SHA1 _hasher; + IOPath _javaVersionDir; + + public JavaDownloadTaskFactory(VersionDescriptor descriptor) + { + _descriptor = descriptor; + _hasher = SHA1.Create(); + _javaVersionDir = Пути.GetJavaRuntimeDir(_descriptor.javaVersion.component); + } + + public Task CreateAsync(bool checkHashes) + { + NetworkTask? networkTask = null; + if (!CheckFiles(checkHashes)) + networkTask = new( + $"java runtime '{_descriptor.javaVersion.component}'", + GetTotalSize(), + Download + ); + return Task.FromResult(networkTask); + } + + private bool CheckFiles(bool checkHashes) + { + throw new NotImplementedException(); + } + + private long GetTotalSize() + { + throw new NotImplementedException(); + } + + private Task Download(NetworkProgressReporter pr, CancellationToken ct) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/сеть/NetworkTaskFactories/LibrariesDownloadTaskFactory.cs b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/LibrariesDownloadTaskFactory.cs new file mode 100644 index 0000000..d9c2d6f --- /dev/null +++ b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/LibrariesDownloadTaskFactory.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using DTLib.Extensions; +using Млаумчерб.Клиент.видимое; +using Млаумчерб.Клиент.классы; +using static Млаумчерб.Клиент.сеть.Сеть; + +namespace Млаумчерб.Клиент.сеть.NetworkTaskFactories; + +public class LibrariesDownloadTaskFactory : INetworkTaskFactory +{ + private VersionDescriptor _descriptor; + private Libraries _libraries; + private SHA1 _hasher; + private List _libsToDownload = new(); + + public LibrariesDownloadTaskFactory(VersionDescriptor descriptor, Libraries libraries) + { + _descriptor = descriptor; + _libraries = libraries; + _hasher = SHA1.Create(); + } + + public Task CreateAsync(bool checkHashes) + { + NetworkTask? networkTask = null; + if (!CheckFiles(checkHashes)) + networkTask = new NetworkTask( + $"libraries '{_descriptor.id}'", + GetTotalSize(), + Download + ); + return Task.FromResult(networkTask); + } + + private bool CheckFiles(bool checkHashes) + { + _libsToDownload.Clear(); + + foreach (var l in _libraries.Libs) + { + if (!File.Exists(l.jarFilePath)) + { + _libsToDownload.Add(l); + } + else if (checkHashes) + { + using var fs = File.OpenRead(l.jarFilePath); + string hash = _hasher.ComputeHash(fs).HashToString(); + if(hash != l.artifact.sha1) + _libsToDownload.Add(l); + } + } + + return _libsToDownload.Count == 0; + } + + private long GetTotalSize() + { + long total = 0; + foreach (var l in _libsToDownload) + total += l.artifact.size; + return total; + } + + private async Task Download(NetworkProgressReporter pr, CancellationToken ct) + { + Приложение.Логгер.LogInfo(nameof(Сеть), "started downloading libraries"); + ParallelOptions opt = new() { MaxDegreeOfParallelism = ParallelDownloadsN, CancellationToken = ct }; + await Parallel.ForEachAsync(_libsToDownload, opt, async (l, _ct) => + { + Приложение.Логгер.LogDebug(nameof(Сеть), + $"downloading library '{l.name}' to '{l.jarFilePath}'"); + await DownloadFileHTTP(l.artifact.url, l.jarFilePath, _ct, pr.AddBytesCount); + //TODO: extract natives from jar + }); + Приложение.Логгер.LogInfo(nameof(Сеть), "finished downloading libraries"); + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/сеть/NetworkTaskFactories/VersionFileDownloadTaskFactory.cs b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/VersionFileDownloadTaskFactory.cs new file mode 100644 index 0000000..3a44c22 --- /dev/null +++ b/Млаумчерб.Клиент/сеть/NetworkTaskFactories/VersionFileDownloadTaskFactory.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; +using DTLib.Extensions; +using Млаумчерб.Клиент.классы; +using static Млаумчерб.Клиент.сеть.Сеть; + +namespace Млаумчерб.Клиент.сеть.NetworkTaskFactories; + +public class VersionFileDownloadTaskFactory : INetworkTaskFactory +{ + private VersionDescriptor _descriptor; + private IOPath _filePath; + private SHA1 _hasher; + + public VersionFileDownloadTaskFactory(VersionDescriptor descriptor) + { + _descriptor = descriptor; + _filePath = Пути.GetVersionJarFilePath(_descriptor.id); + _hasher = SHA1.Create(); + } + + public Task CreateAsync(bool checkHashes) + { + NetworkTask? networkTask = null; + if (!CheckFiles(checkHashes)) + networkTask = new NetworkTask( + $"version file '{_descriptor.id}'", + GetTotalSize(), + Download + ); + return Task.FromResult(networkTask); + } + + private bool CheckFiles(bool checkHashes) + { + if (!File.Exists(_filePath)) + return false; + if (!checkHashes) + return true; + using var fs = File.OpenRead(_filePath); + string hash = _hasher.ComputeHash(fs).HashToString(); + return hash == _descriptor.downloads.client.sha1; + } + + private long GetTotalSize() + { + return _descriptor.downloads.client.size; + } + + private Task Download(NetworkProgressReporter pr, CancellationToken ct) + { + return DownloadFileHTTP(_descriptor.downloads.client.url, _filePath, ct, pr.AddBytesCount); + } +} \ No newline at end of file diff --git a/Млаумчерб.Клиент/сеть/Сеть.cs b/Млаумчерб.Клиент/сеть/Сеть.cs new file mode 100644 index 0000000..35b37fa --- /dev/null +++ b/Млаумчерб.Клиент/сеть/Сеть.cs @@ -0,0 +1,97 @@ +using System.Buffers; +using System.Net.Http; +using Млаумчерб.Клиент.видимое; +using Млаумчерб.Клиент.классы; + +namespace Млаумчерб.Клиент.сеть; + +public static class Сеть +{ + public static int ParallelDownloadsN = 32; + public static HttpClient http = new(); + + public static async Task DownloadFileHTTP(string url, IOPath outPath, CancellationToken ct = default, + Action>? transformFunc = null) + { + 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); + } + } + + private static readonly string[] VERSION_MANIFEST_URLS = + { + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" + }; + + 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(Сеть), 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; + } +} \ No newline at end of file