Skip to content
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