using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Messaging;
using HanumanInstitute.MvvmDialogs;
using Nitrox.Launcher.Models.Design;
using Nitrox.Launcher.Models.Utils;
using Nitrox.Launcher.ViewModels;
using NitroxModel.Helper;
using NitroxModel.Logger;
using NitroxModel.Server;
namespace Nitrox.Launcher.Models.Services;
///
/// Keeps track of server instances.
///
public class ServerService : IMessageReceiver, INotifyPropertyChanged
{
private readonly IDialogService dialogService;
private readonly IKeyValueStore keyValueStore;
private readonly IRoutingScreen screen;
private List servers = [];
private readonly Lock serversLock = new();
private bool shouldRefreshServersList;
private FileSystemWatcher watcher;
private readonly CancellationTokenSource serverRefreshCts = new();
private readonly HashSet loggedErrorDirectories = [];
private volatile bool hasUpdatedAtLeastOnce;
public ServerService(IDialogService dialogService, IKeyValueStore keyValueStore, IRoutingScreen screen)
{
this.dialogService = dialogService;
this.keyValueStore = keyValueStore;
this.screen = screen;
_ = LoadServersAsync().ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message));
this.RegisterMessageListener(static (message, receiver) =>
{
lock (receiver.serversLock)
{
bool changes = false;
for (int i = receiver.servers.Count - 1; i >= 0; i--)
{
if (receiver.servers[i].Name == message.SaveName)
{
receiver.servers.RemoveAt(i);
changes = true;
}
}
if (changes)
{
receiver.SetField(ref receiver.servers, receiver.servers);
}
}
});
}
private async Task LoadServersAsync()
{
await GetSavesOnDiskAsync();
_ = WatchServersAsync(serverRefreshCts.Token).ContinueWithHandleError(ex => LauncherNotifier.Error(ex.Message));
}
public async Task StartServerAsync(ServerEntry server)
{
// TODO: Exclude upgradeable versions + add separate prompt to upgrade first?
if (server.Version != NitroxEnvironment.Version && !await ConfirmServerVersionAsync(server))
{
return false;
}
if (await GameInspect.IsOutdatedGameAndNotify(NitroxUser.GamePath, dialogService))
{
return false;
}
try
{
server.Version = NitroxEnvironment.Version;
server.Start(keyValueStore.GetSavesFolderDir());
if (server.IsEmbedded)
{
await screen.ShowAsync(new EmbeddedServerViewModel(server));
}
return true;
}
catch (Exception ex)
{
Log.Error(ex, $"Error while starting server \"{server.Name}\"");
await Dispatcher.UIThread.InvokeAsync(async () => await dialogService.ShowErrorAsync(ex, $"Error while starting server \"{server.Name}\""));
return false;
}
}
public async Task ConfirmServerVersionAsync(ServerEntry server) =>
await dialogService.ShowAsync(model =>
{
model.Title = $"The version of '{server.Name}' is v{(server.Version != null ? server.Version.ToString() : "X.X.X.X")}. It is highly recommended to NOT use this save file with Nitrox v{NitroxEnvironment.Version}. Would you still like to continue?";
model.ButtonOptions = ButtonOptions.YesNo;
});
private async Task GetSavesOnDiskAsync(CancellationToken cancellationToken = default)
{
try
{
Directory.CreateDirectory(keyValueStore.GetSavesFolderDir());
Dictionary serversOnDisk = Servers.ToDictionary(entry => entry.Name, entry => (entry, false));
foreach (string saveDir in Directory.EnumerateDirectories(keyValueStore.GetSavesFolderDir()))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (serversOnDisk.TryGetValue(Path.GetFileName(saveDir), out (ServerEntry Data, bool _) server))
{
// This server has files, so don't filter it away from server list.
serversOnDisk[Path.GetFileName(saveDir)] = (server.Data, true);
continue;
}
ServerEntry entryFromDir = await Task.Run(() => ServerEntry.FromDirectory(saveDir), cancellationToken);
if (entryFromDir != null)
{
serversOnDisk.Add(entryFromDir.Name, (entryFromDir, true));
}
loggedErrorDirectories.Remove(saveDir);
}
catch (Exception ex)
{
if (loggedErrorDirectories.Add(saveDir)) // Only log once per directory to prevent log spam
{
Log.Error(ex, $"Error while initializing save from directory \"{saveDir}\". Skipping...");
}
}
}
lock (serversLock)
{
Servers = [..serversOnDisk.Values.Where(server => server.HasFiles).Select(server => server.Data).OrderByDescending(entry => entry.LastAccessedTime)];
hasUpdatedAtLeastOnce = true;
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
Log.Error(ex, "Error while getting saves");
await dialogService.ShowErrorAsync(ex, "Error while getting saves");
}
}
private async Task WatchServersAsync(CancellationToken cancellationToken = default)
{
watcher = new FileSystemWatcher
{
Path = keyValueStore.GetSavesFolderDir(),
NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.LastWrite | NotifyFilters.Size,
Filter = "*.*",
IncludeSubdirectories = true
};
watcher.Changed += OnDirectoryChanged;
watcher.Created += OnDirectoryChanged;
watcher.Deleted += OnDirectoryChanged;
watcher.Renamed += OnDirectoryChanged;
try
{
await Task.Run(async () =>
{
watcher.EnableRaisingEvents = true; // Slowish (~2ms) - Moved into Task.Run.
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
while (shouldRefreshServersList)
{
try
{
await GetSavesOnDiskAsync(cancellationToken);
shouldRefreshServersList = false;
}
catch (IOException)
{
await Task.Delay(100, cancellationToken);
}
}
await Task.Delay(1000, cancellationToken);
}
}, cancellationToken);
}
catch (OperationCanceledException)
{
// ignored
}
}
private void OnDirectoryChanged(object sender, FileSystemEventArgs e)
{
shouldRefreshServersList = true;
}
public void Dispose()
{
serverRefreshCts.Cancel();
serverRefreshCts.Dispose();
WeakReferenceMessenger.Default.UnregisterAll(this);
watcher?.Dispose();
}
public ServerEntry[] Servers
{
get
{
lock (serversLock)
{
return [..servers];
}
}
private set
{
lock (serversLock)
{
SetField(ref servers, [..value]);
}
}
}
///
/// Gets the servers or waits for servers to be loaded from file system.
///
public async Task GetServersAsync()
{
while (true)
{
lock (serversLock)
{
if (hasUpdatedAtLeastOnce)
{
return Servers;
}
}
await Task.Delay(100);
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetField(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
public async Task GetOrCreateServerAsync(string saveName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(saveName);
string serverPath = Path.Combine(keyValueStore.GetSavesFolderDir(), saveName);
return (await GetServersAsync()).FirstOrDefault(s => s.Name == saveName) ?? ServerEntry.FromDirectory(serverPath) ?? ServerEntry.CreateNew(serverPath, NitroxGameMode.SURVIVAL);
}
}