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 51bf68e

Browse files
authoredMar 6, 2025··
feat: show vpn start/stop failure in app (#44)
Adds red text that appears when the VPN fails to start or stop. After an error, any manual start/stop operation will clear the error. Contributes to #40
1 parent e1d9774 commit 51bf68e

6 files changed

+201
-169
lines changed
 

‎App/Converters/AgentStatusToColorConverter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Coder.Desktop.App.Converters;
99
public class AgentStatusToColorConverter : IValueConverter
1010
{
1111
private static readonly SolidColorBrush Green = new(Color.FromArgb(255, 52, 199, 89));
12-
private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 204, 1, 0));
12+
private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 255, 204, 1));
1313
private static readonly SolidColorBrush Red = new(Color.FromArgb(255, 255, 59, 48));
1414
private static readonly SolidColorBrush Gray = new(Color.FromArgb(255, 142, 142, 147));
1515

‎App/Converters/InverseBoolToVisibilityConverter.cs

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

‎App/Converters/VpnLifecycleToVisibilityConverter.cs

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

‎App/Services/RpcController.cs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ public async Task Reconnect(CancellationToken ct = default)
146146
Status = new StatusRequest(),
147147
}, ct);
148148
if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status)
149-
throw new InvalidOperationException($"Unexpected reply message type: {statusReply.MsgCase}");
149+
throw new VpnLifecycleException(
150+
$"Failed to get VPN status. Unexpected reply message type: {statusReply.MsgCase}");
150151
ApplyStatusUpdate(statusReply.Status);
151152
}
152153

@@ -172,20 +173,26 @@ public async Task StartVpn(CancellationToken ct = default)
172173
ApiToken = credentials.ApiToken,
173174
},
174175
}, ct);
175-
if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start)
176-
throw new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}");
177176
}
178177
catch (Exception e)
179178
{
180179
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
181180
throw new RpcOperationException("Failed to send start command to service", e);
182181
}
183182

183+
if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start)
184+
{
185+
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
186+
throw new VpnLifecycleException($"Failed to start VPN. Unexpected reply message type: {reply.MsgCase}");
187+
}
188+
184189
if (!reply.Start.Success)
185190
{
191+
// We use Stopped instead of Unknown here as it's usually the case
192+
// that a failed start got cleaned up successfully.
186193
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
187-
throw new VpnLifecycleException("Failed to start VPN",
188-
new InvalidOperationException($"Service reported failure: {reply.Start.ErrorMessage}"));
194+
throw new VpnLifecycleException(
195+
$"Failed to start VPN. Service reported failure: {reply.Start.ErrorMessage}");
189196
}
190197

191198
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Started; });
@@ -212,16 +219,20 @@ public async Task StopVpn(CancellationToken ct = default)
212219
}
213220
finally
214221
{
215-
// Technically the state is unknown now.
216-
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
222+
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
217223
}
218224

219225
if (reply.MsgCase != ServiceMessage.MsgOneofCase.Stop)
220-
throw new VpnLifecycleException("Failed to stop VPN",
221-
new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}"));
226+
{
227+
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
228+
throw new VpnLifecycleException($"Failed to stop VPN. Unexpected reply message type: {reply.MsgCase}");
229+
}
230+
222231
if (!reply.Stop.Success)
223-
throw new VpnLifecycleException("Failed to stop VPN",
224-
new InvalidOperationException($"Service reported failure: {reply.Stop.ErrorMessage}"));
232+
{
233+
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
234+
throw new VpnLifecycleException($"Failed to stop VPN. Service reported failure: {reply.Stop.ErrorMessage}");
235+
}
225236
}
226237

227238
public async ValueTask DisposeAsync()

‎App/ViewModels/TrayWindowViewModel.cs

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Threading.Tasks;
45
using Coder.Desktop.App.Models;
56
using Coder.Desktop.App.Services;
67
using Coder.Desktop.Vpn.Proto;
@@ -10,6 +11,7 @@
1011
using Microsoft.UI.Dispatching;
1112
using Microsoft.UI.Xaml;
1213
using Microsoft.UI.Xaml.Controls;
14+
using Exception = System.Exception;
1315

1416
namespace Coder.Desktop.App.ViewModels;
1517

@@ -23,22 +25,45 @@ public partial class TrayWindowViewModel : ObservableObject
2325

2426
private DispatcherQueue? _dispatcherQueue;
2527

26-
[ObservableProperty] public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
28+
[ObservableProperty]
29+
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
30+
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
31+
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
32+
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
33+
public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
2734

2835
// This is a separate property because we need the switch to be 2-way.
2936
[ObservableProperty] public partial bool VpnSwitchActive { get; set; } = false;
3037

31-
[ObservableProperty] public partial string? VpnFailedMessage { get; set; } = null;
38+
[ObservableProperty]
39+
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
40+
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
41+
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
42+
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
43+
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
44+
[NotifyPropertyChangedFor(nameof(ShowFailedSection))]
45+
public partial string? VpnFailedMessage { get; set; } = null;
3246

3347
[ObservableProperty]
34-
[NotifyPropertyChangedFor(nameof(NoAgents))]
35-
[NotifyPropertyChangedFor(nameof(AgentOverflow))]
3648
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
49+
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
50+
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
51+
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
3752
public partial List<AgentViewModel> Agents { get; set; } = [];
3853

39-
public bool NoAgents => Agents.Count == 0;
54+
public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started;
55+
56+
public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started;
57+
58+
public bool ShowNoAgentsSection =>
59+
VpnFailedMessage is null && Agents.Count == 0 && VpnLifecycle is VpnLifecycle.Started;
60+
61+
public bool ShowAgentsSection =>
62+
VpnFailedMessage is null && Agents.Count > 0 && VpnLifecycle is VpnLifecycle.Started;
63+
64+
public bool ShowFailedSection => VpnFailedMessage is not null;
4065

41-
public bool AgentOverflow => Agents.Count > MaxAgents;
66+
public bool ShowAgentOverflowButton => VpnFailedMessage is null && Agents.Count > MaxAgents;
4267

4368
[ObservableProperty]
4469
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
@@ -190,24 +215,47 @@ public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
190215
{
191216
if (sender is not ToggleSwitch toggleSwitch) return;
192217

193-
VpnFailedMessage = "";
218+
VpnFailedMessage = null;
219+
220+
// The start/stop methods will call back to update the state.
221+
if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped)
222+
_ = StartVpn(); // in the background
223+
else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started)
224+
_ = StopVpn(); // in the background
225+
else
226+
toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
227+
}
228+
229+
private async Task StartVpn()
230+
{
194231
try
195232
{
196-
// The start/stop methods will call back to update the state.
197-
if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped)
198-
_rpcController.StartVpn();
199-
else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started)
200-
_rpcController.StopVpn();
201-
else
202-
toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
233+
await _rpcController.StartVpn();
203234
}
204-
catch
235+
catch (Exception e)
205236
{
206-
// TODO: display error
207-
VpnFailedMessage = e.ToString();
237+
VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e);
208238
}
209239
}
210240

241+
private async Task StopVpn()
242+
{
243+
try
244+
{
245+
await _rpcController.StopVpn();
246+
}
247+
catch (Exception e)
248+
{
249+
VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e);
250+
}
251+
}
252+
253+
private static string MaybeUnwrapTunnelError(Exception e)
254+
{
255+
if (e is VpnLifecycleException vpnError) return vpnError.Message;
256+
return e.ToString();
257+
}
258+
211259
[RelayCommand]
212260
public void ToggleShowAllAgents()
213261
{

‎App/Views/Pages/TrayWindowMainPage.xaml

Lines changed: 113 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,11 @@
1313

1414
<Page.Resources>
1515
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
16-
<converters:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibilityConverter" />
1716

1817
<converters:VpnLifecycleToBoolConverter x:Key="ConnectingBoolConverter" Unknown="true" Starting="true"
1918
Stopping="true" />
2019
<converters:VpnLifecycleToBoolConverter x:Key="NotConnectingBoolConverter" Started="true" Stopped="true" />
2120
<converters:VpnLifecycleToBoolConverter x:Key="StoppedBoolConverter" Stopped="true" />
22-
<converters:VpnLifecycleToVisibilityConverter x:Key="StartedVisibilityConverter" Started="true" />
23-
<converters:VpnLifecycleToVisibilityConverter x:Key="NotStartedVisibilityConverter" Starting="true"
24-
Stopping="true" Stopped="true" />
2521

2622
<converters:AgentStatusToColorConverter x:Key="AgentStatusToColorConverter" />
2723
<converters:BoolToObjectConverter x:Key="ShowMoreLessTextConverter" TrueValue="Show less"
@@ -67,129 +63,132 @@
6763

6864
<controls:HorizontalRule />
6965

66+
<TextBlock
67+
Text="{x:Bind ViewModel.VpnFailedMessage, Mode=OneWay}"
68+
Foreground="Red"
69+
Margin="0,6,0,6"
70+
TextWrapping="Wrap"
71+
Visibility="{x:Bind ViewModel.ShowFailedSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" />
72+
73+
<TextBlock
74+
Text="Enable CoderVPN to view your workspaces."
75+
TextWrapping="Wrap"
76+
Margin="0,6,0,6"
77+
Visibility="{x:Bind ViewModel.ShowEnableSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
78+
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
79+
7080
<TextBlock
7181
Text="Workspaces"
7282
FontWeight="semibold"
83+
Visibility="{x:Bind ViewModel.ShowWorkspacesHeader, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
7384
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
7485

7586
<TextBlock
76-
Text="Enable CoderVPN to view your workspaces."
87+
Text="There are no workspace agents to display."
7788
TextWrapping="Wrap"
7889
Margin="0,0,0,6"
79-
Visibility="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource NotStartedVisibilityConverter}, Mode=OneWay}"
90+
Visibility="{x:Bind ViewModel.ShowNoAgentsSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
8091
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
8192

82-
<StackPanel
83-
Orientation="Vertical"
84-
Visibility="{x:Bind ViewModel.VpnLifecycle, Converter={StaticResource StartedVisibilityConverter}, Mode=OneWay}">
85-
86-
<TextBlock
87-
Text="There are no workspace agents to display."
88-
TextWrapping="Wrap"
89-
Margin="0,0,0,6"
90-
Visibility="{x:Bind ViewModel.NoAgents, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
91-
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
92-
93-
<ItemsRepeater
94-
ItemsSource="{x:Bind ViewModel.VisibleAgents, Mode=OneWay}"
95-
Visibility="{x:Bind ViewModel.NoAgents, Converter={StaticResource InverseBoolToVisibilityConverter}, Mode=OneWay}">
96-
97-
<ItemsRepeater.Layout>
98-
<StackLayout Orientation="Vertical" />
99-
</ItemsRepeater.Layout>
100-
101-
<ItemsRepeater.ItemTemplate>
102-
<DataTemplate x:DataType="viewModels:AgentViewModel">
103-
<Grid>
104-
<Grid.ColumnDefinitions>
105-
<ColumnDefinition Width="*" />
106-
<ColumnDefinition Width="Auto" />
107-
</Grid.ColumnDefinitions>
108-
109-
<HyperlinkButton
110-
Grid.Column="0"
111-
NavigateUri="{x:Bind DashboardUrl, Mode=OneWay}"
112-
Margin="-12,0,0,0"
93+
<ItemsRepeater
94+
ItemsSource="{x:Bind ViewModel.VisibleAgents, Mode=OneWay}"
95+
Visibility="{x:Bind ViewModel.ShowAgentsSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
96+
97+
<ItemsRepeater.Layout>
98+
<StackLayout Orientation="Vertical" />
99+
</ItemsRepeater.Layout>
100+
101+
<ItemsRepeater.ItemTemplate>
102+
<DataTemplate x:DataType="viewModels:AgentViewModel">
103+
<Grid>
104+
<Grid.ColumnDefinitions>
105+
<ColumnDefinition Width="*" />
106+
<ColumnDefinition Width="Auto" />
107+
</Grid.ColumnDefinitions>
108+
109+
<HyperlinkButton
110+
Grid.Column="0"
111+
NavigateUri="{x:Bind DashboardUrl, Mode=OneWay}"
112+
Margin="-12,0,0,0"
113+
HorizontalAlignment="Stretch"
114+
HorizontalContentAlignment="Left">
115+
116+
<StackPanel
117+
Orientation="Horizontal"
113118
HorizontalAlignment="Stretch"
114-
HorizontalContentAlignment="Left">
115-
116-
<StackPanel
117-
Orientation="Horizontal"
119+
Spacing="10">
120+
121+
<Canvas
122+
HorizontalAlignment="Center"
123+
VerticalAlignment="Center"
124+
Height="14" Width="14"
125+
Margin="0,1,0,0">
126+
127+
<Ellipse
128+
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
129+
Opacity="0.2"
130+
Width="14"
131+
Height="14"
132+
Canvas.Left="0"
133+
Canvas.Top="0" />
134+
135+
<Ellipse
136+
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
137+
Width="8"
138+
Height="8"
139+
VerticalAlignment="Center"
140+
Canvas.Left="3"
141+
Canvas.Top="3" />
142+
</Canvas>
143+
144+
<!-- See .cs for why the Loaded event handler is needed -->
145+
<!-- TODO: I couldn't get ellipsis to work without hardcoding a width here -->
146+
<TextBlock
147+
Loaded="AgentHostnameText_OnLoaded"
148+
VerticalAlignment="Center"
118149
HorizontalAlignment="Stretch"
119-
Spacing="10">
150+
HorizontalTextAlignment="Left"
151+
TextTrimming="CharacterEllipsis"
152+
TextWrapping="NoWrap"
153+
Width="180">
154+
155+
<Run Text="{x:Bind Hostname, Mode=OneWay}"
156+
Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
157+
<Run Text="{x:Bind HostnameSuffix, Mode=OneWay}"
158+
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
159+
</TextBlock>
160+
</StackPanel>
161+
</HyperlinkButton>
162+
163+
<HyperlinkButton
164+
Grid.Column="1"
165+
x:Name="AgentHostnameCopyButton"
166+
Command="{x:Bind CopyHostnameCommand}"
167+
CommandParameter="{Binding ElementName=AgentHostnameCopyButton}"
168+
Margin="0,0,-12,0"
169+
VerticalAlignment="Stretch">
170+
171+
<FontIcon
172+
Glyph="&#xE8C8;"
173+
FontSize="16"
174+
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
175+
</HyperlinkButton>
176+
</Grid>
177+
</DataTemplate>
178+
</ItemsRepeater.ItemTemplate>
179+
</ItemsRepeater>
120180

121-
<Canvas
122-
HorizontalAlignment="Center"
123-
VerticalAlignment="Center"
124-
Height="14" Width="14"
125-
Margin="0,1,0,0">
126-
127-
<Ellipse
128-
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
129-
Opacity="0.2"
130-
Width="14"
131-
Height="14"
132-
Canvas.Left="0"
133-
Canvas.Top="0" />
134-
135-
<Ellipse
136-
Fill="{x:Bind ConnectionStatus, Converter={StaticResource AgentStatusToColorConverter}, Mode=OneWay}"
137-
Width="8"
138-
Height="8"
139-
VerticalAlignment="Center"
140-
Canvas.Left="3"
141-
Canvas.Top="3" />
142-
</Canvas>
143-
144-
<!-- See .cs for why the Loaded event handler is needed -->
145-
<!-- TODO: I couldn't get ellipsis to work without hardcoding a width here -->
146-
<TextBlock
147-
Loaded="AgentHostnameText_OnLoaded"
148-
VerticalAlignment="Center"
149-
HorizontalAlignment="Stretch"
150-
HorizontalTextAlignment="Left"
151-
TextTrimming="CharacterEllipsis"
152-
TextWrapping="NoWrap"
153-
Width="180">
154-
155-
<Run Text="{x:Bind Hostname, Mode=OneWay}"
156-
Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
157-
<Run Text="{x:Bind HostnameSuffix, Mode=OneWay}"
158-
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
159-
</TextBlock>
160-
</StackPanel>
161-
</HyperlinkButton>
162-
163-
<HyperlinkButton
164-
Grid.Column="1"
165-
x:Name="AgentHostnameCopyButton"
166-
Command="{x:Bind CopyHostnameCommand}"
167-
CommandParameter="{Binding ElementName=AgentHostnameCopyButton}"
168-
Margin="0,0,-12,0"
169-
VerticalAlignment="Stretch">
170-
171-
<FontIcon
172-
Glyph="&#xE8C8;"
173-
FontSize="16"
174-
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
175-
</HyperlinkButton>
176-
</Grid>
177-
</DataTemplate>
178-
</ItemsRepeater.ItemTemplate>
179-
</ItemsRepeater>
180-
181-
<HyperlinkButton
182-
Margin="-12,10,-12,0"
183-
HorizontalAlignment="Stretch"
184-
HorizontalContentAlignment="Left"
185-
Visibility="{x:Bind ViewModel.AgentOverflow, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
186-
Command="{x:Bind ViewModel.ToggleShowAllAgentsCommand}">
187-
188-
<TextBlock
189-
Text="{x:Bind ViewModel.ShowAllAgents, Converter={StaticResource ShowMoreLessTextConverter}, Mode=OneWay}"
190-
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
191-
</HyperlinkButton>
192-
</StackPanel>
181+
<HyperlinkButton
182+
Margin="-12,0,-12,0"
183+
HorizontalAlignment="Stretch"
184+
HorizontalContentAlignment="Left"
185+
Visibility="{x:Bind ViewModel.ShowAgentOverflowButton, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"
186+
Command="{x:Bind ViewModel.ToggleShowAllAgentsCommand}">
187+
188+
<TextBlock
189+
Text="{x:Bind ViewModel.ShowAllAgents, Converter={StaticResource ShowMoreLessTextConverter}, Mode=OneWay}"
190+
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
191+
</HyperlinkButton>
193192

194193
<controls:HorizontalRule />
195194

0 commit comments

Comments
 (0)
Please sign in to comment.