Skip to content

feat: show vpn start/stop failure in app #44

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 2 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion App/Converters/AgentStatusToColorConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Coder.Desktop.App.Converters;
public class AgentStatusToColorConverter : IValueConverter
{
private static readonly SolidColorBrush Green = new(Color.FromArgb(255, 52, 199, 89));
private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 204, 1, 0));
private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 255, 204, 1));
private static readonly SolidColorBrush Red = new(Color.FromArgb(255, 255, 59, 48));
private static readonly SolidColorBrush Gray = new(Color.FromArgb(255, 142, 142, 147));

Expand Down
12 changes: 0 additions & 12 deletions App/Converters/InverseBoolToVisibilityConverter.cs

This file was deleted.

14 changes: 0 additions & 14 deletions App/Converters/VpnLifecycleToVisibilityConverter.cs

This file was deleted.

33 changes: 22 additions & 11 deletions App/Services/RpcController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ public async Task Reconnect(CancellationToken ct = default)
Status = new StatusRequest(),
}, ct);
if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status)
throw new InvalidOperationException($"Unexpected reply message type: {statusReply.MsgCase}");
throw new VpnLifecycleException(
$"Failed to get VPN status. Unexpected reply message type: {statusReply.MsgCase}");
ApplyStatusUpdate(statusReply.Status);
}

Expand All @@ -172,20 +173,26 @@ public async Task StartVpn(CancellationToken ct = default)
ApiToken = credentials.ApiToken,
},
}, ct);
if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start)
throw new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}");
}
catch (Exception e)
{
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
throw new RpcOperationException("Failed to send start command to service", e);
}

if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start)
{
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
throw new VpnLifecycleException($"Failed to start VPN. Unexpected reply message type: {reply.MsgCase}");
}

if (!reply.Start.Success)
{
// We use Stopped instead of Unknown here as it's usually the case
// that a failed start got cleaned up successfully.
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
throw new VpnLifecycleException("Failed to start VPN",
new InvalidOperationException($"Service reported failure: {reply.Start.ErrorMessage}"));
throw new VpnLifecycleException(
$"Failed to start VPN. Service reported failure: {reply.Start.ErrorMessage}");
}

MutateState(state => { state.VpnLifecycle = VpnLifecycle.Started; });
Expand All @@ -212,16 +219,20 @@ public async Task StopVpn(CancellationToken ct = default)
}
finally
{
// Technically the state is unknown now.
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
}

if (reply.MsgCase != ServiceMessage.MsgOneofCase.Stop)
throw new VpnLifecycleException("Failed to stop VPN",
new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}"));
{
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
throw new VpnLifecycleException($"Failed to stop VPN. Unexpected reply message type: {reply.MsgCase}");
}

if (!reply.Stop.Success)
throw new VpnLifecycleException("Failed to stop VPN",
new InvalidOperationException($"Service reported failure: {reply.Stop.ErrorMessage}"));
{
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
throw new VpnLifecycleException($"Failed to stop VPN. Service reported failure: {reply.Stop.ErrorMessage}");
}
}

public async ValueTask DisposeAsync()
Expand Down
82 changes: 65 additions & 17 deletions App/ViewModels/TrayWindowViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
using Coder.Desktop.Vpn.Proto;
Expand All @@ -10,6 +11,7 @@
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Exception = System.Exception;

namespace Coder.Desktop.App.ViewModels;

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

private DispatcherQueue? _dispatcherQueue;

[ObservableProperty] public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowEnableSection))]
[NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;

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

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

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NoAgents))]
[NotifyPropertyChangedFor(nameof(AgentOverflow))]
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
[NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
[NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
[NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
public partial List<AgentViewModel> Agents { get; set; } = [];

public bool NoAgents => Agents.Count == 0;
public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started;

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

public bool ShowNoAgentsSection =>
VpnFailedMessage is null && Agents.Count == 0 && VpnLifecycle is VpnLifecycle.Started;

public bool ShowAgentsSection =>
VpnFailedMessage is null && Agents.Count > 0 && VpnLifecycle is VpnLifecycle.Started;

public bool ShowFailedSection => VpnFailedMessage is not null;

public bool AgentOverflow => Agents.Count > MaxAgents;
public bool ShowAgentOverflowButton => VpnFailedMessage is null && Agents.Count > MaxAgents;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
Expand Down Expand Up @@ -190,24 +215,47 @@ public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (sender is not ToggleSwitch toggleSwitch) return;

VpnFailedMessage = "";
VpnFailedMessage = null;

// The start/stop methods will call back to update the state.
if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped)
_ = StartVpn(); // in the background
else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started)
_ = StopVpn(); // in the background
else
toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
}

private async Task StartVpn()
{
try
{
// The start/stop methods will call back to update the state.
if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped)
_rpcController.StartVpn();
else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started)
_rpcController.StopVpn();
else
toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
await _rpcController.StartVpn();
}
catch
catch (Exception e)
{
// TODO: display error
VpnFailedMessage = e.ToString();
VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e);
}
}

private async Task StopVpn()
{
try
{
await _rpcController.StopVpn();
}
catch (Exception e)
{
VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e);
}
}

private static string MaybeUnwrapTunnelError(Exception e)
{
if (e is VpnLifecycleException vpnError) return vpnError.Message;
return e.ToString();
}

[RelayCommand]
public void ToggleShowAllAgents()
{
Expand Down
Loading
Loading