diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c4280b6..55f6da6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,9 @@ permissions: jobs: release: - runs-on: ${{ github.repository_owner == 'coder' && 'windows-latest-16-cores' || 'windows-latest' }} + # windows-2025 is required for an up-to-date version of OpenSSL for the + # appcast generation. + runs-on: ${{ github.repository_owner == 'coder' && 'windows-2025-16-cores' || 'windows-2025' }} outputs: version: ${{ steps.version.outputs.VERSION }} timeout-minutes: 15 @@ -166,6 +168,7 @@ jobs: APPCAST_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml APPCAST_SIGNATURE_GCS_URI: gs://releases.coder.com/coder-desktop/windows/appcast.xml.signature APPCAST_SIGNATURE_KEY_BASE64: ${{ secrets.APPCAST_SIGNATURE_KEY_BASE64 }} + GH_TOKEN: ${{ github.token }} GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }} winget: 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/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml index 8a107cb..ce1623b 100644 --- a/App/Views/DirectoryPickerWindow.xaml +++ b/App/Views/DirectoryPickerWindow.xaml @@ -13,7 +13,7 @@ MinWidth="400" MinHeight="600"> - + diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs index 7af6db3..d2eb320 100644 --- a/App/Views/DirectoryPickerWindow.xaml.cs +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -19,8 +19,6 @@ public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) InitializeComponent(); TitleBarIcon.SetTitlebarIcon(this); - SystemBackdrop = new DesktopAcrylicBackdrop(); - viewModel.Initialize(this, DispatcherQueue); RootFrame.Content = new DirectoryPickerMainPage(viewModel); diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml index 070efd2..991d02a 100644 --- a/App/Views/FileSyncListWindow.xaml +++ b/App/Views/FileSyncListWindow.xaml @@ -13,7 +13,7 @@ MinWidth="1000" MinHeight="300"> - + diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index ccd2452..9d8510b 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -16,8 +16,6 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) InitializeComponent(); TitleBarIcon.SetTitlebarIcon(this); - SystemBackdrop = new DesktopAcrylicBackdrop(); - ViewModel.Initialize(this, DispatcherQueue); RootFrame.Content = new FileSyncListMainPage(ViewModel); diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml b/App/Views/Pages/DirectoryPickerMainPage.xaml index dd08c46..0fbbaea 100644 --- a/App/Views/Pages/DirectoryPickerMainPage.xaml +++ b/App/Views/Pages/DirectoryPickerMainPage.xaml @@ -9,8 +9,7 @@ xmlns:converters="using:Coder.Desktop.App.Converters" xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" xmlns:viewmodels="using:Coder.Desktop.App.ViewModels" - mc:Ignorable="d" - Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + mc:Ignorable="d"> + mc:Ignorable="d"> + mc:Ignorable="d"> + 4 diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index 0ca754d..7f20b69 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -6,8 +6,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" - Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + mc:Ignorable="d"> + mc:Ignorable="d"> - + - + - + - + + + + + + @@ -189,6 +194,7 @@ HorizontalAlignment="Right" VerticalAlignment="Center" Height="14" Width="14" + ToolTipService.ToolTip="{x:Bind ConnectionTooltip, Mode=OneWay}" Margin="0,1,0,0"> + + + + - + diff --git a/App/Views/SettingsWindow.xaml.cs b/App/Views/SettingsWindow.xaml.cs index 7cc9661..f2a0fdb 100644 --- a/App/Views/SettingsWindow.xaml.cs +++ b/App/Views/SettingsWindow.xaml.cs @@ -16,8 +16,6 @@ public SettingsWindow(SettingsViewModel viewModel) InitializeComponent(); TitleBarIcon.SetTitlebarIcon(this); - SystemBackdrop = new DesktopAcrylicBackdrop(); - RootFrame.Content = new SettingsMainPage(ViewModel); this.CenterOnScreen(); diff --git a/App/Views/SignInWindow.xaml b/App/Views/SignInWindow.xaml index d2c1326..6d8340c 100644 --- a/App/Views/SignInWindow.xaml +++ b/App/Views/SignInWindow.xaml @@ -11,7 +11,7 @@ Title="Sign in to Coder"> - + diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs index 2acd0a5..da68867 100644 --- a/App/Views/SignInWindow.xaml.cs +++ b/App/Views/SignInWindow.xaml.cs @@ -24,7 +24,6 @@ public SignInWindow(SignInViewModel viewModel) { InitializeComponent(); TitleBarIcon.SetTitlebarIcon(this); - SystemBackdrop = new DesktopAcrylicBackdrop(); RootFrame.SizeChanged += RootFrame_SizeChanged; _signInUrlPage = new SignInUrlPage(this, viewModel); diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs index e505511..de944c2 100644 --- a/App/Views/TrayWindow.xaml.cs +++ b/App/Views/TrayWindow.xaml.cs @@ -5,18 +5,19 @@ using Coder.Desktop.App.Views.Pages; using CommunityToolkit.Mvvm.Input; using Microsoft.UI; -using Microsoft.UI.Input; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Animation; using System; +using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using Windows.Graphics; using Windows.System; using Windows.UI.Core; +using Microsoft.UI.Input; using WinRT.Interop; using WindowActivatedEventArgs = Microsoft.UI.Xaml.WindowActivatedEventArgs; @@ -34,27 +35,29 @@ public sealed partial class TrayWindow : Window private int _lastWindowHeight; private Storyboard? _currentSb; - private NativeApi.POINT? _lastActivatePosition; + private VpnLifecycle curVpnLifecycle = VpnLifecycle.Stopped; + private RpcLifecycle curRpcLifecycle = RpcLifecycle.Disconnected; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; - private readonly ISyncSessionController _syncSessionController; private readonly IUpdateController _updateController; + private readonly IUserNotifier _userNotifier; private readonly TrayWindowLoadingPage _loadingPage; private readonly TrayWindowDisconnectedPage _disconnectedPage; private readonly TrayWindowLoginRequiredPage _loginRequiredPage; private readonly TrayWindowMainPage _mainPage; - public TrayWindow(IRpcController rpcController, ICredentialManager credentialManager, - ISyncSessionController syncSessionController, IUpdateController updateController, + public TrayWindow( + IRpcController rpcController, ICredentialManager credentialManager, + IUpdateController updateController, IUserNotifier userNotifier, TrayWindowLoadingPage loadingPage, TrayWindowDisconnectedPage disconnectedPage, TrayWindowLoginRequiredPage loginRequiredPage, TrayWindowMainPage mainPage) { _rpcController = rpcController; _credentialManager = credentialManager; - _syncSessionController = syncSessionController; _updateController = updateController; + _userNotifier = userNotifier; _loadingPage = loadingPage; _disconnectedPage = disconnectedPage; _loginRequiredPage = loginRequiredPage; @@ -62,15 +65,12 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan InitializeComponent(); AppWindow.Hide(); - SystemBackdrop = new DesktopAcrylicBackdrop(); Activated += Window_Activated; RootFrame.SizeChanged += RootFrame_SizeChanged; _rpcController.StateChanged += RpcController_StateChanged; _credentialManager.CredentialsChanged += CredentialManager_CredentialsChanged; - _syncSessionController.StateChanged += SyncSessionController_StateChanged; - SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials(), - _syncSessionController.GetState()); + SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials()); // Setting these directly in the .xaml doesn't seem to work for whatever reason. TrayIcon.OpenCommand = Tray_OpenCommand; @@ -100,18 +100,18 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan WindowNative.GetWindowHandle(this))); SizeProxy.SizeChanged += (_, e) => { - if (_currentSb is null) return; // nothing running + if (_currentSb is null) return; // nothing running - int newHeight = (int)Math.Round( + var newHeight = (int)Math.Round( e.NewSize.Height * DisplayScale.WindowScale(this)); - int delta = newHeight - _lastWindowHeight; + var delta = newHeight - _lastWindowHeight; if (delta == 0) return; var pos = _aw.Position; var size = _aw.Size; - pos.Y -= delta; // grow upward + pos.Y -= delta; // grow upward size.Height = newHeight; _aw.MoveAndResize( @@ -121,8 +121,7 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan }; } - private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel, - SyncSessionControllerStateModel syncSessionModel) + private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel) { if (credentialModel.State == CredentialState.Unknown) { @@ -146,19 +145,62 @@ private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel, } } - private void RpcController_StateChanged(object? _, RpcModel model) + /// + /// 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 @@ -227,7 +269,6 @@ private void OnStoryboardCompleted(object? sender, object e) private void MoveResizeAndActivate() { - SaveCursorPos(); var size = CalculateWindowSize(RootFrame.GetContentSize().Height); var pos = CalculateWindowPosition(size); var rect = new RectInt32(pos.X, pos.Y, size.Width, size.Height); @@ -236,18 +277,6 @@ private void MoveResizeAndActivate() ForegroundWindow.MakeForeground(this); } - private void SaveCursorPos() - { - var res = NativeApi.GetCursorPos(out var cursorPosition); - if (res) - _lastActivatePosition = cursorPosition; - else - // When the cursor position is null, we will spawn the window in - // the bottom right corner of the primary display. - // TODO: log(?) an error when this happens - _lastActivatePosition = null; - } - private SizeInt32 CalculateWindowSize(double height) { if (height <= 0) height = 100; // will be resolved next frame typically @@ -259,41 +288,44 @@ private SizeInt32 CalculateWindowSize(double height) return new SizeInt32(newWidth, newHeight); } - private PointInt32 CalculateWindowPosition(SizeInt32 size) + private PointInt32 CalculateWindowPosition(SizeInt32 panelSize) { - var width = size.Width; - var height = size.Height; - - var cursorPosition = _lastActivatePosition; - if (cursorPosition is null) + var area = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Primary); + // whole monitor + var bounds = area.OuterBounds; + // monitor minus taskbar + var workArea = area.WorkArea; + + // get taskbar details - position, gap (size), auto-hide + var tb = GetTaskbarInfo(area); + + // safe edges where tray window can touch the screen + var safeRight = workArea.X + workArea.Width; + var safeBottom = workArea.Y + workArea.Height; + + // if the taskbar is auto-hidden at the bottom, stay clear of its reveal band + if (tb.Position == TaskbarPosition.Bottom && tb.AutoHide) + safeBottom -= tb.Gap; // shift everything up by its thickness + + // pick corner & position the panel + int x, y; + switch (tb.Position) { - var primaryWorkArea = DisplayArea.Primary.WorkArea; - return new PointInt32( - primaryWorkArea.Width - width, - primaryWorkArea.Height - height - ); - } - - // Spawn the window to the top right of the cursor. - var x = cursorPosition.Value.X + 10; - var y = cursorPosition.Value.Y - 10 - height; - - var workArea = DisplayArea.GetFromPoint( - new PointInt32(cursorPosition.Value.X, cursorPosition.Value.Y), - DisplayAreaFallback.Primary - ).WorkArea; - - // Adjust if the window goes off the right edge of the display. - if (x + width > workArea.X + workArea.Width) x = workArea.X + workArea.Width - width; - - // Adjust if the window goes off the bottom edge of the display. - if (y + height > workArea.Y + workArea.Height) y = workArea.Y + workArea.Height - height; + case TaskbarPosition.Left: // for Left we will stick to the left-bottom corner + x = bounds.X + tb.Gap; // just right of the bar + y = safeBottom - panelSize.Height; + break; - // Adjust if the window goes off the left edge of the display (somehow). - if (x < workArea.X) x = workArea.X; + case TaskbarPosition.Top: // for Top we will stick to the top-right corner + x = safeRight - panelSize.Width; + y = bounds.Y + tb.Gap; // just below the bar + break; - // Adjust if the window goes off the top edge of the display (somehow). - if (y < workArea.Y) y = workArea.Y; + default: // Bottom or Right bar we will stick to the bottom-right corner + x = safeRight - panelSize.Width; + y = safeBottom - panelSize.Height; + break; + } return new PointInt32(x, y); } @@ -311,7 +343,7 @@ private void Window_Activated(object sender, WindowActivatedEventArgs e) } [RelayCommand] - private void Tray_Open() + public void Tray_Open() { MoveResizeAndActivate(); } @@ -344,4 +376,71 @@ public struct POINT public int Y; } } + + internal enum TaskbarPosition { Left, Top, Right, Bottom } + + internal readonly record struct TaskbarInfo(TaskbarPosition Position, int Gap, bool AutoHide); + + // ----------------------------------------------------------------------------- + // Taskbar helpers – ABM_GETTASKBARPOS / ABM_GETSTATE via SHAppBarMessage + // ----------------------------------------------------------------------------- + private static TaskbarInfo GetTaskbarInfo(DisplayArea area) + { + var data = new APPBARDATA + { + cbSize = (uint)Marshal.SizeOf() + }; + + // Locate the taskbar. + if (SHAppBarMessage(ABM_GETTASKBARPOS, ref data) == 0) + return new TaskbarInfo(TaskbarPosition.Bottom, 0, false); // failsafe + + var autoHide = (SHAppBarMessage(ABM_GETSTATE, ref data) & ABS_AUTOHIDE) != 0; + + // Use uEdge instead of guessing from the RECT. + var pos = data.uEdge switch + { + ABE_LEFT => TaskbarPosition.Left, + ABE_TOP => TaskbarPosition.Top, + ABE_RIGHT => TaskbarPosition.Right, + _ => TaskbarPosition.Bottom, // ABE_BOTTOM or anything unexpected + }; + + // Thickness (gap) = shorter side of the rect. + var gap = (pos == TaskbarPosition.Left || pos == TaskbarPosition.Right) + ? data.rc.right - data.rc.left // width + : data.rc.bottom - data.rc.top; // height + + return new TaskbarInfo(pos, gap, autoHide); + } + + // ------------- P/Invoke plumbing ------------- + private const uint ABM_GETTASKBARPOS = 0x0005; + private const uint ABM_GETSTATE = 0x0004; + private const int ABS_AUTOHIDE = 0x0001; + + private const int ABE_LEFT = 0; // values returned in APPBARDATA.uEdge + private const int ABE_TOP = 1; + private const int ABE_RIGHT = 2; + private const int ABE_BOTTOM = 3; + + [StructLayout(LayoutKind.Sequential)] + private struct APPBARDATA + { + public uint cbSize; + public IntPtr hWnd; + public uint uCallbackMessage; + public uint uEdge; // contains ABE_* value + public RECT rc; + public int lParam; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int left, top, right, bottom; + } + + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + private static extern uint SHAppBarMessage(uint dwMessage, ref APPBARDATA pData); } 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