Skip to content

feat: add vpn start progress #114

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 5 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .idea/.idea.Coder.Desktop/.idea/projectSettingsUpdater.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions App/Models/RpcModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,30 @@ public enum VpnLifecycle
Stopping,
}

public class VpnStartupProgress
{
public double Progress { get; set; } = 0.0; // 0.0 to 1.0
public string Message { get; set; } = string.Empty;

public VpnStartupProgress Clone()
{
return new VpnStartupProgress
{
Progress = Progress,
Message = Message,
};
}
}

public class RpcModel
{
public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected;

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

// Nullable because it is only set when the VpnLifecycle is Starting
public VpnStartupProgress? VpnStartupProgress { get; set; }

public IReadOnlyList<Workspace> Workspaces { get; set; } = [];

public IReadOnlyList<Agent> Agents { get; set; } = [];
Expand All @@ -35,6 +53,7 @@ public RpcModel Clone()
{
RpcLifecycle = RpcLifecycle,
VpnLifecycle = VpnLifecycle,
VpnStartupProgress = VpnStartupProgress?.Clone(),
Workspaces = Workspaces,
Agents = Agents,
};
Expand Down
12 changes: 0 additions & 12 deletions App/Properties/PublishProfiles/win-arm64.pubxml

This file was deleted.

12 changes: 0 additions & 12 deletions App/Properties/PublishProfiles/win-x64.pubxml

This file was deleted.

12 changes: 0 additions & 12 deletions App/Properties/PublishProfiles/win-x86.pubxml

This file was deleted.

31 changes: 28 additions & 3 deletions App/Services/RpcController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ public async Task StartVpn(CancellationToken ct = default)
throw new RpcOperationException(
$"Cannot start VPN without valid credentials, current state: {credentials.State}");

MutateState(state => { state.VpnLifecycle = VpnLifecycle.Starting; });
MutateState(state =>
{
state.VpnLifecycle = VpnLifecycle.Starting;
// Explicitly clear the startup progress.
state.VpnStartupProgress = null;
});

ServiceMessage reply;
try
Expand Down Expand Up @@ -251,6 +256,9 @@ private void MutateState(Action<RpcModel> mutator)
using (_stateLock.Lock())
{
mutator(_state);
// Unset the startup progress if the VpnLifecycle is not Starting
if (_state.VpnLifecycle != VpnLifecycle.Starting)
_state.VpnStartupProgress = null;
newState = _state.Clone();
}

Expand Down Expand Up @@ -283,15 +291,32 @@ private void ApplyStatusUpdate(Status status)
});
}

private void ApplyStartProgressUpdate(StartProgress message)
{
MutateState(state =>
{
// MutateState will undo these changes if it doesn't believe we're
// in the "Starting" state.
state.VpnStartupProgress = new VpnStartupProgress
{
Progress = message.Progress,
Message = message.Message,
};
});
}

private void SpeakerOnReceive(ReplyableRpcMessage<ClientMessage, ServiceMessage> message)
{
switch (message.Message.MsgCase)
{
case ServiceMessage.MsgOneofCase.Start:
case ServiceMessage.MsgOneofCase.Stop:
case ServiceMessage.MsgOneofCase.Status:
ApplyStatusUpdate(message.Message.Status);
break;
case ServiceMessage.MsgOneofCase.Start:
case ServiceMessage.MsgOneofCase.Stop:
case ServiceMessage.MsgOneofCase.StartProgress:
ApplyStartProgressUpdate(message.Message.StartProgress);
break;
case ServiceMessage.MsgOneofCase.None:
default:
// TODO: log unexpected message
Expand Down
38 changes: 36 additions & 2 deletions App/ViewModels/TrayWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
{
private const int MaxAgents = 5;
private const string DefaultDashboardUrl = "https://coder.com";
private const string DefaultHostnameSuffix = ".coder";
private const string DefaultStartProgressMessage = "Starting Coder Connect...";

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

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
Expand All @@ -63,14 +64,33 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
[NotifyPropertyChangedFor(nameof(ShowFailedSection))]
public partial string? VpnFailedMessage { get; set; } = null;

public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VpnStartProgressIsIndeterminate))]
[NotifyPropertyChangedFor(nameof(VpnStartProgressValueOrDefault))]
public partial int? VpnStartProgressValue { get; set; } = null;

public int VpnStartProgressValueOrDefault => VpnStartProgressValue ?? 0;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VpnStartProgressMessageOrDefault))]
public partial string? VpnStartProgressMessage { get; set; } = null;

public string VpnStartProgressMessageOrDefault =>
string.IsNullOrEmpty(VpnStartProgressMessage) ? DefaultStartProgressMessage : VpnStartProgressMessage;

public bool VpnStartProgressIsIndeterminate => VpnStartProgressValueOrDefault == 0;

public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Starting and not VpnLifecycle.Started;

public bool ShowVpnStartProgressSection => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Starting;

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

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

// VpnStartupProgress is only set when the VPN is starting.
if (rpcModel.VpnLifecycle is VpnLifecycle.Starting && rpcModel.VpnStartupProgress != null)
{
// Convert 0.00-1.00 to 0-100.
var progress = (int)(rpcModel.VpnStartupProgress.Progress * 100);
VpnStartProgressValue = Math.Clamp(progress, 0, 100);
VpnStartProgressMessage = string.IsNullOrEmpty(rpcModel.VpnStartupProgress.Message) ? null : rpcModel.VpnStartupProgress.Message;
}
else
{
VpnStartProgressValue = null;
VpnStartProgressMessage = null;
}

// Add every known agent.
HashSet<ByteString> workspacesWithAgents = [];
List<AgentViewModel> agents = [];
Expand Down
2 changes: 1 addition & 1 deletion App/Views/Pages/TrayWindowLoginRequiredPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
</HyperlinkButton>

<HyperlinkButton
Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
Command="{x:Bind ViewModel.ExitCommand}"
Margin="-12,-8,-12,-5"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left">
Expand Down
11 changes: 10 additions & 1 deletion App/Views/Pages/TrayWindowMainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
<ProgressRing
Grid.Column="1"
IsActive="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource ConnectingBoolConverter}, Mode=OneWay}"
IsIndeterminate="{x:Bind ViewModel.VpnStartProgressIsIndeterminate, Mode=OneWay}"
Value="{x:Bind ViewModel.VpnStartProgressValueOrDefault, Mode=OneWay}"
Width="24"
Height="24"
Margin="10,0"
Expand Down Expand Up @@ -74,6 +76,13 @@
Visibility="{x:Bind ViewModel.ShowEnableSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />

<TextBlock
Text="{x:Bind ViewModel.VpnStartProgressMessageOrDefault, Mode=OneWay}"
TextWrapping="Wrap"
Margin="0,6,0,6"
Visibility="{x:Bind ViewModel.ShowVpnStartProgressSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />

<TextBlock
Text="Workspaces"
FontWeight="semibold"
Expand Down Expand Up @@ -344,7 +353,7 @@
Command="{x:Bind ViewModel.ExitCommand, Mode=OneWay}"
Margin="-12,-8,-12,-5"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left">
HorizontalContentAlignment="Left">

<TextBlock Text="Exit" Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
</HyperlinkButton>
Expand Down
50 changes: 45 additions & 5 deletions Tests.Vpn.Service/DownloaderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Channels;
using Coder.Desktop.Vpn.Service;
using Microsoft.Extensions.Logging.Abstractions;

Expand Down Expand Up @@ -278,7 +279,7 @@ public async Task Download(CancellationToken ct)
NullDownloadValidator.Instance, ct);
await dlTask.Task;
Assert.That(dlTask.TotalBytes, Is.EqualTo(4));
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
Assert.That(dlTask.Progress, Is.EqualTo(1));
Assert.That(dlTask.IsCompleted, Is.True);
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
Expand All @@ -301,17 +302,56 @@ public async Task DownloadSameDest(CancellationToken ct)
var dlTask0 = await startTask0;
await dlTask0.Task;
Assert.That(dlTask0.TotalBytes, Is.EqualTo(5));
Assert.That(dlTask0.BytesRead, Is.EqualTo(5));
Assert.That(dlTask0.BytesWritten, Is.EqualTo(5));
Assert.That(dlTask0.Progress, Is.EqualTo(1));
Assert.That(dlTask0.IsCompleted, Is.True);
var dlTask1 = await startTask1;
await dlTask1.Task;
Assert.That(dlTask1.TotalBytes, Is.EqualTo(5));
Assert.That(dlTask1.BytesRead, Is.EqualTo(5));
Assert.That(dlTask1.BytesWritten, Is.EqualTo(5));
Assert.That(dlTask1.Progress, Is.EqualTo(1));
Assert.That(dlTask1.IsCompleted, Is.True);
}

[Test(Description = "Download with X-Original-Content-Length")]
[CancelAfter(30_000)]
public async Task DownloadWithXOriginalContentLength(CancellationToken ct)
{
using var httpServer = new TestHttpServer(async ctx =>
{
ctx.Response.StatusCode = 200;
ctx.Response.Headers.Add("X-Original-Content-Length", "6"); // wrong but should be used until complete
ctx.Response.ContentType = "text/plain";
ctx.Response.ContentLength64 = 4; // This should be ignored.
await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct);
});
var url = new Uri(httpServer.BaseUrl + "/test");
var destPath = Path.Combine(_tempDir, "test");
var manager = new Downloader(NullLogger<Downloader>.Instance);
var req = new HttpRequestMessage(HttpMethod.Get, url);
var dlTask = await manager.StartDownloadAsync(req, destPath, NullDownloadValidator.Instance, ct);

var progressChannel = Channel.CreateUnbounded<DownloadProgressEvent>();
dlTask.ProgressChanged += (_, args) =>
Assert.That(progressChannel.Writer.TryWrite(args), Is.True);

await dlTask.Task;
Assert.That(dlTask.TotalBytes, Is.EqualTo(4)); // should equal BytesWritten after completion
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
progressChannel.Writer.Complete();

var list = progressChannel.Reader.ReadAllAsync(ct).ToBlockingEnumerable(ct).ToList();
Assert.That(list.Count, Is.GreaterThanOrEqualTo(2)); // there may be an item in the middle
// The first item should be the initial progress with 0 bytes written.
Assert.That(list[0].BytesWritten, Is.EqualTo(0));
Assert.That(list[0].TotalBytes, Is.EqualTo(6)); // from X-Original-Content-Length
Assert.That(list[0].Progress, Is.EqualTo(0.0d));
// The last item should be final progress with the actual total bytes.
Assert.That(list[^1].BytesWritten, Is.EqualTo(4));
Assert.That(list[^1].TotalBytes, Is.EqualTo(4)); // from the actual bytes written
Assert.That(list[^1].Progress, Is.EqualTo(1.0d));
}

[Test(Description = "Download with custom headers")]
[CancelAfter(30_000)]
public async Task WithHeaders(CancellationToken ct)
Expand Down Expand Up @@ -347,7 +387,7 @@ public async Task DownloadExisting(CancellationToken ct)
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
Assert.That(dlTask.BytesRead, Is.Zero);
Assert.That(dlTask.BytesWritten, Is.Zero);
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
Assert.That(File.GetLastWriteTime(destPath), Is.LessThan(DateTime.Now - TimeSpan.FromDays(1)));
}
Expand All @@ -368,7 +408,7 @@ public async Task DownloadExistingDifferentContent(CancellationToken ct)
var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath,
NullDownloadValidator.Instance, ct);
await dlTask.Task;
Assert.That(dlTask.BytesRead, Is.EqualTo(4));
Assert.That(dlTask.BytesWritten, Is.EqualTo(4));
Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test"));
Assert.That(File.GetLastWriteTime(destPath), Is.GreaterThan(DateTime.Now - TimeSpan.FromDays(1)));
}
Expand Down
16 changes: 15 additions & 1 deletion Vpn.Proto/vpn.proto
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ message ServiceMessage {
oneof msg {
StartResponse start = 2;
StopResponse stop = 3;
Status status = 4; // either in reply to a StatusRequest or broadcasted
Status status = 4; // either in reply to a StatusRequest or broadcasted
StartProgress start_progress = 5; // broadcasted during startup
}
}

Expand Down Expand Up @@ -218,6 +219,19 @@ message StartResponse {
string error_message = 2;
}

// StartProgress is sent from the manager to the client to indicate the
// download/startup progress of the tunnel. This will be sent during the
// processing of a StartRequest before the StartResponse is sent.
//
// Note: this is currently a broadcasted message to all clients due to the
// inability to easily send messages to a specific client in the Speaker
// implementation. If clients are not expecting these messages, they
// should ignore them.
message StartProgress {
double progress = 1; // 0.0 to 1.0
string message = 2; // human-readable status message, must be set
}

// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
// StopResponse.
message StopRequest {}
Expand Down
Loading
Loading