From 78ff6da0b467000c845e842e51c75a9672cb2d08 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 2 May 2025 07:12:58 +0400 Subject: [PATCH 01/10] feat: add support for notifications (#85) Adds support for OS notifications, which I'll use to show errors handling URIs in a subsequent PR. [Screen Recording 2025-05-01 145532.mp4 (uploaded via Graphite) ](https://app.graphite.dev/media/video/tCz4CxRU9jhAJ7zH8RTi/f838fb8a-6815-48a7-bd52-63d6a06ce742.mp4) --- App/App.xaml.cs | 14 +++++++++++++- App/Program.cs | 12 +++++++++++- App/Services/UserNotifier.cs | 29 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 App/Services/UserNotifier.cs diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 2c7e87e..2cdee97 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -21,6 +21,7 @@ using Microsoft.Windows.AppLifecycle; using Serilog; using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; +using Microsoft.Windows.AppNotifications; namespace Coder.Desktop.App; @@ -70,6 +71,7 @@ public App() services.AddOptions() .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); services.AddSingleton(); + services.AddSingleton(); // SignInWindow views and view models services.AddTransient(); @@ -188,10 +190,14 @@ public void OnActivated(object? sender, AppActivationArguments args) _logger.LogWarning("URI activation with null data"); return; } - HandleURIActivation(protoArgs.Uri); break; + case ExtendedActivationKind.AppNotification: + var notificationArgs = (args.Data as AppNotificationActivatedEventArgs)!; + HandleNotification(null, notificationArgs); + break; + default: _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind); break; @@ -204,6 +210,12 @@ public void HandleURIActivation(Uri uri) _logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath); } + public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args) + { + // right now, we don't do anything other than log + _logger.LogInformation("handled notification activation"); + } + private static void AddDefaultConfig(IConfigurationBuilder builder) { var logPath = Path.Combine( diff --git a/App/Program.cs b/App/Program.cs index 1a54b2b..3749c3b 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -5,6 +5,7 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using WinRT; namespace Coder.Desktop.App; @@ -28,9 +29,9 @@ private static void Main(string[] args) { ComWrappersSupport.InitializeComWrappers(); var mainInstance = GetMainInstance(); + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); if (!mainInstance.IsCurrent) { - var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait(); return; } @@ -58,6 +59,15 @@ private static void Main(string[] args) // redirections via RedirectActivationToAsync above get routed to the App mainInstance.Activated += app.OnActivated; + var notificationManager = AppNotificationManager.Default; + notificationManager.NotificationInvoked += app.HandleNotification; + notificationManager.Register(); + if (activationArgs.Kind != ExtendedActivationKind.Launch) + { + // this means we were activated without having already launched, so handle + // the activation as well. + app.OnActivated(null, activationArgs); + } }); } catch (Exception e) diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs new file mode 100644 index 0000000..9cdf6c1 --- /dev/null +++ b/App/Services/UserNotifier.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Windows.AppNotifications; +using Microsoft.Windows.AppNotifications.Builder; + +namespace Coder.Desktop.App.Services; + +public interface IUserNotifier : IAsyncDisposable +{ + public Task ShowErrorNotification(string title, string message); +} + +public class UserNotifier : IUserNotifier +{ + private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public Task ShowErrorNotification(string title, string message) + { + var builder = new AppNotificationBuilder().AddText(title).AddText(message); + _notificationManager.Show(builder.BuildNotification()); + return Task.CompletedTask; + } +} + From 119e52a893eb2669b9acc258401c15ab4886f0e3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 7 May 2025 12:48:53 +0200 Subject: [PATCH 02/10] feat: add coder icon to all forms (#89) Closes: #76 --- App/Utils/TitleBarIcon.cs | 19 +++++++++++++++++++ App/Views/DirectoryPickerWindow.xaml.cs | 3 +++ App/Views/FileSyncListWindow.xaml.cs | 4 ++++ App/Views/SignInWindow.xaml.cs | 2 ++ 4 files changed, 28 insertions(+) create mode 100644 App/Utils/TitleBarIcon.cs diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs new file mode 100644 index 0000000..3efc81d --- /dev/null +++ b/App/Utils/TitleBarIcon.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; +using WinRT.Interop; + +namespace Coder.Desktop.App.Utils +{ + public static class TitleBarIcon + { + public static void SetTitlebarIcon(Window window) + { + var hwnd = WindowNative.GetWindowHandle(window); + var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico"); + } + } +} diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs index 6ed5f43..2409d4b 100644 --- a/App/Views/DirectoryPickerWindow.xaml.cs +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -8,6 +8,7 @@ using Microsoft.UI.Xaml.Media; using WinRT.Interop; using WinUIEx; +using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -16,6 +17,8 @@ public sealed partial class DirectoryPickerWindow : WindowEx public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) { InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + SystemBackdrop = new DesktopAcrylicBackdrop(); viewModel.Initialize(this, DispatcherQueue); diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 428363b..fb899cc 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -2,6 +2,7 @@ using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Xaml.Media; using WinUIEx; +using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -13,6 +14,8 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) { ViewModel = viewModel; InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + SystemBackdrop = new DesktopAcrylicBackdrop(); ViewModel.Initialize(this, DispatcherQueue); @@ -20,4 +23,5 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) this.CenterOnScreen(); } + } diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs index 3fe4b5c..fb933c7 100644 --- a/App/Views/SignInWindow.xaml.cs +++ b/App/Views/SignInWindow.xaml.cs @@ -6,6 +6,7 @@ using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media; +using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -22,6 +23,7 @@ public sealed partial class SignInWindow : Window public SignInWindow(SignInViewModel viewModel) { InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); SystemBackdrop = new DesktopAcrylicBackdrop(); RootFrame.SizeChanged += RootFrame_SizeChanged; From 2a4814ea4c7a0d0af75cbbc501660be2ad00915a Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 8 May 2025 13:54:03 +0400 Subject: [PATCH 03/10] feat: add support for RDP URIs (#87) Adds basic support for `coder:/` URIs for opening RDP. relates to #52 but I still need to add support for checking the authority. --- App/App.xaml.cs | 24 ++- App/Services/CredentialManager.cs | 218 ++++++++++++++++--------- App/Services/RdpConnector.cs | 76 +++++++++ App/Services/UriHandler.cs | 152 +++++++++++++++++ App/Services/UserNotifier.cs | 5 +- Tests.App/Services/RdpConnectorTest.cs | 27 +++ Tests.App/Services/UriHandlerTest.cs | 178 ++++++++++++++++++++ Tests.App/Tests.App.csproj | 2 + 8 files changed, 596 insertions(+), 86 deletions(-) create mode 100644 App/Services/RdpConnector.cs create mode 100644 App/Services/UriHandler.cs create mode 100644 Tests.App/Services/RdpConnectorTest.cs create mode 100644 Tests.App/Services/UriHandlerTest.cs diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 2cdee97..ba6fa67 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -41,6 +41,7 @@ public partial class App : Application #endif private readonly ILogger _logger; + private readonly IUriHandler _uriHandler; public App() { @@ -72,6 +73,8 @@ public App() .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // SignInWindow views and view models services.AddTransient(); @@ -98,6 +101,7 @@ public App() _services = services.BuildServiceProvider(); _logger = (ILogger)_services.GetService(typeof(ILogger))!; + _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!; InitializeComponent(); } @@ -190,7 +194,19 @@ public void OnActivated(object? sender, AppActivationArguments args) _logger.LogWarning("URI activation with null data"); return; } - HandleURIActivation(protoArgs.Uri); + + // don't need to wait for it to complete. + _uriHandler.HandleUri(protoArgs.Uri).ContinueWith(t => + { + if (t.Exception != null) + { + // don't log query params, as they contain secrets. + _logger.LogError(t.Exception, + "unhandled exception while processing URI coder://{authority}{path}", + protoArgs.Uri.Authority, protoArgs.Uri.AbsolutePath); + } + }); + break; case ExtendedActivationKind.AppNotification: @@ -204,12 +220,6 @@ public void OnActivated(object? sender, AppActivationArguments args) } } - public void HandleURIActivation(Uri uri) - { - // don't log the query string as that's where we include some sensitive information like passwords - _logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath); - } - public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args) { // right now, we don't do anything other than log diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index a2f6567..280169c 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -307,7 +307,7 @@ public WindowsCredentialBackend(string credentialsTargetName) public Task ReadCredentials(CancellationToken ct = default) { - var raw = NativeApi.ReadCredentials(_credentialsTargetName); + var raw = Wincred.ReadCredentials(_credentialsTargetName); if (raw == null) return Task.FromResult(null); RawCredentials? credentials; @@ -326,115 +326,179 @@ public WindowsCredentialBackend(string credentialsTargetName) public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default) { var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials); - NativeApi.WriteCredentials(_credentialsTargetName, raw); + Wincred.WriteCredentials(_credentialsTargetName, raw); return Task.CompletedTask; } public Task DeleteCredentials(CancellationToken ct = default) { - NativeApi.DeleteCredentials(_credentialsTargetName); + Wincred.DeleteCredentials(_credentialsTargetName); return Task.CompletedTask; } - private static class NativeApi +} + +/// +/// Wincred provides relatively low level wrapped calls to the Wincred.h native API. +/// +internal static class Wincred +{ + private const int CredentialTypeGeneric = 1; + private const int CredentialTypeDomainPassword = 2; + private const int PersistenceTypeLocalComputer = 2; + private const int ErrorNotFound = 1168; + private const int CredMaxCredentialBlobSize = 5 * 512; + private const string PackageNTLM = "NTLM"; + + public static string? ReadCredentials(string targetName) { - private const int CredentialTypeGeneric = 1; - private const int PersistenceTypeLocalComputer = 2; - private const int ErrorNotFound = 1168; - private const int CredMaxCredentialBlobSize = 5 * 512; + if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return null; + throw new InvalidOperationException($"Failed to read credentials (Error {error})"); + } - public static string? ReadCredentials(string targetName) + try { - if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr)) - { - var error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) return null; - throw new InvalidOperationException($"Failed to read credentials (Error {error})"); - } + var cred = Marshal.PtrToStructure(credentialPtr); + return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); + } + finally + { + CredFree(credentialPtr); + } + } - try - { - var cred = Marshal.PtrToStructure(credentialPtr); - return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); - } - finally + public static void WriteCredentials(string targetName, string secret) + { + var byteCount = Encoding.Unicode.GetByteCount(secret); + if (byteCount > CredMaxCredentialBlobSize) + throw new ArgumentOutOfRangeException(nameof(secret), + $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + + var credentialBlob = Marshal.StringToHGlobalUni(secret); + var cred = new CREDENTIALW + { + Type = CredentialTypeGeneric, + TargetName = targetName, + CredentialBlobSize = byteCount, + CredentialBlob = credentialBlob, + Persist = PersistenceTypeLocalComputer, + }; + try + { + if (!CredWriteW(ref cred, 0)) { - CredFree(credentialPtr); + var error = Marshal.GetLastWin32Error(); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } - - public static void WriteCredentials(string targetName, string secret) + finally { - var byteCount = Encoding.Unicode.GetByteCount(secret); - if (byteCount > CredMaxCredentialBlobSize) - throw new ArgumentOutOfRangeException(nameof(secret), - $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + Marshal.FreeHGlobal(credentialBlob); + } + } - var credentialBlob = Marshal.StringToHGlobalUni(secret); - var cred = new CREDENTIAL - { - Type = CredentialTypeGeneric, - TargetName = targetName, - CredentialBlobSize = byteCount, - CredentialBlob = credentialBlob, - Persist = PersistenceTypeLocalComputer, - }; - try - { - if (!CredWriteW(ref cred, 0)) - { - var error = Marshal.GetLastWin32Error(); - throw new InvalidOperationException($"Failed to write credentials (Error {error})"); - } - } - finally - { - Marshal.FreeHGlobal(credentialBlob); - } + public static void DeleteCredentials(string targetName) + { + if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return; + throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); } + } + + public static void WriteDomainCredentials(string domainName, string serverName, string username, string password) + { + var targetName = $"{domainName}/{serverName}"; + var targetInfo = new CREDENTIAL_TARGET_INFORMATIONW + { + TargetName = targetName, + DnsServerName = serverName, + DnsDomainName = domainName, + PackageName = PackageNTLM, + }; + var byteCount = Encoding.Unicode.GetByteCount(password); + if (byteCount > CredMaxCredentialBlobSize) + throw new ArgumentOutOfRangeException(nameof(password), + $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); - public static void DeleteCredentials(string targetName) + var credentialBlob = Marshal.StringToHGlobalUni(password); + var cred = new CREDENTIALW { - if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + Type = CredentialTypeDomainPassword, + TargetName = targetName, + CredentialBlobSize = byteCount, + CredentialBlob = credentialBlob, + Persist = PersistenceTypeLocalComputer, + UserName = username, + }; + try + { + if (!CredWriteDomainCredentialsW(ref targetInfo, ref cred, 0)) { var error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) return; - throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } + finally + { + Marshal.FreeHGlobal(credentialBlob); + } + } - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteW([In] ref CREDENTIALW userCredential, [In] uint flags); - [DllImport("Advapi32.dll", SetLastError = true)] - private static extern void CredFree([In] IntPtr cred); + [DllImport("Advapi32.dll", SetLastError = true)] + private static extern void CredFree([In] IntPtr cred); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredDeleteW(string target, int type, int flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDeleteW(string target, int type, int flags); - [StructLayout(LayoutKind.Sequential)] - private struct CREDENTIAL - { - public int Flags; - public int Type; + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteDomainCredentialsW([In] ref CREDENTIAL_TARGET_INFORMATIONW target, [In] ref CREDENTIALW userCredential, [In] uint flags); - [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIALW + { + public int Flags; + public int Type; - [MarshalAs(UnmanagedType.LPWStr)] public string Comment; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; - public long LastWritten; - public int CredentialBlobSize; - public IntPtr CredentialBlob; - public int Persist; - public int AttributeCount; - public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string Comment; - [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + public long LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; - [MarshalAs(UnmanagedType.LPWStr)] public string UserName; - } + [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIAL_TARGET_INFORMATIONW + { + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsTreeName; + [MarshalAs(UnmanagedType.LPWStr)] public string PackageName; + + public uint Flags; + public uint CredTypeCount; + public IntPtr CredTypes; } } diff --git a/App/Services/RdpConnector.cs b/App/Services/RdpConnector.cs new file mode 100644 index 0000000..a48d0ac --- /dev/null +++ b/App/Services/RdpConnector.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.Services; + +public struct RdpCredentials(string username, string password) +{ + public readonly string Username = username; + public readonly string Password = password; +} + +public interface IRdpConnector +{ + public const int DefaultPort = 3389; + + public void WriteCredentials(string fqdn, RdpCredentials credentials); + + public Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default); +} + +public class RdpConnector(ILogger logger) : IRdpConnector +{ + // Remote Desktop always uses TERMSRV as the domain; RDP is a part of Windows "Terminal Services". + private const string RdpDomain = "TERMSRV"; + + public void WriteCredentials(string fqdn, RdpCredentials credentials) + { + // writing credentials is idempotent for the same domain and server name. + Wincred.WriteDomainCredentials(RdpDomain, fqdn, credentials.Username, credentials.Password); + logger.LogDebug("wrote domain credential for {serverName} with username {username}", fqdn, + credentials.Username); + return; + } + + public Task Connect(string fqdn, int port = IRdpConnector.DefaultPort, CancellationToken ct = default) + { + // use mstsc to launch the RDP connection + var mstscProc = new Process(); + mstscProc.StartInfo.FileName = "mstsc"; + var args = $"/v {fqdn}"; + if (port != IRdpConnector.DefaultPort) + { + args = $"/v {fqdn}:{port}"; + } + + mstscProc.StartInfo.Arguments = args; + mstscProc.StartInfo.CreateNoWindow = true; + mstscProc.StartInfo.UseShellExecute = false; + try + { + if (!mstscProc.Start()) + throw new InvalidOperationException("Failed to start mstsc, Start returned false"); + } + catch (Exception e) + { + logger.LogWarning(e, "mstsc failed to start"); + + try + { + mstscProc.Kill(); + } + catch + { + // ignored, the process likely doesn't exist + } + + mstscProc.Dispose(); + throw; + } + + return mstscProc.WaitForExitAsync(ct); + } +} diff --git a/App/Services/UriHandler.cs b/App/Services/UriHandler.cs new file mode 100644 index 0000000..b0b0a9a --- /dev/null +++ b/App/Services/UriHandler.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Coder.Desktop.App.Models; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Extensions.Logging; + + +namespace Coder.Desktop.App.Services; + +public interface IUriHandler +{ + public Task HandleUri(Uri uri, CancellationToken ct = default); +} + +public class UriHandler( + ILogger logger, + IRpcController rpcController, + IUserNotifier userNotifier, + IRdpConnector rdpConnector) : IUriHandler +{ + private const string OpenWorkspacePrefix = "/v0/open/ws/"; + + internal class UriException : Exception + { + internal readonly string Title; + internal readonly string Detail; + + internal UriException(string title, string detail) : base($"{title}: {detail}") + { + Title = title; + Detail = detail; + } + } + + public async Task HandleUri(Uri uri, CancellationToken ct = default) + { + try + { + await HandleUriThrowingErrors(uri, ct); + } + catch (UriException e) + { + await userNotifier.ShowErrorNotification(e.Title, e.Detail, ct); + } + } + + private async Task HandleUriThrowingErrors(Uri uri, CancellationToken ct = default) + { + if (uri.AbsolutePath.StartsWith(OpenWorkspacePrefix)) + { + await HandleOpenWorkspaceApp(uri, ct); + return; + } + + logger.LogWarning("unhandled URI path {path}", uri.AbsolutePath); + throw new UriException("URI handling error", + $"URI with path '{uri.AbsolutePath}' is unsupported or malformed"); + } + + public async Task HandleOpenWorkspaceApp(Uri uri, CancellationToken ct = default) + { + const string errTitle = "Open Workspace Application Error"; + var subpath = uri.AbsolutePath[OpenWorkspacePrefix.Length..]; + var components = subpath.Split("/"); + if (components.Length != 4 || components[1] != "agent") + { + logger.LogWarning("unsupported open workspace app format in URI {path}", uri.AbsolutePath); + throw new UriException(errTitle, $"Failed to open '{uri.AbsolutePath}' because the format is unsupported."); + } + + var workspaceName = components[0]; + var agentName = components[2]; + var appName = components[3]; + + var state = rpcController.GetState(); + if (state.VpnLifecycle != VpnLifecycle.Started) + { + logger.LogDebug("got URI to open workspace '{workspace}', but Coder Connect is not started", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on '{workspaceName}' because Coder Connect is not started."); + } + + var workspace = state.Workspaces.FirstOrDefault(w => w.Name == workspaceName); + if (workspace == null) + { + logger.LogDebug("got URI to open workspace '{workspace}', but the workspace doesn't exist", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}' because it doesn't exist"); + } + + var agent = state.Agents.FirstOrDefault(a => a.WorkspaceId == workspace.Id && a.Name == agentName); + if (agent == null) + { + logger.LogDebug( + "got URI to open workspace/agent '{workspaceName}/{agentName}', but the agent doesn't exist", + workspaceName, agentName); + // If the workspace isn't running, that is almost certainly why we can't find the agent, so report that + // to the user. + if (workspace.Status != Workspace.Types.Status.Running) + { + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because the workspace is not running."); + } + + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because agent '{agentName}' doesn't exist."); + } + + if (appName != "rdp") + { + logger.LogWarning("unsupported agent application type {app}", appName); + throw new UriException(errTitle, + $"Failed to open agent in URI '{uri.AbsolutePath}' because application '{appName}' is unsupported"); + } + + await OpenRDP(agent.Fqdn.First(), uri.Query, ct); + } + + public async Task OpenRDP(string domainName, string queryString, CancellationToken ct = default) + { + const string errTitle = "Workspace Remote Desktop Error"; + NameValueCollection query; + try + { + query = HttpUtility.ParseQueryString(queryString); + } + catch (Exception ex) + { + // unfortunately, we can't safely write they query string to logs because it might contain + // sensitive info like a password. This is also why we don't log the exception directly + var trace = new System.Diagnostics.StackTrace(ex, false); + logger.LogWarning("failed to parse open RDP query string: {classMethod}", + trace?.GetFrame(0)?.GetMethod()?.ReflectedType?.FullName); + throw new UriException(errTitle, + "Failed to open remote desktop on a workspace because the URI was malformed"); + } + + var username = query.Get("username"); + var password = query.Get("password"); + if (!string.IsNullOrEmpty(username)) + { + password ??= string.Empty; + rdpConnector.WriteCredentials(domainName, new RdpCredentials(username, password)); + } + + await rdpConnector.Connect(domainName, ct: ct); + } +} diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 9cdf6c1..9150f47 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppNotifications.Builder; @@ -7,7 +8,7 @@ namespace Coder.Desktop.App.Services; public interface IUserNotifier : IAsyncDisposable { - public Task ShowErrorNotification(string title, string message); + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default); } public class UserNotifier : IUserNotifier @@ -19,7 +20,7 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } - public Task ShowErrorNotification(string title, string message) + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default) { var builder = new AppNotificationBuilder().AddText(title).AddText(message); _notificationManager.Show(builder.BuildNotification()); diff --git a/Tests.App/Services/RdpConnectorTest.cs b/Tests.App/Services/RdpConnectorTest.cs new file mode 100644 index 0000000..b4a870e --- /dev/null +++ b/Tests.App/Services/RdpConnectorTest.cs @@ -0,0 +1,27 @@ +using Coder.Desktop.App.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class RdpConnectorTest +{ + [Test(Description = "Spawns RDP for real")] + [Ignore("Comment out to run manually")] + [CancelAfter(30_000)] + public async Task ConnectToRdp(CancellationToken ct) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(); + var services = builder.Services.BuildServiceProvider(); + + var rdpConnector = (RdpConnector)services.GetService()!; + var creds = new RdpCredentials("Administrator", "coderRDP!"); + var workspace = "myworkspace.coder"; + rdpConnector.WriteCredentials(workspace, creds); + await rdpConnector.Connect(workspace, ct: ct); + } +} diff --git a/Tests.App/Services/UriHandlerTest.cs b/Tests.App/Services/UriHandlerTest.cs new file mode 100644 index 0000000..65c886c --- /dev/null +++ b/Tests.App/Services/UriHandlerTest.cs @@ -0,0 +1,178 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.Vpn.Proto; +using Google.Protobuf; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; +using Serilog; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class UriHandlerTest +{ + [SetUp] + public void SetupMocksAndUriHandler() + { + Serilog.Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.NUnitOutput().CreateLogger(); + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + var logger = (ILogger)builder.Build().Services.GetService(typeof(ILogger))!; + + _mUserNotifier = new Mock(MockBehavior.Strict); + _mRdpConnector = new Mock(MockBehavior.Strict); + _mRpcController = new Mock(MockBehavior.Strict); + + uriHandler = new UriHandler(logger, _mRpcController.Object, _mUserNotifier.Object, _mRdpConnector.Object); + } + + private Mock _mUserNotifier; + private Mock _mRdpConnector; + private Mock _mRpcController; + private UriHandler uriHandler; // Unit under test. + + [SetUp] + public void AgentAndWorkspaceFixtures() + { + agent11 = new Agent(); + agent11.Fqdn.Add("workspace1.coder"); + agent11.Id = ByteString.CopyFrom(0x1, 0x1); + agent11.WorkspaceId = ByteString.CopyFrom(0x1, 0x0); + agent11.Name = "agent11"; + + workspace1 = new Workspace + { + Id = ByteString.CopyFrom(0x1, 0x0), + Name = "workspace1", + Status = Workspace.Types.Status.Running, + }; + + modelWithWorkspace1 = new RpcModel + { + VpnLifecycle = VpnLifecycle.Started, + Workspaces = [workspace1], + Agents = [agent11], + }; + } + + private Agent agent11; + private Workspace workspace1; + private RpcModel modelWithWorkspace1; + + [Test(Description = "Open RDP with username & password")] + [CancelAfter(30_000)] + public async Task Mainline(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?username=testy&password=sesame"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + var expectedCred = new RdpCredentials("testy", "sesame"); + _ = _mRdpConnector.Setup(m => m.WriteCredentials(agent11.Fqdn[0], expectedCred)); + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Open RDP with no credentials")] + [CancelAfter(30_000)] + public async Task NoCredentials(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown app slug")] + [CancelAfter(30_000)] + public async Task UnknownApp(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/someapp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("someapp"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown agent name")] + [CancelAfter(30_000)] + public async Task UnknownAgent(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/wrongagent/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("wrongagent"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown workspace name")] + [CancelAfter(30_000)] + public async Task UnknownWorkspace(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("wrongworkspace"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Malformed Query String")] + [CancelAfter(30_000)] + public async Task MalformedQuery(CancellationToken ct) + { + // there might be some query string that gets the parser to throw an exception, but I could not find one. + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?%&##"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + // treated the same as if we just didn't include credentials + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "VPN not started")] + [CancelAfter(30_000)] + public async Task VPNNotStarted(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(new RpcModel + { + VpnLifecycle = VpnLifecycle.Starting, + }); + // Coder Connect is the user facing name, so make sure the error mentions it. + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("Coder Connect"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Wrong number of components")] + [CancelAfter(30_000)] + public async Task UnknownNumComponents(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsAny(), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown prefix")] + [CancelAfter(30_000)] + public async Task UnknownPrefix(CancellationToken ct) + { + var input = new Uri("coder:/v300/open/ws/workspace1/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsAny(), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } +} diff --git a/Tests.App/Tests.App.csproj b/Tests.App/Tests.App.csproj index cc01512..e20eba1 100644 --- a/Tests.App/Tests.App.csproj +++ b/Tests.App/Tests.App.csproj @@ -26,6 +26,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + From 9b8408df3e1c8a191c044c7bb279a99e128420a9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 8 May 2025 17:25:00 +0200 Subject: [PATCH 04/10] feat: enter submits sign in information (#90) Closes: #88 --------- Co-authored-by: Dean Sheather --- App/Views/Pages/SignInTokenPage.xaml | 3 ++- App/Views/Pages/SignInTokenPage.xaml.cs | 10 ++++++++++ App/Views/Pages/SignInUrlPage.xaml | 3 ++- App/Views/Pages/SignInUrlPage.xaml.cs | 10 ++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index 8613f19..e21b46b 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -62,8 +62,9 @@ Grid.Row="2" HorizontalAlignment="Stretch" PlaceholderText="Paste your token here" + KeyDown="PasswordBox_KeyDown" LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}" - Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" /> + Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + KeyDown="TextBox_KeyDown"/> Date: Mon, 12 May 2025 10:30:29 +0200 Subject: [PATCH 05/10] feat: add exit to main tray window (#95) Closes: #94 --- App/ViewModels/TrayWindowViewModel.cs | 6 ++++++ App/Views/Pages/TrayWindowMainPage.xaml | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index f845521..ae6c910 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -301,4 +301,10 @@ public void SignOut() return; _credentialManager.ClearCredentials(); } + + [RelayCommand] + public void Exit() + { + _ = ((App)Application.Current).ExitApplication(); + } } diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index 42a9abd..f296327 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -249,5 +249,14 @@ + + + + + From a6f7bb67bb111628d3de1f46d7b404d4bab67717 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 13 May 2025 03:13:51 +1000 Subject: [PATCH 06/10] feat: add workspace app icons to tray window (#86) Closes #50 --- App/App.csproj | 5 +- App/App.xaml.cs | 8 +- App/Controls/ExpandChevron.xaml | 31 ++ App/Controls/ExpandChevron.xaml.cs | 19 + App/Controls/ExpandContent.xaml | 51 +++ App/Controls/ExpandContent.xaml.cs | 39 ++ App/Converters/DependencyObjectSelector.cs | 13 + App/Models/CredentialModel.cs | 8 +- App/Program.cs | 2 - App/Services/CredentialManager.cs | 37 +- App/Services/RpcController.cs | 2 +- App/Services/UserNotifier.cs | 1 - App/{ => Utils}/DisplayScale.cs | 2 +- App/Utils/ModelUpdate.cs | 105 +++++ App/Utils/TitleBarIcon.cs | 17 +- App/ViewModels/AgentAppViewModel.cs | 188 ++++++++ App/ViewModels/AgentViewModel.cs | 342 ++++++++++++++- App/ViewModels/FileSyncListViewModel.cs | 3 +- App/ViewModels/TrayWindowViewModel.cs | 186 +++++--- App/Views/DirectoryPickerWindow.xaml.cs | 2 +- App/Views/FileSyncListWindow.xaml.cs | 3 +- App/Views/Pages/SignInTokenPage.xaml | 4 +- App/Views/Pages/SignInUrlPage.xaml | 2 +- App/Views/Pages/TrayWindowMainPage.xaml | 285 ++++++++---- App/Views/SignInWindow.xaml.cs | 2 +- App/Views/TrayWindow.xaml.cs | 54 ++- App/packages.lock.json | 19 +- Coder.Desktop.sln | 18 + Coder.Desktop.sln.DotSettings | 1 + CoderSdk/Coder/CoderApiClient.cs | 31 ++ CoderSdk/Coder/WorkspaceAgents.cs | 38 ++ CoderSdk/Uuid.cs | 180 ++++++++ .../Converters/FriendlyByteConverterTest.cs | 2 +- Tests.App/Services/CredentialManagerTest.cs | 6 +- Tests.App/Utils/ModelUpdateTest.cs | 413 ++++++++++++++++++ Tests.CoderSdk/Tests.CoderSdk.csproj | 36 ++ Tests.CoderSdk/UuidTest.cs | 141 ++++++ Vpn.Proto/packages.lock.json | 3 + 38 files changed, 2057 insertions(+), 242 deletions(-) create mode 100644 App/Controls/ExpandChevron.xaml create mode 100644 App/Controls/ExpandChevron.xaml.cs create mode 100644 App/Controls/ExpandContent.xaml create mode 100644 App/Controls/ExpandContent.xaml.cs rename App/{ => Utils}/DisplayScale.cs (94%) create mode 100644 App/Utils/ModelUpdate.cs create mode 100644 App/ViewModels/AgentAppViewModel.cs create mode 100644 CoderSdk/Coder/WorkspaceAgents.cs create mode 100644 CoderSdk/Uuid.cs create mode 100644 Tests.App/Utils/ModelUpdateTest.cs create mode 100644 Tests.CoderSdk/Tests.CoderSdk.csproj create mode 100644 Tests.CoderSdk/UuidTest.cs diff --git a/App/App.csproj b/App/App.csproj index 982612f..fcfb92f 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -1,4 +1,4 @@ - + WinExe net8.0-windows10.0.19041.0 @@ -16,7 +16,7 @@ preview - DISABLE_XAML_GENERATED_MAIN + DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION Coder Desktop coder.ico @@ -57,6 +57,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/App/App.xaml.cs b/App/App.xaml.cs index ba6fa67..e756efd 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -11,6 +11,7 @@ using Coder.Desktop.App.Views; using Coder.Desktop.App.Views.Pages; using Coder.Desktop.CoderSdk.Agent; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -19,9 +20,9 @@ using Microsoft.UI.Xaml; using Microsoft.Win32; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using Serilog; using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; -using Microsoft.Windows.AppNotifications; namespace Coder.Desktop.App; @@ -64,8 +65,11 @@ public App() loggerConfig.ReadFrom.Configuration(builder.Configuration); }); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(_ => + new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); services.AddSingleton(); @@ -95,6 +99,8 @@ public App() services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/App/Controls/ExpandChevron.xaml b/App/Controls/ExpandChevron.xaml new file mode 100644 index 0000000..0b68d4d --- /dev/null +++ b/App/Controls/ExpandChevron.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/App/Controls/ExpandChevron.xaml.cs b/App/Controls/ExpandChevron.xaml.cs new file mode 100644 index 0000000..45aa6c4 --- /dev/null +++ b/App/Controls/ExpandChevron.xaml.cs @@ -0,0 +1,19 @@ +using DependencyPropertyGenerator; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Controls; + +[DependencyProperty("IsOpen", DefaultValue = false)] +public sealed partial class ExpandChevron : UserControl +{ + public ExpandChevron() + { + InitializeComponent(); + } + + partial void OnIsOpenChanged(bool oldValue, bool newValue) + { + var newState = newValue ? "NormalOn" : "NormalOff"; + AnimatedIcon.SetState(ChevronIcon, newState); + } +} diff --git a/App/Controls/ExpandContent.xaml b/App/Controls/ExpandContent.xaml new file mode 100644 index 0000000..d36170d --- /dev/null +++ b/App/Controls/ExpandContent.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Controls/ExpandContent.xaml.cs b/App/Controls/ExpandContent.xaml.cs new file mode 100644 index 0000000..1cd5d2f --- /dev/null +++ b/App/Controls/ExpandContent.xaml.cs @@ -0,0 +1,39 @@ +using DependencyPropertyGenerator; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; + +namespace Coder.Desktop.App.Controls; + +[ContentProperty(Name = nameof(Children))] +[DependencyProperty("IsOpen", DefaultValue = false)] +public sealed partial class ExpandContent : UserControl +{ + public UIElementCollection Children => CollapsiblePanel.Children; + + public ExpandContent() + { + InitializeComponent(); + } + + public void CollapseAnimation_Completed(object? sender, object args) + { + // Hide the panel completely when the collapse animation is done. This + // cannot be done with keyframes for some reason. + // + // Without this, the space will still be reserved for the panel. + CollapsiblePanel.Visibility = Visibility.Collapsed; + } + + partial void OnIsOpenChanged(bool oldValue, bool newValue) + { + var newState = newValue ? "ExpandedState" : "CollapsedState"; + + // The animation can't set visibility when starting or ending the + // animation. + if (newValue) + CollapsiblePanel.Visibility = Visibility.Visible; + + VisualStateManager.GoToState(this, newState, true); + } +} diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs index a31c33b..ec586d0 100644 --- a/App/Converters/DependencyObjectSelector.cs +++ b/App/Converters/DependencyObjectSelector.cs @@ -156,6 +156,15 @@ private void UpdateSelectedObject() ClearValue(SelectedObjectProperty); } + private static void VerifyReferencesProperty(IObservableVector references) + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem. + var items = references.OfType>().ToArray(); + var keys = items.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != references.Count) + throw new ArgumentException("ObservableCollection Keys must be unique."); + } + // Called when the References property is replaced. private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { @@ -166,12 +175,16 @@ private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPr oldValue.VectorChanged -= self.OnVectorChangedReferences; var newValue = args.NewValue as DependencyObjectCollection; if (newValue != null) + { + VerifyReferencesProperty(newValue); newValue.VectorChanged += self.OnVectorChangedReferences; + } } // Called when the References collection changes without being replaced. private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args) { + VerifyReferencesProperty(sender); UpdateSelectedObject(); } diff --git a/App/Models/CredentialModel.cs b/App/Models/CredentialModel.cs index 542c1c0..d30f894 100644 --- a/App/Models/CredentialModel.cs +++ b/App/Models/CredentialModel.cs @@ -1,3 +1,5 @@ +using System; + namespace Coder.Desktop.App.Models; public enum CredentialState @@ -5,10 +7,10 @@ public enum CredentialState // Unknown means "we haven't checked yet" Unknown, - // Invalid means "we checked and there's either no saved credentials or they are not valid" + // Invalid means "we checked and there's either no saved credentials, or they are not valid" Invalid, - // Valid means "we checked and there are saved credentials and they are valid" + // Valid means "we checked and there are saved credentials, and they are valid" Valid, } @@ -16,7 +18,7 @@ public class CredentialModel { public CredentialState State { get; init; } = CredentialState.Unknown; - public string? CoderUrl { get; init; } + public Uri? CoderUrl { get; init; } public string? ApiToken { get; init; } public string? Username { get; init; } diff --git a/App/Program.cs b/App/Program.cs index 3749c3b..bf4f16e 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -63,11 +63,9 @@ private static void Main(string[] args) notificationManager.NotificationInvoked += app.HandleNotification; notificationManager.Register(); if (activationArgs.Kind != ExtendedActivationKind.Launch) - { // this means we were activated without having already launched, so handle // the activation as well. app.OnActivated(null, activationArgs); - } }); } catch (Exception e) diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 280169c..6868ae7 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -21,7 +21,7 @@ public class RawCredentials [JsonSerializable(typeof(RawCredentials))] public partial class RawCredentialsJsonContext : JsonSerializerContext; -public interface ICredentialManager +public interface ICredentialManager : ICoderApiClientCredentialProvider { public event EventHandler CredentialsChanged; @@ -59,7 +59,8 @@ public interface ICredentialBackend /// public class CredentialManager : ICredentialManager { - private const string CredentialsTargetName = "Coder.Desktop.App.Credentials"; + private readonly ICredentialBackend Backend; + private readonly ICoderApiClientFactory CoderApiClientFactory; // _opLock is held for the full duration of SetCredentials, and partially // during LoadCredentials. _opLock protects _inFlightLoad, _loadCts, and @@ -79,14 +80,6 @@ public class CredentialManager : ICredentialManager // immediate). private volatile CredentialModel? _latestCredentials; - private ICredentialBackend Backend { get; } = new WindowsCredentialBackend(CredentialsTargetName); - - private ICoderApiClientFactory CoderApiClientFactory { get; } = new CoderApiClientFactory(); - - public CredentialManager() - { - } - public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory) { Backend = backend; @@ -108,6 +101,20 @@ public CredentialModel GetCachedCredentials() }; } + // Implements ICoderApiClientCredentialProvider + public CoderApiClientCredential? GetCoderApiClientCredential() + { + var latestCreds = _latestCredentials; + if (latestCreds is not { State: CredentialState.Valid } || latestCreds.CoderUrl is null) + return null; + + return new CoderApiClientCredential + { + CoderUrl = latestCreds.CoderUrl, + ApiToken = latestCreds.ApiToken ?? "", + }; + } + public async Task GetSignInUri() { try @@ -253,6 +260,12 @@ private async Task PopulateModel(RawCredentials? credentials, C State = CredentialState.Invalid, }; + if (!Uri.TryCreate(credentials.CoderUrl, UriKind.Absolute, out var uri)) + return new CredentialModel + { + State = CredentialState.Invalid, + }; + BuildInfo buildInfo; User me; try @@ -279,7 +292,7 @@ private async Task PopulateModel(RawCredentials? credentials, C return new CredentialModel { State = CredentialState.Valid, - CoderUrl = credentials.CoderUrl, + CoderUrl = uri, ApiToken = credentials.ApiToken, Username = me.Username, }; @@ -298,6 +311,8 @@ private void UpdateState(CredentialModel newModel) public class WindowsCredentialBackend : ICredentialBackend { + public const string CoderCredentialsTargetName = "Coder.Desktop.App.Credentials"; + private readonly string _credentialsTargetName; public WindowsCredentialBackend(string credentialsTargetName) diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 17d3ccb..70dfe9f 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -170,7 +170,7 @@ public async Task StartVpn(CancellationToken ct = default) { Start = new StartRequest { - CoderUrl = credentials.CoderUrl, + CoderUrl = credentials.CoderUrl?.ToString(), ApiToken = credentials.ApiToken, }, }, ct); diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 9150f47..3b4ac05 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -27,4 +27,3 @@ public Task ShowErrorNotification(string title, string message, CancellationToke return Task.CompletedTask; } } - diff --git a/App/DisplayScale.cs b/App/Utils/DisplayScale.cs similarity index 94% rename from App/DisplayScale.cs rename to App/Utils/DisplayScale.cs index cd5101c..7cc79d6 100644 --- a/App/DisplayScale.cs +++ b/App/Utils/DisplayScale.cs @@ -3,7 +3,7 @@ using Microsoft.UI.Xaml; using WinRT.Interop; -namespace Coder.Desktop.App; +namespace Coder.Desktop.App.Utils; /// /// A static utility class to house methods related to the visual scale of the display monitor. diff --git a/App/Utils/ModelUpdate.cs b/App/Utils/ModelUpdate.cs new file mode 100644 index 0000000..de8b2b6 --- /dev/null +++ b/App/Utils/ModelUpdate.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Coder.Desktop.App.Utils; + +public interface IModelUpdateable +{ + /// + /// Applies changes from obj to `this` if they represent the same + /// object based on some identifier like an ID or fixed name. + /// + /// + /// True if the two objects represent the same item and the changes + /// were applied. + /// + public bool TryApplyChanges(T obj); +} + +/// +/// A static utility class providing methods for applying model updates +/// with as little UI updates as possible. +/// The main goal of the utilities in this class is to prevent redraws in +/// ItemsRepeater items when nothing has changed. +/// +public static class ModelUpdate +{ + /// + /// Takes all items in `update` and either applies them to existing + /// items in `target`, or adds them to `target` if there are no + /// matching items. + /// Any items in `target` that don't have a corresponding item in + /// `update` will be removed from `target`. + /// Items are inserted in their correct sort position according to + /// `sorter`. It's assumed that the target list is already sorted by + /// `sorter`. + /// + /// Target list to be updated + /// Incoming list to apply to `target` + /// + /// Comparison to use for sorting. Note that the sort order does not + /// need to be the ID/name field used in the IModelUpdateable + /// implementation, and can be by any order. + /// New items will be sorted after existing items. + /// + public static void ApplyLists(IList target, IEnumerable update, Comparison sorter) + where T : IModelUpdateable + { + var newItems = update.ToList(); + + // Update and remove existing items. We use index-based for loops here + // because we remove items, and removing items while using the list as + // an IEnumerable will throw an exception. + for (var i = 0; i < target.Count; i++) + { + // Even though we're removing items before a "break", we still use + // index-based for loops here to avoid exceptions. + for (var j = 0; j < newItems.Count; j++) + { + if (!target[i].TryApplyChanges(newItems[j])) continue; + + // Prevent it from being added below, or checked again. We + // don't need to decrement `j` here because we're breaking + // out of this inner loop. + newItems.RemoveAt(j); + goto OuterLoopEnd; // continue outer loop + } + + // A merge couldn't occur, so we need to remove the old item and + // decrement `i` for the next iteration. + target.RemoveAt(i); + i--; + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + + // Add any items that were missing into their correct sorted place. + // It's assumed the list is already sorted. + foreach (var newItem in newItems) + { + for (var i = 0; i < target.Count; i++) + // If the new item sorts before the current item, insert it + // after. + if (sorter(newItem, target[i]) < 0) + { + target.Insert(i, newItem); + goto OuterLoopEnd; + } + + // Handle the case where target is empty or the new item is + // equal to or after every other item. + target.Add(newItem); + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + } +} diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs index 3efc81d..283453d 100644 --- a/App/Utils/TitleBarIcon.cs +++ b/App/Utils/TitleBarIcon.cs @@ -1,19 +1,16 @@ -using System; using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls.Primitives; using WinRT.Interop; -namespace Coder.Desktop.App.Utils +namespace Coder.Desktop.App.Utils; + +public static class TitleBarIcon { - public static class TitleBarIcon + public static void SetTitlebarIcon(Window window) { - public static void SetTitlebarIcon(Window window) - { - var hwnd = WindowNative.GetWindowHandle(window); - var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); - AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico"); - } + var hwnd = WindowNative.GetWindowHandle(window); + var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico"); } } diff --git a/App/ViewModels/AgentAppViewModel.cs b/App/ViewModels/AgentAppViewModel.cs new file mode 100644 index 0000000..5620eb2 --- /dev/null +++ b/App/ViewModels/AgentAppViewModel.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using Windows.System; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Coder.Desktop.App.ViewModels; + +public interface IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl); +} + +public class AgentAppViewModelFactory(ILogger childLogger, ICredentialManager credentialManager) + : IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl) + { + return new AgentAppViewModel(childLogger, credentialManager) + { + Id = id, + Name = name, + AppUri = appUri, + IconUrl = iconUrl, + }; + } +} + +public partial class AgentAppViewModel : ObservableObject, IModelUpdateable +{ + private const string SessionTokenUriVar = "$SESSION_TOKEN"; + + private readonly ILogger _logger; + private readonly ICredentialManager _credentialManager; + + public required Uuid Id { get; init; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial string Name { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial Uri AppUri { get; set; } + + [ObservableProperty] public partial Uri? IconUrl { get; set; } + + [ObservableProperty] public partial ImageSource IconImageSource { get; set; } + + [ObservableProperty] public partial bool UseFallbackIcon { get; set; } = true; + + public string Details => + (string.IsNullOrWhiteSpace(Name) ? "(no name)" : Name) + ":\n\n" + AppUri; + + public AgentAppViewModel(ILogger logger, ICredentialManager credentialManager) + { + _logger = logger; + _credentialManager = credentialManager; + + // Apply the icon URL to the icon image source when it is updated. + IconImageSource = UpdateIcon(); + PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(IconUrl)) + IconImageSource = UpdateIcon(); + }; + } + + public bool TryApplyChanges(AgentAppViewModel obj) + { + if (Id != obj.Id) return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (Name != obj.Name) + Name = obj.Name; + if (AppUri != obj.AppUri) + AppUri = obj.AppUri; + if (IconUrl != obj.IconUrl) + { + UseFallbackIcon = true; + IconUrl = obj.IconUrl; + } + + return true; + } + + private ImageSource UpdateIcon() + { + if (IconUrl is null || (IconUrl.Scheme != "http" && IconUrl.Scheme != "https")) + { + UseFallbackIcon = true; + return new BitmapImage(); + } + + // Determine what image source to use based on extension, use a + // BitmapImage as last resort. + var ext = IconUrl.AbsolutePath.Split('/').LastOrDefault()?.Split('.').LastOrDefault(); + // TODO: this is definitely a hack, URLs shouldn't need to end in .svg + if (ext is "svg") + { + // TODO: Some SVGs like `/icon/cursor.svg` contain PNG data and + // don't render at all. + var svg = new SvgImageSource(IconUrl); + svg.Opened += (_, _) => _logger.LogDebug("app icon opened (svg): {uri}", IconUrl); + svg.OpenFailed += (_, args) => + _logger.LogDebug("app icon failed to open (svg): {uri}: {Status}", IconUrl, args.Status); + return svg; + } + + var bitmap = new BitmapImage(IconUrl); + bitmap.ImageOpened += (_, _) => _logger.LogDebug("app icon opened (bitmap): {uri}", IconUrl); + bitmap.ImageFailed += (_, args) => + _logger.LogDebug("app icon failed to open (bitmap): {uri}: {ErrorMessage}", IconUrl, args.ErrorMessage); + return bitmap; + } + + public void OnImageOpened(object? sender, RoutedEventArgs e) + { + UseFallbackIcon = false; + } + + public void OnImageFailed(object? sender, RoutedEventArgs e) + { + UseFallbackIcon = true; + } + + [RelayCommand] + private void OpenApp(object parameter) + { + try + { + var uri = AppUri; + + // http and https URLs should already be filtered out by + // AgentViewModel, but as a second line of defence don't do session + // token var replacement on those URLs. + if (uri.Scheme is not "http" and not "https") + { + var cred = _credentialManager.GetCachedCredentials(); + if (cred.State is CredentialState.Valid && cred.ApiToken is not null) + uri = new Uri(uri.ToString().Replace(SessionTokenUriVar, cred.ApiToken)); + } + + if (uri.ToString().Contains(SessionTokenUriVar)) + throw new Exception( + $"URI contains {SessionTokenUriVar} variable but could not be replaced (http and https URLs cannot contain {SessionTokenUriVar})"); + + _ = Launcher.LaunchUriAsync(uri); + } + catch (Exception e) + { + _logger.LogWarning(e, "could not parse or launch app"); + + if (parameter is not FrameworkElement frameworkElement) return; + var flyout = new Flyout + { + Content = new TextBlock + { + Text = $"Could not open app: {e.Message}", + Margin = new Thickness(4), + TextWrapping = TextWrapping.Wrap, + }, + FlyoutPresenterStyle = new Style(typeof(FlyoutPresenter)) + { + Setters = + { + new Setter(ScrollViewer.HorizontalScrollModeProperty, ScrollMode.Disabled), + new Setter(ScrollViewer.HorizontalScrollBarVisibilityProperty, ScrollBarVisibility.Disabled), + }, + }, + }; + FlyoutBase.SetAttachedFlyout(frameworkElement, flyout); + FlyoutBase.ShowAttachedFlyout(frameworkElement); + } + } +} diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index f5b5e0e..c44db3e 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -1,11 +1,53 @@ +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; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; namespace Coder.Desktop.App.ViewModels; +public interface IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName); +} + +public class AgentViewModelFactory( + ILogger childLogger, + ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, + IAgentAppViewModelFactory agentAppViewModelFactory) : IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + expanderHost, id) + { + Hostname = hostname, + HostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + }; + } +} + public enum AgentConnectionStatus { Green, @@ -14,17 +56,307 @@ public enum AgentConnectionStatus Gray, } -public partial class AgentViewModel +public partial class AgentViewModel : ObservableObject, IModelUpdateable { - public required string Hostname { get; set; } + private const string DefaultDashboardUrl = "https://coder.com"; + private const int MaxAppsPerRow = 6; + + // These are fake UUIDs, for UI purposes only. Display apps don't exist on + // the backend as real app resources and therefore don't have an ID. + private static readonly Uuid VscodeAppUuid = new("819828b1-5213-4c3d-855e-1b74db6ddd19"); + private static readonly Uuid VscodeInsidersAppUuid = new("becf1e10-5101-4940-a853-59af86468069"); + + private readonly ILogger _logger; + private readonly ICoderApiClientFactory _coderApiClientFactory; + private readonly ICredentialManager _credentialManager; + private readonly IAgentAppViewModelFactory _agentAppViewModelFactory; - public required string HostnameSuffix { get; set; } // including leading dot + // The AgentViewModel only gets created on the UI thread. + private readonly DispatcherQueue _dispatcherQueue = + DispatcherQueue.GetForCurrentThread(); - public required AgentConnectionStatus ConnectionStatus { get; set; } + private readonly IAgentExpanderHost _expanderHost; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Apps = []; + + public readonly Uuid Id; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FullHostname))] + public required partial string Hostname { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FullHostname))] + public required partial string HostnameSuffix { get; set; } // including leading dot public string FullHostname => Hostname + HostnameSuffix; - public required string DashboardUrl { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public required partial AgentConnectionStatus ConnectionStatus { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial Uri DashboardBaseUrl { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial string? WorkspaceName { get; set; } + + [ObservableProperty] public partial bool IsExpanded { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool FetchingApps { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool AppFetchErrored { get; set; } = false; + + // 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; + + public bool ShowExpandAppsMessage => ExpandAppsMessage != null; + + public string? ExpandAppsMessage + { + get + { + if (ConnectionStatus == AgentConnectionStatus.Gray) + return "Your workspace is offline."; + if (FetchingApps && Apps.Count == 0) + // Don't show this message if we have any apps already. When + // they finish loading, we'll just update the screen with any + // changes. + return "Fetching workspace apps..."; + if (AppFetchErrored && Apps.Count == 0) + // There's very limited screen real estate here so we don't + // show the actual error message. + return "Could not fetch workspace apps."; + if (Apps.Count == 0) + return "No apps to show."; + return null; + } + } + + public string DashboardUrl + { + get + { + if (string.IsNullOrWhiteSpace(WorkspaceName)) return DashboardBaseUrl.ToString(); + try + { + return new Uri(DashboardBaseUrl, $"/@me/{WorkspaceName}").ToString(); + } + catch + { + return DefaultDashboardUrl; + } + } + } + + public AgentViewModel(ILogger logger, ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, IAgentAppViewModelFactory agentAppViewModelFactory, + IAgentExpanderHost expanderHost, Uuid id) + { + _logger = logger; + _coderApiClientFactory = coderApiClientFactory; + _credentialManager = credentialManager; + _agentAppViewModelFactory = agentAppViewModelFactory; + _expanderHost = expanderHost; + + Id = id; + + PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(IsExpanded)) + { + _expanderHost.HandleAgentExpanded(Id, IsExpanded); + + // Every time the drawer is expanded, re-fetch all apps. + if (IsExpanded && !FetchingApps) + FetchApps(); + } + }; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Apps.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleApps))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowExpandAppsMessage))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ExpandAppsMessage))); + }; + } + + public bool TryApplyChanges(AgentViewModel model) + { + if (Id != model.Id) return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (Hostname != model.Hostname) + Hostname = model.Hostname; + if (HostnameSuffix != model.HostnameSuffix) + HostnameSuffix = model.HostnameSuffix; + if (ConnectionStatus != model.ConnectionStatus) + ConnectionStatus = model.ConnectionStatus; + if (DashboardBaseUrl != model.DashboardBaseUrl) + DashboardBaseUrl = model.DashboardBaseUrl; + if (WorkspaceName != model.WorkspaceName) + WorkspaceName = model.WorkspaceName; + + // Apps are not set externally. + + return true; + } + + [RelayCommand] + private void ToggleExpanded() + { + SetExpanded(!IsExpanded); + } + + public void SetExpanded(bool expanded) + { + if (IsExpanded == expanded) return; + // This will bubble up to the TrayWindowViewModel because of the + // PropertyChanged handler. + IsExpanded = expanded; + } + + partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue) + { + if (IsExpanded && newValue is not AgentConnectionStatus.Gray) FetchApps(); + } + + private void FetchApps() + { + if (FetchingApps) return; + FetchingApps = true; + + // If the workspace is off, then there's no agent and there's no apps. + if (ConnectionStatus == AgentConnectionStatus.Gray) + { + FetchingApps = false; + Apps.Clear(); + return; + } + + // API client creation could fail, which would leave FetchingApps true. + ICoderApiClient client; + try + { + client = _coderApiClientFactory.Create(_credentialManager); + } + catch + { + FetchingApps = false; + throw; + } + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + client.GetWorkspaceAgent(Id.ToString(), cts.Token).ContinueWith(t => + { + cts.Dispose(); + ContinueFetchApps(t); + }, CancellationToken.None); + } + + private void ContinueFetchApps(Task task) + { + // Ensure we're on the UI thread. + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => ContinueFetchApps(task)); + return; + } + + FetchingApps = false; + AppFetchErrored = !task.IsCompletedSuccessfully; + if (!task.IsCompletedSuccessfully) + { + _logger.LogWarning(task.Exception, "Could not fetch workspace agent"); + return; + } + + var workspaceAgent = task.Result; + var apps = new List(); + foreach (var app in workspaceAgent.Apps) + { + if (!app.External || !string.IsNullOrEmpty(app.Command)) continue; + + if (!Uri.TryCreate(app.Url, UriKind.Absolute, out var appUri)) + { + _logger.LogWarning("Could not parse app URI '{Url}' for '{DisplayName}', app will not appear in list", + app.Url, + app.DisplayName); + continue; + } + + // HTTP or HTTPS external apps are usually things like + // wikis/documentation, which clutters up the app. + if (appUri.Scheme is "http" or "https") + continue; + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, app.Icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(app.Id, app.DisplayName, appUri, iconUrl)); + } + + foreach (var displayApp in workspaceAgent.DisplayApps) + { + if (displayApp is not WorkspaceAgent.DisplayAppVscode and not WorkspaceAgent.DisplayAppVscodeInsiders) + continue; + + var id = VscodeAppUuid; + var displayName = "VS Code"; + var icon = "/icon/code.svg"; + var scheme = "vscode"; + if (displayApp is WorkspaceAgent.DisplayAppVscodeInsiders) + { + id = VscodeInsidersAppUuid; + displayName = "VS Code Insiders"; + icon = "/icon/code-insiders.svg"; + scheme = "vscode-insiders"; + } + + Uri appUri; + try + { + appUri = new UriBuilder + { + Scheme = scheme, + Host = "vscode-remote", + Path = $"/ssh-remote+{FullHostname}/{workspaceAgent.ExpandedDirectory}", + }.Uri; + } + catch (Exception e) + { + _logger.LogWarning(e, "Could not craft app URI for display app {displayApp}, app will not appear in list", + displayApp); + continue; + } + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(id, displayName, appUri, iconUrl)); + } + + // Sort by name. + ModelUpdate.ApplyLists(Apps, apps, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + } [RelayCommand] private void CopyHostname(object parameter) diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 9235141..da40e5c 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -339,7 +339,8 @@ public void OpenRemotePathSelectDialog() pickerViewModel.PathSelected += OnRemotePathSelected; _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); - _remotePickerWindow.SetParent(_window); + if (_window is not null) + _remotePickerWindow.SetParent(_window); _remotePickerWindow.Closed += (_, _) => { _remotePickerWindow = null; diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index ae6c910..b0c9a8b 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk; using Coder.Desktop.Vpn.Proto; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -13,11 +17,15 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Exception = System.Exception; namespace Coder.Desktop.App.ViewModels; -public partial class TrayWindowViewModel : ObservableObject +public interface IAgentExpanderHost +{ + public void HandleAgentExpanded(Uuid id, bool expanded); +} + +public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; @@ -25,11 +33,22 @@ public partial class TrayWindowViewModel : ObservableObject private readonly IServiceProvider _services; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private readonly IAgentViewModelFactory _agentViewModelFactory; private FileSyncListWindow? _fileSyncListWindow; private DispatcherQueue? _dispatcherQueue; + // When we transition from 0 online workspaces to >0 online workspaces, the + // first agent will be expanded. This bool tracks whether this has occurred + // yet (or if the user has expanded something themselves). + private bool _hasExpandedAgent; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Agents = []; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowEnableSection))] [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] @@ -49,13 +68,6 @@ public partial class TrayWindowViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowFailedSection))] public partial string? VpnFailedMessage { get; set; } = null; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(VisibleAgents))] - [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] - [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] - [NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))] - public partial List Agents { get; set; } = []; - public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started; public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started; @@ -76,14 +88,43 @@ public partial class TrayWindowViewModel : ObservableObject public IEnumerable VisibleAgents => ShowAllAgents ? Agents : Agents.Take(MaxAgents); - [ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com"; + [ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl; public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, - ICredentialManager credentialManager) + ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory) { _services = services; _rpcController = rpcController; _credentialManager = credentialManager; + _agentViewModelFactory = agentViewModelFactory; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Agents.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleAgents))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowNoAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentOverflowButton))); + }; + } + + // Implements IAgentExpanderHost + public void HandleAgentExpanded(Uuid id, bool expanded) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleAgentExpanded(id, expanded)); + return; + } + + if (!expanded) return; + _hasExpandedAgent = true; + // Collapse every other agent. + foreach (var otherAgent in Agents.Where(a => a.Id != id)) + otherAgent.SetExpanded(false); } public void Initialize(DispatcherQueue dispatcherQueue) @@ -93,8 +134,8 @@ public void Initialize(DispatcherQueue dispatcherQueue) _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); UpdateFromRpcModel(_rpcController.GetState()); - _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel); - UpdateFromCredentialsModel(_credentialManager.GetCachedCredentials()); + _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel); + UpdateFromCredentialModel(_credentialManager.GetCachedCredentials()); } private void UpdateFromRpcModel(RpcModel rpcModel) @@ -107,37 +148,30 @@ private void UpdateFromRpcModel(RpcModel rpcModel) return; } - // As a failsafe, if RPC is disconnected we disable the switch. The - // Window should not show the current Page if the RPC is disconnected. - if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected) + // As a failsafe, if RPC is disconnected (or we're not signed in) we + // disable the switch. The Window should not show the current Page if + // the RPC is disconnected. + var credentialModel = _credentialManager.GetCachedCredentials(); + if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected || credentialModel.State is not CredentialState.Valid || + credentialModel.CoderUrl == null) { VpnLifecycle = VpnLifecycle.Unknown; VpnSwitchActive = false; - Agents = []; + Agents.Clear(); return; } VpnLifecycle = rpcModel.VpnLifecycle; VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; - // Get the current dashboard URL. - var credentialModel = _credentialManager.GetCachedCredentials(); - Uri? coderUri = null; - if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl)) - try - { - coderUri = new Uri(credentialModel.CoderUrl, UriKind.Absolute); - } - catch - { - // Ignore - } - // Add every known agent. HashSet workspacesWithAgents = []; List agents = []; foreach (var agent in rpcModel.Agents) { + if (!Uuid.TryFrom(agent.Id.Span, out var uuid)) + continue; + // Find the FQDN with the least amount of dots and split it into // prefix and suffix. var fqdn = agent.Fqdn @@ -156,75 +190,95 @@ private void UpdateFromRpcModel(RpcModel rpcModel) } var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) + ? AgentConnectionStatus.Green + : AgentConnectionStatus.Yellow; workspacesWithAgents.Add(agent.WorkspaceId); var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId); - agents.Add(new AgentViewModel - { - Hostname = fqdnPrefix, - HostnameSuffix = fqdnSuffix, - ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) - ? AgentConnectionStatus.Green - : AgentConnectionStatus.Yellow, - DashboardUrl = WorkspaceUri(coderUri, workspace?.Name), - }); + agents.Add(_agentViewModelFactory.Create( + this, + uuid, + fqdnPrefix, + fqdnSuffix, + connectionStatus, + credentialModel.CoderUrl, + workspace?.Name)); } // For every stopped workspace that doesn't have any agents, add a // dummy agent row. foreach (var workspace in rpcModel.Workspaces.Where(w => w.Status == Workspace.Types.Status.Stopped && !workspacesWithAgents.Contains(w.Id))) - agents.Add(new AgentViewModel - { - // We just assume that it's a single-agent workspace. - Hostname = workspace.Name, + { + if (!Uuid.TryFrom(workspace.Id.Span, out var uuid)) + continue; + + agents.Add(_agentViewModelFactory.Create( + this, + // Workspace ID is fine as a stand-in here, it shouldn't + // conflict with any agent IDs. + uuid, + // We assume that it's a single-agent workspace. + workspace.Name, // TODO: this needs to get the suffix from the server - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Gray, - DashboardUrl = WorkspaceUri(coderUri, workspace.Name), - }); + ".coder", + AgentConnectionStatus.Gray, + credentialModel.CoderUrl, + workspace.Name)); + } // Sort by status green, red, gray, then by hostname. - agents.Sort((a, b) => + ModelUpdate.ApplyLists(Agents, agents, (a, b) => { if (a.ConnectionStatus != b.ConnectionStatus) return a.ConnectionStatus.CompareTo(b.ConnectionStatus); return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal); }); - Agents = agents; if (Agents.Count < MaxAgents) ShowAllAgents = false; - } - private string WorkspaceUri(Uri? baseUri, string? workspaceName) - { - if (baseUri == null) return DefaultDashboardUrl; - if (string.IsNullOrWhiteSpace(workspaceName)) return baseUri.ToString(); - try - { - return new Uri(baseUri, $"/@me/{workspaceName}").ToString(); - } - catch + var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Gray); + if (firstOnlineAgent is null) + _hasExpandedAgent = false; + if (!_hasExpandedAgent && firstOnlineAgent is not null) { - return DefaultDashboardUrl; + firstOnlineAgent.SetExpanded(true); + _hasExpandedAgent = true; } } - private void UpdateFromCredentialsModel(CredentialModel credentialModel) + private void UpdateFromCredentialModel(CredentialModel credentialModel) { // Ensure we're on the UI thread. if (_dispatcherQueue == null) return; if (!_dispatcherQueue.HasThreadAccess) { - _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel)); + _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialModel(credentialModel)); return; } + // CredentialModel updates trigger RpcStateModel updates first. This + // resolves an issue on startup where the window would be locked for 5 + // seconds, even if all startup preconditions have been met: + // + // 1. RPC state updates, but credentials are invalid so the window + // enters the invalid loading state to prevent interaction. + // 2. Credential model finally becomes valid after reaching out to the + // server to check credentials. + // 3. UpdateFromCredentialModel previously did not re-trigger RpcModel + // update. + // 4. Five seconds after step 1, a new RPC state update would come in + // and finally unlock the window. + // + // Calling UpdateFromRpcModel at step 3 resolves this issue. + UpdateFromRpcModel(_rpcController.GetState()); + // HACK: the HyperlinkButton crashes the whole app if the initial URI // or this URI is invalid. CredentialModel.CoderUrl should never be // null while the Page is active as the Page is only displayed when // CredentialModel.Status == Valid. - DashboardUrl = credentialModel.CoderUrl ?? DefaultDashboardUrl; + DashboardUrl = credentialModel.CoderUrl?.ToString() ?? DefaultDashboardUrl; } public void VpnSwitch_Toggled(object sender, RoutedEventArgs e) @@ -273,13 +327,13 @@ private static string MaybeUnwrapTunnelError(Exception e) } [RelayCommand] - public void ToggleShowAllAgents() + private void ToggleShowAllAgents() { ShowAllAgents = !ShowAllAgents; } [RelayCommand] - public void ShowFileSyncListWindow() + private void ShowFileSyncListWindow() { // This is safe against concurrent access since it all happens in the // UI thread. @@ -295,7 +349,7 @@ public void ShowFileSyncListWindow() } [RelayCommand] - public void SignOut() + private void SignOut() { if (VpnLifecycle is not VpnLifecycle.Stopped) return; diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs index 2409d4b..7af6db3 100644 --- a/App/Views/DirectoryPickerWindow.xaml.cs +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.InteropServices; using Windows.Graphics; +using Coder.Desktop.App.Utils; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Windowing; @@ -8,7 +9,6 @@ using Microsoft.UI.Xaml.Media; using WinRT.Interop; using WinUIEx; -using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index fb899cc..ccd2452 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -1,8 +1,8 @@ +using Coder.Desktop.App.Utils; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Xaml.Media; using WinUIEx; -using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -23,5 +23,4 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) this.CenterOnScreen(); } - } diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index e21b46b..0ca754d 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -87,14 +87,14 @@ private async Task StartDaemon(CancellationToken ct) { + _logger.LogDebug("StartDaemon called"); // Stop the running daemon if (_daemonProcess != null) await StopDaemon(ct); // Attempt to stop any orphaned daemon try { + _logger.LogDebug("creating MutagenClient to stop orphan"); var client = new MutagenClient(_mutagenDataDirectory); await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } @@ -496,6 +499,10 @@ private async Task StartDaemon(CancellationToken ct) { // Mainline; no daemon running. } + finally + { + _logger.LogDebug("finished with orphan mutagen client"); + } // If we get some failure while creating the log file or starting the process, we'll retry // it up to 5 times x 100ms. Those issues should resolve themselves quickly if they are @@ -528,6 +535,7 @@ private async Task StartDaemon(CancellationToken ct) ct.ThrowIfCancellationRequested(); try { + _logger.LogDebug("creating mainline mutagen client"); var client = new MutagenClient(_mutagenDataDirectory); _ = await client.Daemon.VersionAsync(new VersionRequest(), cancellationToken: ct); _mutagenClient = client; diff --git a/MutagenSdk/MutagenClient.cs b/MutagenSdk/MutagenClient.cs index 27ffa7a..89fad29 100644 --- a/MutagenSdk/MutagenClient.cs +++ b/MutagenSdk/MutagenClient.cs @@ -16,26 +16,6 @@ public class MutagenClient : IDisposable public MutagenClient(string dataDir) { - // Check for the lock file first, since it should exist if it's running. - var daemonLockFile = Path.Combine(dataDir, "daemon", "daemon.lock"); - if (!File.Exists(daemonLockFile)) - throw new FileNotFoundException( - "Mutagen daemon lock file not found, did the mutagen daemon start successfully?", daemonLockFile); - - // We should not be able to open the lock file. - try - { - using var _ = File.Open(daemonLockFile, FileMode.Open, FileAccess.Write, FileShare.None); - // We throw a FileNotFoundException if we could open the file because - // it means the same thing and allows us to return the path nicely. - throw new InvalidOperationException( - $"Mutagen daemon lock file '{daemonLockFile}' is unlocked, did the mutagen daemon start successfully?"); - } - catch (IOException) - { - // this is what we expect - } - // Read the IPC named pipe address from the sock file. var daemonSockFile = Path.Combine(dataDir, "daemon", "daemon.sock"); if (!File.Exists(daemonSockFile)) From 9e50acdfaa85b1317ef8743efa56bcbf804a9846 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 16 May 2025 10:02:18 +0400 Subject: [PATCH 09/10] chore: make hostname suffix mutable in views (#102) Part 1 of #49 Makes the workspace suffix dynamic in the views. A later PR in this stack will fetch the suffix and apply it to any views if it changes. If the suffix doesn't match the FQDN provided by the VPN service, we won't gray out anything when we show the name, but if we later get a suffix that matches, it will update. --- App/ViewModels/AgentViewModel.cs | 98 ++++++++++++++++++---- App/ViewModels/TrayWindowViewModel.cs | 24 ++---- App/Views/Pages/TrayWindowMainPage.xaml | 4 +- App/Views/Pages/TrayWindowMainPage.xaml.cs | 14 ++-- 4 files changed, 99 insertions(+), 41 deletions(-) diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index c44db3e..34b01d7 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -23,8 +23,13 @@ namespace Coder.Desktop.App.ViewModels; public interface IAgentViewModelFactory { - public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName); + + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName); } public class AgentViewModelFactory( @@ -33,14 +38,32 @@ public class AgentViewModelFactory( ICredentialManager credentialManager, IAgentAppViewModelFactory agentAppViewModelFactory) : IAgentViewModelFactory { - public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName) { return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, expanderHost, id) { - Hostname = hostname, - HostnameSuffix = hostnameSuffix, + ConfiguredFqdn = fullyQualifiedDomainName, + ConfiguredHostname = string.Empty, + ConfiguredHostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + }; + } + + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + expanderHost, id) + { + ConfiguredFqdn = string.Empty, + ConfiguredHostname = workspaceName, + ConfiguredHostnameSuffix = hostnameSuffix, ConnectionStatus = connectionStatus, DashboardBaseUrl = dashboardBaseUrl, WorkspaceName = workspaceName, @@ -84,15 +107,55 @@ public partial class AgentViewModel : ObservableObject, IModelUpdateable Hostname + HostnameSuffix; + /// + /// ViewableHostname is the hostname portion of the fully qualified domain name (FQDN) specifically for + /// views that render it differently than the suffix. If the ConfiguredHostnameSuffix doesn't actually + /// match the FQDN, then this will be the entire FQDN, and ViewableHostnameSuffix will be empty. + /// + public string ViewableHostname => !FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? FullyQualifiedDomainName + : FullyQualifiedDomainName[0..^ConfiguredHostnameSuffix.Length]; + + /// + /// ViewableHostnameSuffix is the domain suffix portion (including leading dot) of the fully qualified + /// domain name (FQDN) specifically for views that render it differently from the rest of the FQDN. If + /// the ConfiguredHostnameSuffix doesn't actually match the FQDN, then this will be empty and the + /// ViewableHostname will contain the entire FQDN. + /// + public string ViewableHostnameSuffix => FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? ConfiguredHostnameSuffix + : string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] @@ -202,10 +265,12 @@ public bool TryApplyChanges(AgentViewModel model) // To avoid spurious UI updates which cause flashing, don't actually // write to values unless they've changed. - if (Hostname != model.Hostname) - Hostname = model.Hostname; - if (HostnameSuffix != model.HostnameSuffix) - HostnameSuffix = model.HostnameSuffix; + if (ConfiguredFqdn != model.ConfiguredFqdn) + ConfiguredFqdn = model.ConfiguredFqdn; + if (ConfiguredHostname != model.ConfiguredHostname) + ConfiguredHostname = model.ConfiguredHostname; + if (ConfiguredHostnameSuffix != model.ConfiguredHostnameSuffix) + ConfiguredHostnameSuffix = model.ConfiguredHostnameSuffix; if (ConnectionStatus != model.ConnectionStatus) ConnectionStatus = model.ConnectionStatus; if (DashboardBaseUrl != model.DashboardBaseUrl) @@ -337,12 +402,13 @@ private void ContinueFetchApps(Task task) { Scheme = scheme, Host = "vscode-remote", - Path = $"/ssh-remote+{FullHostname}/{workspaceAgent.ExpandedDirectory}", + Path = $"/ssh-remote+{FullyQualifiedDomainName}/{workspaceAgent.ExpandedDirectory}", }.Uri; } catch (Exception e) { - _logger.LogWarning(e, "Could not craft app URI for display app {displayApp}, app will not appear in list", + _logger.LogWarning(e, + "Could not craft app URI for display app {displayApp}, app will not appear in list", displayApp); continue; } @@ -365,7 +431,7 @@ private void CopyHostname(object parameter) { RequestedOperation = DataPackageOperation.Copy, }; - dataPackage.SetText(FullHostname); + dataPackage.SetText(FullyQualifiedDomainName); Clipboard.SetContent(dataPackage); if (parameter is not FrameworkElement frameworkElement) return; diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index b0c9a8b..1dccab0 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -29,6 +29,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; + private const string DefaultHostnameSuffix = ".coder"; private readonly IServiceProvider _services; private readonly IRpcController _rpcController; @@ -90,6 +91,8 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost [ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl; + private string _hostnameSuffix = DefaultHostnameSuffix; + public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory) { @@ -181,14 +184,6 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (string.IsNullOrWhiteSpace(fqdn)) continue; - var fqdnPrefix = fqdn; - var fqdnSuffix = ""; - if (fqdn.Contains('.')) - { - fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')]; - fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..]; - } - var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) ? AgentConnectionStatus.Green @@ -199,8 +194,8 @@ private void UpdateFromRpcModel(RpcModel rpcModel) agents.Add(_agentViewModelFactory.Create( this, uuid, - fqdnPrefix, - fqdnSuffix, + fqdn, + _hostnameSuffix, connectionStatus, credentialModel.CoderUrl, workspace?.Name)); @@ -214,15 +209,12 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (!Uuid.TryFrom(workspace.Id.Span, out var uuid)) continue; - agents.Add(_agentViewModelFactory.Create( + agents.Add(_agentViewModelFactory.CreateDummy( this, // Workspace ID is fine as a stand-in here, it shouldn't // conflict with any agent IDs. uuid, - // We assume that it's a single-agent workspace. - workspace.Name, - // TODO: this needs to get the suffix from the server - ".coder", + _hostnameSuffix, AgentConnectionStatus.Gray, credentialModel.CoderUrl, workspace.Name)); @@ -233,7 +225,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) { if (a.ConnectionStatus != b.ConnectionStatus) return a.ConnectionStatus.CompareTo(b.ConnectionStatus); - return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal); + return string.Compare(a.FullyQualifiedDomainName, b.FullyQualifiedDomainName, StringComparison.Ordinal); }); if (Agents.Count < MaxAgents) ShowAllAgents = false; diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index b66aa6e..f3549c2 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -169,9 +169,9 @@ TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"> - - diff --git a/App/Views/Pages/TrayWindowMainPage.xaml.cs b/App/Views/Pages/TrayWindowMainPage.xaml.cs index 5911092..e1cbab3 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml.cs +++ b/App/Views/Pages/TrayWindowMainPage.xaml.cs @@ -18,9 +18,9 @@ public TrayWindowMainPage(TrayWindowViewModel viewModel) } // HACK: using XAML to populate the text Runs results in an additional - // whitespace Run being inserted between the Hostname and the - // HostnameSuffix. You might think, "OK let's populate the entire TextBlock - // content from code then!", but this results in the ItemsRepeater + // whitespace Run being inserted between the ViewableHostname and the + // ViewableHostnameSuffix. You might think, "OK let's populate the entire + // TextBlock content from code then!", but this results in the ItemsRepeater // corrupting it and firing events off to the wrong AgentModel. // // This is the best solution I came up with that worked. @@ -28,12 +28,12 @@ public void AgentHostnameText_OnLoaded(object sender, RoutedEventArgs e) { if (sender is not TextBlock textBlock) return; - var nonEmptyRuns = new List(); + var nonWhitespaceRuns = new List(); foreach (var inline in textBlock.Inlines) - if (inline is Run run && !string.IsNullOrWhiteSpace(run.Text)) - nonEmptyRuns.Add(run); + if (inline is Run run && run.Text != " ") + nonWhitespaceRuns.Add(run); textBlock.Inlines.Clear(); - foreach (var run in nonEmptyRuns) textBlock.Inlines.Add(run); + foreach (var run in nonWhitespaceRuns) textBlock.Inlines.Add(run); } } From be72f80acb04e5041f8c5e053cba3e982323877f Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Tue, 20 May 2025 10:07:14 +0400 Subject: [PATCH 10/10] feat: fetch hostname suffix from API (#103) Fixes #49 Adds support to query the hostname suffix from Coder server, and then propagates any changes to the agent view models. --- App/App.xaml.cs | 1 + App/Models/CredentialModel.cs | 13 +- App/Services/HostnameSuffixGetter.cs | 144 ++++++++++++++++++ App/ViewModels/TrayWindowViewModel.cs | 29 +++- CoderSdk/Coder/CoderApiClient.cs | 1 + CoderSdk/Coder/WorkspaceAgents.cs | 13 ++ .../Services/HostnameSuffixGetterTest.cs | 121 +++++++++++++++ 7 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 App/Services/HostnameSuffixGetter.cs create mode 100644 Tests.App/Services/HostnameSuffixGetterTest.cs diff --git a/App/App.xaml.cs b/App/App.xaml.cs index e756efd..5b82ced 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -72,6 +72,7 @@ public App() new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddOptions() .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); diff --git a/App/Models/CredentialModel.cs b/App/Models/CredentialModel.cs index d30f894..b38bbba 100644 --- a/App/Models/CredentialModel.cs +++ b/App/Models/CredentialModel.cs @@ -1,4 +1,5 @@ using System; +using Coder.Desktop.CoderSdk.Coder; namespace Coder.Desktop.App.Models; @@ -14,7 +15,7 @@ public enum CredentialState Valid, } -public class CredentialModel +public class CredentialModel : ICoderApiClientCredentialProvider { public CredentialState State { get; init; } = CredentialState.Unknown; @@ -33,4 +34,14 @@ public CredentialModel Clone() Username = Username, }; } + + public CoderApiClientCredential? GetCoderApiClientCredential() + { + if (State != CredentialState.Valid) return null; + return new CoderApiClientCredential + { + ApiToken = ApiToken!, + CoderUrl = CoderUrl!, + }; + } } diff --git a/App/Services/HostnameSuffixGetter.cs b/App/Services/HostnameSuffixGetter.cs new file mode 100644 index 0000000..3816623 --- /dev/null +++ b/App/Services/HostnameSuffixGetter.cs @@ -0,0 +1,144 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Utilities; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.Services; + +public interface IHostnameSuffixGetter +{ + public event EventHandler SuffixChanged; + + public string GetCachedSuffix(); +} + +public class HostnameSuffixGetter : IHostnameSuffixGetter +{ + private const string DefaultSuffix = ".coder"; + + private readonly ICredentialManager _credentialManager; + private readonly ICoderApiClientFactory _clientFactory; + private readonly ILogger _logger; + + // _lock protects all private (non-readonly) values + private readonly RaiiSemaphoreSlim _lock = new(1, 1); + private string _domainSuffix = DefaultSuffix; + private bool _dirty = false; + private bool _getInProgress = false; + private CredentialModel _credentialModel = new() { State = CredentialState.Invalid }; + + public event EventHandler? SuffixChanged; + + public HostnameSuffixGetter(ICredentialManager credentialManager, ICoderApiClientFactory apiClientFactory, + ILogger logger) + { + _credentialManager = credentialManager; + _clientFactory = apiClientFactory; + _logger = logger; + credentialManager.CredentialsChanged += HandleCredentialsChanged; + HandleCredentialsChanged(this, _credentialManager.GetCachedCredentials()); + } + + ~HostnameSuffixGetter() + { + _credentialManager.CredentialsChanged -= HandleCredentialsChanged; + } + + private void HandleCredentialsChanged(object? sender, CredentialModel credentials) + { + using var _ = _lock.Lock(); + _logger.LogDebug("credentials updated with state {state}", credentials.State); + _credentialModel = credentials; + if (credentials.State != CredentialState.Valid) return; + + _dirty = true; + if (!_getInProgress) + { + _getInProgress = true; + Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + } + } + + private async Task Refresh() + { + _logger.LogDebug("refreshing domain suffix"); + CredentialModel credentials; + using (_ = await _lock.LockAsync()) + { + credentials = _credentialModel; + if (credentials.State != CredentialState.Valid) + { + _logger.LogDebug("abandoning refresh because credentials are now invalid"); + return; + } + + _dirty = false; + } + + var client = _clientFactory.Create(credentials); + using var timeoutSrc = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var connInfo = await client.GetAgentConnectionInfoGeneric(timeoutSrc.Token); + + // older versions of Coder might not set this + var suffix = string.IsNullOrEmpty(connInfo.HostnameSuffix) + ? DefaultSuffix + // and, it doesn't include the leading dot. + : "." + connInfo.HostnameSuffix; + + var changed = false; + using (_ = await _lock.LockAsync(CancellationToken.None)) + { + if (_domainSuffix != suffix) changed = true; + _domainSuffix = suffix; + } + + if (changed) + { + _logger.LogInformation("got new domain suffix '{suffix}'", suffix); + // grab a local copy of the EventHandler to avoid TOCTOU race on the `?.` null-check + var del = SuffixChanged; + del?.Invoke(this, suffix); + } + else + { + _logger.LogDebug("domain suffix unchanged '{suffix}'", suffix); + } + } + + private async Task MaybeRefreshAgain(Task prev) + { + if (prev.IsFaulted) + { + _logger.LogError(prev.Exception, "failed to query domain suffix"); + // back off here before retrying. We're just going to use a fixed, long + // delay since this just affects UI stuff; we're not in a huge rush as + // long as we eventually get the right value. + await Task.Delay(TimeSpan.FromSeconds(10)); + } + + using var l = await _lock.LockAsync(CancellationToken.None); + if ((_dirty || prev.IsFaulted) && _credentialModel.State == CredentialState.Valid) + { + // we still have valid credentials and we're either dirty or the last Get failed. + _logger.LogDebug("retrying domain suffix query"); + _ = Task.Run(Refresh).ContinueWith(MaybeRefreshAgain); + return; + } + + // Getting here means either the credentials are not valid or we don't need to + // refresh anyway. + // The next time we get new, valid credentials, HandleCredentialsChanged will kick off + // a new Refresh + _getInProgress = false; + return; + } + + public string GetCachedSuffix() + { + using var _ = _lock.Lock(); + return _domainSuffix; + } +} diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 1dccab0..cfa5163 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -35,6 +35,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; private readonly IAgentViewModelFactory _agentViewModelFactory; + private readonly IHostnameSuffixGetter _hostnameSuffixGetter; private FileSyncListWindow? _fileSyncListWindow; @@ -91,15 +92,14 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost [ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl; - private string _hostnameSuffix = DefaultHostnameSuffix; - public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, - ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory) + ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory, IHostnameSuffixGetter hostnameSuffixGetter) { _services = services; _rpcController = rpcController; _credentialManager = credentialManager; _agentViewModelFactory = agentViewModelFactory; + _hostnameSuffixGetter = hostnameSuffixGetter; // Since the property value itself never changes, we add event // listeners for the underlying collection changing instead. @@ -139,6 +139,9 @@ public void Initialize(DispatcherQueue dispatcherQueue) _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel); UpdateFromCredentialModel(_credentialManager.GetCachedCredentials()); + + _hostnameSuffixGetter.SuffixChanged += (_, suffix) => HandleHostnameSuffixChanged(suffix); + HandleHostnameSuffixChanged(_hostnameSuffixGetter.GetCachedSuffix()); } private void UpdateFromRpcModel(RpcModel rpcModel) @@ -195,7 +198,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) this, uuid, fqdn, - _hostnameSuffix, + _hostnameSuffixGetter.GetCachedSuffix(), connectionStatus, credentialModel.CoderUrl, workspace?.Name)); @@ -214,7 +217,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) // Workspace ID is fine as a stand-in here, it shouldn't // conflict with any agent IDs. uuid, - _hostnameSuffix, + _hostnameSuffixGetter.GetCachedSuffix(), AgentConnectionStatus.Gray, credentialModel.CoderUrl, workspace.Name)); @@ -273,6 +276,22 @@ private void UpdateFromCredentialModel(CredentialModel credentialModel) DashboardUrl = credentialModel.CoderUrl?.ToString() ?? DefaultDashboardUrl; } + private void HandleHostnameSuffixChanged(string suffix) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleHostnameSuffixChanged(suffix)); + return; + } + + foreach (var agent in Agents) + { + agent.ConfiguredHostnameSuffix = suffix; + } + } + public void VpnSwitch_Toggled(object sender, RoutedEventArgs e) { if (sender is not ToggleSwitch toggleSwitch) return; diff --git a/CoderSdk/Coder/CoderApiClient.cs b/CoderSdk/Coder/CoderApiClient.cs index 15845bb..a24f364 100644 --- a/CoderSdk/Coder/CoderApiClient.cs +++ b/CoderSdk/Coder/CoderApiClient.cs @@ -49,6 +49,7 @@ public partial interface ICoderApiClient public void SetSessionToken(string token); } +[JsonSerializable(typeof(AgentConnectionInfo))] [JsonSerializable(typeof(BuildInfo))] [JsonSerializable(typeof(Response))] [JsonSerializable(typeof(User))] diff --git a/CoderSdk/Coder/WorkspaceAgents.cs b/CoderSdk/Coder/WorkspaceAgents.cs index d566286..9a7e6ff 100644 --- a/CoderSdk/Coder/WorkspaceAgents.cs +++ b/CoderSdk/Coder/WorkspaceAgents.cs @@ -3,6 +3,14 @@ namespace Coder.Desktop.CoderSdk.Coder; public partial interface ICoderApiClient { public Task GetWorkspaceAgent(string id, CancellationToken ct = default); + public Task GetAgentConnectionInfoGeneric(CancellationToken ct = default); +} + +public class AgentConnectionInfo +{ + public string HostnameSuffix { get; set; } = string.Empty; + // note that we're leaving out several fields including the DERP Map because + // we don't use that information, and it's a complex object to define. } public class WorkspaceAgent @@ -35,4 +43,9 @@ public Task GetWorkspaceAgent(string id, CancellationToken ct = { return SendRequestNoBodyAsync(HttpMethod.Get, "/api/v2/workspaceagents/" + id, ct); } + + public Task GetAgentConnectionInfoGeneric(CancellationToken ct = default) + { + return SendRequestNoBodyAsync(HttpMethod.Get, "/api/v2/workspaceagents/connection", ct); + } } diff --git a/Tests.App/Services/HostnameSuffixGetterTest.cs b/Tests.App/Services/HostnameSuffixGetterTest.cs new file mode 100644 index 0000000..9897d98 --- /dev/null +++ b/Tests.App/Services/HostnameSuffixGetterTest.cs @@ -0,0 +1,121 @@ +using System.ComponentModel.DataAnnotations; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.CoderSdk.Coder; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; +using Serilog; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class HostnameSuffixGetterTest +{ + const string coderUrl = "https://coder.test/"; + + [SetUp] + public void SetupMocks() + { + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.NUnitOutput().CreateLogger(); + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + _logger = (ILogger)builder.Build().Services + .GetService(typeof(ILogger))!; + + _mCoderApiClientFactory = new Mock(MockBehavior.Strict); + _mCredentialManager = new Mock(MockBehavior.Strict); + _mCoderApiClient = new Mock(MockBehavior.Strict); + _mCoderApiClientFactory.Setup(m => m.Create(It.IsAny())) + .Returns(_mCoderApiClient.Object); + } + + private Mock _mCoderApiClientFactory; + private Mock _mCredentialManager; + private Mock _mCoderApiClient; + private ILogger _logger; + + [Test(Description = "Mainline no errors")] + [CancelAfter(10_000)] + public async Task Mainline(CancellationToken ct) + { + _mCredentialManager.Setup(m => m.GetCachedCredentials()) + .Returns(new CredentialModel() { State = CredentialState.Invalid }); + var hostnameSuffixGetter = + new HostnameSuffixGetter(_mCredentialManager.Object, _mCoderApiClientFactory.Object, _logger); + + // initially, we return the default + Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".coder")); + + // subscribed to suffix changes + var suffixCompletion = new TaskCompletionSource(); + hostnameSuffixGetter.SuffixChanged += (_, suffix) => suffixCompletion.SetResult(suffix); + + // set the client to return "test" as the suffix + _mCoderApiClient.Setup(m => m.SetSessionToken("test-token")); + _mCoderApiClient.Setup(m => m.GetAgentConnectionInfoGeneric(It.IsAny())) + .Returns(Task.FromResult(new AgentConnectionInfo() { HostnameSuffix = "test" })); + + _mCredentialManager.Raise(m => m.CredentialsChanged += null, _mCredentialManager.Object, new CredentialModel + { + State = CredentialState.Valid, + CoderUrl = new Uri(coderUrl), + ApiToken = "test-token", + }); + var gotSuffix = await TaskOrCancellation(suffixCompletion.Task, ct); + Assert.That(gotSuffix, Is.EqualTo(".test")); + + // now, we should return the .test domain going forward + Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".test")); + } + + [Test(Description = "Retries if error")] + [CancelAfter(30_000)] + // TODO: make this test not have to actually wait for the retry. + public async Task RetryError(CancellationToken ct) + { + _mCredentialManager.Setup(m => m.GetCachedCredentials()) + .Returns(new CredentialModel() { State = CredentialState.Invalid }); + var hostnameSuffixGetter = + new HostnameSuffixGetter(_mCredentialManager.Object, _mCoderApiClientFactory.Object, _logger); + + // subscribed to suffix changes + var suffixCompletion = new TaskCompletionSource(); + hostnameSuffixGetter.SuffixChanged += (_, suffix) => suffixCompletion.SetResult(suffix); + + // set the client to fail once, then return successfully + _mCoderApiClient.Setup(m => m.SetSessionToken("test-token")); + var connectionInfoCompletion = new TaskCompletionSource(); + _mCoderApiClient.SetupSequence(m => m.GetAgentConnectionInfoGeneric(It.IsAny())) + .Returns(Task.FromException(new Exception("a bad thing happened"))) + .Returns(Task.FromResult(new AgentConnectionInfo() { HostnameSuffix = "test" })); + + _mCredentialManager.Raise(m => m.CredentialsChanged += null, _mCredentialManager.Object, new CredentialModel + { + State = CredentialState.Valid, + CoderUrl = new Uri(coderUrl), + ApiToken = "test-token", + }); + var gotSuffix = await TaskOrCancellation(suffixCompletion.Task, ct); + Assert.That(gotSuffix, Is.EqualTo(".test")); + + // now, we should return the .test domain going forward + Assert.That(hostnameSuffixGetter.GetCachedSuffix(), Is.EqualTo(".test")); + } + + /// + /// TaskOrCancellation waits for either the task to complete, or the given token to be canceled. + /// + internal static async Task TaskOrCancellation(Task task, + CancellationToken cancellationToken) + { + var cancellationTask = new TaskCompletionSource(); + await using (cancellationToken.Register(() => cancellationTask.TrySetCanceled())) + { + // Wait for either the task or the cancellation + var completedTask = await Task.WhenAny(task, cancellationTask.Task); + // Await to propagate exceptions, if any + return await completedTask; + } + } +}