- add: Service think
This commit is contained in:
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "Robust.LoaderApi"]
|
||||
path = Robust.LoaderApi
|
||||
url = https://github.com/space-wizards/Robust.LoaderApi
|
||||
2
.idea/.idea.Nebula/.idea/avalonia.xml
generated
2
.idea/.idea.Nebula/.idea/avalonia.xml
generated
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
18
Nebula.Launcher/Assets/Icons.axaml
Normal file
18
Nebula.Launcher/Assets/Icons.axaml
Normal file
File diff suppressed because one or more lines are too long
60
Nebula.Launcher/Assets/Style.axaml
Normal file
60
Nebula.Launcher/Assets/Style.axaml
Normal 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>
|
||||
22
Nebula.Launcher/CurrentConVar.cs
Normal file
22
Nebula.Launcher/CurrentConVar.cs
Normal 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"
|
||||
]);
|
||||
}
|
||||
22
Nebula.Launcher/FileApis/AssemblyApi.cs
Normal file
22
Nebula.Launcher/FileApis/AssemblyApi.cs
Normal 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;
|
||||
}
|
||||
55
Nebula.Launcher/FileApis/FileApi.cs
Normal file
55
Nebula.Launcher/FileApis/FileApi.cs
Normal 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);
|
||||
}
|
||||
32
Nebula.Launcher/FileApis/HashApi.cs
Normal file
32
Nebula.Launcher/FileApis/HashApi.cs
Normal 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;
|
||||
}
|
||||
7
Nebula.Launcher/FileApis/Interfaces/IReadWriteFileApi.cs
Normal file
7
Nebula.Launcher/FileApis/Interfaces/IReadWriteFileApi.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Robust.LoaderApi;
|
||||
|
||||
namespace Nebula.Launcher.FileApis.Interfaces;
|
||||
|
||||
public interface IReadWriteFileApi : IFileApi, IWriteFileApi
|
||||
{
|
||||
}
|
||||
10
Nebula.Launcher/FileApis/Interfaces/IWriteFileApi.cs
Normal file
10
Nebula.Launcher/FileApis/Interfaces/IWriteFileApi.cs
Normal 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);
|
||||
}
|
||||
64
Nebula.Launcher/FileApis/ZipFileApi.cs
Normal file
64
Nebula.Launcher/FileApis/ZipFileApi.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
5
Nebula.Launcher/Models/RobustManifestInfo.cs
Normal file
5
Nebula.Launcher/Models/RobustManifestInfo.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Nebula.Launcher.Utils;
|
||||
|
||||
public record struct RobustManifestInfo(Uri ManifestUri, Uri DownloadUri, string Hash);
|
||||
3
Nebula.Launcher/Models/RobustManifestItem.cs
Normal file
3
Nebula.Launcher/Models/RobustManifestItem.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Nebula.Launcher.Utils;
|
||||
|
||||
public record struct RobustManifestItem(string Hash, string Path, int Id);
|
||||
80
Nebula.Launcher/Models/RobustServerEntry.cs
Normal file
80
Nebula.Launcher/Models/RobustServerEntry.cs
Normal 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);
|
||||
@@ -37,4 +37,8 @@
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Robust.LoaderApi\Robust.LoaderApi\Robust.LoaderApi.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
62
Nebula.Launcher/Services/ConfigurationService.cs
Normal file
62
Nebula.Launcher/Services/ConfigurationService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
70
Nebula.Launcher/Services/DebugService.cs
Normal file
70
Nebula.Launcher/Services/DebugService.cs
Normal 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
|
||||
}
|
||||
64
Nebula.Launcher/Services/FileService.cs
Normal file
64
Nebula.Launcher/Services/FileService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
76
Nebula.Launcher/Services/HubService.cs
Normal file
76
Nebula.Launcher/Services/HubService.cs
Normal 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,
|
||||
}
|
||||
15
Nebula.Launcher/Services/Logging/ConsoleLogger.cs
Normal file
15
Nebula.Launcher/Services/Logging/ConsoleLogger.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
6
Nebula.Launcher/Services/Logging/ILogger.cs
Normal file
6
Nebula.Launcher/Services/Logging/ILogger.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Nebula.Launcher.Services.Logging;
|
||||
|
||||
public interface ILogger
|
||||
{
|
||||
public void Log(LoggerCategory loggerCategory, string message);
|
||||
}
|
||||
154
Nebula.Launcher/Services/RestService.cs
Normal file
154
Nebula.Launcher/Services/RestService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
142
Nebula.Launcher/Utils/BandwidthStream.cs
Normal file
142
Nebula.Launcher/Utils/BandwidthStream.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
Nebula.Launcher/Utils/Manifest.cs
Normal file
118
Nebula.Launcher/Utils/Manifest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
121
Nebula.Launcher/Utils/RidUtility.cs
Normal file
121
Nebula.Launcher/Utils/RidUtility.cs
Normal 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
|
||||
}
|
||||
58
Nebula.Launcher/Utils/StreamHelper.cs
Normal file
58
Nebula.Launcher/Utils/StreamHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
135
Nebula.Launcher/Utils/UriHelper.cs
Normal file
135
Nebula.Launcher/Utils/UriHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
3084
Nebula.Launcher/Utils/runtime.json
Normal file
3084
Nebula.Launcher/Utils/runtime.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
1
Robust.LoaderApi
Submodule
Submodule Robust.LoaderApi added at 86a02eef16
Reference in New Issue
Block a user