Skip to content

Commit d49de5b

Browse files
authored
feat: add vpn start progress (#114)
1 parent 74b8658 commit d49de5b

File tree

14 files changed

+464
-68
lines changed

14 files changed

+464
-68
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: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
using System;
12
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using Coder.Desktop.App.Converters;
25
using Coder.Desktop.Vpn.Proto;
36

47
namespace Coder.Desktop.App.Models;
@@ -19,11 +22,168 @@ public enum VpnLifecycle
1922
Stopping,
2023
}
2124

25+
public enum VpnStartupStage
26+
{
27+
Unknown,
28+
Initializing,
29+
Downloading,
30+
Finalizing,
31+
}
32+
33+
public class VpnDownloadProgress
34+
{
35+
public ulong BytesWritten { get; set; } = 0;
36+
public ulong? BytesTotal { get; set; } = null; // null means unknown total size
37+
38+
public double Progress
39+
{
40+
get
41+
{
42+
if (BytesTotal is > 0)
43+
{
44+
return (double)BytesWritten / BytesTotal.Value;
45+
}
46+
return 0.0;
47+
}
48+
}
49+
50+
public override string ToString()
51+
{
52+
// TODO: it would be nice if the two suffixes could match
53+
var s = FriendlyByteConverter.FriendlyBytes(BytesWritten);
54+
if (BytesTotal != null)
55+
s += $" of {FriendlyByteConverter.FriendlyBytes(BytesTotal.Value)}";
56+
else
57+
s += " of unknown";
58+
if (BytesTotal != null)
59+
s += $" ({Progress:0%})";
60+
return s;
61+
}
62+
63+
public VpnDownloadProgress Clone()
64+
{
65+
return new VpnDownloadProgress
66+
{
67+
BytesWritten = BytesWritten,
68+
BytesTotal = BytesTotal,
69+
};
70+
}
71+
72+
public static VpnDownloadProgress FromProto(StartProgressDownloadProgress proto)
73+
{
74+
return new VpnDownloadProgress
75+
{
76+
BytesWritten = proto.BytesWritten,
77+
BytesTotal = proto.HasBytesTotal ? proto.BytesTotal : null,
78+
};
79+
}
80+
}
81+
82+
public class VpnStartupProgress
83+
{
84+
public const string DefaultStartProgressMessage = "Starting Coder Connect...";
85+
86+
// Scale the download progress to an overall progress value between these
87+
// numbers.
88+
private const double DownloadProgressMin = 0.05;
89+
private const double DownloadProgressMax = 0.80;
90+
91+
public VpnStartupStage Stage { get; init; } = VpnStartupStage.Unknown;
92+
public VpnDownloadProgress? DownloadProgress { get; init; } = null;
93+
94+
// 0.0 to 1.0
95+
public double Progress
96+
{
97+
get
98+
{
99+
switch (Stage)
100+
{
101+
case VpnStartupStage.Unknown:
102+
case VpnStartupStage.Initializing:
103+
return 0.0;
104+
case VpnStartupStage.Downloading:
105+
var progress = DownloadProgress?.Progress ?? 0.0;
106+
return DownloadProgressMin + (DownloadProgressMax - DownloadProgressMin) * progress;
107+
case VpnStartupStage.Finalizing:
108+
return DownloadProgressMax;
109+
default:
110+
throw new ArgumentOutOfRangeException();
111+
}
112+
}
113+
}
114+
115+
public override string ToString()
116+
{
117+
switch (Stage)
118+
{
119+
case VpnStartupStage.Unknown:
120+
case VpnStartupStage.Initializing:
121+
return DefaultStartProgressMessage;
122+
case VpnStartupStage.Downloading:
123+
var s = "Downloading Coder Connect binary...";
124+
if (DownloadProgress is not null)
125+
{
126+
s += "\n" + DownloadProgress;
127+
}
128+
129+
return s;
130+
case VpnStartupStage.Finalizing:
131+
return "Finalizing Coder Connect startup...";
132+
default:
133+
throw new ArgumentOutOfRangeException();
134+
}
135+
}
136+
137+
public VpnStartupProgress Clone()
138+
{
139+
return new VpnStartupProgress
140+
{
141+
Stage = Stage,
142+
DownloadProgress = DownloadProgress?.Clone(),
143+
};
144+
}
145+
146+
public static VpnStartupProgress FromProto(StartProgress proto)
147+
{
148+
return new VpnStartupProgress
149+
{
150+
Stage = proto.Stage switch
151+
{
152+
StartProgressStage.Initializing => VpnStartupStage.Initializing,
153+
StartProgressStage.Downloading => VpnStartupStage.Downloading,
154+
StartProgressStage.Finalizing => VpnStartupStage.Finalizing,
155+
_ => VpnStartupStage.Unknown,
156+
},
157+
DownloadProgress = proto.Stage is StartProgressStage.Downloading ?
158+
VpnDownloadProgress.FromProto(proto.DownloadProgress) :
159+
null,
160+
};
161+
}
162+
}
163+
22164
public class RpcModel
23165
{
24166
public RpcLifecycle RpcLifecycle { get; set; } = RpcLifecycle.Disconnected;
25167

26-
public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
168+
public VpnLifecycle VpnLifecycle
169+
{
170+
get;
171+
set
172+
{
173+
if (VpnLifecycle != value && value == VpnLifecycle.Starting)
174+
// Reset the startup progress when the VPN lifecycle changes to
175+
// Starting.
176+
VpnStartupProgress = null;
177+
field = value;
178+
}
179+
}
180+
181+
// Nullable because it is only set when the VpnLifecycle is Starting
182+
public VpnStartupProgress? VpnStartupProgress
183+
{
184+
get => VpnLifecycle is VpnLifecycle.Starting ? field ?? new VpnStartupProgress() : null;
185+
set;
186+
}
27187

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

@@ -35,6 +195,7 @@ public RpcModel Clone()
35195
{
36196
RpcLifecycle = RpcLifecycle,
37197
VpnLifecycle = VpnLifecycle,
198+
VpnStartupProgress = VpnStartupProgress?.Clone(),
38199
Workspaces = Workspaces,
39200
Agents = Agents,
40201
};

App/Services/RpcController.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,10 @@ 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+
});
165168

166169
ServiceMessage reply;
167170
try
@@ -283,15 +286,28 @@ private void ApplyStatusUpdate(Status status)
283286
});
284287
}
285288

289+
private void ApplyStartProgressUpdate(StartProgress message)
290+
{
291+
MutateState(state =>
292+
{
293+
// The model itself will ignore this value if we're not in the
294+
// starting state.
295+
state.VpnStartupProgress = VpnStartupProgress.FromProto(message);
296+
});
297+
}
298+
286299
private void SpeakerOnReceive(ReplyableRpcMessage<ClientMessage, ServiceMessage> message)
287300
{
288301
switch (message.Message.MsgCase)
289302
{
303+
case ServiceMessage.MsgOneofCase.Start:
304+
case ServiceMessage.MsgOneofCase.Stop:
290305
case ServiceMessage.MsgOneofCase.Status:
291306
ApplyStatusUpdate(message.Message.Status);
292307
break;
293-
case ServiceMessage.MsgOneofCase.Start:
294-
case ServiceMessage.MsgOneofCase.Stop:
308+
case ServiceMessage.MsgOneofCase.StartProgress:
309+
ApplyStartProgressUpdate(message.Message.StartProgress);
310+
break;
295311
case ServiceMessage.MsgOneofCase.None:
296312
default:
297313
// TODO: log unexpected message

App/ViewModels/TrayWindowViewModel.cs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ 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";
3332

3433
private readonly IServiceProvider _services;
3534
private readonly IRpcController _rpcController;
@@ -53,6 +52,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
5352

5453
[ObservableProperty]
5554
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
55+
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
5656
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
5757
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
5858
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
@@ -63,14 +63,33 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
6363

6464
[ObservableProperty]
6565
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
66+
[NotifyPropertyChangedFor(nameof(ShowVpnStartProgressSection))]
6667
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
6768
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
6869
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
6970
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
7071
[NotifyPropertyChangedFor(nameof(ShowFailedSection))]
7172
public partial string? VpnFailedMessage { get; set; } = null;
7273

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

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

@@ -170,6 +189,20 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
170189
VpnLifecycle = rpcModel.VpnLifecycle;
171190
VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
172191

192+
// VpnStartupProgress is only set when the VPN is starting.
193+
if (rpcModel.VpnLifecycle is VpnLifecycle.Starting && rpcModel.VpnStartupProgress != null)
194+
{
195+
// Convert 0.00-1.00 to 0-100.
196+
var progress = (int)(rpcModel.VpnStartupProgress.Progress * 100);
197+
VpnStartProgressValue = Math.Clamp(progress, 0, 100);
198+
VpnStartProgressMessage = rpcModel.VpnStartupProgress.ToString();
199+
}
200+
else
201+
{
202+
VpnStartProgressValue = null;
203+
VpnStartProgressMessage = null;
204+
}
205+
173206
// Add every known agent.
174207
HashSet<ByteString> workspacesWithAgents = [];
175208
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>

0 commit comments

Comments
 (0)