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..3165e2f 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, INotificationHandler
{
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,13 @@ public void RunInUiThread(DispatcherQueueHandler action)
}
dispatcherQueue.TryEnqueue(action);
}
+
+ public void HandleNotificationActivation(IDictionary args)
+ {
+ var app = (App)Current;
+ if (app != null && app.TrayWindow != null)
+ {
+ app.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/UserNotifier.cs b/App/Services/UserNotifier.cs
index 5ad8e38..e759c50 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;
@@ -20,17 +21,40 @@ public interface IUserNotifier : INotificationHandler, IAsyncDisposable
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,
+ INotificationHandler 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 +74,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 +87,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 +119,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 +131,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..7269e68 100644
--- a/App/Views/TrayWindow.xaml.cs
+++ b/App/Views/TrayWindow.xaml.cs
@@ -9,10 +9,12 @@
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Documents;
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;
@@ -34,19 +36,23 @@ 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,
+ public TrayWindow(
+ IRpcController rpcController, ICredentialManager credentialManager,
ISyncSessionController syncSessionController, IUpdateController updateController,
+ IUserNotifier userNotifier,
TrayWindowLoadingPage loadingPage,
TrayWindowDisconnectedPage disconnectedPage, TrayWindowLoginRequiredPage loginRequiredPage,
TrayWindowMainPage mainPage)
@@ -55,6 +61,7 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
_credentialManager = credentialManager;
_syncSessionController = syncSessionController;
_updateController = updateController;
+ _userNotifier = userNotifier;
_loadingPage = loadingPage;
_disconnectedPage = disconnectedPage;
_loginRequiredPage = loginRequiredPage;
@@ -62,7 +69,6 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
InitializeComponent();
AppWindow.Hide();
- SystemBackdrop = new DesktopAcrylicBackdrop();
Activated += Window_Activated;
RootFrame.SizeChanged += RootFrame_SizeChanged;
@@ -100,18 +106,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(
@@ -146,9 +152,54 @@ private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel,
}
}
+ private void MaybeNotifyUser(RpcModel rpcModel)
+ {
+ // This method is called when the state changes, but we don't want to notify
+ // the user if the state hasn't changed.
+ 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 RpcController_StateChanged(object? _, RpcModel model)
{
SetPageByState(model, _credentialManager.GetCachedCredentials(), _syncSessionController.GetState());
+ MaybeNotifyUser(model);
}
private void CredentialManager_CredentialsChanged(object? _, CredentialModel model)
@@ -227,7 +278,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 +286,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 +297,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 +352,7 @@ private void Window_Activated(object sender, WindowActivatedEventArgs e)
}
[RelayCommand]
- private void Tray_Open()
+ public void Tray_Open()
{
MoveResizeAndActivate();
}
@@ -344,4 +385,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/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/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