Skip to content

chore: check server version on sign-in and launch #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 11, 2025
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