Skip to content

Commit cb3a049

Browse files
committed
chore: check server version on sign-in and launch
Updates CredentialModel to have an additional state "Unknown" during startup while credentials are validated in the background asynchronously (15s timeout). While loading credentials on startup, the tray app shows a loading message. While updating credentials, we now check that the server version falls in our expected range.
1 parent e1d9774 commit cb3a049

21 files changed

+313
-88
lines changed

App/App.xaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public App()
2626
services.AddTransient<SignInWindow>();
2727

2828
// TrayWindow views and view models
29+
services.AddTransient<TrayWindowLoadingPage>();
2930
services.AddTransient<TrayWindowDisconnectedViewModel>();
3031
services.AddTransient<TrayWindowDisconnectedPage>();
3132
services.AddTransient<TrayWindowLoginRequiredViewModel>();

App/Models/CredentialModel.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,33 @@ namespace Coder.Desktop.App.Models;
22

33
public enum CredentialState
44
{
5+
// Unknown means "we haven't checked yet"
6+
Unknown,
7+
8+
// Invalid means "we checked and there's either no saved credentials or they are not valid"
59
Invalid,
10+
11+
// Valid means "we checked and there are saved credentials and they are valid"
612
Valid,
713
}
814

915
public class CredentialModel
1016
{
11-
public CredentialState State { get; set; } = CredentialState.Invalid;
17+
public CredentialState State { get; set; } = CredentialState.Unknown;
1218

1319
public string? CoderUrl { get; set; }
1420
public string? ApiToken { get; set; }
1521

22+
public string? Username { get; set; }
23+
1624
public CredentialModel Clone()
1725
{
1826
return new CredentialModel
1927
{
2028
State = State,
2129
CoderUrl = CoderUrl,
2230
ApiToken = ApiToken,
31+
Username = Username,
2332
};
2433
}
2534
}

App/Services/CredentialManager.cs

Lines changed: 106 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,27 @@ public class RawCredentials
1818
}
1919

2020
[JsonSerializable(typeof(RawCredentials))]
21-
public partial class RawCredentialsJsonContext : JsonSerializerContext
22-
{
23-
}
21+
public partial class RawCredentialsJsonContext : JsonSerializerContext;
2422

2523
public interface ICredentialManager
2624
{
2725
public event EventHandler<CredentialModel> CredentialsChanged;
2826

29-
public CredentialModel GetCredentials();
27+
/// <summary>
28+
/// Returns cached credentials or an invalid credential model if none are cached. It's preferable to use
29+
/// LoadCredentials if you are operating in an async context.
30+
/// </summary>
31+
public CredentialModel GetCachedCredentials();
32+
33+
/// <summary>
34+
/// Get any sign-in URL. The returned value is not parsed to check if it's a valid URI.
35+
/// </summary>
36+
public string? GetSignInUri();
37+
38+
/// <summary>
39+
/// Returns cached credentials or loads/verifies them from storage if not cached.
40+
/// </summary>
41+
public Task<CredentialModel> LoadCredentials(CancellationToken ct = default);
3042

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

@@ -37,30 +49,65 @@ public class CredentialManager : ICredentialManager
3749
{
3850
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";
3951

40-
private readonly RaiiSemaphoreSlim _lock = new(1, 1);
52+
private readonly RaiiSemaphoreSlim _loadLock = new(1, 1);
53+
private readonly RaiiSemaphoreSlim _stateLock = new(1, 1);
4154
private CredentialModel? _latestCredentials;
4255

4356
public event EventHandler<CredentialModel>? CredentialsChanged;
4457

45-
public CredentialModel GetCredentials()
58+
public CredentialModel GetCachedCredentials()
4659
{
47-
using var _ = _lock.Lock();
60+
using var _ = _stateLock.Lock();
4861
if (_latestCredentials != null) return _latestCredentials.Clone();
4962

50-
var rawCredentials = ReadCredentials();
51-
if (rawCredentials is null)
52-
_latestCredentials = new CredentialModel
63+
return new CredentialModel
64+
{
65+
State = CredentialState.Unknown,
66+
};
67+
}
68+
69+
public string? GetSignInUri()
70+
{
71+
try
72+
{
73+
var raw = ReadCredentials();
74+
if (raw is not null && !string.IsNullOrWhiteSpace(raw.CoderUrl)) return raw.CoderUrl;
75+
}
76+
catch
77+
{
78+
// ignored
79+
}
80+
81+
return null;
82+
}
83+
84+
public async Task<CredentialModel> LoadCredentials(CancellationToken ct = default)
85+
{
86+
using var _ = await _loadLock.LockAsync(ct);
87+
using (await _stateLock.LockAsync(ct))
88+
{
89+
if (_latestCredentials != null) return _latestCredentials.Clone();
90+
}
91+
92+
CredentialModel model;
93+
try
94+
{
95+
var raw = ReadCredentials();
96+
model = await PopulateModel(raw, ct);
97+
}
98+
catch (Exception e)
99+
{
100+
// We don't need to clear the credentials here, the app will think
101+
// they're unset and any subsequent SetCredentials call after the
102+
// user signs in again will overwrite the old invalid ones.
103+
model = new CredentialModel
53104
{
54105
State = CredentialState.Invalid,
55106
};
56-
else
57-
_latestCredentials = new CredentialModel
58-
{
59-
State = CredentialState.Valid,
60-
CoderUrl = rawCredentials.CoderUrl,
61-
ApiToken = rawCredentials.ApiToken,
62-
};
63-
return _latestCredentials.Clone();
107+
}
108+
109+
UpdateState(model.Clone());
110+
return model.Clone();
64111
}
65112

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

79-
try
80-
{
81-
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
82-
cts.CancelAfter(TimeSpan.FromSeconds(15));
83-
var sdkClient = new CoderApiClient(uri);
84-
sdkClient.SetSessionToken(apiToken);
85-
// TODO: we should probably perform a version check here too,
86-
// rather than letting the service do it on Start
87-
_ = await sdkClient.GetBuildInfo(cts.Token);
88-
_ = await sdkClient.GetUser(User.Me, cts.Token);
89-
}
90-
catch (Exception e)
91-
{
92-
throw new InvalidOperationException("Could not connect to or verify Coder server", e);
93-
}
94-
95-
WriteCredentials(new RawCredentials
124+
var raw = new RawCredentials
96125
{
97126
CoderUrl = coderUrl,
98127
ApiToken = apiToken,
99-
});
100-
101-
UpdateState(new CredentialModel
102-
{
103-
State = CredentialState.Valid,
104-
CoderUrl = coderUrl,
105-
ApiToken = apiToken,
106-
});
128+
};
129+
var model = await PopulateModel(raw, ct);
130+
WriteCredentials(raw);
131+
UpdateState(model);
107132
}
108133

109134
public void ClearCredentials()
@@ -112,14 +137,47 @@ public void ClearCredentials()
112137
UpdateState(new CredentialModel
113138
{
114139
State = CredentialState.Invalid,
115-
CoderUrl = null,
116-
ApiToken = null,
117140
});
118141
}
119142

143+
private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, CancellationToken ct = default)
144+
{
145+
if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
146+
string.IsNullOrWhiteSpace(credentials.ApiToken))
147+
return new CredentialModel
148+
{
149+
State = CredentialState.Invalid,
150+
};
151+
152+
BuildInfo buildInfo;
153+
User me;
154+
try
155+
{
156+
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
157+
cts.CancelAfter(TimeSpan.FromSeconds(15));
158+
var sdkClient = new CoderApiClient(credentials.CoderUrl);
159+
sdkClient.SetSessionToken(credentials.ApiToken);
160+
buildInfo = await sdkClient.GetBuildInfo(cts.Token);
161+
me = await sdkClient.GetUser(User.Me, cts.Token);
162+
}
163+
catch (Exception e)
164+
{
165+
throw new InvalidOperationException("Could not connect to or verify Coder server", e);
166+
}
167+
168+
ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version);
169+
return new CredentialModel
170+
{
171+
State = CredentialState.Valid,
172+
CoderUrl = credentials.CoderUrl,
173+
ApiToken = credentials.ApiToken,
174+
Username = me.Username,
175+
};
176+
}
177+
120178
private void UpdateState(CredentialModel newModel)
121179
{
122-
using (_lock.Lock())
180+
using (_stateLock.Lock())
123181
{
124182
_latestCredentials = newModel.Clone();
125183
}

App/Services/RpcController.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,10 @@ public async Task StartVpn(CancellationToken ct = default)
155155
using var _ = await AcquireOperationLockNowAsync();
156156
AssertRpcConnected();
157157

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

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

App/ViewModels/SignInViewModel.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using CommunityToolkit.Mvvm.ComponentModel;
77
using CommunityToolkit.Mvvm.Input;
88
using Microsoft.UI.Xaml;
9+
using Microsoft.UI.Xaml.Controls;
910

1011
namespace Coder.Desktop.App.ViewModels;
1112

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

36-
[ObservableProperty] public partial string? SignInError { get; set; } = null;
37-
3837
[ObservableProperty] public partial bool SignInLoading { get; set; } = false;
3938

4039
public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null;
@@ -80,6 +79,8 @@ public Uri GenTokenUrl
8079
public SignInViewModel(ICredentialManager credentialManager)
8180
{
8281
_credentialManager = credentialManager;
82+
CoderUrl = _credentialManager.GetSignInUri() ?? "";
83+
if (!string.IsNullOrWhiteSpace(CoderUrl)) CoderUrlTouched = true;
8384
}
8485

8586
public void CoderUrl_FocusLost(object sender, RoutedEventArgs e)
@@ -117,7 +118,6 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow)
117118
try
118119
{
119120
SignInLoading = true;
120-
SignInError = null;
121121

122122
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
123123
await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token);
@@ -126,7 +126,14 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow)
126126
}
127127
catch (Exception e)
128128
{
129-
SignInError = $"Failed to sign in: {e}";
129+
var dialog = new ContentDialog
130+
{
131+
Title = "Failed to sign in",
132+
Content = $"{e}",
133+
CloseButtonText = "Ok",
134+
XamlRoot = signInWindow.Content.XamlRoot,
135+
};
136+
_ = await dialog.ShowAsync();
130137
}
131138
finally
132139
{

App/ViewModels/TrayWindowViewModel.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public void Initialize(DispatcherQueue dispatcherQueue)
6262
UpdateFromRpcModel(_rpcController.GetState());
6363

6464
_credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel);
65-
UpdateFromCredentialsModel(_credentialManager.GetCredentials());
65+
UpdateFromCredentialsModel(_credentialManager.GetCachedCredentials());
6666
}
6767

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

9191
// Get the current dashboard URL.
92-
var credentialModel = _credentialManager.GetCredentials();
92+
var credentialModel = _credentialManager.GetCachedCredentials();
9393
Uri? coderUri = null;
9494
if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl))
9595
try

App/Views/Pages/SignInTokenPage.xaml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,5 @@
9595
Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}"
9696
CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" />
9797
</StackPanel>
98-
99-
<TextBlock
100-
Text="{x:Bind ViewModel.SignInError, Mode=OneWay}"
101-
HorizontalAlignment="Center"
102-
Foreground="Red" />
10398
</StackPanel>
10499
</Page>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
3+
<Page
4+
x:Class="Coder.Desktop.App.Views.Pages.TrayWindowLoadingPage"
5+
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
6+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
7+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
8+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
9+
mc:Ignorable="d">
10+
11+
<StackPanel
12+
Orientation="Vertical"
13+
HorizontalAlignment="Stretch"
14+
VerticalAlignment="Top"
15+
Padding="20,20,20,30"
16+
Spacing="10">
17+
18+
<TextBlock
19+
Text="CoderVPN"
20+
FontSize="18"
21+
VerticalAlignment="Center" />
22+
<TextBlock
23+
Text="Please wait..."
24+
Margin="0,0,0,10" />
25+
</StackPanel>
26+
</Page>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Microsoft.UI.Xaml.Controls;
2+
3+
namespace Coder.Desktop.App.Views.Pages;
4+
5+
public sealed partial class TrayWindowLoadingPage : Page
6+
{
7+
public TrayWindowLoadingPage()
8+
{
9+
InitializeComponent();
10+
}
11+
}

0 commit comments

Comments
 (0)