30 Commits

Author SHA1 Message Date
63a4b39aa9 - tweak: change mirror 2026-04-08 21:58:51 +03:00
8bf665d1f1 - fix: code logic 2026-03-16 12:39:25 +03:00
7a77af2d80 - add: game service 2026-03-15 15:47:20 +03:00
830cb38d9f - tweak: less memory tweak part 2 2026-03-13 20:29:53 +03:00
8f66bf9f09 - tweak: less memory tweak 2026-03-12 21:25:38 +03:00
755fa51adc - add: separation user data 2026-01-25 12:01:08 +03:00
a15d187550 - fix: update resolver dotnet resolving 2026-01-24 00:10:58 +03:00
6e6ebffb62 - tweak: improve update resolver for further updates 2026-01-23 23:52:21 +03:00
c2ab550329 - tweak: exception representation 2026-01-22 21:13:39 +03:00
ff31412719 - add: zip content support 2026-01-16 21:02:34 +03:00
15e4e3fbd7 - fix: harmony thinks 2026-01-16 18:53:26 +03:00
5306a86d13 - fix: dotnet url 2026-01-16 18:44:09 +03:00
c304ac94fe - tweak: dotnet 10 update 2026-01-16 18:32:37 +03:00
e0a16f7fb6 - fix: memory leak from canalisation part 2 2025-12-12 22:23:45 +03:00
f7cec5d093 - fix: memory leak part 1 2025-12-11 21:47:54 +03:00
Cinkafox
0c6bbaadac - tweak: loading popup thinks
* - tweak: change loading handle logic

* - tweak: beautify loading thinks

* - fix: speed thinks while downloading
2025-12-06 23:25:25 +03:00
d7f775e80c - add: linux support 2025-12-03 23:16:18 +03:00
6eead05308 - fix: update RobustNatives 2025-11-08 18:39:54 +03:00
673a972598 Merge remote-tracking branch 'origin/master' 2025-11-08 13:42:36 +03:00
f44589454c - fix: auth renew and null fix 2025-11-08 13:42:11 +03:00
fd06729035 - fix: update resolver startup 2025-10-04 19:59:23 +03:00
a09ace0d39 - fix: UpdateResolver preview think 2025-09-08 21:26:12 +03:00
56c373134f - fix: little thinks 2025-09-07 21:21:50 +03:00
fa68b4bcd5 - fix: Message on error 2025-08-17 21:40:31 +03:00
b86b65fd66 - fix: profiles token refresh now 2025-08-17 21:02:45 +03:00
6c967efd85 - fix: auth logic part 2 2025-08-07 22:34:25 +03:00
6a6bb4f27c - fix: auth logic part 1 2025-08-06 21:29:00 +03:00
f6a15e9c45 - add: multiauth 2025-07-15 18:38:53 +03:00
c148f6ed34 - add: nice error view 2025-07-14 10:06:38 +03:00
a475148543 - tweak: a little bit of repair and tweaks 2025-07-13 10:07:36 +03:00
153 changed files with 5044 additions and 2439 deletions

View File

@@ -17,7 +17,7 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v3.2.0
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Install dependencies
run: dotnet restore
- name: Set version

View File

@@ -17,7 +17,7 @@ jobs:
- name: Setup .NET Core
uses: actions/setup-dotnet@v3.2.0
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Install dependencies
run: dotnet restore
- name: Create build

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ obj/
riderModule.iml
/_ReSharper.Caches/
release/
publish/
publish/
/.vs

View File

@@ -45,6 +45,7 @@
<entry key="Nebula.Launcher/Views/ServerListView.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/Views/Tabs/AccountInfoTab.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/Views/Tabs/ServerListTab.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.Launcher/Views/VisualErrorView.axaml" value="Nebula.Launcher/Nebula.Launcher.csproj" />
<entry key="Nebula.UpdateResolver/App.axaml" value="Nebula.UpdateResolver/Nebula.UpdateResolver.csproj" />
<entry key="Nebula.UpdateResolver/MainWindow.axaml" value="Nebula.UpdateResolver/Nebula.UpdateResolver.csproj" />
</map>

View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/Robust.LoaderApi" vcs="Git" />
</component>
</project>

6
Directory.Build.props Normal file
View File

@@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>

33
Directory.Packages.props Normal file
View File

@@ -0,0 +1,33 @@
<Project>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="11.3.11" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.11" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.11" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.11" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.11" />
<PackageVersion Include="Avalonia.Svg.Skia" Version="11.3.0" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="Fluent.Net" Version="1.0.63" />
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.4" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
<PackageVersion Include="libsodium" Version="1.0.20" />
<PackageVersion Include="Robust.Natives" Version="0.2.3" />
<PackageVersion Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageVersion Include="Lib.Harmony" Version="2.4.2" />
<PackageVersion Include="SharpZstd.Interop" Version="1.5.6" />
<PackageVersion Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="NUnit" Version="3.14.0" />
<PackageVersion Include="NUnit.Analyzers" Version="3.9.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.3.0" />
</ItemGroup>
</Project>

View File

@@ -28,14 +28,16 @@ public class App : Application
{
case IClassicDesktopStyleApplicationLifetime desktop:
DisableAvaloniaDataAnnotationValidation();
desktop.MainWindow = new MessageWindow(out provider);
desktop.MainWindow = (Window)(provider = new MessageWindow());
break;
case ISingleViewApplicationLifetime singleViewPlatform:
singleViewPlatform.MainView = new MessageView(out provider);
singleViewPlatform.MainView = (Control)(provider = new MessageView());
break;
}
provider?.ShowMessage("Launcher is already running.","hey shithead!");
provider?.ShowMessage(
"Error: An instance of the application is already running. Please close the existing instance before launching a new one.",
"Duplicate instance detected.");
return;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -24,8 +24,9 @@ account-auth-server = Authentication Server
account-auth-button = Authenticate
account-auth-save = Save Profile
account-auth-hello = Hello,
account-auth-current-server = Current server auth:
account-auth-logout = Log out
auth-current-login-name = Current login: {$login}
auth-current-login-name = Current login {$auth_server}: {$login}
auth-current-login-no-name = Profile not selected
auth-processing = Processing authentication request...
@@ -37,6 +38,7 @@ auth-name-resolution-error = Failed to resolve server address. Check your networ
auth-secure-error = Failed to cinnect to the server using SSL
auth-config-read = Reading authentication configuration...
auth-try-auth-config = Attempting to authenticate using saved configuration.
auth-try-auth-profile = Attempting to authenticate using profile
config-export-logs = Export logs
config-open-data = Open data path
@@ -68,3 +70,5 @@ popup-login-credentials-warning-cancel = Cancel
popup-login-credentials-warning-proceed = Proceed
goto-path-home = Root folder
tab-favorite = Favorite
server-list-loading = Loading server list.. Please wait

View File

@@ -24,8 +24,9 @@ account-auth-server = Сервер аутентификации
account-auth-button = Аутентифицировать
account-auth-save = Сохранить профиль
account-auth-hello = Привет,
account-auth-current-server = Текущий сервер авторизации:
account-auth-logout = Выйти
auth-current-login-name = Текущий профиль: {$login}
auth-current-login-name = Текущий профиль {$auth_server}: {$login}
auth-current-login-no-name = Профиль не выбран
auth-processing = Обработка запроса аутентификации...
@@ -37,6 +38,7 @@ auth-name-resolution-error = Не удалось разрешить адрес
auth-secure-error = Не удалось подключиться к серверу по SSL. Проверьте сетевые настройки.
auth-config-read = Чтение конфигурации аутентификации...
auth-try-auth-config = Попытка аутентификации с использованием сохраненной конфигурации.
auth-try-auth-profile = Попытка аутентификации с использованием профиля
config-export-logs = Экспортировать логи
config-open-data = Открыть путь данных
@@ -67,4 +69,6 @@ popup-login-credentials-warning-go-auth = Перейти на страницу
popup-login-credentials-warning-cancel = Отмена
popup-login-credentials-warning-proceed = Продолжить
goto-path-home = Корн. папка
goto-path-home = Корн. папка
tab-favorite = Избранное
server-list-loading = Загрузка списка серверов. Пожалуйста, подождите...

View File

@@ -0,0 +1,99 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
namespace Nebula.Launcher.ViewModels.Pages;
public sealed class ArrayUnitConfigControl : Border, IConfigControl
{
private readonly List<IConfigControl> _itemControls = [];
private readonly StackPanel _itemsPanel = new StackPanel() { Orientation = Orientation.Vertical };
private readonly Button _addButton = new Button() { Content = new Label()
{
Content = "Add Item"
}, Classes = { "ConfigBorder" }};
private readonly int _oldCount;
private readonly Type _elementType;
private readonly StackPanel _panel = new();
public string ConfigName { get; }
public bool Dirty => _itemControls.Any(dirty => dirty.Dirty) || _itemControls.Count != _oldCount;
public ArrayUnitConfigControl(string name, object value)
{
Classes.Add("ConfigBorder");
_elementType = value.GetType().GetElementType()!;
ConfigName = name;
_panel.Orientation = Orientation.Vertical;
_panel.Spacing = 4f;
_itemsPanel.Spacing = 4f;
_panel.Children.Add(new Label { Content = name });
_panel.Children.Add(_itemsPanel);
_panel.Children.Add(_addButton);
_addButton.Click += (_, _) => AddItem(ConfigControlHelper.CreateDefaultValue(_elementType)!);
Child = _panel;
SetValue(value);
_oldCount = _itemControls.Count;
}
private void AddItem(object value)
{
var itemPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2 };
var control = ConfigControlHelper.GetConfigControl(_itemControls.Count.ToString(), value);
var removeButton = new Button { Content = new Label(){ Content = "Remove" }, Classes = { "ConfigBorder" }};
removeButton.Click += (_, _) =>
{
_itemControls.Remove(control);
_itemsPanel.Children.Remove(itemPanel);
};
((Control)control).Margin = new Thickness(5);
itemPanel.Children.Add((Control)control);
itemPanel.Children.Add(removeButton);
_itemsPanel.Children.Add(itemPanel);
_itemControls.Add(control);
}
public void SetValue(object value)
{
_itemControls.Clear();
_itemsPanel.Children.Clear();
if (value is IEnumerable list)
{
foreach (var item in list)
{
AddItem(item);
}
}
}
public object GetValue()
{
return ConvertArray(_itemControls.Select(c => c.GetValue()).ToArray(), _elementType);
}
public static Array ConvertArray(Array sourceArray, Type targetType)
{
int length = sourceArray.Length;
var newArray = Array.CreateInstance(targetType, length);
for (int i = 0; i < length; i++)
{
var value = sourceArray.GetValue(i);
var converted = Convert.ChangeType(value, targetType);
newArray.SetValue(converted, i);
}
return newArray;
}
}

View File

@@ -0,0 +1,86 @@
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Nebula.Shared.Configurations;
namespace Nebula.Launcher.Configurations;
public abstract class ComplexConVarBinder<T> : INotifyPropertyChanged, INotifyPropertyChanging
{
private readonly ConVarObserver<T> _baseConVar;
private readonly Lock _lock = new();
private readonly SemaphoreSlim _valueChangeSemaphore = new(1, 1);
public T? Value
{
get
{
lock (_lock)
{
return _baseConVar.Value;
}
}
set
{
_ = SetValueAsync(value);
}
}
public bool HasValue
{
get
{
lock (_lock)
{
return _baseConVar.HasValue;
}
}
}
protected ComplexConVarBinder(ConVarObserver<T> baseConVar)
{
_baseConVar = baseConVar ?? throw new ArgumentNullException(nameof(baseConVar));
_baseConVar.PropertyChanged += BaseConVarOnPropertyChanged;
_baseConVar.PropertyChanging += BaseConVarOnPropertyChanging;
}
private async Task SetValueAsync(T? value)
{
await _valueChangeSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var newValue = await OnValueChange(value).ConfigureAwait(false);
lock (_lock)
{
_baseConVar.Value = newValue;
}
}
finally
{
_valueChangeSemaphore.Release();
}
}
protected abstract Task<T?> OnValueChange(T? newValue);
private void BaseConVarOnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasValue)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
}
private void BaseConVarOnPropertyChanging(object? sender, PropertyChangingEventArgs e)
{
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(HasValue)));
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(Value)));
}
public event PropertyChangedEventHandler? PropertyChanged;
public event PropertyChangingEventHandler? PropertyChanging;
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
namespace Nebula.Launcher.ViewModels.Pages;
public sealed class ComplexUnitConfigControl : Border, IConfigControl
{
private readonly List<(PropertyInfo, IConfigControl)> _units = [];
private Type _objectType = typeof(object);
private readonly StackPanel _panel = new();
public string ConfigName { get; }
public bool Dirty => _units.Any(dirty => dirty.Item2.Dirty);
public ComplexUnitConfigControl(string name, object obj)
{
Classes.Add("ConfigBorder");
_panel.Orientation = Orientation.Vertical;
_panel.Spacing = 4f;
ConfigName = name;
Child = _panel;
SetValue(obj);
}
public void SetValue(object value)
{
_units.Clear();
_panel.Children.Clear();
_objectType = value.GetType();
_panel.Children.Add(new Label()
{
Content = ConfigName
});
foreach (var propInfo in _objectType.GetProperties())
{
if(propInfo.PropertyType.IsInterface)
continue;
var propValue = propInfo.GetValue(value);
var control = ConfigControlHelper.GetConfigControl(propInfo.Name, propValue!);
((Control)control).Margin = new Thickness(5);
_panel.Children.Add((Control)control);
_units.Add((propInfo,control));
}
}
public object GetValue()
{
var obj = ConfigControlHelper.CreateDefaultValue(_objectType);
foreach (var (fieldInfo, configControl) in _units)
{
fieldInfo.SetValue(obj, configControl.GetValue());
}
return obj!;
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Linq;
namespace Nebula.Launcher.ViewModels.Pages;
public static class ConfigControlHelper{
public static IConfigControl GetConfigControl(string name,object value)
{
switch (value)
{
case string stringValue:
return new StringUnitConfigControl(name, stringValue);
case int intValue:
return new IntUnitConfigControl(name, intValue);
case float floatValue:
return new FloatUnitConfigControl(name, floatValue);
}
var valueType = value.GetType();
if (valueType.IsArray)
return new ArrayUnitConfigControl(name, value);
return new ComplexUnitConfigControl(name, value);
}
public static object? CreateDefaultValue(Type type)
{
if(type.IsValueType)
return Activator.CreateInstance(type);
var ctor = type.GetConstructors().First();
var parameters = ctor.GetParameters()
.Select(p => CreateDefaultValue(p.ParameterType))
.ToArray();
return ctor.Invoke(parameters);
}
}

View File

@@ -0,0 +1,19 @@
using System.Globalization;
namespace Nebula.Launcher.ViewModels.Pages;
public sealed class FloatUnitConfigControl(string name, float value) : UnitConfigControl<float>(name, value)
{
public CultureInfo CultureInfo = CultureInfo.InvariantCulture;
public override void SetConfValue(float value)
{
ConfigValue = value.ToString(CultureInfo);
}
public override float GetConfValue()
{
return float.Parse(ConfigValue, CultureInfo);
}
}

View File

@@ -0,0 +1,9 @@
namespace Nebula.Launcher.ViewModels.Pages;
public interface IConfigControl
{
public string ConfigName { get; }
public bool Dirty {get;}
public abstract void SetValue(object value);
public abstract object GetValue();
}

View File

@@ -0,0 +1,14 @@
namespace Nebula.Launcher.ViewModels.Pages;
public sealed class IntUnitConfigControl(string name, int value) : UnitConfigControl<int>(name, value)
{
public override void SetConfValue(int value)
{
ConfigValue = value.ToString();
}
public override int GetConfValue()
{
return int.Parse(ConfigValue);
}
}

View File

@@ -0,0 +1,14 @@
namespace Nebula.Launcher.ViewModels.Pages;
public sealed class StringUnitConfigControl(string name, string value) : UnitConfigControl<string>(name, value)
{
public override void SetConfValue(string value)
{
ConfigValue = value;
}
public override string GetConfValue()
{
return ConfigValue;
}
}

View File

@@ -0,0 +1,53 @@
using Avalonia.Controls;
using Avalonia.Layout;
namespace Nebula.Launcher.ViewModels.Pages;
public abstract class UnitConfigControl<T> : Border, IConfigControl where T : notnull
{
private readonly Label _nameLabel = new();
private readonly TextBox _valueLabel = new();
private string _originalValue;
private StackPanel _panel = new();
public string ConfigName { get; }
public bool Dirty => _originalValue != ConfigValue;
protected string ConfigValue
{
get => _valueLabel.Text ?? string.Empty;
set => _valueLabel.Text = value;
}
public UnitConfigControl(string name, T value)
{
Classes.Add("ConfigBorder");
ConfigName = name;
_panel.Orientation = Orientation.Horizontal;
_panel.Children.Add(_nameLabel);
_panel.Children.Add(_valueLabel);
_nameLabel.Content = name;
_nameLabel.VerticalAlignment = VerticalAlignment.Center;
Child = _panel;
SetConfValue(value);
_originalValue = ConfigValue;
}
public abstract void SetConfValue(T value);
public abstract T GetConfValue();
public void SetValue(object value)
{
SetConfValue((T)value);
}
public object GetValue()
{
return GetConfValue()!;
}
}

View File

@@ -14,7 +14,7 @@ public class LocalizedLabel : Label
set
{
SetValue(LocalIdProperty, value);
Content = LocalisationService.GetString(value);
Content = LocalizationService.GetString(value);
}
}
}

View File

@@ -1,118 +0,0 @@
using Avalonia.Controls;
using Nebula.Launcher.Models;
using Nebula.Launcher.ServerListProviders;
using Nebula.Launcher.ViewModels;
using Nebula.Launcher.ViewModels.Pages;
namespace Nebula.Launcher.Controls;
public partial class ServerListView : UserControl
{
private IServerListProvider _provider = default!;
private ServerFilter? _currentFilter;
public bool IsLoading { get; private set; }
public ServerListView()
{
InitializeComponent();
}
public static ServerListView TakeFrom(IServerListProvider provider)
{
var serverListView = new ServerListView();
if (provider is IServerListDirtyInvoker invoker)
{
invoker.Dirty += serverListView.OnDirty;
}
serverListView._provider = provider;
serverListView.RefreshFromProvider();
return serverListView;
}
public void RefreshFromProvider()
{
if (IsLoading)
return;
Clear();
StartLoading();
_provider.LoadServerList();
if (_provider.IsLoaded) PasteServersFromList();
else _provider.OnLoaded += RefreshRequired;
}
public void RequireStatusUpdate()
{
foreach (var rawView in ServerList.Items)
{
if (rawView is ServerEntryModelView serverEntryModelView)
{
//serverEntryModelView.UpdateStatusIfNecessary();
}
}
}
public void ApplyFilter(ServerFilter? filter)
{
_currentFilter = filter;
if(IsLoading)
return;
foreach (var serverView in ServerList.Items)
{
if(serverView is IFilterConsumer filterConsumer)
filterConsumer.ProcessFilter(filter);
}
}
private void OnDirty()
{
RefreshFromProvider();
}
private void Clear()
{
ErrorList.Items.Clear();
ServerList.Items.Clear();
}
private void PasteServersFromList()
{
foreach (var serverEntry in _provider.GetServers())
{
ServerList.Items.Add(serverEntry);
if(serverEntry is IFilterConsumer serverFilter)
serverFilter.ProcessFilter(_currentFilter);
}
foreach (var error in _provider.GetErrors())
{
ErrorList.Items.Add(error);
}
EndLoading();
}
private void RefreshRequired()
{
PasteServersFromList();
_provider.OnLoaded -= RefreshRequired;
}
private void StartLoading()
{
Clear();
IsLoading = true;
LoadingLabel.IsVisible = true;
}
private void EndLoading()
{
IsLoading = false;
LoadingLabel.IsVisible = false;
}
}

View File

@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Reactive;
using Avalonia.Threading;
namespace Nebula.Launcher.Controls;
public class SimpleGraph : Control
{
// Bindable data: list of doubles or points
public static readonly StyledProperty<ObservableCollection<double>> ValuesProperty =
AvaloniaProperty.Register<SimpleGraph,ObservableCollection<double>>(nameof(Values));
public static readonly StyledProperty<IBrush> GraphBrushProperty =
AvaloniaProperty.Register<SimpleGraph, IBrush>(nameof(GraphBrush), Brushes.CornflowerBlue);
public static readonly StyledProperty<IBrush> GridBrushProperty =
AvaloniaProperty.Register<SimpleGraph, IBrush>(nameof(GridBrush), Brushes.LightGray);
static SimpleGraph()
{
ValuesProperty.Changed.Subscribe(
new AnonymousObserver<AvaloniaPropertyChangedEventArgs<ObservableCollection<double>>>(args =>
{
if (args.Sender is not SimpleGraph g)
return;
g.InvalidateVisual();
g.Values.CollectionChanged += g.ValuesOnCollectionChanged;
}));
}
public SimpleGraph()
{
Values = new ObservableCollection<double>();
}
private void ValuesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
Dispatcher.UIThread.Post(InvalidateVisual);
}
public ObservableCollection<double> Values
{
get => GetValue(ValuesProperty);
set => SetValue(ValuesProperty, value);
}
public IBrush GraphBrush
{
get => GetValue(GraphBrushProperty);
set => SetValue(GraphBrushProperty, value);
}
public IBrush GridBrush
{
get => GetValue(GridBrushProperty);
set => SetValue(GridBrushProperty, value);
}
public override void Render(DrawingContext context)
{
base.Render(context);
if (Bounds.Width <= 0 || Bounds.Height <= 0)
return;
// background grid
DrawGrid(context, Bounds);
if (Values.Count == 0)
return;
var min = Values.Min();
var max = Values.Max();
if (Math.Abs(min - max) < 0.001)
{
min -= 1;
max += 1;
}
var geo = new StreamGeometry();
using (var ctx = geo.Open())
{
if (Values.Count > 1)
{
Point p0 = Map(0, Values[0]);
ctx.BeginFigure(p0, false);
for (int i = 0; i < Values.Count - 1; i++)
{
var p1 = Map(i, Values[i]);
var p2 = Map(i + 1, Values[i + 1]);
// control points for smoothing
var c1 = new Point((p1.X + p2.X) / 2, p1.Y);
var c2 = new Point((p1.X + p2.X) / 2, p2.Y);
ctx.CubicBezierTo(c1, c2, p2);
}
ctx.EndFigure(false);
}
}
// stroke
context.DrawGeometry(null, new Pen(GraphBrush, 2), geo);
// draw points
for (var i = 0; i < Values.Count; i++)
{
var p = Map(i, Values[i]);
context.DrawEllipse(GraphBrush, null, p, 3, 3);
}
return;
// map data index/value -> point
Point Map(int i, double val)
{
var x = Bounds.X + Bounds.Width * (i / (double)Math.Max(1, Values.Count - 1));
var y = Bounds.Y + Bounds.Height - (val - min) / (max - min) * Bounds.Height;
return new Point(x, y);
}
}
private void DrawGrid(DrawingContext dc, Rect r)
{
var pen = new Pen(GridBrush, 0.5);
var rows = 4;
var cols = Math.Max(2, Values?.Count ?? 2);
for (var i = 0; i <= rows; i++)
{
var y = r.Y + i * (r.Height / rows);
dc.DrawLine(pen, new Point(r.X, y), new Point(r.Right, y));
}
for (var j = 0; j <= cols; j++)
{
var x = r.X + j * (r.Width / cols);
dc.DrawLine(pen, new Point(x, r.Y), new Point(x, r.Bottom));
}
}
}

View File

@@ -1,11 +1,14 @@
using Avalonia;
using Avalonia.Controls;
using System;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Avalonia.Platform;
using Nebula.Launcher.Utils;
using Nebula.Launcher.ViewModels.Pages;
using Color = System.Drawing.Color;
namespace Nebula.Launcher.Converters;
public class TypeConverters
public static class TypeConverters
{
public static FuncValueConverter<string, string?> IconConverter { get; } =
new(iconKey =>
@@ -13,4 +16,17 @@ public class TypeConverters
if (iconKey == null) return null;
return $"/Assets/svg/{iconKey}.svg";
});
public static FuncValueConverter<string, IImage?> ImageConverter { get; } =
new(iconKey =>
{
if (iconKey == null) return null;
return new Avalonia.Media.Imaging.Bitmap(AssetLoader.Open(new Uri($"avares://Nebula.Launcher/Assets/error_presentation/{iconKey}.png")));
});
public static FuncValueConverter<string, Avalonia.Media.Color> NameColorRepresentation { get; } =
new((str)=>ColorUtils.GetColorFromString(str ?? throw new ArgumentNullException(nameof(str),"Name of color is null!")));
public static FuncValueConverter<string, bool> StringIsNotEmpty { get; } =
new(iconKey => !string.IsNullOrEmpty(iconKey));
}

View File

@@ -2,8 +2,10 @@ using System.Collections.Generic;
using System.Globalization;
using Nebula.Launcher.Models;
using Nebula.Launcher.Models.Auth;
using Nebula.Shared.ConfigMigrations;
using Nebula.Shared.Configurations;
using Nebula.Shared.Configurations.Migrations;
using Nebula.Shared.Services;
using Nebula.Shared.Services.ConfigMigrations;
namespace Nebula.Launcher;
@@ -12,10 +14,11 @@ public static class LauncherConVar
public static readonly ConVar<bool> DoMigration =
ConVarBuilder.Build("migration.doMigrate", true);
public static readonly ConVar<AuthTokenCredentials[]> AuthProfiles =
ConVarBuilder.BuildWithMigration<AuthTokenCredentials[]>("auth.profiles.v3",
public static readonly ConVar<string[]> AuthProfiles =
ConVarBuilder.BuildWithMigration<string[]>("auth.profiles.v4",
MigrationQueueBuilder.Instance
.With(new ProfileMigrationV2("auth.profiles.v2","auth.profiles.v3"))
.With(new ProfileMigrationV2("auth.profiles.v2","auth.profiles.v4"))
.With(new ProfileMigrationV3V4("auth.profiles.v3","auth.profiles.v4"))
.Build(),
[]);
@@ -33,19 +36,25 @@ public static class LauncherConVar
new AuthServerCredentials(
"WizDen",
[
"https://harpy.durenko.tatar/auth-api/",
"https://feline.durenko.tatar/auth-api/",
"https://auth.spacestation14.com/",
"https://auth.fallback.spacestation14.com/",
]),
new AuthServerCredentials(
"SimpleStation",
[
"https://auth.simplestation.org/",
])
]);
public static readonly ConVar<ServerHubRecord[]> Hub = ConVarBuilder.Build<ServerHubRecord[]>("launcher.hub.v2", [
new ServerHubRecord("WizDen", "https://harpy.durenko.tatar/hub-api/api/servers"),
new ServerHubRecord("AltHub","https://web.networkgamez.com/api/servers")
new ServerHubRecord("WizDen", "https://feline.durenko.tatar/hub-api/api/servers"),
new ServerHubRecord("AltHub","https://hub.singularity14.co.uk/api/servers")
]);
public static readonly ConVar<string> CurrentLang = ConVarBuilder.Build<string>("launcher.language", CultureInfo.CurrentCulture.Name);
public static readonly ConVar<string> ILSpyUrl = ConVarBuilder.Build<string>("decompiler.url",
"https://github.com/icsharpcode/ILSpy/releases/download/v9.0/ILSpy_binaries_9.0.0.7889-x64.zip");
"https://feline.durenko.tatar/ILSpy_selfcontained_10.0.0.8330-x64.zip");
public static readonly ConVar<string> ILSpyVersion = ConVarBuilder.Build<string>("dotnet.version", "10");
}

View File

@@ -2,16 +2,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:Nebula.Launcher.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
Width="600"
Height="400"
x:Class="Nebula.Launcher.MessageBox.MessageView">
<Grid RowDefinitions="50,*" ColumnDefinitions="*">
<Border Grid.Column="0" Background="#222222" Padding="10" BorderBrush="#444444" BorderThickness="0,0,0,3">
<Label VerticalAlignment="Center" x:Name="Title">Text</Label>
</Border>
<Panel Margin="5" Grid.Row="1">
<Label x:Name="Message">Message</Label>
</Panel>
</Grid>
<views:VisualErrorView x:Name="ErrorView"/>
</UserControl>

View File

@@ -1,21 +1,22 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Nebula.Launcher.ViewModels;
namespace Nebula.Launcher.MessageBox;
public partial class MessageView : UserControl, IMessageContainerProvider
{
public MessageView(out IMessageContainerProvider provider)
private readonly VisualErrorViewModel _context;
public MessageView()
{
InitializeComponent();
provider = this;
_context = new VisualErrorViewModel();
ErrorView.Content = _context;
}
public void ShowMessage(string message, string title)
{
Title.Content = title;
Message.Content = message;
_context.Title = title;
_context.Description = message;
}
}

View File

@@ -3,10 +3,52 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:messageBox="clr-namespace:Nebula.Launcher.MessageBox"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
SystemDecorations="BorderOnly"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="260"
Width="600"
Height="400"
Height="260"
CanResize="False"
x:Class="Nebula.Launcher.MessageBox.MessageWindow"
Title="MessageWindow">
<Grid ColumnDefinitions="*" RowDefinitions="30,*">
<messageBox:MessageView
Grid.Column="0"
Grid.Row="0"
Grid.RowSpan="2"
x:Name="MessageView" />
<Border
BorderThickness="0,0,0,2"
CornerRadius="0"
Grid.Column="0"
Grid.Row="0">
<Border.BorderBrush>
<LinearGradientBrush EndPoint="100%,50%" StartPoint="0%,50%">
<GradientStop Color="#222222" Offset="0.0" />
<GradientStop Color="#442222" Offset="1.0" />
</LinearGradientBrush>
</Border.BorderBrush>
<Panel
Height="30"
PointerPressed="InputElement_OnPointerPressed">
<TextBlock
FontSize="10"
Foreground="White"
IsVisible="False"
Margin="15,0"
Text="Nebula Launcher"
VerticalAlignment="Center" />
<StackPanel
HorizontalAlignment="Right"
Margin="5,0,5,0"
Orientation="Horizontal"
Spacing="8">
<Button
Click="Close_Click"
Content="🗙"
Foreground="Azure" />
</StackPanel>
</Panel>
</Border>
</Grid>
</Window>

View File

@@ -1,12 +1,28 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
namespace Nebula.Launcher.MessageBox;
public partial class MessageWindow : Window
public partial class MessageWindow : Window, IMessageContainerProvider
{
public MessageWindow(out IMessageContainerProvider provider)
public MessageWindow()
{
InitializeComponent();
Content = new MessageView(out provider);
}
public void ShowMessage(string message, string title)
{
MessageView.ShowMessage(message, title);
}
private void Close_Click(object? sender, RoutedEventArgs e)
{
Close();
}
private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
BeginMoveDrag(e);
}
}

View File

@@ -1,10 +1,12 @@
using System.Text.Json.Serialization;
using System.Windows.Input;
using Avalonia.Media;
using Nebula.Shared.Services;
namespace Nebula.Launcher.Models.Auth;
public sealed record ProfileAuthCredentials(
AuthTokenCredentials Credentials,
public sealed record ProfileEntry(
ProfileAuthCredentials Credentials,
string AuthName,
[property: JsonIgnore] ICommand OnSelect = default!,
[property: JsonIgnore] ICommand OnDelete = default!);

View File

@@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using Nebula.Launcher.ProcessHelper;
using Nebula.Launcher.ViewModels;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Shared.Services;
@@ -6,27 +9,59 @@ namespace Nebula.Launcher.Models;
public sealed class ContentLogConsumer : IProcessLogConsumer
{
private readonly LogPopupModelView _currLog;
private readonly PopupMessageService _popupMessageService;
private readonly List<string> _outMessages = [];
private LogPopupModelView? _currentLogPopup;
public int MaxMessages { get; set; } = 100;
public ContentLogConsumer(LogPopupModelView currLog, PopupMessageService popupMessageService)
public ContentLogConsumer(PopupMessageService popupMessageService)
{
_currLog = currLog;
_popupMessageService = popupMessageService;
}
public void Popup()
{
if(_currentLogPopup is not null)
return;
_currentLogPopup = new LogPopupModelView(_popupMessageService);
_currentLogPopup.OnDisposing += OnLogPopupDisposing;
foreach (var message in _outMessages.ToArray())
{
_currentLogPopup.Append(message);
}
_popupMessageService.Popup(_currentLogPopup);
}
private void OnLogPopupDisposing(PopupViewModelBase obj)
{
if(_currentLogPopup == null)
return;
_currentLogPopup.OnDisposing -= OnLogPopupDisposing;
_currentLogPopup = null;
}
public void Out(string text)
{
_currLog.Append(text);
_outMessages.Add(text);
if(_outMessages.Count >= MaxMessages)
_outMessages.RemoveAt(0);
_currentLogPopup?.Append(text);
}
public void Error(string text)
{
_currLog.Append(text);
Out(text);
}
public void Fatal(string text)
{
_popupMessageService.Popup("Fatal error while stop instance:" + text);
_popupMessageService.Popup(new ExceptionCompound("Error while running program", text));
}
}

View File

@@ -0,0 +1,6 @@
namespace Nebula.Launcher.Models;
public interface IRunningSignalConsumer
{
public void ProcessRunningSignal(bool isRunning);
}

View File

@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@@ -15,38 +14,24 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0"/>
<PackageReference Include="Avalonia" Version="11.2.1"/>
<PackageReference Include="Avalonia.Desktop" Version="11.2.1"/>
<PackageReference Include="Avalonia.Svg.Skia" Version="11.2.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.1"/>
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.1"/>
<PackageReference Include="AsyncImageLoader.Avalonia"/>
<PackageReference Include="Avalonia"/>
<PackageReference Include="Avalonia.Desktop"/>
<PackageReference Include="Avalonia.Svg.Skia"/>
<PackageReference Include="Avalonia.Themes.Fluent"/>
<PackageReference Include="Avalonia.Fonts.Inter"/>
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.1">
<PackageReference Include="Avalonia.Diagnostics">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1"/>
<PackageReference Include="Fluent.Net" Version="1.0.63" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0"/>
<PackageReference Include="libsodium" Version="1.0.20"/>
<PackageReference Include="Robust.Natives" Version="0.1.1" />
</ItemGroup>
<ItemGroup>
<Compile Update="Views\Tabs\ServerListTab.axaml.cs">
<DependentUpon>ServerListTab.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\Popup\AddFavoriteView.axaml.cs">
<DependentUpon>AddFavoriteView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Controls\ServerListView.axaml.cs">
<DependentUpon>ServerListView.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<PackageReference Include="CommunityToolkit.Mvvm"/>
<PackageReference Include="Fluent.Net"/>
<PackageReference Include="JetBrains.Annotations"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection"/>
<PackageReference Include="libsodium"/>
<PackageReference Include="Robust.Natives"/>
<PackageReference Include="Avalonia.Controls.ItemsRepeater"/>
</ItemGroup>
<Target Name="BuildCheck" AfterTargets="AfterBuild">
@@ -77,9 +62,7 @@
<ItemGroup>
<ProjectReference Include="..\Nebula.Shared\Nebula.Shared.csproj"/>
<ProjectReference Include="..\Nebula.SourceGenerators\Nebula.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Controls\ServerListView.axaml" />
<ProjectReference Include="..\Nebula.Runner\Nebula.Runner.csproj"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Nebula.Shared.Services;
@@ -9,11 +10,11 @@ public abstract class DotnetProcessStartInfoProviderBase(DotnetResolverService r
{
protected abstract string GetDllPath();
public virtual async Task<ProcessStartInfo> GetProcessStartInfo()
public virtual async Task<ProcessStartInfo> GetProcessStartInfo(CancellationToken cancellationToken = default)
{
return new ProcessStartInfo
{
FileName = await resolverService.EnsureDotnet(),
FileName = await resolverService.EnsureDotnet(cancellationToken),
Arguments = GetDllPath(),
CreateNoWindow = true,
UseShellExecute = false,

View File

@@ -2,6 +2,7 @@ using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Shared;
@@ -31,11 +32,11 @@ public sealed class GameProcessStartInfoProvider(DotnetResolverService resolverS
return this;
}
public override async Task<ProcessStartInfo> GetProcessStartInfo()
public override async Task<ProcessStartInfo> GetProcessStartInfo(CancellationToken cancellationToken = default)
{
var baseStart = await base.GetProcessStartInfo();
var baseStart = await base.GetProcessStartInfo(cancellationToken);
var authProv = accountInfoViewModel.Credentials;
var authProv = accountInfoViewModel.Credentials.Value;
if(authProv is null)
throw new Exception("Client is without selected auth");

View File

@@ -5,29 +5,41 @@ using Microsoft.Extensions.DependencyInjection;
using Nebula.Shared;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Utils;
namespace Nebula.Launcher.ProcessHelper;
[ServiceRegister]
public sealed class GameRunnerPreparer(IServiceProvider provider, ContentService contentService, EngineService engineService, DebugService debugService)
public sealed class GameRunnerPreparer(IServiceProvider provider, ContentService contentService, EngineService engineService)
{
public async Task<ProcessRunHandler<GameProcessStartInfoProvider>> GetGameProcessStartInfoProvider(RobustUrl address, ILoadingHandler loadingHandler, CancellationToken cancellationToken = default)
public async Task<GameProcessStartInfoProvider> GetGameProcessStartInfoProvider(RobustUrl address, ILoadingHandlerFactory loadingHandlerFactory, CancellationToken cancellationToken = default)
{
var buildInfo = await contentService.GetBuildInfo(address, cancellationToken);
var engine = await engineService.EnsureEngine(buildInfo.BuildInfo.Build.EngineVersion);
var engine = await engineService.EnsureEngine(buildInfo.BuildInfo.Build.EngineVersion, loadingHandlerFactory, cancellationToken);
if (engine is null)
throw new Exception("Engine version not found: " + buildInfo.BuildInfo.Build.EngineVersion);
await contentService.EnsureItems(buildInfo.RobustManifestInfo, loadingHandler, cancellationToken);
await engineService.EnsureEngineModules("Robust.Client.WebView", buildInfo.BuildInfo.Build.EngineVersion);
var hashApi = await contentService.EnsureItems(buildInfo, loadingHandlerFactory, cancellationToken);
if (hashApi.TryOpen("manifest.yml", out var stream))
{
var modules = ContentManifestParser.ExtractModules(stream);
var gameInfo =
foreach (var moduleStr in modules)
{
var module = await engineService.EnsureEngineModules(moduleStr, loadingHandlerFactory, buildInfo.BuildInfo.Build.EngineVersion);
if(module is null)
throw new Exception("Module not found: " + moduleStr);
}
await stream.DisposeAsync();
}
return
provider.GetService<GameProcessStartInfoProvider>()!.WithBuildInfo(buildInfo.BuildInfo.Auth.PublicKey,
address);
var gameProcessRunHandler = new ProcessRunHandler<GameProcessStartInfoProvider>(gameInfo);
return gameProcessRunHandler;
}
}

View File

@@ -1,9 +1,10 @@
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace Nebula.Launcher.ProcessHelper;
public interface IProcessStartInfoProvider
{
public Task<ProcessStartInfo> GetProcessStartInfo();
public Task<ProcessStartInfo> GetProcessStartInfo(CancellationToken cancellationToken = default);
}

View File

@@ -1,55 +1,47 @@
using System;
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
namespace Nebula.Launcher.ProcessHelper;
public class ProcessRunHandler<T> : IProcessConsumerCollection, IDisposable where T: IProcessStartInfoProvider
public class ProcessRunHandler : IDisposable
{
private ProcessStartInfo? _processInfo;
private Task<ProcessStartInfo>? _processInfoTask;
private Process? _process;
private ProcessLogConsumerCollection _consumerCollection = new();
private readonly IProcessLogConsumer _logConsumer;
private string _lastError = string.Empty;
private readonly T _currentProcessStartInfoProvider;
private StringBuilder _lastErrorBuilder = new StringBuilder();
public T GetCurrentProcessStartInfo() => _currentProcessStartInfoProvider;
public bool IsRunning => _processInfo is not null;
public Action<ProcessRunHandler<T>>? OnProcessExited;
public bool IsRunning => _process is not null;
public Action<ProcessRunHandler>? OnProcessExited;
public void RegisterLogger(IProcessLogConsumer consumer)
{
_consumerCollection.RegisterLogger(consumer);
}
public AsyncValueCache<ProcessStartInfo> ProcessStartInfoProvider { get; }
public ProcessRunHandler(T processStartInfoProvider)
public bool Disposed { get; private set; }
public ProcessRunHandler(IProcessStartInfoProvider processStartInfoProvider, IProcessLogConsumer logConsumer)
{
_currentProcessStartInfoProvider = processStartInfoProvider;
_processInfoTask = _currentProcessStartInfoProvider.GetProcessStartInfo();
_processInfoTask.GetAwaiter().OnCompleted(OnInfoProvided);
_logConsumer = logConsumer;
ProcessStartInfoProvider = new AsyncValueCache<ProcessStartInfo>(processStartInfoProvider.GetProcessStartInfo);
}
private void OnInfoProvided()
private void CheckIfDisposed()
{
if (_processInfoTask == null)
return;
_processInfo = _processInfoTask.GetAwaiter().GetResult();
_processInfoTask = null;
if (!Disposed) return;
throw new ObjectDisposedException(nameof(ProcessRunHandler));
}
public void Start()
{
if (_processInfoTask != null)
{
_processInfoTask.Wait();
}
CheckIfDisposed();
if(_process is not null)
throw new InvalidOperationException("Already running");
_process = Process.Start(_processInfo!);
_process = Process.Start(ProcessStartInfoProvider.GetValue());
if (_process is null) return;
@@ -66,7 +58,8 @@ public class ProcessRunHandler<T> : IProcessConsumerCollection, IDisposable wher
public void Stop()
{
_process?.CloseMainWindow();
CheckIfDisposed();
Dispose();
}
private void OnExited(object? sender, EventArgs e)
@@ -77,37 +70,48 @@ public class ProcessRunHandler<T> : IProcessConsumerCollection, IDisposable wher
_process.ErrorDataReceived -= OnErrorDataReceived;
_process.Exited -= OnExited;
if (_process.ExitCode != 0)
_consumerCollection.Fatal(_lastError);
_logConsumer.Fatal(_lastErrorBuilder.ToString());
_process.Dispose();
_process = null;
OnProcessExited?.Invoke(this);
Dispose();
}
private void OnErrorDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data != null)
{
_lastError = e.Data;
_consumerCollection.Error(e.Data);
}
if (e.Data == null) return;
if (!e.Data.StartsWith(" "))
_lastErrorBuilder.Clear();
_lastErrorBuilder.AppendLine(e.Data);
_logConsumer.Error(e.Data);
}
private void OnOutputDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data != null)
{
_consumerCollection.Out(e.Data);
_logConsumer.Out(e.Data);
}
}
public void Dispose()
{
_processInfoTask?.Dispose();
_process?.Dispose();
if (_process is not null)
{
_process.CloseMainWindow();
return;
}
ProcessStartInfoProvider.Invalidate();
CheckIfDisposed();
Disposed = true;
}
}
@@ -134,4 +138,76 @@ public sealed class DebugLoggerBridge : IProcessLogConsumer
{
_logger.Log(LoggerCategory.Error, text);
}
}
public class AsyncValueCache<T>
{
private readonly Func<CancellationToken, Task<T>> _valueFactory;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly CancellationTokenSource _cacheCts = new();
private Lazy<Task<T>> _lazyTask = null!;
private T _cachedValue = default!;
private bool _isCacheValid;
public AsyncValueCache(Func<CancellationToken, Task<T>> valueFactory)
{
_valueFactory = valueFactory ?? throw new ArgumentNullException(nameof(valueFactory));
ResetLazyTask();
}
public T GetValue()
{
if (_isCacheValid) return _cachedValue;
try
{
_semaphore.Wait();
if (_isCacheValid) return _cachedValue;
_cachedValue = _lazyTask.Value
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
_isCacheValid = true;
return _cachedValue;
}
finally
{
_semaphore.Release();
}
}
public void Invalidate()
{
using var cts = new CancellationTokenSource();
try
{
_semaphore.Wait();
_isCacheValid = false;
_cacheCts.Cancel();
_cacheCts.Dispose();
ResetLazyTask();
}
finally
{
_semaphore.Release();
}
}
private void ResetLazyTask()
{
_lazyTask = new Lazy<Task<T>>(() =>
_valueFactory(_cacheCts.Token)
.ContinueWith(t =>
{
if (t.IsCanceled || t.IsFaulted)
{
_isCacheValid = false;
throw t.Exception ?? new Exception();
}
return t.Result;
}, TaskContinuationOptions.ExecuteSynchronously));
}
}

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
@@ -17,49 +19,27 @@ using Nebula.Shared.Utils;
namespace Nebula.Launcher.ServerListProviders;
[ServiceRegister, ConstructGenerator]
public sealed partial class FavoriteServerListProvider : IServerListProvider, IServerListDirtyInvoker
public sealed partial class FavoriteServerListProvider : IServerListProvider
{
[GenerateProperty] private ConfigurationService ConfigurationService { get; }
[GenerateProperty] private IServiceProvider ServiceProvider { get; }
[GenerateProperty] private ServerViewContainer ServerViewContainer { get; }
private List<IListEntryModelView> _serverLists = [];
private string[] rawServerLists = [];
public Action? OnRefreshRequired;
public bool IsLoaded { get; private set; }
public Action? OnLoaded { get; set; }
public Action? Dirty { get; set; }
public IEnumerable<IListEntryModelView> GetServers()
{
return _serverLists;
}
public IEnumerable<Exception> GetErrors()
{
return [];
}
public void LoadServerList()
{
IsLoaded = false;
_serverLists.Clear();
var servers = GetFavoriteEntries();
var serverEntries = servers.Select(s =>
ServerViewContainer.Get(s.ToRobustUrl())
);
_serverLists.AddRange(serverEntries);
_serverLists.Add(new AddFavoriteButton(ServiceProvider));
IsLoaded = true;
OnLoaded?.Invoke();
}
private string[] _rawServerLists = [];
public void AddFavorite(ServerEntryModelView entryModelView)
public void LoadServerList(
AvaloniaList<IListEntryModelView> servers,
AvaloniaList<Exception> exceptions)
{
AddFavorite(entryModelView.Address);
foreach (var server in _rawServerLists)
{
var container = ServerViewContainer.Get(server);
servers.Add(container);
}
servers.Add(new AddFavoriteButton(ServiceProvider));
}
public void AddFavorite(RobustUrl robustUrl)
@@ -70,10 +50,10 @@ public sealed partial class FavoriteServerListProvider : IServerListProvider, IS
if(ServerViewContainer.Get(robustUrl) is IFavoriteEntryModelView favoriteView) favoriteView.IsFavorite = true;
}
public void RemoveFavorite(ServerEntryModelView entryModelView)
public void RemoveFavorite(ServerEntryViewModel entryViewModel)
{
var servers = GetFavoriteEntries();
servers.Remove(entryModelView.Address.ToString());
servers.Remove(entryViewModel.Address.ToString());
ConfigurationService.SetConfigValue(LauncherConVar.Favorites, servers.ToArray());
}
@@ -86,7 +66,7 @@ public sealed partial class FavoriteServerListProvider : IServerListProvider, IS
private List<string> GetFavoriteEntries()
{
return rawServerLists.ToList();
return _rawServerLists.ToList();
}
private void Initialise()
@@ -98,21 +78,20 @@ public sealed partial class FavoriteServerListProvider : IServerListProvider, IS
{
if (value == null)
{
rawServerLists = [];
Dirty?.Invoke();
_rawServerLists = [];
return;
}
rawServerLists = value;
Dirty?.Invoke();
_rawServerLists = value;
OnRefreshRequired?.Invoke();
}
private void InitialiseInDesignMode(){}
}
public class AddFavoriteButton: Border, IListEntryModelView{
public sealed class AddFavoriteButton: Border, IListEntryModelView{
private Button _addFavoriteButton = new Button();
private readonly Button _addFavoriteButton = new();
public AddFavoriteButton(IServiceProvider serviceProvider)
{
Margin = new Thickness(5, 5, 5, 20);
@@ -127,5 +106,5 @@ public class AddFavoriteButton: Border, IListEntryModelView{
_addFavoriteButton.Content = "Add Favorite";
Child = _addFavoriteButton;
}
public bool IsFavorite { get; set; }
public void Dispose(){}
}

View File

@@ -1,85 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Nebula.Launcher.ViewModels;
using System.Threading.Tasks;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Threading;
using Nebula.Launcher.Services;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Shared;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Utils;
namespace Nebula.Launcher.ServerListProviders;
[ServiceRegister(null, false), ConstructGenerator]
public sealed partial class HubServerListProvider : IServerListProvider
public sealed partial class HubServerListProvider : IServerListProvider, IDisposable
{
private CancellationTokenSource? _cts;
private readonly SemaphoreSlim _loadLock = new(1, 1);
[GenerateProperty] private RestService RestService { get; }
[GenerateProperty] private ServerViewContainer ServerViewContainer { get; }
public string HubUrl { get; set; }
public bool IsLoaded { get; private set; }
public Action? OnLoaded { get; set; }
private CancellationTokenSource? _cts;
private readonly List<IListEntryModelView> _servers = [];
private readonly List<Exception> _errors = [];
private string _hubUrl;
public HubServerListProvider With(string hubUrl)
{
HubUrl = hubUrl;
_hubUrl = hubUrl;
return this;
}
public IEnumerable<IListEntryModelView> GetServers()
public void LoadServerList(
AvaloniaList<IListEntryModelView> servers,
AvaloniaList<Exception> exceptions)
{
return _servers;
servers.Add(new LoadingServerEntry());
Task.Run(() => LoadServerListAsync(servers, exceptions));
}
public IEnumerable<Exception> GetErrors()
private void SyncServers(List<IListEntryModelView> servers,
AvaloniaList<IListEntryModelView> collection)
{
return _errors;
collection.Clear();
collection.AddRange(servers);
}
public async void LoadServerList()
private async Task LoadServerListAsync(
AvaloniaList<IListEntryModelView> servers,
AvaloniaList<Exception> exceptions)
{
if (_cts != null)
{
await _cts.CancelAsync();
_cts = null;
}
CancellationTokenSource localCts;
_servers.Clear();
_errors.Clear();
IsLoaded = false;
_cts = new CancellationTokenSource();
await _loadLock.WaitAsync();
try
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
localCts = _cts;
}
finally
{
_loadLock.Release();
}
try
{
var servers =
await RestService.GetAsync<List<ServerHubInfo>>(new Uri(HubUrl), _cts.Token);
servers.Sort(new ServerComparer());
if(_cts.Token.IsCancellationRequested) return;
_servers.AddRange(
servers.Select(h=>
ServerViewContainer.Get(h.Address.ToRobustUrl(), h.StatusData)
)
var serversRaw = await RestService.GetAsync<List<ServerHubInfo>>(
new Uri(_hubUrl),
localCts.Token
);
serversRaw.Sort(new ServerComparer());
localCts.Token.ThrowIfCancellationRequested();
Dispatcher.UIThread.Invoke(() =>
{
var serverList = new List<IListEntryModelView>();
foreach (var info in serversRaw)
{
serverList.Add(ServerViewContainer.Get(info.Address, info.StatusData));
}
SyncServers(serverList, servers);
});
}
catch (OperationCanceledException)
{
// Ignore cancel think
}
catch (Exception e)
{
_errors.Add(new Exception($"Some error while loading server list from {HubUrl}. See inner exception", e));
Console.WriteLine(e);
exceptions.Add(
new Exception(
$"Some error while loading server list from {_hubUrl}. See inner exception",
e
)
);
}
IsLoaded = true;
OnLoaded?.Invoke();
}
private void Initialise(){}
private void InitialiseInDesignMode(){}
public void Dispose()
{
_cts?.Dispose();
}
}
public sealed class LoadingServerEntry : Label, IListEntryModelView
{
public LoadingServerEntry()
{
HorizontalAlignment = HorizontalAlignment.Center;
Content = LocalizationService.GetString("server-list-loading");
}
public void Dispose()
{}
}

View File

@@ -1,22 +1,12 @@
using System;
using System.Collections.Generic;
using Nebula.Launcher.ViewModels;
using Avalonia.Collections;
using Nebula.Launcher.ViewModels.Pages;
namespace Nebula.Launcher.ServerListProviders;
public interface IServerListProvider
{
public bool IsLoaded { get; }
public Action? OnLoaded { get; set; }
public IEnumerable<IListEntryModelView> GetServers();
public IEnumerable<Exception> GetErrors();
public void LoadServerList();
}
public interface IServerListDirtyInvoker
{
public Action? Dirty { get; set; }
public void LoadServerList(
AvaloniaList<IListEntryModelView> servers,
AvaloniaList<Exception> exceptions);
}

View File

@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using Nebula.Launcher.Controls;
using System.Collections.ObjectModel;
using Avalonia.Collections;
using Nebula.Launcher.ViewModels;
using Nebula.Launcher.ViewModels.Pages;
@@ -8,20 +8,14 @@ namespace Nebula.Launcher.ServerListProviders;
public sealed class TestServerList : IServerListProvider
{
public bool IsLoaded => true;
public Action? OnLoaded { get; set; }
public IEnumerable<IListEntryModelView> GetServers()
{
return [new ServerEntryModelView(),new ServerEntryModelView()];
}
public IEnumerable<Exception> GetErrors()
{
return [new Exception("On no!")];
}
public void LoadServerList()
public void LoadServerList(
AvaloniaList<IListEntryModelView> servers,
AvaloniaList<Exception> exceptions)
{
//servers.Add(new ServerEntryViewModel());
//servers.Add(new ServerEntryViewModel());
exceptions.Add(new Exception("Oh no!"));
}
}

View File

@@ -4,8 +4,10 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.IO.Pipelines;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Shared;
@@ -14,6 +16,8 @@ using Nebula.Shared.FileApis.Interfaces;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
using Nebula.Shared.Utils;
using Nebula.SharedModels;
namespace Nebula.Launcher.Services;
@@ -25,35 +29,32 @@ public sealed partial class DecompilerService
[GenerateProperty] private ViewHelperService ViewHelperService {get;}
[GenerateProperty] private ContentService ContentService {get;}
[GenerateProperty] private FileService FileService {get;}
[GenerateProperty] private CancellationService CancellationService {get;}
[GenerateProperty] private EngineService EngineService {get;}
[GenerateProperty] private DebugService DebugService {get;}
private HttpClient _httpClient = new HttpClient();
private readonly HttpClient _httpClient = new();
private ILogger _logger;
private static string fullPath = Path.Join(FileService.RootPath,"ILSpy");
private static string executePath = Path.Join(fullPath, "ILSpy.exe");
private string FullPath => Path.Join(AppDataPath.RootPath, $"ILSpy.{ConfigurationService.GetConfigValue(LauncherConVar.ILSpyVersion)}");
private string ExecutePath => Path.Join(FullPath, "ILSpy.exe");
public async void OpenDecompiler(string arguments){
await EnsureILSpy();
var startInfo = new ProcessStartInfo(){
FileName = executePath,
FileName = ExecutePath,
Arguments = arguments
};
Process.Start(startInfo);
}
public async void OpenServerDecompiler(RobustUrl url)
public async void OpenServerDecompiler(RobustUrl url, CancellationToken cancellationToken)
{
var myTempDir = FileService.EnsureTempDir(out var tmpDir);
ILoadingHandler loadingHandler = ViewHelperService.GetViewModel<LoadingContextViewModel>();
using var loadingHandler = ViewHelperService.GetViewModel<LoadingContextViewModel>();
var buildInfo =
await ContentService.GetBuildInfo(url, CancellationService.Token);
var engine = await EngineService.EnsureEngine(buildInfo.BuildInfo.Build.EngineVersion);
await ContentService.GetBuildInfo(url, cancellationToken);
var engine = await EngineService.EnsureEngine(buildInfo.BuildInfo.Build.EngineVersion, loadingHandler, cancellationToken);
if (engine is null)
throw new Exception("Engine version not found: " + buildInfo.BuildInfo.Build.EngineVersion);
@@ -63,18 +64,16 @@ public sealed partial class DecompilerService
myTempDir.Save(file, stream);
await stream.DisposeAsync();
}
var hashApi = await ContentService.EnsureItems(buildInfo.RobustManifestInfo, loadingHandler, CancellationService.Token);
foreach (var (file, hash) in hashApi.Manifest)
var hashApi = await ContentService.EnsureItems(buildInfo, loadingHandler, cancellationToken);
foreach (var file in hashApi.AllFiles)
{
if(!file.Contains(".dll") || !hashApi.TryOpen(hash, out var stream)) continue;
if(!file.Contains(".dll") || !hashApi.TryOpen(file, out var stream)) continue;
myTempDir.Save(Path.GetFileName(file), stream);
await stream.DisposeAsync();
}
((IDisposable)loadingHandler).Dispose();
_logger.Log("File extracted. " + tmpDir);
OpenDecompiler(string.Join(' ', myTempDir.AllFiles.Select(f=>Path.Join(tmpDir, f))) + " --newinstance");
@@ -87,18 +86,25 @@ public sealed partial class DecompilerService
private void InitialiseInDesignMode(){}
private async Task EnsureILSpy(){
if(!Directory.Exists(fullPath))
if(!Directory.Exists(FullPath))
await Download();
}
private async Task Download(){
using var loading = ViewHelperService.GetViewModel<LoadingContextViewModel>();
loading.LoadingName = "Download ILSpy";
loading.SetJobsCount(1);
var context = loading.CreateLoadingContext();
PopupMessageService.Popup(loading);
using var response = await _httpClient.GetAsync(ConfigurationService.GetConfigValue(LauncherConVar.ILSpyUrl));
using var zipArchive = new ZipArchive(await response.Content.ReadAsStreamAsync());
Directory.CreateDirectory(fullPath);
zipArchive.ExtractToDirectory(fullPath);
Console.WriteLine(response.StatusCode);
context.SetJobsCount(response.Content.Headers.ContentLength ?? 1000);
using var stream = await response.Content.ReadAsStreamAsync();
using var memoryStream = new MemoryStream();
stream.CopyTo(memoryStream, context);
using var zipArchive = new ZipArchive(memoryStream);
Directory.CreateDirectory(FullPath);
zipArchive.ExtractToDirectory(FullPath);
}
}

View File

@@ -0,0 +1,162 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Nebula.Launcher.Models;
using Nebula.Launcher.ProcessHelper;
using Nebula.Launcher.ServerListProviders;
using Nebula.Launcher.ViewModels;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Shared;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
namespace Nebula.Launcher.Services;
[ServiceRegister]
public class GameRunnerService
{
private readonly PopupMessageService _popupMessageService;
private readonly ViewHelperService _viewHelperService;
private readonly GameRunnerPreparer _gameRunnerPreparer;
private readonly InstanceRunningContainer _instanceRunningContainer;
private readonly AccountInfoViewModel _accountInfoViewModel;
private readonly ServerViewContainer _container;
private readonly MainViewModel _mainViewModel;
private readonly FavoriteServerListProvider _favoriteServerListProvider;
private readonly RestService _restService;
private readonly CancellationService _cancellationService;
private readonly ILogger _logger;
private readonly Dictionary<InstanceKey, RobustUrl> _robustUrls = new();
private readonly Dictionary<RobustUrl, InstanceKey> _robustKeys = new();
public GameRunnerService(PopupMessageService popupMessageService,
DebugService debugService,
ViewHelperService viewHelperService,
GameRunnerPreparer gameRunnerPreparer,
InstanceRunningContainer instanceRunningContainer,
AccountInfoViewModel accountInfoViewModel,
ServerViewContainer container,
MainViewModel mainViewModel,
FavoriteServerListProvider favoriteServerListProvider,
RestService restService,
CancellationService cancellationService)
{
_popupMessageService = popupMessageService;
_viewHelperService = viewHelperService;
_gameRunnerPreparer = gameRunnerPreparer;
_instanceRunningContainer = instanceRunningContainer;
_accountInfoViewModel = accountInfoViewModel;
_container = container;
_mainViewModel = mainViewModel;
_favoriteServerListProvider = favoriteServerListProvider;
_restService = restService;
_cancellationService = cancellationService;
_logger = debugService.GetLogger("GameRunnerService");
_instanceRunningContainer.IsRunningChanged += IsRunningChanged;
}
private void IsRunningChanged(InstanceKey key, bool isRunning)
{
_logger.Debug($"IsRunningChanged {key}: {isRunning}");
if (!_robustUrls.TryGetValue(key, out var robustUrl)) return;
if (_container.Get(robustUrl) is IRunningSignalConsumer signalConsumer)
{
_logger.Debug($"IsRunningChanged conf {robustUrl}: {isRunning}");
signalConsumer.ProcessRunningSignal(isRunning);
}
if (!isRunning)
{
_robustKeys.Remove(robustUrl);
_robustUrls.Remove(key);
}
}
public void StopInstance(RobustUrl robustUrl)
{
if (_robustKeys.TryGetValue(robustUrl, out var instanceKey))
{
_instanceRunningContainer.Stop(instanceKey);
}
}
public void ReadInstanceLog(RobustUrl robustUrl)
{
if (_robustKeys.TryGetValue(robustUrl, out var instanceKey))
{
_instanceRunningContainer.Popup(instanceKey);
}
}
public void OpenContentViewer(RobustUrl robustUrl)
{
_mainViewModel.RequirePage<ContentBrowserViewModel>().Go(robustUrl, ContentPath.Empty);
}
public void AddFavorite(RobustUrl robustUrl)
{
_favoriteServerListProvider.AddFavorite(robustUrl);
}
public void RemoveFavorite(RobustUrl robustUrl)
{
_favoriteServerListProvider.RemoveFavorite(robustUrl);
}
public void EditName(RobustUrl robustUrl, string? oldName)
{
var popup = _viewHelperService.GetViewModel<EditServerNameViewModel>();
popup.IpInput = robustUrl.ToString();
popup.NameInput = oldName ?? string.Empty;
_popupMessageService.Popup(popup);
}
public async Task<InstanceKey?> RunInstanceAsync(ServerEntryViewModel serverEntryViewModel, CancellationToken cancellationToken, bool ignoreLoginCredentials = false)
{
_logger.Log("Running instance..." + serverEntryViewModel.RealName);
if (!ignoreLoginCredentials && _accountInfoViewModel.Credentials.Value is null)
{
var warningContext = _viewHelperService.GetViewModel<IsLoginCredentialsNullPopupViewModel>()
.WithServerEntry(serverEntryViewModel);
_popupMessageService.Popup(warningContext);
return null;
}
try
{
using var viewModelLoading = _viewHelperService.GetViewModel<LoadingContextViewModel>();
viewModelLoading.LoadingName = "Loading instance...";
_popupMessageService.Popup(viewModelLoading);
var currProcessStartProvider =
await _gameRunnerPreparer.GetGameProcessStartInfoProvider(serverEntryViewModel.Address, viewModelLoading, cancellationToken);
_logger.Log("Preparing instance...");
var instanceKey = _instanceRunningContainer.RegisterInstance(currProcessStartProvider);
_robustUrls.Add(instanceKey, serverEntryViewModel.Address);
_robustKeys.Add(serverEntryViewModel.Address, instanceKey);
_instanceRunningContainer.Run(instanceKey);
_logger.Log($"Starting instance... {instanceKey.Id} " + serverEntryViewModel.RealName);
return instanceKey;
}
catch (Exception e)
{
var error = new Exception("Error while attempt run instance", e);
_logger.Error(error);
_popupMessageService.Popup(error);
return null;
}
}
public ServerEntryViewModel GetServerEntry(RobustUrl url, string customName, ServerStatus serverStatus)
{
return new ServerEntryViewModel(_restService, _cancellationService, this)
.WithData(url, customName, serverStatus);
}
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Nebula.Launcher.Models;
using Nebula.Launcher.ProcessHelper;
using Nebula.Launcher.ViewModels;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Shared;
using Nebula.Shared.Services;
namespace Nebula.Launcher.Services;
[ServiceRegister]
public sealed class InstanceRunningContainer(
PopupMessageService popupMessageService,
DebugService debugService
)
{
private readonly InstanceKeyPool _keyPool = new();
private readonly Dictionary<InstanceKey, ProcessRunHandler> _processCache = new();
private readonly Dictionary<InstanceKey, ContentLogConsumer> _contentLoggerCache = new();
private readonly Dictionary<ProcessRunHandler, InstanceKey> _keyCache = new();
public Action<InstanceKey, bool>? IsRunningChanged;
public InstanceKey RegisterInstance(IProcessStartInfoProvider provider)
{
var id = _keyPool.Take();
var currentContentLogConsumer = new ContentLogConsumer(popupMessageService);
var logBridge = new DebugLoggerBridge(debugService.GetLogger("PROCESS_"+id.Id));
var logContainer = new ProcessLogConsumerCollection();
logContainer.RegisterLogger(currentContentLogConsumer);
logContainer.RegisterLogger(logBridge);
var handler = new ProcessRunHandler(provider, logContainer);
handler.OnProcessExited += OnProcessExited;
_processCache[id] = handler;
_contentLoggerCache[id] = currentContentLogConsumer;
_keyCache[handler] = id;
return id;
}
public void Popup(InstanceKey instanceKey)
{
if(!_contentLoggerCache.TryGetValue(instanceKey, out var handler))
return;
handler.Popup();
}
public void Run(InstanceKey instanceKey)
{
if(!_processCache.TryGetValue(instanceKey, out var process))
return;
process.Start();
IsRunningChanged?.Invoke(instanceKey, true);
}
public void Stop(InstanceKey instanceKey)
{
if(!_processCache.TryGetValue(instanceKey, out var process))
return;
process.Stop();
}
public bool IsRunning(InstanceKey instanceKey)
{
return _processCache.ContainsKey(instanceKey);
}
private void RemoveProcess(ProcessRunHandler handler)
{
if(handler.Disposed) return;
var key = _keyCache[handler];
IsRunningChanged?.Invoke(key, false);
_processCache.Remove(key);
_keyCache.Remove(handler);
_contentLoggerCache.Remove(key);
}
private void OnProcessExited(ProcessRunHandler obj)
{
obj.OnProcessExited -= OnProcessExited;
RemoveProcess(obj);
}
}

View File

@@ -11,10 +11,11 @@ using Nebula.Shared.Services;
namespace Nebula.Launcher.Services;
[ConstructGenerator, ServiceRegister]
public partial class LocalisationService
public sealed partial class LocalizationService
{
[GenerateProperty] private ConfigurationService ConfigurationService { get; }
[GenerateProperty] private DebugService DebugService { get; }
private CultureInfo _currentCultureInfo = CultureInfo.CurrentCulture;
private static MessageContext? _currentMessageContext;
@@ -39,9 +40,9 @@ public partial class LocalisationService
Console.WriteLine(error);
}
_currentMessageContext = mc;
} catch (Exception e) {
DebugService.GetLogger("localisationService").Error(e);
LoadLanguage(CultureInfo.GetCultureInfo("en-US"));
}
}
@@ -66,12 +67,12 @@ public partial class LocalisationService
public class LocaledText : MarkupExtension
{
public string Key { get; set; }
public Dictionary<string, object>? Options { get; set; }
public LocaledText(string key) => Key = key;
public override object ProvideValue(IServiceProvider serviceProvider)
{
// Fetch the localized string using the key
return LocalisationService.GetString(Key);
return LocalizationService.GetString(Key, Options);
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Avalonia.Media;
namespace Nebula.Launcher.Utils;
public static class ColorUtils
{
public static Color GetColorFromString(string input)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
var r = byte.Clamp(hash[0], 10, 200);
var g = byte.Clamp(hash[1], 10, 100);
var b = byte.Clamp(hash[2], 10, 100);
return Color.FromArgb(Byte.MaxValue, r, g, b);
}
}

View File

@@ -1,12 +1,11 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Nebula.Shared;
namespace Nebula.Launcher.Services;
namespace Nebula.Launcher.Utils;
public static class ExplorerHelper
public static class ExplorerUtils
{
public static void OpenFolder(string path)
{

View File

@@ -0,0 +1,29 @@
using System;
using System.IO;
namespace Nebula.Launcher.Utils;
public static class VCRuntimeDllChecker
{
public static bool AreVCRuntimeDllsPresent()
{
if (!OperatingSystem.IsWindows()) return true;
string systemDir = Environment.SystemDirectory;
string[] requiredDlls = {
"msvcp140.dll",
"vcruntime140.dll"
};
foreach (var dll in requiredDlls)
{
var path = Path.Combine(systemDir, dll);
if (!File.Exists(path))
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,39 @@
using System;
using Nebula.Launcher.Views;
using Nebula.Shared.ViewHelper;
namespace Nebula.Launcher.ViewModels;
[ViewModelRegister(typeof(ExceptionView), false)]
public class ExceptionCompound : ViewModelBase
{
public ExceptionCompound()
{
Message = "Test exception";
StackTrace = "Stack trace";
}
public ExceptionCompound(string message, string stackTrace)
{
Message = message;
StackTrace = stackTrace;
}
public ExceptionCompound(Exception ex)
{
Message = ex.Message;
StackTrace = ex.StackTrace;
}
public string Message { get; set; }
public string? StackTrace { get; set; }
protected override void InitialiseInDesignMode()
{
}
protected override void Initialise()
{
}
}

View File

@@ -0,0 +1,13 @@
using System;
namespace Nebula.Launcher.ViewModels;
public record struct InstanceKey(int Id):
IEquatable<int>,
IComparable<InstanceKey>
{
public static implicit operator InstanceKey(int id) => new InstanceKey(id);
public static implicit operator int(InstanceKey id) => id.Id;
public bool Equals(int other) => Id == other;
public int CompareTo(InstanceKey other) => Id.CompareTo(other.Id);
};

View File

@@ -0,0 +1,16 @@
namespace Nebula.Launcher.ViewModels;
public sealed class InstanceKeyPool
{
private int _nextId = 1;
public InstanceKey Take()
{
return new InstanceKey(_nextId++);
}
public void Free(InstanceKey id)
{
// TODO: make some free logic later
}
}

View File

@@ -4,9 +4,9 @@ using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nebula.Launcher.Models;
using Nebula.Launcher.Services;
using Nebula.Launcher.Utils;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Launcher.Views;
@@ -14,6 +14,7 @@ using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
using Nebula.Shared.Utils;
using Nebula.Shared.ViewHelper;
using Nebula.SharedModels;
namespace Nebula.Launcher.ViewModels;
@@ -40,17 +41,9 @@ public partial class MainViewModel : ViewModelBase
[ObservableProperty] private bool _isPopupClosable = true;
[ObservableProperty] private bool _popup;
[ObservableProperty] private ListItemTemplate? _selectedListItem;
public bool IsLoggedIn => AccountInfoViewModel.Credentials is not null;
public string LoginName => AccountInfoViewModel.Credentials?.Login ?? string.Empty;
[ObservableProperty] private string? _loginText = LocalizationService.GetString("auth-current-login-no-name");
public string LoginText => LocalisationService.GetString("auth-current-login-name",
new Dictionary<string, object>
{
{ "login", LoginName }
});
[GenerateProperty] private LocalisationService LocalisationService { get; } // Не убирать! Без этой хуйни вся локализация идет в пизду!
[GenerateProperty] private LocalizationService LocalizationService { get; } // Не убирать! Без этой хуйни вся локализация идет в пизду!
[GenerateProperty] private AccountInfoViewModel AccountInfoViewModel { get; }
[GenerateProperty] private DebugService DebugService { get; } = default!;
[GenerateProperty] private PopupMessageService PopupMessageService { get; } = default!;
@@ -66,7 +59,7 @@ public partial class MainViewModel : ViewModelBase
{
Items = new ObservableCollection<ListItemTemplate>(_templates.Select(a=>
{
return a with { Label = LocalisationService.GetString(a.Label) };
return a with { Label = LocalizationService.GetString(a.Label) };
}
));
RequirePage<AccountInfoViewModel>();
@@ -74,15 +67,15 @@ public partial class MainViewModel : ViewModelBase
protected override void Initialise()
{
AccountInfoViewModel.PropertyChanged += (sender, args) =>
AccountInfoViewModel.Credentials.PropertyChanged += (_, args) =>
{
if (args.PropertyName != nameof(AccountInfoViewModel.Credentials))
if (args.PropertyName is not nameof(AccountInfoViewModel.Credentials.Value))
return;
OnPropertyChanged(nameof(LoginText));
OnPropertyChanged(nameof(IsLoggedIn));
UpdateCredentialsInfo();
};
UpdateCredentialsInfo();
_logger = DebugService.GetLogger(this);
using var stream = typeof(MainViewModel).Assembly
@@ -99,24 +92,45 @@ public partial class MainViewModel : ViewModelBase
CheckMigration();
var loadingHandler = ViewHelperService.GetViewModel<LoadingContextViewModel>();
loadingHandler.LoadingName = LocalisationService.GetString("migration-config-task");
loadingHandler.LoadingName = LocalizationService.GetString("migration-config-task");
loadingHandler.IsCancellable = false;
ConfigurationService.MigrateConfigs(loadingHandler);
if (!VCRuntimeDllChecker.AreVCRuntimeDllsPresent())
{
OnPopupRequired(LocalisationService.GetString("vcruntime-check-error"));
OnPopupRequired(LocalizationService.GetString("vcruntime-check-error"));
Helper.OpenBrowser("https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170");
}
}
private void UpdateCredentialsInfo()
{
if(AccountInfoViewModel.Credentials.HasValue)
{
LoginText =
LocalizationService.GetString("auth-current-login-name",
new Dictionary<string, object>
{
{ "login", AccountInfoViewModel.Credentials.Value?.Login ?? "" },
{
"auth_server",
AccountInfoViewModel.GetServerAuthName(AccountInfoViewModel.Credentials.Value?.AuthServer) ?? ""
}
});
}
else
{
LoginText = LocalizationService.GetString("auth-current-login-no-name");
}
}
private void CheckMigration()
{
if (!ConfigurationService.GetConfigValue(LauncherConVar.DoMigration))
return;
var loadingHandler = ViewHelperService.GetViewModel<LoadingContextViewModel>();
loadingHandler.LoadingName = LocalisationService.GetString("migration-label-task");
loadingHandler.LoadingName = LocalizationService.GetString("migration-label-task");
loadingHandler.IsCancellable = false;
if (!ContentService.CheckMigration(loadingHandler))
@@ -194,7 +208,7 @@ public partial class MainViewModel : ViewModelBase
public void OpenRootPath()
{
ExplorerHelper.OpenFolder(FileService.RootPath);
ExplorerUtils.OpenFolder(AppDataPath.RootPath);
}
public void OpenLink()
@@ -216,6 +230,11 @@ public partial class MainViewModel : ViewModelBase
case PopupViewModelBase @base:
PopupMessage(@base);
break;
case ExceptionCompound error:
var errViewModel = ViewHelperService.GetViewModel<ExceptionListViewModel>();
errViewModel.AppendError(error);
PopupMessage(errViewModel);
break;
case Exception error:
var err = ViewHelperService.GetViewModel<ExceptionListViewModel>();
_logger.Error(error);
@@ -234,16 +253,18 @@ public partial class MainViewModel : ViewModelBase
else
_viewQueue.Remove(viewModelBase);
}
[RelayCommand]
private void TriggerPane()
public void TriggerPane()
{
IsPaneOpen = !IsPaneOpen;
}
[RelayCommand]
public void ClosePopup()
public void CloseCurrentPopup()
{
CurrentPopup?.Dispose();
}
private void ClosePopup()
{
var viewModelBase = _viewQueue.FirstOrDefault();
if (viewModelBase is null)
@@ -258,29 +279,4 @@ public partial class MainViewModel : ViewModelBase
CurrentPopup = viewModelBase;
}
}
public static class VCRuntimeDllChecker
{
public static bool AreVCRuntimeDllsPresent()
{
if (!OperatingSystem.IsWindows()) return true;
string systemDir = Environment.SystemDirectory;
string[] requiredDlls = {
"msvcp140.dll",
"vcruntime140.dll"
};
foreach (var dll in requiredDlls)
{
var path = Path.Combine(systemDir, dll);
if (!File.Exists(path))
{
return false;
}
}
return true;
}
}

View File

@@ -5,11 +5,12 @@ using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Nebula.Launcher.Configurations;
using Nebula.Launcher.Models.Auth;
using Nebula.Launcher.Services;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Launcher.Views.Pages;
using Nebula.Shared.Configurations;
using Nebula.Shared.Models.Auth;
using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
@@ -23,82 +24,52 @@ namespace Nebula.Launcher.ViewModels.Pages;
public partial class AccountInfoViewModel : ViewModelBase
{
[ObservableProperty] private bool _authMenuExpand;
[ObservableProperty] private bool _authUrlConfigExpand;
[ObservableProperty] private int _authViewSpan = 1;
[ObservableProperty] private string _currentAuthServer = string.Empty;
[ObservableProperty] private string _currentLogin = string.Empty;
[ObservableProperty] private string _currentPassword = string.Empty;
[ObservableProperty] private bool _isLogged;
[ObservableProperty] private bool _doRetryAuth;
[ObservableProperty] private AuthTokenCredentials? _credentials;
[ObservableProperty] private AuthServerCredentials _authItemSelect;
[ObservableProperty] private string _authServerName;
private bool _isProfilesEmpty;
[GenerateProperty] private PopupMessageService PopupMessageService { get; } = default!;
[GenerateProperty] private ConfigurationService ConfigurationService { get; } = default!;
[GenerateProperty] private PopupMessageService PopupMessageService { get; }
[GenerateProperty] private ConfigurationService ConfigurationService { get; }
[GenerateProperty] private DebugService DebugService { get; }
[GenerateProperty] private AuthService AuthService { get; } = default!;
[GenerateProperty, DesignConstruct] private ViewHelperService ViewHelperService { get; } = default!;
[GenerateProperty] private AuthService AuthService { get; }
[GenerateProperty, DesignConstruct] private ViewHelperService ViewHelperService { get; }
public ObservableCollection<ProfileAuthCredentials> Accounts { get; } = new();
public ObservableCollection<ProfileEntry> Accounts { get; } = new();
public ObservableCollection<AuthServerCredentials> AuthUrls { get; } = new();
[ObservableProperty] private AuthServerCredentials _authItemSelect;
public ComplexConVarBinder<AuthTokenCredentials?> Credentials { get; private set; }
private ILogger _logger;
//Design think
protected override void InitialiseInDesignMode()
{
AddAccount(new AuthTokenCredentials(Guid.Empty, LoginToken.Empty, "Binka", ""));
AddAccount(new AuthTokenCredentials(Guid.Empty, LoginToken.Empty, "Binka", ""));
AuthUrls.Add(new AuthServerCredentials("Test",["example.com","variant.lab"]));
AuthUrls.Add(new AuthServerCredentials("Test",["example.com"]));
AddAccount(new ProfileAuthCredentials("Binka", "","example.com"));
AddAccount(new ProfileAuthCredentials("Vilka","", "variant.lab"));
}
//Real think
protected override void Initialise()
{
_logger = DebugService.GetLogger(this);
Credentials = new AuthTokenCredentialsVar(this);
Task.Run(ReadAuthConfig);
Credentials.Value = Credentials.Value;
}
public async void AuthByProfile(ProfileAuthCredentials credentials)
{
var message = ViewHelperService.GetViewModel<InfoPopupViewModel>();
message.InfoText = LocalisationService.GetString("auth-try-auth-profile");
message.IsInfoClosable = false;
PopupMessageService.Popup(message);
try
{
await CatchAuthError(async () => await TryAuth(credentials.Credentials), () => message.Dispose());
}
catch (Exception ex)
{
CurrentLogin = credentials.Credentials.Login;
CurrentAuthServer = credentials.Credentials.AuthServer;
var unexpectedError = new Exception(LocalisationService.GetString("auth-error"), ex);
_logger.Error(unexpectedError);
PopupMessageService.Popup(unexpectedError);
return;
}
ConfigurationService.SetConfigValue(LauncherConVar.AuthCurrent, Credentials);
message.Dispose();
}
public void DoAuth(string? code = null)
{
var message = ViewHelperService.GetViewModel<InfoPopupViewModel>();
message.InfoText = LocalisationService.GetString("auth-processing");
message.InfoText = LocalizationService.GetString("auth-processing");
message.IsInfoClosable = false;
PopupMessageService.Popup(message);
@@ -112,54 +83,32 @@ public partial class AccountInfoViewModel : ViewModelBase
Task.Run(async () =>
{
Exception? exception = null;
foreach (var server in serverCandidates)
{
try
{
await CatchAuthError(async () => await TryAuth(CurrentLogin, CurrentPassword, server, code), ()=> message.Dispose());
await CatchAuthError(async() =>
{
Credentials.Value = await AuthService.Auth(CurrentLogin, CurrentPassword, server, code);
}, ()=> message.Dispose());
break;
}
catch (Exception ex)
{
var unexpectedError = new Exception(LocalisationService.GetString("auth-error"), ex);
_logger.Error(unexpectedError);
PopupMessageService.Popup(unexpectedError);
exception = new Exception(LocalizationService.GetString("auth-error"), ex);
}
}
message.Dispose();
if (!IsLogged)
if (exception != null)
{
PopupMessageService.Popup(exception ?? new Exception(LocalisationService.GetString("auth-error")));
PopupMessageService.Popup(new Exception("Error while auth", exception));
}
});
}
private async Task TryAuth(AuthTokenCredentials authTokenCredentials)
{
CurrentLogin = authTokenCredentials.Login;
CurrentAuthServer = authTokenCredentials.AuthServer;
await SetAuth(authTokenCredentials);
IsLogged = true;
}
private async Task SetAuth(AuthTokenCredentials authTokenCredentials)
{
await AuthService.EnsureToken(authTokenCredentials);
Credentials = authTokenCredentials;
}
private async Task TryAuth(string login, string password, string authServer, string? code)
{
Credentials = await AuthService.Auth(login, password, authServer, code);
CurrentLogin = login;
CurrentPassword = password;
CurrentAuthServer = authServer;
IsLogged = true;
ConfigurationService.SetConfigValue(LauncherConVar.AuthCurrent, Credentials);
}
private async Task CatchAuthError(Func<Task> a, Action? onError)
{
DoRetryAuth = false;
@@ -176,15 +125,24 @@ public partial class AccountInfoViewModel : ViewModelBase
case AuthenticateDenyCode.TfaRequired:
case AuthenticateDenyCode.TfaInvalid:
var p = ViewHelperService.GetViewModel<TfaViewModel>();
p.OnTfaEntered += OnTfaEntered;
PopupMessageService.Popup(p);
_logger.Log("TFA required");
break;
case AuthenticateDenyCode.InvalidCredentials:
PopupError(LocalisationService.GetString("auth-invalid-credentials"), e);
PopupError(LocalizationService.GetString("auth-invalid-credentials"), e);
break;
case AuthenticateDenyCode.AccountLocked:
PopupError(LocalizationService.GetString("auth-account-locked"), e);
break;
case AuthenticateDenyCode.AccountUnconfirmed:
PopupError(LocalizationService.GetString("auth-account-unconfirmed"), e);
break;
case AuthenticateDenyCode.None:
PopupError(LocalizationService.GetString("auth-none"),e);
break;
default:
throw;
PopupError(LocalizationService.GetString("auth-error-fuck"), e);
break;
}
}
catch (HttpRequestException e)
@@ -193,22 +151,46 @@ public partial class AccountInfoViewModel : ViewModelBase
switch (e.HttpRequestError)
{
case HttpRequestError.ConnectionError:
PopupError(LocalisationService.GetString("auth-connection-error"), e);
PopupError(LocalizationService.GetString("auth-connection-error"), e);
DoRetryAuth = true;
break;
case HttpRequestError.NameResolutionError:
PopupError(LocalisationService.GetString("auth-name-resolution-error"), e);
PopupError(LocalizationService.GetString("auth-name-resolution-error"), e);
DoRetryAuth = true;
break;
case HttpRequestError.SecureConnectionError:
PopupError(LocalisationService.GetString("auth-secure-error"), e);
PopupError(LocalizationService.GetString("auth-secure-error"), e);
DoRetryAuth = true;
break;
case HttpRequestError.UserAuthenticationError:
PopupError(LocalizationService.GetString("auth-user-authentication-error"), e);
break;
case HttpRequestError.Unknown:
PopupError(LocalizationService.GetString("auth-unknown"), e);
break;
case HttpRequestError.HttpProtocolError:
PopupError(LocalizationService.GetString("auth-http-protocol-error"), e);
break;
case HttpRequestError.ExtendedConnectNotSupported:
PopupError(LocalizationService.GetString("auth-extended-connect-not-support"), e);
break;
case HttpRequestError.VersionNegotiationError:
PopupError(LocalizationService.GetString("auth-version-negotiation-error"), e);
break;
case HttpRequestError.ProxyTunnelError:
PopupError(LocalizationService.GetString("auth-proxy-tunnel-error"), e);
break;
case HttpRequestError.InvalidResponse:
PopupError(LocalizationService.GetString("auth-invalid-response"), e);
break;
case HttpRequestError.ResponseEnded:
PopupError(LocalizationService.GetString("auth-response-ended"), e);
break;
case HttpRequestError.ConfigurationLimitExceeded:
PopupError(LocalizationService.GetString("auth-configuration-limit-exceeded"), e);
break;
default:
var authError = new Exception(LocalisationService.GetString("auth-error"), e);
var authError = new Exception(LocalizationService.GetString("auth-error"), e);
_logger.Error(authError);
PopupMessageService.Popup(authError);
break;
@@ -216,15 +198,16 @@ public partial class AccountInfoViewModel : ViewModelBase
}
}
private void OnTfaEntered(string code)
{
DoAuth(code);
}
public void Logout()
{
IsLogged = false;
Credentials = null;
Credentials.Value = null;
CurrentAuthServer = "";
}
public string GetServerAuthName(string? url)
{
if (url is null) return "";
return AuthUrls.FirstOrDefault(p => p.Servers.Contains(url))?.Name ?? "CustomAuth";
}
private void UpdateAuthMenu()
@@ -235,13 +218,22 @@ public partial class AccountInfoViewModel : ViewModelBase
AuthViewSpan = 1;
}
private void AddAccount(AuthTokenCredentials credentials)
private void AddAccount(ProfileAuthCredentials credentials)
{
var onDelete = new DelegateCommand<ProfileAuthCredentials>(OnDeleteProfile);
var onSelect = new DelegateCommand<ProfileAuthCredentials>(AuthByProfile);
var onDelete = new DelegateCommand<ProfileEntry>(OnDeleteProfile);
var onSelect = new DelegateCommand<ProfileEntry>((p) =>
{
CurrentLogin = p.Credentials.Login;
CurrentPassword = p.Credentials.Password;
CurrentAuthServer = p.Credentials.AuthServer;
DoAuth();
});
var serverName = GetServerAuthName(credentials.AuthServer);
var alpm = new ProfileAuthCredentials(
var alpm = new ProfileEntry(
credentials,
serverName,
onSelect,
onDelete);
@@ -251,66 +243,100 @@ public partial class AccountInfoViewModel : ViewModelBase
Accounts.Add(alpm);
}
private void ReadAuthConfig()
private async Task ReadAuthConfig()
{
var message = ViewHelperService.GetViewModel<InfoPopupViewModel>();
message.InfoText = LocalisationService.GetString("auth-config-read");
message.InfoText = LocalizationService.GetString("auth-config-read");
message.IsInfoClosable = false;
PopupMessageService.Popup(message);
foreach (var profile in
ConfigurationService.GetConfigValue(LauncherConVar.AuthProfiles)!)
AddAccount(profile);
if (Accounts.Count == 0) UpdateAuthMenu();
_logger.Log("Reading auth config");
AuthUrls.Clear();
var authUrls = ConfigurationService.GetConfigValue(LauncherConVar.AuthServers)!;
foreach (var url in authUrls) AuthUrls.Add(url);
if(authUrls.Length > 0) AuthItemSelect = authUrls[0];
message.Dispose();
var profileCandidates = new List<string>();
DoCurrentAuth();
}
public async void DoCurrentAuth()
{
var message = ViewHelperService.GetViewModel<InfoPopupViewModel>();
message.InfoText = LocalisationService.GetString("auth-try-auth-config");
message.IsInfoClosable = false;
PopupMessageService.Popup(message);
var currProfile = ConfigurationService.GetConfigValue(LauncherConVar.AuthCurrent);
if (currProfile != null)
foreach (var profileRaw in
ConfigurationService.GetConfigValue(LauncherConVar.AuthProfiles)!)
{
_logger.Log($"Decrypting profile...");
try
{
await CatchAuthError(async () => await TryAuth(currProfile), () => message.Dispose());
var decoded =
await CryptographicStore.Decrypt<ProfileAuthCredentials>(profileRaw,
CryptographicStore.GetComputerKey());
_logger.Log($"Decrypted profile: {decoded.Login}");
profileCandidates.Add(profileRaw);
AddAccount(decoded);
}
catch (Exception ex)
catch (Exception e)
{
var unexpectedError = new Exception(LocalisationService.GetString("auth-error"), ex);
_logger.Error(unexpectedError);
PopupMessageService.Popup(unexpectedError);
return;
_logger.Error("Error while decrypting profile");
_logger.Error(e);
}
}
ConfigurationService.SetConfigValue(LauncherConVar.AuthProfiles, profileCandidates.ToArray());
if (Accounts.Count == 0) UpdateAuthMenu();
message.Dispose();
}
[RelayCommand]
private void OnSaveProfile()
public void DoCurrentAuth()
{
if(Credentials is null) return;
DoAuth();
}
private async Task<AuthTokenCredentials?> CheckOrRenewToken(AuthTokenCredentials? authTokenCredentials)
{
if(authTokenCredentials is null)
return null;
var daysLeft = (int)(authTokenCredentials.Token.ExpireTime - DateTime.Now).TotalDays;
AddAccount(Credentials);
if(daysLeft >= 4)
{
_logger.Log("Token " + authTokenCredentials.Login + " is active, "+daysLeft+" days left, undo renewing!");
return authTokenCredentials;
}
try
{
_logger.Log($"Renewing token for {authTokenCredentials.Login}");
return await ExceptionHelper.TryRun(() => AuthService.Refresh(authTokenCredentials), 3,
(attempt, e) => { _logger.Error(new Exception("Error while renewing, attempts: " + attempt, e)); });
}
catch (AuthTokenExpiredException e)
{
_logger.Error(e);
return null;
}
catch (Exception e)
{
var unexpectedError = new Exception(LocalizationService.GetString("auth-error"), e);
_logger.Error(unexpectedError);
return authTokenCredentials;
}
}
public void OnSaveProfile()
{
if(Credentials.Value is null ||
string.IsNullOrEmpty(CurrentPassword)) return;
AddAccount(new ProfileAuthCredentials(CurrentLogin, CurrentPassword, Credentials.Value.AuthServer));
_isProfilesEmpty = Accounts.Count == 0;
UpdateAuthMenu();
DirtyProfile();
}
private void OnDeleteProfile(ProfileAuthCredentials account)
private void OnDeleteProfile(ProfileEntry account)
{
Accounts.Remove(account);
_isProfilesEmpty = Accounts.Count == 0;
@@ -320,7 +346,7 @@ public partial class AccountInfoViewModel : ViewModelBase
private void PopupError(string message, Exception e)
{
message = LocalisationService.GetString("auth-error-occured") + message;
message = LocalizationService.GetString("auth-error-occured") + message;
_logger.Error(new Exception(message, e));
var messageView = ViewHelperService.GetViewModel<InfoPopupViewModel>();
@@ -328,15 +354,13 @@ public partial class AccountInfoViewModel : ViewModelBase
messageView.IsInfoClosable = true;
PopupMessageService.Popup(messageView);
}
[RelayCommand]
private void OnExpandAuthUrl()
public void OnExpandAuthUrl()
{
AuthUrlConfigExpand = !AuthUrlConfigExpand;
}
[RelayCommand]
private void OnExpandAuthView()
public void OnExpandAuthView()
{
AuthMenuExpand = !AuthMenuExpand;
UpdateAuthMenu();
@@ -345,6 +369,79 @@ public partial class AccountInfoViewModel : ViewModelBase
private void DirtyProfile()
{
ConfigurationService.SetConfigValue(LauncherConVar.AuthProfiles,
Accounts.Select(a => a.Credentials).ToArray());
Accounts.Select(a => CryptographicStore.Encrypt(a.Credentials, CryptographicStore.GetComputerKey())).ToArray());
}
}
public sealed class AuthTokenCredentialsVar(AccountInfoViewModel accountInfoViewModel)
: ComplexConVarBinder<AuthTokenCredentials?>(
accountInfoViewModel.ConfigurationService.SubscribeVarChanged(LauncherConVar.AuthCurrent))
{
protected override async Task<AuthTokenCredentials?> OnValueChange(AuthTokenCredentials? currProfile)
{
if (currProfile is null)
{
accountInfoViewModel.IsLogged = false;
accountInfoViewModel._logger.Log("clearing credentials");
return null;
}
var message = accountInfoViewModel.ViewHelperService.GetViewModel<InfoPopupViewModel>();
message.InfoText = LocalizationService.GetString("auth-try-auth-config");
message.IsInfoClosable = false;
accountInfoViewModel.PopupMessageService.Popup(message);
accountInfoViewModel._logger.Log($"trying auth with {currProfile.Login}");
var errorRun = false;
currProfile = await accountInfoViewModel.CheckOrRenewToken(currProfile);
if (currProfile is null)
{
message.Dispose();
accountInfoViewModel._logger.Log("profile credentials update required!");
accountInfoViewModel.PopupMessageService.Popup("profile credentials update required!");
accountInfoViewModel.IsLogged = false;
return null;
}
try
{
await accountInfoViewModel.CatchAuthError(async () =>
{
await accountInfoViewModel.AuthService.EnsureToken(currProfile);
}, () =>
{
message.Dispose();
errorRun = true;
});
message.Dispose();
}
catch (Exception ex)
{
accountInfoViewModel.CurrentLogin = currProfile.Login;
accountInfoViewModel.CurrentAuthServer = currProfile.AuthServer;
var unexpectedError = new Exception(LocalizationService.GetString("auth-error"), ex);
accountInfoViewModel._logger.Error(unexpectedError);
accountInfoViewModel.PopupMessageService.Popup(unexpectedError);
errorRun = true;
}
if (errorRun)
{
accountInfoViewModel.IsLogged = false;
return null;
}
accountInfoViewModel.IsLogged = true;
accountInfoViewModel.AuthServerName = accountInfoViewModel.GetServerAuthName(currProfile.AuthServer);
return currProfile;
}
}
}

View File

@@ -1,23 +1,18 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Nebula.Launcher.Services;
using Nebula.Launcher.Utils;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Launcher.Views.Pages;
using Nebula.Shared;
using Nebula.Shared.Configurations;
using Nebula.Shared.Services;
using Nebula.Shared.ViewHelper;
using Nebula.SharedModels;
namespace Nebula.Launcher.ViewModels.Pages;
@@ -76,17 +71,17 @@ public partial class ConfigurationViewModel : ViewModelBase
public void OpenDataFolder()
{
ExplorerHelper.OpenFolder(FileService.RootPath);
ExplorerUtils.OpenFolder(AppDataPath.RootPath);
}
public void ExportLogs()
{
var logPath = Path.Join(FileService.RootPath, "log");
var logPath = Path.Join(AppDataPath.RootPath, "log");
var path = Path.Combine(Path.GetTempPath(), "tempThink"+Path.GetRandomFileName());
Directory.CreateDirectory(path);
ZipFile.CreateFromDirectory(logPath, Path.Join(path, DateTime.Now.ToString("yyyy-MM-dd") + ".zip"));
ExplorerHelper.OpenFolder(path);
ExplorerUtils.OpenFolder(path);
}
public void RemoveAllContent()
@@ -96,7 +91,7 @@ public partial class ConfigurationViewModel : ViewModelBase
using var loader = ViewHelperService.GetViewModel<LoadingContextViewModel>();
loader.LoadingName = "Removing content";
PopupService.Popup(loader);
ContentService.RemoveAllContent(loader, CancellationService.Token);
ContentService.RemoveAllContent(loader.CreateLoadingContext(), CancellationService.Token);
});
}
@@ -119,288 +114,4 @@ public partial class ConfigurationViewModel : ViewModelBase
{
InitConfiguration();
}
}
public static class ConfigControlHelper{
public static IConfigControl GetConfigControl(string name,object value)
{
switch (value)
{
case string stringValue:
return new StringUnitConfigControl(name, stringValue);
case int intValue:
return new IntUnitConfigControl(name, intValue);
case float floatValue:
return new FloatUnitConfigControl(name, floatValue);
}
var valueType = value.GetType();
if (valueType.IsArray)
return new ArrayUnitConfigControl(name, value);
return new ComplexUnitConfigControl(name, value);
}
public static object? CreateDefaultValue(Type type)
{
if(type.IsValueType)
return Activator.CreateInstance(type);
var ctor = type.GetConstructors().First();
var parameters = ctor.GetParameters()
.Select(p => CreateDefaultValue(p.ParameterType))
.ToArray();
return ctor.Invoke(parameters);
}
}
public sealed class ComplexUnitConfigControl : Border, IConfigControl
{
private readonly List<(PropertyInfo, IConfigControl)> _units = [];
private Type _objectType = typeof(object);
private readonly StackPanel _panel = new();
public string ConfigName { get; }
public bool Dirty => _units.Any(dirty => dirty.Item2.Dirty);
public ComplexUnitConfigControl(string name, object obj)
{
Classes.Add("ConfigBorder");
_panel.Orientation = Orientation.Vertical;
_panel.Spacing = 4f;
ConfigName = name;
Child = _panel;
SetValue(obj);
}
public void SetValue(object value)
{
_units.Clear();
_panel.Children.Clear();
_objectType = value.GetType();
_panel.Children.Add(new Label()
{
Content = ConfigName
});
foreach (var propInfo in _objectType.GetProperties())
{
if(propInfo.PropertyType.IsInterface)
continue;
var propValue = propInfo.GetValue(value);
var control = ConfigControlHelper.GetConfigControl(propInfo.Name, propValue);
((Control)control).Margin = new Thickness(5);
_panel.Children.Add((Control)control);
_units.Add((propInfo,control));
}
}
public object GetValue()
{
var obj = ConfigControlHelper.CreateDefaultValue(_objectType);
foreach (var (fieldInfo, configControl) in _units)
{
fieldInfo.SetValue(obj, configControl.GetValue());
}
return obj;
}
}
public sealed class ArrayUnitConfigControl : Border, IConfigControl
{
private readonly List<IConfigControl> _itemControls = [];
private readonly StackPanel _itemsPanel = new StackPanel() { Orientation = Orientation.Vertical };
private readonly Button _addButton = new Button() { Content = new Label()
{
Content = "Add Item"
}, Classes = { "ConfigBorder" }};
private readonly int _oldCount;
private readonly Type _elementType;
private readonly StackPanel _panel = new();
public string ConfigName { get; }
public bool Dirty => _itemControls.Any(dirty => dirty.Dirty) || _itemControls.Count != _oldCount;
public ArrayUnitConfigControl(string name, object value)
{
Classes.Add("ConfigBorder");
_elementType = value.GetType().GetElementType()!;
ConfigName = name;
_panel.Orientation = Orientation.Vertical;
_panel.Spacing = 4f;
_itemsPanel.Spacing = 4f;
_panel.Children.Add(new Label { Content = name });
_panel.Children.Add(_itemsPanel);
_panel.Children.Add(_addButton);
_addButton.Click += (_, _) => AddItem(ConfigControlHelper.CreateDefaultValue(_elementType)!);
Child = _panel;
SetValue(value);
_oldCount = _itemControls.Count;
}
private void AddItem(object value)
{
var itemPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2 };
var control = ConfigControlHelper.GetConfigControl(_itemControls.Count.ToString(), value);
var removeButton = new Button { Content = new Label(){ Content = "Remove" }, Classes = { "ConfigBorder" }};
removeButton.Click += (_, _) =>
{
_itemControls.Remove(control);
_itemsPanel.Children.Remove(itemPanel);
};
((Control)control).Margin = new Thickness(5);
itemPanel.Children.Add((Control)control);
itemPanel.Children.Add(removeButton);
_itemsPanel.Children.Add(itemPanel);
_itemControls.Add(control);
}
public void SetValue(object value)
{
_itemControls.Clear();
_itemsPanel.Children.Clear();
if (value is IEnumerable list)
{
foreach (var item in list)
{
AddItem(item);
}
}
}
public object GetValue()
{
return ConvertArray(_itemControls.Select(c => c.GetValue()).ToArray(), _elementType);
}
public static Array ConvertArray(Array sourceArray, Type targetType)
{
int length = sourceArray.Length;
var newArray = Array.CreateInstance(targetType, length);
for (int i = 0; i < length; i++)
{
var value = sourceArray.GetValue(i);
var converted = Convert.ChangeType(value, targetType);
newArray.SetValue(converted, i);
}
return newArray;
}
}
public abstract class UnitConfigControl<T> : Border, IConfigControl where T : notnull
{
private readonly Label _nameLabel = new();
private readonly TextBox _valueLabel = new();
private string _originalValue;
private StackPanel _panel = new();
public string ConfigName { get; }
public bool Dirty => _originalValue != ConfigValue;
protected string ConfigValue
{
get => _valueLabel.Text ?? string.Empty;
set => _valueLabel.Text = value;
}
public UnitConfigControl(string name, T value)
{
Classes.Add("ConfigBorder");
ConfigName = name;
_panel.Orientation = Orientation.Horizontal;
_panel.Children.Add(_nameLabel);
_panel.Children.Add(_valueLabel);
_nameLabel.Content = name;
_nameLabel.VerticalAlignment = VerticalAlignment.Center;
Child = _panel;
SetConfValue(value);
_originalValue = ConfigValue;
}
public abstract void SetConfValue(T value);
public abstract T GetConfValue();
public void SetValue(object value)
{
SetConfValue((T)value);
}
public object GetValue()
{
return GetConfValue()!;
}
}
public sealed class StringUnitConfigControl(string name, string value) : UnitConfigControl<string>(name, value)
{
public override void SetConfValue(string value)
{
ConfigValue = value;
}
public override string GetConfValue()
{
return ConfigValue;
}
}
public sealed class IntUnitConfigControl(string name, int value) : UnitConfigControl<int>(name, value)
{
public override void SetConfValue(int value)
{
ConfigValue = value.ToString();
}
public override int GetConfValue()
{
return int.Parse(ConfigValue);
}
}
public sealed class FloatUnitConfigControl(string name, float value) : UnitConfigControl<float>(name, value)
{
public CultureInfo CultureInfo = CultureInfo.InvariantCulture;
public override void SetConfValue(float value)
{
ConfigValue = value.ToString(CultureInfo);
}
public override float GetConfValue()
{
return float.Parse(ConfigValue, CultureInfo);
}
}
public interface IConfigControl
{
public string ConfigName { get; }
public bool Dirty {get;}
public abstract void SetValue(object value);
public abstract object GetValue();
}

View File

@@ -11,14 +11,15 @@ using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
using Nebula.Launcher.Models;
using Nebula.Launcher.Services;
using Nebula.Launcher.Utils;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Launcher.Views;
using Nebula.Launcher.Views.Pages;
using Nebula.Shared.FileApis;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Utils;
using Nebula.Shared.ViewHelper;
using Robust.LoaderApi;
namespace Nebula.Launcher.ViewModels.Pages;
@@ -33,6 +34,7 @@ public sealed partial class ContentBrowserViewModel : ViewModelBase, IContentHol
[GenerateProperty] private FileService FileService { get; } = default!;
[GenerateProperty] private PopupMessageService PopupService { get; } = default!;
[GenerateProperty] private IServiceProvider ServiceProvider { get; }
[GenerateProperty] private CancellationService CancellationService { get; set; } = default!;
[GenerateProperty, DesignConstruct] private ViewHelperService ViewHelperService { get; } = default!;
@@ -57,8 +59,12 @@ public sealed partial class ContentBrowserViewModel : ViewModelBase, IContentHol
loading.LoadingName = "Unpacking entry";
PopupService.Popup(loading);
Task.Run(() => ContentService.Unpack(serverEntry.FileApi, myTempDir, loading));
ExplorerHelper.OpenFolder(tmpDir);
Task.Run(() =>
{
ContentService.Unpack(serverEntry.FileApi, myTempDir, loading.CreateLoadingContext());
loading.Dispose();
});
ExplorerUtils.OpenFolder(tmpDir);
}
public void OnGoEnter()
@@ -74,11 +80,8 @@ public sealed partial class ContentBrowserViewModel : ViewModelBase, IContentHol
{
var cur = ServiceProvider.GetService<ServerFolderContentEntry>()!;
cur.Init(this, ServerText.ToRobustUrl());
var curContent = cur.Go(new ContentPath(SearchText));
if(curContent == null)
throw new NullReferenceException($"{SearchText} not found in {ServerText}");
CurrentEntry = curContent;
var curContent = cur.Go(new ContentPath(SearchText), CancellationService.Token);
CurrentEntry = curContent ?? throw new NullReferenceException($"{SearchText} not found in {ServerText}");
}
catch (Exception e)
{
@@ -144,11 +147,11 @@ public interface IContentEntry
public string IconPath { get; }
public ContentPath FullPath => Parent?.FullPath.With(Name) ?? new ContentPath(Name);
public IContentEntry? Go(ContentPath path);
public IContentEntry? Go(ContentPath path, CancellationToken cancellationToken);
public void GoCurrent()
{
var entry = Go(ContentPath.Empty);
var entry = Go(ContentPath.Empty, CancellationToken.None);
if(entry is not null) Holder.CurrentEntry = entry;
}
@@ -178,7 +181,7 @@ public sealed class LazyContentEntry : IContentEntry
_lazyEntry = entry;
_lazyEntryInit = lazyEntryInit;
}
public IContentEntry? Go(ContentPath path)
public IContentEntry? Go(ContentPath path, CancellationToken cancellationToken)
{
_lazyEntryInit?.Invoke();
return _lazyEntry;
@@ -196,13 +199,13 @@ public sealed class ExtContentExecutor
_decompilerService = decompilerService;
}
public bool TryExecute(RobustManifestItem manifestItem)
public bool TryExecute(IFileApi api, ContentPath path, CancellationToken cancellationToken)
{
var ext = Path.GetExtension(manifestItem.Path);
var ext = Path.GetExtension(path.GetName());
if (ext == ".dll")
{
_decompilerService.OpenServerDecompiler(_root.ServerUrl);
_decompilerService.OpenServerDecompiler(_root.ServerUrl, cancellationToken);
return true;
}
@@ -211,42 +214,39 @@ public sealed class ExtContentExecutor
}
public sealed partial class ManifestContentEntry : IContentEntry
public sealed partial class FileContentEntry : IContentEntry
{
public IContentHolder Holder { get; set; } = default!;
public IContentEntry? Parent { get; set; }
public string? Name { get; set; }
public string IconPath => "/Assets/svg/file.svg";
private RobustManifestItem _manifestItem;
private HashApi _hashApi = default!;
private IFileApi _fileApi = default!;
private ExtContentExecutor _extContentExecutor = default!;
public void Init(IContentHolder holder, RobustManifestItem manifestItem, HashApi api, ExtContentExecutor executor)
public void Init(IContentHolder holder, IFileApi api, string fileName, ExtContentExecutor executor)
{
Holder = holder;
Name = new ContentPath(manifestItem.Path).GetName();
_manifestItem = manifestItem;
_hashApi = api;
Name = fileName;
_fileApi = api;
_extContentExecutor = executor;
}
public IContentEntry? Go(ContentPath path)
public IContentEntry? Go(ContentPath path, CancellationToken cancellationToken)
{
if (_extContentExecutor.TryExecute(_manifestItem))
var fullPath = ((IContentEntry)this).FullPath;
if (_extContentExecutor.TryExecute(_fileApi, fullPath, cancellationToken))
return null;
var ext = Path.GetExtension(_manifestItem.Path);
var ext = Path.GetExtension(fullPath.GetName());
try
{
if (!_hashApi.TryOpen(_manifestItem, out var stream))
if (!_fileApi.TryOpen(fullPath.Path, out var stream))
return null;
var myTempFile = Path.Combine(Path.GetTempPath(), "tempie" + ext);
var sw = new FileStream(myTempFile, FileMode.Create, FileAccess.Write, FileShare.None);
stream.CopyTo(sw);
@@ -295,7 +295,7 @@ public sealed partial class ServerFolderContentEntry : BaseFolderContentEntry
public RobustUrl ServerUrl { get; private set; }
public HashApi FileApi { get; private set; } = default!;
public IFileApi FileApi { get; private set; } = default!;
private ExtContentExecutor _contentExecutor = default!;
@@ -312,20 +312,20 @@ public sealed partial class ServerFolderContentEntry : BaseFolderContentEntry
Task.Run(async () =>
{
var buildInfo = await ContentService.GetBuildInfo(serverUrl, CancellationService.Token);
FileApi = await ContentService.EnsureItems(buildInfo.RobustManifestInfo, loading,
FileApi = await ContentService.EnsureItems(buildInfo, loading,
CancellationService.Token);
foreach (var (path, item) in FileApi.Manifest)
foreach (var path in FileApi.AllFiles)
{
CreateContent(new ContentPath(path), item);
CreateContent(new ContentPath(path));
}
IsLoading = false;
loading.Dispose();
});
}
public ManifestContentEntry CreateContent(ContentPath path, RobustManifestItem manifestItem)
public FileContentEntry CreateContent(ContentPath path)
{
var pathDir = path.GetDirectory();
BaseFolderContentEntry parent = this;
@@ -342,8 +342,8 @@ public sealed partial class ServerFolderContentEntry : BaseFolderContentEntry
parent = folderContentEntry as BaseFolderContentEntry ?? throw new InvalidOperationException();
}
var manifestContent = new ManifestContentEntry();
manifestContent.Init(Holder, manifestItem, FileApi, _contentExecutor);
var manifestContent = new FileContentEntry();
manifestContent.Init(Holder, FileApi, path.GetName(), _contentExecutor);
parent.AddChild(manifestContent);
@@ -416,15 +416,28 @@ public abstract class BaseFolderContentEntry : ViewModelBase, IContentEntry
private Dictionary<string, IContentEntry> _childs = [];
public string IconPath => "/Assets/svg/folder.svg";
public IContentHolder Holder { get; private set; }
private IContentHolder? _holder = null;
public IContentHolder Holder
{
get
{
if(_holder == null)
throw new InvalidOperationException(
GetType().Name + " was not initialised! Call Init(IContentHolder holder, string? name = null) before using it.");
return _holder;
}
}
public IContentEntry? Parent { get; set; }
public string? Name { get; private set; }
public IContentEntry? Go(ContentPath path)
public IContentEntry? Go(ContentPath path, CancellationToken cancellationToken)
{
if (path.IsEmpty()) return this;
if (_childs.TryGetValue(path.GetNext(), out var child))
return child.Go(path);
return child.Go(path, cancellationToken);
return null;
}
@@ -432,7 +445,7 @@ public abstract class BaseFolderContentEntry : ViewModelBase, IContentEntry
public void Init(IContentHolder holder, string? name = null)
{
Name = name;
Holder = holder;
_holder = holder;
}
public T AddChild<T>(T child) where T: IContentEntry

View File

@@ -2,7 +2,9 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
@@ -14,6 +16,7 @@ using Nebula.Launcher.Views.Pages;
using Nebula.Shared;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Utils;
using Nebula.Shared.ViewHelper;
namespace Nebula.Launcher.ViewModels.Pages;
@@ -23,22 +26,17 @@ namespace Nebula.Launcher.ViewModels.Pages;
public partial class ServerOverviewModel : ViewModelBase
{
[ObservableProperty] private string _searchText = string.Empty;
[ObservableProperty] private bool _isFilterVisible;
[ObservableProperty] private ServerListView _currentServerList = new();
public readonly ServerFilter CurrentFilter = new();
[GenerateProperty] private IServiceProvider ServiceProvider { get; }
[GenerateProperty] private ConfigurationService ConfigurationService { get; }
[GenerateProperty] private FavoriteServerListProvider FavoriteServerListProvider { get; }
public ObservableCollection<ServerListTabTemplate> Items { get; private set; }
[ObservableProperty] private ServerListTabTemplate _selectedItem;
[GenerateProperty, DesignConstruct] private ServerViewContainer ServerViewContainer { get; }
[GenerateProperty, DesignConstruct] public ServerListViewModel CurrentServerList { get; }
private Dictionary<string, ServerListView> _viewCache = [];
public ServerFilter CurrentFilter { get; } = new();
public ObservableCollection<ServerListTabTemplate> Items { get; private set; }
//Design think
@@ -54,9 +52,18 @@ public partial class ServerOverviewModel : ViewModelBase
//real think
protected override void Initialise()
{
FavoriteServerListProvider.OnRefreshRequired += OnFavoriteRefreshRequired;
ConfigurationService.SubscribeVarChanged(LauncherConVar.Hub, OnHubListChanged, true);
}
private void OnFavoriteRefreshRequired()
{
if(CurrentServerList.Provider is FavoriteServerListProvider favoriteServerListProvider)
{
RefreshProvider();
}
}
private void OnHubListChanged(ServerHubRecord[]? value)
{
var tempItems = new List<ServerListTabTemplate>();
@@ -66,7 +73,7 @@ public partial class ServerOverviewModel : ViewModelBase
tempItems.Add(new ServerListTabTemplate(ServiceProvider.GetService<HubServerListProvider>()!.With(record.MainUrl), record.Name));
}
tempItems.Add(new ServerListTabTemplate(FavoriteServerListProvider, "Favorite"));
tempItems.Add(new ServerListTabTemplate(FavoriteServerListProvider, LocalizationService.GetString("tab-favorite")));
Items = new ObservableCollection<ServerListTabTemplate>(tempItems);
@@ -79,13 +86,9 @@ public partial class ServerOverviewModel : ViewModelBase
ApplyFilter();
}
public void ApplyFilter()
private void ApplyFilter()
{
foreach (var entry in ServerViewContainer.Items)
{
if(entry is IFilterConsumer filterConsumer)
filterConsumer.ProcessFilter(CurrentFilter);
}
ServerViewContainer.ApplyFilter(CurrentFilter);
}
public void OnFilterChanged(FilterBoxChangedEventArgs args)
@@ -105,32 +108,39 @@ public partial class ServerOverviewModel : ViewModelBase
public void UpdateRequired()
{
ServerViewContainer.Clear();
RefreshProvider();
}
private void RefreshProvider()
{
CurrentServerList.ClearProvider();
CurrentServerList.RefreshFromProvider();
CurrentServerList.RequireStatusUpdate();
CurrentServerList.ApplyFilter(CurrentFilter);
}
partial void OnSelectedItemChanged(ServerListTabTemplate value)
{
if (!_viewCache.TryGetValue(value.TabName, out var view))
{
view = ServerListView.TakeFrom(value.ServerListProvider);
_viewCache[value.TabName] = view;
}
CurrentServerList = view;
CurrentServerList.ClearProvider();
CurrentServerList.SetProvider(value.ServerListProvider);
ApplyFilter();
}
}
[ServiceRegister]
public class ServerViewContainer
public sealed class ServerViewContainer
{
private readonly ViewHelperService _viewHelperService;
private readonly List<string> _favorites = [];
private readonly Dictionary<string, string> _customNames = [];
private readonly Dictionary<string, WeakReference<IListEntryModelView>> _entries = new();
private ServerFilter? _currentFilter;
public ICollection<IListEntryModelView> Items =>
_entries.Values
.Select(wr => wr.TryGetTarget(out var target) ? target : null)
.Where(t => t != null)
.ToList()!;
public ServerViewContainer()
{
_viewHelperService = new ViewHelperService();
@@ -144,111 +154,171 @@ public class ServerViewContainer
configurationService.SubscribeVarChanged(LauncherConVar.ServerCustomNames, OnCustomNamesChanged, true);
}
private void OnCustomNamesChanged(Dictionary<string,string>? value)
public void Clear()
{
var oldNames =
_customNames.ToDictionary(k => k.Key, v => v.Value); //Clone think
_customNames.Clear();
if(value == null)
foreach (var (_, weakRef) in _entries)
{
foreach (var (ip,_) in oldNames)
{
if(!_entries.TryGetValue(ip, out var listEntry) || listEntry is not IEntryNameHolder entryNameHolder)
continue;
entryNameHolder.Name = null;
}
return;
if (weakRef.TryGetTarget(out var value) && value is IDisposable disposable)
disposable.Dispose();
}
foreach (var (oldIp, oldName) in oldNames)
_entries.Clear();
}
public IListEntryModelView Get(string url, ServerStatus? serverStatus = null) => Get(url.ToRobustUrl(), serverStatus);
public IListEntryModelView Get(RobustUrl url, ServerStatus? serverStatus = null)
{
var key = url.ToString();
IListEntryModelView? entry;
lock (_entries)
{
if(value.TryGetValue(oldIp, out var newName))
if (_entries.TryGetValue(key, out var weakEntry)
&& weakEntry.TryGetTarget(out entry))
{
if (oldName == newName)
value.Remove(newName);
continue;
return entry;
}
if(!_entries.TryGetValue(oldIp, out var listEntry) ||
listEntry is not IEntryNameHolder entryNameHolder)
continue;
entryNameHolder.Name = null;
entry = Create(url, serverStatus);
_entries[key] = new WeakReference<IListEntryModelView>(entry);
}
return entry;
}
private IListEntryModelView Create(RobustUrl url, ServerStatus? serverStatus = null)
{
IListEntryModelView? entry;
var key = url.ToString();
_customNames.TryGetValue(key, out var customName);
if (serverStatus is not null)
{
entry = _viewHelperService
.GetViewModel<ServerEntryViewModel>()
.WithData(url, customName, serverStatus);
}
else
{
entry = _viewHelperService
.GetViewModel<ServerCompoundEntryViewModel>()
.LoadServerEntry(url, customName, CancellationToken.None);
}
if (entry is IFavoriteEntryModelView fav)
{
fav.IsFavorite = _favorites.Contains(key);
}
if (entry is IFilterConsumer filterConsumer)
{
filterConsumer.ProcessFilter(_currentFilter);
}
foreach (var (ip, name) in value)
return entry;
}
public void ApplyFilter(ServerFilter? filter)
{
_currentFilter = filter;
foreach (var serverView in Items)
{
_customNames.Add(ip, name);
if(!_entries.TryGetValue(ip, out var listEntry) || listEntry is not IEntryNameHolder entryNameHolder)
continue;
entryNameHolder.Name = name;
if(serverView is IFilterConsumer filterConsumer)
filterConsumer.ProcessFilter(filter);
}
}
private void OnFavoritesChange(string[]? value)
{
_favorites.Clear();
if(value == null) return;
if (value == null) return;
foreach (var favorite in value)
{
_favorites.Add(favorite);
if (_entries.TryGetValue(favorite, out var entry) && entry is IFavoriteEntryModelView favoriteView)
if (_entries.TryGetValue(favorite, out var weak)
&& weak.TryGetTarget(out var entry)
&& entry is IFavoriteEntryModelView fav)
{
favoriteView.IsFavorite = true;
fav.IsFavorite = true;
}
}
}
private readonly Dictionary<string, IListEntryModelView> _entries = new();
public ICollection<IListEntryModelView> Items => _entries.Values;
public void Clear()
private void OnCustomNamesChanged(Dictionary<string, string>? value)
{
_entries.Clear();
}
var oldNames = _customNames.ToDictionary(x => x.Key, x => x.Value);
_customNames.Clear();
public IListEntryModelView Get(RobustUrl url, ServerStatus? serverStatus = null)
{
IListEntryModelView? entry;
lock (_entries)
if (value == null)
{
_customNames.TryGetValue(url.ToString(), out var customName);
if (_entries.TryGetValue(url.ToString(), out entry))
foreach (var (ip, _) in oldNames)
{
return entry;
ResetName(ip);
}
if (serverStatus is not null)
entry = _viewHelperService.GetViewModel<ServerEntryModelView>().WithData(url, customName, serverStatus);
else
entry = _viewHelperService.GetViewModel<ServerCompoundEntryViewModel>().LoadServerEntry(url, customName, CancellationToken.None);
if(_favorites.Contains(url.ToString()) &&
entry is IFavoriteEntryModelView favoriteEntryModelView)
favoriteEntryModelView.IsFavorite = true;
_entries.Add(url.ToString(), entry);
return;
}
foreach (var (oldIp, oldName) in oldNames)
{
if (value.TryGetValue(oldIp, out var newName))
{
if (oldName == newName)
value.Remove(newName);
continue;
}
ResetName(oldIp);
}
foreach (var (ip, name) in value)
{
_customNames.Add(ip, name);
if (_entries.TryGetValue(ip, out var weak)
&& weak.TryGetTarget(out var entry)
&& entry is IEntryNameHolder holder)
{
holder.Name = name;
}
}
}
private void ResetName(string ip)
{
if (_entries.TryGetValue(ip, out var weak)
&& weak.TryGetTarget(out var entry)
&& entry is IEntryNameHolder holder)
{
holder.Name = null;
}
return entry;
}
}
public interface IListEntryModelView
public interface IListEntryModelView
{
}
public sealed class ExampleEntry : StackPanel, IListEntryModelView
{
public ExampleEntry(string name)
{
Children.Add(new Label { Content = name });
}
public void Dispose()
{
}
}
public interface IFavoriteEntryModelView
{
public bool IsFavorite { get; set; }

View File

@@ -32,7 +32,7 @@ public partial class AddFavoriteViewModel : PopupViewModelBase
[GenerateProperty] private ServerOverviewModel ServerOverviewModel { get; }
[GenerateProperty] private DebugService DebugService { get; }
[GenerateProperty] private FavoriteServerListProvider FavoriteServerListProvider { get; }
public override string Title => LocalisationService.GetString("popup-add-favorite");
public override string Title => LocalizationService.GetString("popup-add-favorite");
public override bool IsClosable => true;
[ObservableProperty] private string _ipInput;
@@ -43,7 +43,7 @@ public partial class AddFavoriteViewModel : PopupViewModelBase
try
{
if(string.IsNullOrWhiteSpace(IpInput))
throw new Exception(LocalisationService.GetString("popup-add-favorite-invalid-ip"));
throw new Exception(LocalizationService.GetString("popup-add-favorite-invalid-ip"));
var uri = IpInput.ToRobustUrl();
FavoriteServerListProvider.AddFavorite(uri);
@@ -55,4 +55,10 @@ public partial class AddFavoriteViewModel : PopupViewModelBase
_logger.Error(e);
}
}
protected override void OnDispose()
{
base.OnDispose();
_logger.Dispose();
}
}

View File

@@ -12,7 +12,7 @@ public sealed partial class EditServerNameViewModel : PopupViewModelBase
{
[GenerateProperty] public override PopupMessageService PopupMessageService { get; }
[GenerateProperty] public ConfigurationService ConfigurationService { get; }
public override string Title => LocalisationService.GetString("popup-edit-name");
public override string Title => LocalizationService.GetString("popup-edit-name");
public override bool IsClosable => true;
[ObservableProperty] private string _ipInput;

View File

@@ -12,10 +12,10 @@ namespace Nebula.Launcher.ViewModels.Popup;
public sealed partial class ExceptionListViewModel : PopupViewModelBase
{
[GenerateProperty] public override PopupMessageService PopupMessageService { get; }
public override string Title => LocalisationService.GetString("popup-exception");
public override string Title => LocalizationService.GetString("popup-exception");
public override bool IsClosable => true;
public ObservableCollection<Exception> Errors { get; } = new();
public ObservableCollection<ExceptionCompound> Errors { get; } = new();
protected override void Initialise()
{
@@ -23,13 +23,18 @@ public sealed partial class ExceptionListViewModel : PopupViewModelBase
protected override void InitialiseInDesignMode()
{
var e = new Exception("TEST");
var e = new ExceptionCompound("TEST", "thrown in design mode");
AppendError(e);
}
public void AppendError(ExceptionCompound exception)
{
Errors.Add(exception);
}
public void AppendError(Exception exception)
{
Errors.Add(exception);
AppendError(new ExceptionCompound(exception));
if (exception.InnerException != null)
AppendError(exception.InnerException);
}

View File

@@ -14,7 +14,7 @@ public partial class InfoPopupViewModel : PopupViewModelBase
[ObservableProperty] private string _infoText = "Test";
public override string Title => LocalisationService.GetString("popup-information");
public override string Title => LocalizationService.GetString("popup-information");
public bool IsInfoClosable { get; set; } = true;
public override bool IsClosable => IsInfoClosable;

View File

@@ -9,7 +9,7 @@ namespace Nebula.Launcher.ViewModels.Popup;
[ConstructGenerator, ViewModelRegister(typeof(IsLoginCredentialsNullPopupView))]
public partial class IsLoginCredentialsNullPopupViewModel : PopupViewModelBase
{
private ServerEntryModelView _entry;
private ServerEntryViewModel _entryView;
[GenerateProperty] public override PopupMessageService PopupMessageService { get; }
[GenerateProperty, DesignConstruct] private ViewHelperService ViewHelperService { get; }
@@ -22,15 +22,15 @@ public partial class IsLoginCredentialsNullPopupViewModel : PopupViewModelBase
{
}
public IsLoginCredentialsNullPopupViewModel WithServerEntry(ServerEntryModelView entryModelView)
public IsLoginCredentialsNullPopupViewModel WithServerEntry(ServerEntryViewModel entryViewModel)
{
_entry = entryModelView;
_entryView = entryViewModel;
return this;
}
public void Proceed()
{
_entry.RunInstanceIgnoreAuth();
_entryView.RunInstanceIgnoreAuth();
Dispose();
}
@@ -45,6 +45,6 @@ public partial class IsLoginCredentialsNullPopupViewModel : PopupViewModelBase
Dispose();
}
public override string Title => LocalisationService.GetString("popup-login-credentials-warning");
public override string Title => LocalizationService.GetString("popup-login-credentials-warning");
public override bool IsClosable => true;
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Nebula.Launcher.Services;
using Nebula.Launcher.Views.Popup;
@@ -9,82 +11,121 @@ namespace Nebula.Launcher.ViewModels.Popup;
[ViewModelRegister(typeof(LoadingContextView), false)]
[ConstructGenerator]
public sealed partial class LoadingContextViewModel : PopupViewModelBase, ILoadingHandler
public sealed partial class LoadingContextViewModel : PopupViewModelBase, ILoadingHandlerFactory, IConnectionSpeedHandler
{
public ObservableCollection<LoadingContext> LoadingContexts { get; } = [];
public ObservableCollection<double> Values { get; } = [];
[ObservableProperty] private string _speedText = "";
[ObservableProperty] private bool _showSpeed;
[ObservableProperty] private int _loadingColumnSize = 2;
[GenerateProperty] public override PopupMessageService PopupMessageService { get; }
[GenerateProperty] public CancellationService CancellationService { get; }
[ObservableProperty] private int _currJobs;
[ObservableProperty] private int _resolvedJobs;
[ObservableProperty] private string _message = string.Empty;
public string LoadingName { get; set; } = LocalisationService.GetString("popup-loading");
public string LoadingName { get; set; } = LocalizationService.GetString("popup-loading");
public bool IsCancellable { get; set; } = true;
public override bool IsClosable => false;
public override string Title => LoadingName;
public override string Title => LocalizationService.GetString("popup-loading");
public void SetJobsCount(int count)
public void Cancel()
{
CurrJobs = count;
}
public int GetJobsCount()
{
return CurrJobs;
}
public void SetResolvedJobsCount(int count)
{
ResolvedJobs = count;
}
public int GetResolvedJobsCount()
{
return ResolvedJobs;
}
public void SetLoadingMessage(string message)
{
Message = message + "\n" + Message;
}
public void Cancel(){
if(!IsCancellable) return;
if (!IsCancellable) return;
CancellationService.Cancel();
Dispose();
}
public void PasteSpeed(int speed)
{
if (Values.Count == 0)
{
ShowSpeed = true;
LoadingColumnSize = 1;
}
SpeedText = FileLoadingFormater.FormatBytes(speed) + " / s";
Values.Add(speed);
if(Values.Count > 10) Values.RemoveAt(0);
}
public ILoadingHandler CreateLoadingContext(ILoadingFormater? loadingFormater = null)
{
var instance = new LoadingContext(this, loadingFormater ?? DefaultLoadingFormater.Instance);
LoadingContexts.Add(instance);
return instance;
}
public void RemoveContextInstance(LoadingContext loadingContext)
{
LoadingContexts.Remove(loadingContext);
}
protected override void Initialise()
{
}
protected override void InitialiseInDesignMode()
{
SetJobsCount(5);
SetResolvedJobsCount(2);
string[] debugMessages = {
"Debug: Starting phase 1...",
"Debug: Loading assets...",
"Debug: Connecting to server...",
"Debug: Fetching user data...",
"Debug: Applying configurations...",
"Debug: Starting phase 2...",
"Debug: Rendering UI...",
"Debug: Preparing scene...",
"Debug: Initializing components...",
"Debug: Running diagnostics...",
"Debug: Checking dependencies...",
"Debug: Verifying files...",
"Debug: Cleaning up cache...",
"Debug: Finalizing setup...",
"Debug: Setup complete.",
"Debug: Ready for launch."
};
var context = CreateLoadingContext();
context.SetJobsCount(5);
context.SetResolvedJobsCount(2);
context.SetLoadingMessage("message");
foreach (string message in debugMessages)
var ctx1 = CreateLoadingContext(new FileLoadingFormater());
ctx1.SetJobsCount(1020120);
ctx1.SetResolvedJobsCount(12331);
ctx1.SetLoadingMessage("File data");
for (var i = 0; i < 14; i++)
{
SetLoadingMessage(message);
PasteSpeed(Random.Shared.Next(10000000));
}
}
}
}
public sealed partial class LoadingContext : ObservableObject, ILoadingHandler
{
private readonly LoadingContextViewModel _master;
private readonly ILoadingFormater _loadingFormater;
public string LoadingText => _loadingFormater.Format(this);
[ObservableProperty] private string _message = string.Empty;
[ObservableProperty] private long _currJobs;
[ObservableProperty] private long _resolvedJobs;
public LoadingContext(LoadingContextViewModel master, ILoadingFormater loadingFormater)
{
_master = master;
_loadingFormater = loadingFormater;
}
public void SetJobsCount(long count)
{
CurrJobs = count;
OnPropertyChanged(nameof(LoadingText));
}
public long GetJobsCount()
{
return CurrJobs;
}
public void SetResolvedJobsCount(long count)
{
ResolvedJobs = count;
OnPropertyChanged(nameof(LoadingText));
}
public long GetResolvedJobsCount()
{
return ResolvedJobs;
}
public void SetLoadingMessage(string message)
{
Message = message;
}
public void Dispose()
{
_master.RemoveContextInstance(this);
}
}

View File

@@ -9,9 +9,14 @@ public abstract class PopupViewModelBase : ViewModelBase, IDisposable
public abstract string Title { get; }
public abstract bool IsClosable { get; }
public Action<PopupViewModelBase>? OnDisposing;
public void Dispose()
{
OnDispose();
OnDisposing?.Invoke(this);
PopupMessageService.ClosePopup(this);
}
protected virtual void OnDispose(){}
}

View File

@@ -1,5 +1,6 @@
using System;
using Nebula.Launcher.Services;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Launcher.Views.Popup;
using Nebula.Shared.Services;
using Nebula.Shared.ViewHelper;
@@ -9,7 +10,10 @@ namespace Nebula.Launcher.ViewModels.Popup;
[ConstructGenerator, ViewModelRegister(typeof(TfaView))]
public partial class TfaViewModel : PopupViewModelBase
{
public Action<string>? OnTfaEntered;
[GenerateProperty] public override PopupMessageService PopupMessageService { get; }
[GenerateProperty] public AccountInfoViewModel AccountInfo { get; }
public override string Title => LocalizationService.GetString("popup-twofa");
public override bool IsClosable => true;
protected override void InitialiseInDesignMode()
{
@@ -21,11 +25,7 @@ public partial class TfaViewModel : PopupViewModelBase
public void OnTfaEnter(string code)
{
OnTfaEntered?.Invoke(code);
AccountInfo.DoAuth(code);
Dispose();
}
[GenerateProperty] public override PopupMessageService PopupMessageService { get; }
public override string Title => LocalisationService.GetString("popup-twofa");
public override bool IsClosable => true;
}

View File

@@ -1,9 +1,6 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
using Nebula.Launcher.Models;
@@ -13,7 +10,6 @@ using Nebula.Launcher.Views;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.ViewHelper;
using BindingFlags = System.Reflection.BindingFlags;
namespace Nebula.Launcher.ViewModels;
@@ -22,8 +18,6 @@ namespace Nebula.Launcher.ViewModels;
public sealed partial class ServerCompoundEntryViewModel :
ViewModelBase, IFavoriteEntryModelView, IFilterConsumer, IListEntryModelView, IEntryNameHolder
{
[ObservableProperty] private ServerEntryModelView? _currentEntry;
[ObservableProperty] private Control? _entryControl;
[ObservableProperty] private string _message = "Loading server entry...";
[ObservableProperty] private bool _isFavorite;
[ObservableProperty] private bool _loading = true;
@@ -32,6 +26,28 @@ public sealed partial class ServerCompoundEntryViewModel :
private RobustUrl? _url;
private ServerFilter? _currentFilter;
public ServerEntryViewModel? CurrentEntry
{
get;
set
{
if (value == field) return;
field = value;
if (field != null)
{
field.IsFavorite = IsFavorite;
field.Name = Name;
field.ProcessFilter(_currentFilter);
}
Loading = field == null;
OnPropertyChanged();
}
}
public string? Name
{
get => _name;
@@ -58,31 +74,43 @@ public sealed partial class ServerCompoundEntryViewModel :
{
}
public ServerCompoundEntryViewModel LoadServerEntry(RobustUrl url,string? name, CancellationToken cancellationToken)
public ServerCompoundEntryViewModel LoadWithEntry(ServerEntryViewModel? entry)
{
Task.Run(async () =>
{
_url = url;
try
{
Message = "Loading server entry...";
var status = await RestService.GetAsync<ServerStatus>(_url.StatusUri, cancellationToken);
CurrentEntry = ServiceProvider.GetService<ServerEntryModelView>()!.WithData(_url,name, status);
CurrentEntry.IsFavorite = IsFavorite;
CurrentEntry.Loading = false;
CurrentEntry.ProcessFilter(_currentFilter);
Loading = false;
}
catch (Exception e)
{
Message = e.Message;
}
}, cancellationToken);
CurrentEntry = entry;
return this;
}
public ServerCompoundEntryViewModel LoadServerEntry(RobustUrl url, string? name, CancellationToken cancellationToken)
{
_url = url;
_name = name;
Task.Run(LoadServer, cancellationToken);
return this;
}
private async Task LoadServer()
{
if (_url is null)
{
Message = "Url is not set";
return;
}
try
{
Message = "Loading server entry...";
var status = await RestService.GetAsync<ServerStatus>(_url.StatusUri, CancellationToken.None);
CurrentEntry = ServiceProvider.GetService<ServerEntryViewModel>()!.WithData(_url, null, status);
Loading = false;
}
catch (Exception e)
{
Message = "Error while fetching data from " + _url + " : " + e.Message;
}
}
public void ToggleFavorites()
{
if (CurrentEntry is null && _url is not null)

View File

@@ -1,256 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using Nebula.Launcher.Models;
using Nebula.Launcher.ProcessHelper;
using Nebula.Launcher.ServerListProviders;
using Nebula.Launcher.Services;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Launcher.Views;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
using Nebula.Shared.Utils;
using Nebula.Shared.ViewHelper;
namespace Nebula.Launcher.ViewModels;
[ViewModelRegister(typeof(ServerEntryView), false)]
[ConstructGenerator]
public partial class ServerEntryModelView : ViewModelBase, IFilterConsumer, IListEntryModelView, IFavoriteEntryModelView, IEntryNameHolder
{
[ObservableProperty] private string _description = "Fetching info...";
[ObservableProperty] private bool _expandInfo;
[ObservableProperty] private bool _isFavorite;
[ObservableProperty] private bool _isVisible;
[ObservableProperty] private bool _runVisible = true;
[ObservableProperty] private bool _tagDataVisible;
[ObservableProperty] private bool _loading;
[ObservableProperty] private string _realName;
public string? Name
{
get => RealName;
set => RealName = value ?? Status.Name;
}
private ILogger _logger;
private ServerInfo? _serverInfo;
private ContentLogConsumer _currentContentLogConsumer;
private ProcessRunHandler<GameProcessStartInfoProvider>? _currentInstance;
public LogPopupModelView CurrLog;
public RobustUrl Address { get; private set; }
[GenerateProperty] private AccountInfoViewModel AccountInfoViewModel { get; }
[GenerateProperty] private CancellationService CancellationService { get; } = default!;
[GenerateProperty] private DebugService DebugService { get; } = default!;
[GenerateProperty] private PopupMessageService PopupMessageService { get; } = default!;
[GenerateProperty] private ViewHelperService ViewHelperService { get; } = default!;
[GenerateProperty] private RestService RestService { get; } = default!;
[GenerateProperty] private MainViewModel MainViewModel { get; } = default!;
[GenerateProperty] private FavoriteServerListProvider FavoriteServerListProvider { get; } = default!;
[GenerateProperty] private GameRunnerPreparer GameRunnerPreparer { get; } = default!;
public ServerStatus Status { get; private set; } =
new(
"Fetching data...",
"Loading...", [],
"",
-1,
-1,
-1,
false,
DateTime.Now,
-1
);
public ObservableCollection<ServerLink> Links { get; } = new();
public ObservableCollection<string> Tags { get; } = [];
public ICommand OnLinkGo { get; } = new LinkGoCommand();
public async Task<ServerInfo?> GetServerInfo()
{
if (_serverInfo == null)
try
{
_serverInfo = await RestService.GetAsync<ServerInfo>(Address.InfoUri, CancellationService.Token);
}
catch (Exception e)
{
Description = e.Message;
_logger.Error(e);
}
return _serverInfo;
}
protected override void InitialiseInDesignMode()
{
IsVisible = true;
RealName = "TEST.TEST";
Description = "Server of meow girls! Nya~ \nNyaMeow\nOOOINK!!";
Links.Add(new ServerLink("Discord", "discord", "https://cinka.ru"));
Status = new ServerStatus("Ameba",
"Locala meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow ",
["rp:hrp", "18+"],
"Antag", 15, 5, 1, false
, DateTime.Now, 100);
Address = "ss14://localhost".ToRobustUrl();
}
protected override void Initialise()
{
_logger = DebugService.GetLogger(this);
CurrLog = ViewHelperService.GetViewModel<LogPopupModelView>();
_currentContentLogConsumer = new(CurrLog, PopupMessageService);
}
public void ProcessFilter(ServerFilter? serverFilter)
{
if (serverFilter == null)
{
IsVisible = true;
return;
}
IsVisible = serverFilter.IsMatch(Status.Name, Tags);
}
public void SetStatus(ServerStatus serverStatus)
{
Status = serverStatus;
Tags.Clear();
foreach (var tag in Status.Tags) Tags.Add(tag);
OnPropertyChanged(nameof(Status));
}
public ServerEntryModelView WithData(RobustUrl url, string? name,ServerStatus serverStatus)
{
Address = url;
SetStatus(serverStatus);
Name = name;
return this;
}
public void EditName()
{
var popup = ViewHelperService.GetViewModel<EditServerNameViewModel>();
popup.IpInput = Address.ToString();
popup.NameInput = Name ?? string.Empty;
PopupMessageService.Popup(popup);
}
public void OpenContentViewer()
{
MainViewModel.RequirePage<ContentBrowserViewModel>().Go(Address, ContentPath.Empty);
}
public void ToggleFavorites()
{
IsFavorite = !IsFavorite;
if(IsFavorite)
FavoriteServerListProvider.AddFavorite(this);
else
FavoriteServerListProvider.RemoveFavorite(this);
}
public void RunInstance()
{
CurrLog.Clear();
Task.Run(async ()=> await RunInstanceAsync());
}
public void RunInstanceIgnoreAuth()
{
CurrLog.Clear();
Task.Run(async ()=> await RunInstanceAsync(true));
}
private async Task RunInstanceAsync(bool ignoreLoginCredentials = false)
{
if (!ignoreLoginCredentials && AccountInfoViewModel.Credentials is null)
{
var warningContext = ViewHelperService.GetViewModel<IsLoginCredentialsNullPopupViewModel>()
.WithServerEntry(this);
PopupMessageService.Popup(warningContext);
return;
}
using var loadingContext = ViewHelperService.GetViewModel<LoadingContextViewModel>();
loadingContext.LoadingName = "Loading instance...";
((ILoadingHandler)loadingContext).AppendJob();
PopupMessageService.Popup(loadingContext);
_currentInstance =
await GameRunnerPreparer.GetGameProcessStartInfoProvider(Address, loadingContext, CancellationService.Token);
_currentInstance.RegisterLogger(_currentContentLogConsumer);
_currentInstance.RegisterLogger(new DebugLoggerBridge(DebugService.GetLogger($"PROCESS_{Random.Shared.Next(65535)}")));
_currentInstance.OnProcessExited += OnProcessExited;
RunVisible = false;
_currentInstance.Start();
}
private void OnProcessExited(ProcessRunHandler<GameProcessStartInfoProvider> obj)
{
RunVisible = true;
if (_currentInstance == null) return;
_currentInstance.OnProcessExited -= OnProcessExited;
_currentInstance.Dispose();
_currentInstance = null;
}
public void StopInstance()
{
_currentInstance?.Stop();
}
public void ReadLog()
{
PopupMessageService.Popup(CurrLog);
}
public async void ExpandInfoRequired()
{
ExpandInfo = !ExpandInfo;
if (Design.IsDesignMode) return;
var info = await GetServerInfo();
if (info == null) return;
Description = info.Desc;
Links.Clear();
if (info.Links is null) return;
foreach (var link in info.Links) Links.Add(link);
}
}
public class LinkGoCommand : ICommand
{
public LinkGoCommand()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public bool CanExecute(object? parameter)
{
return true;
}
public void Execute(object? parameter)
{
if (parameter is not string str) return;
Helper.SafeOpenBrowser(str);
}
public event EventHandler? CanExecuteChanged;
}

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Windows.Input;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using Nebula.Launcher.Models;
using Nebula.Launcher.ServerListProviders;
using Nebula.Launcher.Services;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Launcher.ViewModels.Popup;
using Nebula.Launcher.Views;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Utils;
using Nebula.Shared.ViewHelper;
namespace Nebula.Launcher.ViewModels;
[ViewModelRegister(typeof(ServerEntryView), false)]
public sealed partial class ServerEntryViewModel(
RestService restService,
CancellationService cancellationService,
GameRunnerService gameRunnerService
) :
ViewModelBase,
IFilterConsumer,
IListEntryModelView,
IFavoriteEntryModelView,
IEntryNameHolder,
IRunningSignalConsumer
{
[ObservableProperty] private string _description = "Fetching info...";
[ObservableProperty] private bool _expandInfo;
[ObservableProperty] private bool _isFavorite;
[ObservableProperty] private bool _isVisible;
[ObservableProperty] private bool _runVisible = true;
[ObservableProperty] private string _realName = string.Empty;
public string? Name
{
get => RealName;
set => RealName = value ?? Status.Name;
}
private ServerInfo? _serverInfo;
public RobustUrl Address { get; private set; }
public ServerStatus Status { get; private set; } =
new(
"Fetching data...",
"Loading...", [],
"",
-1,
-1,
-1,
false,
DateTime.Now,
-1
);
public ObservableCollection<ServerLink> Links { get; } = new();
public ObservableCollection<string> Tags { get; } = [];
public ICommand OnLinkGo { get; } = new LinkGoCommand();
public async Task<ServerInfo?> GetServerInfo()
{
if (_serverInfo != null)
return _serverInfo;
try
{
_serverInfo = await restService.GetAsync<ServerInfo>(Address.InfoUri, cancellationService.Token);
}
catch (Exception e)
{
Description = e.Message;
}
return _serverInfo;
}
protected override void InitialiseInDesignMode()
{
IsVisible = true;
RealName = "TEST.TEST";
Description = "Server of meow girls! Nya~ \nNyaMeow\nOOOINK!!";
Links.Add(new ServerLink("Discord", "discord", "https://cinka.ru"));
Status = new ServerStatus("Ameba",
"Locala meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow meow ",
["rp:hrp", "18+"],
"Antag", 15, 5, 1, false
, DateTime.Now, 100);
Address = "ss14://localhost";
}
protected override void Initialise()
{
}
public void ProcessFilter(ServerFilter? serverFilter)
{
if (serverFilter == null)
{
IsVisible = true;
return;
}
IsVisible = serverFilter.IsMatch(Status.Name, Tags);
}
public void SetStatus(ServerStatus serverStatus)
{
Status = serverStatus;
Tags.Clear();
foreach (var tag in Status.Tags) Tags.Add(tag);
OnPropertyChanged(nameof(Status));
}
public ServerEntryViewModel WithData(RobustUrl url, string? name, ServerStatus serverStatus)
{
Address = url;
SetStatus(serverStatus);
Name = name;
return this;
}
public void OpenContentViewer()
{
gameRunnerService.OpenContentViewer(Address);
}
public void ToggleFavorites()
{
IsFavorite = !IsFavorite;
if(IsFavorite)
gameRunnerService.AddFavorite(Address);
else
gameRunnerService.RemoveFavorite(Address);
}
public void RunInstance()
{
Task.Run(async ()=> await gameRunnerService.RunInstanceAsync(this, cancellationService.Token));
}
public void RunInstanceIgnoreAuth()
{
Task.Run(async ()=> await gameRunnerService.RunInstanceAsync(this, cancellationService.Token, true));
}
public void StopInstance()
{
gameRunnerService.StopInstance(Address);
}
public void ReadLog()
{
gameRunnerService.ReadInstanceLog(Address);
}
public void EditName()
{
gameRunnerService.EditName(Address, Name);
}
public async void ExpandInfoRequired()
{
ExpandInfo = !ExpandInfo;
if (Design.IsDesignMode) return;
var info = await GetServerInfo();
if (info == null) return;
Description = info.Desc;
Links.Clear();
if (info.Links is null) return;
foreach (var link in info.Links) Links.Add(link);
}
public void ProcessRunningSignal(bool isRunning)
{
RunVisible = !isRunning;
}
}
public sealed class LinkGoCommand : ICommand
{
public LinkGoCommand()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public bool CanExecute(object? parameter)
{
return true;
}
public void Execute(object? parameter)
{
if (parameter is not string str) return;
Helper.SafeOpenBrowser(str);
}
public event EventHandler? CanExecuteChanged;
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.ObjectModel;
using Avalonia.Collections;
using Nebula.Launcher.ServerListProviders;
using Nebula.Launcher.ViewModels.Pages;
using Nebula.Launcher.Views;
using Nebula.Shared.ViewHelper;
namespace Nebula.Launcher.ViewModels;
[ViewModelRegister(typeof(ServerListView), false)]
public class ServerListViewModel : ViewModelBase
{
public AvaloniaList<IListEntryModelView> ServerList { get; private set; } = new();
public AvaloniaList<Exception> ErrorList { get; private set; } = new();
public IServerListProvider? Provider { get; private set; }
public void ClearProvider()
{
foreach (var serverEntry in ServerList)
{
if (serverEntry is IDisposable disposable)
{
disposable.Dispose();
}
}
ServerList.Clear();
ErrorList.Clear();
GC.Collect();
GC.WaitForPendingFinalizers();
}
public void SetProvider(IServerListProvider provider)
{
Provider = provider;
OnPropertyChanged(nameof(ServerList));
OnPropertyChanged(nameof(ErrorList));
RefreshFromProvider();
}
public void RefreshFromProvider()
{
Provider?.LoadServerList(ServerList, ErrorList);
}
protected override void InitialiseInDesignMode()
{
SetProvider(new TestServerList());
}
protected override void Initialise()
{
}
}

View File

@@ -0,0 +1,22 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Nebula.Launcher.Views;
using Nebula.Shared.ViewHelper;
namespace Nebula.Launcher.ViewModels;
[ViewModelRegister(typeof(VisualErrorView))]
public partial class VisualErrorViewModel : ViewModelBase
{
[ObservableProperty] private string _imgPath = "cinka";
[ObservableProperty] private string _title = "Error";
[ObservableProperty] private string _description = "This is an error.";
protected override void InitialiseInDesignMode()
{
}
protected override void Initialise()
{
}
}

View File

@@ -3,14 +3,14 @@
d:DesignWidth="800"
mc:Ignorable="d"
x:Class="Nebula.Launcher.Views.ExceptionView"
x:DataType="system:Exception"
x:DataType="viewModels:ExceptionCompound"
xmlns="https://github.com/avaloniaui"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:viewModels="clr-namespace:Nebula.Launcher.ViewModels">
<Design.DataContext>
<system:Exception />
<viewModels:ExceptionCompound />
</Design.DataContext>
<Border
BoxShadow="{StaticResource DefaultShadow}"

View File

@@ -2,6 +2,8 @@ using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Nebula.Launcher.Models;
using Nebula.Launcher.ViewModels;
namespace Nebula.Launcher.Views;
@@ -14,6 +16,6 @@ public partial class ExceptionView : UserControl
public ExceptionView(Exception exception): this()
{
DataContext = exception;
DataContext = new ExceptionCompound(exception);
}
}

View File

@@ -77,7 +77,7 @@
</ListBox>
<Button
Classes="ViewSelectButton"
Command="{Binding TriggerPaneCommand}"
Command="{Binding TriggerPane}"
Grid.Row="1"
HorizontalAlignment="Stretch"
Padding="5,0,5,0"
@@ -133,8 +133,9 @@
Path="/Assets/svg/user.svg"
Width="10" />
<Panel>
<TextBlock Foreground="#777777" IsVisible="{Binding IsLoggedIn}" Text="{Binding LoginText}"/>
<TextBlock Foreground="#777777" IsVisible="{Binding !IsLoggedIn}" Text="{services:LocaledText auth-current-login-no-name}"/>
<TextBlock
Foreground="#777777"
Text="{Binding LoginText}"/>
</Panel>
</StackPanel>
</Button>
@@ -177,7 +178,7 @@
<Label Content="{Binding CurrentTitle}" VerticalAlignment="Center" />
</StackPanel>
<Button
Command="{Binding ClosePopupCommand}"
Command="{Binding CloseCurrentPopup}"
Content="X"
CornerRadius="0,10,0,0"
HorizontalAlignment="Right"

View File

@@ -3,9 +3,9 @@
ExtendClientAreaChromeHints="NoChrome"
ExtendClientAreaTitleBarHeightHint="-1"
ExtendClientAreaToDecorationsHint="True"
Height="500"
Height="550"
Icon="/Assets/nebula.ico"
MinHeight="500"
MinHeight="550"
MinWidth="800"
SystemDecorations="BorderOnly"
Title="Nebula.Launcher"

View File

@@ -1,6 +1,6 @@
<UserControl
d:DesignHeight="450"
d:DesignWidth="800"
d:DesignWidth="1000"
mc:Ignorable="d"
x:Class="Nebula.Launcher.Views.Pages.AccountInfoView"
x:DataType="pages:AccountInfoViewModel"
@@ -10,7 +10,8 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:pages="clr-namespace:Nebula.Launcher.ViewModels.Pages"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:auth="clr-namespace:Nebula.Launcher.Models.Auth">
xmlns:auth="clr-namespace:Nebula.Launcher.Models.Auth"
xmlns:converters="clr-namespace:Nebula.Launcher.Converters">
<Design.DataContext>
<pages:AccountInfoViewModel />
</Design.DataContext>
@@ -38,40 +39,58 @@
ItemsSource="{Binding Accounts}"
Padding="0">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type auth:ProfileAuthCredentials}">
<Border
BoxShadow="0 1 15 -2 #121212"
CornerRadius="0,10,0,10"
Margin="5,5,5,0"
VerticalAlignment="Center">
<Border.Background>
<LinearGradientBrush EndPoint="50%,100%" StartPoint="50%,0%">
<GradientStop Color="#292222" Offset="0.0" />
<GradientStop Color="#222222" Offset="1.0" />
</LinearGradientBrush>
</Border.Background>
<Panel>
<StackPanel Margin="10,5,5,5" Orientation="Horizontal">
<DataTemplate DataType="{x:Type auth:ProfileEntry}">
<Grid ColumnDefinitions="4*,*">
<Border
BoxShadow="0 1 15 -2 #121212"
CornerRadius="0,10,0,10"
Margin="5,5,5,0">
<Border.Background>
<LinearGradientBrush EndPoint="100%,50%" StartPoint="20%,50%">
<GradientStop Color="{Binding Credentials.AuthServer,
Converter={x:Static converters:TypeConverters.NameColorRepresentation}}" Offset="0.0" />
<GradientStop Color="#222222" Offset="1.0" />
</LinearGradientBrush>
</Border.Background>
<Label>
<TextBlock Text="{Binding AuthName}" Margin="5"/>
</Label>
</Border>
<Border Grid.Column="0"
CornerRadius="0,10,0,10"
Margin="5,5,5,0">
<Border.Background>
<LinearGradientBrush EndPoint="100%,50%" StartPoint="20%,50%">
<GradientStop Color="#aa222222" Offset="0.0" />
<GradientStop Color="#222222" Offset="0.4" />
</LinearGradientBrush>
</Border.Background>
<Button
HorizontalAlignment="Stretch"
Command="{Binding OnSelect}">
<Label>
<TextBlock Text="{Binding Credentials.Login}" />
<TextBlock Text="{Binding Credentials.Login}" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,5,0"/>
</Label>
</StackPanel>
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
Command="{Binding OnSelect}"
CornerRadius="0,0,0,10"
Padding="5">
<customControls:LocalizedLabel LocalId="account-profile-select"/>
</Button>
<Button
Command="{Binding OnDelete}"
CornerRadius="0,10,0,0"
Padding="5">
<customControls:LocalizedLabel LocalId="account-profile-delete"/>
</Button>
</StackPanel>
</Panel>
</Border>
</Button>
</Border>
<Border
BoxShadow="0 1 15 -2 #121212"
CornerRadius="0,10,0,10"
Margin="0,5,5,0" Grid.Column="1" Padding="0">
<Border.Background>
<LinearGradientBrush EndPoint="100%,50%" StartPoint="20%,50%">
<GradientStop Color="#292222" Offset="1.0" />
<GradientStop Color="#222222" Offset="1.0" />
</LinearGradientBrush>
</Border.Background>
<Button Command="{Binding OnDelete}" CornerRadius="0,10,0,10" HorizontalAlignment="Stretch">
<Svg
Height="15"
Path="/Assets/svg/delete.svg"
Width="15" />
</Button>
</Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@@ -121,7 +140,7 @@
</StackPanel>
<StackPanel Orientation="Horizontal">
<customControls:LocalizedLabel VerticalAlignment="Center" LocalId="account-auth-server"/>
<Button Command="{Binding ExpandAuthUrlCommand}" VerticalAlignment="Stretch">
<Button Command="{Binding OnExpandAuthUrl}" VerticalAlignment="Stretch">
<Label>+</Label>
</Button>
</StackPanel>
@@ -156,7 +175,7 @@
<customControls:LocalizedLabel LocalId="account-auth-button"/>
</Button>
</Border>
<Button Command="{Binding ExpandAuthViewCommand}" HorizontalAlignment="Right">
<Button Command="{Binding OnExpandAuthView}" HorizontalAlignment="Right">
<Label>
>
</Label>
@@ -169,9 +188,15 @@
Margin="0,0,0,20"
Path="/Assets/svg/user.svg" />
<Label>
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal">
<customControls:LocalizedLabel LocalId="account-auth-hello"/>
<TextBlock Text="{Binding CurrentLogin}" />
<StackPanel Spacing="15">
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal" Spacing="5">
<customControls:LocalizedLabel LocalId="account-auth-hello"/>
<TextBlock Text="{Binding Credentials.Value.Login}" />
</StackPanel>
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal" Spacing="5">
<customControls:LocalizedLabel LocalId="account-auth-current-server"/>
<TextBlock Text="{Binding AuthServerName}" />
</StackPanel>
</StackPanel>
</Label>
<StackPanel
@@ -184,8 +209,10 @@
<customControls:LocalizedLabel LocalId="account-auth-logout"/>
</Button>
</Border>
<Border BoxShadow="{StaticResource DefaultShadow}">
<Button Command="{Binding SaveProfileCommand}">
<Border BoxShadow="{StaticResource DefaultShadow}"
IsVisible="{Binding CurrentPassword,
Converter={x:Static converters:TypeConverters.StringIsNotEmpty}}">
<Button Command="{Binding OnSaveProfile}">
<customControls:LocalizedLabel LocalId="account-auth-save"/>
</Button>
</Border>

View File

@@ -42,9 +42,10 @@
</ListBox>
<Border
Child="{Binding CurrentServerList}"
Grid.Row="1"
Grid.RowSpan="2" />
Grid.RowSpan="2" >
<ContentControl Content="{Binding CurrentServerList}"></ContentControl>
</Border>
<Border Grid.Row="1"
Background="{StaticResource DefaultGrad}"

View File

@@ -10,33 +10,71 @@
<Design.DataContext>
<popup:LoadingContextViewModel />
</Design.DataContext>
<StackPanel Margin="25" Spacing="15">
<ProgressBar Height="40" Maximum="{Binding CurrJobs}" Value="{Binding ResolvedJobs}" />
<Panel>
<StackPanel Orientation="Horizontal" Spacing="5" HorizontalAlignment="Left" VerticalAlignment="Center">
<Label>
<TextBlock Text="{Binding ResolvedJobs}" />
<StackPanel Margin="25" Spacing="15" >
<Panel Margin="5">
<Border Padding="15" Background="{StaticResource DefaultGrad}" BoxShadow="0 1 1 0 #121212">
<Label VerticalAlignment="Center">
<TextBlock Text="{Binding LoadingName}"/>
</Label>
<Label>
/
</Label>
<Label>
<TextBlock Text="{Binding CurrJobs}" />
</Label>
</StackPanel>
</Border>
<Button
HorizontalAlignment="Right"
VerticalAlignment="Center"
Command="{Binding Cancel}"
IsVisible="{Binding IsCancellable}">
<customControls:LocalizedLabel LocalId="task-cancel"/>
<Border Padding="15" Background="{StaticResource DefaultGrad}" BoxShadow="0 1 1 0 #121212">
<customControls:LocalizedLabel LocalId="task-cancel"/>
</Border>
</Button>
</Panel>
<Panel>
<Border Background="{StaticResource DefaultForeground}" MinHeight="210">
<TextBlock TextWrapping="Wrap" Text="{Binding Message}" MaxLines="10" Margin="15"/>
</Border>
</Panel>
<Grid ColumnDefinitions="*,*">
<ScrollViewer Grid.Column="0" Grid.ColumnSpan="{Binding LoadingColumnSize}">
<ItemsControl
Background="#00000000"
ItemsSource="{Binding LoadingContexts}"
Padding="0" >
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" Spacing="5" Margin="5" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type popup:LoadingContext}">
<Border Background="{StaticResource DefaultGrad}" BoxShadow="0 1 1 0 #121212">
<StackPanel Margin="15">
<ProgressBar Height="40" Maximum="{Binding CurrJobs}" Value="{Binding ResolvedJobs}" />
<Panel Margin="5 15 5 5">
<Label HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding LoadingText}" />
</Label>
<Label HorizontalAlignment="Right" VerticalAlignment="Center">
<TextBlock Text="{Binding Message}" />
</Label>
</Panel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<StackPanel
Grid.Column="1"
Margin="10" Spacing="15"
IsVisible="{Binding ShowSpeed}">
<customControls:SimpleGraph Values="{Binding Values}"
Height="167"
GridBrush="{StaticResource DefaultForeground}"/>
<Border Background="{StaticResource DefaultGrad}" BoxShadow="0 1 1 0 #121212">
<Panel Margin="10">
<Label>Speed</Label>
<Label HorizontalAlignment="Right">
<TextBlock Text="{Binding SpeedText}" />
</Label>
</Panel>
</Border>
</StackPanel>
</Grid>
</StackPanel>
</UserControl>

View File

@@ -70,9 +70,9 @@
</Grid>
</Border>
<Panel IsVisible="{Binding !Loading}">
<views:ServerEntryView IsVisible="{Binding !Loading}" DataContext="{Binding CurrentEntry}"/>
</Panel>
<ContentControl
IsVisible="{Binding !Loading}"
Content="{Binding CurrentEntry}"/>
</Panel>
</UserControl>

View File

@@ -3,7 +3,7 @@
d:DesignWidth="800"
mc:Ignorable="d"
x:Class="Nebula.Launcher.Views.ServerEntryView"
x:DataType="viewModels:ServerEntryModelView"
x:DataType="viewModels:ServerEntryViewModel"
xmlns="https://github.com/avaloniaui"
xmlns:converters="clr-namespace:Nebula.Launcher.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@@ -16,7 +16,7 @@
xmlns:services="clr-namespace:Nebula.Launcher.Services"
IsVisible="{Binding IsVisible}">
<Design.DataContext>
<viewModels:ServerEntryModelView />
<viewModels:ServerEntryViewModel />
</Design.DataContext>
<Border
@@ -213,7 +213,7 @@
BoxShadow="0 0 13 -1 #121212"
CornerRadius="10">
<Button
Command="{Binding $parent[views:ServerEntryView].((viewModels:ServerEntryModelView)DataContext).OnLinkGo}"
Command="{Binding $parent[views:ServerEntryView].((viewModels:ServerEntryViewModel)DataContext).OnLinkGo}"
CommandParameter="{Binding Url}"
Margin="3">
<StackPanel Orientation="Horizontal" Spacing="5">

View File

@@ -2,19 +2,22 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels1="clr-namespace:Nebula.Launcher.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Nebula.Launcher.Controls.ServerListView">
x:Class="Nebula.Launcher.Views.ServerListView"
x:DataType="viewModels1:ServerListViewModel">
<Design.DataContext>
<viewModels1:ServerListViewModel />
</Design.DataContext>
<ScrollViewer
Margin="5,0,0,10"
Padding="0,0,10,0">
<StackPanel Margin="0,0,0,30">
<Label x:Name="LoadingLabel" Margin="10" HorizontalAlignment="Center">Loading... Please wait</Label>
<ItemsControl
x:Name="ErrorList"
Margin="10,0,10,0" />
<ItemsControl
x:Name="ServerList"
Padding="0" />
<ItemsControl ItemsSource="{Binding ErrorList}"
Margin="10,0,10,0" />
<ItemsControl ItemsSource="{Binding ServerList}"
Padding="0" />
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,16 @@
using System;
using Avalonia.Controls;
using Nebula.Launcher.Models;
using Nebula.Launcher.ServerListProviders;
using Nebula.Launcher.ViewModels;
using Nebula.Launcher.ViewModels.Pages;
namespace Nebula.Launcher.Views;
public partial class ServerListView : UserControl
{
public ServerListView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,39 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Nebula.Launcher.ViewModels"
xmlns:converters="clr-namespace:Nebula.Launcher.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="viewModels:VisualErrorViewModel"
x:Class="Nebula.Launcher.Views.VisualErrorView">
<Design.DataContext>
<viewModels:VisualErrorViewModel />
</Design.DataContext>
<Grid RowDefinitions="30,*" ColumnDefinitions="200,*">
<Border Grid.Row="1" Grid.Column="0"
CornerRadius="10,0,0,10"
BorderThickness="0,0,2,0"
BorderBrush="{StaticResource DefaultForeground}">
<Image Source="{Binding ImgPath, Converter={x:Static converters:TypeConverters.ImageConverter}}" Width="200" Height="200"/>
</Border>
<Border Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" CornerRadius="10,10,0,0">
<Border.Background>
<LinearGradientBrush EndPoint="100%,50%" StartPoint="10%,20%">
<GradientStop Color="#FF6B6B" Offset="0.0" />
<GradientStop Color="#FF8E53" Offset="0.3" />
<GradientStop Color="#FF5E3A" Offset="0.6" />
<GradientStop Color="#FF5e5e" Offset="1.0" />
</LinearGradientBrush>
</Border.Background>
<Label HorizontalAlignment="Center"><TextBlock Text="{Binding Title}"/></Label>
</Border>
<TextBlock
Grid.Row="1"
Grid.Column="1"
Margin="15"
HorizontalAlignment="Center"
TextWrapping="Wrap"
Text="{Binding Description}"/>
</Grid>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Nebula.Launcher.Views;
public partial class VisualErrorView : UserControl
{
public VisualErrorView()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,37 @@
namespace Nebula.Packager;
public class CommandLineParser
{
public string Configuration { get; set; } = "Release";
public string RootPath { get; set; } = string.Empty;
public static CommandLineParser Parse(IReadOnlyList<string> args)
{
using var enumerator = args.GetEnumerator();
var parsed = new CommandLineParser();
while (enumerator.MoveNext())
{
var arg = enumerator.Current;
if (arg == "--configuration")
{
if (!enumerator.MoveNext())
throw new InvalidOperationException("Missing args for --configuration");
parsed.Configuration = enumerator.Current;
}
if (arg == "--root-path")
{
if(!enumerator.MoveNext())
throw new InvalidOperationException("Missing args for --root-path");
parsed.RootPath = enumerator.Current;
}
}
return parsed;
}
}

View File

@@ -1,10 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Nebula.SharedModels\Nebula.SharedModels.csproj" />
<ProjectReference Include="..\Nebula.Shared\Nebula.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection"/>
</ItemGroup>
</Project>

View File

@@ -2,24 +2,36 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Nebula.Shared;
using Nebula.SharedModels;
namespace Nebula.Packager;
public static class Program
{
public static void Main(string[] args)
{
Pack("","Release");
var parsedArgs = CommandLineParser.Parse(args);
Pack(parsedArgs.RootPath, parsedArgs.Configuration);
}
private static void Pack(string rootPath,string configuration)
private static string ShowEmptyOrValue(string? value)
{
if(string.IsNullOrWhiteSpace(value)) return "<empty>";
return value;
}
private static void Pack(string rootPath, string configuration)
{
Console.WriteLine($"Packaging with arguments: RootPath {ShowEmptyOrValue(rootPath)} and Configuration {configuration}");
var processInfo = new ProcessStartInfo
{
FileName = "dotnet",
ArgumentList =
{
"publish",
Path.Combine(rootPath,"Nebula.Launcher", "Nebula.Launcher.csproj"),
Path.Combine(rootPath, "Nebula.Launcher", "Nebula.Launcher.csproj"),
"-c", configuration,
}
};
@@ -57,18 +69,14 @@ public static class Program
entries.Add(new LauncherManifestEntry(hashStr, fileNameCut));
Console.WriteLine($"Added {hashStr} file name {fileNameCut}");
}
var manifestRuntimeInfo = new LauncherRuntimeInfo(
CurrentConVar.DotnetVersion.DefaultValue!,
CurrentConVar.DotnetUrl.DefaultValue!
);
using var manifest = File.CreateText(Path.Combine(destinationDirectory, "manifest.json"));
manifest.AutoFlush = true;
manifest.Write(JsonSerializer.Serialize(new LauncherManifest(entries)));
manifest.Write(JsonSerializer.Serialize(new LauncherManifest(entries, manifestRuntimeInfo)));
}
}
public record struct LauncherManifest(
[property: JsonPropertyName("entries")] HashSet<LauncherManifestEntry> Entries
);
public record struct LauncherManifestEntry(
[property: JsonPropertyName("hash")] string Hash,
[property: JsonPropertyName("path")] string Path
);

View File

@@ -1,18 +1,21 @@
using Nebula.Runner.Services;
using Nebula.Shared;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
using Nebula.Shared.Utils;
using Robust.LoaderApi;
namespace Nebula.Runner;
[ServiceRegister]
public sealed class App(RunnerService runnerService, ContentService contentService)
public sealed class App(RunnerService runnerService, ContentService contentService, DebugService debugService)
: IRedialApi
{
public ILogger logger = debugService.GetLogger("Runner");
public void Redial(Uri uri, string text = "")
{
throw new Exception($"Redial requested. Reason: {text}");
}
public async Task Run(string[] args1)
@@ -21,29 +24,37 @@ public sealed class App(RunnerService runnerService, ContentService contentServi
var urlraw = Environment.GetEnvironmentVariable("GAME_URL") ?? "ss14://localhost";
var url = urlraw.ToRobustUrl();
using var cancelTokenSource = new CancellationTokenSource();
var buildInfo = await contentService.GetBuildInfo(url, cancelTokenSource.Token);
var args = new List<string>
{
"--username", login,
"--cvar", "launch.launcher=true"
};
var connectionString = url.ToString();
if (!string.IsNullOrEmpty(buildInfo.BuildInfo.ConnectAddress))
connectionString = buildInfo.BuildInfo.ConnectAddress;
args.Add("--launcher");
try
{
using var cancelTokenSource = new CancellationTokenSource();
var buildInfo = await contentService.GetBuildInfo(url, cancelTokenSource.Token);
args.Add("--connect-address");
args.Add(connectionString);
args.Add("--ss14-address");
args.Add(url.ToString());
var args = new List<string>
{
"--username", login,
"--cvar", "launch.launcher=true"
};
await runnerService.Run(args.ToArray(), buildInfo, this, new ConsoleLoadingHandler(), cancelTokenSource.Token);
var connectionString = url.ToString();
if (!string.IsNullOrEmpty(buildInfo.BuildInfo.ConnectAddress))
connectionString = buildInfo.BuildInfo.ConnectAddress;
args.Add("--launcher");
args.Add("--connect-address");
args.Add(connectionString);
args.Add("--ss14-address");
args.Add(url.ToString());
await runnerService.Run(args.ToArray(), buildInfo, this, new ConsoleLoadingHandlerFactory(), login, cancelTokenSource.Token);
}
catch (Exception e)
{
logger.Error(e);
throw;
}
}
}

View File

@@ -1,8 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -12,8 +10,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Lib.Harmony" Version="2.3.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0"/>
<PackageReference Include="SharpZstd.Interop" Version="1.5.6"/>
<PackageReference Include="Lib.Harmony"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection"/>
<PackageReference Include="SharpZstd.Interop"/>
</ItemGroup>
</Project>

View File

@@ -1,6 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Nebula.Shared;
using Nebula.Shared.Services;
namespace Nebula.Runner;

View File

@@ -29,20 +29,37 @@ public class HarmonyService(ReflectionService reflectionService)
}
/// <summary>
/// Я помню пенис большой,Я помню пенис большой, Я помню пенис большой, я помню....
/// Я не понимаю суть античитов в сосаке.
/// Эту хуйню может обойти любой школьник!
/// Нет.. я не хочу вводить читы, просто мне нужно поменять некоторые штучки :)
/// </summary>
private void UnShittyWizard()
{
var method = reflectionService.GetType("Robust.Client.GameController").TypeInitializer;
_instance!.Harmony.Patch(method, new HarmonyMethod(Prefix));
_instance!.Harmony.Patch(method, new HarmonyMethod(IgnorePrefix));
var method2 = typeof(Type).Method(nameof(Type.GetType), new[] { typeof(string) });
_instance!.Harmony.Patch(method2, new HarmonyMethod(HidifyPrefix));
}
static bool Prefix()
static bool IgnorePrefix()
{
return false;
}
static bool HidifyPrefix(ref Type? __result, string typeName)
{
if (typeName.Contains("Harmony"))
{
__result = null;
return false;
}
return true;
}
}
public class HarmonyInstance
{
public readonly Harmony Harmony;

View File

@@ -6,6 +6,8 @@ using Nebula.Shared;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Services.Logging;
using Nebula.Shared.Utils;
using Nebula.SharedModels;
using Robust.LoaderApi;
namespace Nebula.Runner.Services;
@@ -24,31 +26,31 @@ public sealed class RunnerService(
private bool MetricEnabled = false; //TODO: ADD METRIC THINKS LATER
public async Task Run(string[] runArgs, RobustBuildInfo buildInfo, IRedialApi redialApi,
ILoadingHandler loadingHandler,
CancellationToken cancellationToken)
ILoadingHandlerFactory loadingHandler, string? userDataPath = null,
CancellationToken cancellationToken = default)
{
_logger.Log("Start Content!");
var engine = await engineService.EnsureEngine(buildInfo.BuildInfo.Build.EngineVersion);
var engine = await engineService.EnsureEngine(buildInfo.BuildInfo.Build.EngineVersion, loadingHandler, cancellationToken);
if (engine is null)
throw new Exception("Engine version not found: " + buildInfo.BuildInfo.Build.EngineVersion);
var hashApi = await contentService.EnsureItems(buildInfo.RobustManifestInfo, loadingHandler, cancellationToken);
var fileApi = await contentService.EnsureItems(buildInfo, loadingHandler, cancellationToken);
var extraMounts = new List<ApiMount>
{
new(hashApi, "/")
new(fileApi, "/")
};
if (hashApi.TryOpen("manifest.yml", out var stream))
if (fileApi.TryOpen("manifest.yml", out var stream))
{
var modules = ContentManifestParser.ExtractModules(stream);
foreach (var moduleStr in modules)
{
var module =
await engineService.EnsureEngineModules(moduleStr, buildInfo.BuildInfo.Build.EngineVersion);
await engineService.EnsureEngineModules(moduleStr, loadingHandler, buildInfo.BuildInfo.Build.EngineVersion);
if (module is not null)
extraMounts.Add(new ApiMount(module, "/"));
}
@@ -78,8 +80,14 @@ public sealed class RunnerService(
MetricsEnabledPatcher.ApplyPatch(reflectionService, harmonyService);
metricServer = RunHelper.RunMetric(prometheusAssembly);
}
if (userDataPath is not null)
{
UserDataDirPatcher.UserPath = userDataPath;
UserDataDirPatcher.ApplyPatch(reflectionService, harmonyService);
}
loadingHandler.Dispose();
await Task.Run(() => loader.Main(args), cancellationToken);
metricServer?.Dispose();
@@ -93,7 +101,8 @@ public static class MetricsEnabledPatcher
var harmony = harmonyService.Instance.Harmony;
var targetType = reflectionService.GetType("Robust.Shared.GameObjects.EntitySystemManager");
var targetMethod = targetType.GetProperty("MetricsEnabled").GetGetMethod();
var targetMethod = targetType.GetProperty("MetricsEnabled")?.GetGetMethod() ??
throw new Exception("target method is null.. huh.. do we have patch a right think?");
var prefix = typeof(MetricsEnabledPatcher).GetMethod(nameof(MetricsEnabledGetterPrefix),
BindingFlags.Static | BindingFlags.NonPublic);
@@ -110,6 +119,38 @@ public static class MetricsEnabledPatcher
}
}
public static class UserDataDirPatcher
{
public static string UserPath = "default";
public static void ApplyPatch(ReflectionService reflectionService, HarmonyService harmonyService)
{
var harmony = harmonyService.Instance.Harmony;
var targetType = reflectionService.GetType("Robust.Client.Utility.UserDataDir");
var targetMethod = targetType.GetMethod(
"GetRootUserDataDir",
BindingFlags.Static | BindingFlags.Public
) ?? throw new Exception("target method is null");
var prefix = typeof(UserDataDirPatcher).GetMethod(
nameof(GetRootUserDataDirPrefix),
BindingFlags.Static | BindingFlags.NonPublic
);
var prefixMethod = new HarmonyMethod(prefix);
harmony.Patch(targetMethod, prefix: prefixMethod);
}
private static bool GetRootUserDataDirPrefix(ref string __result)
{
__result = Path.Join(AppDataPath.RootPath, "userData", UserPath);
return false;
}
}
public static class RunHelper
{
public static IDisposable RunMetric(Assembly prometheusAssembly)
@@ -139,44 +180,3 @@ public static class RunHelper
}
}
public static class ContentManifestParser
{
public static List<string> ExtractModules(Stream manifestStream)
{
using var reader = new StreamReader(manifestStream);
return ExtractModules(reader.ReadToEnd());
}
public static List<string> ExtractModules(string manifestContent)
{
var modules = new List<string>();
var lines = manifestContent.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
bool inModulesSection = false;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (line.StartsWith("modules:"))
{
inModulesSection = true;
continue;
}
if (inModulesSection)
{
if (line.StartsWith("- "))
{
modules.Add(line.Substring(2).Trim());
}
else if (!line.StartsWith(" "))
{
break;
}
}
}
return modules;
}
}

View File

@@ -0,0 +1,47 @@
using Microsoft.Extensions.DependencyInjection;
using Nebula.Shared.Configurations.Migrations;
using Nebula.Shared.Models;
using Nebula.Shared.Services;
using Nebula.Shared.Utils;
namespace Nebula.Shared.ConfigMigrations;
public class ProfileMigrationV2(string oldName, string newName)
: BaseConfigurationMigration<ProfileAuthCredentials[], string[]>(oldName, newName)
{
protected override async Task<string[]> Migrate(IServiceProvider serviceProvider, ProfileAuthCredentials[] oldValue, ILoadingHandler loadingHandler)
{
loadingHandler.SetLoadingMessage("Migrating Profile V2 -> V4");
var list = new List<string>();
var authService = serviceProvider.GetRequiredService<AuthService>();
var logger = serviceProvider.GetRequiredService<DebugService>().GetLogger("ProfileMigrationV2");
foreach (var oldCredentials in oldValue)
{
try
{
loadingHandler.SetLoadingMessage($"Migrating {oldCredentials.Login}");
await authService.Auth(oldCredentials.Login, oldCredentials.Password, oldCredentials.AuthServer);
list.Add(CryptographicStore.Encrypt(oldCredentials, CryptographicStore.GetComputerKey()));
}
catch (Exception e)
{
logger.Error(e);
loadingHandler.SetLoadingMessage(e.Message);
}
}
loadingHandler.SetLoadingMessage("Migration done!");
return list.ToArray();
}
}
public class ProfileMigrationV3V4(string oldName, string newName)
: BaseConfigurationMigration<AuthTokenCredentials[], string[]>(oldName, newName)
{
protected override Task<string[]> Migrate(IServiceProvider serviceProvider, AuthTokenCredentials[] oldValue, ILoadingHandler loadingHandler)
{
Console.WriteLine("Removing profile v3 because no password is provided");
return Task.FromResult(Array.Empty<string>());
}
}

View File

@@ -0,0 +1,18 @@
using Nebula.Shared.Services;
namespace Nebula.Shared.Configurations;
public class ConVar<T>
{
internal ConfigurationService.OnConfigurationChangedDelegate<T?>? OnValueChanged;
public ConVar(string name, T? defaultValue = default)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
DefaultValue = defaultValue;
}
public string Name { get; }
public Type Type => typeof(T);
public T? DefaultValue { get; }
}

View File

@@ -0,0 +1,25 @@
using Nebula.Shared.Configurations.Migrations;
using Nebula.Shared.Services;
namespace Nebula.Shared.Configurations;
public static class ConVarBuilder
{
public static ConVar<T> Build<T>(string name, T? defaultValue = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("ConVar name cannot be null or whitespace.", nameof(name));
return new ConVar<T>(name, defaultValue);
}
public static ConVar<T> BuildWithMigration<T>(string name, IConfigurationMigration migration, T? defaultValue = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("ConVar name cannot be null or whitespace.", nameof(name));
ConfigurationService.AddConfigurationMigration(migration);
return new ConVar<T>(name, defaultValue);
}
}

View File

@@ -0,0 +1,67 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Nebula.Shared.Services;
namespace Nebula.Shared.Configurations;
public sealed class ConVarObserver<T> : IDisposable, INotifyPropertyChanged, INotifyPropertyChanging
{
private readonly ConVar<T> _convar;
private readonly ConfigurationService _configurationService;
private T? _value;
private ConfigurationService.OnConfigurationChangedDelegate<T> _delegate;
public bool HasValue => Value != null;
public T? Value
{
get => _value;
set => _configurationService.SetConfigValue(_convar, value);
}
public ConVarObserver(ConVar<T> convar, ConfigurationService configurationService)
{
_convar = convar;
_convar.OnValueChanged += OnValueChanged;
_configurationService = configurationService;
_delegate += OnValueChanged;
OnValueChanged(configurationService.GetConfigValue(_convar));
}
private void OnValueChanged(T? value)
{
OnPropertyChanging(nameof(Value));
OnPropertyChanging(nameof(HasValue));
if(value is null && _value is null)
return;
if (_value is not null && _value.Equals(value))
return;
_value = value;
OnPropertyChanged(nameof(Value));
OnPropertyChanged(nameof(HasValue));
}
public void Dispose()
{
_convar.OnValueChanged -= OnValueChanged;
}
public static implicit operator T? (ConVarObserver<T> convar) => convar.Value;
public event PropertyChangingEventHandler? PropertyChanging;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void OnPropertyChanging([CallerMemberName] string? propertyName = null)
{
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,28 @@
using Nebula.Shared.Models;
using Nebula.Shared.Services;
namespace Nebula.Shared.Configurations.Migrations;
public abstract class BaseConfigurationMigration<T1,T2> : IConfigurationMigration
{
protected ConVar<T1> OldConVar;
protected ConVar<T2> NewConVar;
public BaseConfigurationMigration(string oldName, string newName)
{
OldConVar = ConVarBuilder.Build<T1>(oldName);
NewConVar = ConVarBuilder.Build<T2>(newName);
}
public async Task DoMigrate(ConfigurationService configurationService, IServiceProvider serviceProvider, ILoadingHandler loadingHandler)
{
var oldValue = configurationService.GetConfigValue(OldConVar);
if(oldValue == null) return;
var newValue = await Migrate(serviceProvider, oldValue, loadingHandler);
configurationService.SetConfigValue(NewConVar, newValue);
configurationService.ClearConfigValue(OldConVar);
}
protected abstract Task<T2> Migrate(IServiceProvider serviceProvider, T1 oldValue, ILoadingHandler loadingHandler);
}

View File

@@ -0,0 +1,9 @@
using Nebula.Shared.Models;
using Nebula.Shared.Services;
namespace Nebula.Shared.Configurations.Migrations;
public interface IConfigurationMigration
{
public Task DoMigrate(ConfigurationService configurationService, IServiceProvider serviceProvider, ILoadingHandler loadingHandler);
}

View File

@@ -0,0 +1,15 @@
using Nebula.Shared.Models;
using Nebula.Shared.Services;
namespace Nebula.Shared.Configurations.Migrations;
public class MigrationQueue(List<IConfigurationMigration> migrations) : IConfigurationMigration
{
public async Task DoMigrate(ConfigurationService configurationService, IServiceProvider serviceProvider , ILoadingHandler loadingHandler)
{
foreach (var migration in migrations)
{
await migration.DoMigrate(configurationService, serviceProvider, loadingHandler);
}
}
}

View File

@@ -0,0 +1,19 @@
namespace Nebula.Shared.Configurations.Migrations;
public class MigrationQueueBuilder
{
public static MigrationQueueBuilder Instance => new();
private readonly List<IConfigurationMigration> _migrations = [];
public MigrationQueueBuilder With(IConfigurationMigration migration)
{
_migrations.Add(migration);
return this;
}
public MigrationQueue Build()
{
return new MigrationQueue(_migrations);
}
}

Some files were not shown because too many files have changed in this diff Show More