Skip to content
Merged
1 change: 1 addition & 0 deletions App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public App()
services.AddTransient<SignInWindow>();

// TrayWindow views and view models
services.AddTransient<TrayWindowLoadingPage>();
services.AddTransient<TrayWindowDisconnectedViewModel>();
services.AddTransient<TrayWindowDisconnectedPage>();
services.AddTransient<TrayWindowLoginRequiredViewModel>();
Expand Down
11 changes: 10 additions & 1 deletion App/Models/CredentialModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,33 @@ namespace Coder.Desktop.App.Models;

public enum CredentialState
{
// Unknown means "we haven't checked yet"
Unknown,

// Invalid means "we checked and there's either no saved credentials or they are not valid"
Invalid,

// Valid means "we checked and there are saved credentials and they are valid"
Valid,
}

public class CredentialModel
{
public CredentialState State { get; set; } = CredentialState.Invalid;
public CredentialState State { get; set; } = CredentialState.Unknown;

public string? CoderUrl { get; set; }
public string? ApiToken { get; set; }

public string? Username { get; set; }

public CredentialModel Clone()
{
return new CredentialModel
{
State = State,
CoderUrl = CoderUrl,
ApiToken = ApiToken,
Username = Username,
};
}
}
154 changes: 106 additions & 48 deletions App/Services/CredentialManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,27 @@
}

[JsonSerializable(typeof(RawCredentials))]
public partial class RawCredentialsJsonContext : JsonSerializerContext
{
}
public partial class RawCredentialsJsonContext : JsonSerializerContext;

public interface ICredentialManager
{
public event EventHandler<CredentialModel> CredentialsChanged;

public CredentialModel GetCredentials();
/// <summary>
/// Returns cached credentials or an invalid credential model if none are cached. It's preferable to use
/// LoadCredentials if you are operating in an async context.
/// </summary>
public CredentialModel GetCachedCredentials();

/// <summary>
/// Get any sign-in URL. The returned value is not parsed to check if it's a valid URI.
/// </summary>
public string? GetSignInUri();

/// <summary>
/// Returns cached credentials or loads/verifies them from storage if not cached.
/// </summary>
public Task<CredentialModel> LoadCredentials(CancellationToken ct = default);

public Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default);

Expand All @@ -37,30 +49,65 @@
{
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";

private readonly RaiiSemaphoreSlim _lock = new(1, 1);
private readonly RaiiSemaphoreSlim _loadLock = new(1, 1);
private readonly RaiiSemaphoreSlim _stateLock = new(1, 1);
private CredentialModel? _latestCredentials;

public event EventHandler<CredentialModel>? CredentialsChanged;

public CredentialModel GetCredentials()
public CredentialModel GetCachedCredentials()
{
using var _ = _lock.Lock();
using var _ = _stateLock.Lock();
if (_latestCredentials != null) return _latestCredentials.Clone();

var rawCredentials = ReadCredentials();
if (rawCredentials is null)
_latestCredentials = new CredentialModel
return new CredentialModel
{
State = CredentialState.Unknown,
};
}

public string? GetSignInUri()
{
try
{
var raw = ReadCredentials();
if (raw is not null && !string.IsNullOrWhiteSpace(raw.CoderUrl)) return raw.CoderUrl;
}
catch
{
// ignored
}

return null;
}

public async Task<CredentialModel> LoadCredentials(CancellationToken ct = default)
{
using var _ = await _loadLock.LockAsync(ct);
using (await _stateLock.LockAsync(ct))
{
if (_latestCredentials != null) return _latestCredentials.Clone();
}

CredentialModel model;
try
{
var raw = ReadCredentials();
model = await PopulateModel(raw, ct);
}
catch (Exception e)

Check warning on line 98 in App/Services/CredentialManager.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'e' is declared but never used

Check warning on line 98 in App/Services/CredentialManager.cs

View workflow job for this annotation

GitHub Actions / build

The variable 'e' is declared but never used
{
// We don't need to clear the credentials here, the app will think
// they're unset and any subsequent SetCredentials call after the
// user signs in again will overwrite the old invalid ones.
model = new CredentialModel
{
State = CredentialState.Invalid,
};
else
_latestCredentials = new CredentialModel
{
State = CredentialState.Valid,
CoderUrl = rawCredentials.CoderUrl,
ApiToken = rawCredentials.ApiToken,
};
return _latestCredentials.Clone();
}

UpdateState(model.Clone());
return model.Clone();
}

public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default)
Expand All @@ -73,37 +120,15 @@
if (uri.PathAndQuery != "/") throw new ArgumentException("Coder URL must be the root URL", nameof(coderUrl));
if (string.IsNullOrWhiteSpace(apiToken)) throw new ArgumentException("API token is required", nameof(apiToken));
apiToken = apiToken.Trim();
if (apiToken.Length != 33)
throw new ArgumentOutOfRangeException(nameof(apiToken), "API token must be 33 characters long");

try
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var sdkClient = new CoderApiClient(uri);
sdkClient.SetSessionToken(apiToken);
// TODO: we should probably perform a version check here too,
// rather than letting the service do it on Start
_ = await sdkClient.GetBuildInfo(cts.Token);
_ = await sdkClient.GetUser(User.Me, cts.Token);
}
catch (Exception e)
{
throw new InvalidOperationException("Could not connect to or verify Coder server", e);
}

WriteCredentials(new RawCredentials
var raw = new RawCredentials
{
CoderUrl = coderUrl,
ApiToken = apiToken,
});

UpdateState(new CredentialModel
{
State = CredentialState.Valid,
CoderUrl = coderUrl,
ApiToken = apiToken,
});
};
var model = await PopulateModel(raw, ct);
WriteCredentials(raw);
UpdateState(model);
}

public void ClearCredentials()
Expand All @@ -112,14 +137,47 @@
UpdateState(new CredentialModel
{
State = CredentialState.Invalid,
CoderUrl = null,
ApiToken = null,
});
}

private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, CancellationToken ct = default)
{
if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
string.IsNullOrWhiteSpace(credentials.ApiToken))
return new CredentialModel
{
State = CredentialState.Invalid,
};

BuildInfo buildInfo;
User me;
try
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(15));
var sdkClient = new CoderApiClient(credentials.CoderUrl);
sdkClient.SetSessionToken(credentials.ApiToken);
buildInfo = await sdkClient.GetBuildInfo(cts.Token);
me = await sdkClient.GetUser(User.Me, cts.Token);
}
catch (Exception e)
{
throw new InvalidOperationException("Could not connect to or verify Coder server", e);
}

ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version);
return new CredentialModel
{
State = CredentialState.Valid,
CoderUrl = credentials.CoderUrl,
ApiToken = credentials.ApiToken,
Username = me.Username,
};
}

private void UpdateState(CredentialModel newModel)
{
using (_lock.Lock())
using (_stateLock.Lock())
{
_latestCredentials = newModel.Clone();
}
Expand Down
5 changes: 3 additions & 2 deletions App/Services/RpcController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ public async Task StartVpn(CancellationToken ct = default)
using var _ = await AcquireOperationLockNowAsync();
AssertRpcConnected();

var credentials = _credentialManager.GetCredentials();
var credentials = _credentialManager.GetCachedCredentials();
if (credentials.State != CredentialState.Valid)
throw new RpcOperationException("Cannot start VPN without valid credentials");
throw new RpcOperationException(
$"Cannot start VPN without valid credentials, current state: {credentials.State}");

MutateState(state => { state.VpnLifecycle = VpnLifecycle.Starting; });

Expand Down
15 changes: 11 additions & 4 deletions App/ViewModels/SignInViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Coder.Desktop.App.ViewModels;

Expand Down Expand Up @@ -33,8 +34,6 @@ public partial class SignInViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(ApiTokenError))]
public partial bool ApiTokenTouched { get; set; } = false;

[ObservableProperty] public partial string? SignInError { get; set; } = null;

[ObservableProperty] public partial bool SignInLoading { get; set; } = false;

public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null;
Expand Down Expand Up @@ -80,6 +79,8 @@ public Uri GenTokenUrl
public SignInViewModel(ICredentialManager credentialManager)
{
_credentialManager = credentialManager;
CoderUrl = _credentialManager.GetSignInUri() ?? "";
if (!string.IsNullOrWhiteSpace(CoderUrl)) CoderUrlTouched = true;
}

public void CoderUrl_FocusLost(object sender, RoutedEventArgs e)
Expand Down Expand Up @@ -117,7 +118,6 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow)
try
{
SignInLoading = true;
SignInError = null;

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token);
Expand All @@ -126,7 +126,14 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow)
}
catch (Exception e)
{
SignInError = $"Failed to sign in: {e}";
var dialog = new ContentDialog
{
Title = "Failed to sign in",
Content = $"{e}",
CloseButtonText = "Ok",
XamlRoot = signInWindow.Content.XamlRoot,
};
_ = await dialog.ShowAsync();
}
finally
{
Expand Down
4 changes: 2 additions & 2 deletions App/ViewModels/TrayWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public void Initialize(DispatcherQueue dispatcherQueue)
UpdateFromRpcModel(_rpcController.GetState());

_credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel);
UpdateFromCredentialsModel(_credentialManager.GetCredentials());
UpdateFromCredentialsModel(_credentialManager.GetCachedCredentials());
}

private void UpdateFromRpcModel(RpcModel rpcModel)
Expand All @@ -89,7 +89,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;

// Get the current dashboard URL.
var credentialModel = _credentialManager.GetCredentials();
var credentialModel = _credentialManager.GetCachedCredentials();
Uri? coderUri = null;
if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl))
try
Expand Down
5 changes: 0 additions & 5 deletions App/Views/Pages/SignInTokenPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,5 @@
Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}"
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
</StackPanel>

<TextBlock
Text="{x:Bind ViewModel.SignInError, Mode=OneWay}"
HorizontalAlignment="Center"
Foreground="Red" />
</StackPanel>
</Page>
26 changes: 26 additions & 0 deletions App/Views/Pages/TrayWindowLoadingPage.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>

<Page
x:Class="Coder.Desktop.App.Views.Pages.TrayWindowLoadingPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
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"
mc:Ignorable="d">

<StackPanel
Orientation="Vertical"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Padding="20,20,20,30"
Spacing="10">

<TextBlock
Text="CoderVPN"
FontSize="18"
VerticalAlignment="Center" />
<TextBlock
Text="Please wait..."
Margin="0,0,0,10" />
</StackPanel>
</Page>
11 changes: 11 additions & 0 deletions App/Views/Pages/TrayWindowLoadingPage.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.UI.Xaml.Controls;

namespace Coder.Desktop.App.Views.Pages;

public sealed partial class TrayWindowLoadingPage : Page
{
public TrayWindowLoadingPage()
{
InitializeComponent();
}
}
Loading
Loading