diff --git a/.editorconfig b/.editorconfig
index d0323ad..cd2fd68 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -76,6 +76,10 @@ resharper_web_config_module_not_resolved_highlighting = warning
resharper_web_config_type_not_resolved_highlighting = warning
resharper_web_config_wrong_module_highlighting = warning
-[{*.json,*.jsonc,*.yml,*.yaml,*.proto}]
+[{*.json,*.jsonc,*.yml,*.yaml}]
indent_style = space
indent_size = 2
+
+[{*.proto}]
+indent_style = tab
+indent_size = 1
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..5272401
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+MutagenSdk/Proto/**/*.proto linguist-generated=true
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b5059b2..459579c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -11,6 +11,7 @@ on:
jobs:
fmt:
runs-on: windows-latest
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Setup dotnet
@@ -26,6 +27,7 @@ jobs:
test:
runs-on: windows-latest
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Setup dotnet
@@ -34,13 +36,36 @@ jobs:
dotnet-version: 8.0.x
cache: true
cache-dependency-path: '**/packages.lock.json'
+ - name: Install Windows App SDK Runtime
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+
+ $filename = ".\WindowsAppRuntimeInstall-x64.exe"
+ $url = "https://download.microsoft.com/download/7a3a6a44-b07e-4ca5-8b63-2de185769dbc/WindowsAppRuntimeInstall-x64.exe" # 1.6.5 (1.6.250205002)
+ & curl.exe --progress-bar --show-error --fail --location --output $filename $url
+ if ($LASTEXITCODE -ne 0) { throw "Failed to download Windows App SDK" }
+
+ $process = Start-Process -FilePath $filename -ArgumentList "--quiet --force" -NoNewWindow -Wait -PassThru
+ if ($process.ExitCode -ne 0) { throw "Failed to install Windows App SDK: exit code is $($process.ExitCode)" }
- name: dotnet restore
run: dotnet restore --locked-mode
- name: dotnet test
- run: dotnet test --no-restore
+ run: dotnet test --no-restore --blame-hang --blame-hang-dump-type full --blame-hang-timeout 2m -p:Platform=x64
+ - name: Upload test binaries and TestResults
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ retention-days: 1
+ path: |
+ ./**/bin
+ ./**/obj
+ ./**/TestResults
build:
runs-on: windows-latest
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Setup dotnet
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 99ed8c1..e6849aa 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -18,6 +18,7 @@ permissions:
jobs:
release:
runs-on: ${{ github.repository_owner == 'coder' && 'windows-latest-16-cores' || 'windows-latest' }}
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
@@ -61,8 +62,8 @@ jobs:
id: gcloud_auth
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8
with:
- workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
- service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
+ workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
+ service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
token_format: "access_token"
- name: Install wix
diff --git a/App/App.csproj b/App/App.csproj
index cd1df42..982612f 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -30,7 +30,7 @@
-
+
@@ -56,17 +56,25 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
+
+
+
+
+
+
+
diff --git a/App/App.xaml b/App/App.xaml
index a5b6d8b..c614e0e 100644
--- a/App/App.xaml
+++ b/App/App.xaml
@@ -3,12 +3,18 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:converters="using:Coder.Desktop.App.Converters">
+
+
+
+
+
diff --git a/App/App.xaml.cs b/App/App.xaml.cs
index af4217e..ba6fa67 100644
--- a/App/App.xaml.cs
+++ b/App/App.xaml.cs
@@ -1,11 +1,27 @@
using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
using System.Threading.Tasks;
+using Windows.ApplicationModel.Activation;
+using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
using Coder.Desktop.App.ViewModels;
using Coder.Desktop.App.Views;
using Coder.Desktop.App.Views.Pages;
+using Coder.Desktop.CoderSdk.Agent;
+using Coder.Desktop.Vpn;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.UI.Xaml;
+using Microsoft.Win32;
+using Microsoft.Windows.AppLifecycle;
+using Serilog;
+using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
+using Microsoft.Windows.AppNotifications;
namespace Coder.Desktop.App;
@@ -14,18 +30,65 @@ public partial class App : Application
private readonly IServiceProvider _services;
private bool _handleWindowClosed = true;
+ private const string MutagenControllerConfigSection = "MutagenController";
+
+#if !DEBUG
+ private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\App";
+ private const string logFilename = "app.log";
+#else
+ private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugApp";
+ private const string logFilename = "debug-app.log";
+#endif
+
+ private readonly ILogger _logger;
+ private readonly IUriHandler _uriHandler;
public App()
{
- var services = new ServiceCollection();
+ var builder = Host.CreateApplicationBuilder();
+ var configBuilder = builder.Configuration as IConfigurationBuilder;
+
+ // Add config in increasing order of precedence: first builtin defaults, then HKLM, finally HKCU
+ // so that the user's settings in the registry take precedence.
+ AddDefaultConfig(configBuilder);
+ configBuilder.Add(
+ new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey));
+ configBuilder.Add(
+ new RegistryConfigurationSource(Registry.CurrentUser, ConfigSubKey));
+
+ var services = builder.Services;
+
+ // Logging
+ builder.Services.AddSerilog((_, loggerConfig) =>
+ {
+ loggerConfig.ReadFrom.Configuration(builder.Configuration);
+ });
+
+ services.AddSingleton();
+
services.AddSingleton();
services.AddSingleton();
+ services.AddOptions()
+ .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection));
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
// SignInWindow views and view models
services.AddTransient();
services.AddTransient();
+ // FileSyncListWindow views and view models
+ services.AddTransient();
+ // 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();
services.AddTransient();
services.AddTransient();
@@ -37,30 +100,149 @@ public App()
services.AddTransient();
_services = services.BuildServiceProvider();
+ _logger = (ILogger)_services.GetService(typeof(ILogger))!;
+ _uriHandler = (IUriHandler)_services.GetService(typeof(IUriHandler))!;
InitializeComponent();
}
public async Task ExitApplication()
{
+ _logger.LogDebug("exiting app");
_handleWindowClosed = false;
Exit();
- var rpcManager = _services.GetRequiredService();
+ var syncController = _services.GetRequiredService();
+ await syncController.DisposeAsync();
+ var rpcController = _services.GetRequiredService();
// TODO: send a StopRequest if we're connected???
- await rpcManager.DisposeAsync();
+ await rpcController.DisposeAsync();
Environment.Exit(0);
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
- var trayWindow = _services.GetRequiredService();
+ _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.
+ _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
+ 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 =>
+ {
+ 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);
+
+ // Initialize file sync.
+ var syncSessionCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var syncSessionController = _services.GetRequiredService();
+ _ = syncSessionController.RefreshState(syncSessionCts.Token).ContinueWith(t =>
+ {
+ if (t.IsCanceled || t.Exception != null)
+ {
+ _logger.LogError(t.Exception, "failed to refresh sync state (canceled = {canceled})", t.IsCanceled);
+#if DEBUG
+ Debugger.Break();
+#endif
+ }
+
+ syncSessionCts.Dispose();
+ }, CancellationToken.None);
// Prevent the TrayWindow from closing, just hide it.
- trayWindow.Closed += (sender, args) =>
+ var trayWindow = _services.GetRequiredService();
+ trayWindow.Closed += (_, closedArgs) =>
{
if (!_handleWindowClosed) return;
- args.Handled = true;
+ closedArgs.Handled = true;
trayWindow.AppWindow.Hide();
};
}
+
+ public void OnActivated(object? sender, AppActivationArguments args)
+ {
+ switch (args.Kind)
+ {
+ case ExtendedActivationKind.Protocol:
+ var protoArgs = args.Data as IProtocolActivatedEventArgs;
+ if (protoArgs == null)
+ {
+ _logger.LogWarning("URI activation with null data");
+ return;
+ }
+
+ // don't need to wait for it to complete.
+ _uriHandler.HandleUri(protoArgs.Uri).ContinueWith(t =>
+ {
+ if (t.Exception != null)
+ {
+ // don't log query params, as they contain secrets.
+ _logger.LogError(t.Exception,
+ "unhandled exception while processing URI coder://{authority}{path}",
+ protoArgs.Uri.Authority, protoArgs.Uri.AbsolutePath);
+ }
+ });
+
+ break;
+
+ case ExtendedActivationKind.AppNotification:
+ var notificationArgs = (args.Data as AppNotificationActivatedEventArgs)!;
+ HandleNotification(null, notificationArgs);
+ break;
+
+ default:
+ _logger.LogWarning("activation for {kind}, which is unhandled", args.Kind);
+ break;
+ }
+ }
+
+ public void HandleNotification(AppNotificationManager? sender, AppNotificationActivatedEventArgs args)
+ {
+ // right now, we don't do anything other than log
+ _logger.LogInformation("handled notification activation");
+ }
+
+ private static void AddDefaultConfig(IConfigurationBuilder builder)
+ {
+ var logPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "CoderDesktop",
+ logFilename);
+ builder.AddInMemoryCollection(new Dictionary
+ {
+ [MutagenControllerConfigSection + ":MutagenExecutablePath"] = @"C:\mutagen.exe",
+ ["Serilog:Using:0"] = "Serilog.Sinks.File",
+ ["Serilog:MinimumLevel"] = "Information",
+ ["Serilog:Enrich:0"] = "FromLogContext",
+ ["Serilog:WriteTo:0:Name"] = "File",
+ ["Serilog:WriteTo:0:Args:path"] = logPath,
+ ["Serilog:WriteTo:0:Args:outputTemplate"] =
+ "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}",
+ ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day",
+ });
+ }
}
diff --git a/App/Controls/SizedFrame.cs b/App/Controls/SizedFrame.cs
new file mode 100644
index 0000000..bd2462b
--- /dev/null
+++ b/App/Controls/SizedFrame.cs
@@ -0,0 +1,67 @@
+using System;
+using Windows.Foundation;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Controls;
+
+public class SizedFrameEventArgs : EventArgs
+{
+ public Size NewSize { get; init; }
+}
+
+///
+/// SizedFrame extends Frame by adding a SizeChanged event, which will be triggered when:
+/// - The contained Page's content's size changes
+/// - We switch to a different page.
+/// Sadly this is necessary because Window.Content.SizeChanged doesn't trigger when the Page's content changes.
+///
+public class SizedFrame : Frame
+{
+ public delegate void SizeChangeDelegate(object sender, SizedFrameEventArgs e);
+
+ public new event SizeChangeDelegate? SizeChanged;
+
+ private Size _lastSize;
+
+ public void SetPage(Page page)
+ {
+ if (ReferenceEquals(page, Content)) return;
+
+ // Set the new event listener.
+ if (page.Content is not FrameworkElement newElement)
+ throw new Exception("Failed to get Page.Content as FrameworkElement on SizedFrame navigation");
+ newElement.SizeChanged += Content_SizeChanged;
+
+ // Unset the previous event listener.
+ if (Content is Page { Content: FrameworkElement oldElement })
+ oldElement.SizeChanged -= Content_SizeChanged;
+
+ // We don't use RootFrame.Navigate here because it doesn't let you
+ // instantiate the page yourself. We also don't need forwards/backwards
+ // capabilities.
+ Content = page;
+
+ // Fire an event.
+ Content_SizeChanged(newElement, null);
+ }
+
+ public Size GetContentSize()
+ {
+ if (Content is not Page { Content: FrameworkElement frameworkElement })
+ throw new Exception("Failed to get Content as FrameworkElement for SizedFrame");
+
+ frameworkElement.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ return new Size(frameworkElement.ActualWidth, frameworkElement.ActualHeight);
+ }
+
+ private void Content_SizeChanged(object sender, SizeChangedEventArgs? _)
+ {
+ var size = GetContentSize();
+ if (size == _lastSize) return;
+ _lastSize = size;
+
+ var args = new SizedFrameEventArgs { NewSize = size };
+ SizeChanged?.Invoke(this, args);
+ }
+}
diff --git a/App/Converters/AgentStatusToColorConverter.cs b/App/Converters/AgentStatusToColorConverter.cs
deleted file mode 100644
index 25f1f66..0000000
--- a/App/Converters/AgentStatusToColorConverter.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System;
-using Windows.UI;
-using Coder.Desktop.App.ViewModels;
-using Microsoft.UI.Xaml.Data;
-using Microsoft.UI.Xaml.Media;
-
-namespace Coder.Desktop.App.Converters;
-
-public class AgentStatusToColorConverter : IValueConverter
-{
- private static readonly SolidColorBrush Green = new(Color.FromArgb(255, 52, 199, 89));
- private static readonly SolidColorBrush Yellow = new(Color.FromArgb(255, 204, 1, 0));
- private static readonly SolidColorBrush Red = new(Color.FromArgb(255, 255, 59, 48));
- private static readonly SolidColorBrush Gray = new(Color.FromArgb(255, 142, 142, 147));
-
- public object Convert(object value, Type targetType, object parameter, string language)
- {
- if (value is not AgentConnectionStatus status) return Gray;
-
- return status switch
- {
- AgentConnectionStatus.Green => Green,
- AgentConnectionStatus.Yellow => Yellow,
- AgentConnectionStatus.Red => Red,
- _ => Gray,
- };
- }
-
- public object ConvertBack(object value, Type targetType, object parameter, string language)
- {
- throw new NotImplementedException();
- }
-}
diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs
new file mode 100644
index 0000000..a31c33b
--- /dev/null
+++ b/App/Converters/DependencyObjectSelector.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Linq;
+using Windows.Foundation.Collections;
+using Windows.UI.Xaml.Markup;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Media;
+
+namespace Coder.Desktop.App.Converters;
+
+// This file uses manual DependencyProperty properties rather than
+// DependencyPropertyGenerator since it doesn't seem to work properly with
+// generics.
+
+///
+/// An item in a DependencyObjectSelector. Each item has a key and a value.
+/// The default item in a DependencyObjectSelector will be the only item
+/// with a null key.
+///
+/// Key type
+/// Value type
+public class DependencyObjectSelectorItem : DependencyObject
+ where TK : IEquatable
+{
+ public static readonly DependencyProperty KeyProperty =
+ DependencyProperty.Register(nameof(Key),
+ typeof(TK?),
+ typeof(DependencyObjectSelectorItem),
+ new PropertyMetadata(null));
+
+ public static readonly DependencyProperty ValueProperty =
+ DependencyProperty.Register(nameof(Value),
+ typeof(TV?),
+ typeof(DependencyObjectSelectorItem),
+ new PropertyMetadata(null));
+
+ public TK? Key
+ {
+ get => (TK?)GetValue(KeyProperty);
+ set => SetValue(KeyProperty, value);
+ }
+
+ public TV? Value
+ {
+ get => (TV?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+}
+
+///
+/// Allows selecting between multiple value references based on a selected
+/// key. This allows for dynamic mapping of model values to other objects.
+/// The main use case is for selecting between other bound values, which
+/// you cannot do with a simple ValueConverter.
+///
+/// Key type
+/// Value type
+[ContentProperty(Name = nameof(References))]
+public class DependencyObjectSelector : DependencyObject
+ where TK : IEquatable
+{
+ public static readonly DependencyProperty ReferencesProperty =
+ DependencyProperty.Register(nameof(References),
+ typeof(DependencyObjectCollection),
+ typeof(DependencyObjectSelector),
+ new PropertyMetadata(null, ReferencesPropertyChanged));
+
+ public static readonly DependencyProperty SelectedKeyProperty =
+ DependencyProperty.Register(nameof(SelectedKey),
+ typeof(TK?),
+ typeof(DependencyObjectSelector),
+ new PropertyMetadata(null, SelectedKeyPropertyChanged));
+
+ public static readonly DependencyProperty SelectedObjectProperty =
+ DependencyProperty.Register(nameof(SelectedObject),
+ typeof(TV?),
+ typeof(DependencyObjectSelector),
+ new PropertyMetadata(null));
+
+ public DependencyObjectCollection? References
+ {
+ get => (DependencyObjectCollection?)GetValue(ReferencesProperty);
+ set
+ {
+ // Ensure unique keys and that the values are DependencyObjectSelectorItem.
+ if (value != null)
+ {
+ var items = value.OfType>().ToArray();
+ var keys = items.Select(i => i.Key).Distinct().ToArray();
+ if (keys.Length != value.Count)
+ throw new ArgumentException("ObservableCollection Keys must be unique.");
+ }
+
+ SetValue(ReferencesProperty, value);
+ }
+ }
+
+ ///
+ /// The key of the selected item. This should be bound to a property on
+ /// the model.
+ ///
+ public TK? SelectedKey
+ {
+ get => (TK?)GetValue(SelectedKeyProperty);
+ set => SetValue(SelectedKeyProperty, value);
+ }
+
+ ///
+ /// The selected object. This can be read from to get the matching
+ /// object for the selected key. If the selected key doesn't match any
+ /// object, this will be the value of the null key. If there is no null
+ /// key, this will be null.
+ ///
+ public TV? SelectedObject
+ {
+ get => (TV?)GetValue(SelectedObjectProperty);
+ set => SetValue(SelectedObjectProperty, value);
+ }
+
+ public DependencyObjectSelector()
+ {
+ References = [];
+ }
+
+ private void UpdateSelectedObject()
+ {
+ if (References != null)
+ {
+ // Look for a matching item a matching key, or fallback to the null
+ // key.
+ var references = References.OfType>().ToArray();
+ var item = references
+ .FirstOrDefault(i =>
+ (i.Key == null && SelectedKey == null) ||
+ (i.Key != null && SelectedKey != null && i.Key!.Equals(SelectedKey!)))
+ ?? references.FirstOrDefault(i => i.Key == null);
+ if (item is not null)
+ {
+ // Bind the SelectedObject property to the reference's Value.
+ // If the underlying Value changes, it will propagate to the
+ // SelectedObject.
+ BindingOperations.SetBinding
+ (
+ this,
+ SelectedObjectProperty,
+ new Binding
+ {
+ Source = item,
+ Path = new PropertyPath(nameof(DependencyObjectSelectorItem.Value)),
+ }
+ );
+ return;
+ }
+ }
+
+ ClearValue(SelectedObjectProperty);
+ }
+
+ // Called when the References property is replaced.
+ private static void ReferencesPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
+ {
+ var self = obj as DependencyObjectSelector;
+ if (self == null) return;
+ var oldValue = args.OldValue as DependencyObjectCollection;
+ if (oldValue != null)
+ oldValue.VectorChanged -= self.OnVectorChangedReferences;
+ var newValue = args.NewValue as DependencyObjectCollection;
+ if (newValue != null)
+ newValue.VectorChanged += self.OnVectorChangedReferences;
+ }
+
+ // Called when the References collection changes without being replaced.
+ private void OnVectorChangedReferences(IObservableVector sender, IVectorChangedEventArgs args)
+ {
+ UpdateSelectedObject();
+ }
+
+ // Called when SelectedKey changes.
+ private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
+ {
+ var self = obj as DependencyObjectSelector;
+ self?.UpdateSelectedObject();
+ }
+}
+
+public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem;
+
+public sealed class StringToBrushSelector : DependencyObjectSelector;
+
+public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem;
+
+public sealed class StringToStringSelector : DependencyObjectSelector;
diff --git a/App/Converters/FriendlyByteConverter.cs b/App/Converters/FriendlyByteConverter.cs
new file mode 100644
index 0000000..c2bce4e
--- /dev/null
+++ b/App/Converters/FriendlyByteConverter.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.UI.Xaml.Data;
+
+namespace Coder.Desktop.App.Converters;
+
+public class FriendlyByteConverter : IValueConverter
+{
+ private static readonly string[] Suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
+
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ switch (value)
+ {
+ case int i:
+ if (i < 0) i = 0;
+ return FriendlyBytes((ulong)i);
+ case uint ui:
+ return FriendlyBytes(ui);
+ case long l:
+ if (l < 0) l = 0;
+ return FriendlyBytes((ulong)l);
+ case ulong ul:
+ return FriendlyBytes(ul);
+ default:
+ return FriendlyBytes(0);
+ }
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotImplementedException();
+ }
+
+ public static string FriendlyBytes(ulong bytes)
+ {
+ if (bytes == 0)
+ return $"0 {Suffixes[0]}";
+
+ var place = System.Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
+ var num = Math.Round(bytes / Math.Pow(1024, place), 1);
+ return $"{num} {Suffixes[place]}";
+ }
+}
diff --git a/App/Converters/InverseBoolConverter.cs b/App/Converters/InverseBoolConverter.cs
new file mode 100644
index 0000000..927b420
--- /dev/null
+++ b/App/Converters/InverseBoolConverter.cs
@@ -0,0 +1,17 @@
+using System;
+using Microsoft.UI.Xaml.Data;
+
+namespace Coder.Desktop.App.Converters;
+
+public class InverseBoolConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ return value is false;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/App/Converters/VpnLifecycleToVisibilityConverter.cs b/App/Converters/VpnLifecycleToVisibilityConverter.cs
deleted file mode 100644
index bf83bea..0000000
--- a/App/Converters/VpnLifecycleToVisibilityConverter.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System;
-using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Data;
-
-namespace Coder.Desktop.App.Converters;
-
-public partial class VpnLifecycleToVisibilityConverter : VpnLifecycleToBoolConverter, IValueConverter
-{
- public new object Convert(object value, Type targetType, object parameter, string language)
- {
- var boolValue = base.Convert(value, targetType, parameter, language);
- return boolValue is true ? Visibility.Visible : Visibility.Collapsed;
- }
-}
diff --git a/App/Models/CredentialModel.cs b/App/Models/CredentialModel.cs
index 5388722..542c1c0 100644
--- a/App/Models/CredentialModel.cs
+++ b/App/Models/CredentialModel.cs
@@ -2,16 +2,24 @@ namespace Coder.Desktop.App.Models;
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,
+
+ // Valid means "we checked and there are saved credentials and they are valid"
Valid,
}
public class CredentialModel
{
- public CredentialState State { get; set; } = CredentialState.Invalid;
+ public CredentialState State { get; init; } = CredentialState.Unknown;
+
+ public string? CoderUrl { get; init; }
+ public string? ApiToken { get; init; }
- public string? CoderUrl { get; set; }
- public string? ApiToken { get; set; }
+ public string? Username { get; init; }
public CredentialModel Clone()
{
@@ -20,6 +28,7 @@ public CredentialModel Clone()
State = State,
CoderUrl = CoderUrl,
ApiToken = ApiToken,
+ Username = Username,
};
}
}
diff --git a/App/Models/RpcModel.cs b/App/Models/RpcModel.cs
index dacef38..034f405 100644
--- a/App/Models/RpcModel.cs
+++ b/App/Models/RpcModel.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Linq;
using Coder.Desktop.Vpn.Proto;
namespace Coder.Desktop.App.Models;
@@ -26,9 +25,9 @@ public class RpcModel
public VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
- public List Workspaces { get; set; } = [];
+ public IReadOnlyList Workspaces { get; set; } = [];
- public List Agents { get; set; } = [];
+ public IReadOnlyList Agents { get; set; } = [];
public RpcModel Clone()
{
@@ -36,8 +35,8 @@ public RpcModel Clone()
{
RpcLifecycle = RpcLifecycle,
VpnLifecycle = VpnLifecycle,
- Workspaces = Workspaces.ToList(),
- Agents = Agents.ToList(),
+ Workspaces = Workspaces,
+ Agents = Agents,
};
}
}
diff --git a/App/Models/SyncSessionControllerStateModel.cs b/App/Models/SyncSessionControllerStateModel.cs
new file mode 100644
index 0000000..524a858
--- /dev/null
+++ b/App/Models/SyncSessionControllerStateModel.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+
+namespace Coder.Desktop.App.Models;
+
+public enum SyncSessionControllerLifecycle
+{
+ // Uninitialized means that the daemon has not been started yet. This can
+ // be resolved by calling RefreshState (or any other RPC method
+ // successfully).
+ Uninitialized,
+
+ // Stopped means that the daemon is not running. This could be because:
+ // - It was never started (pre-Initialize)
+ // - It was stopped due to no sync sessions (post-Initialize, post-operation)
+ // - The last start attempt failed (DaemonError will be set)
+ // - The last daemon process crashed (DaemonError will be set)
+ Stopped,
+
+ // Running is the normal state where the daemon is running and managing
+ // sync sessions. This is only set after a successful start (including
+ // being able to connect to the daemon).
+ Running,
+}
+
+public class SyncSessionControllerStateModel
+{
+ public SyncSessionControllerLifecycle Lifecycle { get; init; } = SyncSessionControllerLifecycle.Stopped;
+
+ ///
+ /// May be set when Lifecycle is Stopped to signify that the daemon failed
+ /// to start or unexpectedly crashed.
+ ///
+ public string? DaemonError { get; init; }
+
+ public required string DaemonLogFilePath { get; init; }
+
+ ///
+ /// This contains the last known state of all sync sessions. Sync sessions
+ /// are periodically refreshed if the daemon is running. This list is
+ /// sorted by creation time.
+ ///
+ public IReadOnlyList SyncSessions { get; init; } = [];
+}
diff --git a/App/Models/SyncSessionModel.cs b/App/Models/SyncSessionModel.cs
new file mode 100644
index 0000000..46137f5
--- /dev/null
+++ b/App/Models/SyncSessionModel.cs
@@ -0,0 +1,305 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Coder.Desktop.App.Converters;
+using Coder.Desktop.MutagenSdk.Proto.Synchronization;
+using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core;
+using Coder.Desktop.MutagenSdk.Proto.Url;
+
+namespace Coder.Desktop.App.Models;
+
+// This is a much slimmer enum than the original enum from Mutagen and only
+// contains the overarching states that we care about from a code perspective.
+// We still store the original state in the model for rendering purposes.
+public enum SyncSessionStatusCategory
+{
+ Unknown,
+ Paused,
+
+ // Halted is a combination of Error and Paused. If the session
+ // automatically pauses due to a safety check, we want to show it as an
+ // error, but also show that it can be resumed.
+ Halted,
+ Error,
+
+ // If there are any conflicts, the state will be set to Conflicts,
+ // overriding Working and Ok.
+ Conflicts,
+ Working,
+ Ok,
+}
+
+public sealed class SyncSessionModelEndpointSize
+{
+ public ulong SizeBytes { get; init; }
+ public ulong FileCount { get; init; }
+ public ulong DirCount { get; init; }
+ public ulong SymlinkCount { get; init; }
+
+ public string Description(string linePrefix = "")
+ {
+ var str =
+ $"{linePrefix}{FriendlyByteConverter.FriendlyBytes(SizeBytes)}\n" +
+ $"{linePrefix}{FileCount:N0} files\n" +
+ $"{linePrefix}{DirCount:N0} directories";
+ if (SymlinkCount > 0) str += $"\n{linePrefix} {SymlinkCount:N0} symlinks";
+
+ return str;
+ }
+}
+
+public class SyncSessionModel
+{
+ public readonly string Identifier;
+ public readonly DateTime CreatedAt;
+
+ public readonly string AlphaName;
+ public readonly string AlphaPath;
+ public readonly string BetaName;
+ public readonly string BetaPath;
+
+ public readonly SyncSessionStatusCategory StatusCategory;
+ public readonly string StatusString;
+ public readonly string StatusDescription;
+
+ public readonly SyncSessionModelEndpointSize AlphaSize;
+ public readonly SyncSessionModelEndpointSize BetaSize;
+
+ public readonly IReadOnlyList Conflicts; // Conflict descriptions
+ public readonly ulong OmittedConflicts;
+ public readonly IReadOnlyList Errors;
+
+ // If Paused is true, the session can be resumed. If false, the session can
+ // be paused.
+ public bool Paused => StatusCategory is SyncSessionStatusCategory.Paused or SyncSessionStatusCategory.Halted;
+
+ public string StatusDetails
+ {
+ get
+ {
+ var str = StatusString;
+ if (StatusCategory.ToString() != StatusString) str += $" ({StatusCategory})";
+ str += $"\n\n{StatusDescription}";
+ foreach (var err in Errors) str += $"\n\n-----\n\n{err}";
+ foreach (var conflict in Conflicts) str += $"\n\n-----\n\n{conflict}";
+ if (OmittedConflicts > 0) str += $"\n\n-----\n\n{OmittedConflicts:N0} conflicts omitted";
+ return str;
+ }
+ }
+
+ public string SizeDetails
+ {
+ get
+ {
+ var str = "Alpha:\n" + AlphaSize.Description(" ") + "\n\n" +
+ "Remote:\n" + BetaSize.Description(" ");
+ return str;
+ }
+ }
+
+ public SyncSessionModel(State state)
+ {
+ Identifier = state.Session.Identifier;
+ CreatedAt = state.Session.CreationTime.ToDateTime();
+
+ (AlphaName, AlphaPath) = NameAndPathFromUrl(state.Session.Alpha);
+ (BetaName, BetaPath) = NameAndPathFromUrl(state.Session.Beta);
+
+ switch (state.Status)
+ {
+ case Status.Disconnected:
+ StatusCategory = SyncSessionStatusCategory.Error;
+ StatusString = "Disconnected";
+ StatusDescription =
+ "The session is unpaused but not currently connected or connecting to either endpoint.";
+ break;
+ case Status.HaltedOnRootEmptied:
+ StatusCategory = SyncSessionStatusCategory.Halted;
+ StatusString = "Halted on root emptied";
+ StatusDescription = "The session is halted due to the root emptying safety check.";
+ break;
+ case Status.HaltedOnRootDeletion:
+ StatusCategory = SyncSessionStatusCategory.Halted;
+ StatusString = "Halted on root deletion";
+ StatusDescription = "The session is halted due to the root deletion safety check.";
+ break;
+ case Status.HaltedOnRootTypeChange:
+ StatusCategory = SyncSessionStatusCategory.Halted;
+ StatusString = "Halted on root type change";
+ StatusDescription = "The session is halted due to the root type change safety check.";
+ break;
+ case Status.ConnectingAlpha:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Connecting (alpha)";
+ StatusDescription = "The session is attempting to connect to the alpha endpoint.";
+ break;
+ case Status.ConnectingBeta:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Connecting (beta)";
+ StatusDescription = "The session is attempting to connect to the beta endpoint.";
+ break;
+ case Status.Watching:
+ StatusCategory = SyncSessionStatusCategory.Ok;
+ StatusString = "Watching";
+ StatusDescription = "The session is watching for filesystem changes.";
+ break;
+ case Status.Scanning:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Scanning";
+ StatusDescription = "The session is scanning the filesystem on each endpoint.";
+ break;
+ case Status.WaitingForRescan:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Waiting for rescan";
+ StatusDescription =
+ "The session is waiting to retry scanning after an error during the previous scanning operation.";
+ break;
+ case Status.Reconciling:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Reconciling";
+ StatusDescription = "The session is performing reconciliation.";
+ break;
+ case Status.StagingAlpha:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Staging (alpha)";
+ StatusDescription = "The session is staging files on alpha.";
+ break;
+ case Status.StagingBeta:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Staging (beta)";
+ StatusDescription = "The session is staging files on beta.";
+ break;
+ case Status.Transitioning:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Transitioning";
+ StatusDescription = "The session is performing transition operations on each endpoint.";
+ break;
+ case Status.Saving:
+ StatusCategory = SyncSessionStatusCategory.Working;
+ StatusString = "Saving";
+ StatusDescription = "The session is recording synchronization history to disk.";
+ break;
+ default:
+ StatusCategory = SyncSessionStatusCategory.Unknown;
+ StatusString = state.Status.ToString();
+ StatusDescription = "Unknown status message.";
+ break;
+ }
+
+ // If the session is paused, override all other statuses except Halted.
+ if (state.Session.Paused && StatusCategory is not SyncSessionStatusCategory.Halted)
+ {
+ StatusCategory = SyncSessionStatusCategory.Paused;
+ StatusString = "Paused";
+ StatusDescription = "The session is paused.";
+ }
+
+ // If there are any conflicts, override Working and Ok.
+ if (state.Conflicts.Count > 0 && StatusCategory > SyncSessionStatusCategory.Conflicts)
+ {
+ StatusCategory = SyncSessionStatusCategory.Conflicts;
+ StatusString = "Conflicts";
+ StatusDescription = "The session has conflicts that need to be resolved.";
+ }
+
+ Conflicts = state.Conflicts.Select(ConflictToString).ToList();
+ OmittedConflicts = state.ExcludedConflicts;
+
+ AlphaSize = new SyncSessionModelEndpointSize
+ {
+ SizeBytes = state.AlphaState.TotalFileSize,
+ FileCount = state.AlphaState.Files,
+ DirCount = state.AlphaState.Directories,
+ SymlinkCount = state.AlphaState.SymbolicLinks,
+ };
+ BetaSize = new SyncSessionModelEndpointSize
+ {
+ SizeBytes = state.BetaState.TotalFileSize,
+ FileCount = state.BetaState.Files,
+ DirCount = state.BetaState.Directories,
+ SymlinkCount = state.BetaState.SymbolicLinks,
+ };
+
+ List errors = [];
+ if (!string.IsNullOrWhiteSpace(state.LastError)) errors.Add($"Last error:\n {state.LastError}");
+ // TODO: scan problems + transition problems + omissions should probably be fields
+ foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Alpha scan problem: {scanProblem}");
+ if (state.AlphaState.ExcludedScanProblems > 0)
+ errors.Add($"Alpha scan problems omitted: {state.AlphaState.ExcludedScanProblems}");
+ foreach (var scanProblem in state.AlphaState.ScanProblems) errors.Add($"Beta scan problem: {scanProblem}");
+ if (state.BetaState.ExcludedScanProblems > 0)
+ errors.Add($"Beta scan problems omitted: {state.BetaState.ExcludedScanProblems}");
+ foreach (var transitionProblem in state.AlphaState.TransitionProblems)
+ errors.Add($"Alpha transition problem: {transitionProblem}");
+ if (state.AlphaState.ExcludedTransitionProblems > 0)
+ errors.Add($"Alpha transition problems omitted: {state.AlphaState.ExcludedTransitionProblems}");
+ foreach (var transitionProblem in state.AlphaState.TransitionProblems)
+ errors.Add($"Beta transition problem: {transitionProblem}");
+ if (state.BetaState.ExcludedTransitionProblems > 0)
+ errors.Add($"Beta transition problems omitted: {state.BetaState.ExcludedTransitionProblems}");
+ Errors = errors;
+ }
+
+ private static (string, string) NameAndPathFromUrl(URL url)
+ {
+ var name = "Local";
+ var path = !string.IsNullOrWhiteSpace(url.Path) ? url.Path : "Unknown";
+
+ if (url.Protocol is not Protocol.Local)
+ name = !string.IsNullOrWhiteSpace(url.Host) ? url.Host : "Unknown";
+ if (string.IsNullOrWhiteSpace(url.Host)) name = url.Host;
+
+ return (name, path);
+ }
+
+ private static string ConflictToString(Conflict conflict)
+ {
+ string? friendlyProblem = null;
+ if (conflict.AlphaChanges.Count == 1 && conflict.BetaChanges.Count == 1 &&
+ conflict.AlphaChanges[0].Old == null &&
+ conflict.BetaChanges[0].Old == null &&
+ conflict.AlphaChanges[0].New != null &&
+ conflict.BetaChanges[0].New != null)
+ friendlyProblem =
+ "An entry was created on both endpoints and they do not match. You can resolve this conflict by deleting one of the entries on either side.";
+
+ var str = $"Conflict at path '{conflict.Root}':";
+ foreach (var change in conflict.AlphaChanges)
+ str += $"\n (alpha) {ChangeToString(change)}";
+ foreach (var change in conflict.BetaChanges)
+ str += $"\n (beta) {ChangeToString(change)}";
+ if (friendlyProblem != null)
+ str += $"\n\n{friendlyProblem}";
+
+ return str;
+ }
+
+ private static string ChangeToString(Change change)
+ {
+ return $"{change.Path} ({EntryToString(change.Old)} -> {EntryToString(change.New)})";
+ }
+
+ private static string EntryToString(Entry? entry)
+ {
+ if (entry == null) return "";
+ var str = entry.Kind.ToString();
+ switch (entry.Kind)
+ {
+ case EntryKind.Directory:
+ str += $" ({entry.Contents.Count} entries)";
+ break;
+ case EntryKind.File:
+ var digest = BitConverter.ToString(entry.Digest.ToByteArray()).Replace("-", "").ToLower();
+ str += $" ({digest}, executable: {entry.Executable})";
+ break;
+ case EntryKind.SymbolicLink:
+ str += $" (target: {entry.Target})";
+ break;
+ case EntryKind.Problematic:
+ str += $" ({entry.Problem})";
+ break;
+ }
+
+ return str;
+ }
+}
diff --git a/App/Package.appxmanifest b/App/Package.appxmanifest
deleted file mode 100644
index e3ad480..0000000
--- a/App/Package.appxmanifest
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
- Coder Desktop (Package)
- Coder Technologies Inc.
- Images\StoreLogo.png
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/App/Program.cs b/App/Program.cs
index 2918caa..3749c3b 100644
--- a/App/Program.cs
+++ b/App/Program.cs
@@ -1,9 +1,11 @@
using System;
+using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.Windows.AppLifecycle;
+using Microsoft.Windows.AppNotifications;
using WinRT;
namespace Coder.Desktop.App;
@@ -26,7 +28,23 @@ private static void Main(string[] args)
try
{
ComWrappersSupport.InitializeComWrappers();
- if (!CheckSingleInstance()) return;
+ var mainInstance = GetMainInstance();
+ var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
+ if (!mainInstance.IsCurrent)
+ {
+ mainInstance.RedirectActivationToAsync(activationArgs).AsTask().Wait();
+ return;
+ }
+
+ // Register for URI handling (known as "protocol activation")
+#if DEBUG
+ const string scheme = "coder-debug";
+#else
+ const string scheme = "coder";
+#endif
+ var thisBin = Assembly.GetExecutingAssembly().Location;
+ ActivationRegistrationManager.RegisterForProtocolActivation(scheme, thisBin + ",1", "Coder Desktop", "");
+
Application.Start(p =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
@@ -38,6 +56,18 @@ private static void Main(string[] args)
e.Handled = true;
ShowExceptionAndCrash(e.Exception);
};
+
+ // redirections via RedirectActivationToAsync above get routed to the App
+ mainInstance.Activated += app.OnActivated;
+ var notificationManager = AppNotificationManager.Default;
+ notificationManager.NotificationInvoked += app.HandleNotification;
+ notificationManager.Register();
+ if (activationArgs.Kind != ExtendedActivationKind.Launch)
+ {
+ // this means we were activated without having already launched, so handle
+ // the activation as well.
+ app.OnActivated(null, activationArgs);
+ }
});
}
catch (Exception e)
@@ -46,8 +76,7 @@ private static void Main(string[] args)
}
}
- [STAThread]
- private static bool CheckSingleInstance()
+ private static AppInstance GetMainInstance()
{
#if !DEBUG
const string appInstanceName = "Coder.Desktop.App";
@@ -55,11 +84,9 @@ private static bool CheckSingleInstance()
const string appInstanceName = "Coder.Desktop.App.Debug";
#endif
- var instance = AppInstance.FindOrRegisterForKey(appInstanceName);
- return instance.IsCurrent;
+ return AppInstance.FindOrRegisterForKey(appInstanceName);
}
- [STAThread]
private static void ShowExceptionAndCrash(Exception e)
{
const string title = "Coder Desktop Fatal Error";
diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs
index a3456b7..280169c 100644
--- a/App/Services/CredentialManager.cs
+++ b/App/Services/CredentialManager.cs
@@ -6,8 +6,9 @@
using System.Threading;
using System.Threading.Tasks;
using Coder.Desktop.App.Models;
+using Coder.Desktop.CoderSdk;
+using Coder.Desktop.CoderSdk.Coder;
using Coder.Desktop.Vpn.Utilities;
-using CoderSdk;
namespace Coder.Desktop.App.Services;
@@ -18,119 +19,296 @@ public class RawCredentials
}
[JsonSerializable(typeof(RawCredentials))]
-public partial class RawCredentialsJsonContext : JsonSerializerContext
-{
-}
+public partial class RawCredentialsJsonContext : JsonSerializerContext;
public interface ICredentialManager
{
public event EventHandler CredentialsChanged;
- public CredentialModel GetCredentials();
+ ///
+ /// Returns cached credentials or an invalid credential model if none are cached. It's preferable to use
+ /// LoadCredentials if you are operating in an async context.
+ ///
+ public CredentialModel GetCachedCredentials();
+
+ ///
+ /// Get any sign-in URL. The returned value is not parsed to check if it's a valid URI.
+ ///
+ public Task GetSignInUri();
+
+ ///
+ /// Returns cached credentials or loads/verifies them from storage if not cached.
+ ///
+ public Task LoadCredentials(CancellationToken ct = default);
public Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default);
- public void ClearCredentials();
+ public Task ClearCredentials(CancellationToken ct = default);
+}
+
+public interface ICredentialBackend
+{
+ public Task ReadCredentials(CancellationToken ct = default);
+ public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default);
+ public Task DeleteCredentials(CancellationToken ct = default);
}
+///
+/// Implements ICredentialManager using an ICredentialBackend to store
+/// credentials.
+///
public class CredentialManager : ICredentialManager
{
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";
- private readonly RaiiSemaphoreSlim _lock = new(1, 1);
- private CredentialModel? _latestCredentials;
+ // _opLock is held for the full duration of SetCredentials, and partially
+ // during LoadCredentials. _opLock protects _inFlightLoad, _loadCts, and
+ // writes to _latestCredentials.
+ private readonly RaiiSemaphoreSlim _opLock = new(1, 1);
+
+ // _inFlightLoad and _loadCts are set at the beginning of a LoadCredentials
+ // call.
+ private Task? _inFlightLoad;
+ private CancellationTokenSource? _loadCts;
+
+ // Reading and writing a reference in C# is always atomic, so this doesn't
+ // need to be protected on reads with a lock in GetCachedCredentials.
+ //
+ // The volatile keyword disables optimizations on reads/writes which helps
+ // other threads see the new value quickly (no guarantee that it's
+ // 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;
+ CoderApiClientFactory = coderApiClientFactory;
+ }
public event EventHandler? CredentialsChanged;
- public CredentialModel GetCredentials()
+ public CredentialModel GetCachedCredentials()
{
- using var _ = _lock.Lock();
- if (_latestCredentials != null) return _latestCredentials.Clone();
+ // No lock required to read the reference.
+ var latestCreds = _latestCredentials;
+ // No clone needed as the model is immutable.
+ if (latestCreds != null) return latestCreds;
- var rawCredentials = ReadCredentials();
- if (rawCredentials is null)
- _latestCredentials = new CredentialModel
- {
- State = CredentialState.Invalid,
- };
- else
- _latestCredentials = new CredentialModel
- {
- State = CredentialState.Valid,
- CoderUrl = rawCredentials.CoderUrl,
- ApiToken = rawCredentials.ApiToken,
- };
- return _latestCredentials.Clone();
+ return new CredentialModel
+ {
+ State = CredentialState.Unknown,
+ };
}
- public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default)
+ public async Task GetSignInUri()
{
+ try
+ {
+ var raw = await Backend.ReadCredentials();
+ if (raw is not null && !string.IsNullOrWhiteSpace(raw.CoderUrl)) return raw.CoderUrl;
+ }
+ catch
+ {
+ // ignored
+ }
+
+ return null;
+ }
+
+ // LoadCredentials may be preempted by SetCredentials.
+ public Task LoadCredentials(CancellationToken ct = default)
+ {
+ // This function is not `async` because we may return an existing task.
+ // However, we still want to acquire the lock with the
+ // CancellationToken so it can be canceled if needed.
+ using var _ = _opLock.LockAsync(ct).Result;
+
+ // If we already have a cached value, return it.
+ var latestCreds = _latestCredentials;
+ if (latestCreds != null) return Task.FromResult(latestCreds);
+
+ // If we are already loading, return the existing task.
+ if (_inFlightLoad != null) return _inFlightLoad;
+
+ // Otherwise, kick off a new load.
+ // Note: subsequent loads returned from above will ignore the passed in
+ // CancellationToken. We set a maximum timeout of 15 seconds anyway.
+ _loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ _loadCts.CancelAfter(TimeSpan.FromSeconds(15));
+ _inFlightLoad = LoadCredentialsInner(_loadCts.Token);
+ return _inFlightLoad;
+ }
+
+ public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct)
+ {
+ using var _ = await _opLock.LockAsync(ct);
+
+ // If there's an ongoing load, cancel it.
+ if (_loadCts != null)
+ {
+ await _loadCts.CancelAsync();
+ _loadCts.Dispose();
+ _loadCts = null;
+ _inFlightLoad = null;
+ }
+
if (string.IsNullOrWhiteSpace(coderUrl)) throw new ArgumentException("Coder URL is required", nameof(coderUrl));
coderUrl = coderUrl.Trim();
- if (coderUrl.Length > 128) throw new ArgumentOutOfRangeException(nameof(coderUrl), "Coder URL is too long");
+ if (coderUrl.Length > 128) throw new ArgumentException("Coder URL is too long", nameof(coderUrl));
if (!Uri.TryCreate(coderUrl, UriKind.Absolute, out var uri))
throw new ArgumentException($"Coder URL '{coderUrl}' is not a valid URL", nameof(coderUrl));
+ if (uri.Scheme != "http" && uri.Scheme != "https")
+ throw new ArgumentException("Coder URL must be HTTP or HTTPS", nameof(coderUrl));
if (uri.PathAndQuery != "/") throw new ArgumentException("Coder URL must be the root URL", nameof(coderUrl));
if (string.IsNullOrWhiteSpace(apiToken)) throw new ArgumentException("API token is required", nameof(apiToken));
apiToken = apiToken.Trim();
- if (apiToken.Length != 33)
- throw new ArgumentOutOfRangeException(nameof(apiToken), "API token must be 33 characters long");
-
- try
- {
- var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- cts.CancelAfter(TimeSpan.FromSeconds(15));
- var sdkClient = new CoderApiClient(uri);
- sdkClient.SetSessionToken(apiToken);
- // TODO: we should probably perform a version check here too,
- // rather than letting the service do it on Start
- _ = await sdkClient.GetBuildInfo(cts.Token);
- _ = await sdkClient.GetUser(User.Me, cts.Token);
- }
- catch (Exception e)
- {
- throw new InvalidOperationException("Could not connect to or verify Coder server", e);
- }
- WriteCredentials(new RawCredentials
+ var raw = new RawCredentials
{
CoderUrl = coderUrl,
ApiToken = apiToken,
- });
+ };
+ var populateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ populateCts.CancelAfter(TimeSpan.FromSeconds(15));
+ var model = await PopulateModel(raw, populateCts.Token);
+ await Backend.WriteCredentials(raw, ct);
+ UpdateState(model);
+ }
+ public async Task ClearCredentials(CancellationToken ct = default)
+ {
+ using var _ = await _opLock.LockAsync(ct);
+ await Backend.DeleteCredentials(ct);
UpdateState(new CredentialModel
{
- State = CredentialState.Valid,
- CoderUrl = coderUrl,
- ApiToken = apiToken,
+ State = CredentialState.Invalid,
});
}
- public void ClearCredentials()
+ private async Task LoadCredentialsInner(CancellationToken ct)
{
- NativeApi.DeleteCredentials(CredentialsTargetName);
- UpdateState(new CredentialModel
+ CredentialModel model;
+ try
{
- State = CredentialState.Invalid,
- CoderUrl = null,
- ApiToken = null,
- });
+ var raw = await Backend.ReadCredentials(ct);
+ model = await PopulateModel(raw, ct);
+ }
+ catch
+ {
+ // This catch will be hit if a SetCredentials operation started, or
+ // if the read/populate failed for some other reason (e.g. HTTP
+ // timeout).
+ //
+ // We don't need to clear the credentials here, the app will think
+ // they're unset and any subsequent SetCredentials call after the
+ // user signs in again will overwrite the old invalid ones.
+ model = new CredentialModel
+ {
+ State = CredentialState.Invalid,
+ };
+ }
+
+ // Grab the lock again so we can update the state.
+ using (await _opLock.LockAsync(ct))
+ {
+ // Prevent new LoadCredentials calls from returning this task.
+ if (_loadCts != null)
+ {
+ _loadCts.Dispose();
+ _loadCts = null;
+ _inFlightLoad = null;
+ }
+
+ // If we were canceled but made it this far, try to return the
+ // latest credentials instead.
+ if (ct.IsCancellationRequested)
+ {
+ var latestCreds = _latestCredentials;
+ if (latestCreds is not null) return latestCreds;
+ }
+
+ // If there aren't any latest credentials after a cancellation, we
+ // most likely timed out and should throw.
+ ct.ThrowIfCancellationRequested();
+
+ UpdateState(model);
+ return model;
+ }
}
- private void UpdateState(CredentialModel newModel)
+ private async Task PopulateModel(RawCredentials? credentials, CancellationToken ct)
{
- using (_lock.Lock())
+ if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
+ string.IsNullOrWhiteSpace(credentials.ApiToken))
+ return new CredentialModel
+ {
+ State = CredentialState.Invalid,
+ };
+
+ BuildInfo buildInfo;
+ User me;
+ try
{
- _latestCredentials = newModel.Clone();
+ var sdkClient = CoderApiClientFactory.Create(credentials.CoderUrl);
+ // BuildInfo does not require authentication.
+ buildInfo = await sdkClient.GetBuildInfo(ct);
+ sdkClient.SetSessionToken(credentials.ApiToken);
+ me = await sdkClient.GetUser(User.Me, ct);
}
+ catch (CoderApiHttpException)
+ {
+ throw;
+ }
+ catch (Exception e)
+ {
+ throw new InvalidOperationException("Could not connect to or verify Coder server", e);
+ }
+
+ ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version);
+ if (string.IsNullOrWhiteSpace(me.Username))
+ throw new InvalidOperationException("Could not retrieve user information, username is empty");
+
+ return new CredentialModel
+ {
+ State = CredentialState.Valid,
+ CoderUrl = credentials.CoderUrl,
+ ApiToken = credentials.ApiToken,
+ Username = me.Username,
+ };
+ }
+
+ // Lock must be held when calling this function.
+ private void UpdateState(CredentialModel newModel)
+ {
+ _latestCredentials = newModel;
+ // Since the event handlers could block (or call back the
+ // CredentialManager and deadlock), we run these in a new task.
+ if (CredentialsChanged == null) return;
+ Task.Run(() => { CredentialsChanged?.Invoke(this, newModel); });
+ }
+}
- CredentialsChanged?.Invoke(this, newModel.Clone());
+public class WindowsCredentialBackend : ICredentialBackend
+{
+ private readonly string _credentialsTargetName;
+
+ public WindowsCredentialBackend(string credentialsTargetName)
+ {
+ _credentialsTargetName = credentialsTargetName;
}
- private static RawCredentials? ReadCredentials()
+ public Task ReadCredentials(CancellationToken ct = default)
{
- var raw = NativeApi.ReadCredentials(CredentialsTargetName);
- if (raw == null) return null;
+ var raw = Wincred.ReadCredentials(_credentialsTargetName);
+ if (raw == null) return Task.FromResult(null);
RawCredentials? credentials;
try
@@ -139,120 +317,188 @@ private void UpdateState(CredentialModel newModel)
}
catch (JsonException)
{
- return null;
+ credentials = null;
}
- if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
- string.IsNullOrWhiteSpace(credentials.ApiToken)) return null;
-
- return credentials;
+ return Task.FromResult(credentials);
}
- private static void WriteCredentials(RawCredentials credentials)
+ 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;
}
- private static class NativeApi
+ public Task DeleteCredentials(CancellationToken ct = default)
{
- private const int CredentialTypeGeneric = 1;
- private const int PersistenceTypeLocalComputer = 2;
- private const int ErrorNotFound = 1168;
- private const int CredMaxCredentialBlobSize = 5 * 512;
+ Wincred.DeleteCredentials(_credentialsTargetName);
+ return Task.CompletedTask;
+ }
- public static string? ReadCredentials(string targetName)
- {
- 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})");
- }
+}
- try
- {
- var cred = Marshal.PtrToStructure(credentialPtr);
- return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char));
- }
- finally
- {
- CredFree(credentialPtr);
- }
+///
+/// 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)
+ {
+ 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 void WriteCredentials(string targetName, string secret)
+ try
{
- var byteCount = Encoding.Unicode.GetByteCount(secret);
- if (byteCount > CredMaxCredentialBlobSize)
- throw new ArgumentOutOfRangeException(nameof(secret),
- $"The secret is greater than {CredMaxCredentialBlobSize} bytes");
+ var cred = Marshal.PtrToStructure(credentialPtr);
+ return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char));
+ }
+ finally
+ {
+ CredFree(credentialPtr);
+ }
+ }
- 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
+ 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))
{
- Marshal.FreeHGlobal(credentialBlob);
+ 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 DeleteCredentials(string targetName)
+ 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");
+
+ var credentialBlob = Marshal.StringToHGlobalUni(password);
+ var cred = new CREDENTIALW
{
- if (!CredDeleteW(targetName, CredentialTypeGeneric, 0))
+ Type = CredentialTypeDomainPassword,
+ TargetName = targetName,
+ CredentialBlobSize = byteCount,
+ CredentialBlob = credentialBlob,
+ Persist = PersistenceTypeLocalComputer,
+ UserName = username,
+ };
+ try
+ {
+ if (!CredWriteDomainCredentialsW(ref targetInfo, ref cred, 0))
{
var error = Marshal.GetLastWin32Error();
- if (error == ErrorNotFound) return;
- throw new InvalidOperationException($"Failed to delete credentials (Error {error})");
+ throw new InvalidOperationException($"Failed to write credentials (Error {error})");
}
}
+ finally
+ {
+ Marshal.FreeHGlobal(credentialBlob);
+ }
+ }
- [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr);
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr);
- [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags);
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredWriteW([In] ref CREDENTIALW userCredential, [In] uint flags);
- [DllImport("Advapi32.dll", SetLastError = true)]
- private static extern void CredFree([In] IntPtr cred);
+ [DllImport("Advapi32.dll", SetLastError = true)]
+ private static extern void CredFree([In] IntPtr cred);
- [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- private static extern bool CredDeleteW(string target, int type, int flags);
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredDeleteW(string target, int type, int flags);
- [StructLayout(LayoutKind.Sequential)]
- private struct CREDENTIAL
- {
- public int Flags;
- public int Type;
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredWriteDomainCredentialsW([In] ref CREDENTIAL_TARGET_INFORMATIONW target, [In] ref CREDENTIALW userCredential, [In] uint flags);
- [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CREDENTIALW
+ {
+ public int Flags;
+ public int Type;
- [MarshalAs(UnmanagedType.LPWStr)] public string Comment;
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
- public long LastWritten;
- public int CredentialBlobSize;
- public IntPtr CredentialBlob;
- public int Persist;
- public int AttributeCount;
- public IntPtr Attributes;
+ [MarshalAs(UnmanagedType.LPWStr)] public string Comment;
- [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
+ public long LastWritten;
+ public int CredentialBlobSize;
+ public IntPtr CredentialBlob;
+ public int Persist;
+ public int AttributeCount;
+ public IntPtr Attributes;
- [MarshalAs(UnmanagedType.LPWStr)] public string UserName;
- }
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
+
+ [MarshalAs(UnmanagedType.LPWStr)] public string UserName;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CREDENTIAL_TARGET_INFORMATIONW
+ {
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosServerName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string DnsServerName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string NetbiosDomainName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string DnsDomainName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string DnsTreeName;
+ [MarshalAs(UnmanagedType.LPWStr)] public string PackageName;
+
+ public uint Flags;
+ public uint CredTypeCount;
+ public IntPtr CredTypes;
}
}
diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs
new file mode 100644
index 0000000..3931b66
--- /dev/null
+++ b/App/Services/MutagenController.cs
@@ -0,0 +1,805 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.MutagenSdk;
+using Coder.Desktop.MutagenSdk.Proto.Selection;
+using Coder.Desktop.MutagenSdk.Proto.Service.Daemon;
+using Coder.Desktop.MutagenSdk.Proto.Service.Prompting;
+using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization;
+using Coder.Desktop.MutagenSdk.Proto.Synchronization;
+using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core.Ignore;
+using Coder.Desktop.MutagenSdk.Proto.Url;
+using Coder.Desktop.Vpn.Utilities;
+using Grpc.Core;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Serilog;
+using DaemonTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Daemon.TerminateRequest;
+using MutagenProtocol = Coder.Desktop.MutagenSdk.Proto.Url.Protocol;
+using SynchronizationTerminateRequest = Coder.Desktop.MutagenSdk.Proto.Service.Synchronization.TerminateRequest;
+
+namespace Coder.Desktop.App.Services;
+
+public class CreateSyncSessionRequest
+{
+ public required Endpoint Alpha { get; init; }
+ public required Endpoint Beta { get; init; }
+
+ public class Endpoint
+ {
+ public enum ProtocolKind
+ {
+ Local,
+ Ssh,
+ }
+
+ public required ProtocolKind Protocol { get; init; }
+ public string User { get; init; } = "";
+ public string Host { get; init; } = "";
+ public uint Port { get; init; } = 0;
+ public string Path { get; init; } = "";
+
+ public URL MutagenUrl
+ {
+ get
+ {
+ var protocol = Protocol switch
+ {
+ ProtocolKind.Local => MutagenProtocol.Local,
+ ProtocolKind.Ssh => MutagenProtocol.Ssh,
+ _ => throw new ArgumentException($"Invalid protocol '{Protocol}'", nameof(Protocol)),
+ };
+
+ return new URL
+ {
+ Kind = Kind.Synchronization,
+ Protocol = protocol,
+ User = User,
+ Host = Host,
+ Port = Port,
+ Path = Path,
+ };
+ }
+ }
+ }
+}
+
+public interface ISyncSessionController : IAsyncDisposable
+{
+ public event EventHandler StateChanged;
+
+ ///
+ /// Gets the current state of the controller.
+ ///
+ SyncSessionControllerStateModel GetState();
+
+ // All the following methods will raise a StateChanged event *BEFORE* they return.
+
+ ///
+ /// Starts the daemon (if it's not running) and fully refreshes the state of the controller. This should be
+ /// called at startup and after any unexpected daemon crashes to attempt to retry.
+ /// Additionally, the first call to RefreshState will start a background task to keep the state up-to-date while
+ /// the daemon is running.
+ ///
+ Task RefreshState(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);
+}
+
+// These values are the config option names used in the registry. Any option
+// here can be configured with `(Debug)?AppMutagenController:OptionName` in the registry.
+//
+// They should not be changed without backwards compatibility considerations.
+// If changed here, they should also be changed in the installer.
+public class MutagenControllerConfig
+{
+ // This is set to "[INSTALLFOLDER]\vpn\mutagen.exe" by the installer.
+ [Required] public string MutagenExecutablePath { get; set; } = @"c:\mutagen.exe";
+}
+
+///
+/// A file synchronization controller based on the Mutagen Daemon.
+///
+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;
+
+ // _state is the current state of the controller. It is updated
+ // continuously while the daemon is running and after most operations.
+ private SyncSessionControllerStateModel? _state;
+
+ // _daemonProcess is non-null while the daemon is running, starting, or
+ // in the process of stopping.
+ private Process? _daemonProcess;
+
+ private LogWriter? _logWriter;
+
+ // holds a client connected to the running mutagen daemon, if the daemon is running.
+ private MutagenClient? _mutagenClient;
+
+ // set to true if we are disposing the controller. Prevents the daemon from being
+ // restarted.
+ private bool _disposing;
+
+ private readonly string _mutagenExecutablePath;
+
+ private readonly string _mutagenDataDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "CoderDesktop",
+ "mutagen");
+
+ private string MutagenDaemonLog => Path.Combine(_mutagenDataDirectory, "daemon.log");
+
+ 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;
+
+ public async ValueTask DisposeAsync()
+ {
+ using var _ = await _lock.LockAsync(CancellationToken.None);
+ _disposing = true;
+
+ await _stateUpdateCts.CancelAsync();
+ if (_stateUpdateTask != null)
+ try
+ {
+ await _stateUpdateTask;
+ }
+ catch
+ {
+ // ignored
+ }
+
+ _stateUpdateCts.Dispose();
+
+ using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await StopDaemon(stopCts.Token);
+
+ GC.SuppressFinalize(this);
+ }
+
+ public SyncSessionControllerStateModel GetState()
+ {
+ // No lock required to read the reference.
+ var state = _state;
+ // No clone needed as the model is immutable.
+ if (state != null) return state;
+ return new SyncSessionControllerStateModel
+ {
+ Lifecycle = SyncSessionControllerLifecycle.Uninitialized,
+ DaemonError = null,
+ DaemonLogFilePath = MutagenDaemonLog,
+ SyncSessions = [],
+ };
+ }
+
+ public async Task RefreshState(CancellationToken ct = default)
+ {
+ using var _ = await _lock.LockAsync(ct);
+ var client = await EnsureDaemon(ct);
+ var state = await UpdateState(client, ct);
+ _stateUpdateTask ??= UpdateLoop(_stateUpdateCts.Token);
+ return state;
+ }
+
+ 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,
+ Specification = new CreationSpecification
+ {
+ Alpha = req.Alpha.MutagenUrl,
+ Beta = req.Beta.MutagenUrl,
+ // TODO: probably should add a configuration page for these at some point
+ Configuration = new Configuration
+ {
+ IgnoreVCSMode = IgnoreVCSMode.Ignore,
+ },
+ ConfigurationAlpha = new Configuration(),
+ ConfigurationBeta = new Configuration(),
+ },
+ }, cancellationToken: ct);
+ if (createRes == null) throw new InvalidOperationException("CreateAsync returned null");
+
+ var session = await GetSyncSession(client, createRes.Session, ct);
+ await UpdateState(client, ct);
+ return session;
+ }
+
+ public async Task PauseSyncSession(string identifier, CancellationToken ct = default)
+ {
+ using var _ = await _lock.LockAsync(ct);
+ var client = await EnsureDaemon(ct);
+
+ // Pausing sessions doesn't require prompting as seen in the mutagen CLI.
+ await using var prompter = await Prompter.Create(client, false, ct);
+ await client.Synchronization.PauseAsync(new PauseRequest
+ {
+ Prompter = prompter.Identifier,
+ Selection = new Selection
+ {
+ Specifications = { identifier },
+ },
+ }, cancellationToken: ct);
+
+ var session = await GetSyncSession(client, identifier, ct);
+ await UpdateState(client, ct);
+ return session;
+ }
+
+ public async Task ResumeSyncSession(string identifier, CancellationToken ct = default)
+ {
+ using var _ = await _lock.LockAsync(ct);
+ var client = await EnsureDaemon(ct);
+
+ await using var prompter = await Prompter.Create(client, true, ct);
+ await client.Synchronization.ResumeAsync(new ResumeRequest
+ {
+ Prompter = prompter.Identifier,
+ Selection = new Selection
+ {
+ Specifications = { identifier },
+ },
+ }, cancellationToken: ct);
+
+ var session = await GetSyncSession(client, identifier, ct);
+ await UpdateState(client, ct);
+ return session;
+ }
+
+ public async Task TerminateSyncSession(string identifier, CancellationToken ct = default)
+ {
+ using var _ = await _lock.LockAsync(ct);
+ var client = await EnsureDaemon(ct);
+
+ // Terminating sessions doesn't require prompting as seen in the mutagen CLI.
+ await using var prompter = await Prompter.Create(client, true, ct);
+
+ await client.Synchronization.TerminateAsync(new SynchronizationTerminateRequest
+ {
+ Prompter = prompter.Identifier,
+ Selection = new Selection
+ {
+ Specifications = { identifier },
+ },
+ }, cancellationToken: ct);
+
+ await UpdateState(client, ct);
+ }
+
+ private async Task UpdateLoop(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(2), ct); // 2s matches macOS app
+ try
+ {
+ // We use a zero timeout here to avoid waiting. If another
+ // operation is holding the lock, it will update the state once
+ // it completes anyway.
+ var locker = await _lock.LockAsync(TimeSpan.Zero, ct);
+ if (locker == null) continue;
+ using (locker)
+ {
+ if (_mutagenClient == null) continue;
+ await UpdateState(_mutagenClient, ct);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+
+ private static async Task GetSyncSession(MutagenClient client, string identifier,
+ CancellationToken ct)
+ {
+ var listRes = await client.Synchronization.ListAsync(new ListRequest
+ {
+ Selection = new Selection
+ {
+ Specifications = { identifier },
+ },
+ }, cancellationToken: ct);
+ if (listRes == null) throw new InvalidOperationException("ListAsync returned null");
+ if (listRes.SessionStates.Count != 1)
+ throw new InvalidOperationException("ListAsync returned wrong number of sessions");
+
+ return new SyncSessionModel(listRes.SessionStates[0]);
+ }
+
+ private void ReplaceState(SyncSessionControllerStateModel state)
+ {
+ _state = state;
+ // Since the event handlers could block (or call back the
+ // SyncSessionController and deadlock), we run these in a new task.
+ var stateChanged = StateChanged;
+ if (stateChanged == null) return;
+ Task.Run(() => stateChanged.Invoke(this, state));
+ }
+
+ ///
+ /// Refreshes state and potentially stops the daemon if there are no sessions. The client must not be used after
+ /// this method is called.
+ /// Must be called AND awaited with the lock held.
+ ///
+ private async Task UpdateState(MutagenClient client,
+ CancellationToken ct = default)
+ {
+ ListResponse listResponse;
+ try
+ {
+ listResponse = await client.Synchronization.ListAsync(new ListRequest
+ {
+ Selection = new Selection { All = true },
+ }, cancellationToken: ct);
+ if (listResponse == null)
+ throw new InvalidOperationException("ListAsync returned null");
+ }
+ catch (Exception e)
+ {
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var error = $"Failed to UpdateState: ListAsync: {e}";
+ try
+ {
+ await StopDaemon(cts.Token);
+ }
+ catch (Exception e2)
+ {
+ error = $"Failed to UpdateState: StopDaemon failed after failed ListAsync call: {e2}";
+ }
+
+ ReplaceState(new SyncSessionControllerStateModel
+ {
+ Lifecycle = SyncSessionControllerLifecycle.Stopped,
+ DaemonError = error,
+ DaemonLogFilePath = MutagenDaemonLog,
+ SyncSessions = [],
+ });
+ throw;
+ }
+
+ var lifecycle = SyncSessionControllerLifecycle.Running;
+ if (listResponse.SessionStates.Count == 0)
+ {
+ lifecycle = SyncSessionControllerLifecycle.Stopped;
+ try
+ {
+ await StopDaemon(ct);
+ }
+ catch (Exception e)
+ {
+ ReplaceState(new SyncSessionControllerStateModel
+ {
+ Lifecycle = SyncSessionControllerLifecycle.Stopped,
+ DaemonError = $"Failed to stop daemon after no sessions: {e}",
+ DaemonLogFilePath = MutagenDaemonLog,
+ SyncSessions = [],
+ });
+ throw new InvalidOperationException("Failed to stop daemon after no sessions", e);
+ }
+ }
+
+ var sessions = listResponse.SessionStates
+ .Select(s => new SyncSessionModel(s))
+ .ToList();
+ sessions.Sort((a, b) => a.CreatedAt < b.CreatedAt ? -1 : 1);
+ var state = new SyncSessionControllerStateModel
+ {
+ Lifecycle = lifecycle,
+ DaemonError = null,
+ DaemonLogFilePath = MutagenDaemonLog,
+ SyncSessions = sessions,
+ };
+ ReplaceState(state);
+ return state;
+ }
+
+ ///
+ /// Starts the daemon if it's not running and returns a client to it.
+ /// Must be called AND awaited with the lock held.
+ ///
+ private async Task EnsureDaemon(CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(_disposing, typeof(MutagenController));
+ if (_mutagenClient != null && _daemonProcess != null)
+ return _mutagenClient;
+
+ try
+ {
+ return await StartDaemon(ct);
+ }
+ catch (Exception e)
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ try
+ {
+ await StopDaemon(cts.Token);
+ }
+ catch (Exception stopEx)
+ {
+ _logger.LogError(stopEx, "failed to stop daemon");
+ }
+
+ ReplaceState(new SyncSessionControllerStateModel
+ {
+ Lifecycle = SyncSessionControllerLifecycle.Stopped,
+ DaemonError = $"Failed to start daemon: {e}",
+ DaemonLogFilePath = MutagenDaemonLog,
+ SyncSessions = [],
+ });
+
+ throw;
+ }
+ }
+
+ ///
+ /// Starts the daemon and returns a client to it.
+ /// Must be called AND awaited with the lock held.
+ ///
+ private async Task StartDaemon(CancellationToken ct)
+ {
+ // Stop the running daemon
+ if (_daemonProcess != null) await StopDaemon(ct);
+
+ // Attempt to stop any orphaned daemon
+ try
+ {
+ var client = new MutagenClient(_mutagenDataDirectory);
+ await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct);
+ }
+ catch (FileNotFoundException)
+ {
+ // Mainline; no daemon running.
+ }
+ catch (InvalidOperationException)
+ {
+ // Mainline; no daemon running.
+ }
+
+ // 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
+ // going to at all.
+ const int maxAttempts = 5;
+ for (var attempts = 1; attempts <= maxAttempts; attempts++)
+ {
+ ct.ThrowIfCancellationRequested();
+ try
+ {
+ StartDaemonProcess();
+ }
+ 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.
+ await Task.Delay(100, ct);
+ continue;
+ }
+
+ break;
+ }
+
+ // Wait for the RPC to be available.
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+ try
+ {
+ var client = new MutagenClient(_mutagenDataDirectory);
+ _ = await client.Daemon.VersionAsync(new VersionRequest(), cancellationToken: ct);
+ _mutagenClient = client;
+ return client;
+ }
+ catch (Exception e) when
+ (e is not OperationCanceledException) // TODO: Are there other permanent errors we can detect?
+ {
+ // just wait a little longer for the daemon to come up
+ await Task.Delay(100, ct);
+ }
+ }
+ }
+
+ ///
+ /// Starts the daemon process.
+ /// Must be called AND awaited with the lock held.
+ ///
+ private void StartDaemonProcess()
+ {
+ if (_daemonProcess != null)
+ throw new InvalidOperationException("StartDaemonProcess called when _daemonProcess already present");
+
+ // create the log file first, so ensure we have permissions
+ Directory.CreateDirectory(_mutagenDataDirectory);
+ var logPath = Path.Combine(_mutagenDataDirectory, "daemon.log");
+ var logStream = new StreamWriter(logPath, true);
+
+ _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;
+ // 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 += (_, _) =>
+ {
+ var exitCode = -1;
+ try
+ {
+ // ReSharper disable once AccessToDisposedClosure
+ exitCode = daemonProcess.ExitCode;
+ }
+ catch
+ {
+ // ignored
+ }
+
+ _logger.LogInformation("mutagen daemon exited with code {exitCode}", exitCode);
+ };
+
+ try
+ {
+ if (!daemonProcess.Start())
+ throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false");
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning(e, "mutagen daemon failed to start");
+
+ logStream.Dispose();
+ try
+ {
+ daemonProcess.Kill();
+ }
+ catch
+ {
+ // ignored, the process likely doesn't exist
+ }
+
+ daemonProcess.Dispose();
+ throw;
+ }
+
+ _daemonProcess = daemonProcess;
+ var writer = new LogWriter(_daemonProcess.StandardError, logStream);
+ Task.Run(() => { _ = writer.Run(); });
+ _logWriter = writer;
+ }
+
+ ///
+ /// Stops the daemon process.
+ /// Must be called AND awaited with the lock held.
+ ///
+ private async Task StopDaemon(CancellationToken ct)
+ {
+ _logger.LogDebug("stopping mutagen daemon");
+ var process = _daemonProcess;
+ var client = _mutagenClient;
+ var writer = _logWriter;
+ _daemonProcess = null;
+ _mutagenClient = null;
+ _logWriter = null;
+
+ try
+ {
+ 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 (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);
+ }
+ }
+
+ 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();
+ }
+ }
+
+ private class Prompter : IAsyncDisposable
+ {
+ public event EventHandler? OnProgress;
+
+ private readonly AsyncDuplexStreamingCall _dup;
+ private readonly CancellationTokenSource _cts;
+ private readonly Task _handleRequestsTask;
+ public string Identifier { get; }
+
+ private Prompter(string identifier, AsyncDuplexStreamingCall dup,
+ CancellationToken ct)
+ {
+ Identifier = identifier;
+ _dup = dup;
+
+ _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ _handleRequestsTask = HandleRequests(_cts.Token);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+ try
+ {
+ await _handleRequestsTask;
+ }
+ catch
+ {
+ // ignored
+ }
+
+ _cts.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ public static async Task Create(MutagenClient client, bool allowPrompts = false,
+ CancellationToken ct = default)
+ {
+ var dup = client.Prompting.Host(cancellationToken: ct);
+ if (dup == null) throw new InvalidOperationException("Prompting.Host returned null");
+
+ try
+ {
+ // Write first request.
+ await dup.RequestStream.WriteAsync(new HostRequest
+ {
+ AllowPrompts = allowPrompts,
+ }, ct);
+
+ // Read initial response.
+ if (!await dup.ResponseStream.MoveNext(ct))
+ throw new InvalidOperationException("Prompting.Host response stream ended early");
+ var response = dup.ResponseStream.Current;
+ if (response == null)
+ throw new InvalidOperationException("Prompting.Host response stream returned null");
+ if (string.IsNullOrEmpty(response.Identifier))
+ throw new InvalidOperationException("Prompting.Host response stream returned empty identifier");
+
+ return new Prompter(response.Identifier, dup, ct);
+ }
+ catch
+ {
+ await dup.RequestStream.CompleteAsync();
+ dup.Dispose();
+ throw;
+ }
+ }
+
+ private async Task HandleRequests(CancellationToken ct)
+ {
+ try
+ {
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ // Read next request and validate it.
+ if (!await _dup.ResponseStream.MoveNext(ct))
+ throw new InvalidOperationException("Prompting.Host response stream ended early");
+ var response = _dup.ResponseStream.Current;
+ if (response == null)
+ throw new InvalidOperationException("Prompting.Host response stream returned null");
+ 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 = "";
+ if (response.IsPrompt && response.Message.Contains("yes/no/[fingerprint]")) reply = "yes";
+
+ await _dup.RequestStream.WriteAsync(new HostRequest
+ {
+ Response = reply,
+ }, ct);
+ }
+ }
+ catch
+ {
+ await _dup.RequestStream.CompleteAsync();
+ // TODO: log?
+ }
+ }
+ }
+
+ private class LogWriter(StreamReader reader, StreamWriter writer) : IDisposable
+ {
+ public void Dispose()
+ {
+ reader.Dispose();
+ writer.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ public async Task Run()
+ {
+ try
+ {
+ while (await reader.ReadLineAsync() is { } line) await writer.WriteLineAsync(line);
+ }
+ catch
+ {
+ // TODO: Log?
+ }
+ finally
+ {
+ Dispose();
+ }
+ }
+ }
+}
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/RpcController.cs b/App/Services/RpcController.cs
index a02347f..17d3ccb 100644
--- a/App/Services/RpcController.cs
+++ b/App/Services/RpcController.cs
@@ -96,8 +96,8 @@ public async Task Reconnect(CancellationToken ct = default)
{
state.RpcLifecycle = RpcLifecycle.Connecting;
state.VpnLifecycle = VpnLifecycle.Stopped;
- state.Workspaces.Clear();
- state.Agents.Clear();
+ state.Workspaces = [];
+ state.Agents = [];
});
if (_speaker != null)
@@ -127,8 +127,8 @@ public async Task Reconnect(CancellationToken ct = default)
{
state.RpcLifecycle = RpcLifecycle.Disconnected;
state.VpnLifecycle = VpnLifecycle.Unknown;
- state.Workspaces.Clear();
- state.Agents.Clear();
+ state.Workspaces = [];
+ state.Agents = [];
});
throw new RpcOperationException("Failed to reconnect to the RPC server", e);
}
@@ -137,8 +137,8 @@ public async Task Reconnect(CancellationToken ct = default)
{
state.RpcLifecycle = RpcLifecycle.Connected;
state.VpnLifecycle = VpnLifecycle.Unknown;
- state.Workspaces.Clear();
- state.Agents.Clear();
+ state.Workspaces = [];
+ state.Agents = [];
});
var statusReply = await _speaker.SendRequestAwaitReply(new ClientMessage
@@ -146,7 +146,8 @@ public async Task Reconnect(CancellationToken ct = default)
Status = new StatusRequest(),
}, ct);
if (statusReply.MsgCase != ServiceMessage.MsgOneofCase.Status)
- throw new InvalidOperationException($"Unexpected reply message type: {statusReply.MsgCase}");
+ throw new VpnLifecycleException(
+ $"Failed to get VPN status. Unexpected reply message type: {statusReply.MsgCase}");
ApplyStatusUpdate(statusReply.Status);
}
@@ -155,9 +156,10 @@ public async Task StartVpn(CancellationToken ct = default)
using var _ = await AcquireOperationLockNowAsync();
AssertRpcConnected();
- var credentials = _credentialManager.GetCredentials();
+ var credentials = _credentialManager.GetCachedCredentials();
if (credentials.State != CredentialState.Valid)
- throw new RpcOperationException("Cannot start VPN without valid credentials");
+ throw new RpcOperationException(
+ $"Cannot start VPN without valid credentials, current state: {credentials.State}");
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Starting; });
@@ -172,8 +174,6 @@ public async Task StartVpn(CancellationToken ct = default)
ApiToken = credentials.ApiToken,
},
}, ct);
- if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start)
- throw new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}");
}
catch (Exception e)
{
@@ -181,11 +181,19 @@ public async Task StartVpn(CancellationToken ct = default)
throw new RpcOperationException("Failed to send start command to service", e);
}
+ if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start)
+ {
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
+ throw new VpnLifecycleException($"Failed to start VPN. Unexpected reply message type: {reply.MsgCase}");
+ }
+
if (!reply.Start.Success)
{
+ // We use Stopped instead of Unknown here as it's usually the case
+ // that a failed start got cleaned up successfully.
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
- throw new VpnLifecycleException("Failed to start VPN",
- new InvalidOperationException($"Service reported failure: {reply.Start.ErrorMessage}"));
+ throw new VpnLifecycleException(
+ $"Failed to start VPN. Service reported failure: {reply.Start.ErrorMessage}");
}
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Started; });
@@ -212,16 +220,20 @@ public async Task StopVpn(CancellationToken ct = default)
}
finally
{
- // Technically the state is unknown now.
- MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
}
if (reply.MsgCase != ServiceMessage.MsgOneofCase.Stop)
- throw new VpnLifecycleException("Failed to stop VPN",
- new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}"));
+ {
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
+ throw new VpnLifecycleException($"Failed to stop VPN. Unexpected reply message type: {reply.MsgCase}");
+ }
+
if (!reply.Stop.Success)
- throw new VpnLifecycleException("Failed to stop VPN",
- new InvalidOperationException($"Service reported failure: {reply.Stop.ErrorMessage}"));
+ {
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
+ throw new VpnLifecycleException($"Failed to stop VPN. Service reported failure: {reply.Stop.ErrorMessage}");
+ }
}
public async ValueTask DisposeAsync()
@@ -264,10 +276,8 @@ private void ApplyStatusUpdate(Status status)
Status.Types.Lifecycle.Stopped => VpnLifecycle.Stopped,
_ => VpnLifecycle.Stopped,
};
- state.Workspaces.Clear();
- state.Workspaces.AddRange(status.PeerUpdate.UpsertedWorkspaces);
- state.Agents.Clear();
- state.Agents.AddRange(status.PeerUpdate.UpsertedAgents);
+ state.Workspaces = status.PeerUpdate.UpsertedWorkspaces;
+ state.Agents = status.PeerUpdate.UpsertedAgents;
});
}
diff --git a/App/Services/UriHandler.cs b/App/Services/UriHandler.cs
new file mode 100644
index 0000000..b0b0a9a
--- /dev/null
+++ b/App/Services/UriHandler.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Web;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.Vpn.Proto;
+using Microsoft.Extensions.Logging;
+
+
+namespace Coder.Desktop.App.Services;
+
+public interface IUriHandler
+{
+ public Task HandleUri(Uri uri, CancellationToken ct = default);
+}
+
+public class UriHandler(
+ ILogger logger,
+ IRpcController rpcController,
+ IUserNotifier userNotifier,
+ IRdpConnector rdpConnector) : IUriHandler
+{
+ private const string OpenWorkspacePrefix = "/v0/open/ws/";
+
+ internal class UriException : Exception
+ {
+ internal readonly string Title;
+ internal readonly string Detail;
+
+ internal UriException(string title, string detail) : base($"{title}: {detail}")
+ {
+ Title = title;
+ Detail = detail;
+ }
+ }
+
+ public async Task HandleUri(Uri uri, CancellationToken ct = default)
+ {
+ try
+ {
+ await HandleUriThrowingErrors(uri, ct);
+ }
+ catch (UriException e)
+ {
+ await userNotifier.ShowErrorNotification(e.Title, e.Detail, ct);
+ }
+ }
+
+ private async Task HandleUriThrowingErrors(Uri uri, CancellationToken ct = default)
+ {
+ if (uri.AbsolutePath.StartsWith(OpenWorkspacePrefix))
+ {
+ await HandleOpenWorkspaceApp(uri, ct);
+ return;
+ }
+
+ logger.LogWarning("unhandled URI path {path}", uri.AbsolutePath);
+ throw new UriException("URI handling error",
+ $"URI with path '{uri.AbsolutePath}' is unsupported or malformed");
+ }
+
+ public async Task HandleOpenWorkspaceApp(Uri uri, CancellationToken ct = default)
+ {
+ const string errTitle = "Open Workspace Application Error";
+ var subpath = uri.AbsolutePath[OpenWorkspacePrefix.Length..];
+ var components = subpath.Split("/");
+ if (components.Length != 4 || components[1] != "agent")
+ {
+ logger.LogWarning("unsupported open workspace app format in URI {path}", uri.AbsolutePath);
+ throw new UriException(errTitle, $"Failed to open '{uri.AbsolutePath}' because the format is unsupported.");
+ }
+
+ var workspaceName = components[0];
+ var agentName = components[2];
+ var appName = components[3];
+
+ var state = rpcController.GetState();
+ if (state.VpnLifecycle != VpnLifecycle.Started)
+ {
+ logger.LogDebug("got URI to open workspace '{workspace}', but Coder Connect is not started", workspaceName);
+ throw new UriException(errTitle,
+ $"Failed to open application on '{workspaceName}' because Coder Connect is not started.");
+ }
+
+ var workspace = state.Workspaces.FirstOrDefault(w => w.Name == workspaceName);
+ if (workspace == null)
+ {
+ logger.LogDebug("got URI to open workspace '{workspace}', but the workspace doesn't exist", workspaceName);
+ throw new UriException(errTitle,
+ $"Failed to open application on workspace '{workspaceName}' because it doesn't exist");
+ }
+
+ var agent = state.Agents.FirstOrDefault(a => a.WorkspaceId == workspace.Id && a.Name == agentName);
+ if (agent == null)
+ {
+ logger.LogDebug(
+ "got URI to open workspace/agent '{workspaceName}/{agentName}', but the agent doesn't exist",
+ workspaceName, agentName);
+ // If the workspace isn't running, that is almost certainly why we can't find the agent, so report that
+ // to the user.
+ if (workspace.Status != Workspace.Types.Status.Running)
+ {
+ throw new UriException(errTitle,
+ $"Failed to open application on workspace '{workspaceName}', because the workspace is not running.");
+ }
+
+ throw new UriException(errTitle,
+ $"Failed to open application on workspace '{workspaceName}', because agent '{agentName}' doesn't exist.");
+ }
+
+ if (appName != "rdp")
+ {
+ logger.LogWarning("unsupported agent application type {app}", appName);
+ throw new UriException(errTitle,
+ $"Failed to open agent in URI '{uri.AbsolutePath}' because application '{appName}' is unsupported");
+ }
+
+ await OpenRDP(agent.Fqdn.First(), uri.Query, ct);
+ }
+
+ public async Task OpenRDP(string domainName, string queryString, CancellationToken ct = default)
+ {
+ const string errTitle = "Workspace Remote Desktop Error";
+ NameValueCollection query;
+ try
+ {
+ query = HttpUtility.ParseQueryString(queryString);
+ }
+ catch (Exception ex)
+ {
+ // unfortunately, we can't safely write they query string to logs because it might contain
+ // sensitive info like a password. This is also why we don't log the exception directly
+ var trace = new System.Diagnostics.StackTrace(ex, false);
+ logger.LogWarning("failed to parse open RDP query string: {classMethod}",
+ trace?.GetFrame(0)?.GetMethod()?.ReflectedType?.FullName);
+ throw new UriException(errTitle,
+ "Failed to open remote desktop on a workspace because the URI was malformed");
+ }
+
+ var username = query.Get("username");
+ var password = query.Get("password");
+ if (!string.IsNullOrEmpty(username))
+ {
+ password ??= string.Empty;
+ rdpConnector.WriteCredentials(domainName, new RdpCredentials(username, password));
+ }
+
+ await rdpConnector.Connect(domainName, ct: ct);
+ }
+}
diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs
new file mode 100644
index 0000000..9150f47
--- /dev/null
+++ b/App/Services/UserNotifier.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Windows.AppNotifications;
+using Microsoft.Windows.AppNotifications.Builder;
+
+namespace Coder.Desktop.App.Services;
+
+public interface IUserNotifier : IAsyncDisposable
+{
+ public Task ShowErrorNotification(string title, string message, CancellationToken ct = default);
+}
+
+public class UserNotifier : IUserNotifier
+{
+ private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default;
+
+ public ValueTask DisposeAsync()
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ public Task ShowErrorNotification(string title, string message, CancellationToken ct = default)
+ {
+ var builder = new AppNotificationBuilder().AddText(title).AddText(message);
+ _notificationManager.Show(builder.BuildNotification());
+ return Task.CompletedTask;
+ }
+}
+
diff --git a/App/Utils/TitleBarIcon.cs b/App/Utils/TitleBarIcon.cs
new file mode 100644
index 0000000..3efc81d
--- /dev/null
+++ b/App/Utils/TitleBarIcon.cs
@@ -0,0 +1,19 @@
+using System;
+using Microsoft.UI;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using WinRT.Interop;
+
+namespace Coder.Desktop.App.Utils
+{
+ public static class TitleBarIcon
+ {
+ public static void SetTitlebarIcon(Window window)
+ {
+ var hwnd = WindowNative.GetWindowHandle(window);
+ var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
+ AppWindow.GetFromWindowId(windowId).SetIcon("coder.ico");
+ }
+ }
+}
diff --git a/App/ViewModels/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs
new file mode 100644
index 0000000..131934f
--- /dev/null
+++ b/App/ViewModels/DirectoryPickerViewModel.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Coder.Desktop.CoderSdk.Agent;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public class DirectoryPickerBreadcrumb
+{
+ // HACK: you cannot access the parent context when inside an ItemsRepeater.
+ public required DirectoryPickerViewModel ViewModel;
+
+ public required string Name { get; init; }
+
+ public required IReadOnlyList AbsolutePathSegments { get; init; }
+
+ // HACK: we need to know which one is first so we don't prepend an arrow
+ // icon. You can't get the index of the current ItemsRepeater item in XAML.
+ public required bool IsFirst { get; init; }
+}
+
+public enum DirectoryPickerItemKind
+{
+ ParentDirectory, // aka. ".."
+ Directory,
+ File, // includes everything else
+}
+
+public class DirectoryPickerItem
+{
+ // HACK: you cannot access the parent context when inside an ItemsRepeater.
+ public required DirectoryPickerViewModel ViewModel;
+
+ public required DirectoryPickerItemKind Kind { get; init; }
+ public required string Name { get; init; }
+ public required IReadOnlyList AbsolutePathSegments { get; init; }
+
+ public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory;
+}
+
+public partial class DirectoryPickerViewModel : ObservableObject
+{
+ // PathSelected will be called ONCE when the user either cancels or selects
+ // a directory. If the user cancelled, the path will be null.
+ public event EventHandler? PathSelected;
+
+ private const int RequestTimeoutMilliseconds = 15_000;
+
+ private readonly IAgentApiClient _client;
+
+ private Window? _window;
+ private DispatcherQueue? _dispatcherQueue;
+
+ public readonly string AgentFqdn;
+
+ // The initial loading screen is differentiated from subsequent loading
+ // screens because:
+ // 1. We don't want to show a broken state while the page is loading.
+ // 2. An error dialog allows the user to get to a broken state with no
+ // breadcrumbs, no items, etc. with no chance to reload.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowListScreen))]
+ public partial bool InitialLoading { get; set; } = true;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowErrorScreen))]
+ [NotifyPropertyChangedFor(nameof(ShowListScreen))]
+ public partial string? InitialLoadError { get; set; } = null;
+
+ [ObservableProperty] public partial bool NavigatingLoading { get; set; } = false;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsSelectable))]
+ public partial string CurrentDirectory { get; set; } = "";
+
+ [ObservableProperty] public partial IReadOnlyList Breadcrumbs { get; set; } = [];
+
+ [ObservableProperty] public partial IReadOnlyList Items { get; set; } = [];
+
+ public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading;
+ public bool ShowErrorScreen => InitialLoadError != null;
+ public bool ShowListScreen => InitialLoadError == null && !InitialLoading;
+
+ // The "root" directory on Windows isn't a real thing, but in our model
+ // it's a drive listing. We don't allow users to select the fake drive
+ // listing directory.
+ //
+ // On Linux, this will never be empty since the highest you can go is "/".
+ public bool IsSelectable => CurrentDirectory != "";
+
+ public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn)
+ {
+ _client = clientFactory.Create(agentFqdn);
+ AgentFqdn = agentFqdn;
+ }
+
+ public void Initialize(Window window, DispatcherQueue dispatcherQueue)
+ {
+ _window = window;
+ _dispatcherQueue = dispatcherQueue;
+ if (!_dispatcherQueue.HasThreadAccess)
+ throw new InvalidOperationException("Initialize must be called from the UI thread");
+
+ InitialLoading = true;
+ InitialLoadError = null;
+ // Initial load is in the home directory.
+ _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad);
+ }
+
+ [RelayCommand]
+ private void RetryLoad()
+ {
+ InitialLoading = true;
+ InitialLoadError = null;
+ // Subsequent loads after the initial failure are always in the root
+ // directory in case there's a permanent issue preventing listing the
+ // home directory.
+ _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad);
+ }
+
+ private async Task BackgroundLoad(ListDirectoryRelativity relativity, List path)
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ return await _client.ListDirectory(new ListDirectoryRequest
+ {
+ Path = path,
+ Relativity = relativity,
+ }, cts.Token);
+ }
+
+ private void ContinueInitialLoad(Task task)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => ContinueInitialLoad(task));
+ return;
+ }
+
+ if (task.IsCompletedSuccessfully)
+ {
+ ProcessResponse(task.Result);
+ return;
+ }
+
+ InitialLoadError = "Could not list home directory in workspace: ";
+ if (task.IsCanceled) InitialLoadError += new TaskCanceledException();
+ else if (task.IsFaulted) InitialLoadError += task.Exception;
+ else InitialLoadError += "no successful result or error";
+ InitialLoading = false;
+ }
+
+ [RelayCommand]
+ public async Task ListPath(IReadOnlyList path)
+ {
+ if (_window is null || NavigatingLoading) return;
+ NavigatingLoading = true;
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds));
+ try
+ {
+ var res = await _client.ListDirectory(new ListDirectoryRequest
+ {
+ Path = path.ToList(),
+ Relativity = ListDirectoryRelativity.Root,
+ }, cts.Token);
+ ProcessResponse(res);
+ }
+ catch (Exception e)
+ {
+ // Subsequent listing errors are just shown as dialog boxes.
+ var dialog = new ContentDialog
+ {
+ Title = "Failed to list remote directory",
+ Content = $"{e}",
+ CloseButtonText = "Ok",
+ XamlRoot = _window.Content.XamlRoot,
+ };
+ _ = await dialog.ShowAsync();
+ }
+ finally
+ {
+ NavigatingLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ public void Cancel()
+ {
+ PathSelected?.Invoke(this, null);
+ _window?.Close();
+ }
+
+ [RelayCommand]
+ public void Select()
+ {
+ if (CurrentDirectory == "") return;
+ PathSelected?.Invoke(this, CurrentDirectory);
+ _window?.Close();
+ }
+
+ private void ProcessResponse(ListDirectoryResponse res)
+ {
+ InitialLoading = false;
+ InitialLoadError = null;
+ NavigatingLoading = false;
+
+ var breadcrumbs = new List(res.AbsolutePath.Count + 1)
+ {
+ new()
+ {
+ Name = "🖥️",
+ AbsolutePathSegments = [],
+ IsFirst = true,
+ ViewModel = this,
+ },
+ };
+ for (var i = 0; i < res.AbsolutePath.Count; i++)
+ breadcrumbs.Add(new DirectoryPickerBreadcrumb
+ {
+ Name = res.AbsolutePath[i],
+ AbsolutePathSegments = res.AbsolutePath[..(i + 1)],
+ IsFirst = false,
+ ViewModel = this,
+ });
+
+ var items = new List(res.Contents.Count + 1);
+ if (res.AbsolutePath.Count != 0)
+ items.Add(new DirectoryPickerItem
+ {
+ Kind = DirectoryPickerItemKind.ParentDirectory,
+ Name = "..",
+ AbsolutePathSegments = res.AbsolutePath[..^1],
+ ViewModel = this,
+ });
+
+ foreach (var item in res.Contents)
+ {
+ if (item.Name.StartsWith(".")) continue;
+ items.Add(new DirectoryPickerItem
+ {
+ Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File,
+ Name = item.Name,
+ AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(),
+ ViewModel = this,
+ });
+ }
+
+ CurrentDirectory = res.AbsolutePathString;
+ Breadcrumbs = breadcrumbs;
+ Items = items;
+ }
+}
diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs
new file mode 100644
index 0000000..9235141
--- /dev/null
+++ b/App/ViewModels/FileSyncListViewModel.cs
@@ -0,0 +1,508 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+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;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using WinRT.Interop;
+
+namespace Coder.Desktop.App.ViewModels;
+
+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))]
+ [NotifyPropertyChangedFor(nameof(ShowLoading))]
+ [NotifyPropertyChangedFor(nameof(ShowError))]
+ [NotifyPropertyChangedFor(nameof(ShowSessions))]
+ public partial string? UnavailableMessage { get; set; } = null;
+
+ // Initially we use the current cached state, the loading screen is only
+ // shown when the user clicks "Reload" on the error screen.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoading))]
+ [NotifyPropertyChangedFor(nameof(ShowSessions))]
+ public partial bool Loading { get; set; } = false;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowLoading))]
+ [NotifyPropertyChangedFor(nameof(ShowError))]
+ [NotifyPropertyChangedFor(nameof(ShowSessions))]
+ public partial string? Error { get; set; } = null;
+
+ [ObservableProperty] public partial bool OperationInProgress { get; set; } = false;
+
+ [ObservableProperty] public partial IReadOnlyList Sessions { get; set; } = [];
+
+ [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+ public partial string NewSessionLocalPath { get; set; } = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+ public partial bool NewSessionLocalPathDialogOpen { get; set; } = false;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))]
+ public partial IReadOnlyList AvailableHosts { get; set; } = [];
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+ [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
+ public partial string? NewSessionRemoteHost { get; set; } = null;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+ public partial string NewSessionRemotePath { get; set; } = "";
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))]
+ [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))]
+ public partial bool NewSessionRemotePathDialogOpen { get; set; } = false;
+
+ public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0;
+
+ public bool NewSessionRemotePathDialogEnabled =>
+ !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen;
+
+ [ObservableProperty] public partial string NewSessionStatus { get; set; } = "";
+
+ public bool NewSessionCreateEnabled
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(NewSessionLocalPath)) return false;
+ if (NewSessionLocalPathDialogOpen) return false;
+ if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false;
+ if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false;
+ if (NewSessionRemotePathDialogOpen) return false;
+ return true;
+ }
+ }
+
+ // TODO: this could definitely be improved
+ public bool ShowUnavailable => UnavailableMessage != null;
+ public bool ShowLoading => Loading && UnavailableMessage == null && Error == null;
+ public bool ShowError => UnavailableMessage == null && Error != null;
+ public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null;
+
+ public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController,
+ ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory)
+ {
+ _syncSessionController = syncSessionController;
+ _rpcController = rpcController;
+ _credentialManager = credentialManager;
+ _agentApiClientFactory = agentApiClientFactory;
+ }
+
+ 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");
+
+ _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();
+ MaybeSetUnavailableMessage(rpcModel, credentialModel);
+ var syncSessionState = _syncSessionController.GetState();
+ UpdateSyncSessionState(syncSessionState);
+ }
+
+ private void RpcControllerStateChanged(object? sender, RpcModel rpcModel)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => RpcControllerStateChanged(sender, rpcModel));
+ return;
+ }
+
+ var credentialModel = _credentialManager.GetCachedCredentials();
+ MaybeSetUnavailableMessage(rpcModel, credentialModel);
+ }
+
+ private void CredentialManagerCredentialsChanged(object? sender, CredentialModel credentialModel)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => CredentialManagerCredentialsChanged(sender, credentialModel));
+ return;
+ }
+
+ var rpcModel = _rpcController.GetState();
+ MaybeSetUnavailableMessage(rpcModel, credentialModel);
+ }
+
+ private void SyncSessionStateChanged(object? sender, SyncSessionControllerStateModel syncSessionState)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => SyncSessionStateChanged(sender, syncSessionState));
+ return;
+ }
+
+ UpdateSyncSessionState(syncSessionState);
+ }
+
+ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel credentialModel)
+ {
+ var oldMessage = UnavailableMessage;
+ if (rpcModel.RpcLifecycle != RpcLifecycle.Connected)
+ {
+ UnavailableMessage =
+ "Disconnected from the Windows service. Please see the tray window for more information.";
+ }
+ else if (credentialModel.State != CredentialState.Valid)
+ {
+ UnavailableMessage = "Please sign in to access file sync.";
+ }
+ else if (rpcModel.VpnLifecycle != VpnLifecycle.Started)
+ {
+ UnavailableMessage = "Please start Coder Connect from the tray window to access file sync.";
+ }
+ 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)
+ {
+ Error = syncSessionState.DaemonError;
+ Sessions = syncSessionState.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList();
+ }
+
+ private void ClearNewForm()
+ {
+ CreatingNewSession = false;
+ NewSessionLocalPath = "";
+ NewSessionRemoteHost = "";
+ NewSessionRemotePath = "";
+ NewSessionStatus = "";
+ _remotePickerWindow?.Close();
+ }
+
+ [RelayCommand]
+ private void ReloadSessions()
+ {
+ Loading = true;
+ Error = null;
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ _syncSessionController.RefreshState(cts.Token).ContinueWith(HandleRefresh, CancellationToken.None);
+ }
+
+ private void HandleRefresh(Task t)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => HandleRefresh(t));
+ return;
+ }
+
+ if (t.IsCompletedSuccessfully)
+ {
+ Sessions = t.Result.SyncSessions.Select(s => new SyncSessionViewModel(this, s)).ToList();
+ Loading = false;
+ Error = t.Result.DaemonError;
+ return;
+ }
+
+ Error = "Could not list sync sessions: ";
+ if (t.IsCanceled) Error += new TaskCanceledException();
+ else if (t.IsFaulted) Error += t.Exception;
+ else Error += "no successful result or error";
+ 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;
+ }
+
+ [RelayCommand]
+ public async Task OpenLocalPathSelectDialog()
+ {
+ if (_window is null) return;
+
+ var picker = new FolderPicker
+ {
+ SuggestedStartLocation = PickerLocationId.ComputerFolder,
+ };
+
+ var hwnd = WindowNative.GetWindowHandle(_window);
+ InitializeWithWindow.Initialize(picker, hwnd);
+
+ NewSessionLocalPathDialogOpen = true;
+ try
+ {
+ var path = await picker.PickSingleFolderAsync();
+ if (path == null) return;
+ NewSessionLocalPath = path.Path;
+ }
+ catch
+ {
+ // ignored
+ }
+ finally
+ {
+ NewSessionLocalPathDialogOpen = false;
+ }
+ }
+
+ [RelayCommand]
+ public void OpenRemotePathSelectDialog()
+ {
+ if (string.IsNullOrWhiteSpace(NewSessionRemoteHost))
+ return;
+ if (_remotePickerWindow is not null)
+ {
+ _remotePickerWindow.Activate();
+ return;
+ }
+
+ NewSessionRemotePathDialogOpen = true;
+ var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, NewSessionRemoteHost);
+ pickerViewModel.PathSelected += OnRemotePathSelected;
+
+ _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel);
+ _remotePickerWindow.SetParent(_window);
+ _remotePickerWindow.Closed += (_, _) =>
+ {
+ _remotePickerWindow = null;
+ NewSessionRemotePathDialogOpen = false;
+ };
+ _remotePickerWindow.Activate();
+ }
+
+ private void OnRemotePathSelected(object? sender, string? path)
+ {
+ if (sender is not DirectoryPickerViewModel pickerViewModel) return;
+ pickerViewModel.PathSelected -= OnRemotePathSelected;
+
+ if (path == null) return;
+ NewSessionRemotePath = path;
+ }
+
+ [RelayCommand]
+ private void CancelNewSession()
+ {
+ ClearNewForm();
+ }
+
+ private void OnCreateSessionProgress(string message)
+ {
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => OnCreateSessionProgress(message));
+ return;
+ }
+
+ NewSessionStatus = message;
+ }
+
+ [RelayCommand]
+ private async Task ConfirmNewSession()
+ {
+ if (OperationInProgress || !NewSessionCreateEnabled) return;
+ OperationInProgress = true;
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
+ try
+ {
+ // The controller will send us a state changed event.
+ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest
+ {
+ Alpha = new CreateSyncSessionRequest.Endpoint
+ {
+ Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local,
+ Path = NewSessionLocalPath,
+ },
+ Beta = new CreateSyncSessionRequest.Endpoint
+ {
+ Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh,
+ Host = NewSessionRemoteHost!,
+ Path = NewSessionRemotePath,
+ },
+ }, OnCreateSessionProgress, cts.Token);
+
+ ClearNewForm();
+ }
+ catch (Exception e)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = "Failed to create sync session",
+ Content = $"{e}",
+ CloseButtonText = "Ok",
+ XamlRoot = _window?.Content.XamlRoot,
+ };
+ _ = await dialog.ShowAsync();
+ }
+ finally
+ {
+ OperationInProgress = false;
+ NewSessionStatus = "";
+ }
+ }
+
+ public async Task PauseOrResumeSession(string identifier)
+ {
+ if (OperationInProgress) return;
+ OperationInProgress = true;
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ var actionString = "resume/pause";
+ try
+ {
+ if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session)
+ throw new InvalidOperationException("Session not found");
+
+ // The controller will send us a state changed event.
+ if (session.Model.Paused)
+ {
+ actionString = "resume";
+ await _syncSessionController.ResumeSyncSession(session.Model.Identifier, cts.Token);
+ }
+ else
+ {
+ actionString = "pause";
+ await _syncSessionController.PauseSyncSession(session.Model.Identifier, cts.Token);
+ }
+ }
+ catch (Exception e)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = $"Failed to {actionString} sync session",
+ Content = $"Identifier: {identifier}\n{e}",
+ CloseButtonText = "Ok",
+ XamlRoot = _window?.Content.XamlRoot,
+ };
+ _ = await dialog.ShowAsync();
+ }
+ finally
+ {
+ OperationInProgress = false;
+ }
+ }
+
+ public async Task TerminateSession(string identifier)
+ {
+ if (OperationInProgress) return;
+ OperationInProgress = true;
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
+ try
+ {
+ if (Sessions.FirstOrDefault(s => s.Model.Identifier == identifier) is not { } session)
+ throw new InvalidOperationException("Session not found");
+
+ var confirmDialog = new ContentDialog
+ {
+ Title = "Terminate sync session",
+ Content = "Are you sure you want to terminate this sync session?",
+ PrimaryButtonText = "Terminate",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = _window?.Content.XamlRoot,
+ };
+ var res = await confirmDialog.ShowAsync();
+ if (res is not ContentDialogResult.Primary)
+ return;
+
+ // The controller will send us a state changed event.
+ await _syncSessionController.TerminateSyncSession(session.Model.Identifier, cts.Token);
+ }
+ catch (Exception e)
+ {
+ var dialog = new ContentDialog
+ {
+ Title = "Failed to terminate sync session",
+ Content = $"Identifier: {identifier}\n{e}",
+ CloseButtonText = "Ok",
+ XamlRoot = _window?.Content.XamlRoot,
+ };
+ _ = await dialog.ShowAsync();
+ }
+ finally
+ {
+ OperationInProgress = false;
+ }
+ }
+}
diff --git a/App/ViewModels/SignInViewModel.cs b/App/ViewModels/SignInViewModel.cs
index ae64f2b..fcd47d4 100644
--- a/App/ViewModels/SignInViewModel.cs
+++ b/App/ViewModels/SignInViewModel.cs
@@ -6,6 +6,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
namespace Coder.Desktop.App.ViewModels;
@@ -33,8 +34,6 @@ public partial class SignInViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(ApiTokenError))]
public partial bool ApiTokenTouched { get; set; } = false;
- [ObservableProperty] public partial string? SignInError { get; set; } = null;
-
[ObservableProperty] public partial bool SignInLoading { get; set; } = false;
public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null;
@@ -82,6 +81,29 @@ public SignInViewModel(ICredentialManager credentialManager)
_credentialManager = credentialManager;
}
+ // When the URL box loads, get the old URI from the credential manager.
+ // This is an async operation on paper, but we would expect it to be
+ // synchronous or extremely quick in practice.
+ public void CoderUrl_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not TextBox textBox) return;
+
+ var dispatcherQueue = textBox.DispatcherQueue;
+ _credentialManager.GetSignInUri().ContinueWith(t =>
+ {
+ if (t.IsCompleted && !string.IsNullOrWhiteSpace(t.Result))
+ dispatcherQueue.TryEnqueue(() =>
+ {
+ if (!CoderUrlTouched)
+ {
+ CoderUrl = t.Result;
+ CoderUrlTouched = true;
+ textBox.SelectionStart = CoderUrl.Length;
+ }
+ });
+ });
+ }
+
public void CoderUrl_FocusLost(object sender, RoutedEventArgs e)
{
CoderUrlTouched = true;
@@ -117,7 +139,6 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow)
try
{
SignInLoading = true;
- SignInError = null;
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token);
@@ -126,7 +147,14 @@ public async Task TokenPage_SignIn(SignInWindow signInWindow)
}
catch (Exception e)
{
- SignInError = $"Failed to sign in: {e}";
+ var dialog = new ContentDialog
+ {
+ Title = "Failed to sign in",
+ Content = $"{e}",
+ CloseButtonText = "Ok",
+ XamlRoot = signInWindow.Content.XamlRoot,
+ };
+ _ = await dialog.ShowAsync();
}
finally
{
diff --git a/App/ViewModels/SyncSessionViewModel.cs b/App/ViewModels/SyncSessionViewModel.cs
new file mode 100644
index 0000000..7de6500
--- /dev/null
+++ b/App/ViewModels/SyncSessionViewModel.cs
@@ -0,0 +1,69 @@
+using System.Threading.Tasks;
+using Coder.Desktop.App.Models;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public partial class SyncSessionViewModel : ObservableObject
+{
+ public SyncSessionModel Model { get; }
+
+ private FileSyncListViewModel Parent { get; }
+
+ public string Icon => Model.Paused ? "\uE768" : "\uE769";
+
+ public SyncSessionViewModel(FileSyncListViewModel parent, SyncSessionModel model)
+ {
+ Parent = parent;
+ Model = model;
+ }
+
+ [RelayCommand]
+ public async Task PauseOrResumeSession()
+ {
+ await Parent.PauseOrResumeSession(Model.Identifier);
+ }
+
+ [RelayCommand]
+ public async Task TerminateSession()
+ {
+ await Parent.TerminateSession(Model.Identifier);
+ }
+
+ // Check the comments in FileSyncListMainPage.xaml to see why this tooltip
+ // stuff is necessary.
+ private void SetToolTip(FrameworkElement element, string text)
+ {
+ // Get current tooltip and compare the text. Setting the tooltip with
+ // the same text causes it to dismiss itself.
+ var currentToolTip = ToolTipService.GetToolTip(element) as ToolTip;
+ if (currentToolTip?.Content as string == text) return;
+
+ ToolTipService.SetToolTip(element, new ToolTip { Content = text });
+ }
+
+ public void OnStatusTextLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement element) return;
+ SetToolTip(element, Model.StatusDetails);
+ }
+
+ public void OnStatusTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
+ {
+ SetToolTip(sender, Model.StatusDetails);
+ }
+
+ public void OnSizeTextLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement element) return;
+ SetToolTip(element, Model.SizeDetails);
+ }
+
+ public void OnSizeTextDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
+ {
+ SetToolTip(sender, Model.SizeDetails);
+ }
+}
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index 1fccb7e..f845521 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -1,15 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading.Tasks;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
+using Coder.Desktop.App.Views;
using Coder.Desktop.Vpn.Proto;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Google.Protobuf;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Exception = System.Exception;
namespace Coder.Desktop.App.ViewModels;
@@ -18,27 +22,53 @@ public partial class TrayWindowViewModel : ObservableObject
private const int MaxAgents = 5;
private const string DefaultDashboardUrl = "https://coder.com";
+ private readonly IServiceProvider _services;
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;
+ private FileSyncListWindow? _fileSyncListWindow;
+
private DispatcherQueue? _dispatcherQueue;
- [ObservableProperty] public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowEnableSection))]
+ [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
+ [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
+ [NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
+ public partial VpnLifecycle VpnLifecycle { get; set; } = VpnLifecycle.Unknown;
// This is a separate property because we need the switch to be 2-way.
[ObservableProperty] public partial bool VpnSwitchActive { get; set; } = false;
- [ObservableProperty] public partial string? VpnFailedMessage { get; set; } = null;
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowEnableSection))]
+ [NotifyPropertyChangedFor(nameof(ShowWorkspacesHeader))]
+ [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
+ [NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
+ [NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
+ [NotifyPropertyChangedFor(nameof(ShowFailedSection))]
+ public partial string? VpnFailedMessage { get; set; } = null;
[ObservableProperty]
- [NotifyPropertyChangedFor(nameof(NoAgents))]
- [NotifyPropertyChangedFor(nameof(AgentOverflow))]
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
+ [NotifyPropertyChangedFor(nameof(ShowNoAgentsSection))]
+ [NotifyPropertyChangedFor(nameof(ShowAgentsSection))]
+ [NotifyPropertyChangedFor(nameof(ShowAgentOverflowButton))]
public partial List Agents { get; set; } = [];
- public bool NoAgents => Agents.Count == 0;
+ public bool ShowEnableSection => VpnFailedMessage is null && VpnLifecycle is not VpnLifecycle.Started;
+
+ public bool ShowWorkspacesHeader => VpnFailedMessage is null && VpnLifecycle is VpnLifecycle.Started;
+
+ public bool ShowNoAgentsSection =>
+ VpnFailedMessage is null && Agents.Count == 0 && VpnLifecycle is VpnLifecycle.Started;
- public bool AgentOverflow => Agents.Count > MaxAgents;
+ public bool ShowAgentsSection =>
+ VpnFailedMessage is null && Agents.Count > 0 && VpnLifecycle is VpnLifecycle.Started;
+
+ public bool ShowFailedSection => VpnFailedMessage is not null;
+
+ public bool ShowAgentOverflowButton => VpnFailedMessage is null && Agents.Count > MaxAgents;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VisibleAgents))]
@@ -48,8 +78,10 @@ public partial class TrayWindowViewModel : ObservableObject
[ObservableProperty] public partial string DashboardUrl { get; set; } = "https://coder.com";
- public TrayWindowViewModel(IRpcController rpcController, ICredentialManager credentialManager)
+ public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController,
+ ICredentialManager credentialManager)
{
+ _services = services;
_rpcController = rpcController;
_credentialManager = credentialManager;
}
@@ -62,7 +94,7 @@ public void Initialize(DispatcherQueue dispatcherQueue)
UpdateFromRpcModel(_rpcController.GetState());
_credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel);
- UpdateFromCredentialsModel(_credentialManager.GetCredentials());
+ UpdateFromCredentialsModel(_credentialManager.GetCachedCredentials());
}
private void UpdateFromRpcModel(RpcModel rpcModel)
@@ -89,7 +121,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
VpnSwitchActive = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
// Get the current dashboard URL.
- var credentialModel = _credentialManager.GetCredentials();
+ var credentialModel = _credentialManager.GetCachedCredentials();
Uri? coderUri = null;
if (credentialModel.State == CredentialState.Valid && !string.IsNullOrWhiteSpace(credentialModel.CoderUrl))
try
@@ -146,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),
@@ -179,6 +212,14 @@ private string WorkspaceUri(Uri? baseUri, string? workspaceName)
private void UpdateFromCredentialsModel(CredentialModel credentialModel)
{
+ // Ensure we're on the UI thread.
+ if (_dispatcherQueue == null) return;
+ if (!_dispatcherQueue.HasThreadAccess)
+ {
+ _dispatcherQueue.TryEnqueue(() => UpdateFromCredentialsModel(credentialModel));
+ return;
+ }
+
// 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
@@ -190,30 +231,69 @@ public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (sender is not ToggleSwitch toggleSwitch) return;
- VpnFailedMessage = "";
+ VpnFailedMessage = null;
+
+ // The start/stop methods will call back to update the state.
+ if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped)
+ _ = StartVpn(); // in the background
+ else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started)
+ _ = StopVpn(); // in the background
+ else
+ toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
+ }
+
+ private async Task StartVpn()
+ {
try
{
- // The start/stop methods will call back to update the state.
- if (toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Stopped)
- _rpcController.StartVpn();
- else if (!toggleSwitch.IsOn && VpnLifecycle is VpnLifecycle.Started)
- _rpcController.StopVpn();
- else
- toggleSwitch.IsOn = VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
+ await _rpcController.StartVpn();
}
- catch
+ catch (Exception e)
{
- // TODO: display error
- VpnFailedMessage = e.ToString();
+ VpnFailedMessage = "Failed to start CoderVPN: " + MaybeUnwrapTunnelError(e);
}
}
+ private async Task StopVpn()
+ {
+ try
+ {
+ await _rpcController.StopVpn();
+ }
+ catch (Exception e)
+ {
+ VpnFailedMessage = "Failed to stop CoderVPN: " + MaybeUnwrapTunnelError(e);
+ }
+ }
+
+ private static string MaybeUnwrapTunnelError(Exception e)
+ {
+ if (e is VpnLifecycleException vpnError) return vpnError.Message;
+ return e.ToString();
+ }
+
[RelayCommand]
public void ToggleShowAllAgents()
{
ShowAllAgents = !ShowAllAgents;
}
+ [RelayCommand]
+ public void ShowFileSyncListWindow()
+ {
+ // This is safe against concurrent access since it all happens in the
+ // UI thread.
+ if (_fileSyncListWindow != null)
+ {
+ _fileSyncListWindow.Activate();
+ return;
+ }
+
+ _fileSyncListWindow = _services.GetRequiredService();
+ _fileSyncListWindow.Closed += (_, _) => _fileSyncListWindow = null;
+ _fileSyncListWindow.Activate();
+ }
+
[RelayCommand]
public void SignOut()
{
diff --git a/App/Views/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml
new file mode 100644
index 0000000..8a107cb
--- /dev/null
+++ b/App/Views/DirectoryPickerWindow.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs
new file mode 100644
index 0000000..2409d4b
--- /dev/null
+++ b/App/Views/DirectoryPickerWindow.xaml.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Runtime.InteropServices;
+using Windows.Graphics;
+using Coder.Desktop.App.ViewModels;
+using Coder.Desktop.App.Views.Pages;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using WinRT.Interop;
+using WinUIEx;
+using Coder.Desktop.App.Utils;
+
+namespace Coder.Desktop.App.Views;
+
+public sealed partial class DirectoryPickerWindow : WindowEx
+{
+ public DirectoryPickerWindow(DirectoryPickerViewModel viewModel)
+ {
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+
+ viewModel.Initialize(this, DispatcherQueue);
+ RootFrame.Content = new DirectoryPickerMainPage(viewModel);
+
+ // This will be moved to the center of the parent window in SetParent.
+ this.CenterOnScreen();
+ }
+
+ public void SetParent(Window parentWindow)
+ {
+ // Move the window to the center of the parent window.
+ var scale = DisplayScale.WindowScale(parentWindow);
+ var windowPos = new PointInt32(
+ parentWindow.AppWindow.Position.X + parentWindow.AppWindow.Size.Width / 2 - AppWindow.Size.Width / 2,
+ parentWindow.AppWindow.Position.Y + parentWindow.AppWindow.Size.Height / 2 - AppWindow.Size.Height / 2
+ );
+
+ // Ensure we stay within the display.
+ var workArea = DisplayArea.GetFromPoint(parentWindow.AppWindow.Position, DisplayAreaFallback.Primary).WorkArea;
+ if (windowPos.X + AppWindow.Size.Width > workArea.X + workArea.Width) // right edge
+ windowPos.X = workArea.X + workArea.Width - AppWindow.Size.Width;
+ if (windowPos.Y + AppWindow.Size.Height > workArea.Y + workArea.Height) // bottom edge
+ windowPos.Y = workArea.Y + workArea.Height - AppWindow.Size.Height;
+ if (windowPos.X < workArea.X) // left edge
+ windowPos.X = workArea.X;
+ if (windowPos.Y < workArea.Y) // top edge
+ windowPos.Y = workArea.Y;
+
+ AppWindow.Move(windowPos);
+
+ var parentHandle = WindowNative.GetWindowHandle(parentWindow);
+ var thisHandle = WindowNative.GetWindowHandle(this);
+
+ // Set the parent window in win API.
+ NativeApi.SetWindowParent(thisHandle, parentHandle);
+
+ // Override the presenter, which allows us to enable modal-like
+ // behavior for this window:
+ // - Disables the parent window
+ // - Any activations of the parent window will play a bell sound and
+ // focus the modal window
+ //
+ // This behavior is very similar to the native file/directory picker on
+ // Windows.
+ var presenter = OverlappedPresenter.CreateForDialog();
+ presenter.IsModal = true;
+ AppWindow.SetPresenter(presenter);
+ AppWindow.Show();
+
+ // Cascade close events.
+ parentWindow.Closed += OnParentWindowClosed;
+ Closed += (_, _) =>
+ {
+ parentWindow.Closed -= OnParentWindowClosed;
+ parentWindow.Activate();
+ };
+ }
+
+ private void OnParentWindowClosed(object? sender, WindowEventArgs e)
+ {
+ Close();
+ }
+
+ private static class NativeApi
+ {
+ [DllImport("user32.dll")]
+ private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
+
+ public static void SetWindowParent(IntPtr window, IntPtr parent)
+ {
+ SetWindowLongPtr(window, -8, parent);
+ }
+ }
+}
diff --git a/App/Views/FileSyncListWindow.xaml b/App/Views/FileSyncListWindow.xaml
new file mode 100644
index 0000000..070efd2
--- /dev/null
+++ b/App/Views/FileSyncListWindow.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs
new file mode 100644
index 0000000..fb899cc
--- /dev/null
+++ b/App/Views/FileSyncListWindow.xaml.cs
@@ -0,0 +1,27 @@
+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;
+
+public sealed partial class FileSyncListWindow : WindowEx
+{
+ public readonly FileSyncListViewModel ViewModel;
+
+ public FileSyncListWindow(FileSyncListViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+
+ ViewModel.Initialize(this, DispatcherQueue);
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml.cs b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs
new file mode 100644
index 0000000..4e26200
--- /dev/null
+++ b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs
@@ -0,0 +1,27 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class DirectoryPickerMainPage : Page
+{
+ public readonly DirectoryPickerViewModel ViewModel;
+
+ public DirectoryPickerMainPage(DirectoryPickerViewModel viewModel)
+ {
+ ViewModel = viewModel;
+ InitializeComponent();
+ }
+
+ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e)
+ {
+ ToolTipService.SetToolTip(sender, null);
+ if (!sender.IsTextTrimmed) return;
+
+ var toolTip = new ToolTip
+ {
+ Content = sender.Text,
+ };
+ ToolTipService.SetToolTip(sender, toolTip);
+ }
+}
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml
new file mode 100644
index 0000000..cb9f2bb
--- /dev/null
+++ b/App/Views/Pages/FileSyncListMainPage.xaml
@@ -0,0 +1,377 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs
new file mode 100644
index 0000000..a677522
--- /dev/null
+++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs
@@ -0,0 +1,28 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class FileSyncListMainPage : Page
+{
+ public FileSyncListViewModel ViewModel;
+
+ public FileSyncListMainPage(FileSyncListViewModel viewModel)
+ {
+ ViewModel = viewModel; // already initialized
+ InitializeComponent();
+ }
+
+ // Adds a tooltip with the full text when it's ellipsized.
+ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e)
+ {
+ ToolTipService.SetToolTip(sender, null);
+ if (!sender.IsTextTrimmed) return;
+
+ var toolTip = new ToolTip
+ {
+ Content = sender.Text,
+ };
+ ToolTipService.SetToolTip(sender, toolTip);
+ }
+}
diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml
index 93a1796..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}" />
-
-
diff --git a/App/Views/Pages/SignInTokenPage.xaml.cs b/App/Views/Pages/SignInTokenPage.xaml.cs
index 1219508..f471dcd 100644
--- a/App/Views/Pages/SignInTokenPage.xaml.cs
+++ b/App/Views/Pages/SignInTokenPage.xaml.cs
@@ -1,5 +1,6 @@
using Coder.Desktop.App.ViewModels;
using Microsoft.UI.Xaml.Controls;
+using Windows.System;
namespace Coder.Desktop.App.Views.Pages;
@@ -17,4 +18,13 @@ public SignInTokenPage(SignInWindow parent, SignInViewModel viewModel)
ViewModel = viewModel;
SignInWindow = parent;
}
+
+ private async void PasswordBox_KeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Enter)
+ {
+ await ViewModel.TokenPage_SignIn(SignInWindow);
+ e.Handled = true;
+ }
+ }
}
diff --git a/App/Views/Pages/SignInUrlPage.xaml b/App/Views/Pages/SignInUrlPage.xaml
index 1c12b03..e2fef1a 100644
--- a/App/Views/Pages/SignInUrlPage.xaml
+++ b/App/Views/Pages/SignInUrlPage.xaml
@@ -46,8 +46,10 @@
Grid.Row="0"
HorizontalAlignment="Stretch"
PlaceholderText="https://coder.example.com"
+ Loaded="{x:Bind ViewModel.CoderUrl_Loaded, Mode=OneWay}"
LostFocus="{x:Bind ViewModel.CoderUrl_FocusLost, Mode=OneWay}"
- Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay}" />
+ Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
+ KeyDown="TextBox_KeyDown"/>
+ Text="Could not connect to the Coder Connect system service." />
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/TrayWindowLoadingPage.xaml.cs b/App/Views/Pages/TrayWindowLoadingPage.xaml.cs
new file mode 100644
index 0000000..9b207a7
--- /dev/null
+++ b/App/Views/Pages/TrayWindowLoadingPage.xaml.cs
@@ -0,0 +1,11 @@
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class TrayWindowLoadingPage : Page
+{
+ public TrayWindowLoadingPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
index 62db4d7..ce161e3 100644
--- a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
+++ b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
@@ -17,11 +17,11 @@
Spacing="10">
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml
index 66ec273..42a9abd 100644
--- a/App/Views/Pages/TrayWindowMainPage.xaml
+++ b/App/Views/Pages/TrayWindowMainPage.xaml
@@ -12,18 +12,11 @@
mc:Ignorable="d">
-
-
-
-
-
-
@@ -44,7 +37,7 @@
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/App/Views/SignInWindow.xaml b/App/Views/SignInWindow.xaml
index 299c0c1..d2c1326 100644
--- a/App/Views/SignInWindow.xaml
+++ b/App/Views/SignInWindow.xaml
@@ -4,6 +4,7 @@
x:Class="Coder.Desktop.App.Views.SignInWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:controls="using:Coder.Desktop.App.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
@@ -13,5 +14,5 @@
-
+
diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs
index 771dda0..fb933c7 100644
--- a/App/Views/SignInWindow.xaml.cs
+++ b/App/Views/SignInWindow.xaml.cs
@@ -1,18 +1,21 @@
+using System;
using Windows.Graphics;
+using Coder.Desktop.App.Controls;
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 Coder.Desktop.App.Utils;
namespace Coder.Desktop.App.Views;
///
-/// The dialog window to allow the user to sign into their Coder server.
+/// The dialog window to allow the user to sign in to their Coder server.
///
public sealed partial class SignInWindow : Window
{
- private const double WIDTH = 600.0;
- private const double HEIGHT = 300.0;
+ private const double WIDTH = 500.0;
private readonly SignInUrlPage _signInUrlPage;
private readonly SignInTokenPage _signInTokenPage;
@@ -20,9 +23,19 @@ public sealed partial class SignInWindow : Window
public SignInWindow(SignInViewModel viewModel)
{
InitializeComponent();
+ TitleBarIcon.SetTitlebarIcon(this);
+ SystemBackdrop = new DesktopAcrylicBackdrop();
+ RootFrame.SizeChanged += RootFrame_SizeChanged;
+
_signInUrlPage = new SignInUrlPage(this, viewModel);
_signInTokenPage = new SignInTokenPage(this, viewModel);
+ // Prevent the window from being resized.
+ if (AppWindow.Presenter is not OverlappedPresenter presenter)
+ throw new Exception("Failed to get OverlappedPresenter for window");
+ presenter.IsMaximizable = false;
+ presenter.IsResizable = false;
+
NavigateToUrlPage();
ResizeWindow();
MoveWindowToCenterOfDisplay();
@@ -30,20 +43,32 @@ public SignInWindow(SignInViewModel viewModel)
public void NavigateToTokenPage()
{
- RootFrame.Content = _signInTokenPage;
+ RootFrame.SetPage(_signInTokenPage);
}
public void NavigateToUrlPage()
{
- RootFrame.Content = _signInUrlPage;
+ RootFrame.SetPage(_signInUrlPage);
+ }
+
+ private void RootFrame_SizeChanged(object sender, SizedFrameEventArgs e)
+ {
+ ResizeWindow(e.NewSize.Height);
}
private void ResizeWindow()
{
+ ResizeWindow(RootFrame.GetContentSize().Height);
+ }
+
+ private void ResizeWindow(double height)
+ {
+ if (height <= 0) height = 100; // will be resolved next frame typically
+
var scale = DisplayScale.WindowScale(this);
- var height = (int)(HEIGHT * scale);
- var width = (int)(WIDTH * scale);
- AppWindow.Resize(new SizeInt32(width, height));
+ var newWidth = (int)(WIDTH * scale);
+ var newHeight = (int)(height * scale);
+ AppWindow.ResizeClient(new SizeInt32(newWidth, newHeight));
}
private void MoveWindowToCenterOfDisplay()
diff --git a/App/Views/TrayWindow.xaml b/App/Views/TrayWindow.xaml
index c9aa24b..0d87874 100644
--- a/App/Views/TrayWindow.xaml
+++ b/App/Views/TrayWindow.xaml
@@ -19,6 +19,6 @@
-
+
diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs
index b528723..eac24e8 100644
--- a/App/Views/TrayWindow.xaml.cs
+++ b/App/Views/TrayWindow.xaml.cs
@@ -1,10 +1,9 @@
using System;
using System.Runtime.InteropServices;
-using System.Threading;
-using Windows.Foundation;
using Windows.Graphics;
using Windows.System;
using Windows.UI.Core;
+using Coder.Desktop.App.Controls;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
using Coder.Desktop.App.Views.Pages;
@@ -28,16 +27,22 @@ public sealed partial class TrayWindow : Window
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;
+ private readonly ISyncSessionController _syncSessionController;
+ private readonly TrayWindowLoadingPage _loadingPage;
private readonly TrayWindowDisconnectedPage _disconnectedPage;
private readonly TrayWindowLoginRequiredPage _loginRequiredPage;
private readonly TrayWindowMainPage _mainPage;
public TrayWindow(IRpcController rpcController, ICredentialManager credentialManager,
+ ISyncSessionController syncSessionController,
+ TrayWindowLoadingPage loadingPage,
TrayWindowDisconnectedPage disconnectedPage, TrayWindowLoginRequiredPage loginRequiredPage,
TrayWindowMainPage mainPage)
{
_rpcController = rpcController;
_credentialManager = credentialManager;
+ _syncSessionController = syncSessionController;
+ _loadingPage = loadingPage;
_disconnectedPage = disconnectedPage;
_loginRequiredPage = loginRequiredPage;
_mainPage = mainPage;
@@ -46,12 +51,13 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
AppWindow.Hide();
SystemBackdrop = new DesktopAcrylicBackdrop();
Activated += Window_Activated;
+ RootFrame.SizeChanged += RootFrame_SizeChanged;
- rpcController.StateChanged += RpcController_StateChanged;
- credentialManager.CredentialsChanged += CredentialManager_CredentialsChanged;
- SetPageByState(rpcController.GetState(), credentialManager.GetCredentials());
-
- _rpcController.Reconnect(CancellationToken.None);
+ _rpcController.StateChanged += RpcController_StateChanged;
+ _credentialManager.CredentialsChanged += CredentialManager_CredentialsChanged;
+ _syncSessionController.StateChanged += SyncSessionController_StateChanged;
+ SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials(),
+ _syncSessionController.GetState());
// Setting OpenCommand and ExitCommand directly in the .xaml doesn't seem to work for whatever reason.
TrayIcon.OpenCommand = Tray_OpenCommand;
@@ -76,8 +82,16 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
_ = NativeApi.DwmSetWindowAttribute(windowHandle, 33, ref value, Marshal.SizeOf());
}
- private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel)
+ private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel,
+ SyncSessionControllerStateModel syncSessionModel)
{
+ if (credentialModel.State == CredentialState.Unknown ||
+ syncSessionModel.Lifecycle == SyncSessionControllerLifecycle.Uninitialized)
+ {
+ SetRootFrame(_loadingPage);
+ return;
+ }
+
switch (rpcModel.RpcLifecycle)
{
case RpcLifecycle.Connected:
@@ -96,12 +110,17 @@ private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel)
private void RpcController_StateChanged(object? _, RpcModel model)
{
- SetPageByState(model, _credentialManager.GetCredentials());
+ SetPageByState(model, _credentialManager.GetCachedCredentials(), _syncSessionController.GetState());
}
private void CredentialManager_CredentialsChanged(object? _, CredentialModel model)
{
- SetPageByState(_rpcController.GetState(), model);
+ SetPageByState(_rpcController.GetState(), model, _syncSessionController.GetState());
+ }
+
+ private void SyncSessionController_StateChanged(object? _, SyncSessionControllerStateModel model)
+ {
+ SetPageByState(_rpcController.GetState(), _credentialManager.GetCachedCredentials(), model);
}
// Sadly this is necessary because Window.Content.SizeChanged doesn't
@@ -114,55 +133,31 @@ public void SetRootFrame(Page page)
return;
}
- if (ReferenceEquals(page, RootFrame.Content)) return;
-
- if (page.Content is not FrameworkElement newElement)
- throw new Exception("Failed to get Page.Content as FrameworkElement on RootFrame navigation");
- newElement.SizeChanged += Content_SizeChanged;
-
- // Unset the previous event listener.
- if (RootFrame.Content is Page { Content: FrameworkElement oldElement })
- oldElement.SizeChanged -= Content_SizeChanged;
-
- // Swap them out and reconfigure the window.
- // We don't use RootFrame.Navigate here because it doesn't let you
- // instantiate the page yourself. We also don't need forwards/backwards
- // capabilities.
- RootFrame.Content = page;
- ResizeWindow();
- MoveWindow();
+ RootFrame.SetPage(page);
}
- private void Content_SizeChanged(object sender, SizeChangedEventArgs e)
+ private void RootFrame_SizeChanged(object sender, SizedFrameEventArgs e)
{
- ResizeWindow();
+ ResizeWindow(e.NewSize.Height);
MoveWindow();
}
private void ResizeWindow()
{
- if (RootFrame.Content is not Page { Content: FrameworkElement frameworkElement })
- throw new Exception("Failed to get Content as FrameworkElement for window");
-
- // Measure the desired size of the content
- frameworkElement.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
-
- // Adjust the AppWindow size
- var scale = GetDisplayScale();
- var height = (int)(frameworkElement.ActualHeight * scale);
- var width = (int)(WIDTH * scale);
- AppWindow.Resize(new SizeInt32(width, height));
+ ResizeWindow(RootFrame.GetContentSize().Height);
}
- private double GetDisplayScale()
+ private void ResizeWindow(double height)
{
- var hwnd = WindowNative.GetWindowHandle(this);
- var dpi = NativeApi.GetDpiForWindow(hwnd);
- if (dpi == 0) return 1; // assume scale of 1
- return dpi / 96.0; // 96 DPI == 1
+ if (height <= 0) height = 100; // will be resolved next frame typically
+
+ var scale = DisplayScale.WindowScale(this);
+ var newWidth = (int)(WIDTH * scale);
+ var newHeight = (int)(height * scale);
+ AppWindow.Resize(new SizeInt32(newWidth, newHeight));
}
- public void MoveResizeAndActivate()
+ private void MoveResizeAndActivate()
{
SaveCursorPos();
ResizeWindow();
@@ -262,9 +257,6 @@ public static class NativeApi
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hwnd);
- [DllImport("user32.dll")]
- public static extern int GetDpiForWindow(IntPtr hwnd);
-
public struct POINT
{
public int X;
diff --git a/App/packages.lock.json b/App/packages.lock.json
index e547ab4..1541d01 100644
--- a/App/packages.lock.json
+++ b/App/packages.lock.json
@@ -8,6 +8,16 @@
"resolved": "8.4.0",
"contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw=="
},
+ "CommunityToolkit.WinUI.Controls.Primitives": {
+ "type": "Direct",
+ "requested": "[8.2.250402, )",
+ "resolved": "8.2.250402",
+ "contentHash": "Wx3t1zADrzBWDar45uRl+lmSxDO5Vx7tTMFm/mNgl3fs5xSQ1ySPdGqD10EFov3rkKc5fbpHGW5xj8t62Yisvg==",
+ "dependencies": {
+ "CommunityToolkit.WinUI.Extensions": "8.2.250402",
+ "Microsoft.WindowsAppSDK": "1.6.250108002"
+ }
+ },
"DependencyPropertyGenerator": {
"type": "Direct",
"requested": "[1.5.0, )",
@@ -28,11 +38,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.4"
+ }
+ },
+ "Microsoft.Extensions.Hosting": {
+ "type": "Direct",
+ "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.4, )",
+ "resolved": "9.0.4",
+ "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4"
}
},
"Microsoft.WindowsAppSDK": {
@@ -45,11 +95,89 @@
"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, )",
+ "resolved": "2.5.1",
+ "contentHash": "ihW4bA2quKbwWBOl5Uu80jBZagc4cT4E6CdExmvSZ05Qwz0jgoGyZuSTKcU9Uz7lZlQij3KxNor0dGXNUyIV9Q==",
+ "dependencies": {
+ "Microsoft.WindowsAppSDK": "1.6.240829007"
+ }
+ },
+ "CommunityToolkit.Common": {
+ "type": "Transitive",
+ "resolved": "8.2.1",
+ "contentHash": "LWuhy8cQKJ/MYcy3XafJ916U3gPH/YDvYoNGWyQWN11aiEKCZszzPOTJAOvBjP9yG8vHmIcCyPUt4L82OK47Iw=="
+ },
+ "CommunityToolkit.WinUI.Extensions": {
+ "type": "Transitive",
+ "resolved": "8.2.250402",
+ "contentHash": "rAOYzNX6kdUeeE1ejGd6Q8B+xmyZvOrWFUbqCgOtP8OQsOL66en9ZQTtzxAlaaFC4qleLvnKcn8FJFBezujOlw==",
+ "dependencies": {
+ "CommunityToolkit.Common": "8.2.1",
+ "Microsoft.WindowsAppSDK": "1.6.250108002"
+ }
+ },
"Google.Protobuf": {
"type": "Transitive",
"resolved": "3.29.3",
"contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw=="
},
+ "Grpc.Core.Api": {
+ "type": "Transitive",
+ "resolved": "2.67.0",
+ "contentHash": "cL1/2f8kc8lsAGNdfCU25deedXVehhLA6GXKLLN4hAWx16XN7BmjYn3gFU+FBpir5yJynvDTHEypr3Tl0j7x/Q=="
+ },
+ "Grpc.Net.Client": {
+ "type": "Transitive",
+ "resolved": "2.67.0",
+ "contentHash": "ofTjJQfegWkVlk5R4k/LlwpcucpsBzntygd4iAeuKd/eLMkmBWoXN+xcjYJ5IibAahRpIJU461jABZvT6E9dwA==",
+ "dependencies": {
+ "Grpc.Net.Common": "2.67.0",
+ "Microsoft.Extensions.Logging.Abstractions": "6.0.0"
+ }
+ },
+ "Grpc.Net.Common": {
+ "type": "Transitive",
+ "resolved": "2.67.0",
+ "contentHash": "gazn1cD2Eol0/W5ZJRV4PYbNrxJ9oMs8pGYux5S9E4MymClvl7aqYSmpqgmWAUWvziRqK9K+yt3cjCMfQ3x/5A==",
+ "dependencies": {
+ "Grpc.Core.Api": "2.67.0"
+ }
+ },
"H.GeneratedIcons.System.Drawing": {
"type": "Transitive",
"resolved": "2.2.0",
@@ -66,10 +194,251 @@
"H.GeneratedIcons.System.Drawing": "2.2.0"
}
},
+ "Microsoft.Extensions.Configuration": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4",
+ "Microsoft.Extensions.Primitives": "9.0.4"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "9.0.4"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Binder": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4"
+ }
+ },
+ "Microsoft.Extensions.Configuration.CommandLine": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4"
+ }
+ },
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "9.0.4",
+ "Microsoft.Extensions.Configuration.Abstractions": "9.0.4"
+ }
+ },
+ "Microsoft.Extensions.Configuration.FileExtensions": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "9.0.4"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Physical": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ=="
+ },
+ "Microsoft.Extensions.Hosting.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4",
+ "System.Diagnostics.DiagnosticSource": "9.0.4"
+ }
+ },
+ "Microsoft.Extensions.Logging.Configuration": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==",
+ "dependencies": {
+ "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.4",
+ "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA=="
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
@@ -86,11 +455,43 @@
"resolved": "10.0.22621.756",
"contentHash": "7ZL2sFSioYm1Ry067Kw1hg0SCcW5kuVezC2SwjGbcPE61Nn+gTbH86T73G3LcEOVj0S3IZzNuE/29gZvOLS7VA=="
},
+ "Semver": {
+ "type": "Transitive",
+ "resolved": "3.0.0",
+ "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==",
+ "dependencies": {
+ "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",
"contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w=="
},
+ "System.Diagnostics.DiagnosticSource": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
+ },
"System.Drawing.Common": {
"type": "Transitive",
"resolved": "9.0.0",
@@ -101,8 +502,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",
@@ -112,13 +513,36 @@
"System.Collections.Immutable": "9.0.0"
}
},
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
+ },
+ "System.Text.Json": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==",
+ "dependencies": {
+ "System.IO.Pipelines": "9.0.4",
+ "System.Text.Encodings.Web": "9.0.4"
+ }
+ },
"Coder.Desktop.CoderSdk": {
"type": "Project"
},
+ "Coder.Desktop.MutagenSdk": {
+ "type": "Project",
+ "dependencies": {
+ "Google.Protobuf": "[3.29.3, )",
+ "Grpc.Net.Client": "[2.67.0, )"
+ }
+ },
"Coder.Desktop.Vpn": {
"type": "Project",
"dependencies": {
"Coder.Desktop.Vpn.Proto": "[1.0.0, )",
+ "Microsoft.Extensions.Configuration": "[9.0.1, )",
+ "Semver": "[3.0.0, )",
"System.IO.Pipelines": "[9.0.1, )"
}
},
@@ -149,6 +573,16 @@
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
+ },
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
}
},
"net8.0-windows10.0.19041/win-x64": {
@@ -171,6 +605,16 @@
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
+ },
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
}
},
"net8.0-windows10.0.19041/win-x86": {
@@ -193,6 +637,16 @@
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ=="
+ },
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "9.0.4",
+ "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg=="
}
}
}
diff --git a/Coder.Desktop.sln b/Coder.Desktop.sln
index 2f78a8a..0a20185 100644
--- a/Coder.Desktop.sln
+++ b/Coder.Desktop.sln
@@ -23,6 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vpn.DebugClient", "Vpn.Debu
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer", "Installer\Installer.csproj", "{39F5B55A-09D8-477D-A3FA-ADAC29C52605}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.App", "Tests.App\Tests.App.csproj", "{3E91CED7-5528-4B46-8722-FB95D4FAB967}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MutagenSdk", "MutagenSdk\MutagenSdk.csproj", "{E2477ADC-03DA-490D-9369-79A4CC4A58D2}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -203,8 +207,43 @@ Global
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x64.Build.0 = Release|Any CPU
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x86.ActiveCfg = Release|Any CPU
{39F5B55A-09D8-477D-A3FA-ADAC29C52605}.Release|x86.Build.0 = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x64.Build.0 = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Debug|x86.Build.0 = Debug|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|ARM64.Build.0 = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x64.ActiveCfg = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x64.Build.0 = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x86.ActiveCfg = Release|Any CPU
+ {3E91CED7-5528-4B46-8722-FB95D4FAB967}.Release|x86.Build.0 = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x64.Build.0 = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Debug|x86.Build.0 = Debug|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|ARM64.Build.0 = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x64.ActiveCfg = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x64.Build.0 = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x86.ActiveCfg = Release|Any CPU
+ {E2477ADC-03DA-490D-9369-79A4CC4A58D2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {FC108D8D-B425-4DA0-B9CC-69670BCF4835}
+ EndGlobalSection
EndGlobal
diff --git a/CoderSdk/Agent/AgentApiClient.cs b/CoderSdk/Agent/AgentApiClient.cs
new file mode 100644
index 0000000..27eaea3
--- /dev/null
+++ b/CoderSdk/Agent/AgentApiClient.cs
@@ -0,0 +1,61 @@
+using System.Text.Json.Serialization;
+
+namespace Coder.Desktop.CoderSdk.Agent;
+
+public interface IAgentApiClientFactory
+{
+ public IAgentApiClient Create(string hostname);
+}
+
+public class AgentApiClientFactory : IAgentApiClientFactory
+{
+ public IAgentApiClient Create(string hostname)
+ {
+ return new AgentApiClient(hostname);
+ }
+}
+
+public partial interface IAgentApiClient
+{
+}
+
+[JsonSerializable(typeof(ListDirectoryRequest))]
+[JsonSerializable(typeof(ListDirectoryResponse))]
+[JsonSerializable(typeof(Response))]
+public partial class AgentApiJsonContext : JsonSerializerContext;
+
+public partial class AgentApiClient : IAgentApiClient
+{
+ private const int AgentApiPort = 4;
+
+ private readonly JsonHttpClient _httpClient;
+
+ public AgentApiClient(string hostname) : this(new UriBuilder
+ {
+ Scheme = "http",
+ Host = hostname,
+ Port = AgentApiPort,
+ Path = "/",
+ }.Uri)
+ {
+ }
+
+ public AgentApiClient(Uri baseUrl)
+ {
+ if (baseUrl.PathAndQuery != "/")
+ throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl));
+ _httpClient = new JsonHttpClient(baseUrl, AgentApiJsonContext.Default);
+ }
+
+ private async Task SendRequestNoBodyAsync(HttpMethod method, string path,
+ CancellationToken ct = default)
+ {
+ return await SendRequestAsync