From 78ff6da0b467000c845e842e51c75a9672cb2d08 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 2 May 2025 07:12:58 +0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 @@