Skip to content

Commit 51bf68e

Browse files
authored
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

+1-1
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

-12
This file was deleted.

App/Converters/VpnLifecycleToVisibilityConverter.cs

-14
This file was deleted.

App/Services/RpcController.cs

+22-11
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

+65-17
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
{

0 commit comments

Comments
 (0)