Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1089677

Browse files
committedMar 9, 2025·
Rework CredentialManager
1 parent 90e85fb commit 1089677

File tree

14 files changed

+638
-109
lines changed

14 files changed

+638
-109
lines changed
 

‎App/App.xaml.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using System.Threading;
23
using System.Threading.Tasks;
4+
using Coder.Desktop.App.Models;
35
using Coder.Desktop.App.Services;
46
using Coder.Desktop.App.ViewModels;
57
using Coder.Desktop.App.Views;
@@ -46,17 +48,29 @@ public async Task ExitApplication()
4648
{
4749
_handleWindowClosed = false;
4850
Exit();
49-
var rpcManager = _services.GetRequiredService<IRpcController>();
51+
var rpcController = _services.GetRequiredService<IRpcController>();
5052
// TODO: send a StopRequest if we're connected???
51-
await rpcManager.DisposeAsync();
53+
await rpcController.DisposeAsync();
5254
Environment.Exit(0);
5355
}
5456

5557
protected override void OnLaunched(LaunchActivatedEventArgs args)
5658
{
57-
var trayWindow = _services.GetRequiredService<TrayWindow>();
59+
// Start connecting to the manager in the background.
60+
var rpcController = _services.GetRequiredService<IRpcController>();
61+
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
62+
// Passing in a CT with no cancellation is desired here, because
63+
// the named pipe open will block until the pipe comes up.
64+
_ = rpcController.Reconnect(CancellationToken.None);
65+
66+
// Load the credentials in the background. Even though we pass a CT
67+
// with no cancellation, the method itself will impose a timeout on the
68+
// HTTP portion.
69+
var credentialManager = _services.GetRequiredService<ICredentialManager>();
70+
_ = credentialManager.LoadCredentials(CancellationToken.None);
5871

5972
// Prevent the TrayWindow from closing, just hide it.
73+
var trayWindow = _services.GetRequiredService<TrayWindow>();
6074
trayWindow.Closed += (sender, args) =>
6175
{
6276
if (!_handleWindowClosed) return;

‎App/Services/CredentialManager.cs

Lines changed: 169 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
using System.Threading;
77
using System.Threading.Tasks;
88
using Coder.Desktop.App.Models;
9+
using Coder.Desktop.CoderSdk;
910
using Coder.Desktop.Vpn.Utilities;
10-
using CoderSdk;
1111

1212
namespace Coder.Desktop.App.Services;
1313

@@ -33,7 +33,7 @@ public interface ICredentialManager
3333
/// <summary>
3434
/// Get any sign-in URL. The returned value is not parsed to check if it's a valid URI.
3535
/// </summary>
36-
public string? GetSignInUri();
36+
public Task<string?> GetSignInUri();
3737

3838
/// <summary>
3939
/// Returns cached credentials or loads/verifies them from storage if not cached.
@@ -42,35 +42,76 @@ public interface ICredentialManager
4242

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

45-
public void ClearCredentials();
45+
public Task ClearCredentials(CancellationToken ct = default);
4646
}
4747

48+
public interface ICredentialBackend
49+
{
50+
public Task<RawCredentials?> ReadCredentials(CancellationToken ct = default);
51+
public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default);
52+
public Task DeleteCredentials(CancellationToken ct = default);
53+
}
54+
55+
/// <summary>
56+
/// Implements ICredentialManager using the Windows Credential Manager to
57+
/// store credentials.
58+
/// </summary>
4859
public class CredentialManager : ICredentialManager
4960
{
5061
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";
5162

52-
private readonly RaiiSemaphoreSlim _loadLock = new(1, 1);
53-
private readonly RaiiSemaphoreSlim _stateLock = new(1, 1);
54-
private CredentialModel? _latestCredentials;
63+
// _opLock is held for the full duration of SetCredentials, and partially
64+
// during LoadCredentials. _lock protects _inFlightLoad, _loadCts, and
65+
// writes to _latestCredentials.
66+
private readonly RaiiSemaphoreSlim _opLock = new(1, 1);
67+
68+
// _inFlightLoad and _loadCts are set at the beginning of a LoadCredentials
69+
// call.
70+
private Task<CredentialModel>? _inFlightLoad;
71+
private CancellationTokenSource? _loadCts;
72+
73+
// Reading and writing a reference in C# is always atomic, so this doesn't
74+
// need to be protected on reads with a lock in GetCachedCredentials.
75+
//
76+
// The volatile keyword disables optimizations on reads/writes which helps
77+
// other threads see the new value quickly (no guarantee that it's
78+
// immediate).
79+
private volatile CredentialModel? _latestCredentials;
80+
81+
private ICredentialBackend Backend { get; } = new WindowsCredentialBackend(CredentialsTargetName);
82+
83+
private ICoderApiClientFactory CoderApiClientFactory { get; } = new CoderApiClientFactory();
84+
85+
public CredentialManager()
86+
{
87+
}
88+
89+
public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory)
90+
{
91+
Backend = backend;
92+
CoderApiClientFactory = coderApiClientFactory;
93+
}
5594

5695
public event EventHandler<CredentialModel>? CredentialsChanged;
5796

5897
public CredentialModel GetCachedCredentials()
5998
{
60-
using var _ = _stateLock.Lock();
61-
if (_latestCredentials != null) return _latestCredentials.Clone();
99+
// No lock required to read the reference.
100+
var latestCreds = _latestCredentials;
101+
// No clone needed as the model is immutable.
102+
if (latestCreds != null) return latestCreds;
62103

63104
return new CredentialModel
64105
{
65106
State = CredentialState.Unknown,
66107
};
67108
}
68109

69-
public string? GetSignInUri()
110+
public async Task<string?> GetSignInUri()
70111
{
71112
try
72113
{
73-
var raw = ReadCredentials();
114+
var raw = await Backend.ReadCredentials();
74115
if (raw is not null && !string.IsNullOrWhiteSpace(raw.CoderUrl)) return raw.CoderUrl;
75116
}
76117
catch
@@ -81,42 +122,50 @@ public CredentialModel GetCachedCredentials()
81122
return null;
82123
}
83124

84-
public async Task<CredentialModel> LoadCredentials(CancellationToken ct = default)
125+
// LoadCredentials may be preempted by SetCredentials.
126+
public Task<CredentialModel> LoadCredentials(CancellationToken ct = default)
85127
{
86-
using var _ = await _loadLock.LockAsync(ct);
87-
using (await _stateLock.LockAsync(ct))
88-
{
89-
if (_latestCredentials != null) return _latestCredentials.Clone();
90-
}
128+
// This function is not `async` because we may return an existing task.
129+
// However, we still want to acquire the lock with the
130+
// CancellationToken so it can be canceled if needed.
131+
using var _ = _opLock.LockAsync(ct).Result;
132+
133+
// If we already have a cached value, return it.
134+
var latestCreds = _latestCredentials;
135+
if (latestCreds != null) return Task.FromResult(latestCreds);
136+
137+
// If we are already loading, return the existing task.
138+
if (_inFlightLoad != null) return _inFlightLoad;
139+
140+
// Otherwise, kick off a new load.
141+
// Note: subsequent loads returned from above will ignore the passed in
142+
// CancellationToken. We set a maximum timeout of 15 seconds anyway.
143+
_loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
144+
_loadCts.CancelAfter(TimeSpan.FromSeconds(15));
145+
_inFlightLoad = LoadCredentialsInner(_loadCts.Token);
146+
return _inFlightLoad;
147+
}
91148

92-
CredentialModel model;
93-
try
94-
{
95-
var raw = ReadCredentials();
96-
model = await PopulateModel(raw, ct);
97-
}
98-
catch
149+
public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct)
150+
{
151+
using var _ = await _opLock.LockAsync(ct);
152+
153+
// If there's an ongoing load, cancel it.
154+
if (_loadCts != null)
99155
{
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
104-
{
105-
State = CredentialState.Invalid,
106-
};
156+
await _loadCts.CancelAsync();
157+
_loadCts.Dispose();
158+
_loadCts = null;
159+
_inFlightLoad = null;
107160
}
108161

109-
UpdateState(model.Clone());
110-
return model.Clone();
111-
}
112-
113-
public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default)
114-
{
115162
if (string.IsNullOrWhiteSpace(coderUrl)) throw new ArgumentException("Coder URL is required", nameof(coderUrl));
116163
coderUrl = coderUrl.Trim();
117-
if (coderUrl.Length > 128) throw new ArgumentOutOfRangeException(nameof(coderUrl), "Coder URL is too long");
164+
if (coderUrl.Length > 128) throw new ArgumentException("Coder URL is too long", nameof(coderUrl));
118165
if (!Uri.TryCreate(coderUrl, UriKind.Absolute, out var uri))
119166
throw new ArgumentException($"Coder URL '{coderUrl}' is not a valid URL", nameof(coderUrl));
167+
if (uri.Scheme != "http" && uri.Scheme != "https")
168+
throw new ArgumentException("Coder URL must be HTTP or HTTPS", nameof(coderUrl));
120169
if (uri.PathAndQuery != "/") throw new ArgumentException("Coder URL must be the root URL", nameof(coderUrl));
121170
if (string.IsNullOrWhiteSpace(apiToken)) throw new ArgumentException("API token is required", nameof(apiToken));
122171
apiToken = apiToken.Trim();
@@ -126,21 +175,66 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
126175
CoderUrl = coderUrl,
127176
ApiToken = apiToken,
128177
};
129-
var model = await PopulateModel(raw, ct);
130-
WriteCredentials(raw);
178+
var populateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
179+
populateCts.CancelAfter(TimeSpan.FromSeconds(15));
180+
var model = await PopulateModel(raw, populateCts.Token);
181+
await Backend.WriteCredentials(raw, ct);
131182
UpdateState(model);
132183
}
133184

134-
public void ClearCredentials()
185+
public async Task ClearCredentials(CancellationToken ct = default)
135186
{
136-
NativeApi.DeleteCredentials(CredentialsTargetName);
187+
using var _ = await _opLock.LockAsync(ct);
188+
await Backend.DeleteCredentials(ct);
137189
UpdateState(new CredentialModel
138190
{
139191
State = CredentialState.Invalid,
140192
});
141193
}
142194

143-
private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, CancellationToken ct = default)
195+
private async Task<CredentialModel> LoadCredentialsInner(CancellationToken ct)
196+
{
197+
CredentialModel model;
198+
try
199+
{
200+
var raw = await Backend.ReadCredentials(ct);
201+
model = await PopulateModel(raw, ct);
202+
}
203+
catch
204+
{
205+
// This catch will be hit if a SetCredentials operation started, or
206+
// if the read/populate failed for some other reason (e.g. HTTP
207+
// timeout).
208+
//
209+
// We don't need to clear the credentials here, the app will think
210+
// they're unset and any subsequent SetCredentials call after the
211+
// user signs in again will overwrite the old invalid ones.
212+
model = new CredentialModel
213+
{
214+
State = CredentialState.Invalid,
215+
};
216+
}
217+
218+
// Grab the lock again so we can update the state. If we got cancelled
219+
// due to a SetCredentials call, _latestCredentials will be populated so
220+
// we just return that instead.
221+
using (await _opLock.LockAsync(ct))
222+
{
223+
var latestCreds = _latestCredentials;
224+
if (latestCreds != null) return latestCreds;
225+
if (_loadCts != null)
226+
{
227+
_loadCts.Dispose();
228+
_loadCts = null;
229+
_inFlightLoad = null;
230+
}
231+
232+
UpdateState(model);
233+
return model;
234+
}
235+
}
236+
237+
private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, CancellationToken ct)
144238
{
145239
if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
146240
string.IsNullOrWhiteSpace(credentials.ApiToken))
@@ -153,19 +247,21 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
153247
User me;
154248
try
155249
{
156-
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
157-
cts.CancelAfter(TimeSpan.FromSeconds(15));
158-
var sdkClient = new CoderApiClient(credentials.CoderUrl);
250+
var sdkClient = CoderApiClientFactory.Create(credentials.CoderUrl);
251+
// BuildInfo does not require authentication.
252+
buildInfo = await sdkClient.GetBuildInfo(ct);
159253
sdkClient.SetSessionToken(credentials.ApiToken);
160-
buildInfo = await sdkClient.GetBuildInfo(cts.Token);
161-
me = await sdkClient.GetUser(User.Me, cts.Token);
254+
me = await sdkClient.GetUser(User.Me, ct);
162255
}
163256
catch (Exception e)
164257
{
165258
throw new InvalidOperationException("Could not connect to or verify Coder server", e);
166259
}
167260

168261
ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version);
262+
if (string.IsNullOrWhiteSpace(me.Username))
263+
throw new InvalidOperationException("Could not retrieve user information, username is empty");
264+
169265
return new CredentialModel
170266
{
171267
State = CredentialState.Valid,
@@ -175,20 +271,27 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
175271
};
176272
}
177273

274+
// Lock must be held when calling this function.
178275
private void UpdateState(CredentialModel newModel)
179276
{
180-
using (_stateLock.Lock())
181-
{
182-
_latestCredentials = newModel.Clone();
183-
}
184-
277+
_latestCredentials = newModel;
185278
CredentialsChanged?.Invoke(this, newModel.Clone());
186279
}
280+
}
281+
282+
public class WindowsCredentialBackend : ICredentialBackend
283+
{
284+
private readonly string _credentialsTargetName;
285+
286+
public WindowsCredentialBackend(string credentialsTargetName)
287+
{
288+
_credentialsTargetName = credentialsTargetName;
289+
}
187290

188-
private static RawCredentials? ReadCredentials()
291+
public Task<RawCredentials?> ReadCredentials(CancellationToken ct = default)
189292
{
190-
var raw = NativeApi.ReadCredentials(CredentialsTargetName);
191-
if (raw == null) return null;
293+
var raw = NativeApi.ReadCredentials(_credentialsTargetName);
294+
if (raw == null) return Task.FromResult<RawCredentials?>(null);
192295

193296
RawCredentials? credentials;
194297
try
@@ -197,19 +300,23 @@ private void UpdateState(CredentialModel newModel)
197300
}
198301
catch (JsonException)
199302
{
200-
return null;
303+
credentials = null;
201304
}
202305

203-
if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
204-
string.IsNullOrWhiteSpace(credentials.ApiToken)) return null;
205-
206-
return credentials;
306+
return Task.FromResult(credentials);
207307
}
208308

209-
private static void WriteCredentials(RawCredentials credentials)
309+
public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default)
210310
{
211311
var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials);
212-
NativeApi.WriteCredentials(CredentialsTargetName, raw);
312+
NativeApi.WriteCredentials(_credentialsTargetName, raw);
313+
return Task.CompletedTask;
314+
}
315+
316+
public Task DeleteCredentials(CancellationToken ct = default)
317+
{
318+
NativeApi.DeleteCredentials(_credentialsTargetName);
319+
return Task.CompletedTask;
213320
}
214321

215322
private static class NativeApi

‎App/ViewModels/SignInViewModel.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,29 @@ public Uri GenTokenUrl
7979
public SignInViewModel(ICredentialManager credentialManager)
8080
{
8181
_credentialManager = credentialManager;
82-
CoderUrl = _credentialManager.GetSignInUri() ?? "";
83-
if (!string.IsNullOrWhiteSpace(CoderUrl)) CoderUrlTouched = true;
82+
}
83+
84+
// When the URL box loads, get the old URI from the credential manager.
85+
// This is an async operation on paper, but we would expect it to be
86+
// synchronous or extremely quick in practice.
87+
public void CoderUrl_Loaded(object sender, RoutedEventArgs e)
88+
{
89+
if (sender is not TextBox textBox) return;
90+
91+
var dispatcherQueue = textBox.DispatcherQueue;
92+
_credentialManager.GetSignInUri().ContinueWith(t =>
93+
{
94+
if (t.IsCompleted && !string.IsNullOrWhiteSpace(t.Result))
95+
dispatcherQueue.TryEnqueue(() =>
96+
{
97+
if (!CoderUrlTouched)
98+
{
99+
CoderUrl = t.Result;
100+
CoderUrlTouched = true;
101+
textBox.SelectionStart = CoderUrl.Length;
102+
}
103+
});
104+
});
84105
}
85106

86107
public void CoderUrl_FocusLost(object sender, RoutedEventArgs e)

‎App/Views/Pages/SignInUrlPage.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
Grid.Row="0"
4747
HorizontalAlignment="Stretch"
4848
PlaceholderText="https://coder.example.com"
49+
Loaded="{x:Bind ViewModel.CoderUrl_Loaded, Mode=OneWay}"
4950
LostFocus="{x:Bind ViewModel.CoderUrl_FocusLost, Mode=OneWay}"
5051
Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay}" />
5152

‎App/Views/TrayWindow.xaml.cs

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System;
22
using System.Runtime.InteropServices;
3-
using System.Threading;
43
using Windows.Foundation;
54
using Windows.Graphics;
65
using Windows.System;
@@ -28,7 +27,7 @@ public sealed partial class TrayWindow : Window
2827

2928
private readonly IRpcController _rpcController;
3029
private readonly ICredentialManager _credentialManager;
31-
private readonly TrayWindowLoadingPage _loadingPgae;
30+
private readonly TrayWindowLoadingPage _loadingPage;
3231
private readonly TrayWindowDisconnectedPage _disconnectedPage;
3332
private readonly TrayWindowLoginRequiredPage _loginRequiredPage;
3433
private readonly TrayWindowMainPage _mainPage;
@@ -40,7 +39,7 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
4039
{
4140
_rpcController = rpcController;
4241
_credentialManager = credentialManager;
43-
_loadingPgae = loadingPage;
42+
_loadingPage = loadingPage;
4443
_disconnectedPage = disconnectedPage;
4544
_loginRequiredPage = loginRequiredPage;
4645
_mainPage = mainPage;
@@ -54,14 +53,6 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
5453
credentialManager.CredentialsChanged += CredentialManager_CredentialsChanged;
5554
SetPageByState(rpcController.GetState(), credentialManager.GetCachedCredentials());
5655

57-
// Start connecting in the background.
58-
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
59-
_ = _rpcController.Reconnect(CancellationToken.None);
60-
61-
// Load the credentials in the background. Even though we pass a CT with no cancellation, the method itself will
62-
// impose a timeout on the HTTP portion.
63-
_ = _credentialManager.LoadCredentials(CancellationToken.None);
64-
6556
// Setting OpenCommand and ExitCommand directly in the .xaml doesn't seem to work for whatever reason.
6657
TrayIcon.OpenCommand = Tray_OpenCommand;
6758
TrayIcon.ExitCommand = Tray_ExitCommand;
@@ -89,7 +80,7 @@ private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel)
8980
{
9081
if (credentialModel.State == CredentialState.Unknown)
9182
{
92-
SetRootFrame(_loadingPgae);
83+
SetRootFrame(_loadingPage);
9384
return;
9485
}
9586

‎Coder.Desktop.sln

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vpn.DebugClient", "Vpn.Debu
2323
EndProject
2424
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer", "Installer\Installer.csproj", "{39F5B55A-09D8-477D-A3FA-ADAC29C52605}"
2525
EndProject
26+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.App", "Tests.App\Tests.App.csproj", "{3E91CED7-5528-4B46-8722-FB95D4FAB967}"
27+
EndProject
2628
Global
2729
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2830
Debug|Any CPU = Debug|Any CPU
@@ -203,8 +205,27 @@ Global
203205
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x64.Build.0 = Release|Any CPU
204206
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x86.ActiveCfg = Release|Any CPU
205207
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x86.Build.0 = Release|Any CPU
208+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
209+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|Any CPU.Build.0 = Debug|Any CPU
210+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|ARM64.ActiveCfg = Debug|Any CPU
211+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|ARM64.Build.0 = Debug|Any CPU
212+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x64.ActiveCfg = Debug|Any CPU
213+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x64.Build.0 = Debug|Any CPU
214+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x86.ActiveCfg = Debug|Any CPU
215+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x86.Build.0 = Debug|Any CPU
216+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|Any CPU.ActiveCfg = Release|Any CPU
217+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|Any CPU.Build.0 = Release|Any CPU
218+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|ARM64.ActiveCfg = Release|Any CPU
219+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|ARM64.Build.0 = Release|Any CPU
220+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x64.ActiveCfg = Release|Any CPU
221+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x64.Build.0 = Release|Any CPU
222+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x86.ActiveCfg = Release|Any CPU
223+
{3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x86.Build.0 = Release|Any CPU
206224
EndGlobalSection
207225
GlobalSection(SolutionProperties) = preSolution
208226
HideSolutionNode = FALSE
209227
EndGlobalSection
228+
GlobalSection(ExtensibilityGlobals) = postSolution
229+
SolutionGuid = {FC108D8D-B425-4DA0-B9CC-69670BCF4835}
230+
EndGlobalSection
210231
EndGlobal

‎CoderSdk/CoderApiClient.cs

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,25 @@
22
using System.Text.Json;
33
using System.Text.Json.Serialization;
44

5-
namespace CoderSdk;
5+
namespace Coder.Desktop.CoderSdk;
6+
7+
public interface ICoderApiClientFactory
8+
{
9+
public ICoderApiClient Create(string baseUrl);
10+
}
11+
12+
public class CoderApiClientFactory : ICoderApiClientFactory
13+
{
14+
public ICoderApiClient Create(string baseUrl)
15+
{
16+
return new CoderApiClient(baseUrl);
17+
}
18+
}
19+
20+
public partial interface ICoderApiClient
21+
{
22+
public void SetSessionToken(string token);
23+
}
624

725
/// <summary>
826
/// Changes names from PascalCase to snake_case.
@@ -24,11 +42,18 @@ public partial class CoderSdkJsonContext : JsonSerializerContext;
2442
/// <summary>
2543
/// Provides a limited selection of API methods for a Coder instance.
2644
/// </summary>
27-
public partial class CoderApiClient
45+
public partial class CoderApiClient : ICoderApiClient
2846
{
47+
public static readonly JsonSerializerOptions JsonOptions = new()
48+
{
49+
TypeInfoResolver = CoderSdkJsonContext.Default,
50+
PropertyNameCaseInsensitive = true,
51+
PropertyNamingPolicy = new SnakeCaseNamingPolicy(),
52+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
53+
};
54+
2955
// TODO: allow adding headers
3056
private readonly HttpClient _httpClient = new();
31-
private readonly JsonSerializerOptions _jsonOptions;
3257

3358
public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute))
3459
{
@@ -39,13 +64,6 @@ public CoderApiClient(Uri baseUrl)
3964
if (baseUrl.PathAndQuery != "/")
4065
throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl));
4166
_httpClient.BaseAddress = baseUrl;
42-
_jsonOptions = new JsonSerializerOptions
43-
{
44-
TypeInfoResolver = CoderSdkJsonContext.Default,
45-
PropertyNameCaseInsensitive = true,
46-
PropertyNamingPolicy = new SnakeCaseNamingPolicy(),
47-
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
48-
};
4967
}
5068

5169
public CoderApiClient(string baseUrl, string token) : this(baseUrl)
@@ -74,7 +92,7 @@ private async Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod m
7492

7593
if (payload is not null)
7694
{
77-
var json = JsonSerializer.Serialize(payload, typeof(TRequest), _jsonOptions);
95+
var json = JsonSerializer.Serialize(payload, typeof(TRequest), JsonOptions);
7896
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
7997
}
8098

@@ -83,7 +101,7 @@ private async Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod m
83101
res.EnsureSuccessStatusCode();
84102

85103
var content = await res.Content.ReadAsStringAsync(ct);
86-
var data = JsonSerializer.Deserialize<TResponse>(content, _jsonOptions);
104+
var data = JsonSerializer.Deserialize<TResponse>(content, JsonOptions);
87105
if (data is null) throw new JsonException("Deserialized response is null");
88106
return data;
89107
}

‎CoderSdk/Deployment.cs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
1-
namespace CoderSdk;
1+
namespace Coder.Desktop.CoderSdk;
2+
3+
public partial interface ICoderApiClient
4+
{
5+
public Task<BuildInfo> GetBuildInfo(CancellationToken ct = default);
6+
}
27

38
public class BuildInfo
49
{
5-
public string ExternalUrl { get; set; } = "";
610
public string Version { get; set; } = "";
7-
public string DashboardUrl { get; set; } = "";
8-
public bool Telemetry { get; set; } = false;
9-
public bool WorkspaceProxy { get; set; } = false;
10-
public string AgentApiVersion { get; set; } = "";
11-
public string ProvisionerApiVersion { get; set; } = "";
12-
public string UpgradeMessage { get; set; } = "";
13-
public string DeploymentId { get; set; } = "";
1411
}
1512

1613
public partial class CoderApiClient

‎CoderSdk/Users.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
namespace CoderSdk;
1+
namespace Coder.Desktop.CoderSdk;
2+
3+
public partial interface ICoderApiClient
4+
{
5+
public Task<User> GetUser(string user, CancellationToken ct = default);
6+
}
27

38
public class User
49
{
510
public const string Me = "me";
611

7-
// TODO: fill out more fields
812
public string Username { get; set; } = "";
913
}
1014

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
using System.Diagnostics;
2+
using Coder.Desktop.App.Models;
3+
using Coder.Desktop.App.Services;
4+
using Coder.Desktop.CoderSdk;
5+
using Moq;
6+
7+
namespace Coder.Desktop.Tests.App.Services;
8+
9+
[TestFixture]
10+
public class CredentialManagerTest
11+
{
12+
private const string TestServerUrl = "https://dev.coder.com";
13+
private const string TestApiToken = "abcdef1234-abcdef1234567890ABCDEF";
14+
private const string TestUsername = "dean";
15+
16+
[Test(Description = "End to end test with WindowsCredentialBackend")]
17+
[CancelAfter(30_000)]
18+
public async Task EndToEnd(CancellationToken ct)
19+
{
20+
var credentialBackend = new WindowsCredentialBackend($"Coder.Desktop.Test.App.{Guid.NewGuid()}");
21+
22+
// I lied. It's not fully end to end. We don't use a real or fake API
23+
// server for this and use a mock client instead.
24+
var apiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
25+
apiClient.Setup(x => x.SetSessionToken(TestApiToken));
26+
apiClient.Setup(x => x.GetBuildInfo(It.IsAny<CancellationToken>()).Result)
27+
.Returns(new BuildInfo { Version = "v2.20.0" });
28+
apiClient.Setup(x => x.GetUser(User.Me, It.IsAny<CancellationToken>()).Result)
29+
.Returns(new User { Username = TestUsername });
30+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
31+
apiClientFactory.Setup(x => x.Create(TestServerUrl))
32+
.Returns(apiClient.Object);
33+
34+
try
35+
{
36+
var manager1 = new CredentialManager(credentialBackend, apiClientFactory.Object);
37+
38+
// Cached credential should be unknown.
39+
var cred = manager1.GetCachedCredentials();
40+
Assert.That(cred.State, Is.EqualTo(CredentialState.Unknown));
41+
42+
// Load credentials from backend. No credentials are stored so it
43+
// should be invalid.
44+
cred = await manager1.LoadCredentials(ct);
45+
Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid));
46+
47+
// SetCredentials should succeed.
48+
await manager1.SetCredentials(TestServerUrl, TestApiToken, ct);
49+
50+
// Cached credential should be valid.
51+
cred = manager1.GetCachedCredentials();
52+
Assert.That(cred.State, Is.EqualTo(CredentialState.Valid));
53+
Assert.That(cred.CoderUrl, Is.EqualTo(TestServerUrl));
54+
Assert.That(cred.ApiToken, Is.EqualTo(TestApiToken));
55+
Assert.That(cred.Username, Is.EqualTo(TestUsername));
56+
57+
// Load credentials should return the same reference.
58+
var loadedCred = await manager1.LoadCredentials(ct);
59+
Assert.That(ReferenceEquals(cred, loadedCred), Is.True);
60+
61+
// A second manager should be able to load the same credentials.
62+
var manager2 = new CredentialManager(credentialBackend, apiClientFactory.Object);
63+
cred = await manager2.LoadCredentials(ct);
64+
Assert.That(cred.State, Is.EqualTo(CredentialState.Valid));
65+
Assert.That(cred.CoderUrl, Is.EqualTo(TestServerUrl));
66+
Assert.That(cred.ApiToken, Is.EqualTo(TestApiToken));
67+
Assert.That(cred.Username, Is.EqualTo(TestUsername));
68+
69+
// Clearing the credentials should make them invalid.
70+
await manager1.ClearCredentials(ct);
71+
cred = manager1.GetCachedCredentials();
72+
Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid));
73+
74+
// And loading them in a new manager should also be invalid.
75+
var manager3 = new CredentialManager(credentialBackend, apiClientFactory.Object);
76+
cred = await manager3.LoadCredentials(ct);
77+
Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid));
78+
}
79+
finally
80+
{
81+
// In case something goes wrong, make sure to clean up.
82+
await credentialBackend.DeleteCredentials(CancellationToken.None);
83+
}
84+
}
85+
86+
[Test(Description = "Test SetCredentials with invalid URL or token")]
87+
public void SetCredentialsInvalidUrlOrToken()
88+
{
89+
var credentialBackend = new Mock<ICredentialBackend>(MockBehavior.Strict);
90+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
91+
var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object);
92+
93+
var cases = new List<(string, string, string)>
94+
{
95+
(null!, TestApiToken, "Coder URL is required"),
96+
("", TestApiToken, "Coder URL is required"),
97+
(" ", TestApiToken, "Coder URL is required"),
98+
(new string('a', 129), TestApiToken, "Coder URL is too long"),
99+
("a", TestApiToken, "not a valid URL"),
100+
("ftp://dev.coder.com", TestApiToken, "Coder URL must be HTTP or HTTPS"),
101+
102+
(TestServerUrl, null!, "API token is required"),
103+
(TestServerUrl, "", "API token is required"),
104+
(TestServerUrl, " ", "API token is required"),
105+
};
106+
107+
foreach (var (url, token, expectedMessage) in cases)
108+
{
109+
var ex = Assert.ThrowsAsync<ArgumentException>(() =>
110+
manager.SetCredentials(url, token, CancellationToken.None));
111+
Assert.That(ex.Message, Does.Contain(expectedMessage));
112+
}
113+
}
114+
115+
[Test(Description = "Invalid server buildinfo response")]
116+
public void InvalidServerBuildInfoResponse()
117+
{
118+
var credentialBackend = new Mock<ICredentialBackend>(MockBehavior.Strict);
119+
var apiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
120+
apiClient.Setup(x => x.GetBuildInfo(It.IsAny<CancellationToken>()).Result)
121+
.Throws(new Exception("Test exception"));
122+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
123+
apiClientFactory.Setup(x => x.Create(TestServerUrl))
124+
.Returns(apiClient.Object);
125+
126+
// Attempt a set.
127+
var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object);
128+
var ex = Assert.ThrowsAsync<InvalidOperationException>(() =>
129+
manager.SetCredentials(TestServerUrl, TestApiToken, CancellationToken.None));
130+
Assert.That(ex.Message, Does.Contain("Could not connect to or verify Coder server"));
131+
132+
// Attempt a load.
133+
credentialBackend.Setup(x => x.ReadCredentials(It.IsAny<CancellationToken>()).Result)
134+
.Returns(new RawCredentials
135+
{
136+
CoderUrl = TestServerUrl,
137+
ApiToken = TestApiToken,
138+
});
139+
var cred = manager.LoadCredentials(CancellationToken.None).Result;
140+
Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid));
141+
}
142+
143+
[Test(Description = "Invalid server version")]
144+
public void InvalidServerVersion()
145+
{
146+
var credentialBackend = new Mock<ICredentialBackend>(MockBehavior.Strict);
147+
var apiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
148+
apiClient.Setup(x => x.GetBuildInfo(It.IsAny<CancellationToken>()).Result)
149+
.Returns(new BuildInfo { Version = "v2.19.0" });
150+
apiClient.Setup(x => x.SetSessionToken(TestApiToken));
151+
apiClient.Setup(x => x.GetUser(User.Me, It.IsAny<CancellationToken>()).Result)
152+
.Returns(new User { Username = TestUsername });
153+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
154+
apiClientFactory.Setup(x => x.Create(TestServerUrl))
155+
.Returns(apiClient.Object);
156+
157+
// Attempt a set.
158+
var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object);
159+
var ex = Assert.ThrowsAsync<ArgumentException>(() =>
160+
manager.SetCredentials(TestServerUrl, TestApiToken, CancellationToken.None));
161+
Assert.That(ex.Message, Does.Contain("not within required server version range"));
162+
163+
// Attempt a load.
164+
credentialBackend.Setup(x => x.ReadCredentials(It.IsAny<CancellationToken>()).Result)
165+
.Returns(new RawCredentials
166+
{
167+
CoderUrl = TestServerUrl,
168+
ApiToken = TestApiToken,
169+
});
170+
var cred = manager.LoadCredentials(CancellationToken.None).Result;
171+
Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid));
172+
}
173+
174+
[Test(Description = "Invalid server user response")]
175+
public void InvalidServerUserResponse()
176+
{
177+
var credentialBackend = new Mock<ICredentialBackend>(MockBehavior.Strict);
178+
var apiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
179+
apiClient.Setup(x => x.GetBuildInfo(It.IsAny<CancellationToken>()).Result)
180+
.Returns(new BuildInfo { Version = "v2.20.0" });
181+
apiClient.Setup(x => x.SetSessionToken(TestApiToken));
182+
apiClient.Setup(x => x.GetUser(User.Me, It.IsAny<CancellationToken>()).Result)
183+
.Throws(new Exception("Test exception"));
184+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
185+
apiClientFactory.Setup(x => x.Create(TestServerUrl))
186+
.Returns(apiClient.Object);
187+
188+
// Attempt a set.
189+
var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object);
190+
var ex = Assert.ThrowsAsync<InvalidOperationException>(() =>
191+
manager.SetCredentials(TestServerUrl, TestApiToken, CancellationToken.None));
192+
Assert.That(ex.Message, Does.Contain("Could not connect to or verify Coder server"));
193+
194+
// Attempt a load.
195+
credentialBackend.Setup(x => x.ReadCredentials(It.IsAny<CancellationToken>()).Result)
196+
.Returns(new RawCredentials
197+
{
198+
CoderUrl = TestServerUrl,
199+
ApiToken = TestApiToken,
200+
});
201+
var cred = manager.LoadCredentials(CancellationToken.None).Result;
202+
Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid));
203+
}
204+
205+
[Test(Description = "Invalid username")]
206+
public void InvalidUsername()
207+
{
208+
var credentialBackend = new Mock<ICredentialBackend>(MockBehavior.Strict);
209+
var apiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
210+
apiClient.Setup(x => x.GetBuildInfo(It.IsAny<CancellationToken>()).Result)
211+
.Returns(new BuildInfo { Version = "v2.20.0" });
212+
apiClient.Setup(x => x.SetSessionToken(TestApiToken));
213+
apiClient.Setup(x => x.GetUser(User.Me, It.IsAny<CancellationToken>()).Result)
214+
.Returns(new User { Username = "" });
215+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
216+
apiClientFactory.Setup(x => x.Create(TestServerUrl))
217+
.Returns(apiClient.Object);
218+
219+
// Attempt a set.
220+
var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object);
221+
var ex = Assert.ThrowsAsync<InvalidOperationException>(() =>
222+
manager.SetCredentials(TestServerUrl, TestApiToken, CancellationToken.None));
223+
Assert.That(ex.Message, Does.Contain("username is empty"));
224+
225+
// Attempt a load.
226+
credentialBackend.Setup(x => x.ReadCredentials(It.IsAny<CancellationToken>()).Result)
227+
.Returns(new RawCredentials
228+
{
229+
CoderUrl = TestServerUrl,
230+
ApiToken = TestApiToken,
231+
});
232+
var cred = manager.LoadCredentials(CancellationToken.None).Result;
233+
Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid));
234+
}
235+
236+
[Test(Description = "Duplicate loads should use the same Task")]
237+
[CancelAfter(30_000)]
238+
public async Task DuplicateLoads(CancellationToken ct)
239+
{
240+
var credentialBackend = new Mock<ICredentialBackend>(MockBehavior.Strict);
241+
credentialBackend.Setup(x => x.ReadCredentials(It.IsAny<CancellationToken>()).Result)
242+
.Returns(new RawCredentials
243+
{
244+
CoderUrl = TestServerUrl,
245+
ApiToken = TestApiToken,
246+
})
247+
.Verifiable(Times.Exactly(1));
248+
var apiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
249+
// To accomplish delay, the GetBuildInfo will wait for a TCS.
250+
var tcs = new TaskCompletionSource();
251+
apiClient.Setup(x => x.GetBuildInfo(It.IsAny<CancellationToken>()))
252+
.Returns(async (CancellationToken _) =>
253+
{
254+
await tcs.Task;
255+
return new BuildInfo { Version = "v2.20.0" };
256+
})
257+
.Verifiable(Times.Exactly(1));
258+
apiClient.Setup(x => x.SetSessionToken(TestApiToken));
259+
apiClient.Setup(x => x.GetUser(User.Me, It.IsAny<CancellationToken>()).Result)
260+
.Returns(new User { Username = TestUsername })
261+
.Verifiable(Times.Exactly(1));
262+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
263+
apiClientFactory.Setup(x => x.Create(TestServerUrl))
264+
.Returns(apiClient.Object)
265+
.Verifiable(Times.Exactly(1));
266+
267+
var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object);
268+
var cred1Task = manager.LoadCredentials(ct);
269+
var cred2Task = manager.LoadCredentials(ct);
270+
Assert.That(ReferenceEquals(cred1Task, cred2Task), Is.True);
271+
tcs.SetResult();
272+
var cred1 = await cred1Task;
273+
var cred2 = await cred2Task;
274+
Assert.That(ReferenceEquals(cred1, cred2), Is.True);
275+
276+
credentialBackend.Verify();
277+
apiClient.Verify();
278+
apiClientFactory.Verify();
279+
}
280+
281+
[Test(Description = "A set during a load should cancel the load")]
282+
[CancelAfter(30_000)]
283+
public async Task SetDuringLoad(CancellationToken ct)
284+
{
285+
var credentialBackend = new Mock<ICredentialBackend>(MockBehavior.Strict);
286+
// To accomplish a delay on the load, ReadCredentials will block on the CT.
287+
credentialBackend.Setup(x => x.ReadCredentials(It.IsAny<CancellationToken>()))
288+
.Returns(async (CancellationToken innerCt) =>
289+
{
290+
await Task.Delay(Timeout.Infinite, innerCt);
291+
throw new UnreachableException();
292+
});
293+
credentialBackend.Setup(x =>
294+
x.WriteCredentials(
295+
It.Is<RawCredentials>(c => c.CoderUrl == TestServerUrl && c.ApiToken == TestApiToken),
296+
It.IsAny<CancellationToken>()))
297+
.Returns(Task.CompletedTask);
298+
var apiClient = new Mock<ICoderApiClient>(MockBehavior.Strict);
299+
apiClient.Setup(x => x.GetBuildInfo(It.IsAny<CancellationToken>()).Result)
300+
.Returns(new BuildInfo { Version = "v2.20.0" });
301+
apiClient.Setup(x => x.SetSessionToken(TestApiToken));
302+
apiClient.Setup(x => x.GetUser(User.Me, It.IsAny<CancellationToken>()).Result)
303+
.Returns(new User { Username = TestUsername });
304+
var apiClientFactory = new Mock<ICoderApiClientFactory>(MockBehavior.Strict);
305+
apiClientFactory.Setup(x => x.Create(TestServerUrl))
306+
.Returns(apiClient.Object);
307+
308+
var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object);
309+
// Start a load...
310+
var loadTask = manager.LoadCredentials(ct);
311+
// Then fully perform a set.
312+
await manager.SetCredentials(TestServerUrl, TestApiToken, ct);
313+
// The load should have been cancelled.
314+
Assert.ThrowsAsync<TaskCanceledException>(() => loadTask);
315+
}
316+
}

‎Tests.App/Tests.App.csproj

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<AssemblyName>Coder.Desktop.Tests.App</AssemblyName>
5+
<RootNamespace>Coder.Desktop.Tests.App</RootNamespace>
6+
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
7+
<LangVersion>preview</LangVersion>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
<Nullable>enable</Nullable>
10+
<EnableMsixTooling>true</EnableMsixTooling>
11+
12+
<IsPackable>false</IsPackable>
13+
<IsTestProject>true</IsTestProject>
14+
</PropertyGroup>
15+
16+
<ItemGroup>
17+
<PackageReference Include="coverlet.collector" Version="6.0.4">
18+
<PrivateAssets>all</PrivateAssets>
19+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
20+
</PackageReference>
21+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
22+
<PackageReference Include="Moq" Version="4.20.72" />
23+
<PackageReference Include="NUnit" Version="4.3.2" />
24+
<PackageReference Include="NUnit.Analyzers" Version="4.6.0">
25+
<PrivateAssets>all</PrivateAssets>
26+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
27+
</PackageReference>
28+
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
29+
</ItemGroup>
30+
31+
<ItemGroup>
32+
<Using Include="NUnit.Framework" />
33+
</ItemGroup>
34+
35+
<ItemGroup>
36+
<ProjectReference Include="..\App\App.csproj" />
37+
</ItemGroup>
38+
</Project>

‎Tests.Vpn/Utilities/ServerVersionUtilitiesTest.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ public void InvalidVersions()
3030

3131
foreach (var (version, expectedErrorMessage) in invalidVersions)
3232
{
33-
var ex = Assert.Throws<ArgumentException>(() => ServerVersionUtilities.ParseAndValidateServerVersion(version));
33+
var ex = Assert.Throws<ArgumentException>(() =>
34+
ServerVersionUtilities.ParseAndValidateServerVersion(version));
3435
Assert.That(ex.Message, Does.Contain(expectedErrorMessage));
3536
}
3637
}

‎Vpn.Service/Manager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
using System.Runtime.InteropServices;
2+
using Coder.Desktop.CoderSdk;
23
using Coder.Desktop.Vpn.Proto;
34
using Coder.Desktop.Vpn.Utilities;
4-
using CoderSdk;
55
using Microsoft.Extensions.Logging;
66
using Microsoft.Extensions.Options;
77
using Semver;

‎Vpn/Utilities/RaiiSemaphoreSlim.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,30 @@ public IDisposable Lock()
3030
return new Locker(_semaphore);
3131
}
3232

33-
public async ValueTask<IDisposable> LockAsync(CancellationToken ct = default)
33+
public async Task<IDisposable> LockAsync(CancellationToken ct = default)
3434
{
3535
await _semaphore.WaitAsync(ct);
3636
return new Locker(_semaphore);
3737
}
3838

39-
public async ValueTask<IDisposable?> LockAsync(TimeSpan timeout, CancellationToken ct = default)
39+
public async Task<IDisposable?> LockAsync(TimeSpan timeout, CancellationToken ct = default)
4040
{
4141
if (!await _semaphore.WaitAsync(timeout, ct)) return null;
4242
return new Locker(_semaphore);
4343
}
4444

4545
private class Locker : IDisposable
4646
{
47-
private readonly SemaphoreSlim _semaphore1;
47+
private readonly SemaphoreSlim _semaphore;
4848

4949
public Locker(SemaphoreSlim semaphore)
5050
{
51-
_semaphore1 = semaphore;
51+
_semaphore = semaphore;
5252
}
5353

5454
public void Dispose()
5555
{
56-
_semaphore1.Release();
56+
_semaphore.Release();
5757
GC.SuppressFinalize(this);
5858
}
5959
}

0 commit comments

Comments
 (0)
Please sign in to comment.