NetworkTaskFactory

This commit is contained in:
Timerix 2024-09-29 08:21:48 +05:00
parent 45c3f90da0
commit 4704f1217a
24 changed files with 817 additions and 287 deletions

View File

@ -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,11 +16,11 @@ 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<CommandResult>? commandTask;
public static async Task<List<GameVersionProps>> GetAllVersionsAsync()
@ -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<GameVersionDescriptor> CreateFromPropsAsync(GameVersionProps props)
public static async Task<GameVersion> 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);
return new GameVersion(props);
}
private GameVersionDescriptor(GameVersionProps props)
private GameVersion(GameVersionProps props)
{
_props = props;
WorkingDirectory = Path.Concat(Приложение.Настройки.путь_к_кубачу, Name);
string descriptorText = File.ReadAllText(props.LocalDescriptorPath);
descriptor = JsonConvert.DeserializeObject<MinecraftVersionDescriptor>(descriptorText)
?? throw new Exception($"can't parse descriptor file '{props.LocalDescriptorPath}'");
descriptor = JsonConvert.DeserializeObject<VersionDescriptor>(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");
libraries = new Libraries(descriptor);
WorkingDirectory = GetVersionDir(descriptor.id);
JavaExecutableFilePath = GetJavaExecutablePath(descriptor.javaVersion.component);
}
public async void BeginUpdate(bool force)
public async Task<List<NetworkTask>> CreateUpdateTasksAsync(bool checkHashes)
{
try
List<INetworkTaskFactory> 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<NetworkTask> 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)
{
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)
else
{
Ошибки.ПоказатьСообщение("GameUpdate", ex);
tasks[^1].OnStop += status =>
{
if (status == NetworkTask.Status.Completed)
_props.IsDownloaded = true;
};
}
}
public void CancelUpdate()
{
downloadCts?.Cancel();
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()

View File

@ -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(Настройки), $"настройки сохранены: {текст}");
}
}

View File

@ -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<ArraySegment<byte>>? 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<ArraySegment<byte>>? transformFunc = null, CancellationToken ct = default)
{
// default dotnet runtime buffer size
int bufferSize = 81920;
byte[] readBuffer = ArrayPool<byte>.Shared.Rent(bufferSize);
byte[] writeBuffer = ArrayPool<byte>.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<byte>(writeBuffer, 0, readCount));
dst.Write(writeBuffer, 0, readCount);
}
}
catch (OperationCanceledException)
{ }
finally
{
ArrayPool<byte>.Shared.Return(readBuffer);
ArrayPool<byte>.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<AssetIndex>(indexFileText)
?? throw new Exception($"can't deserialize asset index file '{indexFilePathTmp}'");
var assets = new List<KeyValuePair<string, AssetProperties>>();
HashSet<string> assetHashes = new HashSet<string>();
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<byte> 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<List<RemoteVersionDescriptorProps>> GetRemoteVersionDescriptorsAsync()
{
List<RemoteVersionDescriptorProps> descriptors = new();
foreach (var url in VERSION_MANIFEST_URLS)
{
try
{
var manifestText = await http.GetStringAsync(url);
var catalog = JsonConvert.DeserializeObject<VersionCatalog>(manifestText);
if (catalog != null)
descriptors.AddRange(catalog.versions);
}
catch (Exception ex)
{
Приложение.Логгер.LogWarn(nameof(Сеть), ex);
}
}
return descriptors;
}
private static List<GameVersionProps>? _versionPropsList;
/// <returns>empty list if couldn't find any remote versions</returns>
public static async Task<IReadOnlyList<GameVersionProps>> 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<Library> libraries, IOPath librariesDir, bool force)
{
}
}

View File

@ -0,0 +1,20 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Млаумчерб.Клиент.видимое.DownloadTaskView"
Padding="4" MaxHeight="60" MinWidth="200"
VerticalAlignment="Top"
HorizontalAlignment="Stretch"
BorderThickness="1" BorderBrush="#999999">
<Grid RowDefinitions="30 30" ColumnDefinitions="* 30">
<TextBlock Grid.Row="0" Grid.Column="0" Name="NameText"/>
<Button Grid.Row="0" Grid.Column="1"
Classes="button_no_border"
Background="Transparent"
Foreground="#FF4040"
FontSize="12"
Click="RemoveFromList">
[X]
</Button>
<TextBlock Grid.Row="1" Grid.Column="0" Name="DownloadedProgressText"/>
</Grid>
</UserControl>

View File

@ -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<DownloadTaskView> _removeFromList;
public DownloadTaskView()
{
throw new NotImplementedException();
}
public DownloadTaskView(NetworkTask task, Action<DownloadTaskView> 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));
}
}

View File

@ -20,7 +20,7 @@ public partial class VersionItemView : ListBoxItem
Props = props;
InitializeComponent();
text.Text = props.Name;
props.DownloadCompleted += UpdateBackground;
props.OnDownloadCompleted += UpdateBackground;
UpdateBackground();
}

View File

@ -33,8 +33,9 @@
VerticalScrollBarVisibility="Visible"
Background="Transparent">
<TextBox Name="LogTextBox"
FontSize="12"
FontSize="14"
IsReadOnly="True" TextWrapping="Wrap"
VerticalAlignment="Top"
Background="Transparent" BorderThickness="0"/>
</ScrollViewer>
</Grid>
@ -92,10 +93,13 @@
Загрузки
</TextBlock>
</Border>
<ScrollViewer Name="DownloadsScrollViewer" Grid.Row="1"
<ScrollViewer Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Visible"
Background="Transparent"/>
Background="Transparent"
Padding="1">
<StackPanel Name="DownloadsPanel" VerticalAlignment="Top"/>
</ScrollViewer>
</Grid>
</Border>
</Grid>

View File

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

View File

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

View File

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

View File

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

View File

@ -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<JarLib> Libs { get; }
public Libraries(VersionDescriptor descriptor)
{
List<JarLib> libs = new();
HashSet<string> 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;
}
}

View File

@ -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<string, bool>? 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<string, Artifact>? classifiers { get; set; }
}
public class Extract

View File

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

View File

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

View File

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

View File

@ -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<DownloadProgress> _reportProgressDelegate;
private long _totalSize;
private long _curSize;
private long _prevSize;
private int _timerDelay = 1000;
private Timer _timer;
public NetworkProgressReporter(long totalSize, Action<DownloadProgress> reportProgressDelegate)
{
_totalSize = totalSize;
_reportProgressDelegate = reportProgressDelegate;
_timer = new Timer(true, _timerDelay, ReportProgress);
}
// atomic add
public void AddBytesCount(ArraySegment<byte> 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);
}
}

View File

@ -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<DownloadProgress>? OnProgress;
public event Action<Status>? 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();
}
}

View File

@ -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<AssetDownloadProperties> _assetsToDownload = new();
public AssetsDownloadTaskFactory(VersionDescriptor descriptor)
{
_descriptor = descriptor;
_hasher = SHA1.Create();
_indexFilePath = Пути.GetAssetIndexFilePath(_descriptor.assetIndex.id);
}
public async Task<NetworkTask?> CreateAsync(bool checkHashes)
{
if (!await CheckFilesAsync(checkHashes))
return new NetworkTask(
$"assets '{_descriptor.assetIndex.id}'",
GetTotalSize(),
Download
);
return null;
}
private async Task<bool> 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<AssetIndex>(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<string> assetHashes = new HashSet<string>();
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");
}
}

View File

@ -0,0 +1,7 @@
namespace Млаумчерб.Клиент.сеть.NetworkTaskFactories;
public interface INetworkTaskFactory
{
/// <returns>unstarted network task or null if there is nothing to download</returns>
Task<NetworkTask?> CreateAsync(bool checkHashes);
}

View File

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

View File

@ -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<Libraries.JarLib> _libsToDownload = new();
public LibrariesDownloadTaskFactory(VersionDescriptor descriptor, Libraries libraries)
{
_descriptor = descriptor;
_libraries = libraries;
_hasher = SHA1.Create();
}
public Task<NetworkTask?> 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");
}
}

View File

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

View File

@ -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<ArraySegment<byte>>? 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<ArraySegment<byte>>? transformFunc = null, CancellationToken ct = default)
{
// default dotnet runtime buffer size
int bufferSize = 81920;
byte[] readBuffer = ArrayPool<byte>.Shared.Rent(bufferSize);
byte[] writeBuffer = ArrayPool<byte>.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<byte>(writeBuffer, 0, readCount));
dst.Write(writeBuffer, 0, readCount);
}
}
catch (OperationCanceledException)
{
}
finally
{
ArrayPool<byte>.Shared.Return(readBuffer);
ArrayPool<byte>.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<List<RemoteVersionDescriptorProps>> GetRemoteVersionDescriptorsAsync()
{
List<RemoteVersionDescriptorProps> descriptors = new();
foreach (var url in VERSION_MANIFEST_URLS)
{
try
{
var manifestText = await http.GetStringAsync(url);
var catalog = JsonConvert.DeserializeObject<VersionCatalog>(manifestText);
if (catalog != null)
descriptors.AddRange(catalog.versions);
}
catch (Exception ex)
{
Приложение.Логгер.LogWarn(nameof(Сеть), ex);
}
}
return descriptors;
}
private static List<GameVersionProps>? _versionPropsList;
/// <returns>empty list if couldn't find any remote versions</returns>
public static async Task<IReadOnlyList<GameVersionProps>> 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;
}
}