- add: Service think

This commit is contained in:
2024-12-22 16:38:47 +03:00
parent d9161f837b
commit 4d64c995f1
38 changed files with 4625 additions and 80 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "Robust.LoaderApi"]
path = Robust.LoaderApi
url = https://github.com/space-wizards/Robust.LoaderApi

View File

@@ -4,6 +4,8 @@
<option name="projectPerEditor">
<map>
<entry key="Nebula.Launcher/App.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/Assets/Icons.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/Assets/Style.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/ViewModels/Styles1.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/Views/Controls/PlayerContainerControl.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/Views/Controls/ServerContainerControl.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />

View File

@@ -11,57 +11,7 @@
<Application.Styles>
<FluentTheme />
<Style Selector="Window">
<Setter Property="Background" Value="#121212" />
</Style>
<Style Selector="Border">
<Setter Property="BorderBrush" Value="#343334" />
<Setter Property="Background" Value="#222222" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="Label">
<Setter Property="Foreground" Value="#f7f7ff" />
</Style>
<Style Selector="Button">
<Setter Property="BorderBrush" Value="#343334" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="5" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style Selector="Button:pressed">
<Setter Property="RenderTransform" Value="{x:Null}" />
</Style>
<Style Selector="Border.ButtonBack">
<Setter Property="Background" Value="#e63462" />
<Setter Property="CornerRadius" Value="0,0,10,0" />
</Style>
<Style Selector="Button.ViewSelectButton">
<Setter Property="CornerRadius" Value="0,8,8,0" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="Padding" Value="8" />
<Setter Property="Background" Value="#00000000" />
</Style>
<Style Selector="Button.ViewSelectButton:pressed">
<Setter Property="BorderThickness" Value="0,0,0,0" />
</Style>
<Style Selector="TextBox">
<Setter Property="Foreground" Value="#f7f7ff" />
<Setter Property="SelectionForegroundBrush" Value="#f7f7ff" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="#f7f7ff" />
</Style>
<Style Selector="ListBoxItem">
<Setter Property="CornerRadius" Value="0,8,8,0" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="Padding" Value="8" />
<Setter Property="Background" Value="#00000000" />
</Style>
<StyleInclude Source="Assets/Icons.axaml" />
<StyleInclude Source="Assets/Style.axaml" />
</Application.Styles>
</Application>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,60 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.PreviewWith>
<Border Padding="20">
<!-- Add Controls for Previewer Here -->
</Border>
</Design.PreviewWith>
<Style Selector="Window">
<Setter Property="Background" Value="#121212" />
</Style>
<Style Selector="Border">
<Setter Property="BorderBrush" Value="#343334" />
<Setter Property="Background" Value="#222222" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="Label">
<Setter Property="Foreground" Value="#f7f7ff" />
</Style>
<Style Selector="Button">
<Setter Property="BorderBrush" Value="#343334" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="5" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style Selector="Button:pressed">
<Setter Property="RenderTransform" Value="{x:Null}" />
</Style>
<Style Selector="Border.ButtonBack">
<Setter Property="Background" Value="#e63462" />
<Setter Property="CornerRadius" Value="0,0,10,0" />
</Style>
<Style Selector="Button.ViewSelectButton">
<Setter Property="CornerRadius" Value="0,8,8,0" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="Padding" Value="8" />
<Setter Property="Background" Value="#00000000" />
</Style>
<Style Selector="Button.ViewSelectButton:pressed">
<Setter Property="BorderThickness" Value="0,0,0,0" />
</Style>
<Style Selector="TextBox">
<Setter Property="Foreground" Value="#f7f7ff" />
<Setter Property="SelectionForegroundBrush" Value="#f7f7ff" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="#f7f7ff" />
</Style>
<Style Selector="ListBoxItem">
<Setter Property="CornerRadius" Value="0,8,8,0" />
<Setter Property="Margin" Value="0,0,0,5" />
<Setter Property="Padding" Value="8" />
<Setter Property="Background" Value="#00000000" />
</Style>
</Styles>

View File

@@ -0,0 +1,22 @@
using Nebula.Launcher.Services;
namespace Nebula.Launcher;
public static class CurrentConVar
{
public static readonly ConVar EngineManifestUrl =
ConVar.Build<string>("engine.manifestUrl", "https://robust-builds.cdn.spacestation14.com/manifest.json");
public static readonly ConVar EngineModuleManifestUrl =
ConVar.Build<string>("engine.moduleManifestUrl", "https://robust-builds.cdn.spacestation14.com/modules.json");
public static readonly ConVar ManifestDownloadProtocolVersion =
ConVar.Build<int>("engine.manifestDownloadProtocolVersion", 1);
public static readonly ConVar RobustAssemblyName =
ConVar.Build("engine.robustAssemblyName", "Robust.Client");
public static readonly ConVar Hub = ConVar.Build<string[]>("launcher.hub", [
"https://hub.spacestation14.com/api/servers"
]);
public static readonly ConVar AuthServers = ConVar.Build<string[]>("launcher.authServers", [
"https://auth.spacestation14.com/api/auth/authenticate"
]);
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.IO;
using Robust.LoaderApi;
namespace Nebula.Launcher.FileApis;
public class AssemblyApi : IFileApi
{
private readonly IFileApi _root;
public AssemblyApi(IFileApi root)
{
_root = root;
}
public bool TryOpen(string path, out Stream? stream)
{
return _root.TryOpen(path, out stream);
}
public IEnumerable<string> AllFiles => _root.AllFiles;
}

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.IO;
using Nebula.Launcher.FileApis.Interfaces;
namespace Nebula.Launcher.FileApis;
public class FileApi : IReadWriteFileApi
{
public string RootPath;
public FileApi(string rootPath)
{
RootPath = rootPath;
}
public bool TryOpen(string path, out Stream? stream)
{
if (File.Exists(Path.Join(RootPath, path)))
{
stream = File.OpenRead(Path.Join(RootPath, path));
return true;
}
stream = null;
return false;
}
public bool Save(string path, Stream input)
{
var currPath = Path.Join(RootPath, path);
var dirInfo = new DirectoryInfo(Path.GetDirectoryName(currPath));
if (!dirInfo.Exists) dirInfo.Create();
using var stream = File.OpenWrite(currPath);
input.CopyTo(stream);
stream.Close();
return true;
}
public bool Remove(string path)
{
if (!Has(path)) return false;
File.Delete(Path.Join(RootPath, path));
return true;
}
public bool Has(string path)
{
var currPath = Path.Join(RootPath, path);
return File.Exists(currPath);
}
public IEnumerable<string> AllFiles => Directory.EnumerateFiles(RootPath, "*.*", SearchOption.AllDirectories);
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.IO;
using Nebula.Launcher.Utils;
using Robust.LoaderApi;
namespace Nebula.Launcher.FileApis;
public class HashApi : IFileApi
{
private readonly IFileApi _fileApi;
public Dictionary<string, RobustManifestItem> Manifest;
public HashApi(List<RobustManifestItem> manifest, IFileApi fileApi)
{
_fileApi = fileApi;
Manifest = new Dictionary<string, RobustManifestItem>();
foreach (var item in manifest) Manifest.TryAdd(item.Path, item);
}
public bool TryOpen(string path, out Stream? stream)
{
if (path[0] == '/') path = path.Substring(1);
if (Manifest.TryGetValue(path, out var a) && _fileApi.TryOpen(a.Hash, out stream))
return true;
stream = null;
return false;
}
public IEnumerable<string> AllFiles => Manifest.Keys;
}

View File

@@ -0,0 +1,7 @@
using Robust.LoaderApi;
namespace Nebula.Launcher.FileApis.Interfaces;
public interface IReadWriteFileApi : IFileApi, IWriteFileApi
{
}

View File

@@ -0,0 +1,10 @@
using System.IO;
namespace Nebula.Launcher.FileApis.Interfaces;
public interface IWriteFileApi
{
public bool Save(string path, Stream input);
public bool Remove(string path);
public bool Has(string path);
}

View File

@@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Runtime.InteropServices;
using Robust.LoaderApi;
namespace Nebula.Launcher.FileApis;
public sealed class ZipFileApi : IFileApi
{
private readonly ZipArchive _archive;
private readonly string? _prefix;
public ZipFileApi(ZipArchive archive, string? prefix)
{
_archive = archive;
_prefix = prefix;
}
public bool TryOpen(string path, [NotNullWhen(true)] out Stream? stream)
{
var entry = _archive.GetEntry(_prefix != null ? _prefix + path : path);
if (entry == null)
{
stream = null;
return false;
}
stream = new MemoryStream();
lock (_archive)
{
using var zipStream = entry.Open();
zipStream.CopyTo(stream);
}
stream.Position = 0;
return true;
}
public IEnumerable<string> AllFiles
{
get
{
if (_prefix != null)
return _archive.Entries
.Where(e => e.Name != "" && e.FullName.StartsWith(_prefix))
.Select(e => e.FullName[_prefix.Length..]);
return _archive.Entries
.Where(e => e.Name != "")
.Select(entry => entry.FullName);
}
}
public static ZipFileApi FromPath(string path)
{
var zipArchive = new ZipArchive(File.OpenRead(path), ZipArchiveMode.Read);
var prefix = "";
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) prefix = "Space Station 14.app/Contents/Resources/";
return new ZipFileApi(zipArchive, prefix);
}
}

View File

@@ -0,0 +1,5 @@
using System;
namespace Nebula.Launcher.Utils;
public record struct RobustManifestInfo(Uri ManifestUri, Uri DownloadUri, string Hash);

View File

@@ -0,0 +1,3 @@
namespace Nebula.Launcher.Utils;
public record struct RobustManifestItem(string Hash, string Path, int Id);

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Nebula.Launcher.Models;
public sealed record Auth(string Mode, string PublicKey);
public sealed record Build(
string EngineVersion,
string ForkId,
string Version,
string DownloadUrl,
string ManifestUrl,
bool Acz,
string Hash,
string ManifestHash);
public sealed record Link(string Name, string Icon, string Url);
public sealed record Info(string ConnectAddress, Auth Auth, Build Build, string Desc, List<Link> Links);
public sealed record Status(
string Name,
int Players,
List<object> Tags,
string Map,
int RoundId,
int SoftMaxPlayer,
bool PanicBunker,
int RunLevel,
string Preset);
public enum ContentCompressionScheme
{
None = 0,
Deflate = 1,
/// <summary>
/// ZStandard compression. In the future may use SS14 specific dictionary IDs in the frame header.
/// </summary>
ZStd = 2
}
public sealed record VersionInfo(
bool Insecure,
[property: JsonPropertyName("redirect")]
string? RedirectVersion,
Dictionary<string, BuildInfo> Platforms);
public sealed class BuildInfo
{
[JsonInclude] [JsonPropertyName("sha256")]
public string Sha256 = default!;
[JsonInclude] [JsonPropertyName("sig")]
public string Signature = default!;
[JsonInclude] [JsonPropertyName("url")]
public string Url = default!;
}
public sealed record ServerInfo(string Address, StatusData StatusData, List<string> InferredTags);
public sealed record StatusData(
string Map,
string Name,
List<string> Tags,
string Preset,
int Players,
int RoundId,
int RunLevel,
bool PanicBunker,
DateTime RoundStartTime,
int SoftMaxPlayer);
public sealed record ModulesInfo(Dictionary<string, Module> Modules);
public sealed record Module(Dictionary<string, ModuleVersionInfo> Versions);
public sealed record ModuleVersionInfo(Dictionary<string, BuildInfo> Platforms);

View File

@@ -37,4 +37,8 @@
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Robust.LoaderApi\Robust.LoaderApi\Robust.LoaderApi.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,9 +1,12 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using Nebula.Launcher.ViewHelper;
using Nebula.Launcher.ViewModels;
using Nebula.Launcher.Views;
using Nebula.Launcher.Views.Pages;
@@ -38,9 +41,24 @@ public static class ServiceCollectionExtensions
private static void AddViews(this IServiceCollection services)
{
services.AddTransient<MainWindow>();
services.AddView<MainView, MainViewModel>();
services.AddView<AccountInfoView, AccountInfoViewModel>();
services.AddView<ServerListView, ServerListViewModel>();
foreach (var (viewModel, view) in GetTypesWithHelpAttribute(Assembly.GetExecutingAssembly()))
{
services.AddTransient(viewModel);
services.AddTransient(view);
}
foreach (var (type, inference) in GetServicesWithHelpAttribute(Assembly.GetExecutingAssembly()))
{
if (inference is null)
{
services.AddSingleton(type);
}
else
{
services.AddSingleton(inference, type);
}
}
}
private static void AddView<TView, TViewModel>(this IServiceCollection services)
@@ -50,4 +68,34 @@ public static class ServiceCollectionExtensions
services.AddTransient<TViewModel>();
services.AddTransient<TView>();
}
private static IEnumerable<(Type,Type)> GetTypesWithHelpAttribute(Assembly assembly) {
foreach(Type type in assembly.GetTypes())
{
var attr = type.GetCustomAttribute<ViewRegisterAttribute>();
if (attr is not null) {
yield return (type, attr.Type);
}
}
}
private static IEnumerable<(Type,Type?)> GetServicesWithHelpAttribute(Assembly assembly) {
foreach(Type type in assembly.GetTypes())
{
var attr = type.GetCustomAttribute<ServiceRegisterAttribute>();
if (attr is not null) {
yield return (type, attr.Inference);
}
}
}
}
public sealed class ServiceRegisterAttribute : Attribute
{
public Type? Inference { get; }
public ServiceRegisterAttribute(Type? inference = null)
{
Inference = inference;
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace Nebula.Launcher.Services;
public class ConVar
{
public string Name { get; }
public Type Type { get; }
public object? DefaultValue { get; }
private ConVar(string name, Type type, object? defaultValue)
{
Name = name;
Type = type;
DefaultValue = defaultValue;
}
public static ConVar Build<T>(string name, T? defaultValue = default)
{
return new ConVar(name, typeof(T), defaultValue);
}
}
[ServiceRegister]
public class ConfigurationService
{
public ConfigurationService()
{
}
public object? GetConfigValue(ConVar conVar)
{
return conVar.DefaultValue;
}
public T? GetConfigValue<T>(ConVar conVar)
{
var value = GetConfigValue(conVar);
if (value is not T tv) return default;
return tv;
}
public bool TryGetConfigValue(ConVar conVar,[NotNullWhen(true)] out object? value)
{
value = GetConfigValue(conVar);
return value != null;
}
public bool TryGetConfigValue<T>(ConVar conVar, [NotNullWhen(true)] out T? value)
{
value = GetConfigValue<T>(conVar);
return value != null;
}
public void SetValue(ConVar conVar, object value)
{
if(conVar.Type != value.GetType())
return;
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.IO;
using Nebula.Launcher.Services.Logging;
namespace Nebula.Launcher.Services;
[ServiceRegister]
public class DebugService : IDisposable
{
public ILogger Logger;
private static string LogPath = Path.Combine(FileService.RootPath, "log");
public DateTime LogDate = DateTime.Now;
private FileStream LogStream;
private StreamWriter LogWriter;
public DebugService(ILogger logger)
{
Logger = logger;
if (!Directory.Exists(LogPath))
Directory.CreateDirectory(LogPath);
var filename = String.Format("{0:yyyy-MM-dd}.txt", DateTime.Now);
LogStream = File.Open(Path.Combine(LogPath, filename),
FileMode.Append, FileAccess.Write);
LogWriter = new StreamWriter(LogStream);
}
public void Debug(string message)
{
Log(LoggerCategory.Debug, message);
}
public void Error(string message)
{
Log(LoggerCategory.Error, message);
}
public void Log(string message)
{
Log(LoggerCategory.Log, message);
}
public void Dispose()
{
LogWriter.Dispose();
LogStream.Dispose();
}
private void Log(LoggerCategory category, string message)
{
Logger.Log(category, message);
SaveToLog(category, message);
}
private void SaveToLog(LoggerCategory category, string message)
{
LogWriter.WriteLine($"[{category}] {message}");
LogWriter.Flush();
}
}
public enum LoggerCategory
{
Log,
Debug,
Error
}

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Runtime.InteropServices;
using Nebula.Launcher.FileApis;
using Nebula.Launcher.FileApis.Interfaces;
using Nebula.Launcher.Utils;
using Robust.LoaderApi;
namespace Nebula.Launcher.Services;
public class FileService
{
public static string RootPath = Path.Join(Environment.GetFolderPath(
Environment.SpecialFolder.ApplicationData), "./Datum/");
private readonly DebugService _debugService;
public readonly IReadWriteFileApi ContentFileApi;
public readonly IReadWriteFileApi EngineFileApi;
public readonly IReadWriteFileApi ManifestFileApi;
private HashApi? _hashApi;
public FileService(DebugService debugService)
{
_debugService = debugService;
ContentFileApi = CreateFileApi("content/");
EngineFileApi = CreateFileApi("engine/");
ManifestFileApi = CreateFileApi("manifest/");
}
public List<RobustManifestItem> ManifestItems
{
set => _hashApi = new HashApi(value, ContentFileApi);
}
public HashApi HashApi
{
get
{
if (_hashApi is null) throw new Exception("Hash API is not initialized!");
return _hashApi;
}
set => _hashApi = value;
}
public IReadWriteFileApi CreateFileApi(string path)
{
return new FileApi(Path.Join(RootPath, path));
}
public ZipFileApi OpenZip(string path, IFileApi fileApi)
{
if (!fileApi.TryOpen(path, out var zipStream))
return null;
var zipArchive = new ZipArchive(zipStream, ZipArchiveMode.Read);
var prefix = "";
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) prefix = "Space Station 14.app/Contents/Resources/";
return new ZipFileApi(zipArchive, prefix);
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Threading;
using Nebula.Launcher.Models;
namespace Nebula.Launcher.Services;
[ServiceRegister]
public class HubService
{
private readonly RestService _restService;
public Action<HubServerChangedEventArgs>? HubServerChangedEventArgs;
public readonly ObservableCollection<string> HubList = new();
private readonly Dictionary<string, List<ServerInfo>> _servers = new();
public HubService(ConfigurationService configurationService, RestService restService)
{
_restService = restService;
HubList.CollectionChanged += HubListCollectionChanged;
foreach (var hubUrl in configurationService.GetConfigValue<string[]>(CurrentConVar.Hub)!)
{
HubList.Add(hubUrl);
}
}
private async void HubListCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems is not null)
{
foreach (var hubUri in e.NewItems)
{
var urlStr = (string)hubUri;
var servers = await _restService.GetAsyncDefault<List<ServerInfo>>(new Uri(urlStr), [], CancellationToken.None);
_servers[urlStr] = servers;
HubServerChangedEventArgs?.Invoke(new HubServerChangedEventArgs(servers, HubServerChangeAction.Add));
}
}
if (e.OldItems is not null)
{
foreach (var hubUri in e.OldItems)
{
var urlStr = (string)hubUri;
if (_servers.TryGetValue(urlStr, out var serverInfos))
{
_servers.Remove(urlStr);
HubServerChangedEventArgs?.Invoke(new HubServerChangedEventArgs(serverInfos, HubServerChangeAction.Remove));
}
}
}
}
}
public class HubServerChangedEventArgs : EventArgs
{
public HubServerChangeAction Action;
public List<ServerInfo> Items;
public HubServerChangedEventArgs(List<ServerInfo> items, HubServerChangeAction action)
{
Items = items;
Action = action;
}
}
public enum HubServerChangeAction
{
Add, Remove,
}

View File

@@ -0,0 +1,15 @@
using System;
namespace Nebula.Launcher.Services.Logging;
[ServiceRegister(typeof(ILogger))]
public class ConsoleLogger : ILogger
{
public void Log(LoggerCategory loggerCategory, string message)
{
Console.ForegroundColor = ConsoleColor.DarkCyan;
Console.Write($"[{Enum.GetName(loggerCategory)}] ");
Console.ResetColor();
Console.WriteLine(message);
}
}

View File

@@ -0,0 +1,6 @@
namespace Nebula.Launcher.Services.Logging;
public interface ILogger
{
public void Log(LoggerCategory loggerCategory, string message);
}

View File

@@ -0,0 +1,154 @@
using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Nebula.Launcher.Services;
[ServiceRegister]
public class RestService
{
private readonly HttpClient _client = new();
private readonly DebugService _debug;
private readonly JsonSerializerOptions _serializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public RestService(DebugService debug)
{
_debug = debug;
}
public async Task<RestResult<T>> GetAsync<T>(Uri uri, CancellationToken cancellationToken)
{
_debug.Debug("GET " + uri);
try
{
var response = await _client.GetAsync(uri, cancellationToken);
return await ReadResult<T>(response, cancellationToken);
}
catch (Exception ex)
{
_debug.Error("ERROR WHILE CONNECTION " + uri + ": " + ex.Message);
return new RestResult<T>(default, ex.Message, HttpStatusCode.RequestTimeout);
}
}
public async Task<T> GetAsyncDefault<T>(Uri uri, T defaultValue, CancellationToken cancellationToken)
{
var result = await GetAsync<T>(uri, cancellationToken);
return result.Value ?? defaultValue;
}
public async Task<RestResult<K>> PostAsync<K, T>(T information, Uri uri, CancellationToken cancellationToken)
{
_debug.Debug("POST " + uri);
try
{
var json = JsonSerializer.Serialize(information, _serializerOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _client.PostAsync(uri, content, cancellationToken);
return await ReadResult<K>(response, cancellationToken);
}
catch (Exception ex)
{
_debug.Debug("ERROR " + ex.Message);
return new RestResult<K>(default, ex.Message, HttpStatusCode.RequestTimeout);
}
}
public async Task<RestResult<T>> PostAsync<T>(Stream stream, Uri uri, CancellationToken cancellationToken)
{
_debug.Debug("POST " + uri);
try
{
using var multipartFormContent =
new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture));
multipartFormContent.Add(new StreamContent(stream), "formFile", "image.png");
var response = await _client.PostAsync(uri, multipartFormContent, cancellationToken);
return await ReadResult<T>(response, cancellationToken);
}
catch (Exception ex)
{
_debug.Error("ERROR " + ex.Message);
if (ex.StackTrace != null) _debug.Error(ex.StackTrace);
return new RestResult<T>(default, ex.Message, HttpStatusCode.RequestTimeout);
}
}
public async Task<RestResult<T>> DeleteAsync<T>(Uri uri, CancellationToken cancellationToken)
{
_debug.Debug("DELETE " + uri);
try
{
var response = await _client.DeleteAsync(uri, cancellationToken);
return await ReadResult<T>(response, cancellationToken);
}
catch (Exception ex)
{
_debug.Debug("ERROR " + ex.Message);
return new RestResult<T>(default, ex.Message, HttpStatusCode.RequestTimeout);
}
}
private async Task<RestResult<T>> ReadResult<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
//_debug.Debug("CONTENT:" + content);
if (response.IsSuccessStatusCode)
{
_debug.Debug($"SUCCESSFUL GET CONTENT {typeof(T)}");
if (typeof(T) == typeof(RawResult))
return (new RestResult<RawResult>(new RawResult(content), null, response.StatusCode) as RestResult<T>)!;
return new RestResult<T>(JsonSerializer.Deserialize<T>(content, _serializerOptions), null,
response.StatusCode);
}
_debug.Error("ERROR " + response.StatusCode + " " + content);
return new RestResult<T>(default, "response code:" + response.StatusCode, response.StatusCode);
}
}
public class RestResult<T>
{
public string? Message;
public HttpStatusCode StatusCode;
public T? Value;
public RestResult(T? value, string? message, HttpStatusCode statusCode)
{
Value = value;
Message = message;
StatusCode = statusCode;
}
public static implicit operator T?(RestResult<T> result)
{
return result.Value;
}
}
public class RawResult
{
public string Result;
public RawResult(string result)
{
Result = result;
}
public static implicit operator string(RawResult result)
{
return result.Result;
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Nebula.Launcher.Utils;
public sealed class BandwidthStream : Stream
{
private const int NumSeconds = 8;
private const int BucketDivisor = 2;
private const int BucketsPerSecond = 2 << BucketDivisor;
// TotalBuckets MUST be power of two!
private const int TotalBuckets = NumSeconds * BucketsPerSecond;
private readonly Stream _baseStream;
private readonly long[] _buckets;
private readonly Stopwatch _stopwatch;
private long _bucketIndex;
public BandwidthStream(Stream baseStream)
{
_stopwatch = Stopwatch.StartNew();
_baseStream = baseStream;
_buckets = new long[TotalBuckets];
}
public override bool CanRead => _baseStream.CanRead;
public override bool CanSeek => _baseStream.CanSeek;
public override bool CanWrite => _baseStream.CanWrite;
public override long Length => _baseStream.Length;
public override long Position
{
get => _baseStream.Position;
set => _baseStream.Position = value;
}
private void TrackBandwidth(long value)
{
const int bucketMask = TotalBuckets - 1;
var bucketIdx = CurBucketIdx();
// Increment to bucket idx, clearing along the way.
if (bucketIdx != _bucketIndex)
{
var diff = bucketIdx - _bucketIndex;
if (diff > TotalBuckets)
for (var i = _bucketIndex; i < bucketIdx; i++)
_buckets[i & bucketMask] = 0;
else
// We managed to skip so much time the whole buffer is empty.
Array.Clear(_buckets);
_bucketIndex = bucketIdx;
}
// Write value.
_buckets[bucketIdx & bucketMask] += value;
}
private long CurBucketIdx()
{
var elapsed = _stopwatch.Elapsed.TotalSeconds;
return (long)(elapsed / BucketsPerSecond);
}
public long CalcCurrentAvg()
{
var sum = 0L;
for (var i = 0; i < TotalBuckets; i++) sum += _buckets[i];
return sum >> BucketDivisor;
}
public override void Flush()
{
_baseStream.Flush();
}
public override Task FlushAsync(CancellationToken cancellationToken)
{
return _baseStream.FlushAsync(cancellationToken);
}
protected override void Dispose(bool disposing)
{
if (disposing)
_baseStream.Dispose();
}
public override ValueTask DisposeAsync()
{
return _baseStream.DisposeAsync();
}
public override int Read(byte[] buffer, int offset, int count)
{
var read = _baseStream.Read(buffer, offset, count);
TrackBandwidth(read);
return read;
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var read = await base.ReadAsync(buffer, cancellationToken);
TrackBandwidth(read);
return read;
}
public override long Seek(long offset, SeekOrigin origin)
{
return _baseStream.Seek(offset, origin);
}
public override void SetLength(long value)
{
_baseStream.SetLength(value);
}
public override void Write(byte[] buffer, int offset, int count)
{
_baseStream.Write(buffer, offset, count);
TrackBandwidth(count);
}
public override async ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default)
{
await _baseStream.WriteAsync(buffer, cancellationToken);
TrackBandwidth(buffer.Length);
}
}

View File

@@ -0,0 +1,118 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
namespace Nebula.Launcher.Utils;
public class ManifestReader : StreamReader
{
public const int BufferSize = 128;
public ManifestReader(Stream stream) : base(stream)
{
ReadManifestVersion();
}
public ManifestReader(Stream stream, bool detectEncodingFromByteOrderMarks) : base(stream,
detectEncodingFromByteOrderMarks)
{
ReadManifestVersion();
}
public ManifestReader(Stream stream, Encoding encoding) : base(stream, encoding)
{
ReadManifestVersion();
}
public ManifestReader(Stream stream, Encoding encoding, bool detectEncodingFromByteOrderMarks) : base(stream,
encoding, detectEncodingFromByteOrderMarks)
{
ReadManifestVersion();
}
public ManifestReader(Stream stream, Encoding encoding, bool detectEncodingFromByteOrderMarks, int bufferSize) :
base(stream, encoding, detectEncodingFromByteOrderMarks, bufferSize)
{
ReadManifestVersion();
}
public ManifestReader(Stream stream, Encoding? encoding = null, bool detectEncodingFromByteOrderMarks = true,
int bufferSize = -1, bool leaveOpen = false) : base(stream, encoding, detectEncodingFromByteOrderMarks,
bufferSize, leaveOpen)
{
ReadManifestVersion();
}
public ManifestReader(string path) : base(path)
{
ReadManifestVersion();
}
public ManifestReader(string path, bool detectEncodingFromByteOrderMarks) : base(path,
detectEncodingFromByteOrderMarks)
{
ReadManifestVersion();
}
public ManifestReader(string path, FileStreamOptions options) : base(path, options)
{
ReadManifestVersion();
}
public ManifestReader(string path, Encoding encoding) : base(path, encoding)
{
ReadManifestVersion();
}
public ManifestReader(string path, Encoding encoding, bool detectEncodingFromByteOrderMarks) : base(path, encoding,
detectEncodingFromByteOrderMarks)
{
ReadManifestVersion();
}
public ManifestReader(string path, Encoding encoding, bool detectEncodingFromByteOrderMarks, int bufferSize) : base(
path, encoding, detectEncodingFromByteOrderMarks, bufferSize)
{
ReadManifestVersion();
}
public ManifestReader(string path, Encoding encoding, bool detectEncodingFromByteOrderMarks,
FileStreamOptions options) : base(path, encoding, detectEncodingFromByteOrderMarks, options)
{
ReadManifestVersion();
}
public string ManifestVersion { get; private set; } = "";
public int CurrentId { get; private set; }
private void ReadManifestVersion()
{
ManifestVersion = ReadLine();
}
public RobustManifestItem? ReadItem()
{
var line = ReadLine();
if (line == null) return null;
var splited = line.Split(" ");
return new RobustManifestItem(splited[0], line.Substring(splited[0].Length + 1), CurrentId++);
}
public bool TryReadItem([NotNullWhen(true)] out RobustManifestItem? item)
{
item = ReadItem();
return item != null;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
CurrentId = 0;
}
public new void DiscardBufferedData()
{
base.DiscardBufferedData();
CurrentId = 0;
}
}

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Nebula.Launcher.Utils;
public static class RidUtility
{
public static string? FindBestRid(ICollection<string> runtimes, string? currentRid = null)
{
var catalog = LoadRidCatalog();
if (currentRid == null)
{
var reportedRid = RuntimeInformation.RuntimeIdentifier;
if (!catalog.Runtimes.ContainsKey(reportedRid))
{
currentRid = GuessRid();
Console.WriteLine(".NET reported unknown RID: {0}, guessing: {1}", reportedRid, currentRid);
}
else
{
currentRid = reportedRid;
}
}
// Breadth-first search.
var q = new Queue<string>();
if (!catalog.Runtimes.TryGetValue(currentRid, out var root))
// RID doesn't exist in catalog???
return null;
root.Discovered = true;
q.Enqueue(currentRid);
while (q.TryDequeue(out var v))
{
if (runtimes.Contains(v)) return v;
foreach (var w in catalog.Runtimes[v].Imports)
{
var r = catalog.Runtimes[w];
if (!r.Discovered)
{
q.Enqueue(w);
r.Discovered = true;
}
}
}
return null;
}
private static string GuessRid()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return RuntimeInformation.ProcessArchitecture switch
{
Architecture.X86 => "linux-x86",
Architecture.X64 => "linux-x64",
Architecture.Arm => "linux-arm",
Architecture.Arm64 => "linux-arm64",
_ => "unknown"
};
if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD))
return RuntimeInformation.ProcessArchitecture switch
{
Architecture.X64 => "freebsd-x64",
_ => "unknown"
};
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return RuntimeInformation.ProcessArchitecture switch
{
Architecture.X86 => "win-x86",
Architecture.X64 => "win-x64",
Architecture.Arm => "win-arm",
Architecture.Arm64 => "win-arm64",
_ => "unknown"
};
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return RuntimeInformation.ProcessArchitecture switch
{
Architecture.X64 => "osx-x64",
Architecture.Arm64 => "osx-arm64",
_ => "unknown"
};
return "unknown";
}
private static RidCatalog LoadRidCatalog()
{
using var stream = typeof(RidCatalog).Assembly.GetManifestResourceStream("Utility.runtime.json")!;
var ms = new MemoryStream();
stream.CopyTo(ms);
return JsonSerializer.Deserialize<RidCatalog>(ms.GetBuffer().AsSpan(0, (int)ms.Length))!;
}
#pragma warning disable 649
private sealed class RidCatalog
{
[JsonInclude] [JsonPropertyName("runtimes")]
public Dictionary<string, Runtime> Runtimes = default!;
public class Runtime
{
public bool Discovered;
[JsonInclude] [JsonPropertyName("#import")]
public string[] Imports = default!;
}
}
#pragma warning restore 649
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Buffers;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Nebula.Launcher.Utils;
public static class StreamHelper
{
public static async ValueTask<byte[]> ReadExactAsync(this Stream stream, int amount, CancellationToken? cancel)
{
var data = new byte[amount];
await ReadExactAsync(stream, data, cancel);
return data;
}
public static async ValueTask ReadExactAsync(this Stream stream, Memory<byte> into, CancellationToken? cancel)
{
while (into.Length > 0)
{
var read = await stream.ReadAsync(into);
// Check EOF.
if (read == 0)
throw new EndOfStreamException();
into = into[read..];
}
}
public static async Task CopyAmountToAsync(
this Stream stream,
Stream to,
int amount,
int bufferSize,
CancellationToken cancel)
{
var buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
while (amount > 0)
{
Memory<byte> readInto = buffer;
if (amount < readInto.Length)
readInto = readInto[..amount];
var read = await stream.ReadAsync(readInto, cancel);
if (read == 0)
throw new EndOfStreamException();
amount -= read;
readInto = readInto[..read];
await to.WriteAsync(readInto, cancel);
}
}
}

View File

@@ -0,0 +1,135 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Web;
namespace Nebula.Launcher.Utils;
public static class UriHelper
{
public const string SchemeSs14 = "ss14";
// ReSharper disable once InconsistentNaming
public const string SchemeSs14s = "ss14s";
/// <summary>
/// Parses an <c>ss14://</c> or <c>ss14s://</c> URI,
/// defaulting to <c>ss14://</c> if no scheme is specified.
/// </summary>
[Pure]
public static Uri ParseSs14Uri(string address)
{
if (!TryParseSs14Uri(address, out var uri)) throw new FormatException("Not a valid SS14 URI");
return uri;
}
[Pure]
public static bool TryParseSs14Uri(string address, [NotNullWhen(true)] out Uri? uri)
{
if (!address.Contains("://")) address = "ss14://" + address;
if (!Uri.TryCreate(address, UriKind.Absolute, out uri)) return false;
if (uri.Scheme != SchemeSs14 && uri.Scheme != SchemeSs14s) return false;
if (string.IsNullOrWhiteSpace(uri.Host))
return false;
return true;
}
/// <summary>
/// Gets the <c>http://</c> or <c>https://</c> API address for a server address.
/// </summary>
[Pure]
public static Uri GetServerApiAddress(Uri serverAddress)
{
var dataScheme = serverAddress.Scheme switch
{
"ss14" => Uri.UriSchemeHttp,
"ss14s" => Uri.UriSchemeHttps,
_ => throw new ArgumentException($"Wrong URI scheme: {serverAddress.Scheme}")
};
var builder = new UriBuilder(serverAddress)
{
Scheme = dataScheme
};
// No port specified.
// Default port for ss14:// is 1212, for ss14s:// it's 443 (HTTPS)
if (serverAddress.IsDefaultPort && serverAddress.Scheme == SchemeSs14) builder.Port = 1212;
if (!builder.Path.EndsWith('/')) builder.Path += "/";
return builder.Uri;
}
/// <summary>
/// Gets the <c>/status</c> HTTP address for a server address.
/// </summary>
[Pure]
public static Uri GetServerStatusAddress(string serverAddress)
{
return GetServerStatusAddress(ParseSs14Uri(serverAddress));
}
/// <summary>
/// Gets the <c>/status</c> HTTP address for an ss14 uri.
/// </summary>
[Pure]
public static Uri GetServerStatusAddress(Uri serverAddress)
{
return new Uri(GetServerApiAddress(serverAddress), "status");
}
/// <summary>
/// Gets the <c>/info</c> HTTP address for a server address.
/// </summary>
[Pure]
public static Uri GetServerInfoAddress(string serverAddress)
{
return GetServerInfoAddress(ParseSs14Uri(serverAddress));
}
/// <summary>
/// Gets the <c>/info</c> HTTP address for an ss14 uri.
/// </summary>
[Pure]
public static Uri GetServerInfoAddress(Uri serverAddress)
{
return new Uri(GetServerApiAddress(serverAddress), "info");
}
/// <summary>
/// Gets the <c>/client.zip</c> HTTP address for a server address.
/// This is not necessarily the actual client ZIP address.
/// </summary>
[Pure]
public static Uri GetServerSelfhostedClientZipAddress(string serverAddress)
{
return GetServerSelfhostedClientZipAddress(ParseSs14Uri(serverAddress));
}
/// <summary>
/// Gets the <c>/client.zip</c> HTTP address for an ss14 uri.
/// This is not necessarily the actual client ZIP address.
/// </summary>
[Pure]
public static Uri GetServerSelfhostedClientZipAddress(Uri serverAddress)
{
return new Uri(GetServerApiAddress(serverAddress), "client.zip");
}
[Pure]
public static Uri AddParameter(this Uri url, string paramName, string paramValue)
{
var uriBuilder = new UriBuilder(url);
var query = HttpUtility.ParseQueryString(uriBuilder.Query);
query[paramName] = paramValue;
uriBuilder.Query = query.ToString();
return uriBuilder.Uri;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -36,8 +36,8 @@ public partial class MainViewModel : ViewModelBase
private readonly List<ListItemTemplate> _templates =
[
new ListItemTemplate(typeof(AccountInfoViewModel), "HomeRegular", "Account"),
new ListItemTemplate(typeof(ServerListViewModel), "List", "Servers")
new ListItemTemplate(typeof(AccountInfoViewModel), "Account", "Account"),
new ListItemTemplate(typeof(ServerListViewModel), "HomeRegular", "Servers")
];
[ObservableProperty]
@@ -51,12 +51,10 @@ public partial class MainViewModel : ViewModelBase
partial void OnSelectedListItemChanged(ListItemTemplate? value)
{
Console.WriteLine("FUCKED " + value?.ModelType);
if (value is null) return;
if(!TryGetViewModel(value.ModelType, out var vmb))
{
Console.WriteLine("FUCKCCC");
return;
}

View File

@@ -1,13 +1,46 @@
using System;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Nebula.Launcher.Models;
using Nebula.Launcher.Services;
using Nebula.Launcher.ViewHelper;
using Nebula.Launcher.Views.Pages;
namespace Nebula.Launcher.ViewModels;
[ViewRegister(typeof(ServerListView))]
public class ServerListViewModel : ViewModelBase
public partial class ServerListViewModel : ViewModelBase
{
public ServerListViewModel(IServiceProvider serviceProvider) : base(serviceProvider)
public ObservableCollection<ServerInfo> ServerInfos { get; }
[ObservableProperty]
private ServerInfo? _selectedListItem;
public ServerListViewModel()
{
ServerInfos = new ObservableCollection<ServerInfo>();
}
public ServerListViewModel(IServiceProvider serviceProvider, HubService hubService) : base(serviceProvider)
{
ServerInfos = new ObservableCollection<ServerInfo>();
hubService.HubServerChangedEventArgs += HubServerChangedEventArgs;
}
private void HubServerChangedEventArgs(HubServerChangedEventArgs obj)
{
if (obj.Action == HubServerChangeAction.Add)
{
foreach (var info in obj.Items)
{
ServerInfos.Add(info);
}
}
else
{
foreach (var info in obj.Items)
{
ServerInfos.Remove(info);
}
}
}
}

View File

@@ -24,8 +24,6 @@ public abstract class ViewModelBase : ObservableObject
var vm = Design.IsDesignMode
? Activator.CreateInstance(type)
: _serviceProvider.GetService(type);
Console.WriteLine(vm?.ToString());
if (vm is not ViewModelBase vmb) return false;

View File

@@ -17,7 +17,7 @@
Grid.Column="0"
Grid.Row="0"
Padding="10">
<Label>Server name</Label>
<TextBlock x:Name="ServerNameLabel">Server name</TextBlock>
</Border>
<Border
BorderThickness="2,0,0,0"

View File

@@ -1,9 +1,23 @@
using Avalonia;
using Avalonia.Controls;
namespace Nebula.Launcher.Views.Controls;
public partial class ServerContainerControl : UserControl
{
public static readonly StyledProperty<string> ServerNameProperty
= AvaloniaProperty.Register<ServerContainerControl, string>(nameof (ServerName));
public string ServerName
{
get => GetValue(ServerNameProperty);
set
{
SetValue(ServerNameProperty, value);
ServerNameLabel.Text = value;
}
}
public ServerContainerControl()
{
InitializeComponent();

View File

@@ -2,29 +2,34 @@
d:DesignHeight="450"
d:DesignWidth="800"
mc:Ignorable="d"
x:Class="Nebula.Launcher.Views.Pages.ServerListView"
x:DataType="pages:ServerListView"
x:Class="Nebula.Launcher.Views.Pages.ServerListView"
x:DataType="viewModels:ServerListViewModel"
xmlns="https://github.com/avaloniaui"
xmlns:controls="clr-namespace:Nebula.Launcher.Views.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:pages="clr-namespace:Nebula.Launcher.Views.Pages">
xmlns:models="clr-namespace:Nebula.Launcher.Models"
xmlns:viewModels="clr-namespace:Nebula.Launcher.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Design.DataContext>
<viewModels:ServerListViewModel />
</Design.DataContext>
<Grid ColumnDefinitions="*" RowDefinitions="*,40">
<ScrollViewer Margin="0,0,0,10" Padding="0,0,8,0">
<StackPanel>
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
<controls:ServerContainerControl />
</StackPanel>
<ListBox
Background="#00000000"
ItemsSource="{Binding ServerInfos}"
Padding="0">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:ServerInfo}">
<controls:ServerContainerControl ServerName="{Binding StatusData.Name}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
<Border
BorderThickness="2,0,0,0"
CornerRadius="10"

View File

@@ -2,6 +2,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nebula.Launcher", "Nebula.Launcher\Nebula.Launcher.csproj", "{D8F9728D-6153-4351-8BE2-52F7D54C299D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.LoaderApi", "Robust.LoaderApi\Robust.LoaderApi\Robust.LoaderApi.csproj", "{8AE91631-DE96-4A97-A255-058E27A7C3EA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -12,5 +14,9 @@ Global
{D8F9728D-6153-4351-8BE2-52F7D54C299D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8F9728D-6153-4351-8BE2-52F7D54C299D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8F9728D-6153-4351-8BE2-52F7D54C299D}.Release|Any CPU.Build.0 = Release|Any CPU
{8AE91631-DE96-4A97-A255-058E27A7C3EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8AE91631-DE96-4A97-A255-058E27A7C3EA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8AE91631-DE96-4A97-A255-058E27A7C3EA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8AE91631-DE96-4A97-A255-058E27A7C3EA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

1
Robust.LoaderApi Submodule

Submodule Robust.LoaderApi added at 86a02eef16