Detect disconnection from server, automatically go into offline mode

This commit is contained in:
Pat Hartl 2025-01-18 20:25:39 -06:00
parent 38e95c9102
commit cf7e81ba54
16 changed files with 844 additions and 437 deletions

View file

@ -0,0 +1,7 @@
namespace LANCommander.Launcher.Models;
public class ConnectionState
{
public bool IsConnected { get; set; }
public bool OfflineModeEnabled { get; set; }
}

View file

@ -9,11 +9,24 @@ namespace LANCommander.Launcher.Services;
public class AuthenticationService : BaseService
{
private readonly Settings Settings = SettingService.GetSettings();
private readonly ProfileService ProfileService;
private Settings Settings;
public event EventHandler OnLogin;
public event EventHandler OnLogout;
public event EventHandler OnRegister;
public delegate void OnOfflineModeChangedHandler(bool state);
public event OnOfflineModeChangedHandler OnOfflineModeChanged;
public AuthenticationService(
Client client,
ILogger<AuthenticationService> logger) : base(client, logger)
ILogger<AuthenticationService> logger,
ProfileService profileService) : base(client, logger)
{
Settings = SettingService.GetSettings();
ProfileService = profileService;
}
public bool IsConnected()
@ -32,6 +45,109 @@ public class AuthenticationService : BaseService
return false;
}
}
public async Task Login()
{
await Login(Settings.Authentication.ServerAddress, new SDK.Models.AuthToken
{
AccessToken = Settings.Authentication.AccessToken,
RefreshToken = Settings.Authentication.RefreshToken,
});
}
public async Task Login(string serverAddress, string username, string password)
{
Client.ChangeServerAddress(serverAddress);
var token = await Client.AuthenticateAsync(username, password);
await Login(serverAddress, token);
}
public async Task Login(string serverAddress, SDK.Models.AuthToken token)
{
Client.ChangeServerAddress(serverAddress);
Settings = SettingService.GetSettings();
Settings.Authentication.ServerAddress = serverAddress;
Settings.Authentication.AccessToken = token.AccessToken;
Settings.Authentication.RefreshToken = token.RefreshToken;
Client.UseToken(token);
if (await Client.ValidateTokenAsync())
{
SetOfflineMode(false);
SettingService.SaveSettings(Settings);
OnLogin?.Invoke(this, EventArgs.Empty);
await ProfileService.DownloadProfileInfoAsync();
}
}
public async Task Register(string serverAddress, string username, string password, string passwordConfirmation)
{
if (String.IsNullOrWhiteSpace(serverAddress))
throw new Exception("Server address cannot be blank");
if (String.IsNullOrWhiteSpace(username))
throw new Exception("Username cannot be blank");
if (String.IsNullOrWhiteSpace(password))
throw new Exception("Password cannot be blank");
if (password != passwordConfirmation)
throw new Exception("Passwords do not match");
Client.ChangeServerAddress(serverAddress);
var token = await Client.RegisterAsync(username, password, passwordConfirmation);
Client.UseToken(token);
Settings = SettingService.GetSettings();
Settings.Authentication.ServerAddress = serverAddress;
Settings.Authentication.AccessToken = token.AccessToken;
Settings.Authentication.RefreshToken = token.RefreshToken;
SettingService.SaveSettings(Settings);
OnRegister?.Invoke(this, EventArgs.Empty);
await ProfileService.DownloadProfileInfoAsync();
}
public void SetOfflineMode(bool state)
{
Settings = SettingService.GetSettings();
Settings.Authentication.OfflineMode = state;
if (!state)
Client.Disconnect();
SettingService.SaveSettings(Settings);
OnOfflineModeChanged?.Invoke(state);
}
public async Task Logout()
{
await Client.LogoutAsync();
Settings = SettingService.GetSettings();
Settings.Profile = new ProfileSettings();
Settings.Authentication = new AuthenticationSettings();
SettingService.SaveSettings(Settings);
OnLogout?.Invoke(this, EventArgs.Empty);
}
public Guid GetUserId()
{
@ -75,6 +191,6 @@ public class AuthenticationService : BaseService
public async Task<bool> OfflineModeAvailableAsync()
{
return !IsConnected() && !(await IsServerOnlineAsync()) && HasStoredCredentials();
return !(await IsServerOnlineAsync()) && !IsConnected() && HasStoredCredentials();
}
}

View file

@ -21,10 +21,11 @@ namespace LANCommander.Launcher.Services.Extensions
using (var scope = serviceProvider.CreateScope())
{
var logger = scope.ServiceProvider.GetService<ILogger>();
var profileService = scope.ServiceProvider.GetService<ProfileService>();
var authenticationService = scope.ServiceProvider.GetService<AuthenticationService>();
var keepAliveService = scope.ServiceProvider.GetService<KeepAliveService>();
#region Sign in
profileService.Login().Wait();
authenticationService.Login().Wait();
#endregion
#region Scaffold Required Directories

View file

@ -55,9 +55,10 @@ namespace LANCommander.Launcher.Services.Extensions
services.AddSingleton(client);
services.AddSingleton<MessageBusService>();
services.AddSingleton<AuthenticationService>();
services.AddSingleton<KeepAliveService>();
#endregion
services.AddScoped<AuthenticationService>();
services.AddScoped<CollectionService>();
services.AddScoped<CommandLineService>();
services.AddScoped<CompanyService>();

View file

@ -0,0 +1,120 @@
using System.Management.Automation.Language;
using LANCommander.SDK;
using System.Timers;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace LANCommander.Launcher.Services;
public class KeepAliveService : BaseService
{
private readonly AuthenticationService AuthenticationService;
private Timer CheckConnectionTimer;
private Timer RetryConnectionTimer;
private int PingInterval = 2000;
private int RetryInterval = 1000;
private int RetryCount;
private int MaxRetries = 10;
private bool ConnectionLost = false;
public event EventHandler ConnectionSevered;
public event EventHandler ConnectionLostPermanently;
public event EventHandler ConnectionEstablished;
public KeepAliveService(
Client client,
ILogger<KeepAliveService> logger,
AuthenticationService authenticationService) : base(client, logger)
{
AuthenticationService = authenticationService;
AuthenticationService.OnLogin += (sender, args) => StartMonitoring();
AuthenticationService.OnLogout += (sender, args) => StopMonitoring();
AuthenticationService.OnRegister += (sender, args) => StartMonitoring();
AuthenticationService.OnOfflineModeChanged += (state) =>
{
if (state)
StopMonitoring();
else
StartMonitoring();
};
ConnectionEstablished += (sender, args) => StartMonitoring();
}
public void StartMonitoring()
{
RetryCount = 0;
CheckConnectionTimer?.Stop();
CheckConnectionTimer?.Dispose();
CheckConnectionTimer = new Timer(PingInterval);
CheckConnectionTimer.Elapsed += CheckConnection;
CheckConnectionTimer.AutoReset = true;
CheckConnectionTimer.Start();
}
public void StopMonitoring()
{
RetryCount = 0;
CheckConnectionTimer?.Stop();
CheckConnectionTimer?.Dispose();
RetryConnectionTimer?.Stop();
RetryConnectionTimer?.Dispose();
}
private async void CheckConnection(object sender, ElapsedEventArgs e)
{
var serverOnline = await AuthenticationService.IsServerOnlineAsync();
if (!serverOnline && !ConnectionLost)
{
CheckConnectionTimer.Stop();
CheckConnectionTimer.Dispose();
CheckConnectionTimer = null;
ConnectionLost = true;
ConnectionSevered?.Invoke(this, EventArgs.Empty);
RetryConnectionTimer = new Timer(RetryInterval);
RetryConnectionTimer.Elapsed += RetryConnection;
RetryConnectionTimer.AutoReset = true;
RetryConnectionTimer.Start();
}
}
private async void RetryConnection(object sender, ElapsedEventArgs e)
{
var serverOnline = await AuthenticationService.IsServerOnlineAsync();
if (serverOnline && ConnectionLost)
{
ConnectionLost = false;
RetryConnectionTimer.Stop();
RetryConnectionTimer.Elapsed -= RetryConnection;
ConnectionEstablished?.Invoke(this, EventArgs.Empty);
AuthenticationService.SetOfflineMode(false);
}
else
{
RetryCount++;
if (RetryCount == MaxRetries)
{
ConnectionLostPermanently?.Invoke(this, EventArgs.Empty);
RetryConnectionTimer.Stop();
RetryConnectionTimer.Elapsed -= RetryConnection;
AuthenticationService.SetOfflineMode(true);
}
}
}
}

View file

@ -26,105 +26,19 @@ namespace LANCommander.Launcher.Services
Settings = SettingService.GetSettings();
}
public async Task Login()
public async Task ChangeAlias(string newName)
{
await Login(Settings.Authentication.ServerAddress, new SDK.Models.AuthToken
{
AccessToken = Settings.Authentication.AccessToken,
RefreshToken = Settings.Authentication.RefreshToken,
});
}
public async Task Login(string serverAddress, SDK.Models.AuthToken token)
{
Client.ChangeServerAddress(serverAddress);
await Client.Profile.ChangeAliasAsync(newName);
Settings = SettingService.GetSettings();
Settings.Authentication.ServerAddress = serverAddress;
Settings.Authentication.AccessToken = token.AccessToken;
Settings.Authentication.RefreshToken = token.RefreshToken;
Client.UseToken(token);
if (await Client.ValidateTokenAsync())
{
SettingService.SaveSettings(Settings);
var remoteProfile = await Client.Profile.GetAsync();
Settings.Profile.Id = remoteProfile.Id;
Settings.Profile.Alias = String.IsNullOrWhiteSpace(remoteProfile.Alias) ? remoteProfile.UserName : remoteProfile.Alias;
try
{
var tempAvatarPath = await Client.Profile.DownloadAvatar();
if (!String.IsNullOrWhiteSpace(tempAvatarPath))
{
var media = new Media
{
FileId = Guid.NewGuid(),
Type = SDK.Enums.MediaType.Avatar,
MimeType = MediaTypeNames.Image.Png,
Crc32 = SDK.Services.MediaService.CalculateChecksum(tempAvatarPath),
};
media = await MediaService.Add(media);
var localPath = MediaService.GetImagePath(media);
if (File.Exists(tempAvatarPath))
File.Move(tempAvatarPath, localPath);
Settings.Profile.AvatarId = media.Id;
}
}
catch (Exception ex)
{
Logger?.LogError(ex, "Could not download avatar");
}
SettingService.SaveSettings(Settings);
}
}
public async Task Login(string serverAddress, string username, string password)
{
Client.ChangeServerAddress(serverAddress);
var token = await Client.AuthenticateAsync(username, password);
await Login(serverAddress, token);
}
public async Task Register(string serverAddress, string username, string password, string passwordConfirmation)
{
if (String.IsNullOrWhiteSpace(serverAddress))
throw new Exception("Server address cannot be blank");
if (String.IsNullOrWhiteSpace(username))
throw new Exception("Username cannot be blank");
if (String.IsNullOrWhiteSpace(password))
throw new Exception("Password cannot be blank");
if (password != passwordConfirmation)
throw new Exception("Passwords do not match");
Client.ChangeServerAddress(serverAddress);
var token = await Client.RegisterAsync(username, password, passwordConfirmation);
Settings = SettingService.GetSettings();
Settings.Authentication.ServerAddress = serverAddress;
Settings.Authentication.AccessToken = token.AccessToken;
Settings.Authentication.RefreshToken = token.RefreshToken;
Settings.Profile.Alias = newName;
SettingService.SaveSettings(Settings);
}
public async Task DownloadProfileInfoAsync()
{
var remoteProfile = await Client.Profile.GetAsync();
Settings.Profile.Id = remoteProfile.Id;
@ -162,38 +76,6 @@ namespace LANCommander.Launcher.Services
SettingService.SaveSettings(Settings);
}
public void SetOfflineMode(bool state)
{
Settings = SettingService.GetSettings();
Settings.Authentication.OfflineMode = false;
SettingService.SaveSettings(Settings);
}
public async Task Logout()
{
await Client.LogoutAsync();
Settings = SettingService.GetSettings();
Settings.Profile = new ProfileSettings();
Settings.Authentication = new AuthenticationSettings();
SettingService.SaveSettings(Settings);
}
public async Task ChangeAlias(string newName)
{
await Client.Profile.ChangeAliasAsync(newName);
Settings = SettingService.GetSettings();
Settings.Profile.Alias = newName;
SettingService.SaveSettings(Settings);
}
public bool IsAuthenticated()
{
return !String.IsNullOrWhiteSpace(Settings.Authentication.AccessToken);

View file

@ -1,5 +1,5 @@
@using LANCommander.SDK.Models
@inject ProfileService ProfileService
@inject AuthenticationService AuthenticationService
@inject SDK.Client Client
@inject NavigationManager NavigationManager
@inject IMessageService MessageService
@ -105,7 +105,7 @@
try
{
await ProfileService.Login(Client.GetServerAddress(), Model.UserName, Model.Password);
await AuthenticationService.Login(Client.GetServerAddress(), Model.UserName, Model.Password);
MainLayout.Import();
@ -133,7 +133,7 @@
try
{
await ProfileService.Login(ServerAddress, token);
await AuthenticationService.Login(ServerAddress, token);
MainLayout.Import();

View file

@ -1,5 +1,5 @@
@using LANCommander.SDK.Models
@inject ProfileService ProfileService
@inject AuthenticationService AuthenticationService
@inject SDK.Client Client
@inject NavigationManager NavigationManager
@inject IMessageService MessageService
@ -52,7 +52,7 @@
try
{
await ProfileService.Register(Client.GetServerAddress(), Model.UserName, Model.Password, Model.PasswordConfirmation);
await AuthenticationService.Register(Client.GetServerAddress(), Model.UserName, Model.Password, Model.PasswordConfirmation);
MainLayout.Import();
@ -78,7 +78,7 @@
try
{
await ProfileService.Login(ServerAddress, token);
await AuthenticationService.Login(ServerAddress, token);
MainLayout.Import();

View file

@ -0,0 +1,15 @@
@if (!ConnectionState.IsConnected || ConnectionState.OfflineModeEnabled)
{
@Offline
}
else
{
@Online
}
@code {
[Parameter] public RenderFragment Online { get; set; }
[Parameter] public RenderFragment Offline { get; set; }
[CascadingParameter] public Models.ConnectionState ConnectionState { get; set; }
}

View file

@ -12,16 +12,16 @@
}
else
{
if (Client.IsConnected())
{
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.AppstoreAdd" OnClick="@(() => NavigationManager.NavigateTo("/Depot"))">Depot</Button>
}
else
{
<Tooltip Title="You are currently offline">
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.AppstoreAdd" Disabled>Depot</Button>
</Tooltip>
}
<ConnectionStateView>
<Online>
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.AppstoreAdd" OnClick="@(() => NavigationManager.NavigateTo("/Depot"))">Depot</Button>
</Online>
<Offline>
<Tooltip Title="You are currently offline" Placement="Placement.TopLeft">
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.AppstoreAdd" Disabled>Depot</Button>
</Tooltip>
</Offline>
</ConnectionStateView>
}
@if (InstallService.Queue.Any(qi => qi.State))
@ -35,12 +35,16 @@
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.Download" OnClick="() => ShowDownloadQueue()">Downloads</Button>
}
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.Team">Friends</Button>
<Tooltip Title="Coming soon!" Placement="Placement.TopRight">
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.Team" Disabled>Friends</Button>
</Tooltip>
</Flex>
<DownloadQueue @ref="DownloadQueue" />
@code {
[CascadingParameter] public bool Connected { get; set; }
bool DownloadQueueVisible = false;
DownloadQueue DownloadQueue;

View file

@ -0,0 +1,60 @@
@implements IDisposable
@inject KeepAliveService KeepAliveService
@inject IMessageService MessageService
@code {
string ConnectionMessageKey = Guid.NewGuid().ToString();
protected override void OnInitialized()
{
KeepAliveService.ConnectionSevered += OnConnectionSevered;
KeepAliveService.ConnectionLostPermanently += OnConnectionLostPermanently;
KeepAliveService.ConnectionEstablished += OnConnectionEstablished;
}
void OnConnectionSevered(object? sender, EventArgs e)
{
var messageConfig = new MessageConfig
{
Key = ConnectionMessageKey,
Type = MessageType.Loading,
Content = "Lost connection, retrying...",
Duration = 0,
};
MessageService.Open(messageConfig);
}
void OnConnectionLostPermanently(object? sender, EventArgs e)
{
var messageConfig = new MessageConfig
{
Key = ConnectionMessageKey,
Type = MessageType.Error,
Content = "Server unavailable, enabling offline mode",
Duration = 2.5,
};
MessageService.Open(messageConfig);
}
void OnConnectionEstablished(object? sender, EventArgs e)
{
var messageConfig = new MessageConfig
{
Key = ConnectionMessageKey,
Type = MessageType.Success,
Content = "Connection established!",
Duration = 2.5,
};
MessageService.Open(messageConfig);
}
public void Dispose()
{
KeepAliveService.ConnectionSevered -= OnConnectionSevered;
KeepAliveService.ConnectionLostPermanently -= OnConnectionLostPermanently;
KeepAliveService.ConnectionEstablished -= OnConnectionEstablished;
}
}

View file

@ -12,6 +12,8 @@
[Parameter] public Guid Id { get; set; }
[Parameter] public string Class { get; set; }
[Parameter] public string Key { get; set; }
[CascadingParameter] public bool Connected { get; set; }
[CascadingParameter] public bool OfflineMode { get; set; }
string MediaUrl { get; set; }
string HiddenClass { get; set; }
@ -28,7 +30,7 @@
try {
var media = await MediaService.Get(Id);
if (media == null && Client.IsConnected())
if (media == null && (Connected || OfflineMode))
media = await ImportService.ImportMediaAsync(Id);
UpdateMediaUrl(media);

View file

@ -1,18 +1,20 @@
@inject ProfileService ProfileService
@using LANCommander.Launcher.Models
@inject ProfileService ProfileService
@inject AuthenticationService AuthenticationService
@inject NavigationManager NavigationManager
@inject ModalService ModalService
<Dropdown>
<Overlay>
<Menu>
<MenuItem OnClick="ChangeAlias" Disabled="Settings.Authentication.OfflineMode">
<MenuItem OnClick="ChangeAlias" Disabled="ConnectionState.OfflineModeEnabled">
Change Name
</MenuItem>
<MenuItem OnClick="@(() => NavigationManager.NavigateTo("/Settings"))">
Settings
</MenuItem>
@if (!Settings.Authentication.OfflineMode)
@if (!ConnectionState.OfflineModeEnabled)
{
<MenuItem OnClick="Logout">
Logout
@ -34,6 +36,7 @@
</Dropdown>
@code {
[CascadingParameter] public ConnectionState ConnectionState { get; set; }
Models.Settings Settings = null;
protected override async Task OnInitializedAsync()
@ -68,7 +71,7 @@
async Task Logout()
{
await ProfileService.Logout();
await AuthenticationService.Logout();
NavigationManager.NavigateTo("/Authenticate");
}

View file

@ -48,7 +48,16 @@
}
else if (SelectedItem.State == ListItemState.NotInstalled)
{
<Button Type="ButtonType.Primary" Size="ButtonSize.Large" Icon="@IconType.Outline.Download" OnClick="() => Install(SelectedItem)">Install</Button>
<ConnectionStateView>
<Online>
<Button Type="ButtonType.Primary" Size="ButtonSize.Large" Icon="@IconType.Outline.Download" OnClick="() => Install(SelectedItem)">Install</Button>
</Online>
<Offline>
<Tooltip Title="You are currently offline">
<Button Type="ButtonType.Primary" Size="ButtonSize.Large" Icon="@IconType.Outline.Download" Disabled>Install</Button>
</Tooltip>
</Offline>
</ConnectionStateView>
}
else if (SelectedItem.State == ListItemState.Queued)
{
@ -56,7 +65,16 @@
}
else if (SelectedItem.State == ListItemState.UpdateAvailable)
{
<Button Type="ButtonType.Primary" Size="ButtonSize.Large" Icon="@IconType.Outline.Download">Update</Button>
<ConnectionStateView>
<Online>
<Button Type="ButtonType.Primary" Size="ButtonSize.Large" Icon="@IconType.Outline.Download" OnClick="() => Update(SelectedItem)">Update</Button>
</Online>
<Offline>
<Tooltip Title="You are currently offline">
<Button Type="ButtonType.Primary" Size="ButtonSize.Large" Icon="@IconType.Outline.Download" Disabled>Update</Button>
</Tooltip>
</Offline>
</ConnectionStateView>
}
else if (SelectedItem.State == ListItemState.Installing)
{
@ -64,11 +82,15 @@
}
</SpaceItem>
@if (SelectedItem.State == ListItemState.NotInstalled && Client.IsConnected())
@if (SelectedItem.State == ListItemState.NotInstalled && Connected)
{
<SpaceItem>
<Statistic Title="Download Size" Value="@ByteSizeLib.ByteSize.FromBytes(GetDownloadSize()).ToString()" />
</SpaceItem>
<ConnectionStateView>
<Online>
<SpaceItem>
<Statistic Title="Download Size" Value="@ByteSizeLib.ByteSize.FromBytes(GetDownloadSize()).ToString()"/>
</SpaceItem>
</Online>
</ConnectionStateView>
}
<SpaceItem>
@ -148,6 +170,8 @@
@code {
[Parameter] public Guid Id { get; set; }
[CascadingParameter] public bool Connected { get; set; }
[CascadingParameter] public bool OfflineMode { get; set; }
Models.ListItem SelectedItem { get; set; }
@ -172,7 +196,7 @@
{
await OnLibraryItemSelected(await LibraryService.GetItemAsync(Id));
if (SelectedGame != null && Client.IsConnected())
if (SelectedGame != null && (Connected && !OfflineMode))
RemoteGame = await Client.Games.GetAsync(SelectedGame.Id);
}
@ -229,6 +253,13 @@
}
}
async Task Update(Models.ListItem item)
{
var game = item.DataItem as Game;
await InstallService.Add(game);
}
async Task OnQueueChanged()
{
var queueItem = InstallService.Queue.FirstOrDefault(i => SelectedItem != null && i.Id == SelectedItem.Key);

View file

@ -1,85 +1,94 @@
@using LANCommander.Launcher.Models
@using System.Management.Automation.Remoting
@using LANCommander.Launcher.Models
@using LANCommander.SDK
@using Photino.Blazor.CustomWindow.Components
@using ConnectionState = LANCommander.Launcher.Models.ConnectionState
@inherits LayoutComponentBase
@inject ImportService ImportService
@inject ProfileService ProfileService
@inject AuthenticationService AuthenticationService
@inject IMessageService MessageService
@inject NavigationManager NavigationManager
@inject ModalService ModalService
@inject LANCommander.SDK.Client LANCommander
@inject IJSRuntime JS
<CustomWindow HeaderHeight="37">
<HeaderExtraControlsLayout>
<Space Direction="SpaceDirection.Horizontal">
@if (Settings != null && Settings.Profile != null && ProfileService.IsAuthenticated())
{
@if (!Settings.Authentication.OfflineMode)
<CascadingValue Value="ConnectionState">
<CustomWindow HeaderHeight="37">
<HeaderExtraControlsLayout>
<Space Direction="SpaceDirection.Horizontal">
<ConnectionStateView>
<Online>
<SpaceItem>
<Popover Placement="Placement.BottomRight" IsButton OnClick="Import" Trigger="new[] { Trigger.Hover }">
<ChildContent>
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.Sync" Loading="@Importing"/>
</ChildContent>
<ContentTemplate>
<Progress Percent="@((ImportStatusIndex / (float)ImportStatusTotal) * 100)" Steps="@ImportStatusTotal"/>
</ContentTemplate>
</Popover>
</SpaceItem>
</Online>
<Offline>
<SpaceItem>
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.CloudSync" OnClick="Connect" Loading="@Connecting" Danger/>
</SpaceItem>
</Offline>
</ConnectionStateView>
@if (Settings != null && Settings.Profile != null && ProfileService.IsAuthenticated())
{
<SpaceItem>
<Popover Placement="Placement.BottomRight" IsButton OnClick="Import" Trigger="new [] { Trigger.Hover }">
<ChildContent>
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.Sync" Loading="@Importing"/>
</ChildContent>
<ContentTemplate>
<Progress Percent="@((ImportStatusIndex / (float)ImportStatusTotal) * 100)" Steps="@ImportStatusTotal" />
</ContentTemplate>
</Popover>
</SpaceItem>
}
else
{
<SpaceItem>
<Button Type="@ButtonType.Text" Icon="@IconType.Outline.CloudSync" OnClick="Connect" Loading="@Connecting" Danger />
<ProfileButton />
</SpaceItem>
}
</Space>
</HeaderExtraControlsLayout>
<WindowContent>
<ErrorBoundary>
<ChildContent>
@Body
<SpaceItem>
<ProfileButton />
</SpaceItem>
}
</Space>
</HeaderExtraControlsLayout>
<WindowContent>
<ErrorBoundary>
<ChildContent>
@Body
@if (Settings.Debug.EnableScriptDebugging)
{
<PowerShellConsole />
}
@if (Settings.Debug.EnableScriptDebugging)
{
<PowerShellConsole/>
}
<UpdateChecker/>
</ChildContent>
<ErrorContent>
<Result Status="ResultStatus.Error"
Title="Launcher Crashed"
SubTitle="@RandomQuip"
Class="crash-error">
<Extra>
<Button Type="ButtonType.Primary" OnClick="@(() => NavigationManager.NavigateTo("/", true))">View Library</Button>
<Button OnClick="() => CopyError(context)">Copy Error</Button>
</Extra>
<ChildContent>
<code>
@context.StackTrace
</code>
</ChildContent>
</Result>
</ErrorContent>
</ErrorBoundary>
<AntContainer/>
<RedirectToLogin/>
</WindowContent>
</CustomWindow>
<UpdateChecker />
<AntContainer />
<KeepAliveContainer />
<RedirectToLogin />
</ChildContent>
<ErrorContent>
<Result Status="ResultStatus.Error"
Title="Launcher Crashed"
SubTitle="@RandomQuip"
Class="crash-error">
<Extra>
<Button Type="ButtonType.Primary" OnClick="@(() => NavigationManager.NavigateTo("/", true))">View Library</Button>
<Button OnClick="() => CopyError(context)">Copy Error</Button>
</Extra>
<ChildContent>
<code>
@context.StackTrace
</code>
</ChildContent>
</Result>
</ErrorContent>
</ErrorBoundary>
</WindowContent>
</CustomWindow>
</CascadingValue>
@code {
Models.Settings Settings = null;
public bool Importing = false;
public bool Connecting = false;
public ConnectionState ConnectionState = new();
public IMessageService Messages { get; set; }
public static MainLayout _MainLayout { get; set; }
@ -129,12 +138,17 @@
ImportService.OnImportComplete += OnImportComplete;
ImportService.OnImportUpdated += OnImportUpdated;
AuthenticationService.OnOfflineModeChanged += OnOfflineModeChanged;
var randIndex = new Random().Next(0, CrashQuips.Length - 1);
RandomQuip = CrashQuips[randIndex];
if (!(await LANCommander.ValidateTokenAsync()) && !Settings.Authentication.OfflineMode)
ConnectionState.IsConnected = await LANCommander.ValidateTokenAsync();
ConnectionState.OfflineModeEnabled = Settings.Authentication.OfflineMode;
if (!ConnectionState.IsConnected && !ConnectionState.OfflineModeEnabled)
NavigationManager.NavigateTo("/Authenticate");
}
@ -148,6 +162,13 @@
MessageService.Info($"Importing {status.CurrentItem.Name}");
}
async void OnOfflineModeChanged(bool state)
{
_MainLayout.ConnectionState.OfflineModeEnabled = state;
_MainLayout.ConnectionState.IsConnected = LANCommander.IsConnected();
await InvokeAsync(_MainLayout.StateHasChanged);
}
async Task CopyError(Exception ex)
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", ex.Message + "\n" + ex.StackTrace);
@ -169,14 +190,19 @@
if (await LANCommander.ValidateTokenAsync(token))
{
ProfileService.SetOfflineMode(false);
await AuthenticationService.Login();
MessageService.Success("Back Online!");
ConnectionState.IsConnected = true;
ConnectionState.OfflineModeEnabled = false;
await InvokeAsync(StateHasChanged);
}
else
{
if (await LANCommander.PingAsync())
{
ProfileService.SetOfflineMode(false);
await Logout();
}
else
@ -185,7 +211,7 @@
{
Title = "Could Not Reconnect!",
Icon = @<Icon Type="@IconType.Outline.ExclamationCircle"></Icon>,
Content = "The LANCommander server could not be reached. Click stay offline and try later, or logout and fix your credentials.",
Content = "The LANCommander server could not be reached. Click stay offline and try later, or logout and enter your credentials.",
OkText = "Logout",
CancelText = "Stay Offline",
Centered = true,
@ -203,7 +229,10 @@
async Task Logout()
{
await ProfileService.Logout();
await AuthenticationService.Logout();
ConnectionState.IsConnected = false;
ConnectionState.OfflineModeEnabled = false;
NavigationManager.NavigateTo("/Authenticate");
}

View file

@ -57,6 +57,8 @@ namespace LANCommander.SDK
}
}
public EventHandler<Exception> OnError;
public Client(string baseUrl, string defaultInstallDirectory)
{
DefaultInstallDirectory = defaultInstallDirectory;
@ -129,195 +131,286 @@ namespace LANCommander.SDK
internal T PostRequest<T>(string route, object body, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddJsonBody(body)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = ApiClient.Post<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddJsonBody(body)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = ApiClient.Post<T>(request);
return response;
}
}
internal T PostRequest<T>(string route, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = ApiClient.Post<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = ApiClient.Post<T>(request);
return response;
}
}
internal async Task<T> PostRequestAsync<T>(string route, object body, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddJsonBody(body)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.PostAsync<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddJsonBody(body)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.PostAsync<T>(request);
return response;
}
}
internal async Task<T> PostRequestAsync<T>(string route, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.PostAsync<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.PostAsync<T>(request);
return response;
}
}
internal async Task<T> PutRequestAsync<T>(string route, object body, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.PutAsync<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.PutAsync<T>(request);
return response;
}
}
internal T GetRequest<T>(string route, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = ApiClient.Get<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = ApiClient.Get<T>(request);
return response;
}
}
internal async Task<T> GetRequestAsync<T>(string route, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.GetAsync<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.GetAsync<T>(request);
return response;
}
}
internal async Task<T> DeleteRequestAsync<T>(string route, bool ignoreVersion = false)
{
if (Token == null)
try
{
if (Token == null)
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.DeleteAsync<T>(request);
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
var request = new RestRequest(route)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
var response = await ApiClient.DeleteAsync<T>(request);
return response;
}
}
internal async Task<string> DownloadRequestAsync(string route, Action<DownloadProgressChangedEventArgs> progressHandler, Action<AsyncCompletedEventArgs> completeHandler)
{
route = route.TrimStart('/');
var client = new WebClient();
var tempFile = Path.GetTempFileName();
client.Headers.Add("Authorization", $"Bearer {Token.AccessToken}");
client.Headers.Add("X-API-Version", GetCurrentVersion().ToString());
client.DownloadProgressChanged += (s, e) => progressHandler(e);
client.DownloadFileCompleted += (s, e) => completeHandler(e);
try
{
await client.DownloadFileTaskAsync(new Uri(BaseUrl, route), tempFile);
route = route.TrimStart('/');
var client = new WebClient();
var tempFile = Path.GetTempFileName();
client.Headers.Add("Authorization", $"Bearer {Token.AccessToken}");
client.Headers.Add("X-API-Version", GetCurrentVersion().ToString());
client.DownloadProgressChanged += (s, e) => progressHandler(e);
client.DownloadFileCompleted += (s, e) => completeHandler(e);
try
{
await client.DownloadFileTaskAsync(new Uri(BaseUrl, route), tempFile);
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred while downloading from the server at route {Route}", route);
if (File.Exists(tempFile))
File.Delete(tempFile);
tempFile = String.Empty;
}
return tempFile;
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred while downloading from the server at route {Route}", route);
if (File.Exists(tempFile))
File.Delete(tempFile);
tempFile = String.Empty;
OnError?.Invoke(this, ex);
return null;
}
return tempFile;
}
internal async Task<string> DownloadRequestAsync(string route, string destination)
{
route = route.TrimStart('/');
var client = new WebClient();
client.Headers.Add("Authorization", $"Bearer {Token.AccessToken}");
client.Headers.Add("X-API-Version", GetCurrentVersion().ToString());
try
{
await client.DownloadFileTaskAsync(new Uri(BaseUrl, route), destination);
route = route.TrimStart('/');
var client = new WebClient();
client.Headers.Add("Authorization", $"Bearer {Token.AccessToken}");
client.Headers.Add("X-API-Version", GetCurrentVersion().ToString());
try
{
await client.DownloadFileTaskAsync(new Uri(BaseUrl, route), destination);
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred while downloading from the server at route {Route}",
route);
if (File.Exists(destination))
File.Delete(destination);
destination = String.Empty;
}
return destination;
}
catch (Exception ex)
{
Logger?.LogError(ex, "An unknown error occurred while downloading from the server at route {Route}", route);
if (File.Exists(destination))
File.Delete(destination);
destination = String.Empty;
OnError?.Invoke(this, ex);
return null;
}
return destination;
}
internal TrackableStream StreamRequest(string route)
@ -336,123 +429,164 @@ namespace LANCommander.SDK
internal T UploadRequest<T>(string route, string fileName, byte[] data, bool ignoreVersion = false)
{
var request = new RestRequest(route, Method.Post)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
try
{
var request = new RestRequest(route, Method.Post)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
request.AddFile(fileName, data, fileName);
request.AddFile(fileName, data, fileName);
var response = ApiClient.Post<T>(request);
var response = ApiClient.Post<T>(request);
return response;
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
}
}
internal async Task<T> UploadRequestAsync<T>(string route, string fileName, byte[] data, bool ignoreVersion = false)
{
var request = new RestRequest(route, Method.Post)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
try
{
var request = new RestRequest(route, Method.Post)
.AddHeader("Authorization", $"Bearer {Token.AccessToken}")
.AddHeader("X-API-Version", GetCurrentVersion().ToString());
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
request.AddFile(fileName, data, fileName);
request.AddFile(fileName, data, fileName);
var response = await ApiClient.PostAsync<T>(request);
var response = await ApiClient.PostAsync<T>(request);
return response;
return response;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
}
}
internal async Task<Guid> ChunkedUploadRequestAsync(string fileName, Stream stream, bool ignoreVersion = false)
{
var maxChunkSize = 1024 * 1024 * 50;
var initResponse = await PostRequestAsync<UploadInitResponse>("/Upload/Init", ignoreVersion);
var buffer = new byte[maxChunkSize];
while (stream.Position < stream.Length)
try
{
var chunkRequest = new UploadChunkRequest();
var maxChunkSize = 1024 * 1024 * 50;
var initResponse = await PostRequestAsync<UploadInitResponse>("/Upload/Init", ignoreVersion);
chunkRequest.Start = stream.Position;
var buffer = new byte[maxChunkSize];
if (stream.Position + maxChunkSize > stream.Length)
while (stream.Position < stream.Length)
{
var bytes = stream.Length - stream.Position;
var chunkRequest = new UploadChunkRequest();
buffer = new byte[bytes];
chunkRequest.Start = stream.Position;
await stream.ReadAsync(buffer, 0, (int)(stream.Length - stream.Position));
if (stream.Position + maxChunkSize > stream.Length)
{
var bytes = stream.Length - stream.Position;
buffer = new byte[bytes];
await stream.ReadAsync(buffer, 0, (int)(stream.Length - stream.Position));
}
else
await stream.ReadAsync(buffer, 0, maxChunkSize);
chunkRequest.End = stream.Position;
chunkRequest.Total = stream.Length;
chunkRequest.File = buffer;
chunkRequest.Key = initResponse.Key;
await PostRequestAsync<object>("/Upload/Chunk", chunkRequest, ignoreVersion);
}
else
await stream.ReadAsync(buffer, 0, maxChunkSize);
chunkRequest.End = stream.Position;
chunkRequest.Total = stream.Length;
chunkRequest.File = buffer;
chunkRequest.Key = initResponse.Key;
await PostRequestAsync<object>("/Upload/Chunk", chunkRequest, ignoreVersion);
return initResponse.Key;
}
catch (Exception ex)
{
OnError?.Invoke(this, ex);
return default;
}
return initResponse.Key;
}
public async Task<AuthToken> AuthenticateAsync(string username, string password, bool ignoreVersion = false)
{
var request = new RestRequest("/api/Auth/Login", Method.Post);
request.AddJsonBody(new AuthRequest()
try
{
UserName = username,
Password = password
});
var request = new RestRequest("/api/Auth/Login", Method.Post);
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
request.AddJsonBody(new AuthRequest()
{
UserName = username,
Password = password
});
var response = await ApiClient.ExecuteAsync<AuthToken>(request);
if (!ignoreVersion)
request.Interceptors = new List<Interceptor>() { new VersionInterceptor() };
if (response.ErrorException != null)
{
Logger?.LogError(response.ErrorException, "Authentication failed for user {UserName}", username);
var response = await ApiClient.ExecuteAsync<AuthToken>(request);
throw response.ErrorException;
if (response.ErrorException != null)
{
Logger?.LogError(response.ErrorException, "Authentication failed for user {UserName}", username);
throw response.ErrorException;
}
switch (response.StatusCode)
{
case HttpStatusCode.OK:
var token = new AuthToken
{
AccessToken = response.Data.AccessToken,
RefreshToken = response.Data.RefreshToken,
Expiration = response.Data.Expiration
};
UseToken(token);
Connected = true;
return token;
case HttpStatusCode.Forbidden:
case HttpStatusCode.BadRequest:
case HttpStatusCode.Unauthorized:
Connected = false;
Logger?.LogError("Authentication failed for user {UserName}: invalid username or password", username);
throw new WebException("Invalid username or password");
default:
Connected = false;
Logger?.LogError("Authentication failed for user {UserName}: could not communicate with the server", username);
throw new WebException("Could not communicate with the server");
}
}
switch (response.StatusCode)
catch (Exception ex)
{
case HttpStatusCode.OK:
var token = new AuthToken
{
AccessToken = response.Data.AccessToken,
RefreshToken = response.Data.RefreshToken,
Expiration = response.Data.Expiration
};
UseToken(token);
Connected = true;
return token;
case HttpStatusCode.Forbidden:
case HttpStatusCode.BadRequest:
case HttpStatusCode.Unauthorized:
Connected = false;
Logger?.LogError("Authentication failed for user {UserName}: invalid username or password", username);
throw new WebException("Invalid username or password");
default:
Connected = false;
Logger?.LogError("Authentication failed for user {UserName}: could not communicate with the server", username);
throw new WebException("Could not communicate with the server");
OnError?.Invoke(this, ex);
return default;
}
}
public void Disconnect()
{
Connected = false;
}
public async Task LogoutAsync()
{
await ApiClient.ExecuteAsync(new RestRequest("/api/Auth/Logout", Method.Post));
@ -476,6 +610,8 @@ namespace LANCommander.SDK
if (!String.IsNullOrWhiteSpace(response?.Data?.Message))
{
Logger?.LogError(response.Data.Message);
OnError?.Invoke(this, response.ErrorException);
throw new Exception(response.Data.Message);
}
@ -521,7 +657,7 @@ namespace LANCommander.SDK
public async Task<bool> PingAsync()
{
var response = await ApiClient.ExecuteAsync(new RestRequest("/api/Ping", Method.Get));
var response = await ApiClient.ExecuteAsync(new RestRequest("/api/Ping", Method.Head));
return response.StatusCode == HttpStatusCode.OK;
}