Skip to content

Commit 6d2985e

Browse files
committed
feat: add vpn start progress
1 parent 22c9bcd commit 6d2985e

File tree

17 files changed

+421
-91
lines changed

17 files changed

+421
-91
lines changed

.idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

App/Models/RpcModel.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,30 @@ public enum VpnLifecycle
1919
Stopping,
2020
}
2121

22+
public class VpnStartupProgress
23+
{
24+
public double Progress { get; set; } = 0.0; // 0.0 to 1.0
25+
public string Message { get; set; } = string.Empty;
26+
27+
public VpnStartupProgress Clone()
28+
{
29+
return new VpnStartupProgress
30+
{
31+
Progress = Progress,
32+
Message = Message,
33+
};
34+
}
35+
}
36+
2237
public class RpcModel
2338
{
2439
public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected;
2540

2641
public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
2742

43+
// Nullable because it is only set when the VpnLifecycle is Starting
44+
public VpnStartupProgress? VpnStartupProgress { get; set; }
45+
2846
public IReadOnlyList<Workspace> Workspaces { get; set; } = [];
2947

3048
public IReadOnlyList<Agent> Agents { get; set; } = [];
@@ -35,6 +53,7 @@ public RpcModel Clone()
3553
{
3654
RpcLifecycle = RpcLifecycle,
3755
VpnLifecycle = VpnLifecycle,
56+
VpnStartupProgress = VpnStartupProgress?.Clone(),
3857
Workspaces = Workspaces,
3958
Agents = Agents,
4059
};

App/Properties/PublishProfiles/win-arm64.pubxml

Lines changed: 0 additions & 12 deletions
This file was deleted.

App/Properties/PublishProfiles/win-x64.pubxml

Lines changed: 0 additions & 12 deletions
This file was deleted.

App/Properties/PublishProfiles/win-x86.pubxml

Lines changed: 0 additions & 12 deletions
This file was deleted.

App/Services/RpcController.cs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,12 @@ public async Task StartVpn(CancellationToken ct = default)
161161
throw new RpcOperationException(
162162
$"Cannot start VPN without valid credentials, current state: {credentials.State}");
163163

164-
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Starting; });
164+
MutateState(state =>
165+
{
166+
state.VpnLifecycle = VpnLifecycle.Starting;
167+
// Explicitly clear the startup progress.
168+
state.VpnStartupProgress = null;
169+
});
165170

166171
ServiceMessage reply;
167172
try
@@ -251,6 +256,9 @@ private void MutateState(Action<RpcModel> mutator)
251256
using (_stateLock.Lock())
252257
{
253258
mutator(_state);
259+
// Unset the startup progress if the VpnLifecycle is not Starting
260+
if (_state.VpnLifecycle != VpnLifecycle.Starting)
261+
_state.VpnStartupProgress = null;
254262
newState = _state.Clone();
255263
}
256264

@@ -283,15 +291,32 @@ private void ApplyStatusUpdate(Status status)
283291
});
284292
}
285293

294+
private void ApplyStartProgressUpdate(StartProgress message)
295+
{
296+
MutateState(state =>
297+
{
298+
// MutateState will undo these changes if it doesn't believe we're
299+
// in the "Starting" state.
300+
state.VpnStartupProgress = new VpnStartupProgress
301+
{
302+
Progress = message.Progress,
303+
Message = message.Message,
304+
};
305+
});
306+
}
307+
286308
private void SpeakerOnReceive(ReplyableRpcMessage<ClientMessage, ServiceMessage> message)
287309
{
288310
switch (message.Message.MsgCase)
289311
{
312+
case ServiceMessage.MsgOneofCase.Start:
313+
case ServiceMessage.MsgOneofCase.Stop:
290314
case ServiceMessage.MsgOneofCase.Status:
291315
ApplyStatusUpdate(message.Message.Status);
292316
break;
293-
case ServiceMessage.MsgOneofCase.Start:
294-
case ServiceMessage.MsgOneofCase.Stop:
317+
case ServiceMessage.MsgOneofCase.StartProgress:
318+
ApplyStartProgressUpdate(message.Message.StartProgress);
319+
break;
295320
case ServiceMessage.MsgOneofCase.None:
296321
default:
297322
// TODO: log unexpected message

App/ViewModels/TrayWindowViewModel.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
2929
{
3030
private const int MaxAgents = 5;
3131
private const string DefaultDashboardUrl = "https://coder.com";
32-
private const string DefaultHostnameSuffix = ".coder";
32+
private const string DefaultStartProgressMessage = "Starting Coder Connect...";
3333

3434
private readonly IServiceProvider _services;
3535
private readonly IRpcController _rpcController;
@@ -53,6 +53,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
5353

5454
[ObservableProperty]
5555
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
56+
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
5657
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
5758
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
5859
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
@@ -63,14 +64,33 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
6364

6465
[ObservableProperty]
6566
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
67+
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
6668
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
6769
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
6870
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
6971
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
7072
[NotifyPropertyChangedFor(nameof(ShowFailedSection))]
7173
public partial string? VpnFailedMessage { get; set; } = null;
7274

73-
public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started;
75+
[ObservableProperty]
76+
[NotifyPropertyChangedFor(nameof(VpnStartProgressIsIndeterminate))]
77+
[NotifyPropertyChangedFor(nameof(VpnStartProgressValueOrDefault))]
78+
public partial int? VpnStartProgressValue { get; set; } = null;
79+
80+
public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0;
81+
82+
[ObservableProperty]
83+
[NotifyPropertyChangedFor(nameof(VpnStartProgressMessageOrDefault))]
84+
public partial string? VpnStartProgressMessage { get; set; } = null;
85+
86+
public string VpnStartProgressMessageOrDefault =>
87+
string.IsNullOrEmpty(VpnStartProgressMessage) ? DefaultStartProgressMessage : VpnStartProgressMessage;
88+
89+
public bool VpnStartProgressIsIndeterminate => VpnStartProgressValueOrDefault == 0;
90+
91+
public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Starting and not VpnLifecycle.Started;
92+
93+
public bool ShowVpnStartProgressSection => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Starting;
7494

7595
public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started;
7696

@@ -170,6 +190,20 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
170190
VpnLifecycle = rpcModel.VpnLifecycle;
171191
VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
172192

193+
// VpnStartupProgress is only set when the VPN is starting.
194+
if (rpcModel.VpnLifecycle is VpnLifecycle.Starting && rpcModel.VpnStartupProgress != null)
195+
{
196+
// Convert 0.00-1.00 to 0-100.
197+
var progress = (int)(rpcModel.VpnStartupProgress.Progress * 100);
198+
VpnStartProgressValue = Math.Clamp(progress, 0, 100);
199+
VpnStartProgressMessage = string.IsNullOrEmpty(rpcModel.VpnStartupProgress.Message) ? null : rpcModel.VpnStartupProgress.Message;
200+
}
201+
else
202+
{
203+
VpnStartProgressValue = null;
204+
VpnStartProgressMessage = null;
205+
}
206+
173207
// Add every known agent.
174208
HashSet<ByteString> workspacesWithAgents = [];
175209
List<AgentViewModel> agents = [];

App/Views/Pages/TrayWindowLoginRequiredPage.xaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
</HyperlinkButton>
3737

3838
<HyperlinkButton
39-
Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
39+
Command="{x:Bind ViewModel.ExitCommand}"
4040
Margin="-12,-8,-12,-5"
4141
HorizontalAlignment="Stretch"
4242
HorizontalContentAlignment="Left">

App/Views/Pages/TrayWindowMainPage.xaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
<ProgressRing
4444
Grid.Column="1"
4545
IsActive="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource ConnectingBoolConverter}, Mode=OneWay}"
46+
IsIndeterminate="{x:Bind ViewModel.VpnStartProgressIsIndeterminate, Mode=OneWay}"
47+
Value="{x:Bind ViewModel.VpnStartProgressValueOrDefault, Mode=OneWay}"
4648
Width="24"
4749
Height="24"
4850
Margin="10,0"
@@ -74,6 +76,13 @@
7476
Visibility="{x:Bind ViewModel.ShowEnableSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
7577
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
7678

79+
<TextBlock
80+
Text="{x:Bind ViewModel.VpnStartProgressMessageOrDefault, Mode=OneWay}"
81+
TextWrapping="Wrap"
82+
Margin="0,6,0,6"
83+
Visibility="{x:Bind ViewModel.ShowVpnStartProgressSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
84+
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
85+
7786
<TextBlock
7887
Text="Workspaces"
7988
FontWeight="semibold"
@@ -344,7 +353,7 @@
344353
Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
345354
Margin="-12,-8,-12,-5"
346355
HorizontalAlignment="Stretch"
347-
HorizontalContentAlignment="Left">
356+
HorizontalContentAlignment="Left">
348357

349358
<TextBlock Text="Exit" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
350359
</HyperlinkButton>

Tests.Vpn.Service/DownloaderTest.cs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Security.Cryptography;
33
using System.Security.Cryptography.X509Certificates;
44
using System.Text;
5+
using System.Threading.Channels;
56
using Coder.Desktop.Vpn.Service;
67
using Microsoft.Extensions.Logging.Abstractions;
78

@@ -278,7 +279,7 @@ public async Task Download(CancellationToken ct)
278279
NullDownloadValidator.Instance, ct);
279280
await dlTask.Task;
280281
Assert.That(dlTask.TotalBytes, Is.EqualTo(4));
281-
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
282+
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
282283
Assert.That(dlTask.Progress, Is.EqualTo(1));
283284
Assert.That(dlTask.IsCompleted, Is.True);
284285
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
@@ -301,17 +302,56 @@ public async Task DownloadSameDest(CancellationToken ct)
301302
var dlTask0 = await startTask0;
302303
await dlTask0.Task;
303304
Assert.That(dlTask0.TotalBytes, Is.EqualTo(5));
304-
Assert.That(dlTask0.BytesRead, Is.EqualTo(5));
305+
Assert.That(dlTask0.BytesWritten, Is.EqualTo(5));
305306
Assert.That(dlTask0.Progress, Is.EqualTo(1));
306307
Assert.That(dlTask0.IsCompleted, Is.True);
307308
var dlTask1 = await startTask1;
308309
await dlTask1.Task;
309310
Assert.That(dlTask1.TotalBytes, Is.EqualTo(5));
310-
Assert.That(dlTask1.BytesRead, Is.EqualTo(5));
311+
Assert.That(dlTask1.BytesWritten, Is.EqualTo(5));
311312
Assert.That(dlTask1.Progress, Is.EqualTo(1));
312313
Assert.That(dlTask1.IsCompleted, Is.True);
313314
}
314315

316+
[Test(Description = "Download with X-Original-Content-Length")]
317+
[CancelAfter(30_000)]
318+
public async Task DownloadWithXOriginalContentLength(CancellationToken ct)
319+
{
320+
using var httpServer = new TestHttpServer(async ctx =>
321+
{
322+
ctx.Response.StatusCode = 200;
323+
ctx.Response.Headers.Add("X-Original-Content-Length", "6"); // wrong but should be used until complete
324+
ctx.Response.ContentType = "text/plain";
325+
ctx.Response.ContentLength64 = 4; // This should be ignored.
326+
await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct);
327+
});
328+
var url = new Uri(httpServer.BaseUrl + "/test");
329+
var destPath = Path.Combine(_tempDir, "test");
330+
var manager = new Downloader(NullLogger<Downloader>.Instance);
331+
var req = new HttpRequestMessage(HttpMethod.Get, url);
332+
var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);
333+
334+
var progressChannel = Channel.CreateUnbounded<DownloadProgressEvent>();
335+
dlTask.ProgressChanged += (_, args) =>
336+
Assert.That(progressChannel.Writer.TryWrite(args), Is.True);
337+
338+
await dlTask.Task;
339+
Assert.That(dlTask.TotalBytes, Is.EqualTo(4)); // should equal BytesWritten after completion
340+
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
341+
progressChannel.Writer.Complete();
342+
343+
var list = progressChannel.Reader.ReadAllAsync(ct).ToBlockingEnumerable(ct).ToList();
344+
Assert.That(list.Count, Is.GreaterThanOrEqualTo(2)); // there may be an item in the middle
345+
// The first item should be the initial progress with 0 bytes written.
346+
Assert.That(list[0].BytesWritten, Is.EqualTo(0));
347+
Assert.That(list[0].TotalBytes, Is.EqualTo(6)); // from X-Original-Content-Length
348+
Assert.That(list[0].Progress, Is.EqualTo(0.0d));
349+
// The last item should be final progress with the actual total bytes.
350+
Assert.That(list[^1].BytesWritten, Is.EqualTo(4));
351+
Assert.That(list[^1].TotalBytes, Is.EqualTo(4)); // from the actual bytes written
352+
Assert.That(list[^1].Progress, Is.EqualTo(1.0d));
353+
}
354+
315355
[Test(Description = "Download with custom headers")]
316356
[CancelAfter(30_000)]
317357
public async Task WithHeaders(CancellationToken ct)
@@ -347,7 +387,7 @@ public async Task DownloadExisting(CancellationToken ct)
347387
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
348388
NullDownloadValidator.Instance, ct);
349389
await dlTask.Task;
350-
Assert.That(dlTask.BytesRead, Is.Zero);
390+
Assert.That(dlTask.BytesWritten, Is.Zero);
351391
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
352392
Assert.That(File.GetLastWriteTime(destPath), Is.LessThan(DateTime.Now - TimeSpan.FromDays(1)));
353393
}
@@ -368,7 +408,7 @@ public async Task DownloadExistingDifferentContent(CancellationToken ct)
368408
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
369409
NullDownloadValidator.Instance, ct);
370410
await dlTask.Task;
371-
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
411+
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
372412
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
373413
Assert.That(File.GetLastWriteTime(destPath), Is.GreaterThan(DateTime.Now - TimeSpan.FromDays(1)));
374414
}

Vpn.Proto/vpn.proto

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ message ServiceMessage {
6060
oneof msg {
6161
StartResponse start = 2;
6262
StopResponse stop = 3;
63-
Status status = 4; // either in reply to a StatusRequest or broadcasted
63+
Status status = 4; // either in reply to a StatusRequest or broadcasted
64+
StartProgress start_progress = 5; // broadcasted during startup
6465
}
6566
}
6667

@@ -218,6 +219,19 @@ message StartResponse {
218219
string error_message = 2;
219220
}
220221

222+
// StartProgress is sent from the manager to the client to indicate the
223+
// download/startup progress of the tunnel. This will be sent during the
224+
// processing of a StartRequest before the StartResponse is sent.
225+
//
226+
// Note: this is currently a broadcasted message to all clients due to the
227+
// inability to easily send messages to a specific client in the Speaker
228+
// implementation. If clients are not expecting these messages, they
229+
// should ignore them.
230+
message StartProgress {
231+
double progress = 1; // 0.0 to 1.0
232+
string message = 2; // human-readable status message, must be set
233+
}
234+
221235
// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
222236
// StopResponse.
223237
message StopRequest {}

0 commit comments

Comments
 (0)