diff --git a/App/App.csproj b/App/App.csproj index 2a15166..982612f 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -56,15 +56,19 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 4a35a0f..ba6fa67 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,18 +1,27 @@ using System; +using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Threading; using System.Threading.Tasks; +using Windows.ApplicationModel.Activation; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views; using Coder.Desktop.App.Views.Pages; +using Coder.Desktop.CoderSdk.Agent; using Coder.Desktop.Vpn; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml; using Microsoft.Win32; +using Microsoft.Windows.AppLifecycle; +using Serilog; +using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; +using Microsoft.Windows.AppNotifications; namespace Coder.Desktop.App; @@ -21,28 +30,51 @@ public partial class App : Application private readonly IServiceProvider _services; private bool _handleWindowClosed = true; + private const string MutagenControllerConfigSection = "MutagenController"; #if !DEBUG - private const string MutagenControllerConfigSection = "AppMutagenController"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\App"; + private const string logFilename = "app.log"; #else - private const string MutagenControllerConfigSection = "DebugAppMutagenController"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugApp"; + private const string logFilename = "debug-app.log"; #endif + private readonly ILogger _logger; + private readonly IUriHandler _uriHandler; + public App() { var builder = Host.CreateApplicationBuilder(); + var configBuilder = builder.Configuration as IConfigurationBuilder; - (builder.Configuration as IConfigurationBuilder).Add( - new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder Desktop")); + // Add config in increasing order of precedence: first builtin defaults, then HKLM, finally HKCU + // so that the user's settings in the registry take precedence. + AddDefaultConfig(configBuilder); + configBuilder.Add( + new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey)); + configBuilder.Add( + new RegistryConfigurationSource(Registry.CurrentUser, ConfigSubKey)); var services = builder.Services; + // Logging + builder.Services.AddSerilog((_, loggerConfig) => + { + loggerConfig.ReadFrom.Configuration(builder.Configuration); + }); + + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddOptions() .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // SignInWindow views and view models services.AddTransient(); @@ -53,6 +85,8 @@ public App() // FileSyncListMainPage is created by FileSyncListWindow. services.AddTransient(); + // DirectoryPickerWindow views and view models are created by FileSyncListViewModel. + // TrayWindow views and view models services.AddTransient(); services.AddTransient(); @@ -66,12 +100,15 @@ public App() services.AddTransient(); _services = services.BuildServiceProvider(); + _logger = (ILogger)_services.GetService(typeof(ILogger))!; + _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!; InitializeComponent(); } public async Task ExitApplication() { + _logger.LogDebug("exiting app"); _handleWindowClosed = false; Exit(); var syncController = _services.GetRequiredService(); @@ -84,36 +121,39 @@ public async Task ExitApplication() protected override void OnLaunched(LaunchActivatedEventArgs args) { + _logger.LogInformation("new instance launched"); // Start connecting to the manager in the background. var rpcController = _services.GetRequiredService(); if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected) // Passing in a CT with no cancellation is desired here, because // the named pipe open will block until the pipe comes up. - // TODO: log - _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t => + _logger.LogDebug("reconnecting with VPN service"); + _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t => + { + if (t.Exception != null) { + _logger.LogError(t.Exception, "failed to connect to VPN service"); #if DEBUG - if (t.Exception != null) - { - Debug.WriteLine(t.Exception); - Debugger.Break(); - } + Debug.WriteLine(t.Exception); + Debugger.Break(); #endif - }); + } + }); // Load the credentials in the background. var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var credentialManager = _services.GetRequiredService(); _ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t => { - // TODO: log -#if DEBUG if (t.Exception != null) { + _logger.LogError(t.Exception, "failed to load credentials"); +#if DEBUG Debug.WriteLine(t.Exception); Debugger.Break(); - } #endif + } + credentialManagerCts.Dispose(); }, CancellationToken.None); @@ -122,10 +162,14 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) var syncSessionController = _services.GetRequiredService(); _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t => { - // TODO: log + if (t.IsCanceled || t.Exception != null) + { + _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled); #if DEBUG - if (t.IsCanceled || t.Exception != null) Debugger.Break(); + Debugger.Break(); #endif + } + syncSessionCts.Dispose(); }, CancellationToken.None); @@ -138,4 +182,67 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) trayWindow.AppWindow.Hide(); }; } + + public void OnActivated(object? sender, AppActivationArguments args) + { + switch (args.Kind) + { + case ExtendedActivationKind.Protocol: + var protoArgs = args.Data as IProtocolActivatedEventArgs; + if (protoArgs == null) + { + _logger.LogWarning("URI activation with null data"); + return; + } + + // 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: + var notificationArgs = (args.Data as AppNotificationActivatedEventArgs)!; + HandleNotification(null, notificationArgs); + break; + + default: + _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind); + break; + } + } + + 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( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CoderDesktop", + logFilename); + builder.AddInMemoryCollection(new Dictionary + { + [MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe", + ["Serilog:Using:0"] = "Serilog.Sinks.File", + ["Serilog:MinimumLevel"] = "Information", + ["Serilog:Enrich:0"] = "FromLogContext", + ["Serilog:WriteTo:0:Name"] = "File", + ["Serilog:WriteTo:0:Args:path"] = logPath, + ["Serilog:WriteTo:0:Args:outputTemplate"] = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", + ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day", + }); + } } diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs index 8c1570f..a31c33b 100644 --- a/App/Converters/DependencyObjectSelector.cs +++ b/App/Converters/DependencyObjectSelector.cs @@ -186,3 +186,7 @@ private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyP public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem; public sealed class StringToBrushSelector : DependencyObjectSelector; + +public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToStringSelector : DependencyObjectSelector; diff --git a/App/Package.appxmanifest b/App/Package.appxmanifest deleted file mode 100644 index e3ad480..0000000 --- a/App/Package.appxmanifest +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - Coder Desktop (Package) - Coder Technologies Inc. - Images\StoreLogo.png - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/App/Program.cs b/App/Program.cs index 2918caa..3749c3b 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -1,9 +1,11 @@ using System; +using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using WinRT; namespace Coder.Desktop.App; @@ -26,7 +28,23 @@ private static void Main(string[] args) try { ComWrappersSupport.InitializeComWrappers(); - if (!CheckSingleInstance()) return; + var mainInstance = GetMainInstance(); + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + if (!mainInstance.IsCurrent) + { + mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait(); + return; + } + + // Register for URI handling (known as "protocol activation") +#if DEBUG + const string scheme = "coder-debug"; +#else + const string scheme = "coder"; +#endif + var thisBin = Assembly.GetExecutingAssembly().Location; + ActivationRegistrationManager.RegisterForProtocolActivation(scheme, thisBin + ",1", "Coder Desktop", ""); + Application.Start(p => { var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); @@ -38,6 +56,18 @@ private static void Main(string[] args) e.Handled = true; ShowExceptionAndCrash(e.Exception); }; + + // 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) @@ -46,8 +76,7 @@ private static void Main(string[] args) } } - [STAThread] - private static bool CheckSingleInstance() + private static AppInstance GetMainInstance() { #if !DEBUG const string appInstanceName = "Coder.Desktop.App"; @@ -55,11 +84,9 @@ private static bool CheckSingleInstance() const string appInstanceName = "Coder.Desktop.App.Debug"; #endif - var instance = AppInstance.FindOrRegisterForKey(appInstanceName); - return instance.IsCurrent; + return AppInstance.FindOrRegisterForKey(appInstanceName); } - [STAThread] private static void ShowExceptionAndCrash(Exception e) { const string title = "Coder Desktop Fatal Error"; diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 41a8dc7..280169c 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Utilities; namespace Coder.Desktop.App.Services; @@ -306,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; @@ -325,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/MutagenController.cs b/App/Services/MutagenController.cs index dd489df..3931b66 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -12,10 +12,14 @@ using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core.Ignore; using Coder.Desktop.MutagenSdk.Proto.Url; using Coder.Desktop.Vpn.Utilities; using Grpc.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Serilog; using DaemonTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest; using MutagenProtocol = Coder.Desktop.MutagenSdk.Proto.Url.Protocol; using SynchronizationTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Synchronization.TerminateRequest; @@ -85,7 +89,9 @@ public interface ISyncSessionController : IAsyncDisposable /// Task RefreshState(CancellationToken ct = default); - Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default); + Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback, + CancellationToken ct = default); + Task PauseSyncSession(string identifier, CancellationToken ct = default); Task ResumeSyncSession(string identifier, CancellationToken ct = default); Task TerminateSyncSession(string identifier, CancellationToken ct = default); @@ -110,6 +116,8 @@ public sealed class MutagenController : ISyncSessionController // Protects all private non-readonly class members. private readonly RaiiSemaphoreSlim _lock = new(1, 1); + private readonly ILogger _logger; + private readonly CancellationTokenSource _stateUpdateCts = new(); private Task? _stateUpdateTask; @@ -139,15 +147,19 @@ public sealed class MutagenController : ISyncSessionController private string MutagenDaemonLog => Path.Combine(_mutagenDataDirectory, "daemon.log"); - public MutagenController(IOptions config) + public MutagenController(IOptions config, ILogger logger) { _mutagenExecutablePath = config.Value.MutagenExecutablePath; + _logger = logger; } public MutagenController(string executablePath, string dataDirectory) { _mutagenExecutablePath = executablePath; _mutagenDataDirectory = dataDirectory; + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + _logger = (ILogger)builder.Build().Services.GetService(typeof(ILogger))!; } public event EventHandler? StateChanged; @@ -200,12 +212,16 @@ public async Task RefreshState(CancellationToke return state; } - public async Task CreateSyncSession(CreateSyncSessionRequest req, CancellationToken ct = default) + public async Task CreateSyncSession(CreateSyncSessionRequest req, + Action? progressCallback = null, CancellationToken ct = default) { using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); await using var prompter = await Prompter.Create(client, true, ct); + if (progressCallback != null) + prompter.OnProgress += (_, progress) => progressCallback(progress); + var createRes = await client.Synchronization.CreateAsync(new CreateRequest { Prompter = prompter.Identifier, @@ -213,8 +229,11 @@ public async Task CreateSyncSession(CreateSyncSessionRequest r { Alpha = req.Alpha.MutagenUrl, Beta = req.Beta.MutagenUrl, - // TODO: probably should set these at some point - Configuration = new Configuration(), + // TODO: probably should add a configuration page for these at some point + Configuration = new Configuration + { + IgnoreVCSMode = IgnoreVCSMode.Ignore, + }, ConfigurationAlpha = new Configuration(), ConfigurationBeta = new Configuration(), }, @@ -437,9 +456,9 @@ private async Task EnsureDaemon(CancellationToken ct) { await StopDaemon(cts.Token); } - catch + catch (Exception stopEx) { - // ignored + _logger.LogError(stopEx, "failed to stop daemon"); } ReplaceState(new SyncSessionControllerStateModel @@ -491,6 +510,8 @@ private async Task StartDaemon(CancellationToken ct) } catch (Exception e) when (e is not OperationCanceledException) { + _logger.LogWarning(e, "failed to start daemon process, attempt {attempt} of {maxAttempts}", attempts, + maxAttempts); if (attempts == maxAttempts) throw; // back off a little and try again. @@ -535,21 +556,62 @@ private void StartDaemonProcess() var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log"); var logStream = new StreamWriter(logPath, true); - _daemonProcess = new Process(); - _daemonProcess.StartInfo.FileName = _mutagenExecutablePath; - _daemonProcess.StartInfo.Arguments = "daemon run"; - _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); + _logger.LogInformation("starting mutagen daemon process with executable path '{path}'", _mutagenExecutablePath); + _logger.LogInformation("mutagen data directory '{path}'", _mutagenDataDirectory); + _logger.LogInformation("mutagen daemon log path '{path}'", logPath); + + var daemonProcess = new Process(); + daemonProcess.StartInfo.FileName = _mutagenExecutablePath; + daemonProcess.StartInfo.Arguments = "daemon run"; + daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); + daemonProcess.StartInfo.Environment.Add("MUTAGEN_SSH_CONFIG_PATH", "none"); // do not use ~/.ssh/config // hide the console window - _daemonProcess.StartInfo.CreateNoWindow = true; + daemonProcess.StartInfo.CreateNoWindow = true; // shell needs to be disabled since we set the environment // https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.environment?view=net-8.0 - _daemonProcess.StartInfo.UseShellExecute = false; - _daemonProcess.StartInfo.RedirectStandardError = true; - // TODO: log exited process - // _daemonProcess.Exited += ... - if (!_daemonProcess.Start()) - throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); + daemonProcess.StartInfo.UseShellExecute = false; + daemonProcess.StartInfo.RedirectStandardError = true; + daemonProcess.EnableRaisingEvents = true; + daemonProcess.Exited += (_, _) => + { + var exitCode = -1; + try + { + // ReSharper disable once AccessToDisposedClosure + exitCode = daemonProcess.ExitCode; + } + catch + { + // ignored + } + + _logger.LogInformation("mutagen daemon exited with code {exitCode}", exitCode); + }; + try + { + if (!daemonProcess.Start()) + throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); + } + catch (Exception e) + { + _logger.LogWarning(e, "mutagen daemon failed to start"); + + logStream.Dispose(); + try + { + daemonProcess.Kill(); + } + catch + { + // ignored, the process likely doesn't exist + } + + daemonProcess.Dispose(); + throw; + } + + _daemonProcess = daemonProcess; var writer = new LogWriter(_daemonProcess.StandardError, logStream); Task.Run(() => { _ = writer.Run(); }); _logWriter = writer; @@ -561,6 +623,7 @@ private void StartDaemonProcess() /// private async Task StopDaemon(CancellationToken ct) { + _logger.LogDebug("stopping mutagen daemon"); var process = _daemonProcess; var client = _mutagenClient; var writer = _logWriter; @@ -573,17 +636,21 @@ private async Task StopDaemon(CancellationToken ct) if (client == null) { if (process == null) return; + _logger.LogDebug("no client; killing daemon process"); process.Kill(true); } else { try { + _logger.LogDebug("sending DaemonTerminateRequest"); await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } - catch + catch (Exception e) { + _logger.LogError(e, "failed to gracefully terminate agent"); if (process == null) return; + _logger.LogDebug("killing daemon process after failed graceful termination"); process.Kill(true); } } @@ -591,10 +658,12 @@ private async Task StopDaemon(CancellationToken ct) if (process == null) return; var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5)); + _logger.LogDebug("waiting for process to exit"); await process.WaitForExitAsync(cts.Token); } finally { + _logger.LogDebug("cleaning up daemon process objects"); client?.Dispose(); process?.Dispose(); writer?.Dispose(); @@ -603,6 +672,8 @@ private async Task StopDaemon(CancellationToken ct) private class Prompter : IAsyncDisposable { + public event EventHandler? OnProgress; + private readonly AsyncDuplexStreamingCall _dup; private readonly CancellationTokenSource _cts; private readonly Task _handleRequestsTask; @@ -684,6 +755,9 @@ private async Task HandleRequests(CancellationToken ct) if (response.Message == null) throw new InvalidOperationException("Prompting.Host response stream returned a null message"); + if (!response.IsPrompt) + OnProgress?.Invoke(this, response.Message); + // Currently we only reply to SSH fingerprint messages with // "yes" and send an empty reply for everything else. var reply = ""; 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 new file mode 100644 index 0000000..9150f47 --- /dev/null +++ b/App/Services/UserNotifier.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +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, CancellationToken ct = default); +} + +public class UserNotifier : IUserNotifier +{ + private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default) + { + var builder = new AppNotificationBuilder().AddText(title).AddText(message); + _notificationManager.Show(builder.BuildNotification()); + return Task.CompletedTask; + } +} + 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/ViewModels/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs new file mode 100644 index 0000000..131934f --- /dev/null +++ b/App/ViewModels/DirectoryPickerViewModel.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.CoderSdk.Agent; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.ViewModels; + +public class DirectoryPickerBreadcrumb +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required string Name { get; init; } + + public required IReadOnlyList AbsolutePathSegments { get; init; } + + // HACK: we need to know which one is first so we don't prepend an arrow + // icon. You can't get the index of the current ItemsRepeater item in XAML. + public required bool IsFirst { get; init; } +} + +public enum DirectoryPickerItemKind +{ + ParentDirectory, // aka. ".." + Directory, + File, // includes everything else +} + +public class DirectoryPickerItem +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required DirectoryPickerItemKind Kind { get; init; } + public required string Name { get; init; } + public required IReadOnlyList AbsolutePathSegments { get; init; } + + public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory; +} + +public partial class DirectoryPickerViewModel : ObservableObject +{ + // PathSelected will be called ONCE when the user either cancels or selects + // a directory. If the user cancelled, the path will be null. + public event EventHandler? PathSelected; + + private const int RequestTimeoutMilliseconds = 15_000; + + private readonly IAgentApiClient _client; + + private Window? _window; + private DispatcherQueue? _dispatcherQueue; + + public readonly string AgentFqdn; + + // The initial loading screen is differentiated from subsequent loading + // screens because: + // 1. We don't want to show a broken state while the page is loading. + // 2. An error dialog allows the user to get to a broken state with no + // breadcrumbs, no items, etc. with no chance to reload. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial bool InitialLoading { get; set; } = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowErrorScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial string? InitialLoadError { get; set; } = null; + + [ObservableProperty] public partial bool NavigatingLoading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSelectable))] + public partial string CurrentDirectory { get; set; } = ""; + + [ObservableProperty] public partial IReadOnlyList Breadcrumbs { get; set; } = []; + + [ObservableProperty] public partial IReadOnlyList Items { get; set; } = []; + + public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading; + public bool ShowErrorScreen => InitialLoadError != null; + public bool ShowListScreen => InitialLoadError == null && !InitialLoading; + + // The "root" directory on Windows isn't a real thing, but in our model + // it's a drive listing. We don't allow users to select the fake drive + // listing directory. + // + // On Linux, this will never be empty since the highest you can go is "/". + public bool IsSelectable => CurrentDirectory != ""; + + public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn) + { + _client = clientFactory.Create(agentFqdn); + AgentFqdn = agentFqdn; + } + + public void Initialize(Window window, DispatcherQueue dispatcherQueue) + { + _window = window; + _dispatcherQueue = dispatcherQueue; + if (!_dispatcherQueue.HasThreadAccess) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + InitialLoading = true; + InitialLoadError = null; + // Initial load is in the home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad); + } + + [RelayCommand] + private void RetryLoad() + { + InitialLoading = true; + InitialLoadError = null; + // Subsequent loads after the initial failure are always in the root + // directory in case there's a permanent issue preventing listing the + // home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad); + } + + private async Task BackgroundLoad(ListDirectoryRelativity relativity, List path) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + return await _client.ListDirectory(new ListDirectoryRequest + { + Path = path, + Relativity = relativity, + }, cts.Token); + } + + private void ContinueInitialLoad(Task task) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => ContinueInitialLoad(task)); + return; + } + + if (task.IsCompletedSuccessfully) + { + ProcessResponse(task.Result); + return; + } + + InitialLoadError = "Could not list home directory in workspace: "; + if (task.IsCanceled) InitialLoadError += new TaskCanceledException(); + else if (task.IsFaulted) InitialLoadError += task.Exception; + else InitialLoadError += "no successful result or error"; + InitialLoading = false; + } + + [RelayCommand] + public async Task ListPath(IReadOnlyList path) + { + if (_window is null || NavigatingLoading) return; + NavigatingLoading = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds)); + try + { + var res = await _client.ListDirectory(new ListDirectoryRequest + { + Path = path.ToList(), + Relativity = ListDirectoryRelativity.Root, + }, cts.Token); + ProcessResponse(res); + } + catch (Exception e) + { + // Subsequent listing errors are just shown as dialog boxes. + var dialog = new ContentDialog + { + Title = "Failed to list remote directory", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = _window.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + NavigatingLoading = false; + } + } + + [RelayCommand] + public void Cancel() + { + PathSelected?.Invoke(this, null); + _window?.Close(); + } + + [RelayCommand] + public void Select() + { + if (CurrentDirectory == "") return; + PathSelected?.Invoke(this, CurrentDirectory); + _window?.Close(); + } + + private void ProcessResponse(ListDirectoryResponse res) + { + InitialLoading = false; + InitialLoadError = null; + NavigatingLoading = false; + + var breadcrumbs = new List(res.AbsolutePath.Count + 1) + { + new() + { + Name = "🖥️", + AbsolutePathSegments = [], + IsFirst = true, + ViewModel = this, + }, + }; + for (var i = 0; i < res.AbsolutePath.Count; i++) + breadcrumbs.Add(new DirectoryPickerBreadcrumb + { + Name = res.AbsolutePath[i], + AbsolutePathSegments = res.AbsolutePath[..(i + 1)], + IsFirst = false, + ViewModel = this, + }); + + var items = new List(res.Contents.Count + 1); + if (res.AbsolutePath.Count != 0) + items.Add(new DirectoryPickerItem + { + Kind = DirectoryPickerItemKind.ParentDirectory, + Name = "..", + AbsolutePathSegments = res.AbsolutePath[..^1], + ViewModel = this, + }); + + foreach (var item in res.Contents) + { + if (item.Name.StartsWith(".")) continue; + items.Add(new DirectoryPickerItem + { + Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File, + Name = item.Name, + AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(), + ViewModel = this, + }); + } + + CurrentDirectory = res.AbsolutePathString; + Breadcrumbs = breadcrumbs; + Items = items; + } +} diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 7fdd881..9235141 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -6,6 +6,8 @@ using Windows.Storage.Pickers; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk.Agent; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Dispatching; @@ -19,10 +21,12 @@ public partial class FileSyncListViewModel : ObservableObject { private Window? _window; private DispatcherQueue? _dispatcherQueue; + private DirectoryPickerWindow? _remotePickerWindow; private readonly ISyncSessionController _syncSessionController; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private readonly IAgentApiClientFactory _agentApiClientFactory; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowUnavailable))] @@ -46,7 +50,7 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] public partial bool OperationInProgress { get; set; } = false; - [ObservableProperty] public partial List Sessions { get; set; } = []; + [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = []; [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; @@ -58,14 +62,30 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + public partial IReadOnlyList AvailableHosts { get; set; } = []; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] - public partial string NewSessionRemoteHost { get; set; } = ""; + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial string? NewSessionRemoteHost { get; set; } = null; [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] public partial string NewSessionRemotePath { get; set; } = ""; - // TODO: NewSessionRemotePathDialogOpen for remote path + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; + + public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0; + + public bool NewSessionRemotePathDialogEnabled => + !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen; + + [ObservableProperty] public partial string NewSessionStatus { get; set; } = ""; public bool NewSessionCreateEnabled { @@ -75,6 +95,7 @@ public bool NewSessionCreateEnabled if (NewSessionLocalPathDialogOpen) return false; if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; + if (NewSessionRemotePathDialogOpen) return false; return true; } } @@ -86,11 +107,12 @@ public bool NewSessionCreateEnabled public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null; public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, - ICredentialManager credentialManager) + ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory) { _syncSessionController = syncSessionController; _rpcController = rpcController; _credentialManager = credentialManager; + _agentApiClientFactory = agentApiClientFactory; } public void Initialize(Window window, DispatcherQueue dispatcherQueue) @@ -103,6 +125,14 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue) _rpcController.StateChanged += RpcControllerStateChanged; _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; _syncSessionController.StateChanged += SyncSessionStateChanged; + _window.Closed += (_, _) => + { + _remotePickerWindow?.Close(); + + _rpcController.StateChanged -= RpcControllerStateChanged; + _credentialManager.CredentialsChanged -= CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged -= SyncSessionStateChanged; + }; var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); @@ -171,8 +201,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede else { UnavailableMessage = null; + // Reload if we transitioned from unavailable to available. if (oldMessage != null) ReloadSessions(); } + + // When transitioning from available to unavailable: + if (oldMessage == null && UnavailableMessage != null) + ClearNewForm(); } private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) @@ -187,6 +222,8 @@ private void ClearNewForm() NewSessionLocalPath = ""; NewSessionRemoteHost = ""; NewSessionRemotePath = ""; + NewSessionStatus = ""; + _remotePickerWindow?.Close(); } [RelayCommand] @@ -223,21 +260,50 @@ private void HandleRefresh(Task t) Loading = false; } + // Overriding AvailableHosts seems to make the ComboBox clear its value, so + // we only do this while the create form is not open. + // Must be called in UI thread. + private void SetAvailableHostsFromRpcModel(RpcModel rpcModel) + { + var hosts = new List(rpcModel.Agents.Count); + // Agents will only contain started agents. + foreach (var agent in rpcModel.Agents) + { + var fqdn = agent.Fqdn + .Select(a => a.Trim('.')) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b); + if (string.IsNullOrWhiteSpace(fqdn)) + continue; + hosts.Add(fqdn); + } + + NewSessionRemoteHost = null; + AvailableHosts = hosts; + } + [RelayCommand] private void StartCreatingNewSession() { ClearNewForm(); + // Ensure we have a fresh hosts list before we open the form. We don't + // bind directly to the list on RPC state updates as updating the list + // while in use seems to break it. + SetAvailableHostsFromRpcModel(_rpcController.GetState()); CreatingNewSession = true; } - public async Task OpenLocalPathSelectDialog(Window window) + [RelayCommand] + public async Task OpenLocalPathSelectDialog() { + if (_window is null) return; + var picker = new FolderPicker { SuggestedStartLocation = PickerLocationId.ComputerFolder, }; - var hwnd = WindowNative.GetWindowHandle(window); + var hwnd = WindowNative.GetWindowHandle(_window); InitializeWithWindow.Initialize(picker, hwnd); NewSessionLocalPathDialogOpen = true; @@ -257,19 +323,66 @@ public async Task OpenLocalPathSelectDialog(Window window) } } + [RelayCommand] + public void OpenRemotePathSelectDialog() + { + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) + return; + if (_remotePickerWindow is not null) + { + _remotePickerWindow.Activate(); + return; + } + + NewSessionRemotePathDialogOpen = true; + var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, NewSessionRemoteHost); + pickerViewModel.PathSelected += OnRemotePathSelected; + + _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); + _remotePickerWindow.SetParent(_window); + _remotePickerWindow.Closed += (_, _) => + { + _remotePickerWindow = null; + NewSessionRemotePathDialogOpen = false; + }; + _remotePickerWindow.Activate(); + } + + private void OnRemotePathSelected(object? sender, string? path) + { + if (sender is not DirectoryPickerViewModel pickerViewModel) return; + pickerViewModel.PathSelected -= OnRemotePathSelected; + + if (path == null) return; + NewSessionRemotePath = path; + } + [RelayCommand] private void CancelNewSession() { ClearNewForm(); } + private void OnCreateSessionProgress(string message) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => OnCreateSessionProgress(message)); + return; + } + + NewSessionStatus = message; + } + [RelayCommand] private async Task ConfirmNewSession() { if (OperationInProgress || !NewSessionCreateEnabled) return; OperationInProgress = true; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); try { // The controller will send us a state changed event. @@ -283,10 +396,10 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest Beta = new CreateSyncSessionRequest.Endpoint { Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, - Host = NewSessionRemoteHost, + Host = NewSessionRemoteHost!, Path = NewSessionRemotePath, }, - }, cts.Token); + }, OnCreateSessionProgress, cts.Token); ClearNewForm(); } @@ -304,6 +417,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest finally { OperationInProgress = false; + NewSessionStatus = ""; } } diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 532bfe4..f845521 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -178,6 +178,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) { // We just assume that it's a single-agent workspace. Hostname = workspace.Name, + // TODO: this needs to get the suffix from the server HostnameSuffix = ".coder", ConnectionStatus = AgentConnectionStatus.Gray, DashboardUrl = WorkspaceUri(coderUri, workspace.Name), diff --git a/App/Views/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml new file mode 100644 index 0000000..8a107cb --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs new file mode 100644 index 0000000..2409d4b --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -0,0 +1,96 @@ +using System; +using System.Runtime.InteropServices; +using Windows.Graphics; +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views.Pages; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using WinRT.Interop; +using WinUIEx; +using Coder.Desktop.App.Utils; + +namespace Coder.Desktop.App.Views; + +public sealed partial class DirectoryPickerWindow : WindowEx +{ + public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) + { + InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + + SystemBackdrop = new DesktopAcrylicBackdrop(); + + viewModel.Initialize(this, DispatcherQueue); + RootFrame.Content = new DirectoryPickerMainPage(viewModel); + + // This will be moved to the center of the parent window in SetParent. + this.CenterOnScreen(); + } + + public void SetParent(Window parentWindow) + { + // Move the window to the center of the parent window. + var scale = DisplayScale.WindowScale(parentWindow); + var windowPos = new PointInt32( + parentWindow.AppWindow.Position.X + parentWindow.AppWindow.Size.Width / 2 - AppWindow.Size.Width / 2, + parentWindow.AppWindow.Position.Y + parentWindow.AppWindow.Size.Height / 2 - AppWindow.Size.Height / 2 + ); + + // Ensure we stay within the display. + var workArea = DisplayArea.GetFromPoint(parentWindow.AppWindow.Position, DisplayAreaFallback.Primary).WorkArea; + if (windowPos.X + AppWindow.Size.Width > workArea.X + workArea.Width) // right edge + windowPos.X = workArea.X + workArea.Width - AppWindow.Size.Width; + if (windowPos.Y + AppWindow.Size.Height > workArea.Y + workArea.Height) // bottom edge + windowPos.Y = workArea.Y + workArea.Height - AppWindow.Size.Height; + if (windowPos.X < workArea.X) // left edge + windowPos.X = workArea.X; + if (windowPos.Y < workArea.Y) // top edge + windowPos.Y = workArea.Y; + + AppWindow.Move(windowPos); + + var parentHandle = WindowNative.GetWindowHandle(parentWindow); + var thisHandle = WindowNative.GetWindowHandle(this); + + // Set the parent window in win API. + NativeApi.SetWindowParent(thisHandle, parentHandle); + + // Override the presenter, which allows us to enable modal-like + // behavior for this window: + // - Disables the parent window + // - Any activations of the parent window will play a bell sound and + // focus the modal window + // + // This behavior is very similar to the native file/directory picker on + // Windows. + var presenter = OverlappedPresenter.CreateForDialog(); + presenter.IsModal = true; + AppWindow.SetPresenter(presenter); + AppWindow.Show(); + + // Cascade close events. + parentWindow.Closed += OnParentWindowClosed; + Closed += (_, _) => + { + parentWindow.Closed -= OnParentWindowClosed; + parentWindow.Activate(); + }; + } + + private void OnParentWindowClosed(object? sender, WindowEventArgs e) + { + Close(); + } + + private static class NativeApi + { + [DllImport("user32.dll")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + public static void SetWindowParent(IntPtr window, IntPtr parent) + { + SetWindowLongPtr(window, -8, parent); + } + } +} diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 8a409d7..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,11 +14,14 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) { ViewModel = viewModel; InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + SystemBackdrop = new DesktopAcrylicBackdrop(); ViewModel.Initialize(this, DispatcherQueue); - RootFrame.Content = new FileSyncListMainPage(ViewModel, this); + RootFrame.Content = new FileSyncListMainPage(ViewModel); this.CenterOnScreen(); } + } diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml b/App/Views/Pages/DirectoryPickerMainPage.xaml new file mode 100644 index 0000000..dd08c46 --- /dev/null +++ b/App/Views/Pages/DirectoryPickerMainPage.xaml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -132,7 +138,7 @@ @@ -266,7 +272,7 @@ @@ -274,8 +280,11 @@ - - + + @@ -314,7 +323,7 @@ + + + + diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs index c54c29e..a677522 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml.cs +++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs @@ -1,7 +1,4 @@ -using System.Threading.Tasks; using Coder.Desktop.App.ViewModels; -using CommunityToolkit.Mvvm.Input; -using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace Coder.Desktop.App.Views.Pages; @@ -10,12 +7,9 @@ public sealed partial class FileSyncListMainPage : Page { public FileSyncListViewModel ViewModel; - private readonly Window _window; - - public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window) + public FileSyncListMainPage(FileSyncListViewModel viewModel) { ViewModel = viewModel; // already initialized - _window = window; InitializeComponent(); } @@ -31,10 +25,4 @@ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedCha }; ToolTipService.SetToolTip(sender, toolTip); } - - [RelayCommand] - public async Task OpenLocalPathSelectDialog() - { - await ViewModel.OpenLocalPathSelectDialog(_window); - } } 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"/> SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(method, path, payload, ct); + } +} diff --git a/CoderSdk/Agent/ListDirectory.cs b/CoderSdk/Agent/ListDirectory.cs new file mode 100644 index 0000000..72e4a15 --- /dev/null +++ b/CoderSdk/Agent/ListDirectory.cs @@ -0,0 +1,54 @@ +namespace Coder.Desktop.CoderSdk.Agent; + +public partial interface IAgentApiClient +{ + public Task ListDirectory(ListDirectoryRequest req, CancellationToken ct = default); +} + +public enum ListDirectoryRelativity +{ + // Root means `/` on Linux, and lists drive letters on Windows. + Root, + + // Home means the user's home directory, usually `/home/xyz` or + // `C:\Users\xyz`. + Home, +} + +public class ListDirectoryRequest +{ + // Path segments like ["home", "coder", "repo"] or even just [] + public List Path { get; set; } = []; + + // Where the path originates, either in the home directory or on the root + // of the system + public ListDirectoryRelativity Relativity { get; set; } = ListDirectoryRelativity.Root; +} + +public class ListDirectoryItem +{ + public required string Name { get; init; } + public required string AbsolutePathString { get; init; } + public required bool IsDir { get; init; } +} + +public class ListDirectoryResponse +{ + // The resolved absolute path (always from root) for future requests. + // E.g. if you did a request like `home: ["repo"]`, + // this would return ["home", "coder", "repo"] and "/home/coder/repo" + public required List AbsolutePath { get; init; } + + // e.g. "C:\\Users\\coder\\repo" or "/home/coder/repo" + public required string AbsolutePathString { get; init; } + public required List Contents { get; init; } +} + +public partial class AgentApiClient +{ + public Task ListDirectory(ListDirectoryRequest req, CancellationToken ct = default) + { + return SendRequestAsync(HttpMethod.Post, "/api/v0/list-directory", + req, ct); + } +} diff --git a/CoderSdk/Coder/CoderApiClient.cs b/CoderSdk/Coder/CoderApiClient.cs new file mode 100644 index 0000000..79c5c2f --- /dev/null +++ b/CoderSdk/Coder/CoderApiClient.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace Coder.Desktop.CoderSdk.Coder; + +public interface ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl); +} + +public class CoderApiClientFactory : ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl) + { + return new CoderApiClient(baseUrl); + } +} + +public partial interface ICoderApiClient +{ + public void SetSessionToken(string token); +} + +[JsonSerializable(typeof(BuildInfo))] +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(User))] +[JsonSerializable(typeof(ValidationError))] +public partial class CoderApiJsonContext : JsonSerializerContext; + +/// +/// Provides a limited selection of API methods for a Coder instance. +/// +public partial class CoderApiClient : ICoderApiClient +{ + private const string SessionTokenHeader = "Coder-Session-Token"; + + private readonly JsonHttpClient _httpClient; + + public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) + { + } + + public CoderApiClient(Uri baseUrl) + { + if (baseUrl.PathAndQuery != "/") + throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); + _httpClient = new JsonHttpClient(baseUrl, CoderApiJsonContext.Default); + } + + public CoderApiClient(string baseUrl, string token) : this(baseUrl) + { + SetSessionToken(token); + } + + public void SetSessionToken(string token) + { + _httpClient.RemoveHeader(SessionTokenHeader); + _httpClient.SetHeader(SessionTokenHeader, token); + } + + private async Task SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(method, path, payload, ct); + } +} diff --git a/CoderSdk/Deployment.cs b/CoderSdk/Coder/Deployment.cs similarity index 91% rename from CoderSdk/Deployment.cs rename to CoderSdk/Coder/Deployment.cs index e95e039..978d79d 100644 --- a/CoderSdk/Deployment.cs +++ b/CoderSdk/Coder/Deployment.cs @@ -1,4 +1,4 @@ -namespace Coder.Desktop.CoderSdk; +namespace Coder.Desktop.CoderSdk.Coder; public partial interface ICoderApiClient { diff --git a/CoderSdk/Users.cs b/CoderSdk/Coder/Users.cs similarity index 91% rename from CoderSdk/Users.cs rename to CoderSdk/Coder/Users.cs index fd81b32..6d1914b 100644 --- a/CoderSdk/Users.cs +++ b/CoderSdk/Coder/Users.cs @@ -1,4 +1,4 @@ -namespace Coder.Desktop.CoderSdk; +namespace Coder.Desktop.CoderSdk.Coder; public partial interface ICoderApiClient { diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs deleted file mode 100644 index df2d923..0000000 --- a/CoderSdk/CoderApiClient.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Coder.Desktop.CoderSdk; - -public interface ICoderApiClientFactory -{ - public ICoderApiClient Create(string baseUrl); -} - -public class CoderApiClientFactory : ICoderApiClientFactory -{ - public ICoderApiClient Create(string baseUrl) - { - return new CoderApiClient(baseUrl); - } -} - -public partial interface ICoderApiClient -{ - public void SetSessionToken(string token); -} - -/// -/// Changes names from PascalCase to snake_case. -/// -internal class SnakeCaseNamingPolicy : JsonNamingPolicy -{ - public override string ConvertName(string name) - { - return string.Concat( - name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + char.ToLower(x) : char.ToLower(x).ToString()) - ); - } -} - -[JsonSerializable(typeof(BuildInfo))] -[JsonSerializable(typeof(Response))] -[JsonSerializable(typeof(User))] -[JsonSerializable(typeof(ValidationError))] -public partial class CoderSdkJsonContext : JsonSerializerContext; - -/// -/// Provides a limited selection of API methods for a Coder instance. -/// -public partial class CoderApiClient : ICoderApiClient -{ - public static readonly JsonSerializerOptions JsonOptions = new() - { - TypeInfoResolver = CoderSdkJsonContext.Default, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = new SnakeCaseNamingPolicy(), - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - // TODO: allow adding headers - private readonly HttpClient _httpClient = new(); - - public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) - { - } - - public CoderApiClient(Uri baseUrl) - { - if (baseUrl.PathAndQuery != "/") - throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); - _httpClient.BaseAddress = baseUrl; - } - - public CoderApiClient(string baseUrl, string token) : this(baseUrl) - { - SetSessionToken(token); - } - - public void SetSessionToken(string token) - { - _httpClient.DefaultRequestHeaders.Remove("Coder-Session-Token"); - _httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token); - } - - private async Task SendRequestNoBodyAsync(HttpMethod method, string path, - CancellationToken ct = default) - { - return await SendRequestAsync(method, path, null, ct); - } - - private async Task SendRequestAsync(HttpMethod method, string path, - TRequest? payload, CancellationToken ct = default) - { - try - { - var request = new HttpRequestMessage(method, path); - - if (payload is not null) - { - var json = JsonSerializer.Serialize(payload, typeof(TRequest), JsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - - var res = await _httpClient.SendAsync(request, ct); - if (!res.IsSuccessStatusCode) - throw await CoderApiHttpException.FromResponse(res, ct); - - var content = await res.Content.ReadAsStringAsync(ct); - var data = JsonSerializer.Deserialize(content, JsonOptions); - if (data is null) throw new JsonException("Deserialized response is null"); - return data; - } - catch (CoderApiHttpException) - { - throw; - } - catch (Exception e) - { - throw new Exception($"Coder API Request failed: {method} {path}", e); - } - } -} diff --git a/CoderSdk/Errors.cs b/CoderSdk/Errors.cs index 4d79a59..a7c56c0 100644 --- a/CoderSdk/Errors.cs +++ b/CoderSdk/Errors.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using System.Text.Json.Serialization; namespace Coder.Desktop.CoderSdk; @@ -16,8 +17,20 @@ public class Response public List Validations { get; set; } = []; } +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(ValidationError))] +public partial class ErrorJsonContext : JsonSerializerContext; + public class CoderApiHttpException : Exception { + private static readonly JsonSerializerOptions JsonOptions = new() + { + TypeInfoResolver = ErrorJsonContext.Default, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + private static readonly Dictionary Helpers = new() { { HttpStatusCode.Unauthorized, "Try signing in again" }, @@ -45,7 +58,7 @@ public static async Task FromResponse(HttpResponseMessage Response? responseObject; try { - responseObject = JsonSerializer.Deserialize(content, CoderApiClient.JsonOptions); + responseObject = JsonSerializer.Deserialize(content, JsonOptions); } catch (JsonException) { diff --git a/CoderSdk/JsonHttpClient.cs b/CoderSdk/JsonHttpClient.cs new file mode 100644 index 0000000..362391e --- /dev/null +++ b/CoderSdk/JsonHttpClient.cs @@ -0,0 +1,82 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Coder.Desktop.CoderSdk; + +/// +/// Changes names from PascalCase to snake_case. +/// +internal class SnakeCaseNamingPolicy : JsonNamingPolicy +{ + public override string ConvertName(string name) + { + return string.Concat( + name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + char.ToLower(x) : char.ToLower(x).ToString()) + ); + } +} + +internal class JsonHttpClient +{ + private readonly JsonSerializerOptions _jsonOptions; + + // TODO: allow users to add headers + private readonly HttpClient _httpClient = new(); + + public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver) + { + _jsonOptions = new JsonSerializerOptions + { + TypeInfoResolver = typeResolver, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + _jsonOptions.Converters.Add(new JsonStringEnumConverter(new SnakeCaseNamingPolicy(), false)); + _httpClient.BaseAddress = baseUri; + } + + public void RemoveHeader(string key) + { + _httpClient.DefaultRequestHeaders.Remove(key); + } + + public void SetHeader(string key, string value) + { + _httpClient.DefaultRequestHeaders.Add(key, value); + } + + public async Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + try + { + var request = new HttpRequestMessage(method, path); + + if (payload is not null) + { + var json = JsonSerializer.Serialize(payload, typeof(TRequest), _jsonOptions); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + var res = await _httpClient.SendAsync(request, ct); + if (!res.IsSuccessStatusCode) + throw await CoderApiHttpException.FromResponse(res, ct); + + var content = await res.Content.ReadAsStringAsync(ct); + var data = JsonSerializer.Deserialize(content, _jsonOptions); + if (data is null) throw new JsonException("Deserialized response is null"); + return data; + } + catch (CoderApiHttpException) + { + throw; + } + catch (Exception e) + { + throw new Exception($"API Request failed: {method} {path}", e); + } + } +} diff --git a/Installer/Installer.csproj b/Installer/Installer.csproj index b850f6a..99261b9 100644 --- a/Installer/Installer.csproj +++ b/Installer/Installer.csproj @@ -17,6 +17,7 @@ + diff --git a/Installer/Program.cs b/Installer/Program.cs index 78965e4..10a09a7 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -128,7 +128,8 @@ public class BootstrapperOptions : SharedOptions if (!SystemFile.Exists(MsiPath)) throw new ArgumentException($"MSI package not found at '{MsiPath}'", nameof(MsiPath)); if (!SystemFile.Exists(WindowsAppSdkPath)) - throw new ArgumentException($"Windows App Sdk package not found at '{WindowsAppSdkPath}'", nameof(WindowsAppSdkPath)); + throw new ArgumentException($"Windows App Sdk package not found at '{WindowsAppSdkPath}'", + nameof(WindowsAppSdkPath)); } } @@ -138,6 +139,8 @@ public class Program private const string Manufacturer = "Coder Technologies Inc."; private const string HelpUrl = "https://coder.com/docs"; private const string RegistryKey = @"SOFTWARE\Coder Desktop"; + private const string AppConfigRegistryKey = RegistryKey + @"\App"; + private const string VpnServiceConfigRegistryKey = RegistryKey + @"\VpnService"; private const string DotNetCheckName = "DOTNET_RUNTIME_CHECK"; private const RollForward DotNetCheckRollForward = RollForward.minor; @@ -258,18 +261,21 @@ private static int BuildMsiPackage(MsiOptions opts) project.AddRegValues( // Add registry values that are consumed by the manager. Note that these - // should not be changed. See Vpn.Service/Program.cs and + // should not be changed. See Vpn.Service/Program.cs (AddDefaultConfig) and // Vpn.Service/ManagerConfig.cs for more details. - new RegValue(RegistryHive, RegistryKey, "Manager:ServiceRpcPipeName", "Coder.Desktop.Vpn"), - new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryPath", + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:ServiceRpcPipeName", "Coder.Desktop.Vpn"), + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:TunnelBinaryPath", $"[INSTALLFOLDER]{opts.VpnDir}\\coder-vpn.exe"), - new RegValue(RegistryHive, RegistryKey, "Manager:LogFileLocation", + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:TunnelBinarySignatureSigner", + "Coder Technologies Inc."), + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:TunnelBinaryAllowVersionMismatch", + "false"), + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Serilog:WriteTo:0:Args:path", @"[INSTALLFOLDER]coder-desktop-service.log"), - new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinarySignatureSigner", "Coder Technologies Inc."), - new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryAllowVersionMismatch", "false"), + // Add registry values that are consumed by the App MutagenController. See App/Services/MutagenController.cs - new RegValue(RegistryHive, RegistryKey, "AppMutagenController:MutagenExecutablePath", - @"[INSTALLFOLDER]mutagen.exe") + new RegValue(RegistryHive, AppConfigRegistryKey, "MutagenController:MutagenExecutablePath", + @"[INSTALLFOLDER]vpn\mutagen.exe") ); // Note: most of this control panel info will not be visible as this @@ -381,8 +387,8 @@ private static int BuildBundle(BootstrapperOptions opts) [ new ExePackagePayload { - SourceFile = opts.WindowsAppSdkPath - } + SourceFile = opts.WindowsAppSdkPath, + }, ], }, new MsiPackage(opts.MsiPath) diff --git a/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto b/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto index ecbaf4a..2adcd38 100644 --- a/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto +++ b/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/filesystem/behavior/probe_mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/filesystem/behavior/probe_mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/selection/selection.proto b/MutagenSdk/Proto/selection/selection.proto index 55cddb1..0f12504 100644 --- a/MutagenSdk/Proto/selection/selection.proto +++ b/MutagenSdk/Proto/selection/selection.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/selection/selection.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/selection/selection.proto * * MIT License * diff --git a/MutagenSdk/Proto/service/daemon/daemon.proto b/MutagenSdk/Proto/service/daemon/daemon.proto index f810b3e..b1e836a 100644 --- a/MutagenSdk/Proto/service/daemon/daemon.proto +++ b/MutagenSdk/Proto/service/daemon/daemon.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/daemon/daemon.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/daemon/daemon.proto * * MIT License * diff --git a/MutagenSdk/Proto/service/prompting/prompting.proto b/MutagenSdk/Proto/service/prompting/prompting.proto index 19ea8bb..73432b9 100644 --- a/MutagenSdk/Proto/service/prompting/prompting.proto +++ b/MutagenSdk/Proto/service/prompting/prompting.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/prompting/prompting.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/prompting/prompting.proto * * MIT License * diff --git a/MutagenSdk/Proto/service/synchronization/synchronization.proto b/MutagenSdk/Proto/service/synchronization/synchronization.proto index 1e3d6b2..798a234 100644 --- a/MutagenSdk/Proto/service/synchronization/synchronization.proto +++ b/MutagenSdk/Proto/service/synchronization/synchronization.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/synchronization/synchronization.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/synchronization/synchronization.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/compression/algorithm.proto b/MutagenSdk/Proto/synchronization/compression/algorithm.proto index 96f972c..e08d1e2 100644 --- a/MutagenSdk/Proto/synchronization/compression/algorithm.proto +++ b/MutagenSdk/Proto/synchronization/compression/algorithm.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/compression/algorithm.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/compression/algorithm.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/configuration.proto b/MutagenSdk/Proto/synchronization/configuration.proto index 3ba7fdc..8906377 100644 --- a/MutagenSdk/Proto/synchronization/configuration.proto +++ b/MutagenSdk/Proto/synchronization/configuration.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/configuration.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/configuration.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/change.proto b/MutagenSdk/Proto/synchronization/core/change.proto index 3779a25..02ce07c 100644 --- a/MutagenSdk/Proto/synchronization/core/change.proto +++ b/MutagenSdk/Proto/synchronization/core/change.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/change.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/change.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/conflict.proto b/MutagenSdk/Proto/synchronization/core/conflict.proto index ea46bef..ea0cf4e 100644 --- a/MutagenSdk/Proto/synchronization/core/conflict.proto +++ b/MutagenSdk/Proto/synchronization/core/conflict.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/conflict.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/conflict.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/entry.proto b/MutagenSdk/Proto/synchronization/core/entry.proto index 3b937a3..465396e 100644 --- a/MutagenSdk/Proto/synchronization/core/entry.proto +++ b/MutagenSdk/Proto/synchronization/core/entry.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/entry.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/entry.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto b/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto index 9a347c8..bc3fdfb 100644 --- a/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto +++ b/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/ignore/ignore_vcs_mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/ignore_vcs_mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto b/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto index 7db94d9..d7ba811 100644 --- a/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto +++ b/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/ignore/syntax.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/syntax.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/mode.proto b/MutagenSdk/Proto/synchronization/core/mode.proto index 56fbea9..2a7fffb 100644 --- a/MutagenSdk/Proto/synchronization/core/mode.proto +++ b/MutagenSdk/Proto/synchronization/core/mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/permissions_mode.proto b/MutagenSdk/Proto/synchronization/core/permissions_mode.proto index e16648f..2287fc7 100644 --- a/MutagenSdk/Proto/synchronization/core/permissions_mode.proto +++ b/MutagenSdk/Proto/synchronization/core/permissions_mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/permissions_mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/permissions_mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/problem.proto b/MutagenSdk/Proto/synchronization/core/problem.proto index d58dec1..a69470d 100644 --- a/MutagenSdk/Proto/synchronization/core/problem.proto +++ b/MutagenSdk/Proto/synchronization/core/problem.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/problem.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/problem.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto b/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto index 31bee64..b7e623d 100644 --- a/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto +++ b/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/symbolic_link_mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/symbolic_link_mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/hashing/algorithm.proto b/MutagenSdk/Proto/synchronization/hashing/algorithm.proto index 1cb2fa1..5b1983c 100644 --- a/MutagenSdk/Proto/synchronization/hashing/algorithm.proto +++ b/MutagenSdk/Proto/synchronization/hashing/algorithm.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/hashing/algorithm.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/hashing/algorithm.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/rsync/receive.proto b/MutagenSdk/Proto/synchronization/rsync/receive.proto index 7d6b3f2..1c14879 100644 --- a/MutagenSdk/Proto/synchronization/rsync/receive.proto +++ b/MutagenSdk/Proto/synchronization/rsync/receive.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/rsync/receive.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/rsync/receive.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/scan_mode.proto b/MutagenSdk/Proto/synchronization/scan_mode.proto index de1777f..4a612b3 100644 --- a/MutagenSdk/Proto/synchronization/scan_mode.proto +++ b/MutagenSdk/Proto/synchronization/scan_mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/scan_mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/scan_mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/session.proto b/MutagenSdk/Proto/synchronization/session.proto index c23985f..04b177e 100644 --- a/MutagenSdk/Proto/synchronization/session.proto +++ b/MutagenSdk/Proto/synchronization/session.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/session.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/session.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/stage_mode.proto b/MutagenSdk/Proto/synchronization/stage_mode.proto index 247e0a9..9da274e 100644 --- a/MutagenSdk/Proto/synchronization/stage_mode.proto +++ b/MutagenSdk/Proto/synchronization/stage_mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/stage_mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/stage_mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/state.proto b/MutagenSdk/Proto/synchronization/state.proto index 24d7e3f..fc62518 100644 --- a/MutagenSdk/Proto/synchronization/state.proto +++ b/MutagenSdk/Proto/synchronization/state.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/state.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/state.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/version.proto b/MutagenSdk/Proto/synchronization/version.proto index 92a8c62..08743c4 100644 --- a/MutagenSdk/Proto/synchronization/version.proto +++ b/MutagenSdk/Proto/synchronization/version.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/version.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/version.proto * * MIT License * diff --git a/MutagenSdk/Proto/synchronization/watch_mode.proto b/MutagenSdk/Proto/synchronization/watch_mode.proto index 624aa0b..b321a18 100644 --- a/MutagenSdk/Proto/synchronization/watch_mode.proto +++ b/MutagenSdk/Proto/synchronization/watch_mode.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/watch_mode.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/watch_mode.proto * * MIT License * diff --git a/MutagenSdk/Proto/url/url.proto b/MutagenSdk/Proto/url/url.proto index d514f5b..95340e9 100644 --- a/MutagenSdk/Proto/url/url.proto +++ b/MutagenSdk/Proto/url/url.proto @@ -1,6 +1,6 @@ /* * This file was taken from - * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/url/url.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/url/url.proto * * MIT License * diff --git a/MutagenSdk/Update-Proto.ps1 b/MutagenSdk/Update-Proto.ps1 index 33e69e6..eb9992b 100644 --- a/MutagenSdk/Update-Proto.ps1 +++ b/MutagenSdk/Update-Proto.ps1 @@ -6,7 +6,7 @@ param ( $ErrorActionPreference = "Stop" -$repo = "mutagen-io/mutagen" +$repo = "coder/mutagen" $protoPrefix = "pkg" $entryFiles = @( "service/daemon/daemon.proto", @@ -24,7 +24,7 @@ if (Test-Path $cloneDir) { Push-Location $cloneDir try { & git.exe clean -fdx - if ($LASTEXITCODE -ne 0) { throw "Failed to clean $mutagenTag" } + if ($LASTEXITCODE -ne 0) { throw "Failed to clean $cloneDir" } # If we're already on the tag, we don't need to fetch or checkout. if ((& git.exe name-rev --name-only HEAD) -eq "tags/$mutagenTag") { Write-Host "Already on $mutagenTag" @@ -96,6 +96,7 @@ foreach ($entryFile in $entryFiles) { $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Push-Location $repoRoot +$outputDir = Resolve-Path $outputDir if (Test-Path $outputDir) { Remove-Item -Recurse -Force $outputDir } diff --git a/Tests.App/Services/CredentialManagerTest.cs b/Tests.App/Services/CredentialManagerTest.cs index 2fa4699..9d00cf2 100644 --- a/Tests.App/Services/CredentialManagerTest.cs +++ b/Tests.App/Services/CredentialManagerTest.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; -using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Moq; namespace Coder.Desktop.Tests.App.Services; diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs index 1605f1c..2c97515 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -112,6 +112,14 @@ public async Task Ok(CancellationToken ct) // Ensure the daemon is stopped because all sessions are terminated. await AssertDaemonStopped(dataDirectory, ct); + var progressMessages = new List(); + + void OnProgress(string message) + { + TestContext.Out.WriteLine("Create session progress: " + message); + progressMessages.Add(message); + } + var session1 = await controller.CreateSyncSession(new CreateSyncSessionRequest { Alpha = new CreateSyncSessionRequest.Endpoint @@ -124,7 +132,10 @@ public async Task Ok(CancellationToken ct) Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, - }, ct); + }, OnProgress, ct); + + // There should've been at least one progress message. + Assert.That(progressMessages, Is.Not.Empty); state = controller.GetState(); Assert.That(state.SyncSessions, Has.Count.EqualTo(1)); @@ -142,7 +153,7 @@ public async Task Ok(CancellationToken ct) Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, - }, ct); + }, null, ct); state = controller.GetState(); Assert.That(state.SyncSessions, Has.Count.EqualTo(2)); @@ -225,7 +236,7 @@ await controller.CreateSyncSession(new CreateSyncSessionRequest Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, - }, ct); + }, null, ct); } await AssertDaemonStopped(dataDirectory, ct); @@ -265,7 +276,7 @@ await controller1.CreateSyncSession(new CreateSyncSessionRequest Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, - }, ct); + }, null, ct); controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory); await controller2.RefreshState(ct); 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 + + diff --git a/Tests.Vpn.Service/DownloaderTest.cs b/Tests.Vpn.Service/DownloaderTest.cs index 985e331..986ce46 100644 --- a/Tests.Vpn.Service/DownloaderTest.cs +++ b/Tests.Vpn.Service/DownloaderTest.cs @@ -284,6 +284,34 @@ public async Task Download(CancellationToken ct) Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test")); } + [Test(Description = "Perform 2 downloads with the same destination")] + [CancelAfter(30_000)] + public async Task DownloadSameDest(CancellationToken ct) + { + using var httpServer = EchoServer(); + var url0 = new Uri(httpServer.BaseUrl + "/test0"); + var url1 = new Uri(httpServer.BaseUrl + "/test1"); + var destPath = Path.Combine(_tempDir, "test"); + + var manager = new Downloader(NullLogger.Instance); + var startTask0 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url0), destPath, + NullDownloadValidator.Instance, ct); + var startTask1 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url1), destPath, + NullDownloadValidator.Instance, ct); + var dlTask0 = await startTask0; + await dlTask0.Task; + Assert.That(dlTask0.TotalBytes, Is.EqualTo(5)); + Assert.That(dlTask0.BytesRead, Is.EqualTo(5)); + Assert.That(dlTask0.Progress, Is.EqualTo(1)); + Assert.That(dlTask0.IsCompleted, Is.True); + var dlTask1 = await startTask1; + await dlTask1.Task; + Assert.That(dlTask1.TotalBytes, Is.EqualTo(5)); + Assert.That(dlTask1.BytesRead, Is.EqualTo(5)); + Assert.That(dlTask1.Progress, Is.EqualTo(1)); + Assert.That(dlTask1.IsCompleted, Is.True); + } + [Test(Description = "Download with custom headers")] [CancelAfter(30_000)] public async Task WithHeaders(CancellationToken ct) @@ -347,17 +375,17 @@ public async Task DownloadExistingDifferentContent(CancellationToken ct) [Test(Description = "Unexpected response code from server")] [CancelAfter(30_000)] - public void UnexpectedResponseCode(CancellationToken ct) + public async Task UnexpectedResponseCode(CancellationToken ct) { using var httpServer = new TestHttpServer(ctx => { ctx.Response.StatusCode = 404; }); var url = new Uri(httpServer.BaseUrl + "/test"); var destPath = Path.Combine(_tempDir, "test"); var manager = new Downloader(NullLogger.Instance); - // The "outer" Task should fail. - var ex = Assert.ThrowsAsync(async () => - await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - NullDownloadValidator.Instance, ct)); + // The "inner" Task should fail. + var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, + NullDownloadValidator.Instance, ct); + var ex = Assert.ThrowsAsync(async () => await dlTask.Task); Assert.That(ex.Message, Does.Contain("404")); } @@ -384,43 +412,58 @@ public async Task MismatchedETag(CancellationToken ct) Assert.That(ex.Message, Does.Contain("ETag does not match SHA1 hash of downloaded file").And.Contains("beef")); } - [Test(Description = "Timeout on response headers")] + [Test(Description = "Timeout waiting for existing download")] [CancelAfter(30_000)] - public void CancelledOuter(CancellationToken ct) + public async Task CancelledWaitingForOther(CancellationToken ct) { - using var httpServer = new TestHttpServer(async _ => { await Task.Delay(TimeSpan.FromSeconds(5), ct); }); - var url = new Uri(httpServer.BaseUrl + "/test"); + var testCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + using var httpServer = new TestHttpServer(async _ => + { + await Task.Delay(TimeSpan.FromSeconds(5), testCts.Token); + }); + var url0 = new Uri(httpServer.BaseUrl + "/test0"); + var url1 = new Uri(httpServer.BaseUrl + "/test1"); var destPath = Path.Combine(_tempDir, "test"); - var manager = new Downloader(NullLogger.Instance); - // The "outer" Task should fail. + + // first outer task succeeds, getting download started + var dlTask0 = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url0), destPath, + NullDownloadValidator.Instance, testCts.Token); + + // The second request fails if the timeout is short var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token; - Assert.ThrowsAsync( - async () => await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - NullDownloadValidator.Instance, smallerCt)); + Assert.ThrowsAsync(async () => await manager.StartDownloadAsync( + new HttpRequestMessage(HttpMethod.Get, url1), destPath, + NullDownloadValidator.Instance, smallerCt)); + await testCts.CancelAsync(); } [Test(Description = "Timeout on response body")] [CancelAfter(30_000)] public async Task CancelledInner(CancellationToken ct) { + var httpCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var taskCts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var httpServer = new TestHttpServer(async ctx => { ctx.Response.StatusCode = 200; - await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct); - await ctx.Response.OutputStream.FlushAsync(ct); - await Task.Delay(TimeSpan.FromSeconds(5), ct); + await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), httpCts.Token); + await ctx.Response.OutputStream.FlushAsync(httpCts.Token); + // wait up to 5 seconds. + await Task.Delay(TimeSpan.FromSeconds(5), httpCts.Token); }); var url = new Uri(httpServer.BaseUrl + "/test"); var destPath = Path.Combine(_tempDir, "test"); var manager = new Downloader(NullLogger.Instance); // The "inner" Task should fail. - var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token; + var taskCt = taskCts.Token; var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - NullDownloadValidator.Instance, smallerCt); + NullDownloadValidator.Instance, taskCt); + await taskCts.CancelAsync(); var ex = Assert.ThrowsAsync(async () => await dlTask.Task); - Assert.That(ex.CancellationToken, Is.EqualTo(smallerCt)); + Assert.That(ex.CancellationToken, Is.EqualTo(taskCt)); + await httpCts.CancelAsync(); } [Test(Description = "Validation failure")] @@ -451,12 +494,10 @@ public async Task ValidationFailureExistingFile(CancellationToken ct) await File.WriteAllTextAsync(destPath, "test", ct); var manager = new Downloader(NullLogger.Instance); - // The "outer" Task should fail because the inner task never starts. - var ex = Assert.ThrowsAsync(async () => - { - await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - new TestDownloadValidator(new Exception("test exception")), ct); - }); + var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, + new TestDownloadValidator(new Exception("test exception")), ct); + // The "inner" Task should fail. + var ex = Assert.ThrowsAsync(async () => { await dlTask.Task; }); Assert.That(ex.Message, Does.Contain("Existing file failed validation")); Assert.That(ex.InnerException, Is.Not.Null); Assert.That(ex.InnerException!.Message, Is.EqualTo("test exception")); diff --git a/Tests.Vpn.Service/packages.lock.json b/Tests.Vpn.Service/packages.lock.json index 7ba4c03..08a9b56 100644 --- a/Tests.Vpn.Service/packages.lock.json +++ b/Tests.Vpn.Service/packages.lock.json @@ -48,305 +48,314 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "resolved": "9.0.4", + "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "resolved": "9.0.4", + "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", + "resolved": "9.0.4", + "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", + "resolved": "9.0.4", + "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", + "resolved": "9.0.4", + "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", + "resolved": "9.0.4", + "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", + "resolved": "9.0.4", + "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", + "resolved": "9.0.4", + "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", + "resolved": "9.0.4", + "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==", + "dependencies": { + "System.Text.Encodings.Web": "9.0.0", + "System.Text.Json": "9.0.0" + } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", + "resolved": "9.0.4", + "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", + "resolved": "9.0.4", + "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==" + "resolved": "9.0.4", + "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" }, "Microsoft.Extensions.Hosting": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Logging.Console": "9.0.1", - "Microsoft.Extensions.Logging.Debug": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "Microsoft.Extensions.Logging.EventSource": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "resolved": "9.0.4", + "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.Configuration.CommandLine": "9.0.4", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4", + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Logging.Console": "9.0.4", + "Microsoft.Extensions.Logging.Debug": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "Microsoft.Extensions.Logging.EventSource": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", + "resolved": "9.0.4", + "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Hosting.WindowsServices": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "FLapgOXQzPjUsbMqjjagCFCiGjroRmrmHQVK3/PEovRIvDU6nLk7KKs4PalzEHaIfqG+PySlY/BeLTyZtjcshg==", + "resolved": "9.0.4", + "contentHash": "QFeUS0NG4Kwq91Mf1WzVZSbBtw+nKxyOQTi4xTRUEQ2gC7HWiyCUiX0arMJxt9lWwbjXxQY9TQjDptm+ct7BkQ==", "dependencies": { - "Microsoft.Extensions.Hosting": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "System.ServiceProcess.ServiceController": "9.0.1" + "Microsoft.Extensions.Hosting": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "System.ServiceProcess.ServiceController": "9.0.4" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", + "resolved": "9.0.4", + "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", + "resolved": "9.0.4", + "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", + "resolved": "9.0.4", + "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", + "resolved": "9.0.4", + "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", + "resolved": "9.0.4", + "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.EventLog": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.EventLog": "9.0.4" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", + "resolved": "9.0.4", + "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", + "resolved": "9.0.4", + "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.DataAnnotations": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "T16k12gDWOoi9W/oueC7knsZxm3ZjqmrQBFLXx9UH3Kv4fbehMyiOdhi5u1Vw7M4g0uMj21InBfgDE0570byEQ==", + "resolved": "9.0.4", + "contentHash": "jJq7xO1PLi//cts59Yp6dKNN07xV0Day/JmVR7aXCdo2rYHAoFlyARyxrfB0CTzsErA+TOhYTz2Ee0poR8SPeQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Microsoft.Security.Extensions": { "type": "Transitive", @@ -409,6 +418,16 @@ "Serilog": "4.2.0" } }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyModel": "9.0.0", + "Serilog": "4.2.0" + } + }, "Serilog.Sinks.Console": { "type": "Transitive", "resolved": "6.0.0", @@ -427,18 +446,18 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==" + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==" + "resolved": "9.0.4", + "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA==" }, "System.Reflection.Metadata": { "type": "Transitive", @@ -447,24 +466,24 @@ }, "System.ServiceProcess.ServiceController": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Ghm4yP29P3cC65Qof8CrgU3WO/q3ERtht6/CrvcUl1FgRs6D7exj75GuG4ciRv0sjygtvyd675924DFsxxnEgA==", + "resolved": "9.0.4", + "contentHash": "j6Z+ED1d/yxe/Cm+UlFf+LNw2HSYBSgtFh71KnEEmUtHIwgoTVQxji5URvXPOAZ7iuKHItjMIzpCLyRZc8OmrQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.1" + "System.Diagnostics.EventLog": "9.0.4" } }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" }, "System.Text.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", + "resolved": "9.0.4", + "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==", "dependencies": { - "System.IO.Pipelines": "9.0.1", - "System.Text.Encodings.Web": "9.0.1" + "System.IO.Pipelines": "9.0.4", + "System.Text.Encodings.Web": "9.0.4" } }, "Coder.Desktop.CoderSdk": { @@ -490,12 +509,13 @@ "dependencies": { "Coder.Desktop.CoderSdk": "[1.0.0, )", "Coder.Desktop.Vpn": "[1.0.0, )", - "Microsoft.Extensions.Hosting": "[9.0.1, )", - "Microsoft.Extensions.Hosting.WindowsServices": "[9.0.1, )", - "Microsoft.Extensions.Options.DataAnnotations": "[9.0.1, )", + "Microsoft.Extensions.Hosting": "[9.0.4, )", + "Microsoft.Extensions.Hosting.WindowsServices": "[9.0.4, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.4, )", "Microsoft.Security.Extensions": "[1.3.0, )", "Semver": "[3.0.0, )", "Serilog.Extensions.Hosting": "[9.0.0, )", + "Serilog.Settings.Configuration": "[9.0.0, )", "Serilog.Sinks.Console": "[6.0.0, )", "Serilog.Sinks.File": "[6.0.0, )" } diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs index a37a1ec..6a3108b 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -3,6 +3,7 @@ using System.Formats.Asn1; using System.Net; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Coder.Desktop.Vpn.Utilities; @@ -286,9 +287,24 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin { while (true) { + ct.ThrowIfCancellationRequested(); var task = _downloads.GetOrAdd(destinationPath, _ => new DownloadTask(_logger, req, destinationPath, validator)); - await task.EnsureStartedAsync(ct); + // EnsureStarted is a no-op if we didn't create a new DownloadTask. + // So, we will only remove the destination once for each time we start a new task. + task.EnsureStarted(tsk => + { + // remove the key first, before checking the exception, to ensure + // we still clean up. + _downloads.TryRemove(destinationPath, out _); + if (tsk.Exception == null) return; + + if (tsk.Exception.InnerException != null) + ExceptionDispatchInfo.Capture(tsk.Exception.InnerException).Throw(); + + // not sure if this is hittable, but just in case: + throw tsk.Exception; + }, ct); // If the existing (or new) task is for the same URL, return it. if (task.Request.RequestUri == req.RequestUri) @@ -302,7 +318,22 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin _logger.LogWarning( "Download for '{DestinationPath}' is already in progress, but is for a different Url - awaiting completion", destinationPath); - await task.Task; + await TaskOrCancellation(task.Task, ct); + } + } + + /// + /// 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 + await completedTask; } } } @@ -357,13 +388,11 @@ internal DownloadTask(ILogger logger, HttpRequestMessage req, string destination ".download-" + Path.GetRandomFileName()); } - internal async Task EnsureStartedAsync(CancellationToken ct = default) + internal void EnsureStarted(Action continuation, CancellationToken ct = default) { - using var _ = await _semaphore.LockAsync(ct); + using var _ = _semaphore.Lock(); if (Task == null!) - Task = await StartDownloadAsync(ct); - - return Task; + Task = Start(ct).ContinueWith(continuation, ct); } /// @@ -371,7 +400,7 @@ internal async Task EnsureStartedAsync(CancellationToken ct = default) /// and the download will continue in the background. The provided CancellationToken can be used to cancel the /// download. /// - private async Task StartDownloadAsync(CancellationToken ct = default) + private async Task Start(CancellationToken ct = default) { Directory.CreateDirectory(_destinationDirectory); @@ -398,8 +427,7 @@ private async Task StartDownloadAsync(CancellationToken ct = default) throw new Exception("Existing file failed validation after 304 Not Modified", e); } - Task = Task.CompletedTask; - return Task; + return; } if (res.StatusCode != HttpStatusCode.OK) @@ -420,27 +448,25 @@ private async Task StartDownloadAsync(CancellationToken ct = default) if (res.Content.Headers.ContentLength >= 0) TotalBytes = (ulong)res.Content.Headers.ContentLength; - FileStream tempFile; - try - { - tempFile = File.Create(TempDestinationPath, BufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath); - throw; - } - - Task = DownloadAsync(res, tempFile, ct); - return Task; + await Download(res, ct); } - private async Task DownloadAsync(HttpResponseMessage res, FileStream tempFile, CancellationToken ct) + private async Task Download(HttpResponseMessage res, CancellationToken ct) { try { var sha1 = res.Headers.Contains("ETag") ? SHA1.Create() : null; + FileStream tempFile; + try + { + tempFile = File.Create(TempDestinationPath, BufferSize, FileOptions.SequentialScan); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath); + throw; + } + await using (tempFile) { var stream = await res.Content.ReadAsStreamAsync(ct); diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 1eca8bf..fc014c0 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -1,5 +1,5 @@ using System.Runtime.InteropServices; -using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Proto; using Coder.Desktop.Vpn.Utilities; using Microsoft.Extensions.Logging; diff --git a/Vpn.Service/ManagerConfig.cs b/Vpn.Service/ManagerConfig.cs index c7f8863..c60b1b8 100644 --- a/Vpn.Service/ManagerConfig.cs +++ b/Vpn.Service/ManagerConfig.cs @@ -15,8 +15,6 @@ public class ManagerConfig [Required] public string TunnelBinaryPath { get; set; } = @"C:\coder-vpn.exe"; - [Required] public string LogFileLocation { get; set; } = @"C:\coder-desktop-service.log"; - // If empty, signatures will not be verified. [Required] public string TunnelBinarySignatureSigner { get; set; } = "Coder Technologies Inc."; diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index 69b6ea8..fc61247 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Win32; using Serilog; +using ILogger = Serilog.ILogger; namespace Coder.Desktop.Vpn.Service; @@ -14,29 +15,22 @@ public static class Program // installer. #if !DEBUG private const string ServiceName = "Coder Desktop"; - private const string ManagerConfigSection = "Manager"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\VpnService"; #else // This value matches Create-Service.ps1. private const string ServiceName = "Coder Desktop (Debug)"; - private const string ManagerConfigSection = "DebugManager"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugVpnService"; #endif - private const string ConsoleOutputTemplate = - "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"; - - private const string FileOutputTemplate = - "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"; + private const string ManagerConfigSection = "Manager"; private static ILogger MainLogger => Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program"); - private static LoggerConfiguration BaseLogConfig => new LoggerConfiguration() - .Enrich.FromLogContext() - .MinimumLevel.Debug() - .WriteTo.Console(outputTemplate: ConsoleOutputTemplate); - public static async Task Main(string[] args) { - Log.Logger = BaseLogConfig.CreateLogger(); + // This logger will only be used until we load our full logging configuration and replace it. + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console() + .CreateLogger(); MainLogger.Information("Application is starting"); try { @@ -58,27 +52,26 @@ public static async Task Main(string[] args) private static async Task BuildAndRun(string[] args) { var builder = Host.CreateApplicationBuilder(args); + var configBuilder = builder.Configuration as IConfigurationBuilder; // Configuration sources builder.Configuration.Sources.Clear(); - (builder.Configuration as IConfigurationBuilder).Add( - new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder Desktop")); + AddDefaultConfig(configBuilder); + configBuilder.Add( + new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey)); builder.Configuration.AddEnvironmentVariables("CODER_MANAGER_"); builder.Configuration.AddCommandLine(args); // Options types (these get registered as IOptions singletons) builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(ManagerConfigSection)) - .ValidateDataAnnotations() - .PostConfigure(config => - { - Log.Logger = BaseLogConfig - .WriteTo.File(config.LogFileLocation, outputTemplate: FileOutputTemplate) - .CreateLogger(); - }); + .ValidateDataAnnotations(); // Logging - builder.Services.AddSerilog(); + builder.Services.AddSerilog((_, loggerConfig) => + { + loggerConfig.ReadFrom.Configuration(builder.Configuration); + }); // Singletons builder.Services.AddSingleton(); @@ -101,6 +94,32 @@ private static async Task BuildAndRun(string[] args) builder.Services.AddHostedService(); builder.Services.AddHostedService(); - await builder.Build().RunAsync(); + var host = builder.Build(); + Log.Logger = (ILogger)host.Services.GetService(typeof(ILogger))!; + MainLogger.Information("Application is starting"); + + await host.RunAsync(); + } + + private static void AddDefaultConfig(IConfigurationBuilder builder) + { + builder.AddInMemoryCollection(new Dictionary + { + ["Serilog:Using:0"] = "Serilog.Sinks.File", + ["Serilog:Using:1"] = "Serilog.Sinks.Console", + + ["Serilog:MinimumLevel"] = "Information", + ["Serilog:Enrich:0"] = "FromLogContext", + + ["Serilog:WriteTo:0:Name"] = "File", + ["Serilog:WriteTo:0:Args:path"] = @"C:\coder-desktop-service.log", + ["Serilog:WriteTo:0:Args:outputTemplate"] = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", + ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day", + + ["Serilog:WriteTo:1:Name"] = "Console", + ["Serilog:WriteTo:1:Args:outputTemplate"] = + "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", + }); } } diff --git a/Vpn.Service/Vpn.Service.csproj b/Vpn.Service/Vpn.Service.csproj index acaeb3c..aaed3cc 100644 --- a/Vpn.Service/Vpn.Service.csproj +++ b/Vpn.Service/Vpn.Service.csproj @@ -25,12 +25,13 @@ - - - + + + + diff --git a/Vpn.Service/packages.lock.json b/Vpn.Service/packages.lock.json index fb4185a..09c7b76 100644 --- a/Vpn.Service/packages.lock.json +++ b/Vpn.Service/packages.lock.json @@ -4,53 +4,53 @@ "net8.0-windows7.0": { "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Logging.Console": "9.0.1", - "Microsoft.Extensions.Logging.Debug": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "Microsoft.Extensions.Logging.EventSource": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.Configuration.CommandLine": "9.0.4", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4", + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Logging.Console": "9.0.4", + "Microsoft.Extensions.Logging.Debug": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "Microsoft.Extensions.Logging.EventSource": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Hosting.WindowsServices": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "FLapgOXQzPjUsbMqjjagCFCiGjroRmrmHQVK3/PEovRIvDU6nLk7KKs4PalzEHaIfqG+PySlY/BeLTyZtjcshg==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "QFeUS0NG4Kwq91Mf1WzVZSbBtw+nKxyOQTi4xTRUEQ2gC7HWiyCUiX0arMJxt9lWwbjXxQY9TQjDptm+ct7BkQ==", "dependencies": { - "Microsoft.Extensions.Hosting": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "System.ServiceProcess.ServiceController": "9.0.1" + "Microsoft.Extensions.Hosting": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "System.ServiceProcess.ServiceController": "9.0.4" } }, "Microsoft.Extensions.Options.DataAnnotations": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "T16k12gDWOoi9W/oueC7knsZxm3ZjqmrQBFLXx9UH3Kv4fbehMyiOdhi5u1Vw7M4g0uMj21InBfgDE0570byEQ==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "jJq7xO1PLi//cts59Yp6dKNN07xV0Day/JmVR7aXCdo2rYHAoFlyARyxrfB0CTzsErA+TOhYTz2Ee0poR8SPeQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Security.Extensions": { @@ -81,6 +81,17 @@ "Serilog.Extensions.Logging": "9.0.0" } }, + "Serilog.Settings.Configuration": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyModel": "9.0.0", + "Serilog": "4.2.0" + } + }, "Serilog.Sinks.Console": { "type": "Direct", "requested": "[6.0.0, )", @@ -106,257 +117,266 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "resolved": "9.0.4", + "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "resolved": "9.0.4", + "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", + "resolved": "9.0.4", + "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", + "resolved": "9.0.4", + "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", + "resolved": "9.0.4", + "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", + "resolved": "9.0.4", + "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", + "resolved": "9.0.4", + "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", + "resolved": "9.0.4", + "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", + "resolved": "9.0.4", + "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==", + "dependencies": { + "System.Text.Encodings.Web": "9.0.0", + "System.Text.Json": "9.0.0" + } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", + "resolved": "9.0.4", + "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", + "resolved": "9.0.4", + "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==" + "resolved": "9.0.4", + "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", + "resolved": "9.0.4", + "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", + "resolved": "9.0.4", + "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", + "resolved": "9.0.4", + "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", + "resolved": "9.0.4", + "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", + "resolved": "9.0.4", + "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", + "resolved": "9.0.4", + "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.EventLog": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.EventLog": "9.0.4" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", + "resolved": "9.0.4", + "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", + "resolved": "9.0.4", + "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Serilog": { "type": "Transitive", @@ -374,39 +394,39 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==" + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==" + "resolved": "9.0.4", + "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA==" }, "System.ServiceProcess.ServiceController": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Ghm4yP29P3cC65Qof8CrgU3WO/q3ERtht6/CrvcUl1FgRs6D7exj75GuG4ciRv0sjygtvyd675924DFsxxnEgA==", + "resolved": "9.0.4", + "contentHash": "j6Z+ED1d/yxe/Cm+UlFf+LNw2HSYBSgtFh71KnEEmUtHIwgoTVQxji5URvXPOAZ7iuKHItjMIzpCLyRZc8OmrQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.1" + "System.Diagnostics.EventLog": "9.0.4" } }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" }, "System.Text.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", + "resolved": "9.0.4", + "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==", "dependencies": { - "System.IO.Pipelines": "9.0.1", - "System.Text.Encodings.Web": "9.0.1" + "System.IO.Pipelines": "9.0.4", + "System.Text.Encodings.Web": "9.0.4" } }, "Coder.Desktop.CoderSdk": { diff --git a/scripts/Get-Mutagen.ps1 b/scripts/Get-Mutagen.ps1 index c540809..8689377 100644 --- a/scripts/Get-Mutagen.ps1 +++ b/scripts/Get-Mutagen.ps1 @@ -31,7 +31,7 @@ $goArch = switch ($arch) { # Download the mutagen binary from our bucket for this platform if we don't have # it yet (or it's different). -$mutagenVersion = "v0.18.1" +$mutagenVersion = "v0.18.3" $mutagenPath = Join-Path $PSScriptRoot "files\mutagen-windows-$($arch).exe" $mutagenUrl = "https://storage.googleapis.com/coder-desktop/mutagen/$($mutagenVersion)/mutagen-windows-$($goArch).exe" $mutagenEtagFile = $mutagenPath + ".etag"