From 75cdfd0f3d876a84c80ebc9e5f3bb312576f86e3 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Wed, 30 Apr 2025 12:09:11 +1000 Subject: [PATCH 01/16] feat: add create progress to file sync window (#74) https://github.com/user-attachments/assets/96d57799-0db8-4146-a93c-c157645cb4e9 --- App/Services/MutagenController.cs | 12 +++++++++-- App/ViewModels/FileSyncListViewModel.cs | 22 +++++++++++++++++++-- App/Views/Pages/FileSyncListMainPage.xaml | 14 +++++++++++-- Tests.App/Services/MutagenControllerTest.cs | 18 +++++++++++++---- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index dd489df..3a68962 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -85,7 +85,7 @@ 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); @@ -200,12 +200,15 @@ 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, @@ -603,6 +606,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 +689,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/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 7fdd881..d01338c 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -67,6 +67,9 @@ public partial class FileSyncListViewModel : ObservableObject public partial string NewSessionRemotePath { get; set; } = ""; // TODO: NewSessionRemotePathDialogOpen for remote path + [ObservableProperty] + public partial string NewSessionStatus { get; set; } = ""; + public bool NewSessionCreateEnabled { get @@ -187,6 +190,7 @@ private void ClearNewForm() NewSessionLocalPath = ""; NewSessionRemoteHost = ""; NewSessionRemotePath = ""; + NewSessionStatus = ""; } [RelayCommand] @@ -263,13 +267,26 @@ 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. @@ -286,7 +303,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest Host = NewSessionRemoteHost, Path = NewSessionRemotePath, }, - }, cts.Token); + }, OnCreateSessionProgress, cts.Token); ClearNewForm(); } @@ -304,6 +321,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest finally { OperationInProgress = false; + NewSessionStatus = ""; } } diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index d38bc29..5a96898 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -274,8 +274,11 @@ - - + + @@ -340,6 +343,13 @@ HorizontalAlignment="Stretch" Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" /> + + + diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs index 1605f1c..c834009 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -112,6 +112,13 @@ 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 +131,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 +152,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 +235,7 @@ await controller.CreateSyncSession(new CreateSyncSessionRequest Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, Path = betaDirectory.FullName, }, - }, ct); + }, null, ct); } await AssertDaemonStopped(dataDirectory, ct); @@ -265,7 +275,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); From e5d9dc1ad9f2a2ef25b25cec7be84fcfe2f64699 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 30 Apr 2025 15:17:43 +0400 Subject: [PATCH 02/16] fix: fix Downloader to dispose tempFile and use synchronous IO (#81) Fixes https://github.com/coder/internal/issues/598 There is a possible race where if the cancellation token is expired, `Download()` never gets called and the tempFile is never disposed of (at least until GC). We also switch to synchronous IO so that a pending overlapped write won't block the deletion. These issues can cause races in our tests when we try to clean up the directory. --- Tests.Vpn.Service/DownloaderTest.cs | 17 +++++++++++------ Vpn.Service/Downloader.cs | 26 ++++++++++++-------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Tests.Vpn.Service/DownloaderTest.cs b/Tests.Vpn.Service/DownloaderTest.cs index b30e3e4..986ce46 100644 --- a/Tests.Vpn.Service/DownloaderTest.cs +++ b/Tests.Vpn.Service/DownloaderTest.cs @@ -442,23 +442,28 @@ public async Task CancelledWaitingForOther(CancellationToken ct) [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")] diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs index 467c9af..c7b94c6 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -453,27 +453,25 @@ private async Task Start(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; - } - - await Download(res, tempFile, ct); + await Download(res, ct); return; } - private async Task Download(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); From b803aa1ca06b948ccea7dd7af2860ad5e294a154 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Wed, 30 Apr 2025 15:25:18 +0400 Subject: [PATCH 03/16] feat: add logging to App (#78) Adds logging to the Coder Desktop application. --- App/App.csproj | 9 +- App/App.xaml.cs | 94 ++++-- App/packages.lock.json | 420 +++++++++++++++------------ Installer/Installer.csproj | 1 + Installer/Program.cs | 26 +- Tests.Vpn.Service/packages.lock.json | 384 ++++++++++++------------ Vpn.Service/ManagerConfig.cs | 2 - Vpn.Service/Program.cs | 67 +++-- Vpn.Service/Vpn.Service.csproj | 7 +- Vpn.Service/packages.lock.json | 384 ++++++++++++------------ 10 files changed, 788 insertions(+), 606 deletions(-) diff --git a/App/App.csproj b/App/App.csproj index 2a15166..4d049fd 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -61,10 +61,13 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 44f59b6..c6f22b4 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -16,6 +16,9 @@ using Microsoft.Win32; using Microsoft.Windows.AppLifecycle; using Windows.ApplicationModel.Activation; +using Microsoft.Extensions.Logging; +using Serilog; +using System.Collections.Generic; namespace Coder.Desktop.App; @@ -24,22 +27,39 @@ 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; + 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(); @@ -69,12 +89,14 @@ public App() services.AddTransient(); _services = services.BuildServiceProvider(); + _logger = (ILogger)(_services.GetService(typeof(ILogger))!); InitializeComponent(); } public async Task ExitApplication() { + _logger.LogDebug("exiting app"); _handleWindowClosed = false; Exit(); var syncController = _services.GetRequiredService(); @@ -87,36 +109,39 @@ public async Task ExitApplication() protected override void OnLaunched(Microsoft.UI.Xaml.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); @@ -125,10 +150,14 @@ protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs ar 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); @@ -148,17 +177,44 @@ public void OnActivated(object? sender, AppActivationArguments args) { case ExtendedActivationKind.Protocol: var protoArgs = args.Data as IProtocolActivatedEventArgs; + if (protoArgs == null) + { + _logger.LogWarning("URI activation with null data"); + return; + } + HandleURIActivation(protoArgs.Uri); break; default: - // TODO: log + _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind); break; } } public void HandleURIActivation(Uri uri) { - // TODO: handle + // don't log the query string as that's where we include some sensitive information like passwords + _logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath); + } + + 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/packages.lock.json b/App/packages.lock.json index 405ea61..5561686 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -28,51 +28,51 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", + "requested": "[9.0.4, )", + "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.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.Options": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "requested": "[9.0.4, )", + "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.WindowsAppSDK": { @@ -85,6 +85,39 @@ "Microsoft.Windows.SDK.BuildTools": "10.0.22621.756" } }, + "Serilog.Extensions.Hosting": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "u2TRxuxbjvTAldQn7uaAwePkWxTHIqlgjelekBtilAGL5sYyF3+65NWctN4UrwwGLsDC7c3Vz3HnOlu+PcoxXg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Serilog": "4.2.0", + "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.File": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==", + "dependencies": { + "Serilog": "4.0.0" + } + }, "WinUIEx": { "type": "Direct", "requested": "[2.5.1, )", @@ -139,240 +172,249 @@ }, "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.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.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==" }, "Microsoft.Web.WebView2": { "type": "Transitive", @@ -397,6 +439,20 @@ "Microsoft.Extensions.Primitives": "5.0.1" } }, + "Serilog": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA==" + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==", + "dependencies": { + "Microsoft.Extensions.Logging": "9.0.0", + "Serilog": "4.2.0" + } + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "9.0.0", @@ -404,13 +460,13 @@ }, "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.Drawing.Common": { "type": "Transitive", @@ -422,8 +478,8 @@ }, "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", @@ -435,16 +491,16 @@ }, "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": { @@ -496,13 +552,13 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" } }, "net8.0-windows10.0.19041/win-x64": { @@ -528,13 +584,13 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" } }, "net8.0-windows10.0.19041/win-x86": { @@ -560,13 +616,13 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" } } } 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..1894a2d 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using CommandLine; +using Microsoft.Extensions.Configuration; using WixSharp; using WixSharp.Bootstrapper; using WixSharp.CommonTasks; @@ -128,7 +130,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 +141,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 +263,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 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/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": { From e200dd4a6324c605a03694cdb9f6788850cf47d7 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 1 May 2025 12:34:55 +1000 Subject: [PATCH 04/16] feat: add remote directory picker to file sync (#73) Adds a new remote directory picker window used when creating a file sync to select the remote directory. https://github.com/user-attachments/assets/3c661969-4ba8-46b0-8e3c-e97809c2ae1d ## TODOs: - [x] Use a dropdown for picking workspace agent in the file sync UI, currently it's typed out (and will crash if empty lol) - [x] Fix reactivation of the window, try to make it function like any other system dialog window Closes #27 --- App/App.csproj | 1 + App/App.xaml.cs | 16 +- App/Converters/DependencyObjectSelector.cs | 4 + App/Program.cs | 2 +- App/Services/CredentialManager.cs | 1 + App/Services/MutagenController.cs | 15 +- App/ViewModels/DirectoryPickerViewModel.cs | 263 ++++++++++++++++++ App/ViewModels/FileSyncListViewModel.cs | 112 +++++++- App/ViewModels/TrayWindowViewModel.cs | 1 + App/Views/DirectoryPickerWindow.xaml | 20 ++ App/Views/DirectoryPickerWindow.xaml.cs | 93 +++++++ App/Views/FileSyncListWindow.xaml.cs | 2 +- App/Views/Pages/DirectoryPickerMainPage.xaml | 179 ++++++++++++ .../Pages/DirectoryPickerMainPage.xaml.cs | 27 ++ App/Views/Pages/FileSyncListMainPage.xaml | 69 +++-- App/Views/Pages/FileSyncListMainPage.xaml.cs | 14 +- App/packages.lock.json | 24 ++ CoderSdk/Agent/AgentApiClient.cs | 61 ++++ CoderSdk/Agent/ListDirectory.cs | 54 ++++ CoderSdk/Coder/CoderApiClient.cs | 71 +++++ CoderSdk/{ => Coder}/Deployment.cs | 2 +- CoderSdk/{ => Coder}/Users.cs | 2 +- CoderSdk/CoderApiClient.cs | 119 -------- CoderSdk/Errors.cs | 15 +- CoderSdk/JsonHttpClient.cs | 82 ++++++ Installer/Program.cs | 6 +- Tests.App/Services/CredentialManagerTest.cs | 2 +- Tests.App/Services/MutagenControllerTest.cs | 1 + Vpn.Service/Downloader.cs | 11 +- Vpn.Service/Manager.cs | 2 +- 30 files changed, 1078 insertions(+), 193 deletions(-) create mode 100644 App/ViewModels/DirectoryPickerViewModel.cs create mode 100644 App/Views/DirectoryPickerWindow.xaml create mode 100644 App/Views/DirectoryPickerWindow.xaml.cs create mode 100644 App/Views/Pages/DirectoryPickerMainPage.xaml create mode 100644 App/Views/Pages/DirectoryPickerMainPage.xaml.cs create mode 100644 CoderSdk/Agent/AgentApiClient.cs create mode 100644 CoderSdk/Agent/ListDirectory.cs create mode 100644 CoderSdk/Coder/CoderApiClient.cs rename CoderSdk/{ => Coder}/Deployment.cs (91%) rename CoderSdk/{ => Coder}/Users.cs (91%) delete mode 100644 CoderSdk/CoderApiClient.cs create mode 100644 CoderSdk/JsonHttpClient.cs diff --git a/App/App.csproj b/App/App.csproj index 4d049fd..982612f 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -56,6 +56,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/App/App.xaml.cs b/App/App.xaml.cs index c6f22b4..2c7e87e 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,24 +1,26 @@ 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 Windows.ApplicationModel.Activation; -using Microsoft.Extensions.Logging; using Serilog; -using System.Collections.Generic; +using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; namespace Coder.Desktop.App; @@ -60,6 +62,8 @@ public App() loggerConfig.ReadFrom.Configuration(builder.Configuration); }); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); @@ -76,6 +80,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(); @@ -89,7 +95,7 @@ public App() services.AddTransient(); _services = services.BuildServiceProvider(); - _logger = (ILogger)(_services.GetService(typeof(ILogger))!); + _logger = (ILogger)_services.GetService(typeof(ILogger))!; InitializeComponent(); } @@ -107,7 +113,7 @@ public async Task ExitApplication() Environment.Exit(0); } - protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + protected override void OnLaunched(LaunchActivatedEventArgs args) { _logger.LogInformation("new instance launched"); // Start connecting to the manager in the background. 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/Program.cs b/App/Program.cs index 2ad863d..1a54b2b 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -27,7 +27,7 @@ private static void Main(string[] args) try { ComWrappersSupport.InitializeComWrappers(); - AppInstance mainInstance = GetMainInstance(); + var mainInstance = GetMainInstance(); if (!mainInstance.IsCurrent) { var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 41a8dc7..a2f6567 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; diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 3a68962..5b85b2c 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -12,6 +12,7 @@ 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; @@ -85,7 +86,9 @@ public interface ISyncSessionController : IAsyncDisposable /// Task RefreshState(CancellationToken ct = default); - Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback, 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); @@ -200,7 +203,8 @@ public async Task RefreshState(CancellationToke return state; } - public async Task CreateSyncSession(CreateSyncSessionRequest req, Action? progressCallback = null, 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); @@ -216,8 +220,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(), }, 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 d01338c..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,17 +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] - public partial string NewSessionStatus { get; set; } = ""; + [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 { @@ -78,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; } } @@ -89,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) @@ -106,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(); @@ -174,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) @@ -191,6 +223,7 @@ private void ClearNewForm() NewSessionRemoteHost = ""; NewSessionRemotePath = ""; NewSessionStatus = ""; + _remotePickerWindow?.Close(); } [RelayCommand] @@ -227,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; @@ -261,6 +323,40 @@ 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() { @@ -300,7 +396,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest Beta = new CreateSyncSessionRequest.Endpoint { Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, - Host = NewSessionRemoteHost, + Host = NewSessionRemoteHost!, Path = NewSessionRemotePath, }, }, OnCreateSessionProgress, cts.Token); 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..6ed5f43 --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -0,0 +1,93 @@ +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; + +namespace Coder.Desktop.App.Views; + +public sealed partial class DirectoryPickerWindow : WindowEx +{ + public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) + { + InitializeComponent(); + 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..428363b 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -16,7 +16,7 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) 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 @@ @@ -317,7 +323,7 @@ + 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/Program.cs b/Installer/Program.cs index 1894a2d..10a09a7 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -2,9 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using CommandLine; -using Microsoft.Extensions.Configuration; using WixSharp; using WixSharp.Bootstrapper; using WixSharp.CommonTasks; @@ -389,8 +387,8 @@ private static int BuildBundle(BootstrapperOptions opts) [ new ExePackagePayload { - SourceFile = opts.WindowsAppSdkPath - } + SourceFile = opts.WindowsAppSdkPath, + }, ], }, new MsiPackage(opts.MsiPath) 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 c834009..2c97515 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -113,6 +113,7 @@ public async Task Ok(CancellationToken ct) await AssertDaemonStopped(dataDirectory, ct); var progressMessages = new List(); + void OnProgress(string message) { TestContext.Out.WriteLine("Create session progress: " + message); diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs index c7b94c6..6a3108b 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -297,15 +297,10 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin // 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 == 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; @@ -328,7 +323,7 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin } /// - /// TaskOrCancellation waits for either the task to complete, or the given token to be canceled. + /// TaskOrCancellation waits for either the task to complete, or the given token to be canceled. /// internal static async Task TaskOrCancellation(Task task, CancellationToken cancellationToken) { @@ -454,7 +449,6 @@ private async Task Start(CancellationToken ct = default) TotalBytes = (ulong)res.Content.Headers.ContentLength; await Download(res, ct); - return; } private async Task Download(HttpResponseMessage res, CancellationToken ct) @@ -472,6 +466,7 @@ private async Task Download(HttpResponseMessage res, CancellationToken ct) _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; From 7ca3af3f50df1fdf1e7df3a7598780e8b1edf545 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 1 May 2025 12:39:17 +1000 Subject: [PATCH 05/16] chore: update mutagen to v0.18.3 (#83) --- App/Services/MutagenController.cs | 1 + MutagenSdk/Proto/filesystem/behavior/probe_mode.proto | 2 +- MutagenSdk/Proto/selection/selection.proto | 2 +- MutagenSdk/Proto/service/daemon/daemon.proto | 2 +- MutagenSdk/Proto/service/prompting/prompting.proto | 2 +- MutagenSdk/Proto/service/synchronization/synchronization.proto | 2 +- MutagenSdk/Proto/synchronization/compression/algorithm.proto | 2 +- MutagenSdk/Proto/synchronization/configuration.proto | 2 +- MutagenSdk/Proto/synchronization/core/change.proto | 2 +- MutagenSdk/Proto/synchronization/core/conflict.proto | 2 +- MutagenSdk/Proto/synchronization/core/entry.proto | 2 +- .../Proto/synchronization/core/ignore/ignore_vcs_mode.proto | 2 +- MutagenSdk/Proto/synchronization/core/ignore/syntax.proto | 2 +- MutagenSdk/Proto/synchronization/core/mode.proto | 2 +- MutagenSdk/Proto/synchronization/core/permissions_mode.proto | 2 +- MutagenSdk/Proto/synchronization/core/problem.proto | 2 +- MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto | 2 +- MutagenSdk/Proto/synchronization/hashing/algorithm.proto | 2 +- MutagenSdk/Proto/synchronization/rsync/receive.proto | 2 +- MutagenSdk/Proto/synchronization/scan_mode.proto | 2 +- MutagenSdk/Proto/synchronization/session.proto | 2 +- MutagenSdk/Proto/synchronization/stage_mode.proto | 2 +- MutagenSdk/Proto/synchronization/state.proto | 2 +- MutagenSdk/Proto/synchronization/version.proto | 2 +- MutagenSdk/Proto/synchronization/watch_mode.proto | 2 +- MutagenSdk/Proto/url/url.proto | 2 +- scripts/Get-Mutagen.ps1 | 2 +- 27 files changed, 27 insertions(+), 26 deletions(-) diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 5b85b2c..cb12151 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -549,6 +549,7 @@ private void StartDaemonProcess() _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; // shell needs to be disabled since we set the environment diff --git a/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto b/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto index d9a7637..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/coder/mutagen/tree/v0.18.2/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 5d239c0..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/coder/mutagen/tree/v0.18.2/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 fb2e0b4..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/coder/mutagen/tree/v0.18.2/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 b5f9567..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/coder/mutagen/tree/v0.18.2/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 84fb62a..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/coder/mutagen/tree/v0.18.2/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 0eae47d..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/coder/mutagen/tree/v0.18.2/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 2225bd6..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/coder/mutagen/tree/v0.18.2/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 019dfde..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/coder/mutagen/tree/v0.18.2/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 5531e93..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/coder/mutagen/tree/v0.18.2/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 158b876..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/coder/mutagen/tree/v0.18.2/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 0bb0fe1..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/coder/mutagen/tree/v0.18.2/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 4ee76a1..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/coder/mutagen/tree/v0.18.2/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 e509a20..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/coder/mutagen/tree/v0.18.2/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 d920f4e..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/coder/mutagen/tree/v0.18.2/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 d83c892..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/coder/mutagen/tree/v0.18.2/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 5736678..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/coder/mutagen/tree/v0.18.2/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 7fee4c1..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/coder/mutagen/tree/v0.18.2/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 192298a..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/coder/mutagen/tree/v0.18.2/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 1d35e48..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/coder/mutagen/tree/v0.18.2/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 9e604b6..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/coder/mutagen/tree/v0.18.2/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 8813cbf..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/coder/mutagen/tree/v0.18.2/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 147e274..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/coder/mutagen/tree/v0.18.2/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 4bdb479..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/coder/mutagen/tree/v0.18.2/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 bcfae1e..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/coder/mutagen/tree/v0.18.2/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 7983230..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/coder/mutagen/tree/v0.18.2/pkg/url/url.proto + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/url/url.proto * * MIT License * diff --git a/scripts/Get-Mutagen.ps1 b/scripts/Get-Mutagen.ps1 index fec8aa6..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.2" +$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" From b84a4ed36b27b6316f8899e46021ee6bee030038 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 1 May 2025 13:12:41 +0400 Subject: [PATCH 06/16] chore: add logging to mutagen controller (#79) Adds some logging to the mutagen controller. --- App/Services/MutagenController.cs | 33 +++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index cb12151..f1fd674 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -17,9 +17,12 @@ using Coder.Desktop.Vpn.Utilities; using Grpc.Core; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +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; +using Microsoft.Extensions.Hosting; namespace Coder.Desktop.App.Services; @@ -113,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; @@ -142,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; @@ -447,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 @@ -501,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. @@ -556,8 +567,11 @@ private void StartDaemonProcess() // 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 += ... + _daemonProcess.EnableRaisingEvents = true; + _daemonProcess.Exited += (object? sender, EventArgs e) => + { + _logger.LogInformation("mutagen daemon exited with code {exitCode}", _daemonProcess?.ExitCode); + }; if (!_daemonProcess.Start()) throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); @@ -572,6 +586,7 @@ private void StartDaemonProcess() /// private async Task StopDaemon(CancellationToken ct) { + _logger.LogDebug("stopping mutagen daemon"); var process = _daemonProcess; var client = _mutagenClient; var writer = _logWriter; @@ -584,17 +599,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); } } @@ -602,10 +621,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(); From 24957796a68dba253ae32061f68e01a300c85d8c Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 1 May 2025 21:03:44 +1000 Subject: [PATCH 07/16] fix: ensure mutagen daemon log is closed (#84) --- App/Services/MutagenController.cs | 67 ++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index f1fd674..3931b66 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -16,13 +16,13 @@ using Coder.Desktop.MutagenSdk.Proto.Url; using Coder.Desktop.Vpn.Utilities; using Grpc.Core; -using Microsoft.Extensions.Options; +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; -using Microsoft.Extensions.Hosting; namespace Coder.Desktop.App.Services; @@ -556,25 +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); - _daemonProcess.StartInfo.Environment.Add("MUTAGEN_SSH_CONFIG_PATH", "none"); // do not use ~/.ssh/config + _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; - _daemonProcess.EnableRaisingEvents = true; - _daemonProcess.Exited += (object? sender, EventArgs e) => + daemonProcess.StartInfo.UseShellExecute = false; + daemonProcess.StartInfo.RedirectStandardError = true; + daemonProcess.EnableRaisingEvents = true; + daemonProcess.Exited += (_, _) => { - _logger.LogInformation("mutagen daemon exited with code {exitCode}", _daemonProcess?.ExitCode); + var exitCode = -1; + try + { + // ReSharper disable once AccessToDisposedClosure + exitCode = daemonProcess.ExitCode; + } + catch + { + // ignored + } + + _logger.LogInformation("mutagen daemon exited with code {exitCode}", exitCode); }; - if (!_daemonProcess.Start()) - throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); + 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; From 78ff6da0b467000c845e842e51c75a9672cb2d08 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 2 May 2025 07:12:58 +0400 Subject: [PATCH 08/16] feat: add support for notifications (#85) Adds support for OS notifications, which I'll use to show errors handling URIs in a subsequent PR. [Screen Recording 2025-05-01 145532.mp4 (uploaded via Graphite) ](https://app.graphite.dev/media/video/tCz4CxRU9jhAJ7zH8RTi/f838fb8a-6815-48a7-bd52-63d6a06ce742.mp4) --- App/App.xaml.cs | 14 +++++++++++++- App/Program.cs | 12 +++++++++++- App/Services/UserNotifier.cs | 29 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 App/Services/UserNotifier.cs diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 2c7e87e..2cdee97 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -21,6 +21,7 @@ using Microsoft.Windows.AppLifecycle; using Serilog; using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; +using Microsoft.Windows.AppNotifications; namespace Coder.Desktop.App; @@ -70,6 +71,7 @@ public App() services.AddOptions() .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); services.AddSingleton(); + services.AddSingleton(); // SignInWindow views and view models services.AddTransient(); @@ -188,10 +190,14 @@ public void OnActivated(object? sender, AppActivationArguments args) _logger.LogWarning("URI activation with null data"); return; } - HandleURIActivation(protoArgs.Uri); break; + case ExtendedActivationKind.AppNotification: + var notificationArgs = (args.Data as AppNotificationActivatedEventArgs)!; + HandleNotification(null, notificationArgs); + break; + default: _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind); break; @@ -204,6 +210,12 @@ public void HandleURIActivation(Uri uri) _logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath); } + public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args) + { + // right now, we don't do anything other than log + _logger.LogInformation("handled notification activation"); + } + private static void AddDefaultConfig(IConfigurationBuilder builder) { var logPath = Path.Combine( diff --git a/App/Program.cs b/App/Program.cs index 1a54b2b..3749c3b 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -5,6 +5,7 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using WinRT; namespace Coder.Desktop.App; @@ -28,9 +29,9 @@ private static void Main(string[] args) { ComWrappersSupport.InitializeComWrappers(); var mainInstance = GetMainInstance(); + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); if (!mainInstance.IsCurrent) { - var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait(); return; } @@ -58,6 +59,15 @@ private static void Main(string[] args) // redirections via RedirectActivationToAsync above get routed to the App mainInstance.Activated += app.OnActivated; + var notificationManager = AppNotificationManager.Default; + notificationManager.NotificationInvoked += app.HandleNotification; + notificationManager.Register(); + if (activationArgs.Kind != ExtendedActivationKind.Launch) + { + // this means we were activated without having already launched, so handle + // the activation as well. + app.OnActivated(null, activationArgs); + } }); } catch (Exception e) diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs new file mode 100644 index 0000000..9cdf6c1 --- /dev/null +++ b/App/Services/UserNotifier.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Windows.AppNotifications; +using Microsoft.Windows.AppNotifications.Builder; + +namespace Coder.Desktop.App.Services; + +public interface IUserNotifier : IAsyncDisposable +{ + public Task ShowErrorNotification(string title, string message); +} + +public class UserNotifier : IUserNotifier +{ + private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public Task ShowErrorNotification(string title, string message) + { + var builder = new AppNotificationBuilder().AddText(title).AddText(message); + _notificationManager.Show(builder.BuildNotification()); + return Task.CompletedTask; + } +} + From 119e52a893eb2669b9acc258401c15ab4886f0e3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 7 May 2025 12:48:53 +0200 Subject: [PATCH 09/16] feat: add coder icon to all forms (#89) Closes: #76 --- App/Utils/TitleBarIcon.cs | 19 +++++++++++++++++++ App/Views/DirectoryPickerWindow.xaml.cs | 3 +++ App/Views/FileSyncListWindow.xaml.cs | 4 ++++ App/Views/SignInWindow.xaml.cs | 2 ++ 4 files changed, 28 insertions(+) create mode 100644 App/Utils/TitleBarIcon.cs diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs new file mode 100644 index 0000000..3efc81d --- /dev/null +++ b/App/Utils/TitleBarIcon.cs @@ -0,0 +1,19 @@ +using System; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; +using WinRT.Interop; + +namespace Coder.Desktop.App.Utils +{ + public static class TitleBarIcon + { + public static void SetTitlebarIcon(Window window) + { + var hwnd = WindowNative.GetWindowHandle(window); + var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico"); + } + } +} diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs index 6ed5f43..2409d4b 100644 --- a/App/Views/DirectoryPickerWindow.xaml.cs +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -8,6 +8,7 @@ using Microsoft.UI.Xaml.Media; using WinRT.Interop; using WinUIEx; +using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -16,6 +17,8 @@ public sealed partial class DirectoryPickerWindow : WindowEx public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) { InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + SystemBackdrop = new DesktopAcrylicBackdrop(); viewModel.Initialize(this, DispatcherQueue); diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 428363b..fb899cc 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -2,6 +2,7 @@ using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Xaml.Media; using WinUIEx; +using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -13,6 +14,8 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) { ViewModel = viewModel; InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); + SystemBackdrop = new DesktopAcrylicBackdrop(); ViewModel.Initialize(this, DispatcherQueue); @@ -20,4 +23,5 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) this.CenterOnScreen(); } + } diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs index 3fe4b5c..fb933c7 100644 --- a/App/Views/SignInWindow.xaml.cs +++ b/App/Views/SignInWindow.xaml.cs @@ -6,6 +6,7 @@ using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media; +using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -22,6 +23,7 @@ public sealed partial class SignInWindow : Window public SignInWindow(SignInViewModel viewModel) { InitializeComponent(); + TitleBarIcon.SetTitlebarIcon(this); SystemBackdrop = new DesktopAcrylicBackdrop(); RootFrame.SizeChanged += RootFrame_SizeChanged; From 2a4814ea4c7a0d0af75cbbc501660be2ad00915a Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 8 May 2025 13:54:03 +0400 Subject: [PATCH 10/16] feat: add support for RDP URIs (#87) Adds basic support for `coder:/` URIs for opening RDP. relates to #52 but I still need to add support for checking the authority. --- App/App.xaml.cs | 24 ++- App/Services/CredentialManager.cs | 218 ++++++++++++++++--------- App/Services/RdpConnector.cs | 76 +++++++++ App/Services/UriHandler.cs | 152 +++++++++++++++++ App/Services/UserNotifier.cs | 5 +- Tests.App/Services/RdpConnectorTest.cs | 27 +++ Tests.App/Services/UriHandlerTest.cs | 178 ++++++++++++++++++++ Tests.App/Tests.App.csproj | 2 + 8 files changed, 596 insertions(+), 86 deletions(-) create mode 100644 App/Services/RdpConnector.cs create mode 100644 App/Services/UriHandler.cs create mode 100644 Tests.App/Services/RdpConnectorTest.cs create mode 100644 Tests.App/Services/UriHandlerTest.cs diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 2cdee97..ba6fa67 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -41,6 +41,7 @@ public partial class App : Application #endif private readonly ILogger _logger; + private readonly IUriHandler _uriHandler; public App() { @@ -72,6 +73,8 @@ public App() .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // SignInWindow views and view models services.AddTransient(); @@ -98,6 +101,7 @@ public App() _services = services.BuildServiceProvider(); _logger = (ILogger)_services.GetService(typeof(ILogger))!; + _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!; InitializeComponent(); } @@ -190,7 +194,19 @@ public void OnActivated(object? sender, AppActivationArguments args) _logger.LogWarning("URI activation with null data"); return; } - HandleURIActivation(protoArgs.Uri); + + // don't need to wait for it to complete. + _uriHandler.HandleUri(protoArgs.Uri).ContinueWith(t => + { + if (t.Exception != null) + { + // don't log query params, as they contain secrets. + _logger.LogError(t.Exception, + "unhandled exception while processing URI coder://{authority}{path}", + protoArgs.Uri.Authority, protoArgs.Uri.AbsolutePath); + } + }); + break; case ExtendedActivationKind.AppNotification: @@ -204,12 +220,6 @@ public void OnActivated(object? sender, AppActivationArguments args) } } - public void HandleURIActivation(Uri uri) - { - // don't log the query string as that's where we include some sensitive information like passwords - _logger.LogInformation("handling URI activation for {path}", uri.AbsolutePath); - } - public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args) { // right now, we don't do anything other than log diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index a2f6567..280169c 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -307,7 +307,7 @@ public WindowsCredentialBackend(string credentialsTargetName) public Task ReadCredentials(CancellationToken ct = default) { - var raw = NativeApi.ReadCredentials(_credentialsTargetName); + var raw = Wincred.ReadCredentials(_credentialsTargetName); if (raw == null) return Task.FromResult(null); RawCredentials? credentials; @@ -326,115 +326,179 @@ public WindowsCredentialBackend(string credentialsTargetName) public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default) { var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials); - NativeApi.WriteCredentials(_credentialsTargetName, raw); + Wincred.WriteCredentials(_credentialsTargetName, raw); return Task.CompletedTask; } public Task DeleteCredentials(CancellationToken ct = default) { - NativeApi.DeleteCredentials(_credentialsTargetName); + Wincred.DeleteCredentials(_credentialsTargetName); return Task.CompletedTask; } - private static class NativeApi +} + +/// +/// Wincred provides relatively low level wrapped calls to the Wincred.h native API. +/// +internal static class Wincred +{ + private const int CredentialTypeGeneric = 1; + private const int CredentialTypeDomainPassword = 2; + private const int PersistenceTypeLocalComputer = 2; + private const int ErrorNotFound = 1168; + private const int CredMaxCredentialBlobSize = 5 * 512; + private const string PackageNTLM = "NTLM"; + + public static string? ReadCredentials(string targetName) { - private const int CredentialTypeGeneric = 1; - private const int PersistenceTypeLocalComputer = 2; - private const int ErrorNotFound = 1168; - private const int CredMaxCredentialBlobSize = 5 * 512; + if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return null; + throw new InvalidOperationException($"Failed to read credentials (Error {error})"); + } - public static string? ReadCredentials(string targetName) + try { - if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr)) - { - var error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) return null; - throw new InvalidOperationException($"Failed to read credentials (Error {error})"); - } + var cred = Marshal.PtrToStructure(credentialPtr); + return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); + } + finally + { + CredFree(credentialPtr); + } + } - try - { - var cred = Marshal.PtrToStructure(credentialPtr); - return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char)); - } - finally + public static void WriteCredentials(string targetName, string secret) + { + var byteCount = Encoding.Unicode.GetByteCount(secret); + if (byteCount > CredMaxCredentialBlobSize) + throw new ArgumentOutOfRangeException(nameof(secret), + $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + + var credentialBlob = Marshal.StringToHGlobalUni(secret); + var cred = new CREDENTIALW + { + Type = CredentialTypeGeneric, + TargetName = targetName, + CredentialBlobSize = byteCount, + CredentialBlob = credentialBlob, + Persist = PersistenceTypeLocalComputer, + }; + try + { + if (!CredWriteW(ref cred, 0)) { - CredFree(credentialPtr); + var error = Marshal.GetLastWin32Error(); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } - - public static void WriteCredentials(string targetName, string secret) + finally { - var byteCount = Encoding.Unicode.GetByteCount(secret); - if (byteCount > CredMaxCredentialBlobSize) - throw new ArgumentOutOfRangeException(nameof(secret), - $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); + Marshal.FreeHGlobal(credentialBlob); + } + } - var credentialBlob = Marshal.StringToHGlobalUni(secret); - var cred = new CREDENTIAL - { - Type = CredentialTypeGeneric, - TargetName = targetName, - CredentialBlobSize = byteCount, - CredentialBlob = credentialBlob, - Persist = PersistenceTypeLocalComputer, - }; - try - { - if (!CredWriteW(ref cred, 0)) - { - var error = Marshal.GetLastWin32Error(); - throw new InvalidOperationException($"Failed to write credentials (Error {error})"); - } - } - finally - { - Marshal.FreeHGlobal(credentialBlob); - } + public static void DeleteCredentials(string targetName) + { + if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorNotFound) return; + throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); } + } + + public static void WriteDomainCredentials(string domainName, string serverName, string username, string password) + { + var targetName = $"{domainName}/{serverName}"; + var targetInfo = new CREDENTIAL_TARGET_INFORMATIONW + { + TargetName = targetName, + DnsServerName = serverName, + DnsDomainName = domainName, + PackageName = PackageNTLM, + }; + var byteCount = Encoding.Unicode.GetByteCount(password); + if (byteCount > CredMaxCredentialBlobSize) + throw new ArgumentOutOfRangeException(nameof(password), + $"The secret is greater than {CredMaxCredentialBlobSize} bytes"); - public static void DeleteCredentials(string targetName) + var credentialBlob = Marshal.StringToHGlobalUni(password); + var cred = new CREDENTIALW { - if (!CredDeleteW(targetName, CredentialTypeGeneric, 0)) + Type = CredentialTypeDomainPassword, + TargetName = targetName, + CredentialBlobSize = byteCount, + CredentialBlob = credentialBlob, + Persist = PersistenceTypeLocalComputer, + UserName = username, + }; + try + { + if (!CredWriteDomainCredentialsW(ref targetInfo, ref cred, 0)) { var error = Marshal.GetLastWin32Error(); - if (error == ErrorNotFound) return; - throw new InvalidOperationException($"Failed to delete credentials (Error {error})"); + throw new InvalidOperationException($"Failed to write credentials (Error {error})"); } } + finally + { + Marshal.FreeHGlobal(credentialBlob); + } + } - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteW([In] ref CREDENTIALW userCredential, [In] uint flags); - [DllImport("Advapi32.dll", SetLastError = true)] - private static extern void CredFree([In] IntPtr cred); + [DllImport("Advapi32.dll", SetLastError = true)] + private static extern void CredFree([In] IntPtr cred); - [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CredDeleteW(string target, int type, int flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDeleteW(string target, int type, int flags); - [StructLayout(LayoutKind.Sequential)] - private struct CREDENTIAL - { - public int Flags; - public int Type; + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWriteDomainCredentialsW([In] ref CREDENTIAL_TARGET_INFORMATIONW target, [In] ref CREDENTIALW userCredential, [In] uint flags); - [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIALW + { + public int Flags; + public int Type; - [MarshalAs(UnmanagedType.LPWStr)] public string Comment; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; - public long LastWritten; - public int CredentialBlobSize; - public IntPtr CredentialBlob; - public int Persist; - public int AttributeCount; - public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string Comment; - [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + public long LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; - [MarshalAs(UnmanagedType.LPWStr)] public string UserName; - } + [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CREDENTIAL_TARGET_INFORMATIONW + { + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsServerName; + [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsDomainName; + [MarshalAs(UnmanagedType.LPWStr)] public string DnsTreeName; + [MarshalAs(UnmanagedType.LPWStr)] public string PackageName; + + public uint Flags; + public uint CredTypeCount; + public IntPtr CredTypes; } } diff --git a/App/Services/RdpConnector.cs b/App/Services/RdpConnector.cs new file mode 100644 index 0000000..a48d0ac --- /dev/null +++ b/App/Services/RdpConnector.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Coder.Desktop.App.Services; + +public struct RdpCredentials(string username, string password) +{ + public readonly string Username = username; + public readonly string Password = password; +} + +public interface IRdpConnector +{ + public const int DefaultPort = 3389; + + public void WriteCredentials(string fqdn, RdpCredentials credentials); + + public Task Connect(string fqdn, int port = DefaultPort, CancellationToken ct = default); +} + +public class RdpConnector(ILogger logger) : IRdpConnector +{ + // Remote Desktop always uses TERMSRV as the domain; RDP is a part of Windows "Terminal Services". + private const string RdpDomain = "TERMSRV"; + + public void WriteCredentials(string fqdn, RdpCredentials credentials) + { + // writing credentials is idempotent for the same domain and server name. + Wincred.WriteDomainCredentials(RdpDomain, fqdn, credentials.Username, credentials.Password); + logger.LogDebug("wrote domain credential for {serverName} with username {username}", fqdn, + credentials.Username); + return; + } + + public Task Connect(string fqdn, int port = IRdpConnector.DefaultPort, CancellationToken ct = default) + { + // use mstsc to launch the RDP connection + var mstscProc = new Process(); + mstscProc.StartInfo.FileName = "mstsc"; + var args = $"/v {fqdn}"; + if (port != IRdpConnector.DefaultPort) + { + args = $"/v {fqdn}:{port}"; + } + + mstscProc.StartInfo.Arguments = args; + mstscProc.StartInfo.CreateNoWindow = true; + mstscProc.StartInfo.UseShellExecute = false; + try + { + if (!mstscProc.Start()) + throw new InvalidOperationException("Failed to start mstsc, Start returned false"); + } + catch (Exception e) + { + logger.LogWarning(e, "mstsc failed to start"); + + try + { + mstscProc.Kill(); + } + catch + { + // ignored, the process likely doesn't exist + } + + mstscProc.Dispose(); + throw; + } + + return mstscProc.WaitForExitAsync(ct); + } +} diff --git a/App/Services/UriHandler.cs b/App/Services/UriHandler.cs new file mode 100644 index 0000000..b0b0a9a --- /dev/null +++ b/App/Services/UriHandler.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Coder.Desktop.App.Models; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Extensions.Logging; + + +namespace Coder.Desktop.App.Services; + +public interface IUriHandler +{ + public Task HandleUri(Uri uri, CancellationToken ct = default); +} + +public class UriHandler( + ILogger logger, + IRpcController rpcController, + IUserNotifier userNotifier, + IRdpConnector rdpConnector) : IUriHandler +{ + private const string OpenWorkspacePrefix = "/v0/open/ws/"; + + internal class UriException : Exception + { + internal readonly string Title; + internal readonly string Detail; + + internal UriException(string title, string detail) : base($"{title}: {detail}") + { + Title = title; + Detail = detail; + } + } + + public async Task HandleUri(Uri uri, CancellationToken ct = default) + { + try + { + await HandleUriThrowingErrors(uri, ct); + } + catch (UriException e) + { + await userNotifier.ShowErrorNotification(e.Title, e.Detail, ct); + } + } + + private async Task HandleUriThrowingErrors(Uri uri, CancellationToken ct = default) + { + if (uri.AbsolutePath.StartsWith(OpenWorkspacePrefix)) + { + await HandleOpenWorkspaceApp(uri, ct); + return; + } + + logger.LogWarning("unhandled URI path {path}", uri.AbsolutePath); + throw new UriException("URI handling error", + $"URI with path '{uri.AbsolutePath}' is unsupported or malformed"); + } + + public async Task HandleOpenWorkspaceApp(Uri uri, CancellationToken ct = default) + { + const string errTitle = "Open Workspace Application Error"; + var subpath = uri.AbsolutePath[OpenWorkspacePrefix.Length..]; + var components = subpath.Split("/"); + if (components.Length != 4 || components[1] != "agent") + { + logger.LogWarning("unsupported open workspace app format in URI {path}", uri.AbsolutePath); + throw new UriException(errTitle, $"Failed to open '{uri.AbsolutePath}' because the format is unsupported."); + } + + var workspaceName = components[0]; + var agentName = components[2]; + var appName = components[3]; + + var state = rpcController.GetState(); + if (state.VpnLifecycle != VpnLifecycle.Started) + { + logger.LogDebug("got URI to open workspace '{workspace}', but Coder Connect is not started", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on '{workspaceName}' because Coder Connect is not started."); + } + + var workspace = state.Workspaces.FirstOrDefault(w => w.Name == workspaceName); + if (workspace == null) + { + logger.LogDebug("got URI to open workspace '{workspace}', but the workspace doesn't exist", workspaceName); + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}' because it doesn't exist"); + } + + var agent = state.Agents.FirstOrDefault(a => a.WorkspaceId == workspace.Id && a.Name == agentName); + if (agent == null) + { + logger.LogDebug( + "got URI to open workspace/agent '{workspaceName}/{agentName}', but the agent doesn't exist", + workspaceName, agentName); + // If the workspace isn't running, that is almost certainly why we can't find the agent, so report that + // to the user. + if (workspace.Status != Workspace.Types.Status.Running) + { + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because the workspace is not running."); + } + + throw new UriException(errTitle, + $"Failed to open application on workspace '{workspaceName}', because agent '{agentName}' doesn't exist."); + } + + if (appName != "rdp") + { + logger.LogWarning("unsupported agent application type {app}", appName); + throw new UriException(errTitle, + $"Failed to open agent in URI '{uri.AbsolutePath}' because application '{appName}' is unsupported"); + } + + await OpenRDP(agent.Fqdn.First(), uri.Query, ct); + } + + public async Task OpenRDP(string domainName, string queryString, CancellationToken ct = default) + { + const string errTitle = "Workspace Remote Desktop Error"; + NameValueCollection query; + try + { + query = HttpUtility.ParseQueryString(queryString); + } + catch (Exception ex) + { + // unfortunately, we can't safely write they query string to logs because it might contain + // sensitive info like a password. This is also why we don't log the exception directly + var trace = new System.Diagnostics.StackTrace(ex, false); + logger.LogWarning("failed to parse open RDP query string: {classMethod}", + trace?.GetFrame(0)?.GetMethod()?.ReflectedType?.FullName); + throw new UriException(errTitle, + "Failed to open remote desktop on a workspace because the URI was malformed"); + } + + var username = query.Get("username"); + var password = query.Get("password"); + if (!string.IsNullOrEmpty(username)) + { + password ??= string.Empty; + rdpConnector.WriteCredentials(domainName, new RdpCredentials(username, password)); + } + + await rdpConnector.Connect(domainName, ct: ct); + } +} diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 9cdf6c1..9150f47 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppNotifications.Builder; @@ -7,7 +8,7 @@ namespace Coder.Desktop.App.Services; public interface IUserNotifier : IAsyncDisposable { - public Task ShowErrorNotification(string title, string message); + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default); } public class UserNotifier : IUserNotifier @@ -19,7 +20,7 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } - public Task ShowErrorNotification(string title, string message) + public Task ShowErrorNotification(string title, string message, CancellationToken ct = default) { var builder = new AppNotificationBuilder().AddText(title).AddText(message); _notificationManager.Show(builder.BuildNotification()); diff --git a/Tests.App/Services/RdpConnectorTest.cs b/Tests.App/Services/RdpConnectorTest.cs new file mode 100644 index 0000000..b4a870e --- /dev/null +++ b/Tests.App/Services/RdpConnectorTest.cs @@ -0,0 +1,27 @@ +using Coder.Desktop.App.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class RdpConnectorTest +{ + [Test(Description = "Spawns RDP for real")] + [Ignore("Comment out to run manually")] + [CancelAfter(30_000)] + public async Task ConnectToRdp(CancellationToken ct) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(); + var services = builder.Services.BuildServiceProvider(); + + var rdpConnector = (RdpConnector)services.GetService()!; + var creds = new RdpCredentials("Administrator", "coderRDP!"); + var workspace = "myworkspace.coder"; + rdpConnector.WriteCredentials(workspace, creds); + await rdpConnector.Connect(workspace, ct: ct); + } +} diff --git a/Tests.App/Services/UriHandlerTest.cs b/Tests.App/Services/UriHandlerTest.cs new file mode 100644 index 0000000..65c886c --- /dev/null +++ b/Tests.App/Services/UriHandlerTest.cs @@ -0,0 +1,178 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.Vpn.Proto; +using Google.Protobuf; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; +using Serilog; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class UriHandlerTest +{ + [SetUp] + public void SetupMocksAndUriHandler() + { + Serilog.Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.NUnitOutput().CreateLogger(); + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + var logger = (ILogger)builder.Build().Services.GetService(typeof(ILogger))!; + + _mUserNotifier = new Mock(MockBehavior.Strict); + _mRdpConnector = new Mock(MockBehavior.Strict); + _mRpcController = new Mock(MockBehavior.Strict); + + uriHandler = new UriHandler(logger, _mRpcController.Object, _mUserNotifier.Object, _mRdpConnector.Object); + } + + private Mock _mUserNotifier; + private Mock _mRdpConnector; + private Mock _mRpcController; + private UriHandler uriHandler; // Unit under test. + + [SetUp] + public void AgentAndWorkspaceFixtures() + { + agent11 = new Agent(); + agent11.Fqdn.Add("workspace1.coder"); + agent11.Id = ByteString.CopyFrom(0x1, 0x1); + agent11.WorkspaceId = ByteString.CopyFrom(0x1, 0x0); + agent11.Name = "agent11"; + + workspace1 = new Workspace + { + Id = ByteString.CopyFrom(0x1, 0x0), + Name = "workspace1", + Status = Workspace.Types.Status.Running, + }; + + modelWithWorkspace1 = new RpcModel + { + VpnLifecycle = VpnLifecycle.Started, + Workspaces = [workspace1], + Agents = [agent11], + }; + } + + private Agent agent11; + private Workspace workspace1; + private RpcModel modelWithWorkspace1; + + [Test(Description = "Open RDP with username & password")] + [CancelAfter(30_000)] + public async Task Mainline(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?username=testy&password=sesame"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + var expectedCred = new RdpCredentials("testy", "sesame"); + _ = _mRdpConnector.Setup(m => m.WriteCredentials(agent11.Fqdn[0], expectedCred)); + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Open RDP with no credentials")] + [CancelAfter(30_000)] + public async Task NoCredentials(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown app slug")] + [CancelAfter(30_000)] + public async Task UnknownApp(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/someapp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("someapp"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown agent name")] + [CancelAfter(30_000)] + public async Task UnknownAgent(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/wrongagent/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("wrongagent"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown workspace name")] + [CancelAfter(30_000)] + public async Task UnknownWorkspace(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("wrongworkspace"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Malformed Query String")] + [CancelAfter(30_000)] + public async Task MalformedQuery(CancellationToken ct) + { + // there might be some query string that gets the parser to throw an exception, but I could not find one. + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?%&##"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + // treated the same as if we just didn't include credentials + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "VPN not started")] + [CancelAfter(30_000)] + public async Task VPNNotStarted(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(new RpcModel + { + VpnLifecycle = VpnLifecycle.Starting, + }); + // Coder Connect is the user facing name, so make sure the error mentions it. + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("Coder Connect"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Wrong number of components")] + [CancelAfter(30_000)] + public async Task UnknownNumComponents(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsAny(), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown prefix")] + [CancelAfter(30_000)] + public async Task UnknownPrefix(CancellationToken ct) + { + var input = new Uri("coder:/v300/open/ws/workspace1/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsAny(), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } +} diff --git a/Tests.App/Tests.App.csproj b/Tests.App/Tests.App.csproj index cc01512..e20eba1 100644 --- a/Tests.App/Tests.App.csproj +++ b/Tests.App/Tests.App.csproj @@ -26,6 +26,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + From 9b8408df3e1c8a191c044c7bb279a99e128420a9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 8 May 2025 17:25:00 +0200 Subject: [PATCH 11/16] feat: enter submits sign in information (#90) Closes: #88 --------- Co-authored-by: Dean Sheather --- App/Views/Pages/SignInTokenPage.xaml | 3 ++- App/Views/Pages/SignInTokenPage.xaml.cs | 10 ++++++++++ App/Views/Pages/SignInUrlPage.xaml | 3 ++- App/Views/Pages/SignInUrlPage.xaml.cs | 10 ++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index 8613f19..e21b46b 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -62,8 +62,9 @@ Grid.Row="2" HorizontalAlignment="Stretch" PlaceholderText="Paste your token here" + KeyDown="PasswordBox_KeyDown" LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}" - Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" /> + Password="{x:Bind ViewModel.ApiToken, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + KeyDown="TextBox_KeyDown"/> Date: Mon, 12 May 2025 10:30:29 +0200 Subject: [PATCH 12/16] feat: add exit to main tray window (#95) Closes: #94 --- App/ViewModels/TrayWindowViewModel.cs | 6 ++++++ App/Views/Pages/TrayWindowMainPage.xaml | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index f845521..ae6c910 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -301,4 +301,10 @@ public void SignOut() return; _credentialManager.ClearCredentials(); } + + [RelayCommand] + public void Exit() + { + _ = ((App)Application.Current).ExitApplication(); + } } diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index 42a9abd..f296327 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -249,5 +249,14 @@ + + + + + From a6f7bb67bb111628d3de1f46d7b404d4bab67717 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 13 May 2025 03:13:51 +1000 Subject: [PATCH 13/16] feat: add workspace app icons to tray window (#86) Closes #50 --- App/App.csproj | 5 +- App/App.xaml.cs | 8 +- App/Controls/ExpandChevron.xaml | 31 ++ App/Controls/ExpandChevron.xaml.cs | 19 + App/Controls/ExpandContent.xaml | 51 +++ App/Controls/ExpandContent.xaml.cs | 39 ++ App/Converters/DependencyObjectSelector.cs | 13 + App/Models/CredentialModel.cs | 8 +- App/Program.cs | 2 - App/Services/CredentialManager.cs | 37 +- App/Services/RpcController.cs | 2 +- App/Services/UserNotifier.cs | 1 - App/{ => Utils}/DisplayScale.cs | 2 +- App/Utils/ModelUpdate.cs | 105 +++++ App/Utils/TitleBarIcon.cs | 17 +- App/ViewModels/AgentAppViewModel.cs | 188 ++++++++ App/ViewModels/AgentViewModel.cs | 342 ++++++++++++++- App/ViewModels/FileSyncListViewModel.cs | 3 +- App/ViewModels/TrayWindowViewModel.cs | 186 +++++--- App/Views/DirectoryPickerWindow.xaml.cs | 2 +- App/Views/FileSyncListWindow.xaml.cs | 3 +- App/Views/Pages/SignInTokenPage.xaml | 4 +- App/Views/Pages/SignInUrlPage.xaml | 2 +- App/Views/Pages/TrayWindowMainPage.xaml | 285 ++++++++---- App/Views/SignInWindow.xaml.cs | 2 +- App/Views/TrayWindow.xaml.cs | 54 ++- App/packages.lock.json | 19 +- Coder.Desktop.sln | 18 + Coder.Desktop.sln.DotSettings | 1 + CoderSdk/Coder/CoderApiClient.cs | 31 ++ CoderSdk/Coder/WorkspaceAgents.cs | 38 ++ CoderSdk/Uuid.cs | 180 ++++++++ .../Converters/FriendlyByteConverterTest.cs | 2 +- Tests.App/Services/CredentialManagerTest.cs | 6 +- Tests.App/Utils/ModelUpdateTest.cs | 413 ++++++++++++++++++ Tests.CoderSdk/Tests.CoderSdk.csproj | 36 ++ Tests.CoderSdk/UuidTest.cs | 141 ++++++ Vpn.Proto/packages.lock.json | 3 + 38 files changed, 2057 insertions(+), 242 deletions(-) create mode 100644 App/Controls/ExpandChevron.xaml create mode 100644 App/Controls/ExpandChevron.xaml.cs create mode 100644 App/Controls/ExpandContent.xaml create mode 100644 App/Controls/ExpandContent.xaml.cs rename App/{ => Utils}/DisplayScale.cs (94%) create mode 100644 App/Utils/ModelUpdate.cs create mode 100644 App/ViewModels/AgentAppViewModel.cs create mode 100644 CoderSdk/Coder/WorkspaceAgents.cs create mode 100644 CoderSdk/Uuid.cs create mode 100644 Tests.App/Utils/ModelUpdateTest.cs create mode 100644 Tests.CoderSdk/Tests.CoderSdk.csproj create mode 100644 Tests.CoderSdk/UuidTest.cs diff --git a/App/App.csproj b/App/App.csproj index 982612f..fcfb92f 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -1,4 +1,4 @@ - + WinExe net8.0-windows10.0.19041.0 @@ -16,7 +16,7 @@ preview - DISABLE_XAML_GENERATED_MAIN + DISABLE_XAML_GENERATED_MAIN;DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION Coder Desktop coder.ico @@ -57,6 +57,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/App/App.xaml.cs b/App/App.xaml.cs index ba6fa67..e756efd 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -11,6 +11,7 @@ using Coder.Desktop.App.Views; using Coder.Desktop.App.Views.Pages; using Coder.Desktop.CoderSdk.Agent; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -19,9 +20,9 @@ using Microsoft.UI.Xaml; using Microsoft.Win32; using Microsoft.Windows.AppLifecycle; +using Microsoft.Windows.AppNotifications; using Serilog; using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; -using Microsoft.Windows.AppNotifications; namespace Coder.Desktop.App; @@ -64,8 +65,11 @@ public App() loggerConfig.ReadFrom.Configuration(builder.Configuration); }); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(_ => + new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); services.AddSingleton(); @@ -95,6 +99,8 @@ public App() services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/App/Controls/ExpandChevron.xaml b/App/Controls/ExpandChevron.xaml new file mode 100644 index 0000000..0b68d4d --- /dev/null +++ b/App/Controls/ExpandChevron.xaml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + diff --git a/App/Controls/ExpandChevron.xaml.cs b/App/Controls/ExpandChevron.xaml.cs new file mode 100644 index 0000000..45aa6c4 --- /dev/null +++ b/App/Controls/ExpandChevron.xaml.cs @@ -0,0 +1,19 @@ +using DependencyPropertyGenerator; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Controls; + +[DependencyProperty("IsOpen", DefaultValue = false)] +public sealed partial class ExpandChevron : UserControl +{ + public ExpandChevron() + { + InitializeComponent(); + } + + partial void OnIsOpenChanged(bool oldValue, bool newValue) + { + var newState = newValue ? "NormalOn" : "NormalOff"; + AnimatedIcon.SetState(ChevronIcon, newState); + } +} diff --git a/App/Controls/ExpandContent.xaml b/App/Controls/ExpandContent.xaml new file mode 100644 index 0000000..d36170d --- /dev/null +++ b/App/Controls/ExpandContent.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Controls/ExpandContent.xaml.cs b/App/Controls/ExpandContent.xaml.cs new file mode 100644 index 0000000..1cd5d2f --- /dev/null +++ b/App/Controls/ExpandContent.xaml.cs @@ -0,0 +1,39 @@ +using DependencyPropertyGenerator; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; + +namespace Coder.Desktop.App.Controls; + +[ContentProperty(Name = nameof(Children))] +[DependencyProperty("IsOpen", DefaultValue = false)] +public sealed partial class ExpandContent : UserControl +{ + public UIElementCollection Children => CollapsiblePanel.Children; + + public ExpandContent() + { + InitializeComponent(); + } + + public void CollapseAnimation_Completed(object? sender, object args) + { + // Hide the panel completely when the collapse animation is done. This + // cannot be done with keyframes for some reason. + // + // Without this, the space will still be reserved for the panel. + CollapsiblePanel.Visibility = Visibility.Collapsed; + } + + partial void OnIsOpenChanged(bool oldValue, bool newValue) + { + var newState = newValue ? "ExpandedState" : "CollapsedState"; + + // The animation can't set visibility when starting or ending the + // animation. + if (newValue) + CollapsiblePanel.Visibility = Visibility.Visible; + + VisualStateManager.GoToState(this, newState, true); + } +} diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs index a31c33b..ec586d0 100644 --- a/App/Converters/DependencyObjectSelector.cs +++ b/App/Converters/DependencyObjectSelector.cs @@ -156,6 +156,15 @@ private void UpdateSelectedObject() ClearValue(SelectedObjectProperty); } + private static void VerifyReferencesProperty(IObservableVector references) + { + // Ensure unique keys and that the values are DependencyObjectSelectorItem. + var items = references.OfType>().ToArray(); + var keys = items.Select(i => i.Key).Distinct().ToArray(); + if (keys.Length != references.Count) + throw new ArgumentException("ObservableCollection Keys must be unique."); + } + // Called when the References property is replaced. private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args) { @@ -166,12 +175,16 @@ private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPr oldValue.VectorChanged -= self.OnVectorChangedReferences; var newValue = args.NewValue as DependencyObjectCollection; if (newValue != null) + { + VerifyReferencesProperty(newValue); newValue.VectorChanged += self.OnVectorChangedReferences; + } } // Called when the References collection changes without being replaced. private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args) { + VerifyReferencesProperty(sender); UpdateSelectedObject(); } diff --git a/App/Models/CredentialModel.cs b/App/Models/CredentialModel.cs index 542c1c0..d30f894 100644 --- a/App/Models/CredentialModel.cs +++ b/App/Models/CredentialModel.cs @@ -1,3 +1,5 @@ +using System; + namespace Coder.Desktop.App.Models; public enum CredentialState @@ -5,10 +7,10 @@ public enum CredentialState // Unknown means "we haven't checked yet" Unknown, - // Invalid means "we checked and there's either no saved credentials or they are not valid" + // Invalid means "we checked and there's either no saved credentials, or they are not valid" Invalid, - // Valid means "we checked and there are saved credentials and they are valid" + // Valid means "we checked and there are saved credentials, and they are valid" Valid, } @@ -16,7 +18,7 @@ public class CredentialModel { public CredentialState State { get; init; } = CredentialState.Unknown; - public string? CoderUrl { get; init; } + public Uri? CoderUrl { get; init; } public string? ApiToken { get; init; } public string? Username { get; init; } diff --git a/App/Program.cs b/App/Program.cs index 3749c3b..bf4f16e 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -63,11 +63,9 @@ private static void Main(string[] args) notificationManager.NotificationInvoked += app.HandleNotification; notificationManager.Register(); if (activationArgs.Kind != ExtendedActivationKind.Launch) - { // this means we were activated without having already launched, so handle // the activation as well. app.OnActivated(null, activationArgs); - } }); } catch (Exception e) diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 280169c..6868ae7 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -21,7 +21,7 @@ public class RawCredentials [JsonSerializable(typeof(RawCredentials))] public partial class RawCredentialsJsonContext : JsonSerializerContext; -public interface ICredentialManager +public interface ICredentialManager : ICoderApiClientCredentialProvider { public event EventHandler CredentialsChanged; @@ -59,7 +59,8 @@ public interface ICredentialBackend /// public class CredentialManager : ICredentialManager { - private const string CredentialsTargetName = "Coder.Desktop.App.Credentials"; + private readonly ICredentialBackend Backend; + private readonly ICoderApiClientFactory CoderApiClientFactory; // _opLock is held for the full duration of SetCredentials, and partially // during LoadCredentials. _opLock protects _inFlightLoad, _loadCts, and @@ -79,14 +80,6 @@ public class CredentialManager : ICredentialManager // immediate). private volatile CredentialModel? _latestCredentials; - private ICredentialBackend Backend { get; } = new WindowsCredentialBackend(CredentialsTargetName); - - private ICoderApiClientFactory CoderApiClientFactory { get; } = new CoderApiClientFactory(); - - public CredentialManager() - { - } - public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory) { Backend = backend; @@ -108,6 +101,20 @@ public CredentialModel GetCachedCredentials() }; } + // Implements ICoderApiClientCredentialProvider + public CoderApiClientCredential? GetCoderApiClientCredential() + { + var latestCreds = _latestCredentials; + if (latestCreds is not { State: CredentialState.Valid } || latestCreds.CoderUrl is null) + return null; + + return new CoderApiClientCredential + { + CoderUrl = latestCreds.CoderUrl, + ApiToken = latestCreds.ApiToken ?? "", + }; + } + public async Task GetSignInUri() { try @@ -253,6 +260,12 @@ private async Task PopulateModel(RawCredentials? credentials, C State = CredentialState.Invalid, }; + if (!Uri.TryCreate(credentials.CoderUrl, UriKind.Absolute, out var uri)) + return new CredentialModel + { + State = CredentialState.Invalid, + }; + BuildInfo buildInfo; User me; try @@ -279,7 +292,7 @@ private async Task PopulateModel(RawCredentials? credentials, C return new CredentialModel { State = CredentialState.Valid, - CoderUrl = credentials.CoderUrl, + CoderUrl = uri, ApiToken = credentials.ApiToken, Username = me.Username, }; @@ -298,6 +311,8 @@ private void UpdateState(CredentialModel newModel) public class WindowsCredentialBackend : ICredentialBackend { + public const string CoderCredentialsTargetName = "Coder.Desktop.App.Credentials"; + private readonly string _credentialsTargetName; public WindowsCredentialBackend(string credentialsTargetName) diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs index 17d3ccb..70dfe9f 100644 --- a/App/Services/RpcController.cs +++ b/App/Services/RpcController.cs @@ -170,7 +170,7 @@ public async Task StartVpn(CancellationToken ct = default) { Start = new StartRequest { - CoderUrl = credentials.CoderUrl, + CoderUrl = credentials.CoderUrl?.ToString(), ApiToken = credentials.ApiToken, }, }, ct); diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 9150f47..3b4ac05 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -27,4 +27,3 @@ public Task ShowErrorNotification(string title, string message, CancellationToke return Task.CompletedTask; } } - diff --git a/App/DisplayScale.cs b/App/Utils/DisplayScale.cs similarity index 94% rename from App/DisplayScale.cs rename to App/Utils/DisplayScale.cs index cd5101c..7cc79d6 100644 --- a/App/DisplayScale.cs +++ b/App/Utils/DisplayScale.cs @@ -3,7 +3,7 @@ using Microsoft.UI.Xaml; using WinRT.Interop; -namespace Coder.Desktop.App; +namespace Coder.Desktop.App.Utils; /// /// A static utility class to house methods related to the visual scale of the display monitor. diff --git a/App/Utils/ModelUpdate.cs b/App/Utils/ModelUpdate.cs new file mode 100644 index 0000000..de8b2b6 --- /dev/null +++ b/App/Utils/ModelUpdate.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Coder.Desktop.App.Utils; + +public interface IModelUpdateable +{ + /// + /// Applies changes from obj to `this` if they represent the same + /// object based on some identifier like an ID or fixed name. + /// + /// + /// True if the two objects represent the same item and the changes + /// were applied. + /// + public bool TryApplyChanges(T obj); +} + +/// +/// A static utility class providing methods for applying model updates +/// with as little UI updates as possible. +/// The main goal of the utilities in this class is to prevent redraws in +/// ItemsRepeater items when nothing has changed. +/// +public static class ModelUpdate +{ + /// + /// Takes all items in `update` and either applies them to existing + /// items in `target`, or adds them to `target` if there are no + /// matching items. + /// Any items in `target` that don't have a corresponding item in + /// `update` will be removed from `target`. + /// Items are inserted in their correct sort position according to + /// `sorter`. It's assumed that the target list is already sorted by + /// `sorter`. + /// + /// Target list to be updated + /// Incoming list to apply to `target` + /// + /// Comparison to use for sorting. Note that the sort order does not + /// need to be the ID/name field used in the IModelUpdateable + /// implementation, and can be by any order. + /// New items will be sorted after existing items. + /// + public static void ApplyLists(IList target, IEnumerable update, Comparison sorter) + where T : IModelUpdateable + { + var newItems = update.ToList(); + + // Update and remove existing items. We use index-based for loops here + // because we remove items, and removing items while using the list as + // an IEnumerable will throw an exception. + for (var i = 0; i < target.Count; i++) + { + // Even though we're removing items before a "break", we still use + // index-based for loops here to avoid exceptions. + for (var j = 0; j < newItems.Count; j++) + { + if (!target[i].TryApplyChanges(newItems[j])) continue; + + // Prevent it from being added below, or checked again. We + // don't need to decrement `j` here because we're breaking + // out of this inner loop. + newItems.RemoveAt(j); + goto OuterLoopEnd; // continue outer loop + } + + // A merge couldn't occur, so we need to remove the old item and + // decrement `i` for the next iteration. + target.RemoveAt(i); + i--; + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + + // Add any items that were missing into their correct sorted place. + // It's assumed the list is already sorted. + foreach (var newItem in newItems) + { + for (var i = 0; i < target.Count; i++) + // If the new item sorts before the current item, insert it + // after. + if (sorter(newItem, target[i]) < 0) + { + target.Insert(i, newItem); + goto OuterLoopEnd; + } + + // Handle the case where target is empty or the new item is + // equal to or after every other item. + target.Add(newItem); + + // Rider fights `dotnet format` about whether there should be a + // space before the semicolon or not. +#pragma warning disable format + OuterLoopEnd: ; +#pragma warning restore format + } + } +} diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs index 3efc81d..283453d 100644 --- a/App/Utils/TitleBarIcon.cs +++ b/App/Utils/TitleBarIcon.cs @@ -1,19 +1,16 @@ -using System; using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls.Primitives; using WinRT.Interop; -namespace Coder.Desktop.App.Utils +namespace Coder.Desktop.App.Utils; + +public static class TitleBarIcon { - public static class TitleBarIcon + public static void SetTitlebarIcon(Window window) { - public static void SetTitlebarIcon(Window window) - { - var hwnd = WindowNative.GetWindowHandle(window); - var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); - AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico"); - } + var hwnd = WindowNative.GetWindowHandle(window); + var windowId = Win32Interop.GetWindowIdFromWindow(hwnd); + AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico"); } } diff --git a/App/ViewModels/AgentAppViewModel.cs b/App/ViewModels/AgentAppViewModel.cs new file mode 100644 index 0000000..5620eb2 --- /dev/null +++ b/App/ViewModels/AgentAppViewModel.cs @@ -0,0 +1,188 @@ +using System; +using System.Linq; +using Windows.System; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Coder.Desktop.App.ViewModels; + +public interface IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl); +} + +public class AgentAppViewModelFactory(ILogger childLogger, ICredentialManager credentialManager) + : IAgentAppViewModelFactory +{ + public AgentAppViewModel Create(Uuid id, string name, Uri appUri, Uri? iconUrl) + { + return new AgentAppViewModel(childLogger, credentialManager) + { + Id = id, + Name = name, + AppUri = appUri, + IconUrl = iconUrl, + }; + } +} + +public partial class AgentAppViewModel : ObservableObject, IModelUpdateable +{ + private const string SessionTokenUriVar = "$SESSION_TOKEN"; + + private readonly ILogger _logger; + private readonly ICredentialManager _credentialManager; + + public required Uuid Id { get; init; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial string Name { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Details))] + public required partial Uri AppUri { get; set; } + + [ObservableProperty] public partial Uri? IconUrl { get; set; } + + [ObservableProperty] public partial ImageSource IconImageSource { get; set; } + + [ObservableProperty] public partial bool UseFallbackIcon { get; set; } = true; + + public string Details => + (string.IsNullOrWhiteSpace(Name) ? "(no name)" : Name) + ":\n\n" + AppUri; + + public AgentAppViewModel(ILogger logger, ICredentialManager credentialManager) + { + _logger = logger; + _credentialManager = credentialManager; + + // Apply the icon URL to the icon image source when it is updated. + IconImageSource = UpdateIcon(); + PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(IconUrl)) + IconImageSource = UpdateIcon(); + }; + } + + public bool TryApplyChanges(AgentAppViewModel obj) + { + if (Id != obj.Id) return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (Name != obj.Name) + Name = obj.Name; + if (AppUri != obj.AppUri) + AppUri = obj.AppUri; + if (IconUrl != obj.IconUrl) + { + UseFallbackIcon = true; + IconUrl = obj.IconUrl; + } + + return true; + } + + private ImageSource UpdateIcon() + { + if (IconUrl is null || (IconUrl.Scheme != "http" && IconUrl.Scheme != "https")) + { + UseFallbackIcon = true; + return new BitmapImage(); + } + + // Determine what image source to use based on extension, use a + // BitmapImage as last resort. + var ext = IconUrl.AbsolutePath.Split('/').LastOrDefault()?.Split('.').LastOrDefault(); + // TODO: this is definitely a hack, URLs shouldn't need to end in .svg + if (ext is "svg") + { + // TODO: Some SVGs like `/icon/cursor.svg` contain PNG data and + // don't render at all. + var svg = new SvgImageSource(IconUrl); + svg.Opened += (_, _) => _logger.LogDebug("app icon opened (svg): {uri}", IconUrl); + svg.OpenFailed += (_, args) => + _logger.LogDebug("app icon failed to open (svg): {uri}: {Status}", IconUrl, args.Status); + return svg; + } + + var bitmap = new BitmapImage(IconUrl); + bitmap.ImageOpened += (_, _) => _logger.LogDebug("app icon opened (bitmap): {uri}", IconUrl); + bitmap.ImageFailed += (_, args) => + _logger.LogDebug("app icon failed to open (bitmap): {uri}: {ErrorMessage}", IconUrl, args.ErrorMessage); + return bitmap; + } + + public void OnImageOpened(object? sender, RoutedEventArgs e) + { + UseFallbackIcon = false; + } + + public void OnImageFailed(object? sender, RoutedEventArgs e) + { + UseFallbackIcon = true; + } + + [RelayCommand] + private void OpenApp(object parameter) + { + try + { + var uri = AppUri; + + // http and https URLs should already be filtered out by + // AgentViewModel, but as a second line of defence don't do session + // token var replacement on those URLs. + if (uri.Scheme is not "http" and not "https") + { + var cred = _credentialManager.GetCachedCredentials(); + if (cred.State is CredentialState.Valid && cred.ApiToken is not null) + uri = new Uri(uri.ToString().Replace(SessionTokenUriVar, cred.ApiToken)); + } + + if (uri.ToString().Contains(SessionTokenUriVar)) + throw new Exception( + $"URI contains {SessionTokenUriVar} variable but could not be replaced (http and https URLs cannot contain {SessionTokenUriVar})"); + + _ = Launcher.LaunchUriAsync(uri); + } + catch (Exception e) + { + _logger.LogWarning(e, "could not parse or launch app"); + + if (parameter is not FrameworkElement frameworkElement) return; + var flyout = new Flyout + { + Content = new TextBlock + { + Text = $"Could not open app: {e.Message}", + Margin = new Thickness(4), + TextWrapping = TextWrapping.Wrap, + }, + FlyoutPresenterStyle = new Style(typeof(FlyoutPresenter)) + { + Setters = + { + new Setter(ScrollViewer.HorizontalScrollModeProperty, ScrollMode.Disabled), + new Setter(ScrollViewer.HorizontalScrollBarVisibilityProperty, ScrollBarVisibility.Disabled), + }, + }, + }; + FlyoutBase.SetAttachedFlyout(frameworkElement, flyout); + FlyoutBase.ShowAttachedFlyout(frameworkElement); + } + } +} diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index f5b5e0e..c44db3e 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -1,11 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Windows.ApplicationModel.DataTransfer; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; +using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; +using Coder.Desktop.Vpn.Proto; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; namespace Coder.Desktop.App.ViewModels; +public interface IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName); +} + +public class AgentViewModelFactory( + ILogger childLogger, + ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, + IAgentAppViewModelFactory agentAppViewModelFactory) : IAgentViewModelFactory +{ + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + expanderHost, id) + { + Hostname = hostname, + HostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + }; + } +} + public enum AgentConnectionStatus { Green, @@ -14,17 +56,307 @@ public enum AgentConnectionStatus Gray, } -public partial class AgentViewModel +public partial class AgentViewModel : ObservableObject, IModelUpdateable { - public required string Hostname { get; set; } + private const string DefaultDashboardUrl = "https://coder.com"; + private const int MaxAppsPerRow = 6; + + // These are fake UUIDs, for UI purposes only. Display apps don't exist on + // the backend as real app resources and therefore don't have an ID. + private static readonly Uuid VscodeAppUuid = new("819828b1-5213-4c3d-855e-1b74db6ddd19"); + private static readonly Uuid VscodeInsidersAppUuid = new("becf1e10-5101-4940-a853-59af86468069"); + + private readonly ILogger _logger; + private readonly ICoderApiClientFactory _coderApiClientFactory; + private readonly ICredentialManager _credentialManager; + private readonly IAgentAppViewModelFactory _agentAppViewModelFactory; - public required string HostnameSuffix { get; set; } // including leading dot + // The AgentViewModel only gets created on the UI thread. + private readonly DispatcherQueue _dispatcherQueue = + DispatcherQueue.GetForCurrentThread(); - public required AgentConnectionStatus ConnectionStatus { get; set; } + private readonly IAgentExpanderHost _expanderHost; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Apps = []; + + public readonly Uuid Id; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FullHostname))] + public required partial string Hostname { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(FullHostname))] + public required partial string HostnameSuffix { get; set; } // including leading dot public string FullHostname => Hostname + HostnameSuffix; - public required string DashboardUrl { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public required partial AgentConnectionStatus ConnectionStatus { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial Uri DashboardBaseUrl { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DashboardUrl))] + public required partial string? WorkspaceName { get; set; } + + [ObservableProperty] public partial bool IsExpanded { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool FetchingApps { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + public partial bool AppFetchErrored { get; set; } = false; + + // We only show 6 apps max, which fills the entire width of the tray + // window. + public IEnumerable VisibleApps => Apps.Count > MaxAppsPerRow ? Apps.Take(MaxAppsPerRow) : Apps; + + public bool ShowExpandAppsMessage => ExpandAppsMessage != null; + + public string? ExpandAppsMessage + { + get + { + if (ConnectionStatus == AgentConnectionStatus.Gray) + return "Your workspace is offline."; + if (FetchingApps && Apps.Count == 0) + // Don't show this message if we have any apps already. When + // they finish loading, we'll just update the screen with any + // changes. + return "Fetching workspace apps..."; + if (AppFetchErrored && Apps.Count == 0) + // There's very limited screen real estate here so we don't + // show the actual error message. + return "Could not fetch workspace apps."; + if (Apps.Count == 0) + return "No apps to show."; + return null; + } + } + + public string DashboardUrl + { + get + { + if (string.IsNullOrWhiteSpace(WorkspaceName)) return DashboardBaseUrl.ToString(); + try + { + return new Uri(DashboardBaseUrl, $"/@me/{WorkspaceName}").ToString(); + } + catch + { + return DefaultDashboardUrl; + } + } + } + + public AgentViewModel(ILogger logger, ICoderApiClientFactory coderApiClientFactory, + ICredentialManager credentialManager, IAgentAppViewModelFactory agentAppViewModelFactory, + IAgentExpanderHost expanderHost, Uuid id) + { + _logger = logger; + _coderApiClientFactory = coderApiClientFactory; + _credentialManager = credentialManager; + _agentAppViewModelFactory = agentAppViewModelFactory; + _expanderHost = expanderHost; + + Id = id; + + PropertyChanged += (_, args) => + { + if (args.PropertyName == nameof(IsExpanded)) + { + _expanderHost.HandleAgentExpanded(Id, IsExpanded); + + // Every time the drawer is expanded, re-fetch all apps. + if (IsExpanded && !FetchingApps) + FetchApps(); + } + }; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Apps.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleApps))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowExpandAppsMessage))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ExpandAppsMessage))); + }; + } + + public bool TryApplyChanges(AgentViewModel model) + { + if (Id != model.Id) return false; + + // To avoid spurious UI updates which cause flashing, don't actually + // write to values unless they've changed. + if (Hostname != model.Hostname) + Hostname = model.Hostname; + if (HostnameSuffix != model.HostnameSuffix) + HostnameSuffix = model.HostnameSuffix; + if (ConnectionStatus != model.ConnectionStatus) + ConnectionStatus = model.ConnectionStatus; + if (DashboardBaseUrl != model.DashboardBaseUrl) + DashboardBaseUrl = model.DashboardBaseUrl; + if (WorkspaceName != model.WorkspaceName) + WorkspaceName = model.WorkspaceName; + + // Apps are not set externally. + + return true; + } + + [RelayCommand] + private void ToggleExpanded() + { + SetExpanded(!IsExpanded); + } + + public void SetExpanded(bool expanded) + { + if (IsExpanded == expanded) return; + // This will bubble up to the TrayWindowViewModel because of the + // PropertyChanged handler. + IsExpanded = expanded; + } + + partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue) + { + if (IsExpanded && newValue is not AgentConnectionStatus.Gray) FetchApps(); + } + + private void FetchApps() + { + if (FetchingApps) return; + FetchingApps = true; + + // If the workspace is off, then there's no agent and there's no apps. + if (ConnectionStatus == AgentConnectionStatus.Gray) + { + FetchingApps = false; + Apps.Clear(); + return; + } + + // API client creation could fail, which would leave FetchingApps true. + ICoderApiClient client; + try + { + client = _coderApiClientFactory.Create(_credentialManager); + } + catch + { + FetchingApps = false; + throw; + } + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + client.GetWorkspaceAgent(Id.ToString(), cts.Token).ContinueWith(t => + { + cts.Dispose(); + ContinueFetchApps(t); + }, CancellationToken.None); + } + + private void ContinueFetchApps(Task task) + { + // Ensure we're on the UI thread. + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => ContinueFetchApps(task)); + return; + } + + FetchingApps = false; + AppFetchErrored = !task.IsCompletedSuccessfully; + if (!task.IsCompletedSuccessfully) + { + _logger.LogWarning(task.Exception, "Could not fetch workspace agent"); + return; + } + + var workspaceAgent = task.Result; + var apps = new List(); + foreach (var app in workspaceAgent.Apps) + { + if (!app.External || !string.IsNullOrEmpty(app.Command)) continue; + + if (!Uri.TryCreate(app.Url, UriKind.Absolute, out var appUri)) + { + _logger.LogWarning("Could not parse app URI '{Url}' for '{DisplayName}', app will not appear in list", + app.Url, + app.DisplayName); + continue; + } + + // HTTP or HTTPS external apps are usually things like + // wikis/documentation, which clutters up the app. + if (appUri.Scheme is "http" or "https") + continue; + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, app.Icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(app.Id, app.DisplayName, appUri, iconUrl)); + } + + foreach (var displayApp in workspaceAgent.DisplayApps) + { + if (displayApp is not WorkspaceAgent.DisplayAppVscode and not WorkspaceAgent.DisplayAppVscodeInsiders) + continue; + + var id = VscodeAppUuid; + var displayName = "VS Code"; + var icon = "/icon/code.svg"; + var scheme = "vscode"; + if (displayApp is WorkspaceAgent.DisplayAppVscodeInsiders) + { + id = VscodeInsidersAppUuid; + displayName = "VS Code Insiders"; + icon = "/icon/code-insiders.svg"; + scheme = "vscode-insiders"; + } + + Uri appUri; + try + { + appUri = new UriBuilder + { + Scheme = scheme, + Host = "vscode-remote", + Path = $"/ssh-remote+{FullHostname}/{workspaceAgent.ExpandedDirectory}", + }.Uri; + } + catch (Exception e) + { + _logger.LogWarning(e, "Could not craft app URI for display app {displayApp}, app will not appear in list", + displayApp); + continue; + } + + // Icon parse failures are not fatal, we will just use the fallback + // icon. + _ = Uri.TryCreate(DashboardBaseUrl, icon, out var iconUrl); + + apps.Add(_agentAppViewModelFactory.Create(id, displayName, appUri, iconUrl)); + } + + // Sort by name. + ModelUpdate.ApplyLists(Apps, apps, (a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal)); + } [RelayCommand] private void CopyHostname(object parameter) diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index 9235141..da40e5c 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -339,7 +339,8 @@ public void OpenRemotePathSelectDialog() pickerViewModel.PathSelected += OnRemotePathSelected; _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); - _remotePickerWindow.SetParent(_window); + if (_window is not null) + _remotePickerWindow.SetParent(_window); _remotePickerWindow.Closed += (_, _) => { _remotePickerWindow = null; diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index ae6c910..b0c9a8b 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -1,10 +1,14 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.App.Utils; using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk; using Coder.Desktop.Vpn.Proto; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -13,11 +17,15 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Exception = System.Exception; namespace Coder.Desktop.App.ViewModels; -public partial class TrayWindowViewModel : ObservableObject +public interface IAgentExpanderHost +{ + public void HandleAgentExpanded(Uuid id, bool expanded); +} + +public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; @@ -25,11 +33,22 @@ public partial class TrayWindowViewModel : ObservableObject private readonly IServiceProvider _services; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private readonly IAgentViewModelFactory _agentViewModelFactory; private FileSyncListWindow? _fileSyncListWindow; private DispatcherQueue? _dispatcherQueue; + // When we transition from 0 online workspaces to >0 online workspaces, the + // first agent will be expanded. This bool tracks whether this has occurred + // yet (or if the user has expanded something themselves). + private bool _hasExpandedAgent; + + // This isn't an ObservableProperty because the property itself never + // changes. We add an event listener for the collection changing in the + // constructor. + public readonly ObservableCollection Agents = []; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowEnableSection))] [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))] @@ -49,13 +68,6 @@ public partial class TrayWindowViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(ShowFailedSection))] public partial string? VpnFailedMessage { get; set; } = null; - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(VisibleAgents))] - [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))] - [NotifyPropertyChangedFor(nameof(ShowAgentsSection))] - [NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))] - public partial List Agents { get; set; } = []; - public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started; public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started; @@ -76,14 +88,43 @@ public partial class TrayWindowViewModel : ObservableObject public IEnumerable VisibleAgents => ShowAllAgents ? Agents : Agents.Take(MaxAgents); - [ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com"; + [ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl; public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, - ICredentialManager credentialManager) + ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory) { _services = services; _rpcController = rpcController; _credentialManager = credentialManager; + _agentViewModelFactory = agentViewModelFactory; + + // Since the property value itself never changes, we add event + // listeners for the underlying collection changing instead. + Agents.CollectionChanged += (_, _) => + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(VisibleAgents))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowNoAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentsSection))); + OnPropertyChanged(new PropertyChangedEventArgs(nameof(ShowAgentOverflowButton))); + }; + } + + // Implements IAgentExpanderHost + public void HandleAgentExpanded(Uuid id, bool expanded) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => HandleAgentExpanded(id, expanded)); + return; + } + + if (!expanded) return; + _hasExpandedAgent = true; + // Collapse every other agent. + foreach (var otherAgent in Agents.Where(a => a.Id != id)) + otherAgent.SetExpanded(false); } public void Initialize(DispatcherQueue dispatcherQueue) @@ -93,8 +134,8 @@ public void Initialize(DispatcherQueue dispatcherQueue) _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel); UpdateFromRpcModel(_rpcController.GetState()); - _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel); - UpdateFromCredentialsModel(_credentialManager.GetCachedCredentials()); + _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialModel(credentialModel); + UpdateFromCredentialModel(_credentialManager.GetCachedCredentials()); } private void UpdateFromRpcModel(RpcModel rpcModel) @@ -107,37 +148,30 @@ private void UpdateFromRpcModel(RpcModel rpcModel) return; } - // As a failsafe, if RPC is disconnected we disable the switch. The - // Window should not show the current Page if the RPC is disconnected. - if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected) + // As a failsafe, if RPC is disconnected (or we're not signed in) we + // disable the switch. The Window should not show the current Page if + // the RPC is disconnected. + var credentialModel = _credentialManager.GetCachedCredentials(); + if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected || credentialModel.State is not CredentialState.Valid || + credentialModel.CoderUrl == null) { VpnLifecycle = VpnLifecycle.Unknown; VpnSwitchActive = false; - Agents = []; + Agents.Clear(); return; } VpnLifecycle = rpcModel.VpnLifecycle; VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started; - // Get the current dashboard URL. - var credentialModel = _credentialManager.GetCachedCredentials(); - Uri? coderUri = null; - if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl)) - try - { - coderUri = new Uri(credentialModel.CoderUrl, UriKind.Absolute); - } - catch - { - // Ignore - } - // Add every known agent. HashSet workspacesWithAgents = []; List agents = []; foreach (var agent in rpcModel.Agents) { + if (!Uuid.TryFrom(agent.Id.Span, out var uuid)) + continue; + // Find the FQDN with the least amount of dots and split it into // prefix and suffix. var fqdn = agent.Fqdn @@ -156,75 +190,95 @@ private void UpdateFromRpcModel(RpcModel rpcModel) } var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) + ? AgentConnectionStatus.Green + : AgentConnectionStatus.Yellow; workspacesWithAgents.Add(agent.WorkspaceId); var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId); - agents.Add(new AgentViewModel - { - Hostname = fqdnPrefix, - HostnameSuffix = fqdnSuffix, - ConnectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) - ? AgentConnectionStatus.Green - : AgentConnectionStatus.Yellow, - DashboardUrl = WorkspaceUri(coderUri, workspace?.Name), - }); + agents.Add(_agentViewModelFactory.Create( + this, + uuid, + fqdnPrefix, + fqdnSuffix, + connectionStatus, + credentialModel.CoderUrl, + workspace?.Name)); } // For every stopped workspace that doesn't have any agents, add a // dummy agent row. foreach (var workspace in rpcModel.Workspaces.Where(w => w.Status == Workspace.Types.Status.Stopped && !workspacesWithAgents.Contains(w.Id))) - agents.Add(new AgentViewModel - { - // We just assume that it's a single-agent workspace. - Hostname = workspace.Name, + { + if (!Uuid.TryFrom(workspace.Id.Span, out var uuid)) + continue; + + agents.Add(_agentViewModelFactory.Create( + this, + // Workspace ID is fine as a stand-in here, it shouldn't + // conflict with any agent IDs. + uuid, + // We assume that it's a single-agent workspace. + workspace.Name, // TODO: this needs to get the suffix from the server - HostnameSuffix = ".coder", - ConnectionStatus = AgentConnectionStatus.Gray, - DashboardUrl = WorkspaceUri(coderUri, workspace.Name), - }); + ".coder", + AgentConnectionStatus.Gray, + credentialModel.CoderUrl, + workspace.Name)); + } // Sort by status green, red, gray, then by hostname. - agents.Sort((a, b) => + ModelUpdate.ApplyLists(Agents, agents, (a, b) => { if (a.ConnectionStatus != b.ConnectionStatus) return a.ConnectionStatus.CompareTo(b.ConnectionStatus); return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal); }); - Agents = agents; if (Agents.Count < MaxAgents) ShowAllAgents = false; - } - private string WorkspaceUri(Uri? baseUri, string? workspaceName) - { - if (baseUri == null) return DefaultDashboardUrl; - if (string.IsNullOrWhiteSpace(workspaceName)) return baseUri.ToString(); - try - { - return new Uri(baseUri, $"/@me/{workspaceName}").ToString(); - } - catch + var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Gray); + if (firstOnlineAgent is null) + _hasExpandedAgent = false; + if (!_hasExpandedAgent && firstOnlineAgent is not null) { - return DefaultDashboardUrl; + firstOnlineAgent.SetExpanded(true); + _hasExpandedAgent = true; } } - private void UpdateFromCredentialsModel(CredentialModel credentialModel) + private void UpdateFromCredentialModel(CredentialModel credentialModel) { // Ensure we're on the UI thread. if (_dispatcherQueue == null) return; if (!_dispatcherQueue.HasThreadAccess) { - _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel)); + _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialModel(credentialModel)); return; } + // CredentialModel updates trigger RpcStateModel updates first. This + // resolves an issue on startup where the window would be locked for 5 + // seconds, even if all startup preconditions have been met: + // + // 1. RPC state updates, but credentials are invalid so the window + // enters the invalid loading state to prevent interaction. + // 2. Credential model finally becomes valid after reaching out to the + // server to check credentials. + // 3. UpdateFromCredentialModel previously did not re-trigger RpcModel + // update. + // 4. Five seconds after step 1, a new RPC state update would come in + // and finally unlock the window. + // + // Calling UpdateFromRpcModel at step 3 resolves this issue. + UpdateFromRpcModel(_rpcController.GetState()); + // HACK: the HyperlinkButton crashes the whole app if the initial URI // or this URI is invalid. CredentialModel.CoderUrl should never be // null while the Page is active as the Page is only displayed when // CredentialModel.Status == Valid. - DashboardUrl = credentialModel.CoderUrl ?? DefaultDashboardUrl; + DashboardUrl = credentialModel.CoderUrl?.ToString() ?? DefaultDashboardUrl; } public void VpnSwitch_Toggled(object sender, RoutedEventArgs e) @@ -273,13 +327,13 @@ private static string MaybeUnwrapTunnelError(Exception e) } [RelayCommand] - public void ToggleShowAllAgents() + private void ToggleShowAllAgents() { ShowAllAgents = !ShowAllAgents; } [RelayCommand] - public void ShowFileSyncListWindow() + private void ShowFileSyncListWindow() { // This is safe against concurrent access since it all happens in the // UI thread. @@ -295,7 +349,7 @@ public void ShowFileSyncListWindow() } [RelayCommand] - public void SignOut() + private void SignOut() { if (VpnLifecycle is not VpnLifecycle.Stopped) return; diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs index 2409d4b..7af6db3 100644 --- a/App/Views/DirectoryPickerWindow.xaml.cs +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.InteropServices; using Windows.Graphics; +using Coder.Desktop.App.Utils; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Windowing; @@ -8,7 +9,6 @@ using Microsoft.UI.Xaml.Media; using WinRT.Interop; using WinUIEx; -using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index fb899cc..ccd2452 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -1,8 +1,8 @@ +using Coder.Desktop.App.Utils; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views.Pages; using Microsoft.UI.Xaml.Media; using WinUIEx; -using Coder.Desktop.App.Utils; namespace Coder.Desktop.App.Views; @@ -23,5 +23,4 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) this.CenterOnScreen(); } - } diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index e21b46b..0ca754d 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -87,14 +87,14 @@ private async Task StartDaemon(CancellationToken ct) { + _logger.LogDebug("StartDaemon called"); // Stop the running daemon if (_daemonProcess != null) await StopDaemon(ct); // Attempt to stop any orphaned daemon try { + _logger.LogDebug("creating MutagenClient to stop orphan"); var client = new MutagenClient(_mutagenDataDirectory); await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); } @@ -496,6 +499,10 @@ private async Task StartDaemon(CancellationToken ct) { // Mainline; no daemon running. } + finally + { + _logger.LogDebug("finished with orphan mutagen client"); + } // If we get some failure while creating the log file or starting the process, we'll retry // it up to 5 times x 100ms. Those issues should resolve themselves quickly if they are @@ -528,6 +535,7 @@ private async Task StartDaemon(CancellationToken ct) ct.ThrowIfCancellationRequested(); try { + _logger.LogDebug("creating mainline mutagen client"); var client = new MutagenClient(_mutagenDataDirectory); _ = await client.Daemon.VersionAsync(new VersionRequest(), cancellationToken: ct); _mutagenClient = client; diff --git a/MutagenSdk/MutagenClient.cs b/MutagenSdk/MutagenClient.cs index 27ffa7a..89fad29 100644 --- a/MutagenSdk/MutagenClient.cs +++ b/MutagenSdk/MutagenClient.cs @@ -16,26 +16,6 @@ public class MutagenClient : IDisposable public MutagenClient(string dataDir) { - // Check for the lock file first, since it should exist if it's running. - var daemonLockFile = Path.Combine(dataDir, "daemon", "daemon.lock"); - if (!File.Exists(daemonLockFile)) - throw new FileNotFoundException( - "Mutagen daemon lock file not found, did the mutagen daemon start successfully?", daemonLockFile); - - // We should not be able to open the lock file. - try - { - using var _ = File.Open(daemonLockFile, FileMode.Open, FileAccess.Write, FileShare.None); - // We throw a FileNotFoundException if we could open the file because - // it means the same thing and allows us to return the path nicely. - throw new InvalidOperationException( - $"Mutagen daemon lock file '{daemonLockFile}' is unlocked, did the mutagen daemon start successfully?"); - } - catch (IOException) - { - // this is what we expect - } - // Read the IPC named pipe address from the sock file. var daemonSockFile = Path.Combine(dataDir, "daemon", "daemon.sock"); if (!File.Exists(daemonSockFile)) From 9e50acdfaa85b1317ef8743efa56bcbf804a9846 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 16 May 2025 10:02:18 +0400 Subject: [PATCH 16/16] chore: make hostname suffix mutable in views (#102) Part 1 of #49 Makes the workspace suffix dynamic in the views. A later PR in this stack will fetch the suffix and apply it to any views if it changes. If the suffix doesn't match the FQDN provided by the VPN service, we won't gray out anything when we show the name, but if we later get a suffix that matches, it will update. --- App/ViewModels/AgentViewModel.cs | 98 ++++++++++++++++++---- App/ViewModels/TrayWindowViewModel.cs | 24 ++---- App/Views/Pages/TrayWindowMainPage.xaml | 4 +- App/Views/Pages/TrayWindowMainPage.xaml.cs | 14 ++-- 4 files changed, 99 insertions(+), 41 deletions(-) diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index c44db3e..34b01d7 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -23,8 +23,13 @@ namespace Coder.Desktop.App.ViewModels; public interface IAgentViewModelFactory { - public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName); + + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName); } public class AgentViewModelFactory( @@ -33,14 +38,32 @@ public class AgentViewModelFactory( ICredentialManager credentialManager, IAgentAppViewModelFactory agentAppViewModelFactory) : IAgentViewModelFactory { - public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix, + public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName) { return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, expanderHost, id) { - Hostname = hostname, - HostnameSuffix = hostnameSuffix, + ConfiguredFqdn = fullyQualifiedDomainName, + ConfiguredHostname = string.Empty, + ConfiguredHostnameSuffix = hostnameSuffix, + ConnectionStatus = connectionStatus, + DashboardBaseUrl = dashboardBaseUrl, + WorkspaceName = workspaceName, + }; + } + + public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, + string hostnameSuffix, + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName) + { + return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, + expanderHost, id) + { + ConfiguredFqdn = string.Empty, + ConfiguredHostname = workspaceName, + ConfiguredHostnameSuffix = hostnameSuffix, ConnectionStatus = connectionStatus, DashboardBaseUrl = dashboardBaseUrl, WorkspaceName = workspaceName, @@ -84,15 +107,55 @@ public partial class AgentViewModel : ObservableObject, IModelUpdateable Hostname + HostnameSuffix; + /// + /// ViewableHostname is the hostname portion of the fully qualified domain name (FQDN) specifically for + /// views that render it differently than the suffix. If the ConfiguredHostnameSuffix doesn't actually + /// match the FQDN, then this will be the entire FQDN, and ViewableHostnameSuffix will be empty. + /// + public string ViewableHostname => !FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? FullyQualifiedDomainName + : FullyQualifiedDomainName[0..^ConfiguredHostnameSuffix.Length]; + + /// + /// ViewableHostnameSuffix is the domain suffix portion (including leading dot) of the fully qualified + /// domain name (FQDN) specifically for views that render it differently from the rest of the FQDN. If + /// the ConfiguredHostnameSuffix doesn't actually match the FQDN, then this will be empty and the + /// ViewableHostname will contain the entire FQDN. + /// + public string ViewableHostnameSuffix => FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix) + ? ConfiguredHostnameSuffix + : string.Empty; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] @@ -202,10 +265,12 @@ public bool TryApplyChanges(AgentViewModel model) // To avoid spurious UI updates which cause flashing, don't actually // write to values unless they've changed. - if (Hostname != model.Hostname) - Hostname = model.Hostname; - if (HostnameSuffix != model.HostnameSuffix) - HostnameSuffix = model.HostnameSuffix; + if (ConfiguredFqdn != model.ConfiguredFqdn) + ConfiguredFqdn = model.ConfiguredFqdn; + if (ConfiguredHostname != model.ConfiguredHostname) + ConfiguredHostname = model.ConfiguredHostname; + if (ConfiguredHostnameSuffix != model.ConfiguredHostnameSuffix) + ConfiguredHostnameSuffix = model.ConfiguredHostnameSuffix; if (ConnectionStatus != model.ConnectionStatus) ConnectionStatus = model.ConnectionStatus; if (DashboardBaseUrl != model.DashboardBaseUrl) @@ -337,12 +402,13 @@ private void ContinueFetchApps(Task task) { Scheme = scheme, Host = "vscode-remote", - Path = $"/ssh-remote+{FullHostname}/{workspaceAgent.ExpandedDirectory}", + Path = $"/ssh-remote+{FullyQualifiedDomainName}/{workspaceAgent.ExpandedDirectory}", }.Uri; } catch (Exception e) { - _logger.LogWarning(e, "Could not craft app URI for display app {displayApp}, app will not appear in list", + _logger.LogWarning(e, + "Could not craft app URI for display app {displayApp}, app will not appear in list", displayApp); continue; } @@ -365,7 +431,7 @@ private void CopyHostname(object parameter) { RequestedOperation = DataPackageOperation.Copy, }; - dataPackage.SetText(FullHostname); + dataPackage.SetText(FullyQualifiedDomainName); Clipboard.SetContent(dataPackage); if (parameter is not FrameworkElement frameworkElement) return; diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index b0c9a8b..1dccab0 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -29,6 +29,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; + private const string DefaultHostnameSuffix = ".coder"; private readonly IServiceProvider _services; private readonly IRpcController _rpcController; @@ -90,6 +91,8 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost [ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl; + private string _hostnameSuffix = DefaultHostnameSuffix; + public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController, ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory) { @@ -181,14 +184,6 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (string.IsNullOrWhiteSpace(fqdn)) continue; - var fqdnPrefix = fqdn; - var fqdnSuffix = ""; - if (fqdn.Contains('.')) - { - fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')]; - fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..]; - } - var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) ? AgentConnectionStatus.Green @@ -199,8 +194,8 @@ private void UpdateFromRpcModel(RpcModel rpcModel) agents.Add(_agentViewModelFactory.Create( this, uuid, - fqdnPrefix, - fqdnSuffix, + fqdn, + _hostnameSuffix, connectionStatus, credentialModel.CoderUrl, workspace?.Name)); @@ -214,15 +209,12 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (!Uuid.TryFrom(workspace.Id.Span, out var uuid)) continue; - agents.Add(_agentViewModelFactory.Create( + agents.Add(_agentViewModelFactory.CreateDummy( this, // Workspace ID is fine as a stand-in here, it shouldn't // conflict with any agent IDs. uuid, - // We assume that it's a single-agent workspace. - workspace.Name, - // TODO: this needs to get the suffix from the server - ".coder", + _hostnameSuffix, AgentConnectionStatus.Gray, credentialModel.CoderUrl, workspace.Name)); @@ -233,7 +225,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) { if (a.ConnectionStatus != b.ConnectionStatus) return a.ConnectionStatus.CompareTo(b.ConnectionStatus); - return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal); + return string.Compare(a.FullyQualifiedDomainName, b.FullyQualifiedDomainName, StringComparison.Ordinal); }); if (Agents.Count < MaxAgents) ShowAllAgents = false; diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index b66aa6e..f3549c2 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -169,9 +169,9 @@ TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"> - - diff --git a/App/Views/Pages/TrayWindowMainPage.xaml.cs b/App/Views/Pages/TrayWindowMainPage.xaml.cs index 5911092..e1cbab3 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml.cs +++ b/App/Views/Pages/TrayWindowMainPage.xaml.cs @@ -18,9 +18,9 @@ public TrayWindowMainPage(TrayWindowViewModel viewModel) } // HACK: using XAML to populate the text Runs results in an additional - // whitespace Run being inserted between the Hostname and the - // HostnameSuffix. You might think, "OK let's populate the entire TextBlock - // content from code then!", but this results in the ItemsRepeater + // whitespace Run being inserted between the ViewableHostname and the + // ViewableHostnameSuffix. You might think, "OK let's populate the entire + // TextBlock content from code then!", but this results in the ItemsRepeater // corrupting it and firing events off to the wrong AgentModel. // // This is the best solution I came up with that worked. @@ -28,12 +28,12 @@ public void AgentHostnameText_OnLoaded(object sender, RoutedEventArgs e) { if (sender is not TextBlock textBlock) return; - var nonEmptyRuns = new List(); + var nonWhitespaceRuns = new List(); foreach (var inline in textBlock.Inlines) - if (inline is Run run && !string.IsNullOrWhiteSpace(run.Text)) - nonEmptyRuns.Add(run); + if (inline is Run run && run.Text != " ") + nonWhitespaceRuns.Add(run); textBlock.Inlines.Clear(); - foreach (var run in nonEmptyRuns) textBlock.Inlines.Add(run); + foreach (var run in nonWhitespaceRuns) textBlock.Inlines.Add(run); } }