diff --git a/App/App.csproj b/App/App.csproj index bd36f38..ca3d3c9 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -17,6 +17,8 @@ preview DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION + + 0.1.0.0 Coder Desktop Coder Desktop @@ -24,6 +26,7 @@ Coder Desktop © Coder Technologies Inc. coder.ico + false diff --git a/App/App.xaml.cs b/App/App.xaml.cs index f4c05a2..a07af43 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -27,7 +27,7 @@ namespace Coder.Desktop.App; -public partial class App : Application, IDispatcherQueueManager +public partial class App : Application, IDispatcherQueueManager, IDefaultNotificationHandler { private const string MutagenControllerConfigSection = "MutagenController"; private const string UpdaterConfigSection = "Updater"; @@ -91,6 +91,7 @@ public App() services.AddSingleton(); services.AddSingleton(_ => this); + services.AddSingleton(_ => this); services.AddSingleton(_ => new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); @@ -335,4 +336,9 @@ public void RunInUiThread(DispatcherQueueHandler action) } dispatcherQueue.TryEnqueue(action); } + + public void HandleNotificationActivation(IDictionary _) + { + TrayWindow?.Tray_Open(); + } } diff --git a/App/Assets/coder_icon_32_dark.ico b/App/Assets/coder_icon_32_dark.ico index 4eaa1bb..dd68b83 100644 Binary files a/App/Assets/coder_icon_32_dark.ico and b/App/Assets/coder_icon_32_dark.ico differ diff --git a/App/Assets/coder_icon_32_light.ico b/App/Assets/coder_icon_32_light.ico index 1fc307f..f4dc2a8 100644 Binary files a/App/Assets/coder_icon_32_light.ico and b/App/Assets/coder_icon_32_light.ico differ diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 6868ae7..c8030c3 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -223,8 +223,9 @@ private async Task LoadCredentialsInner(CancellationToken ct) }; } - // Grab the lock again so we can update the state. - using (await _opLock.LockAsync(ct)) + // Grab the lock again so we can update the state. Don't use the CT + // here as it may have already been canceled. + using (await _opLock.LockAsync(TimeSpan.FromSeconds(5), CancellationToken.None)) { // Prevent new LoadCredentials calls from returning this task. if (_loadCts != null) @@ -242,11 +243,8 @@ private async Task LoadCredentialsInner(CancellationToken ct) if (latestCreds is not null) return latestCreds; } - // If there aren't any latest credentials after a cancellation, we - // most likely timed out and should throw. - ct.ThrowIfCancellationRequested(); - UpdateState(model); + ct.ThrowIfCancellationRequested(); return model; } } diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 5ad8e38..27f924d 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Coder.Desktop.App.Views; using Microsoft.Extensions.Logging; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppNotifications.Builder; @@ -14,23 +15,52 @@ public interface INotificationHandler public void HandleNotificationActivation(IDictionary args); } +// This interface is meant to protect the default +// notification handler from being overriden by DI. +public interface IDefaultNotificationHandler : INotificationHandler +{ +} + public interface IUserNotifier : INotificationHandler, IAsyncDisposable { public void RegisterHandler(string name, INotificationHandler handler); public void UnregisterHandler(string name); public Task ShowErrorNotification(string title, string message, CancellationToken ct = default); - public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default); + /// + /// This method allows to display a Windows-native notification with an action defined in + /// and provided . + /// + /// Title of the notification. + /// Message to be displayed in the notification body. + /// Handler should be e.g. nameof(Handler) where Handler + /// implements . + /// If handler is null the action will open Coder Desktop. + /// Arguments to be provided to the handler when executing the action. + public Task ShowActionNotification(string title, string message, string? handlerName, IDictionary? args = null, CancellationToken ct = default); } -public class UserNotifier(ILogger logger, IDispatcherQueueManager dispatcherQueueManager) : IUserNotifier +public class UserNotifier : IUserNotifier { private const string CoderNotificationHandler = "CoderNotificationHandler"; + private const string DefaultNotificationHandler = "DefaultNotificationHandler"; private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + private readonly ILogger _logger; + private readonly IDispatcherQueueManager _dispatcherQueueManager; private ConcurrentDictionary Handlers { get; } = new(); + public UserNotifier(ILogger logger, IDispatcherQueueManager dispatcherQueueManager, + IDefaultNotificationHandler notificationHandler) + { + _logger = logger; + _dispatcherQueueManager = dispatcherQueueManager; + var defaultHandlerAdded = Handlers.TryAdd(DefaultNotificationHandler, notificationHandler); + if (!defaultHandlerAdded) + throw new Exception($"UserNotifier failed to be initialized with {nameof(DefaultNotificationHandler)}"); + } + public ValueTask DisposeAsync() { return ValueTask.CompletedTask; @@ -50,6 +80,8 @@ public void RegisterHandler(string name, INotificationHandler handler) public void UnregisterHandler(string name) { + if (name == nameof(DefaultNotificationHandler)) + throw new InvalidOperationException($"You cannot remove '{name}'."); if (!Handlers.TryRemove(name, out _)) throw new InvalidOperationException($"No handler with the name '{name}' is registered."); } @@ -61,8 +93,11 @@ public Task ShowErrorNotification(string title, string message, CancellationToke return Task.CompletedTask; } - public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default) + public Task ShowActionNotification(string title, string message, string? handlerName, IDictionary? args = null, CancellationToken ct = default) { + if (handlerName == null) + handlerName = nameof(DefaultNotificationHandler); // Use default handler if no handler name is provided + if (!Handlers.TryGetValue(handlerName, out _)) throw new InvalidOperationException($"No action handler with the name '{handlerName}' is registered."); @@ -90,11 +125,11 @@ public void HandleNotificationActivation(IDictionary args) if (!Handlers.TryGetValue(handlerName, out var handler)) { - logger.LogWarning("no action handler '{HandlerName}' found for notification activation, ignoring", handlerName); + _logger.LogWarning("no action handler '{HandlerName}' found for notification activation, ignoring", handlerName); return; } - dispatcherQueueManager.RunInUiThread(() => + _dispatcherQueueManager.RunInUiThread(() => { try { @@ -102,7 +137,7 @@ public void HandleNotificationActivation(IDictionary args) } catch (Exception ex) { - logger.LogWarning(ex, "could not handle activation for notification with handler '{HandlerName}", handlerName); + _logger.LogWarning(ex, "could not handle activation for notification with handler '{HandlerName}", handlerName); } }); } diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index cd5907b..0cf2651 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -1,11 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Windows.ApplicationModel.DataTransfer; using Coder.Desktop.App.Services; using Coder.Desktop.App.Utils; using Coder.Desktop.CoderSdk; @@ -18,15 +10,24 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Windows.ApplicationModel.DataTransfer; namespace Coder.Desktop.App.ViewModels; public interface IAgentViewModelFactory { public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, - string hostnameSuffix, - AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName); - + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, DateTime? lastHandshake); public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName); @@ -40,7 +41,9 @@ public class AgentViewModelFactory( { public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, string hostnameSuffix, - AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName) + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, + DateTime? lastHandshake) { return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, expanderHost, id) @@ -51,6 +54,11 @@ public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fu ConnectionStatus = connectionStatus, DashboardBaseUrl = dashboardBaseUrl, WorkspaceName = workspaceName, + DidP2p = didP2p, + PreferredDerp = preferredDerp, + Latency = latency, + PreferredDerpLatency = preferredDerpLatency, + LastHandshake = lastHandshake, }; } @@ -73,10 +81,25 @@ public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, public enum AgentConnectionStatus { - Green, - Yellow, - Red, - Gray, + Healthy, + Connecting, + Unhealthy, + NoRecentHandshake, + Offline +} + +public static class AgentConnectionStatusExtensions +{ + public static string ToDisplayString(this AgentConnectionStatus status) => + status switch + { + AgentConnectionStatus.Healthy => "Healthy", + AgentConnectionStatus.Connecting => "Connecting", + AgentConnectionStatus.Unhealthy => "High latency", + AgentConnectionStatus.NoRecentHandshake => "No recent handshake", + AgentConnectionStatus.Offline => "Offline", + _ => status.ToString() + }; } public partial class AgentViewModel : ObservableObject, IModelUpdateable @@ -160,6 +183,7 @@ public string FullyQualifiedDomainName [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] public required partial AgentConnectionStatus ConnectionStatus { get; set; } [ObservableProperty] @@ -182,6 +206,77 @@ public string FullyQualifiedDomainName [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] public partial bool AppFetchErrored { get; set; } = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial bool? DidP2p { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial string? PreferredDerp { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? Latency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? PreferredDerpLatency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial DateTime? LastHandshake { get; set; } = null; + + public string ConnectionTooltip + { + get + { + var description = new StringBuilder(); + var highLatencyWarning = ConnectionStatus == AgentConnectionStatus.Unhealthy ? $"({AgentConnectionStatus.Unhealthy.ToDisplayString()})" : ""; + + if (DidP2p != null && DidP2p.Value && Latency != null) + { + description.Append($""" + You're connected peer-to-peer. {highLatencyWarning} + + You ↔ {Latency.Value.Milliseconds} ms ↔ {WorkspaceName} + """ + ); + } + else if (Latency != null) + { + description.Append($""" + You're connected through a DERP relay. {highLatencyWarning} + We'll switch over to peer-to-peer when available. + + Total latency: {Latency.Value.Milliseconds} ms + """ + ); + + if (PreferredDerpLatency != null) + { + description.Append($"\nYou ↔ {PreferredDerp}: {PreferredDerpLatency.Value.Milliseconds} ms"); + + var derpToWorkspaceEstimatedLatency = Latency - PreferredDerpLatency; + + // Guard against negative values if the two readings were taken at different times + if (derpToWorkspaceEstimatedLatency > TimeSpan.Zero) + { + description.Append($"\n{PreferredDerp} ms ↔ {WorkspaceName}: {derpToWorkspaceEstimatedLatency.Value.Milliseconds} ms"); + } + } + } + else + { + description.Append(ConnectionStatus.ToDisplayString()); + } + if (LastHandshake != null) + description.Append($"\n\nLast handshake: {LastHandshake?.ToString()}"); + + return description.ToString().TrimEnd('\n', ' '); ; + } + } + + // We only show 6 apps max, which fills the entire width of the tray // window. public IEnumerable VisibleApps => Apps.Count > MaxAppsPerRow ? Apps.Take(MaxAppsPerRow) : Apps; @@ -192,7 +287,7 @@ public string? ExpandAppsMessage { get { - if (ConnectionStatus == AgentConnectionStatus.Gray) + if (ConnectionStatus == AgentConnectionStatus.Offline) return "Your workspace is offline."; if (FetchingApps && Apps.Count == 0) // Don't show this message if we have any apps already. When @@ -285,6 +380,16 @@ public bool TryApplyChanges(AgentViewModel model) DashboardBaseUrl = model.DashboardBaseUrl; if (WorkspaceName != model.WorkspaceName) WorkspaceName = model.WorkspaceName; + if (DidP2p != model.DidP2p) + DidP2p = model.DidP2p; + if (PreferredDerp != model.PreferredDerp) + PreferredDerp = model.PreferredDerp; + if (Latency != model.Latency) + Latency = model.Latency; + if (PreferredDerpLatency != model.PreferredDerpLatency) + PreferredDerpLatency = model.PreferredDerpLatency; + if (LastHandshake != model.LastHandshake) + LastHandshake = model.LastHandshake; // Apps are not set externally. @@ -307,7 +412,7 @@ public void SetExpanded(bool expanded) partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue) { - if (IsExpanded && newValue is not AgentConnectionStatus.Gray) FetchApps(); + if (IsExpanded && newValue is not AgentConnectionStatus.Offline) FetchApps(); } private void FetchApps() @@ -316,7 +421,7 @@ private void FetchApps() FetchingApps = true; // If the workspace is off, then there's no agent and there's no apps. - if (ConnectionStatus == AgentConnectionStatus.Gray) + if (ConnectionStatus == AgentConnectionStatus.Offline) { FetchingApps = false; Apps.Clear(); diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 8540453..f57947d 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; +using System.Security.Principal; using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; @@ -29,6 +30,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; + private readonly TimeSpan HealthyPingThreshold = TimeSpan.FromMilliseconds(150); private readonly IServiceProvider _services; private readonly IRpcController _rpcController; @@ -222,10 +224,28 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (string.IsNullOrWhiteSpace(fqdn)) continue; - var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); - var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) - ? AgentConnectionStatus.Green - : AgentConnectionStatus.Yellow; + var connectionStatus = AgentConnectionStatus.Healthy; + + if (agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default && agent.LastHandshake.ToDateTime() < DateTime.UtcNow) + { + // For compatibility with older deployments, we assume that if the + // last ping is null, the agent is healthy. + var isLatencyAcceptable = agent.LastPing == null || agent.LastPing.Latency.ToTimeSpan() < HealthyPingThreshold; + + var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + + if (lastHandshakeAgo > TimeSpan.FromMinutes(5)) + connectionStatus = AgentConnectionStatus.NoRecentHandshake; + else if (!isLatencyAcceptable) + connectionStatus = AgentConnectionStatus.Unhealthy; + } + else + { + // If the last handshake is not correct (null, default or in the future), + // we assume the agent is connecting (yellow status icon). + connectionStatus = AgentConnectionStatus.Connecting; + } + workspacesWithAgents.Add(agent.WorkspaceId); var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId); @@ -236,7 +256,12 @@ private void UpdateFromRpcModel(RpcModel rpcModel) _hostnameSuffixGetter.GetCachedSuffix(), connectionStatus, credentialModel.CoderUrl, - workspace?.Name)); + workspace?.Name, + agent.LastPing?.DidP2P, + agent.LastPing?.PreferredDerp, + agent.LastPing?.Latency?.ToTimeSpan(), + agent.LastPing?.PreferredDerpLatency?.ToTimeSpan(), + agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default ? agent.LastHandshake?.ToDateTime() : null)); } // For every stopped workspace that doesn't have any agents, add a @@ -253,7 +278,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) // conflict with any agent IDs. uuid, _hostnameSuffixGetter.GetCachedSuffix(), - AgentConnectionStatus.Gray, + AgentConnectionStatus.Offline, credentialModel.CoderUrl, workspace.Name)); } @@ -268,7 +293,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (Agents.Count < MaxAgents) ShowAllAgents = false; - var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Gray); + var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Offline); if (firstOnlineAgent is null) _hasExpandedAgent = false; if (!_hasExpandedAgent && firstOnlineAgent is not null) @@ -433,7 +458,7 @@ private static bool ShouldShowDummy(Workspace workspace) case Workspace.Types.Status.Stopping: case Workspace.Types.Status.Stopped: return true; - // TODO: should we include and show a different color than Gray for workspaces that are + // TODO: should we include and show a different color than Offline for workspaces that are // failed, canceled or deleting? default: return false; diff --git a/App/ViewModels/UpdaterUpdateAvailableViewModel.cs b/App/ViewModels/UpdaterUpdateAvailableViewModel.cs index 9fd6dd9..4d3c692 100644 --- a/App/ViewModels/UpdaterUpdateAvailableViewModel.cs +++ b/App/ViewModels/UpdaterUpdateAvailableViewModel.cs @@ -4,10 +4,12 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Coder.Desktop.App.Services; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.Web.WebView2.Core; using NetSparkleUpdater; using NetSparkleUpdater.Enums; using NetSparkleUpdater.Events; @@ -174,8 +176,18 @@ public async Task Changelog_Loaded(object sender, RoutedEventArgs e) if (sender is not WebView2 webView) return; - // Start the engine. - await webView.EnsureCoreWebView2Async(); + // Start the engine with a custom user data folder. The default for + // unpackaged WinUI 3 apps is to write to a subfolder in the app's + // install directory, which is Program Files by default and not + // writeable by the user. + var userDataFolder = Path.Join(SettingsManagerUtils.AppSettingsDirectory(), "WebView2"); + _logger.LogDebug("Creating WebView2 user data folder at {UserDataFolder}", userDataFolder); + Directory.CreateDirectory(userDataFolder); + var env = await CoreWebView2Environment.CreateWithOptionsAsync( + null, + userDataFolder, + new CoreWebView2EnvironmentOptions()); + await webView.EnsureCoreWebView2Async(env); // Disable unwanted features. var settings = webView.CoreWebView2.Settings; diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index 80f557d..3cc21de 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -137,22 +137,27 @@ x:Key="StatusColor" SelectedKey="{x:Bind Path=ConnectionStatus, Mode=OneWay}"> - + - + - + - + + + + + + @@ -189,6 +194,7 @@ HorizontalAlignment="Right" VerticalAlignment="Center" Height="14" Width="14" + ToolTipService.ToolTip="{x:Bind ConnectionTooltip, Mode=OneWay}" Margin="0,1,0,0"> + /// This method is called when the state changes, but we don't want to notify + /// the user if the state hasn't changed. + /// + /// + private void MaybeNotifyUser(RpcModel rpcModel) { - SetPageByState(model, _credentialManager.GetCachedCredentials(), _syncSessionController.GetState()); + var isRpcLifecycleChanged = rpcModel.RpcLifecycle == RpcLifecycle.Disconnected && curRpcLifecycle != rpcModel.RpcLifecycle; + var isVpnLifecycleChanged = (rpcModel.VpnLifecycle == VpnLifecycle.Started || rpcModel.VpnLifecycle == VpnLifecycle.Stopped) && curVpnLifecycle != rpcModel.VpnLifecycle; + + if (!isRpcLifecycleChanged && !isVpnLifecycleChanged) + { + return; + } + + var oldRpcLifeycle = curRpcLifecycle; + var oldVpnLifecycle = curVpnLifecycle; + curRpcLifecycle = rpcModel.RpcLifecycle; + curVpnLifecycle = rpcModel.VpnLifecycle; + + var messages = new List(); + + if (oldRpcLifeycle != RpcLifecycle.Disconnected && curRpcLifecycle == RpcLifecycle.Disconnected) + { + messages.Add("Disconnected from Coder background service."); + } + + if (oldVpnLifecycle != curVpnLifecycle) + { + switch (curVpnLifecycle) + { + case VpnLifecycle.Started: + messages.Add("Coder Connect started."); + break; + case VpnLifecycle.Stopped: + messages.Add("Coder Connect stopped."); + break; + } + } + + if (messages.Count == 0) return; + if (_aw.IsVisible) return; + + var message = string.Join(" ", messages); + _userNotifier.ShowActionNotification(message, string.Empty, null, null, CancellationToken.None); } - private void CredentialManager_CredentialsChanged(object? _, CredentialModel model) + private void RpcController_StateChanged(object? _, RpcModel model) { - SetPageByState(_rpcController.GetState(), model, _syncSessionController.GetState()); + SetPageByState(model, _credentialManager.GetCachedCredentials()); + MaybeNotifyUser(model); } - private void SyncSessionController_StateChanged(object? _, SyncSessionControllerStateModel model) + private void CredentialManager_CredentialsChanged(object? _, CredentialModel model) { - SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials(), model); + SetPageByState(_rpcController.GetState(), model); } // Sadly this is necessary because Window.Content.SizeChanged doesn't @@ -297,7 +343,7 @@ private void Window_Activated(object sender, WindowActivatedEventArgs e) } [RelayCommand] - private void Tray_Open() + public void Tray_Open() { MoveResizeAndActivate(); } diff --git a/App/coder.ico b/App/coder.ico index e13ad3b..b80bdc2 100644 Binary files a/App/coder.ico and b/App/coder.ico differ diff --git a/Tests.App/Services/CredentialManagerTest.cs b/Tests.App/Services/CredentialManagerTest.cs index 9f1b0df..3f9c4cb 100644 --- a/Tests.App/Services/CredentialManagerTest.cs +++ b/Tests.App/Services/CredentialManagerTest.cs @@ -317,7 +317,10 @@ public async Task SetDuringLoad(CancellationToken ct) var loadTask = manager.LoadCredentials(ct); // Then fully perform a set. await manager.SetCredentials(TestServerUrl, TestApiToken, ct).WaitAsync(ct); - // The load should have been cancelled. - Assert.ThrowsAsync(() => loadTask); + + // The load should complete with the new valid credentials + var result = await loadTask; + Assert.That(result.State, Is.EqualTo(CredentialState.Valid)); + Assert.That(result.CoderUrl?.ToString(), Is.EqualTo(TestServerUrl)); } } diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto index bace7e0..11a481c 100644 --- a/Vpn.Proto/vpn.proto +++ b/Vpn.Proto/vpn.proto @@ -3,6 +3,7 @@ option go_package = "github.com/coder/coder/v2/vpn"; option csharp_namespace = "Coder.Desktop.Vpn.Proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; package vpn; @@ -48,10 +49,10 @@ message TunnelMessage { message ClientMessage { RPC rpc = 1; oneof msg { - StartRequest start = 2; - StopRequest stop = 3; - StatusRequest status = 4; - } + StartRequest start = 2; + StopRequest stop = 3; + StatusRequest status = 4; + } } // ServiceMessage is a message from the service (to the client). Windows only. @@ -131,6 +132,21 @@ message Agent { // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or // anything longer than 5 minutes ago means there is a problem. google.protobuf.Timestamp last_handshake = 6; + // If unset, a successful ping has not yet been made. + optional LastPing last_ping = 7; +} + +message LastPing { + // latency is the RTT of the ping to the agent. + google.protobuf.Duration latency = 1; + // did_p2p indicates whether the ping was sent P2P, or over DERP. + bool did_p2p = 2; + // preferred_derp is the human readable name of the preferred DERP region, + // or the region used for the last ping, if it was sent over DERP. + string preferred_derp = 3; + // preferred_derp_latency is the last known latency to the preferred DERP + // region. Unset if the region does not appear in the DERP map. + optional google.protobuf.Duration preferred_derp_latency = 4; } // NetworkSettingsRequest is based on diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index fdb62af..027a882 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -172,6 +172,10 @@ await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMess if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start) throw new InvalidOperationException("Tunnel did not reply with a Start response"); + // If the tunnel failed to start, stop the subprocess. + if (!reply.Start.Success) + await _tunnelSupervisor.StopAsync(ct); + await BroadcastStatus(reply.Start.Success ? TunnelStatus.Started : TunnelStatus.Stopped, ct); return reply.Start; } diff --git a/Vpn.Service/coder.ico b/Vpn.Service/coder.ico index e13ad3b..b80bdc2 100644 Binary files a/Vpn.Service/coder.ico and b/Vpn.Service/coder.ico differ diff --git a/scripts/files/logo.png b/scripts/files/logo.png index 7d87306..bdb8b9b 100644 Binary files a/scripts/files/logo.png and b/scripts/files/logo.png differ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy