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/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(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(method, path, payload, ct); + } +} diff --git a/CoderSdk/Agent/ListDirectory.cs b/CoderSdk/Agent/ListDirectory.cs new file mode 100644 index 0000000..72e4a15 --- /dev/null +++ b/CoderSdk/Agent/ListDirectory.cs @@ -0,0 +1,54 @@ +namespace Coder.Desktop.CoderSdk.Agent; + +public partial interface IAgentApiClient +{ + public Task ListDirectory(ListDirectoryRequest req, CancellationToken ct = default); +} + +public enum ListDirectoryRelativity +{ + // Root means `/` on Linux, and lists drive letters on Windows. + Root, + + // Home means the user's home directory, usually `/home/xyz` or + // `C:\Users\xyz`. + Home, +} + +public class ListDirectoryRequest +{ + // Path segments like ["home", "coder", "repo"] or even just [] + public List Path { get; set; } = []; + + // Where the path originates, either in the home directory or on the root + // of the system + public ListDirectoryRelativity Relativity { get; set; } = ListDirectoryRelativity.Root; +} + +public class ListDirectoryItem +{ + public required string Name { get; init; } + public required string AbsolutePathString { get; init; } + public required bool IsDir { get; init; } +} + +public class ListDirectoryResponse +{ + // The resolved absolute path (always from root) for future requests. + // E.g. if you did a request like `home: ["repo"]`, + // this would return ["home", "coder", "repo"] and "/home/coder/repo" + public required List AbsolutePath { get; init; } + + // e.g. "C:\\Users\\coder\\repo" or "/home/coder/repo" + public required string AbsolutePathString { get; init; } + public required List Contents { get; init; } +} + +public partial class AgentApiClient +{ + public Task ListDirectory(ListDirectoryRequest req, CancellationToken ct = default) + { + return SendRequestAsync(HttpMethod.Post, "/api/v0/list-directory", + req, ct); + } +} diff --git a/CoderSdk/Coder/CoderApiClient.cs b/CoderSdk/Coder/CoderApiClient.cs new file mode 100644 index 0000000..79c5c2f --- /dev/null +++ b/CoderSdk/Coder/CoderApiClient.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace Coder.Desktop.CoderSdk.Coder; + +public interface ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl); +} + +public class CoderApiClientFactory : ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl) + { + return new CoderApiClient(baseUrl); + } +} + +public partial interface ICoderApiClient +{ + public void SetSessionToken(string token); +} + +[JsonSerializable(typeof(BuildInfo))] +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(User))] +[JsonSerializable(typeof(ValidationError))] +public partial class CoderApiJsonContext : JsonSerializerContext; + +/// +/// Provides a limited selection of API methods for a Coder instance. +/// +public partial class CoderApiClient : ICoderApiClient +{ + private const string SessionTokenHeader = "Coder-Session-Token"; + + private readonly JsonHttpClient _httpClient; + + public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) + { + } + + public CoderApiClient(Uri baseUrl) + { + if (baseUrl.PathAndQuery != "/") + throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); + _httpClient = new JsonHttpClient(baseUrl, CoderApiJsonContext.Default); + } + + public CoderApiClient(string baseUrl, string token) : this(baseUrl) + { + SetSessionToken(token); + } + + public void SetSessionToken(string token) + { + _httpClient.RemoveHeader(SessionTokenHeader); + _httpClient.SetHeader(SessionTokenHeader, token); + } + + private async Task SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(method, path, payload, ct); + } +} diff --git a/CoderSdk/Coder/Deployment.cs b/CoderSdk/Coder/Deployment.cs new file mode 100644 index 0000000..978d79d --- /dev/null +++ b/CoderSdk/Coder/Deployment.cs @@ -0,0 +1,19 @@ +namespace Coder.Desktop.CoderSdk.Coder; + +public partial interface ICoderApiClient +{ + public Task GetBuildInfo(CancellationToken ct = default); +} + +public class BuildInfo +{ + public string Version { get; set; } = ""; +} + +public partial class CoderApiClient +{ + public Task GetBuildInfo(CancellationToken ct = default) + { + return SendRequestNoBodyAsync(HttpMethod.Get, "/api/v2/buildinfo", ct); + } +} diff --git a/CoderSdk/Users.cs b/CoderSdk/Coder/Users.cs similarity index 66% rename from CoderSdk/Users.cs rename to CoderSdk/Coder/Users.cs index 2d99e02..6d1914b 100644 --- a/CoderSdk/Users.cs +++ b/CoderSdk/Coder/Users.cs @@ -1,10 +1,14 @@ -namespace CoderSdk; +namespace Coder.Desktop.CoderSdk.Coder; + +public partial interface ICoderApiClient +{ + public Task GetUser(string user, CancellationToken ct = default); +} public class User { public const string Me = "me"; - // TODO: fill out more fields public string Username { get; set; } = ""; } diff --git a/CoderSdk/Deployment.cs b/CoderSdk/Deployment.cs deleted file mode 100644 index d85a458..0000000 --- a/CoderSdk/Deployment.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CoderSdk; - -public class BuildInfo -{ - public string ExternalUrl { get; set; } = ""; - public string Version { get; set; } = ""; - public string DashboardUrl { get; set; } = ""; - public bool Telemetry { get; set; } = false; - public bool WorkspaceProxy { get; set; } = false; - public string AgentApiVersion { get; set; } = ""; - public string ProvisionerApiVersion { get; set; } = ""; - public string UpgradeMessage { get; set; } = ""; - public string DeploymentId { get; set; } = ""; -} - -public partial class CoderApiClient -{ - public Task GetBuildInfo(CancellationToken ct = default) - { - return SendRequestNoBodyAsync(HttpMethod.Get, "/api/v2/buildinfo", ct); - } -} diff --git a/CoderSdk/Errors.cs b/CoderSdk/Errors.cs new file mode 100644 index 0000000..a7c56c0 --- /dev/null +++ b/CoderSdk/Errors.cs @@ -0,0 +1,95 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Coder.Desktop.CoderSdk; + +public class ValidationError +{ + public string Field { get; set; } = ""; + public string Detail { get; set; } = ""; +} + +public class Response +{ + public string Message { get; set; } = ""; + public string Detail { get; set; } = ""; + public List Validations { get; set; } = []; +} + +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(ValidationError))] +public partial class ErrorJsonContext : JsonSerializerContext; + +public class CoderApiHttpException : Exception +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + TypeInfoResolver = ErrorJsonContext.Default, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static readonly Dictionary Helpers = new() + { + { HttpStatusCode.Unauthorized, "Try signing in again" }, + }; + + public readonly HttpMethod? Method; + public readonly Uri? RequestUri; + public readonly HttpStatusCode StatusCode; + public readonly string? ReasonPhrase; + public readonly Response Response; + + public CoderApiHttpException(HttpMethod? method, Uri? requestUri, HttpStatusCode statusCode, string? reasonPhrase, + Response response) : base(MessageFrom(method, requestUri, statusCode, reasonPhrase, response)) + { + Method = method; + RequestUri = requestUri; + StatusCode = statusCode; + ReasonPhrase = reasonPhrase; + Response = response; + } + + public static async Task FromResponse(HttpResponseMessage response, CancellationToken ct) + { + var content = await response.Content.ReadAsStringAsync(ct); + Response? responseObject; + try + { + responseObject = JsonSerializer.Deserialize(content, JsonOptions); + } + catch (JsonException) + { + responseObject = null; + } + + if (responseObject is null or { Message: null or "" }) + responseObject = new Response + { + Message = "Could not parse response, or response has no message", + Detail = content, + Validations = [], + }; + + return new CoderApiHttpException( + response.RequestMessage?.Method, + response.RequestMessage?.RequestUri, + response.StatusCode, + response.ReasonPhrase, + responseObject); + } + + private static string MessageFrom(HttpMethod? method, Uri? requestUri, HttpStatusCode statusCode, + string? reasonPhrase, Response response) + { + var message = $"Coder API Request: {method} '{requestUri}' failed with status code {(int)statusCode}"; + if (!string.IsNullOrEmpty(reasonPhrase)) message += $" {reasonPhrase}"; + message += $": {response.Message}"; + if (Helpers.TryGetValue(statusCode, out var helperMessage)) message += $": {helperMessage}"; + if (!string.IsNullOrEmpty(response.Detail)) message += $"\n\tError: {response.Detail}"; + foreach (var validation in response.Validations) message += $"\n\t{validation.Field}: {validation.Detail}"; + return message; + } +} diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/JsonHttpClient.cs similarity index 50% rename from CoderSdk/CoderApiClient.cs rename to CoderSdk/JsonHttpClient.cs index 016998d..362391e 100644 --- a/CoderSdk/CoderApiClient.cs +++ b/CoderSdk/JsonHttpClient.cs @@ -1,8 +1,9 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; -namespace CoderSdk; +namespace Coder.Desktop.CoderSdk; /// /// Changes names from PascalCase to snake_case. @@ -17,57 +18,37 @@ public override string ConvertName(string name) } } -[JsonSerializable(typeof(BuildInfo))] -[JsonSerializable(typeof(User))] -public partial class CoderSdkJsonContext : JsonSerializerContext +internal class JsonHttpClient { -} - -/// -/// Provides a limited selection of API methods for a Coder instance. -/// -public partial class CoderApiClient -{ - // TODO: allow adding headers - private readonly HttpClient _httpClient = new(); private readonly JsonSerializerOptions _jsonOptions; - public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) - { - } + // TODO: allow users to add headers + private readonly HttpClient _httpClient = new(); - public CoderApiClient(Uri baseUrl) + public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver) { - if (baseUrl.PathAndQuery != "/") - throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); - _httpClient.BaseAddress = baseUrl; _jsonOptions = new JsonSerializerOptions { - TypeInfoResolver = CoderSdkJsonContext.Default, + TypeInfoResolver = typeResolver, PropertyNameCaseInsensitive = true, PropertyNamingPolicy = new SnakeCaseNamingPolicy(), DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; + _jsonOptions.Converters.Add(new JsonStringEnumConverter(new SnakeCaseNamingPolicy(), false)); + _httpClient.BaseAddress = baseUri; } - public CoderApiClient(string baseUrl, string token) : this(baseUrl) + public void RemoveHeader(string key) { - SetSessionToken(token); + _httpClient.DefaultRequestHeaders.Remove(key); } - public void SetSessionToken(string token) + public void SetHeader(string key, string value) { - _httpClient.DefaultRequestHeaders.Remove("Coder-Session-Token"); - _httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token); + _httpClient.DefaultRequestHeaders.Add(key, value); } - private async Task SendRequestNoBodyAsync(HttpMethod method, string path, - CancellationToken ct = default) - { - return await SendRequestAsync(method, path, null, ct); - } - - private async Task SendRequestAsync(HttpMethod method, string path, + public async Task SendRequestAsync(HttpMethod method, string path, TRequest? payload, CancellationToken ct = default) { try @@ -81,17 +62,21 @@ private async Task SendRequestAsync(HttpMethod m } var res = await _httpClient.SendAsync(request, ct); - // TODO: this should be improved to try and parse a codersdk.Error response - res.EnsureSuccessStatusCode(); + if (!res.IsSuccessStatusCode) + throw await CoderApiHttpException.FromResponse(res, ct); var content = await res.Content.ReadAsStringAsync(ct); var data = JsonSerializer.Deserialize(content, _jsonOptions); if (data is null) throw new JsonException("Deserialized response is null"); return data; } + catch (CoderApiHttpException) + { + throw; + } catch (Exception e) { - throw new Exception($"API Request: {method} {path} (req body: {payload is not null})", e); + throw new Exception($"API Request failed: {method} {path}", e); } } } diff --git a/Installer/Installer.csproj b/Installer/Installer.csproj index 9425a01..99261b9 100644 --- a/Installer/Installer.csproj +++ b/Installer/Installer.csproj @@ -1,24 +1,25 @@ - - Coder.Desktop.Installer - Coder.Desktop.Installer - Exe - net481 - 13.0 - + + Coder.Desktop.Installer + Coder.Desktop.Installer + Exe + net481 + 13.0 + - - - - - - - + + + + + + + - - - - - + + + + + + diff --git a/Installer/Program.cs b/Installer/Program.cs index 0a34a6e..10a09a7 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -116,6 +116,9 @@ public class BootstrapperOptions : SharedOptions [Option('m', "msi-path", Required = true, HelpText = "Path to the MSI package to embed")] public string MsiPath { get; set; } + [Option('w', "windows-app-sdk-path", Required = true, HelpText = "Path to the Windows App Sdk package to embed")] + public string WindowsAppSdkPath { get; set; } + public new void Validate() { base.Validate(); @@ -124,6 +127,9 @@ public class BootstrapperOptions : SharedOptions throw new ArgumentException($"Logo PNG file not found at '{LogoPng}'", nameof(LogoPng)); if (!SystemFile.Exists(MsiPath)) throw new ArgumentException($"MSI package not found at '{MsiPath}'", nameof(MsiPath)); + if (!SystemFile.Exists(WindowsAppSdkPath)) + throw new ArgumentException($"Windows App Sdk package not found at '{WindowsAppSdkPath}'", + nameof(WindowsAppSdkPath)); } } @@ -133,6 +139,8 @@ public class Program private const string Manufacturer = "Coder Technologies Inc."; private const string HelpUrl = "https://coder.com/docs"; private const string RegistryKey = @"SOFTWARE\Coder Desktop"; + private const string AppConfigRegistryKey = RegistryKey + @"\App"; + private const string VpnServiceConfigRegistryKey = RegistryKey + @"\VpnService"; private const string DotNetCheckName = "DOTNET_RUNTIME_CHECK"; private const RollForward DotNetCheckRollForward = RollForward.minor; @@ -250,13 +258,25 @@ private static int BuildMsiPackage(MsiOptions opts) programFiles64Folder.AddDir(installDir); project.AddDir(programFiles64Folder); - // Add registry values that are consumed by the manager. + project.AddRegValues( - new RegValue(RegistryHive, RegistryKey, "Manager:ServiceRpcPipeName", "Coder.Desktop.Vpn"), - new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryPath", + // Add registry values that are consumed by the manager. Note that these + // should not be changed. See Vpn.Service/Program.cs (AddDefaultConfig) and + // Vpn.Service/ManagerConfig.cs for more details. + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:ServiceRpcPipeName", "Coder.Desktop.Vpn"), + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:TunnelBinaryPath", $"[INSTALLFOLDER]{opts.VpnDir}\\coder-vpn.exe"), - new RegValue(RegistryHive, RegistryKey, "Manager:LogFileLocation", - @"[INSTALLFOLDER]coder-desktop-service.log")); + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:TunnelBinarySignatureSigner", + "Coder Technologies Inc."), + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Manager:TunnelBinaryAllowVersionMismatch", + "false"), + new RegValue(RegistryHive, VpnServiceConfigRegistryKey, "Serilog:WriteTo:0:Args:path", + @"[INSTALLFOLDER]coder-desktop-service.log"), + + // Add registry values that are consumed by the App MutagenController. See App/Services/MutagenController.cs + new RegValue(RegistryHive, AppConfigRegistryKey, "MutagenController:MutagenExecutablePath", + @"[INSTALLFOLDER]vpn\mutagen.exe") + ); // Note: most of this control panel info will not be visible as this // package is usually hidden in favor of the bootstrapper showing @@ -328,16 +348,11 @@ private static int BuildBundle(BootstrapperOptions opts) { opts.Validate(); - if (!DotNetRuntimePackagePayloads.TryGetValue(opts.Platform, out var payload)) + if (!DotNetRuntimePackagePayloads.TryGetValue(opts.Platform, out var dotNetRuntimePayload)) throw new ArgumentException($"Invalid architecture '{opts.Platform}' specified", nameof(opts.Platform)); - // TODO: it would be nice to include the WindowsAppRuntime but - // Microsoft makes it difficult to check from a regular - // installer: - // https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/check-windows-app-sdk-versions - // https://github.com/microsoft/WindowsAppSDK/discussions/2437 var bundle = new Bundle(ProductName, - new ExePackage + new ExePackage // .NET Runtime { PerMachine = true, // Don't uninstall the runtime when the bundle is uninstalled. @@ -353,7 +368,28 @@ private static int BuildBundle(BootstrapperOptions opts) // anyway. The MSI will fatally exit if the runtime really isn't // available, and the user can install it themselves. Vital = false, - Payloads = [payload], + Payloads = [dotNetRuntimePayload], + }, + // TODO: right now we are including the Windows App Sdk in the bundle + // and always install it + // Microsoft makes it difficult to check if it exists from a regular installer: + // https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/check-windows-app-sdk-versions + // https://github.com/microsoft/WindowsAppSDK/discussions/2437 + new ExePackage // Windows App Sdk + { + PerMachine = true, + Permanent = true, + Cache = PackageCacheAction.remove, + // There is no license agreement for this SDK. + InstallArguments = "--quiet", + Vital = false, + Payloads = + [ + new ExePackagePayload + { + SourceFile = opts.WindowsAppSdkPath, + }, + ], }, new MsiPackage(opts.MsiPath) { diff --git a/MutagenSdk/MutagenClient.cs b/MutagenSdk/MutagenClient.cs new file mode 100644 index 0000000..27ffa7a --- /dev/null +++ b/MutagenSdk/MutagenClient.cs @@ -0,0 +1,95 @@ +using Coder.Desktop.MutagenSdk.Proto.Service.Daemon; +using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; +using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; +using Grpc.Core; +using Grpc.Net.Client; + +namespace Coder.Desktop.MutagenSdk; + +public class MutagenClient : IDisposable +{ + private readonly GrpcChannel _channel; + + public readonly Daemon.DaemonClient Daemon; + public readonly Prompting.PromptingClient Prompting; + public readonly Synchronization.SynchronizationClient Synchronization; + + public MutagenClient(string dataDir) + { + // Check for the lock file first, since it should exist if it's running. + var daemonLockFile = Path.Combine(dataDir, "daemon", "daemon.lock"); + if (!File.Exists(daemonLockFile)) + throw new FileNotFoundException( + "Mutagen daemon lock file not found, did the mutagen daemon start successfully?", daemonLockFile); + + // We should not be able to open the lock file. + try + { + using var _ = File.Open(daemonLockFile, FileMode.Open, FileAccess.Write, FileShare.None); + // We throw a FileNotFoundException if we could open the file because + // it means the same thing and allows us to return the path nicely. + throw new InvalidOperationException( + $"Mutagen daemon lock file '{daemonLockFile}' is unlocked, did the mutagen daemon start successfully?"); + } + catch (IOException) + { + // this is what we expect + } + + // Read the IPC named pipe address from the sock file. + var daemonSockFile = Path.Combine(dataDir, "daemon", "daemon.sock"); + if (!File.Exists(daemonSockFile)) + throw new FileNotFoundException( + "Mutagen daemon socket file not found, did the mutagen daemon start successfully?", daemonSockFile); + var daemonSockAddress = File.ReadAllText(daemonSockFile).Trim(); + if (string.IsNullOrWhiteSpace(daemonSockAddress)) + throw new InvalidOperationException( + $"Mutagen daemon socket address from '{daemonSockFile}' is empty, did the mutagen daemon start successfully?"); + + const string namedPipePrefix = @"\\.\pipe\"; + if (!daemonSockAddress.StartsWith(namedPipePrefix) || daemonSockAddress == namedPipePrefix) + throw new InvalidOperationException( + $"Mutagen daemon socket address '{daemonSockAddress}' is not a valid named pipe address"); + + // Ensure the pipe exists before we try to connect to it. Obviously + // this is not 100% foolproof, since the pipe could appear/disappear + // after we check it. This allows us to fail early if the pipe isn't + // ready yet (and consumers can retry), otherwise the pipe connection + // may block. + // + // Note: we cannot use File.Exists here without breaking the named + // pipe connection code due to a .NET bug. + // https://github.com/dotnet/runtime/issues/69604 + var pipeName = daemonSockAddress[namedPipePrefix.Length..]; + var foundPipe = Directory + .GetFiles(namedPipePrefix, pipeName) + .FirstOrDefault(p => Path.GetFileName(p) == pipeName); + if (foundPipe == null) + throw new FileNotFoundException( + "Mutagen daemon named pipe not found, did the mutagen daemon start successfully?", daemonSockAddress); + + var connectionFactory = new NamedPipesConnectionFactory(pipeName); + var socketsHttpHandler = new SocketsHttpHandler + { + ConnectCallback = connectionFactory.ConnectAsync, + }; + + // http://localhost is fake address. The HttpHandler will be used to + // open a socket to the named pipe. + _channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + Credentials = ChannelCredentials.Insecure, + HttpHandler = socketsHttpHandler, + }); + + Daemon = new Daemon.DaemonClient(_channel); + Prompting = new Prompting.PromptingClient(_channel); + Synchronization = new Synchronization.SynchronizationClient(_channel); + } + + public void Dispose() + { + _channel.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/MutagenSdk/MutagenSdk.csproj b/MutagenSdk/MutagenSdk.csproj new file mode 100644 index 0000000..d2b8f90 --- /dev/null +++ b/MutagenSdk/MutagenSdk.csproj @@ -0,0 +1,25 @@ + + + + Coder.Desktop.MutagenSdk + Coder.Desktop.MutagenSdk + net8.0 + enable + enable + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/MutagenSdk/NamedPipesConnectionFactory.cs b/MutagenSdk/NamedPipesConnectionFactory.cs new file mode 100644 index 0000000..d50228b --- /dev/null +++ b/MutagenSdk/NamedPipesConnectionFactory.cs @@ -0,0 +1,38 @@ +using System.IO.Pipes; +using System.Security.Principal; + +namespace Coder.Desktop.MutagenSdk; + +public class NamedPipesConnectionFactory +{ + private readonly string _pipeName; + + public NamedPipesConnectionFactory(string pipeName) + { + _pipeName = pipeName; + } + + public async ValueTask ConnectAsync(SocketsHttpConnectionContext _, + CancellationToken cancellationToken = default) + { + var client = new NamedPipeClientStream( + ".", + _pipeName, + PipeDirection.InOut, + PipeOptions.WriteThrough | PipeOptions.Asynchronous, + TokenImpersonationLevel.Anonymous); + + try + { + // Set an upper limit of 2.5 seconds. MutagenSdk consumers can + // retry if necessary. + await client.ConnectAsync(2500, cancellationToken); + return client; + } + catch + { + await client.DisposeAsync(); + throw; + } + } +} diff --git a/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto b/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto new file mode 100644 index 0000000..2adcd38 --- /dev/null +++ b/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto @@ -0,0 +1,50 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/filesystem/behavior/probe_mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package behavior; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Filesystem.Behavior"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/filesystem/behavior"; + +// ProbeMode specifies the mode for filesystem probing. +enum ProbeMode { + // ProbeMode_ProbeModeDefault represents an unspecified probe mode. It + // should be converted to one of the following values based on the desired + // default behavior. + ProbeModeDefault = 0; + // ProbeMode_ProbeModeProbe specifies that filesystem behavior should be + // determined using temporary files or, if possible, a "fast-path" mechanism + // (such as filesystem format detection) that provides quick but certain + // determination of filesystem behavior. + ProbeModeProbe = 1; + // ProbeMode_ProbeModeAssume specifies that filesystem behavior should be + // assumed based on the underlying platform. This is not as accurate as + // ProbeMode_ProbeModeProbe. + ProbeModeAssume = 2; +} diff --git a/MutagenSdk/Proto/selection/selection.proto b/MutagenSdk/Proto/selection/selection.proto new file mode 100644 index 0000000..0f12504 --- /dev/null +++ b/MutagenSdk/Proto/selection/selection.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/selection/selection.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package selection; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Selection"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/selection"; + +// Selection encodes a selection mechanism that can be used to select a +// collection of sessions. It should have exactly one member set. +message Selection { + // All, if true, indicates that all sessions should be selected. + bool all = 1; + // Specifications is a list of session specifications. Each element may be + // either a session identifier or name (or a prefix thereof). If non-empty, + // it indicates that these specifications should be used to select sessions. + repeated string specifications = 2; + // LabelSelector is a label selector specification. If present (non-empty), + // it indicates that this selector should be used to select sessions. + string labelSelector = 3; +} diff --git a/MutagenSdk/Proto/service/daemon/daemon.proto b/MutagenSdk/Proto/service/daemon/daemon.proto new file mode 100644 index 0000000..b1e836a --- /dev/null +++ b/MutagenSdk/Proto/service/daemon/daemon.proto @@ -0,0 +1,53 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/daemon/daemon.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package daemon; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Service.Daemon"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/service/daemon"; + +message VersionRequest{} + +message VersionResponse { + // TODO: Should we encapsulate these inside a Version message type, perhaps + // in the mutagen package? + uint64 major = 1; + uint64 minor = 2; + uint64 patch = 3; + string tag = 4; +} + +message TerminateRequest{} + +message TerminateResponse{} + +service Daemon { + rpc Version(VersionRequest) returns (VersionResponse) {} + rpc Terminate(TerminateRequest) returns (TerminateResponse) {} +} diff --git a/MutagenSdk/Proto/service/prompting/prompting.proto b/MutagenSdk/Proto/service/prompting/prompting.proto new file mode 100644 index 0000000..73432b9 --- /dev/null +++ b/MutagenSdk/Proto/service/prompting/prompting.proto @@ -0,0 +1,81 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/prompting/prompting.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package prompting; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Service.Prompting"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/service/prompting"; + +// HostRequest encodes either an initial request to perform prompt hosting or a +// follow-up response to a message or prompt. +message HostRequest { + // AllowPrompts indicates whether or not the hoster will allow prompts. If + // not, it will only receive message requests. This field may only be set on + // the initial request. + bool allowPrompts = 1; + // Response is the prompt response, if any. On the initial request, this + // must be an empty string. When responding to a prompt, it may be any + // value. When responding to a message, it must be an empty string. + string response = 2; +} + +// HostResponse encodes either an initial response to perform prompt hosting or +// a follow-up request for messaging or prompting. +message HostResponse { + // Identifier is the prompter identifier. It is only set in the initial + // response sent after the initial request. + string identifier = 1; + // IsPrompt indicates if the response is requesting a prompt (as opposed to + // simple message display). + bool isPrompt = 2; + // Message is the message associated with the prompt or message. + string message = 3; +} + +// PromptRequest encodes a request for prompting by a specific prompter. +message PromptRequest { + // Prompter is the prompter identifier. + string prompter = 1; + // Prompt is the prompt to present. + string prompt = 2; +} + +// PromptResponse encodes the response from a prompter. +message PromptResponse { + // Response is the response returned by the prompter. + string response = 1; +} + +// Prompting allows clients to host and request prompting. +service Prompting { + // Host allows clients to perform prompt hosting. + rpc Host(stream HostRequest) returns (stream HostResponse) {} + // Prompt performs prompting using a specific prompter. + rpc Prompt(PromptRequest) returns (PromptResponse) {} +} diff --git a/MutagenSdk/Proto/service/synchronization/synchronization.proto b/MutagenSdk/Proto/service/synchronization/synchronization.proto new file mode 100644 index 0000000..798a234 --- /dev/null +++ b/MutagenSdk/Proto/service/synchronization/synchronization.proto @@ -0,0 +1,169 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/synchronization/synchronization.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Service.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/service/synchronization"; + +import "selection/selection.proto"; +import "synchronization/configuration.proto"; +import "synchronization/state.proto"; +import "url/url.proto"; + +// CreationSpecification contains the metadata required for a new session. +message CreationSpecification { + // Alpha is the alpha endpoint URL for the session. + url.URL alpha = 1; + // Beta is the beta endpoint URL for the session. + url.URL beta = 2; + // Configuration is the base session configuration. It is the result of + // merging the global configuration (unless disabled), any manually + // specified configuration file, and any command line configuration + // parameters. + synchronization.Configuration configuration = 3; + // ConfigurationAlpha is the alpha-specific session configuration. It is + // determined based on command line configuration parameters. + synchronization.Configuration configurationAlpha = 4; + // ConfigurationBeta is the beta-specific session configuration. It is + // determined based on command line configuration parameters. + synchronization.Configuration configurationBeta = 5; + // Name is the name for the session object. + string name = 6; + // Labels are the labels for the session object. + map labels = 7; + // Paused indicates whether or not to create the session pre-paused. + bool paused = 8; +} + +// CreateRequest encodes a request for session creation. +message CreateRequest { + // Prompter is the prompter identifier to use for creating sessions. + string prompter = 1; + // Specification is the creation specification. + CreationSpecification specification = 2; +} + +// CreateResponse encodes a session creation response. +message CreateResponse { + // Session is the resulting session identifier. + string session = 1; +} + +// ListRequest encodes a request for session metadata. +message ListRequest { + // Selection is the session selection criteria. + selection.Selection selection = 1; + // PreviousStateIndex is the previously seen state index. 0 may be provided + // to force an immediate state listing. + uint64 previousStateIndex = 2; +} + +// ListResponse encodes session metadata. +message ListResponse { + // StateIndex is the state index associated with the session metadata. + uint64 stateIndex = 1; + // SessionStates are the session metadata states. + repeated synchronization.State sessionStates = 2; +} + +// FlushRequest encodes a request to flush sessions. +message FlushRequest { + // Prompter is the prompter to use for status message updates. + string prompter = 1; + // Selection is the session selection criteria. + selection.Selection selection = 2; + // SkipWait indicates whether or not the operation should avoid blocking. + bool skipWait = 3; +} + +// FlushResponse indicates completion of flush operation(s). +message FlushResponse{} + +// PauseRequest encodes a request to pause sessions. +message PauseRequest { + // Prompter is the prompter to use for status message updates. + string prompter = 1; + // Selection is the session selection criteria. + selection.Selection selection = 2; +} + +// PauseResponse indicates completion of pause operation(s). +message PauseResponse{} + +// ResumeRequest encodes a request to resume sessions. +message ResumeRequest { + // Prompter is the prompter identifier to use for resuming sessions. + string prompter = 1; + // Selection is the session selection criteria. + selection.Selection selection = 2; +} + +// ResumeResponse indicates completion of resume operation(s). +message ResumeResponse{} + +// ResetRequest encodes a request to reset sessions. +message ResetRequest { + // Prompter is the prompter identifier to use for resetting sessions. + string prompter = 1; + // Selection is the session selection criteria. + selection.Selection selection = 2; +} + +// ResetResponse indicates completion of reset operation(s). +message ResetResponse{} + +// TerminateRequest encodes a request to terminate sessions. +message TerminateRequest { + // Prompter is the prompter to use for status message updates. + string prompter = 1; + // Selection is the session selection criteria. + selection.Selection selection = 2; +} + +// TerminateResponse indicates completion of termination operation(s). +message TerminateResponse{} + +// Synchronization manages the lifecycle of synchronization sessions. +service Synchronization { + // Create creates a new session. + rpc Create(CreateRequest) returns (CreateResponse) {} + // List returns metadata for existing sessions. + rpc List(ListRequest) returns (ListResponse) {} + // Flush flushes sessions. + rpc Flush(FlushRequest) returns (FlushResponse) {} + // Pause pauses sessions. + rpc Pause(PauseRequest) returns (PauseResponse) {} + // Resume resumes paused or disconnected sessions. + rpc Resume(ResumeRequest) returns (ResumeResponse) {} + // Reset resets sessions' histories. + rpc Reset(ResetRequest) returns (ResetResponse) {} + // Terminate terminates sessions. + rpc Terminate(TerminateRequest) returns (TerminateResponse) {} +} diff --git a/MutagenSdk/Proto/synchronization/compression/algorithm.proto b/MutagenSdk/Proto/synchronization/compression/algorithm.proto new file mode 100644 index 0000000..e08d1e2 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/compression/algorithm.proto @@ -0,0 +1,49 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/compression/algorithm.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package compression; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Compression"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/compression"; + +// Algorithm specifies a compression algorithm. +enum Algorithm { + // Algorithm_AlgorithmDefault represents an unspecified compression + // algorithm. It should be converted to one of the following values based on + // the desired default behavior. + AlgorithmDefault = 0; + // Algorithm_AlgorithmNone specifies that no compression should be used. + AlgorithmNone = 1; + // Algorithm_AlgorithmDeflate specifies that DEFLATE compression should be + // used. + AlgorithmDeflate = 2; + // Algorithm_AlgorithmZstandard specifies that Zstandard compression should + // be used. + AlgorithmZstandard = 3; +} diff --git a/MutagenSdk/Proto/synchronization/configuration.proto b/MutagenSdk/Proto/synchronization/configuration.proto new file mode 100644 index 0000000..8906377 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/configuration.proto @@ -0,0 +1,175 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/configuration.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization"; + +import "filesystem/behavior/probe_mode.proto"; +import "synchronization/scan_mode.proto"; +import "synchronization/stage_mode.proto"; +import "synchronization/watch_mode.proto"; +import "synchronization/compression/algorithm.proto"; +import "synchronization/core/mode.proto"; +import "synchronization/core/permissions_mode.proto"; +import "synchronization/core/symbolic_link_mode.proto"; +import "synchronization/core/ignore/syntax.proto"; +import "synchronization/core/ignore/ignore_vcs_mode.proto"; +import "synchronization/hashing/algorithm.proto"; + +// Configuration encodes session configuration parameters. It is used for create +// commands to specify configuration options, for loading global configuration +// options, and for storing a merged configuration inside sessions. It should be +// considered immutable. +message Configuration { + // Synchronization parameters (fields 11-20). + // NOTE: These run from field indices 11-20 (rather than 1-10, which are + // reserved for symbolic link configuration parameters) due to the + // historical order in which these fields were added. Field 17 (the digest + // algorithm) is also listed out of its chronological order of addition due + // to its relative importance in the configuration. + + // SynchronizationMode specifies the synchronization mode that should be + // used in synchronization. + core.SynchronizationMode synchronizationMode = 11; + + // HashingAlgorithm specifies the content hashing algorithm used to track + // content and perform differential transfers. + hashing.Algorithm hashingAlgorithm = 17; + + // MaximumEntryCount specifies the maximum number of filesystem entries that + // endpoints will tolerate managing. A zero value indicates no limit. + uint64 maximumEntryCount = 12; + + // MaximumStagingFileSize is the maximum (individual) file size that + // endpoints will stage. A zero value indicates no limit. + uint64 maximumStagingFileSize = 13; + + // ProbeMode specifies the filesystem probing mode. + behavior.ProbeMode probeMode = 14; + + // ScanMode specifies the synchronization root scanning mode. + ScanMode scanMode = 15; + + // StageMode specifies the file staging mode. + StageMode stageMode = 16; + + // Fields 18-20 are reserved for future synchronization configuration + // parameters. + + + // Symbolic link configuration parameters (fields 1-10). + // NOTE: These run from field indices 1-10. The reason for this is that + // symbolic link configuration parameters is due to the historical order in + // which configuration fields were added. + + // SymbolicLinkMode specifies the symbolic link mode. + core.SymbolicLinkMode symbolicLinkMode = 1; + + // Fields 2-10 are reserved for future symbolic link configuration + // parameters. + + + // Watch configuration parameters (fields 21-30). + + // WatchMode specifies the filesystem watching mode. + WatchMode watchMode = 21; + + // WatchPollingInterval specifies the interval (in seconds) for poll-based + // file monitoring. A value of 0 specifies that the default interval should + // be used. + uint32 watchPollingInterval = 22; + + // Fields 23-30 are reserved for future watch configuration parameters. + + + // Ignore configuration parameters (fields 31-60). + + // IgnoreSyntax specifies the syntax and semantics to use for ignores. + // NOTE: This field is out of order due to the historical order in which it + // was added. + ignore.Syntax ignoreSyntax = 34; + + // DefaultIgnores specifies the ignore patterns brought in from the global + // configuration. + // DEPRECATED: This field is no longer used when loading from global + // configuration. Instead, ignores provided by global configuration are + // simply merged into the ignore list of the main configuration. However, + // older sessions still use this field. + repeated string defaultIgnores = 31; + + // Ignores specifies the ignore patterns brought in from the create request. + repeated string ignores = 32; + + // IgnoreVCSMode specifies the VCS ignore mode that should be used in + // synchronization. + ignore.IgnoreVCSMode ignoreVCSMode = 33; + + // Fields 35-60 are reserved for future ignore configuration parameters. + + + // Permissions configuration parameters (fields 61-80). + + // PermissionsMode species the manner in which permissions should be + // propagated between endpoints. + core.PermissionsMode permissionsMode = 61; + + // Field 62 is reserved for PermissionsPreservationMode. + + // DefaultFileMode specifies the default permission mode to use for new + // files in "portable" permission propagation mode. + uint32 defaultFileMode = 63; + + // DefaultDirectoryMode specifies the default permission mode to use for new + // files in "portable" permission propagation mode. + uint32 defaultDirectoryMode = 64; + + // DefaultOwner specifies the default owner identifier to use when setting + // ownership of new files and directories in "portable" permission + // propagation mode. + string defaultOwner = 65; + + // DefaultGroup specifies the default group identifier to use when setting + // ownership of new files and directories in "portable" permission + // propagation mode. + string defaultGroup = 66; + + // Fields 67-80 are reserved for future permission configuration parameters. + + + // Compression configuration parameters (fields 81-90). + + // CompressionAlgorithm specifies the compression algorithm to use when + // communicating with the endpoint. This only applies to remote endpoints. + compression.Algorithm compressionAlgorithm = 81; + + // Fields 82-90 are reserved for future compression configuration + // parameters. +} diff --git a/MutagenSdk/Proto/synchronization/core/change.proto b/MutagenSdk/Proto/synchronization/core/change.proto new file mode 100644 index 0000000..02ce07c --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/change.proto @@ -0,0 +1,49 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/change.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package core; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core"; + +import "synchronization/core/entry.proto"; + +// Change encodes a change to an entry hierarchy. Change objects should be +// considered immutable and must not be modified. +message Change { + // Path is the path of the root of the change (relative to the + // synchronization root). + string path = 1; + // Old represents the old filesystem hierarchy at the change path. It may be + // nil if no content previously existed. + Entry old = 2; + // New represents the new filesystem hierarchy at the change path. It may be + // nil if content has been deleted. + Entry new = 3; +} diff --git a/MutagenSdk/Proto/synchronization/core/conflict.proto b/MutagenSdk/Proto/synchronization/core/conflict.proto new file mode 100644 index 0000000..ea0cf4e --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/conflict.proto @@ -0,0 +1,53 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/conflict.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package core; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core"; + +import "synchronization/core/change.proto"; + +// Conflict encodes conflicting changes on alpha and beta that prevent +// synchronization of a particular path. Conflict objects should be considered +// immutable and must not be modified. +message Conflict { + // Root is the root path for the conflict (relative to the synchronization + // root). While this can (in theory) be computed based on the change lists + // contained within the conflict, doing so relies on those change lists + // being constructed and ordered in a particular manner that's not possible + // to enforce. Additionally, conflicts are often sorted by their root path, + // and dynamically computing it on every sort comparison operation would be + // prohibitively expensive. + string root = 1; + // AlphaChanges are the relevant changes on alpha. + repeated Change alphaChanges = 2; + // BetaChanges are the relevant changes on beta. + repeated Change betaChanges = 3; +} diff --git a/MutagenSdk/Proto/synchronization/core/entry.proto b/MutagenSdk/Proto/synchronization/core/entry.proto new file mode 100644 index 0000000..465396e --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/entry.proto @@ -0,0 +1,110 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/entry.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package core; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core"; + +// EntryKind encodes the type of entry represented by an Entry object. +enum EntryKind { + // EntryKind_Directory indicates a directory. + Directory = 0; + // EntryKind_File indicates a regular file. + File = 1; + // EntryKind_SymbolicLink indicates a symbolic link. + SymbolicLink = 2; + + // Values 3-99 are reserved for future synchronizable entry types. + + // EntryKind_Untracked indicates content (or the root of content) that is + // intentionally excluded from synchronization by Mutagen. This includes + // explicitly ignored content, content that is ignored due to settings (such + // as symbolic links in the "ignore" symbolic link mode), as well as content + // types that Mutagen doesn't understand and/or have a way to propagate + // (such as FIFOs and Unix domain sockets). This type of entry is not + // synchronizable. + Untracked = 100; + // EntryKind_Problematic indicates content (or the root of content) that + // would normally be synchronized, but which is currently inaccessible to + // scanning. This includes (but is not limited to) content that is modified + // concurrently with scanning, content that is inaccessible due to + // permissions, content that can't be read due to filesystem errors, content + // that cannot be properly encoded given the current settings (such as + // absolute symbolic links found when using the "portable" symbolic link + // mode), and content that Mutagen cannot scan or watch reliably (such as + // directories that are also mount points). This type of entry is not + // synchronizable. + Problematic = 101; + // EntryKind_PhantomDirectory indicates a directory that was recorded with + // an ignore mask. This type is used to support Docker-style ignore syntax + // and semantics, which allow directories to be unignored by child content + // that is explicitly unignored. This type is pseudo-synchronizable; entries + // containing phantom contents must have those contents reified (to tracked + // or ignored directories) using ReifyPhantomDirectories before Reconcile. + PhantomDirectory = 102; + + // Values 102 - 199 are reserved for future unsynchronizable entry types. +} + +// Entry encodes a filesystem entry (e.g. a directory, a file, or a symbolic +// link). A nil Entry represents an absence of content. An zero-value Entry +// represents an empty Directory. Entry objects should be considered immutable +// and must not be modified. +message Entry { + // Kind encodes the type of filesystem entry being represented. + EntryKind kind = 1; + + // Fields 2-4 are reserved for future common entry data. + + // Contents represents a directory entry's contents. It must only be non-nil + // for directory entries. + map contents = 5; + + // Fields 6-7 are reserved for future directory entry data. + + // Digest represents the hash of a file entry's contents. It must only be + // non-nil for file entries. + bytes digest = 8; + // Executable indicates whether or not a file entry is marked as executable. + // It must only be set (if appropriate) for file entries. + bool executable = 9; + + // Fields 10-11 are reserved for future file entry data. + + // Target is the symbolic link target for symbolic link entries. It must be + // non-empty if and only if the entry is a symbolic link. + string target = 12; + + // Fields 13-14 are reserved for future symbolic link entry data. + + // Problem indicates the relevant error for problematic content. It must be + // non-empty if and only if the entry represents problematic content. + string problem = 15; +} diff --git a/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto b/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto new file mode 100644 index 0000000..bc3fdfb --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/ignore_vcs_mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package ignore; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core.Ignore"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core/ignore"; + +// IgnoreVCSMode specifies the mode for ignoring VCS directories. +enum IgnoreVCSMode { + // IgnoreVCSMode_IgnoreVCSModeDefault represents an unspecified VCS ignore + // mode. It is not valid for use with Scan. It should be converted to one of + // the following values based on the desired default behavior. + IgnoreVCSModeDefault = 0; + // IgnoreVCSMode_IgnoreVCSModeIgnore indicates that VCS directories should + // be ignored. + IgnoreVCSModeIgnore = 1; + // IgnoreVCSMode_IgnoreVCSModePropagate indicates that VCS directories + // should be propagated. + IgnoreVCSModePropagate = 2; +} diff --git a/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto b/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto new file mode 100644 index 0000000..d7ba811 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/syntax.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package ignore; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core.Ignore"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core/ignore"; + +// Syntax specifies the syntax and semantics for ignore specifications. +enum Syntax { + // Syntax_SyntaxDefault represents an unspecified ignore syntax. It is not + // valid for use with core synchronization functions. It should be converted + // to one of the following values based on the desired default behavior. + SyntaxDefault = 0; + // Syntax_SyntaxMutagen specifies that Mutagen-style ignore syntax and + // semantics should be used. + SyntaxMutagen = 1; + // Syntax_SyntaxDocker specifies that Docker-style ignore syntax and + // semantics should be used. + SyntaxDocker = 2; +} diff --git a/MutagenSdk/Proto/synchronization/core/mode.proto b/MutagenSdk/Proto/synchronization/core/mode.proto new file mode 100644 index 0000000..2a7fffb --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/mode.proto @@ -0,0 +1,70 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package core; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core"; + +// SynchronizationMode specifies the mode for synchronization, encoding both +// directionality and conflict resolution behavior. +enum SynchronizationMode { + // SynchronizationMode_SynchronizationModeDefault represents an unspecified + // synchronization mode. It is not valid for use with Reconcile. It should + // be converted to one of the following values based on the desired default + // behavior. + SynchronizationModeDefault = 0; + + // SynchronizationMode_SynchronizationModeTwoWaySafe represents a + // bidirectional synchronization mode where automatic conflict resolution is + // performed only in cases where no data would be lost. Specifically, this + // means that modified contents are allowed to propagate to the opposite + // endpoint if the corresponding contents on the opposite endpoint are + // unmodified or deleted. All other conflicts are left unresolved. + SynchronizationModeTwoWaySafe = 1; + + // SynchronizationMode_SynchronizationModeTwoWayResolved is the same as + // SynchronizationMode_SynchronizationModeTwoWaySafe, but specifies that the + // alpha endpoint should win automatically in any conflict between alpha and + // beta, including cases where alpha has deleted contents that beta has + // modified. + SynchronizationModeTwoWayResolved = 2; + + // SynchronizationMode_SynchronizationModeOneWaySafe represents a + // unidirectional synchronization mode where contents and changes propagate + // from alpha to beta, but won't overwrite any creations or modifications on + // beta. + SynchronizationModeOneWaySafe = 3; + + // SynchronizationMode_SynchronizationModeOneWayReplica represents a + // unidirectional synchronization mode where contents on alpha are mirrored + // (verbatim) to beta, overwriting any conflicting contents on beta and + // deleting any extraneous contents on beta. + SynchronizationModeOneWayReplica = 4; +} diff --git a/MutagenSdk/Proto/synchronization/core/permissions_mode.proto b/MutagenSdk/Proto/synchronization/core/permissions_mode.proto new file mode 100644 index 0000000..2287fc7 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/permissions_mode.proto @@ -0,0 +1,51 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/permissions_mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package core; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core"; + +// PermissionsMode specifies the mode for handling permission propagation. +enum PermissionsMode { + // PermissionsMode_PermissionsModeDefault represents an unspecified + // permissions mode. It is not valid for use with Scan. It should be + // converted to one of the following values based on the desired default + // behavior. + PermissionsModeDefault = 0; + // PermissionsMode_PermissionsModePortable specifies that permissions should + // be propagated in a portable fashion. This means that only executability + // bits are managed by Mutagen and that manual specifications for ownership + // and base file permissions are used. + PermissionsModePortable = 1; + // PermissionsMode_PermissionsModeManual specifies that only manual + // permission specifications should be used. In this case, Mutagen does not + // perform any propagation of permissions. + PermissionsModeManual = 2; +} diff --git a/MutagenSdk/Proto/synchronization/core/problem.proto b/MutagenSdk/Proto/synchronization/core/problem.proto new file mode 100644 index 0000000..a69470d --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/problem.proto @@ -0,0 +1,44 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/problem.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package core; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core"; + +// Problem indicates an issue or error encountered at some stage of a +// synchronization cycle. Problem objects should be considered immutable and +// must not be modified. +message Problem { + // Path is the path at which the problem occurred (relative to the + // synchronization root). + string path = 1; + // Error is a human-readable summary of the problem. + string error = 2; +} diff --git a/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto b/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto new file mode 100644 index 0000000..b7e623d --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto @@ -0,0 +1,54 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/symbolic_link_mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package core; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Core"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/core"; + +// SymbolicLinkMode specifies the mode for handling symbolic links. +enum SymbolicLinkMode { + // SymbolicLinkMode_SymbolicLinkModeDefault represents an unspecified + // symbolic link mode. It is not valid for use with Scan or Transition. It + // should be converted to one of the following values based on the desired + // default behavior. + SymbolicLinkModeDefault = 0; + // SymbolicLinkMode_SymbolicLinkModeIgnore specifies that all symbolic links + // should be ignored. + SymbolicLinkModeIgnore = 1; + // SymbolicLinkMode_SymbolicLinkModePortable specifies that only portable + // symbolic links should be synchronized. Any absolute symbolic links or + // symbolic links which are otherwise non-portable will be treate as + // problematic content. + SymbolicLinkModePortable = 2; + // SymbolicLinkMode_SymbolicLinkModePOSIXRaw specifies that symbolic links + // should be propagated in their raw form. It is only valid on POSIX systems + // and only makes sense in the context of POSIX-to-POSIX synchronization. + SymbolicLinkModePOSIXRaw = 3; +} diff --git a/MutagenSdk/Proto/synchronization/hashing/algorithm.proto b/MutagenSdk/Proto/synchronization/hashing/algorithm.proto new file mode 100644 index 0000000..5b1983c --- /dev/null +++ b/MutagenSdk/Proto/synchronization/hashing/algorithm.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/hashing/algorithm.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package hashing; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Hashing"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/hashing"; + +// Algorithm specifies a hashing algorithm. +enum Algorithm { + // Algorithm_AlgorithmDefault represents an unspecified hashing algorithm. + // It should be converted to one of the following values based on the + // desired default behavior. + AlgorithmDefault = 0; + // Algorithm_AlgorithmSHA1 specifies that SHA-1 hashing should be used. + AlgorithmSHA1 = 1; + // Algorithm_AlgorithmSHA256 specifies that SHA-256 hashing should be used. + AlgorithmSHA256 = 2; + // Algorithm_AlgorithmXXH128 specifies that XXH128 hashing should be used. + AlgorithmXXH128 = 3; +} diff --git a/MutagenSdk/Proto/synchronization/rsync/receive.proto b/MutagenSdk/Proto/synchronization/rsync/receive.proto new file mode 100644 index 0000000..1c14879 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/rsync/receive.proto @@ -0,0 +1,57 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/rsync/receive.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package rsync; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization.Rsync"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization/rsync"; + +// ReceiverState encodes that status of an rsync receiver. It should be +// considered immutable. +message ReceiverState { + // Path is the path currently being received. + string path = 1; + // ReceivedSize is the number of bytes that have been received for the + // current path from both block and data operations. + uint64 receivedSize = 2; + // ExpectedSize is the number of bytes expected for the current path. + uint64 expectedSize = 3; + // ReceivedFiles is the number of files that have already been received. + uint64 receivedFiles = 4; + // ExpectedFiles is the total number of files expected. + uint64 expectedFiles = 5; + // TotalReceivedSize is the total number of bytes that have been received + // for all files from both block and data operations. + uint64 totalReceivedSize = 6; + // TODO: We may want to add statistics on the speedup offered by the rsync + // algorithm in terms of data volume, though obviously this can't account + // for any savings that might come from compression at the transport layer. + // It would also be really nice to have TotalExpectedSize, but this is + // prohibitively difficult and expensive to compute. +} diff --git a/MutagenSdk/Proto/synchronization/scan_mode.proto b/MutagenSdk/Proto/synchronization/scan_mode.proto new file mode 100644 index 0000000..4a612b3 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/scan_mode.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/scan_mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization"; + +// ScanMode specifies the mode for synchronization root scanning. +enum ScanMode { + // ScanMode_ScanModeDefault represents an unspecified scan mode. It should + // be converted to one of the following values based on the desired default + // behavior. + ScanModeDefault = 0; + // ScanMode_ScanModeFull specifies that full scans should be performed on + // each synchronization cycle. + ScanModeFull = 1; + // ScanMode_ScanModeAccelerated specifies that scans should attempt to use + // watch-based acceleration. + ScanModeAccelerated = 2; +} diff --git a/MutagenSdk/Proto/synchronization/session.proto b/MutagenSdk/Proto/synchronization/session.proto new file mode 100644 index 0000000..04b177e --- /dev/null +++ b/MutagenSdk/Proto/synchronization/session.proto @@ -0,0 +1,101 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/session.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization"; + +import "google/protobuf/timestamp.proto"; + +import "synchronization/configuration.proto"; +import "synchronization/version.proto"; +import "url/url.proto"; + +// Session represents a synchronization session configuration and persistent +// state. It is mutable within the context of the daemon, so it should be +// accessed and modified in a synchronized fashion. Outside of the daemon (e.g. +// when returned via the API), it should be considered immutable. +message Session { + // The identifier, version, creationTime, and creatingVersion* fields are + // considered the "header" fields for all session versions. A message + // composed purely of these fields is guaranteed to be compatible with all + // future session versions. This can be used to dispatch session decoding to + // more specific message structures once multiple session version formats + // are implemented. + + // Identifier is the (unique) session identifier. It is static. It cannot be + // empty. + string identifier = 1; + // Version is the session version. It is static. + Version version = 2; + // CreationTime is the creation time of the session. It is static. It cannot + // be nil. + google.protobuf.Timestamp creationTime = 3; + // CreatingVersionMajor is the major version component of the version of + // Mutagen which created the session. It is static. + uint32 creatingVersionMajor = 4; + // CreatingVersionMinor is the minor version component of the version of + // Mutagen which created the session. It is static. + uint32 creatingVersionMinor = 5; + // CreatingVersionPatch is the patch version component of the version of + // Mutagen which created the session. It is static. + uint32 creatingVersionPatch = 6; + + // The remaining fields are those currently used by session version 1. + + // Alpha is the alpha endpoint URL. It is static. It cannot be nil. + url.URL alpha = 7; + // Beta is the beta endpoint URL. It is static. It cannot be nil. + url.URL beta = 8; + // Configuration is the flattened session configuration. It is static. It + // cannot be nil. + Configuration configuration = 9; + // ConfigurationAlpha are the alpha-specific session configuration + // overrides. It is static. It may be nil for existing sessions loaded from + // disk, but it is not considered valid unless non-nil, so it should be + // replaced with an empty default value in-memory if a nil on-disk value is + // detected. + Configuration configurationAlpha = 11; + // ConfigurationBeta are the beta-specific session configuration overrides. + // It is static. It may be nil for existing sessions loaded from disk, but + // it is not considered valid unless non-nil, so it should be replaced with + // an empty default value in-memory if a nil on-disk value is detected. + Configuration configurationBeta = 12; + // Name is a user-friendly name for the session. It may be empty and is not + // guaranteed to be unique across all sessions. It is only used as a simpler + // handle for specifying sessions. It is static. + string name = 14; + // Labels are the session labels. They are static. + map labels = 13; + // Paused indicates whether or not the session is marked as paused. + bool paused = 10; + // NOTE: Fields 11, 12, 13, and 14 are used above. They are out of order for + // historical reasons. +} diff --git a/MutagenSdk/Proto/synchronization/stage_mode.proto b/MutagenSdk/Proto/synchronization/stage_mode.proto new file mode 100644 index 0000000..9da274e --- /dev/null +++ b/MutagenSdk/Proto/synchronization/stage_mode.proto @@ -0,0 +1,51 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/stage_mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization"; + +// StageMode specifies the mode for file staging. +enum StageMode { + // StageMode_StageModeDefault represents an unspecified staging mode. It + // should be converted to one of the following values based on the desired + // default behavior. + StageModeDefault = 0; + // StageMode_StageModeMutagen specifies that files should be staged in the + // Mutagen data directory. + StageModeMutagen = 1; + // StageMode_StageModeNeighboring specifies that files should be staged in a + // directory which neighbors the synchronization root. + StageModeNeighboring = 2; + // StageMode_StageModeInternal specified that files should be staged in a + // directory contained within a synchronization root. This mode will only + // function if the synchronization root already exists. + StageModeInternal = 3; +} diff --git a/MutagenSdk/Proto/synchronization/state.proto b/MutagenSdk/Proto/synchronization/state.proto new file mode 100644 index 0000000..fc62518 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/state.proto @@ -0,0 +1,160 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/state.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization"; + +import "synchronization/rsync/receive.proto"; +import "synchronization/session.proto"; +import "synchronization/core/conflict.proto"; +import "synchronization/core/problem.proto"; + +// Status encodes the status of a synchronization session. +enum Status { + // Status_Disconnected indicates that the session is unpaused but not + // currently connected or connecting to either endpoint. + Disconnected = 0; + // Status_HaltedOnRootEmptied indicates that the session is halted due to + // the root emptying safety check. + HaltedOnRootEmptied = 1; + // Status_HaltedOnRootDeletion indicates that the session is halted due to + // the root deletion safety check. + HaltedOnRootDeletion = 2; + // Status_HaltedOnRootTypeChange indicates that the session is halted due to + // the root type change safety check. + HaltedOnRootTypeChange = 3; + // Status_ConnectingAlpha indicates that the session is attempting to + // connect to the alpha endpoint. + ConnectingAlpha = 4; + // Status_ConnectingBeta indicates that the session is attempting to connect + // to the beta endpoint. + ConnectingBeta = 5; + // Status_Watching indicates that the session is watching for filesystem + // changes. + Watching = 6; + // Status_Scanning indicates that the session is scanning the filesystem on + // each endpoint. + Scanning = 7; + // Status_WaitingForRescan indicates that the session is waiting to retry + // scanning after an error during the previous scanning operation. + WaitingForRescan = 8; + // Status_Reconciling indicates that the session is performing + // reconciliation. + Reconciling = 9; + // Status_StagingAlpha indicates that the session is staging files on alpha. + StagingAlpha = 10; + // Status_StagingBeta indicates that the session is staging files on beta. + StagingBeta = 11; + // Status_Transitioning indicates that the session is performing transition + // operations on each endpoint. + Transitioning = 12; + // Status_Saving indicates that the session is recording synchronization + // history to disk. + Saving = 13; +} + +// EndpointState encodes the current state of a synchronization endpoint. It is +// mutable within the context of the daemon, so it should be accessed and +// modified in a synchronized fashion. Outside of the daemon (e.g. when returned +// via the API), it should be considered immutable. +message EndpointState { + // Connected indicates whether or not the controller is currently connected + // to the endpoint. + bool connected = 1; + // Scanned indicates whether or not at least one scan has been performed on + // the endpoint. + bool scanned = 2; + // Directories is the number of synchronizable directory entries contained + // in the last snapshot from the endpoint. + uint64 directories = 3; + // Files is the number of synchronizable file entries contained in the last + // snapshot from the endpoint. + uint64 files = 4; + // SymbolicLinks is the number of synchronizable symbolic link entries + // contained in the last snapshot from the endpoint. + uint64 symbolicLinks = 5; + // TotalFileSize is the total size of all synchronizable files referenced by + // the last snapshot from the endpoint. + uint64 totalFileSize = 6; + // ScanProblems is the list of non-terminal problems encountered during the + // last scanning operation on the endpoint. This list may be a truncated + // version of the full list if too many problems are encountered to report + // via the API, in which case ExcludedScanProblems will be non-zero. + repeated core.Problem scanProblems = 7; + // ExcludedScanProblems is the number of problems that have been excluded + // from ScanProblems due to truncation. This value can be non-zero only if + // ScanProblems is non-empty. + uint64 excludedScanProblems = 8; + // TransitionProblems is the list of non-terminal problems encountered + // during the last transition operation on the endpoint. This list may be a + // truncated version of the full list if too many problems are encountered + // to report via the API, in which case ExcludedTransitionProblems will be + // non-zero. + repeated core.Problem transitionProblems = 9; + // ExcludedTransitionProblems is the number of problems that have been + // excluded from TransitionProblems due to truncation. This value can be + // non-zero only if TransitionProblems is non-empty. + uint64 excludedTransitionProblems = 10; + // StagingProgress is the rsync staging progress. It is non-nil if and only + // if the endpoint is currently staging files. + rsync.ReceiverState stagingProgress = 11; +} + +// State encodes the current state of a synchronization session. It is mutable +// within the context of the daemon, so it should be accessed and modified in a +// synchronized fashion. Outside of the daemon (e.g. when returned via the API), +// it should be considered immutable. +message State { + // Session is the session metadata. If the session is paused, then the + // remainder of the fields in this structure should be ignored. + Session session = 1; + // Status is the session status. + Status status = 2; + // LastError is the last error to occur during synchronization. It is + // cleared after a successful synchronization cycle. + string lastError = 3; + // SuccessfulCycles is the number of successful synchronization cycles to + // occur since successfully connecting to the endpoints. + uint64 successfulCycles = 4; + // Conflicts are the content conflicts identified during reconciliation. + // This list may be a truncated version of the full list if too many + // conflicts are encountered to report via the API, in which case + // ExcludedConflicts will be non-zero. + repeated core.Conflict conflicts = 5; + // ExcludedConflicts is the number of conflicts that have been excluded from + // Conflicts due to truncation. This value can be non-zero only if conflicts + // is non-empty. + uint64 excludedConflicts = 6; + // AlphaState encodes the state of the alpha endpoint. It is always non-nil. + EndpointState alphaState = 7; + // BetaState encodes the state of the beta endpoint. It is always non-nil. + EndpointState betaState = 8; +} diff --git a/MutagenSdk/Proto/synchronization/version.proto b/MutagenSdk/Proto/synchronization/version.proto new file mode 100644 index 0000000..08743c4 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/version.proto @@ -0,0 +1,44 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/version.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization"; + +// Version specifies a session version, providing default behavior that can vary +// without affecting existing sessions. +enum Version { + // Invalid is the default session version and represents an unspecfied and + // invalid version. It is used as a sanity check to ensure that version is + // set for a session. + Invalid = 0; + // Version1 represents session version 1. + Version1 = 1; +} diff --git a/MutagenSdk/Proto/synchronization/watch_mode.proto b/MutagenSdk/Proto/synchronization/watch_mode.proto new file mode 100644 index 0000000..b321a18 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/watch_mode.proto @@ -0,0 +1,54 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/watch_mode.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package synchronization; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Synchronization"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/synchronization"; + +// WatchMode specifies the mode for filesystem watching. +enum WatchMode { + // WatchMode_WatchModeDefault represents an unspecified watch mode. It + // should be converted to one of the following values based on the desired + // default behavior. + WatchModeDefault = 0; + // WatchMode_WatchModePortable specifies that native recursive watching + // should be used to monitor paths on systems that support it if those paths + // fall under the home directory. In these cases, a watch on the entire home + // directory is established and filtered for events pertaining to the + // specified path. On all other systems and for all other paths, poll-based + // watching is used. + WatchModePortable = 1; + // WatchMode_WatchModeForcePoll specifies that only poll-based watching + // should be used. + WatchModeForcePoll = 2; + // WatchMode_WatchModeNoWatch specifies that no watching should be used + // (i.e. no events should be generated). + WatchModeNoWatch = 3; +} diff --git a/MutagenSdk/Proto/url/url.proto b/MutagenSdk/Proto/url/url.proto new file mode 100644 index 0000000..95340e9 --- /dev/null +++ b/MutagenSdk/Proto/url/url.proto @@ -0,0 +1,91 @@ +/* + * This file was taken from + * https://github.com/coder/mutagen/tree/v0.18.3/pkg/url/url.proto + * + * MIT License + * + * Copyright (c) 2016-present Docker, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +syntax = "proto3"; + +package url; +option csharp_namespace = "Coder.Desktop.MutagenSdk.Proto.Url"; + +option go_package = "github.com/mutagen-io/mutagen/pkg/url"; + +// Kind indicates the kind of a URL. +enum Kind { + // Synchronization indicates a synchronization URL. + Synchronization = 0; + // Forwarding indicates a forwarding URL. + Forwarding = 1; +} + +// Protocol indicates a location type. +enum Protocol { + // Local indicates that the resource is on the local system. + Local = 0; + // SSH indicates that the resource is accessible via SSH. + SSH = 1; + + // Enumeration value 2 is reserved for custom protocols. + + // Enumeration value 3 was previously used for the mutagen.io-based tunnel + // protocol. This protocol was experimental and only available as part of + // the v0.11.x release series. It should not be re-used. + + // Enumeration values 4-10 are reserved for core protocols. + + // Docker indicates that the resource is inside a Docker container. + Docker = 11; +} + +// URL represents a pointer to a resource. It should be considered immutable. +message URL { + // Kind indicates the URL kind. + // NOTE: This field number is out of order for historical reasons. + Kind kind = 7; + // Protocol indicates a location type. + Protocol protocol = 1; + // User is the user under which a resource should be accessed. + string user = 2; + // Host is protocol-specific, but generally indicates the location of the + // remote. + string host = 3; + // Port indicates a TCP port via which to access the remote location, if + // applicable. + uint32 port = 4; + // Path indicates the path of a resource. + string path = 5; + // Environment contains captured environment variable information. It is not + // a required component and its contents and their behavior depend on the + // transport implementation. + map environment = 6; + + // Field 7 is already used above for the kind field. It is out of order for + // historical reasons. + + // Parameters are internal transport parameters. These are set for URLs + // generated internally that require additional metadata. Parameters are not + // required and their behavior is dependent on the transport implementation. + map parameters = 8; +} diff --git a/MutagenSdk/Update-Proto.ps1 b/MutagenSdk/Update-Proto.ps1 new file mode 100644 index 0000000..eb9992b --- /dev/null +++ b/MutagenSdk/Update-Proto.ps1 @@ -0,0 +1,148 @@ +# Usage: Update-Proto.ps1 -mutagenTag +param ( + [Parameter(Mandatory = $true)] + [string] $mutagenTag +) + +$ErrorActionPreference = "Stop" + +$repo = "coder/mutagen" +$protoPrefix = "pkg" +$entryFiles = @( + "service/daemon/daemon.proto", + "service/prompting/prompting.proto", + "service/synchronization/synchronization.proto" +) + +$outputNamespace = "Coder.Desktop.MutagenSdk.Proto" +$outputDir = "MutagenSdk\Proto" + +$cloneDir = Join-Path $env:TEMP "coder-desktop-mutagen-proto" +if (Test-Path $cloneDir) { + Write-Host "Found existing mutagen repo at $cloneDir, checking out $mutagenTag..." + # Checkout tag and clean + Push-Location $cloneDir + try { + & git.exe clean -fdx + if ($LASTEXITCODE -ne 0) { throw "Failed to clean $cloneDir" } + # If we're already on the tag, we don't need to fetch or checkout. + if ((& git.exe name-rev --name-only HEAD) -eq "tags/$mutagenTag") { + Write-Host "Already on $mutagenTag" + } + else { + & git.exe fetch --all + if ($LASTEXITCODE -ne 0) { throw "Failed to fetch all tags" } + & git.exe checkout $mutagenTag + if ($LASTEXITCODE -ne 0) { throw "Failed to checkout $mutagenTag" } + } + } + finally { + Pop-Location + } +} +else { + New-Item -ItemType Directory -Path $cloneDir -Force + + Write-Host "Cloning mutagen repo to $cloneDir..." + & git.exe clone ` + --depth 1 ` + --branch $mutagenTag ` + "https://github.com/$repo.git" ` + $cloneDir +} + +# Read and format the license header for the copied files. +$licenseContent = Get-Content (Join-Path $cloneDir "LICENSE") +# Find the index where MIT License starts so we don't include the preamble. +$mitStartIndex = $licenseContent.IndexOf("MIT License") +$licenseHeader = ($licenseContent[$mitStartIndex..($licenseContent.Length - 1)] | ForEach-Object { (" * " + $_).TrimEnd() }) -join "`n" + +# Map of src (in the mutagen repo) to dst (within the $outputDir). +$filesToCopy = @{} + +function Add-ImportedFiles([string] $path) { + $content = Get-Content $path + foreach ($line in $content) { + if ($line -match '^import "(.+)"') { + $importPath = $matches[1] + + # If the import path starts with google, it doesn't exist in the + # mutagen repo, so we need to skip it. + if ($importPath -match '^google/') { + Write-Host "Skipping $importPath" + continue + } + + # Mutagen generates from within the pkg directory, so we need to add + # the prefix. + $filePath = Join-Path $cloneDir (Join-Path $protoPrefix $importPath) + if (-not $filesToCopy.ContainsKey($filePath)) { + Write-Host "Adding $filePath $importPath" + $filesToCopy[$filePath] = $importPath + Add-ImportedFiles $filePath + } + } + } +} + +foreach ($entryFile in $entryFiles) { + $entryFilePath = Join-Path $cloneDir (Join-Path $protoPrefix $entryFile) + if (-not (Test-Path $entryFilePath)) { + throw "Failed to find $entryFilePath in mutagen repo" + } + $filesToCopy[$entryFilePath] = $entryFile + Add-ImportedFiles $entryFilePath +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Push-Location $repoRoot +$outputDir = Resolve-Path $outputDir +if (Test-Path $outputDir) { + Remove-Item -Recurse -Force $outputDir +} +New-Item -ItemType Directory -Path $outputDir -Force + +try { + foreach ($filePath in $filesToCopy.Keys) { + $protoPath = $filesToCopy[$filePath] + $dstPath = Join-Path $outputDir $protoPath + $destDir = Split-Path -Path $dstPath -Parent + if (-not (Test-Path $destDir)) { + New-Item -ItemType Directory -Path $destDir -Force + } + + # Determine the license header. + $fileHeader = "/*`n" + + " * This file was taken from`n" + + " * https://github.com/$repo/tree/$mutagenTag/$protoPrefix/$protoPath`n" + + " *`n" + + $licenseHeader + + "`n */`n`n" + + # Determine the csharp_namespace for the file. + # Remove the filename and capitalize the first letter of each component + # of the path, then join with dots. + $protoDir = Split-Path -Path $protoPath -Parent + $csharpNamespaceSuffix = ($protoDir -split '[/\\]' | ForEach-Object { $_.Substring(0, 1).ToUpper() + $_.Substring(1) }) -join '.' + $csharpNamespace = "$outputNamespace" + if ($csharpNamespaceSuffix) { + $csharpNamespace += ".$csharpNamespaceSuffix" + } + + # Add the license header and csharp_namespace declaration. + $content = Get-Content $filePath -Raw + $content = $fileHeader + $content + $content = $content -replace '(?m)^(package .*?;)', "`$1`noption csharp_namespace = `"$csharpNamespace`";" + + # Replace all LF with CRLF to avoid spurious diffs in git. + $content = $content -replace "(? + { + (0, "0 B"), + ((uint)0, "0 B"), + ((long)0, "0 B"), + ((ulong)0, "0 B"), + + (1, "1 B"), + (1024, "1 KB"), + ((ulong)(1.1 * 1024), "1.1 KB"), + (1024 * 1024, "1 MB"), + (1024 * 1024 * 1024, "1 GB"), + ((ulong)1024 * 1024 * 1024 * 1024, "1 TB"), + ((ulong)1024 * 1024 * 1024 * 1024 * 1024, "1 PB"), + ((ulong)1024 * 1024 * 1024 * 1024 * 1024 * 1024, "1 EB"), + (ulong.MaxValue, "16 EB"), + }; + + var converter = new FriendlyByteConverter(); + foreach (var (input, expected) in cases) + { + var actual = converter.Convert(input, typeof(string), null, null); + Assert.That(actual, Is.EqualTo(expected), $"case ({input.GetType()}){input}"); + } + } +} diff --git a/Tests.App/Services/CredentialManagerTest.cs b/Tests.App/Services/CredentialManagerTest.cs new file mode 100644 index 0000000..9d00cf2 --- /dev/null +++ b/Tests.App/Services/CredentialManagerTest.cs @@ -0,0 +1,323 @@ +using System.Diagnostics; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.CoderSdk.Coder; +using Moq; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class CredentialManagerTest +{ + private const string TestServerUrl = "https://dev.coder.com"; + private const string TestApiToken = "abcdef1234-abcdef1234567890ABCDEF"; + private const string TestUsername = "dean"; + + [Test(Description = "End to end test with WindowsCredentialBackend")] + [CancelAfter(30_000)] + public async Task EndToEnd(CancellationToken ct) + { + var credentialBackend = new WindowsCredentialBackend($"Coder.Desktop.Test.App.{Guid.NewGuid()}"); + + // I lied. It's not fully end to end. We don't use a real or fake API + // server for this and use a mock client instead. + var apiClient = new Mock(MockBehavior.Strict); + apiClient.Setup(x => x.SetSessionToken(TestApiToken)); + apiClient.Setup(x => x.GetBuildInfo(It.IsAny()).Result) + .Returns(new BuildInfo { Version = "v2.20.0" }); + apiClient.Setup(x => x.GetUser(User.Me, It.IsAny()).Result) + .Returns(new User { Username = TestUsername }); + var apiClientFactory = new Mock(MockBehavior.Strict); + apiClientFactory.Setup(x => x.Create(TestServerUrl)) + .Returns(apiClient.Object); + + try + { + var manager1 = new CredentialManager(credentialBackend, apiClientFactory.Object); + + // Cached credential should be unknown. + var cred = manager1.GetCachedCredentials(); + Assert.That(cred.State, Is.EqualTo(CredentialState.Unknown)); + + // Load credentials from backend. No credentials are stored so it + // should be invalid. + cred = await manager1.LoadCredentials(ct).WaitAsync(ct); + Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid)); + + // SetCredentials should succeed. + await manager1.SetCredentials(TestServerUrl, TestApiToken, ct).WaitAsync(ct); + + // Cached credential should be valid. + cred = manager1.GetCachedCredentials(); + Assert.That(cred.State, Is.EqualTo(CredentialState.Valid)); + Assert.That(cred.CoderUrl, Is.EqualTo(TestServerUrl)); + Assert.That(cred.ApiToken, Is.EqualTo(TestApiToken)); + Assert.That(cred.Username, Is.EqualTo(TestUsername)); + + // Load credentials should return the same reference. + var loadedCred = await manager1.LoadCredentials(ct).WaitAsync(ct); + Assert.That(ReferenceEquals(cred, loadedCred), Is.True); + + // A second manager should be able to load the same credentials. + var manager2 = new CredentialManager(credentialBackend, apiClientFactory.Object); + cred = await manager2.LoadCredentials(ct).WaitAsync(ct); + Assert.That(cred.State, Is.EqualTo(CredentialState.Valid)); + Assert.That(cred.CoderUrl, Is.EqualTo(TestServerUrl)); + Assert.That(cred.ApiToken, Is.EqualTo(TestApiToken)); + Assert.That(cred.Username, Is.EqualTo(TestUsername)); + + // Clearing the credentials should make them invalid. + await manager1.ClearCredentials(ct).WaitAsync(ct); + cred = manager1.GetCachedCredentials(); + Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid)); + + // And loading them in a new manager should also be invalid. + var manager3 = new CredentialManager(credentialBackend, apiClientFactory.Object); + cred = await manager3.LoadCredentials(ct).WaitAsync(ct); + Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid)); + } + finally + { + // In case something goes wrong, make sure to clean up. + using var cts = new CancellationTokenSource(); + cts.CancelAfter(15_000); + await credentialBackend.DeleteCredentials(cts.Token); + } + } + + [Test(Description = "Test SetCredentials with invalid URL or token")] + [CancelAfter(30_000)] + public void SetCredentialsInvalidUrlOrToken(CancellationToken ct) + { + var credentialBackend = new Mock(MockBehavior.Strict); + var apiClientFactory = new Mock(MockBehavior.Strict); + var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object); + + var cases = new List<(string, string, string)> + { + (null!, TestApiToken, "Coder URL is required"), + ("", TestApiToken, "Coder URL is required"), + (" ", TestApiToken, "Coder URL is required"), + (new string('a', 129), TestApiToken, "Coder URL is too long"), + ("a", TestApiToken, "not a valid URL"), + ("ftp://dev.coder.com", TestApiToken, "Coder URL must be HTTP or HTTPS"), + + (TestServerUrl, null!, "API token is required"), + (TestServerUrl, "", "API token is required"), + (TestServerUrl, " ", "API token is required"), + }; + + foreach (var (url, token, expectedMessage) in cases) + { + var ex = Assert.ThrowsAsync(() => + manager.SetCredentials(url, token, ct)); + Assert.That(ex.Message, Does.Contain(expectedMessage)); + } + } + + [Test(Description = "Invalid server buildinfo response")] + [CancelAfter(30_000)] + public void InvalidServerBuildInfoResponse(CancellationToken ct) + { + var credentialBackend = new Mock(MockBehavior.Strict); + var apiClient = new Mock(MockBehavior.Strict); + apiClient.Setup(x => x.GetBuildInfo(It.IsAny()).Result) + .Throws(new Exception("Test exception")); + var apiClientFactory = new Mock(MockBehavior.Strict); + apiClientFactory.Setup(x => x.Create(TestServerUrl)) + .Returns(apiClient.Object); + + // Attempt a set. + var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object); + var ex = Assert.ThrowsAsync(() => + manager.SetCredentials(TestServerUrl, TestApiToken, ct)); + Assert.That(ex.Message, Does.Contain("Could not connect to or verify Coder server")); + + // Attempt a load. + credentialBackend.Setup(x => x.ReadCredentials(It.IsAny()).Result) + .Returns(new RawCredentials + { + CoderUrl = TestServerUrl, + ApiToken = TestApiToken, + }); + var cred = manager.LoadCredentials(ct).Result; + Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid)); + } + + [Test(Description = "Invalid server version")] + [CancelAfter(30_000)] + public void InvalidServerVersion(CancellationToken ct) + { + var credentialBackend = new Mock(MockBehavior.Strict); + var apiClient = new Mock(MockBehavior.Strict); + apiClient.Setup(x => x.GetBuildInfo(It.IsAny()).Result) + .Returns(new BuildInfo { Version = "v2.19.0" }); + apiClient.Setup(x => x.SetSessionToken(TestApiToken)); + apiClient.Setup(x => x.GetUser(User.Me, It.IsAny()).Result) + .Returns(new User { Username = TestUsername }); + var apiClientFactory = new Mock(MockBehavior.Strict); + apiClientFactory.Setup(x => x.Create(TestServerUrl)) + .Returns(apiClient.Object); + + // Attempt a set. + var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object); + var ex = Assert.ThrowsAsync(() => + manager.SetCredentials(TestServerUrl, TestApiToken, ct)); + Assert.That(ex.Message, Does.Contain("not within required server version range")); + + // Attempt a load. + credentialBackend.Setup(x => x.ReadCredentials(It.IsAny()).Result) + .Returns(new RawCredentials + { + CoderUrl = TestServerUrl, + ApiToken = TestApiToken, + }); + var cred = manager.LoadCredentials(ct).Result; + Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid)); + } + + [Test(Description = "Invalid server user response")] + [CancelAfter(30_000)] + public void InvalidServerUserResponse(CancellationToken ct) + { + var credentialBackend = new Mock(MockBehavior.Strict); + var apiClient = new Mock(MockBehavior.Strict); + apiClient.Setup(x => x.GetBuildInfo(It.IsAny()).Result) + .Returns(new BuildInfo { Version = "v2.20.0" }); + apiClient.Setup(x => x.SetSessionToken(TestApiToken)); + apiClient.Setup(x => x.GetUser(User.Me, It.IsAny()).Result) + .Throws(new Exception("Test exception")); + var apiClientFactory = new Mock(MockBehavior.Strict); + apiClientFactory.Setup(x => x.Create(TestServerUrl)) + .Returns(apiClient.Object); + + // Attempt a set. + var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object); + var ex = Assert.ThrowsAsync(() => + manager.SetCredentials(TestServerUrl, TestApiToken, ct)); + Assert.That(ex.Message, Does.Contain("Could not connect to or verify Coder server")); + + // Attempt a load. + credentialBackend.Setup(x => x.ReadCredentials(It.IsAny()).Result) + .Returns(new RawCredentials + { + CoderUrl = TestServerUrl, + ApiToken = TestApiToken, + }); + var cred = manager.LoadCredentials(ct).Result; + Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid)); + } + + [Test(Description = "Invalid username")] + [CancelAfter(30_000)] + public void InvalidUsername(CancellationToken ct) + { + var credentialBackend = new Mock(MockBehavior.Strict); + var apiClient = new Mock(MockBehavior.Strict); + apiClient.Setup(x => x.GetBuildInfo(It.IsAny()).Result) + .Returns(new BuildInfo { Version = "v2.20.0" }); + apiClient.Setup(x => x.SetSessionToken(TestApiToken)); + apiClient.Setup(x => x.GetUser(User.Me, It.IsAny()).Result) + .Returns(new User { Username = "" }); + var apiClientFactory = new Mock(MockBehavior.Strict); + apiClientFactory.Setup(x => x.Create(TestServerUrl)) + .Returns(apiClient.Object); + + // Attempt a set. + var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object); + var ex = Assert.ThrowsAsync(() => + manager.SetCredentials(TestServerUrl, TestApiToken, ct)); + Assert.That(ex.Message, Does.Contain("username is empty")); + + // Attempt a load. + credentialBackend.Setup(x => x.ReadCredentials(It.IsAny()).Result) + .Returns(new RawCredentials + { + CoderUrl = TestServerUrl, + ApiToken = TestApiToken, + }); + var cred = manager.LoadCredentials(ct).Result; + Assert.That(cred.State, Is.EqualTo(CredentialState.Invalid)); + } + + [Test(Description = "Duplicate loads should use the same Task")] + [CancelAfter(30_000)] + public async Task DuplicateLoads(CancellationToken ct) + { + var credentialBackend = new Mock(MockBehavior.Strict); + credentialBackend.Setup(x => x.ReadCredentials(It.IsAny()).Result) + .Returns(new RawCredentials + { + CoderUrl = TestServerUrl, + ApiToken = TestApiToken, + }) + .Verifiable(Times.Exactly(1)); + var apiClient = new Mock(MockBehavior.Strict); + // To accomplish delay, the GetBuildInfo will wait for a TCS. + var tcs = new TaskCompletionSource(); + apiClient.Setup(x => x.GetBuildInfo(It.IsAny())) + .Returns(async (CancellationToken _) => + { + await tcs.Task.WaitAsync(ct); + return new BuildInfo { Version = "v2.20.0" }; + }) + .Verifiable(Times.Exactly(1)); + apiClient.Setup(x => x.SetSessionToken(TestApiToken)); + apiClient.Setup(x => x.GetUser(User.Me, It.IsAny()).Result) + .Returns(new User { Username = TestUsername }) + .Verifiable(Times.Exactly(1)); + var apiClientFactory = new Mock(MockBehavior.Strict); + apiClientFactory.Setup(x => x.Create(TestServerUrl)) + .Returns(apiClient.Object) + .Verifiable(Times.Exactly(1)); + + var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object); + var cred1Task = manager.LoadCredentials(ct); + var cred2Task = manager.LoadCredentials(ct); + Assert.That(ReferenceEquals(cred1Task, cred2Task), Is.True); + tcs.SetResult(); + var cred1 = await cred1Task.WaitAsync(ct); + var cred2 = await cred2Task.WaitAsync(ct); + Assert.That(ReferenceEquals(cred1, cred2), Is.True); + + credentialBackend.Verify(); + apiClient.Verify(); + apiClientFactory.Verify(); + } + + [Test(Description = "A set during a load should cancel the load")] + [CancelAfter(30_000)] + public async Task SetDuringLoad(CancellationToken ct) + { + var credentialBackend = new Mock(MockBehavior.Strict); + // To accomplish a delay on the load, ReadCredentials will block on the CT. + credentialBackend.Setup(x => x.ReadCredentials(It.IsAny())) + .Returns(async (CancellationToken innerCt) => + { + await Task.Delay(Timeout.Infinite, innerCt).WaitAsync(ct); + throw new UnreachableException(); + }); + credentialBackend.Setup(x => + x.WriteCredentials( + It.Is(c => c.CoderUrl == TestServerUrl && c.ApiToken == TestApiToken), + It.IsAny())) + .Returns(Task.CompletedTask); + var apiClient = new Mock(MockBehavior.Strict); + apiClient.Setup(x => x.GetBuildInfo(It.IsAny()).Result) + .Returns(new BuildInfo { Version = "v2.20.0" }); + apiClient.Setup(x => x.SetSessionToken(TestApiToken)); + apiClient.Setup(x => x.GetUser(User.Me, It.IsAny()).Result) + .Returns(new User { Username = TestUsername }); + var apiClientFactory = new Mock(MockBehavior.Strict); + apiClientFactory.Setup(x => x.Create(TestServerUrl)) + .Returns(apiClient.Object); + + var manager = new CredentialManager(credentialBackend.Object, apiClientFactory.Object); + // Start a load... + var loadTask = manager.LoadCredentials(ct); + // Then fully perform a set. + await manager.SetCredentials(TestServerUrl, TestApiToken, ct).WaitAsync(ct); + // The load should have been cancelled. + Assert.ThrowsAsync(() => loadTask); + } +} diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs new file mode 100644 index 0000000..2c97515 --- /dev/null +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -0,0 +1,300 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using NUnit.Framework.Interfaces; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class MutagenControllerTest +{ + [OneTimeSetUp] + public async Task DownloadMutagen() + { + var ct = new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token; + var scriptDirectory = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.TestDirectory, + "..", "..", "..", "..", "scripts")); + var process = new Process(); + process.StartInfo.FileName = "powershell.exe"; + process.StartInfo.UseShellExecute = false; + process.StartInfo.Arguments = $"-ExecutionPolicy Bypass -File Get-Mutagen.ps1 -arch {_arch}"; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.WorkingDirectory = scriptDirectory; + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(ct); + TestContext.Out.Write(output); + var error = await process.StandardError.ReadToEndAsync(ct); + TestContext.Error.Write(error); + Assert.That(process.ExitCode, Is.EqualTo(0)); + _mutagenBinaryPath = Path.Combine(scriptDirectory, "files", $"mutagen-windows-{_arch}.exe"); + Assert.That(File.Exists(_mutagenBinaryPath)); + } + + [SetUp] + public void CreateTempDir() + { + _tempDirectory = Directory.CreateTempSubdirectory(GetType().Name); + TestContext.Out.WriteLine($"temp directory: {_tempDirectory}"); + } + + [TearDown] + public void DeleteTempDir() + { + // Only delete the temp directory if the test passed. + if (TestContext.CurrentContext.Result.Outcome == ResultState.Success) + _tempDirectory.Delete(true); + else + TestContext.Out.WriteLine($"persisting temp directory: {_tempDirectory}"); + } + + private string _mutagenBinaryPath; + private DirectoryInfo _tempDirectory; + + private readonly string _arch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + // We only support amd64 and arm64 on Windows currently. + _ => throw new PlatformNotSupportedException( + $"Unsupported architecture '{RuntimeInformation.ProcessArchitecture}'. Coder only supports x64 and arm64."), + }; + + /// + /// Ensures the daemon is stopped by waiting for the daemon.lock file to be released. + /// + private static async Task AssertDaemonStopped(string dataDirectory, CancellationToken ct) + { + var lockPath = Path.Combine(dataDirectory, "daemon", "daemon.lock"); + // If we can lock the daemon.lock file, it means the daemon has stopped. + while (true) + { + ct.ThrowIfCancellationRequested(); + try + { + await using var lockFile = new FileStream(lockPath, FileMode.Open, FileAccess.Write, FileShare.None); + } + catch (IOException e) + { + TestContext.Out.WriteLine($"Could not acquire daemon.lock (will retry): {e.Message}"); + await Task.Delay(100, ct); + } + + break; + } + } + + [Test(Description = "Full sync test")] + [CancelAfter(30_000)] + public async Task Ok(CancellationToken ct) + { + // NUnit runs each test in a temporary directory + var dataDirectory = _tempDirectory.CreateSubdirectory("mutagen").FullName; + var alphaDirectory = _tempDirectory.CreateSubdirectory("alpha"); + var betaDirectory = _tempDirectory.CreateSubdirectory("beta"); + + await using var controller = new MutagenController(_mutagenBinaryPath, dataDirectory); + + // Initial state before calling RefreshState. + var state = controller.GetState(); + Assert.That(state.Lifecycle, Is.EqualTo(SyncSessionControllerLifecycle.Uninitialized)); + Assert.That(state.DaemonError, Is.Null); + Assert.That(state.DaemonLogFilePath, Is.EqualTo(Path.Combine(dataDirectory, "daemon.log"))); + Assert.That(state.SyncSessions, Is.Empty); + + state = await controller.RefreshState(ct); + Assert.That(state.Lifecycle, Is.EqualTo(SyncSessionControllerLifecycle.Stopped)); + Assert.That(state.DaemonError, Is.Null); + Assert.That(state.DaemonLogFilePath, Is.EqualTo(Path.Combine(dataDirectory, "daemon.log"))); + Assert.That(state.SyncSessions, Is.Empty); + + // Ensure the daemon is stopped because all sessions are terminated. + await AssertDaemonStopped(dataDirectory, ct); + + var progressMessages = new List(); + + void OnProgress(string message) + { + TestContext.Out.WriteLine("Create session progress: " + message); + progressMessages.Add(message); + } + + var session1 = await controller.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = betaDirectory.FullName, + }, + }, OnProgress, ct); + + // There should've been at least one progress message. + Assert.That(progressMessages, Is.Not.Empty); + + state = controller.GetState(); + Assert.That(state.SyncSessions, Has.Count.EqualTo(1)); + Assert.That(state.SyncSessions[0].Identifier, Is.EqualTo(session1.Identifier)); + + var session2 = await controller.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = betaDirectory.FullName, + }, + }, null, ct); + + state = controller.GetState(); + Assert.That(state.SyncSessions, Has.Count.EqualTo(2)); + Assert.That(state.SyncSessions.Any(s => s.Identifier == session1.Identifier)); + Assert.That(state.SyncSessions.Any(s => s.Identifier == session2.Identifier)); + + // Write a file to alpha. + var alphaFile = Path.Combine(alphaDirectory.FullName, "file.txt"); + var betaFile = Path.Combine(betaDirectory.FullName, "file.txt"); + const string alphaContent = "hello"; + await File.WriteAllTextAsync(alphaFile, alphaContent, ct); + + // Wait for the file to appear in beta. + while (true) + { + ct.ThrowIfCancellationRequested(); + await Task.Delay(100, ct); + if (!File.Exists(betaFile)) + { + TestContext.Out.WriteLine("Waiting for file to appear in beta"); + continue; + } + + var betaContent = await File.ReadAllTextAsync(betaFile, ct); + if (betaContent == alphaContent) break; + TestContext.Out.WriteLine($"Waiting for file contents to match, current: {betaContent}"); + } + + await controller.TerminateSyncSession(session1.Identifier, ct); + await controller.TerminateSyncSession(session2.Identifier, ct); + + // Ensure the daemon is stopped because all sessions are terminated. + await AssertDaemonStopped(dataDirectory, ct); + + state = controller.GetState(); + Assert.That(state.Lifecycle, Is.EqualTo(SyncSessionControllerLifecycle.Stopped)); + Assert.That(state.DaemonError, Is.Null); + Assert.That(state.DaemonLogFilePath, Is.EqualTo(Path.Combine(dataDirectory, "daemon.log"))); + Assert.That(state.SyncSessions, Is.Empty); + } + + [Test(Description = "Shut down daemon when no sessions")] + [CancelAfter(30_000)] + public async Task ShutdownNoSessions(CancellationToken ct) + { + // NUnit runs each test in a temporary directory + var dataDirectory = _tempDirectory.FullName; + await using var controller = new MutagenController(_mutagenBinaryPath, dataDirectory); + await controller.RefreshState(ct); + + // log file tells us the daemon was started. + var logPath = Path.Combine(dataDirectory, "daemon.log"); + Assert.That(File.Exists(logPath)); + + // Ensure the daemon is stopped. + await AssertDaemonStopped(dataDirectory, ct); + } + + [Test(Description = "Daemon is restarted when we create a session")] + [CancelAfter(30_000)] + public async Task CreateRestartsDaemon(CancellationToken ct) + { + // NUnit runs each test in a temporary directory + var dataDirectory = _tempDirectory.CreateSubdirectory("mutagen").FullName; + var alphaDirectory = _tempDirectory.CreateSubdirectory("alpha"); + var betaDirectory = _tempDirectory.CreateSubdirectory("beta"); + + await using (var controller = new MutagenController(_mutagenBinaryPath, dataDirectory)) + { + await controller.RefreshState(ct); + await controller.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = betaDirectory.FullName, + }, + }, null, ct); + } + + await AssertDaemonStopped(dataDirectory, ct); + var logPath = Path.Combine(dataDirectory, "daemon.log"); + Assert.That(File.Exists(logPath)); + var logLines = await File.ReadAllLinesAsync(logPath, ct); + + // Here we're going to use the log to verify the daemon was started 2 times. + // slightly brittle, but unlikely this log line will change. + Assert.That(logLines.Count(s => s.Contains("[sync] Session manager initialized")), Is.EqualTo(2)); + } + + [Test(Description = "Controller kills orphaned daemon")] + [CancelAfter(30_000)] + public async Task Orphaned(CancellationToken ct) + { + // NUnit runs each test in a temporary directory + var dataDirectory = _tempDirectory.CreateSubdirectory("mutagen").FullName; + var alphaDirectory = _tempDirectory.CreateSubdirectory("alpha"); + var betaDirectory = _tempDirectory.CreateSubdirectory("beta"); + + MutagenController? controller1 = null; + MutagenController? controller2 = null; + try + { + controller1 = new MutagenController(_mutagenBinaryPath, dataDirectory); + await controller1.RefreshState(ct); + await controller1.CreateSyncSession(new CreateSyncSessionRequest + { + Alpha = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = alphaDirectory.FullName, + }, + Beta = new CreateSyncSessionRequest.Endpoint + { + Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Local, + Path = betaDirectory.FullName, + }, + }, null, ct); + + controller2 = new MutagenController(_mutagenBinaryPath, dataDirectory); + await controller2.RefreshState(ct); + } + finally + { + if (controller1 != null) await controller1.DisposeAsync(); + if (controller2 != null) await controller2.DisposeAsync(); + } + + await AssertDaemonStopped(dataDirectory, ct); + + var logPath = Path.Combine(dataDirectory, "daemon.log"); + Assert.That(File.Exists(logPath)); + var logLines = await File.ReadAllLinesAsync(logPath, ct); + + // Here we're going to use the log to verify the daemon was started 3 times. + // slightly brittle, but unlikely this log line will change. + Assert.That(logLines.Count(s => s.Contains("[sync] Session manager initialized")), Is.EqualTo(3)); + } +} diff --git a/Tests.App/Services/RdpConnectorTest.cs b/Tests.App/Services/RdpConnectorTest.cs new file mode 100644 index 0000000..b4a870e --- /dev/null +++ b/Tests.App/Services/RdpConnectorTest.cs @@ -0,0 +1,27 @@ +using Coder.Desktop.App.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class RdpConnectorTest +{ + [Test(Description = "Spawns RDP for real")] + [Ignore("Comment out to run manually")] + [CancelAfter(30_000)] + public async Task ConnectToRdp(CancellationToken ct) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(); + var services = builder.Services.BuildServiceProvider(); + + var rdpConnector = (RdpConnector)services.GetService()!; + var creds = new RdpCredentials("Administrator", "coderRDP!"); + var workspace = "myworkspace.coder"; + rdpConnector.WriteCredentials(workspace, creds); + await rdpConnector.Connect(workspace, ct: ct); + } +} diff --git a/Tests.App/Services/UriHandlerTest.cs b/Tests.App/Services/UriHandlerTest.cs new file mode 100644 index 0000000..65c886c --- /dev/null +++ b/Tests.App/Services/UriHandlerTest.cs @@ -0,0 +1,178 @@ +using Coder.Desktop.App.Models; +using Coder.Desktop.App.Services; +using Coder.Desktop.Vpn.Proto; +using Google.Protobuf; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; +using Serilog; + +namespace Coder.Desktop.Tests.App.Services; + +[TestFixture] +public class UriHandlerTest +{ + [SetUp] + public void SetupMocksAndUriHandler() + { + Serilog.Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.NUnitOutput().CreateLogger(); + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + var logger = (ILogger)builder.Build().Services.GetService(typeof(ILogger))!; + + _mUserNotifier = new Mock(MockBehavior.Strict); + _mRdpConnector = new Mock(MockBehavior.Strict); + _mRpcController = new Mock(MockBehavior.Strict); + + uriHandler = new UriHandler(logger, _mRpcController.Object, _mUserNotifier.Object, _mRdpConnector.Object); + } + + private Mock _mUserNotifier; + private Mock _mRdpConnector; + private Mock _mRpcController; + private UriHandler uriHandler; // Unit under test. + + [SetUp] + public void AgentAndWorkspaceFixtures() + { + agent11 = new Agent(); + agent11.Fqdn.Add("workspace1.coder"); + agent11.Id = ByteString.CopyFrom(0x1, 0x1); + agent11.WorkspaceId = ByteString.CopyFrom(0x1, 0x0); + agent11.Name = "agent11"; + + workspace1 = new Workspace + { + Id = ByteString.CopyFrom(0x1, 0x0), + Name = "workspace1", + Status = Workspace.Types.Status.Running, + }; + + modelWithWorkspace1 = new RpcModel + { + VpnLifecycle = VpnLifecycle.Started, + Workspaces = [workspace1], + Agents = [agent11], + }; + } + + private Agent agent11; + private Workspace workspace1; + private RpcModel modelWithWorkspace1; + + [Test(Description = "Open RDP with username & password")] + [CancelAfter(30_000)] + public async Task Mainline(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?username=testy&password=sesame"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + var expectedCred = new RdpCredentials("testy", "sesame"); + _ = _mRdpConnector.Setup(m => m.WriteCredentials(agent11.Fqdn[0], expectedCred)); + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Open RDP with no credentials")] + [CancelAfter(30_000)] + public async Task NoCredentials(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown app slug")] + [CancelAfter(30_000)] + public async Task UnknownApp(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/someapp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("someapp"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown agent name")] + [CancelAfter(30_000)] + public async Task UnknownAgent(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/workspace1/agent/wrongagent/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("wrongagent"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown workspace name")] + [CancelAfter(30_000)] + public async Task UnknownWorkspace(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("wrongworkspace"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Malformed Query String")] + [CancelAfter(30_000)] + public async Task MalformedQuery(CancellationToken ct) + { + // there might be some query string that gets the parser to throw an exception, but I could not find one. + var input = new Uri("coder:/v0/open/ws/workspace1/agent/agent11/rdp?%&##"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + // treated the same as if we just didn't include credentials + _ = _mRdpConnector.Setup(m => m.Connect(agent11.Fqdn[0], IRdpConnector.DefaultPort, ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "VPN not started")] + [CancelAfter(30_000)] + public async Task VPNNotStarted(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(new RpcModel + { + VpnLifecycle = VpnLifecycle.Starting, + }); + // Coder Connect is the user facing name, so make sure the error mentions it. + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsRegex("Coder Connect"), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Wrong number of components")] + [CancelAfter(30_000)] + public async Task UnknownNumComponents(CancellationToken ct) + { + var input = new Uri("coder:/v0/open/ws/wrongworkspace/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsAny(), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } + + [Test(Description = "Unknown prefix")] + [CancelAfter(30_000)] + public async Task UnknownPrefix(CancellationToken ct) + { + var input = new Uri("coder:/v300/open/ws/workspace1/agent/agent11/rdp"); + + _mRpcController.Setup(m => m.GetState()).Returns(modelWithWorkspace1); + _mUserNotifier.Setup(m => m.ShowErrorNotification(It.IsAny(), It.IsAny(), ct)) + .Returns(Task.CompletedTask); + await uriHandler.HandleUri(input, ct); + } +} diff --git a/Tests.App/Tests.App.csproj b/Tests.App/Tests.App.csproj new file mode 100644 index 0000000..e20eba1 --- /dev/null +++ b/Tests.App/Tests.App.csproj @@ -0,0 +1,40 @@ + + + + Coder.Desktop.Tests.App + Coder.Desktop.Tests.App + net8.0-windows10.0.19041.0 + preview + enable + enable + true + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/Tests.Vpn.Service/DownloaderTest.cs b/Tests.Vpn.Service/DownloaderTest.cs index ae3a0a0..986ce46 100644 --- a/Tests.Vpn.Service/DownloaderTest.cs +++ b/Tests.Vpn.Service/DownloaderTest.cs @@ -1,4 +1,6 @@ +using System.Reflection; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using Coder.Desktop.Vpn.Service; using Microsoft.Extensions.Logging.Abstractions; @@ -27,40 +29,102 @@ public class AuthenticodeDownloadValidatorTest [CancelAfter(30_000)] public void Unsigned(CancellationToken ct) { - // TODO: this + var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe"); + var ex = Assert.ThrowsAsync(() => + AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct)); + Assert.That(ex.Message, + Does.Contain( + "File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=None")); } [Test(Description = "Test an untrusted binary")] [CancelAfter(30_000)] public void Untrusted(CancellationToken ct) { - // TODO: this + var testBinaryPath = + Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-self-signed.exe"); + var ex = Assert.ThrowsAsync(() => + AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct)); + Assert.That(ex.Message, + Does.Contain( + "File is not signed and trusted with an Authenticode signature: State=Unsigned, StateReason=UntrustedRoot")); } [Test(Description = "Test an binary with a detached signature (catalog file)")] [CancelAfter(30_000)] public void DifferentCertTrusted(CancellationToken ct) { - // notepad.exe uses a catalog file for its signature. + // rundll32.exe uses a catalog file for its signature. var ex = Assert.ThrowsAsync(() => - AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\notepad.exe", ct)); + AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Windows\System32\rundll32.exe", ct)); Assert.That(ex.Message, Does.Contain("File is not signed with an embedded Authenticode signature: Kind=Catalog")); } - [Test(Description = "Test a binary signed by a different certificate")] + [Test(Description = "Test a binary signed by a non-EV certificate")] + [CancelAfter(30_000)] + public void NonEvCert(CancellationToken ct) + { + // dotnet.exe is signed by .NET. During tests we can be pretty sure + // this is installed. + var ex = Assert.ThrowsAsync(() => + AuthenticodeDownloadValidator.Coder.ValidateAsync(@"C:\Program Files\dotnet\dotnet.exe", ct)); + Assert.That(ex.Message, + Does.Contain( + "File is not signed with an Extended Validation Code Signing certificate")); + } + + [Test(Description = "Test a binary signed by an EV certificate with a different name")] [CancelAfter(30_000)] - public void DifferentCertUntrusted(CancellationToken ct) + public void EvDifferentCertName(CancellationToken ct) { - // TODO: this + var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", + "hello-versioned-signed.exe"); + var ex = Assert.ThrowsAsync(() => + new AuthenticodeDownloadValidator("Acme Corporation").ValidateAsync(testBinaryPath, ct)); + Assert.That(ex.Message, + Does.Contain( + "File is signed by an unexpected certificate: ExpectedName='Acme Corporation', ActualName='Coder Technologies Inc.'")); } [Test(Description = "Test a binary signed by Coder's certificate")] [CancelAfter(30_000)] public async Task CoderSigned(CancellationToken ct) { - // TODO: this - await Task.CompletedTask; + var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", + "hello-versioned-signed.exe"); + await AuthenticodeDownloadValidator.Coder.ValidateAsync(testBinaryPath, ct); + } + + [Test(Description = "Test if the EV check works")] + public void IsEvCert() + { + // To avoid potential API misuse the function is private. + var method = typeof(AuthenticodeDownloadValidator).GetMethod("IsExtendedValidationCertificate", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(method, Is.Not.Null, "Could not find IsExtendedValidationCertificate method"); + + // Call it with various certificates. + var certs = new List<(string, bool)> + { + // EV: + (Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "coder-ev.crt"), true), + (Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "google-llc-ev.crt"), true), + (Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "self-signed-ev.crt"), true), + // Not EV: + (Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "mozilla-corporation.crt"), false), + (Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "self-signed.crt"), false), + }; + + foreach (var (certPath, isEv) in certs) + { + var x509Cert = new X509Certificate2(certPath); + var result = (bool?)method!.Invoke(null, [x509Cert]); + Assert.That(result, Is.Not.Null, + $"IsExtendedValidationCertificate returned null for {Path.GetFileName(certPath)}"); + Assert.That(result, Is.EqualTo(isEv), + $"IsExtendedValidationCertificate returned wrong result for {Path.GetFileName(certPath)}"); + } } } @@ -71,22 +135,60 @@ public class AssemblyVersionDownloadValidatorTest [CancelAfter(30_000)] public void NoVersion(CancellationToken ct) { - // TODO: this + var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello.exe"); + var ex = Assert.ThrowsAsync(() => + new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct)); + Assert.That(ex.Message, Does.Contain("File ProductVersion is empty or null")); } - [Test(Description = "Version mismatch")] + [Test(Description = "Invalid version on binary")] [CancelAfter(30_000)] - public void VersionMismatch(CancellationToken ct) + public void InvalidVersion(CancellationToken ct) { - // TODO: this + var testBinaryPath = + Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", "hello-invalid-version.exe"); + var ex = Assert.ThrowsAsync(() => + new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct)); + Assert.That(ex.Message, Does.Contain("File ProductVersion '1-2-3-4' is not a valid version string")); + } + + [Test(Description = "Version mismatch with full version check")] + [CancelAfter(30_000)] + public void VersionMismatchFull(CancellationToken ct) + { + var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", + "hello-versioned-signed.exe"); + + // Try changing each version component one at a time + var expectedVersions = new[] { 1, 2, 3, 4 }; + for (var i = 0; i < 4; i++) + { + var testVersions = (int[])expectedVersions.Clone(); + testVersions[i]++; // Increment this component to make it wrong + + var ex = Assert.ThrowsAsync(() => + new AssemblyVersionDownloadValidator( + testVersions[0], testVersions[1], testVersions[2], testVersions[3] + ).ValidateAsync(testBinaryPath, ct)); + + Assert.That(ex.Message, Does.Contain( + $"File ProductVersion does not match expected version: Actual='1.2.3.4', Expected='{string.Join(".", testVersions)}'")); + } } - [Test(Description = "Version match")] + [Test(Description = "Version match with and without partial version check")] [CancelAfter(30_000)] public async Task VersionMatch(CancellationToken ct) { - // TODO: this - await Task.CompletedTask; + var testBinaryPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "testdata", + "hello-versioned-signed.exe"); + + // Test with just major.minor + await new AssemblyVersionDownloadValidator(1, 2).ValidateAsync(testBinaryPath, ct); + // Test with major.minor.patch + await new AssemblyVersionDownloadValidator(1, 2, 3).ValidateAsync(testBinaryPath, ct); + // Test with major.minor.patch.build + await new AssemblyVersionDownloadValidator(1, 2, 3, 4).ValidateAsync(testBinaryPath, ct); } } @@ -182,6 +284,34 @@ public async Task Download(CancellationToken ct) Assert.That(await File.ReadAllTextAsync(destPath, ct), Is.EqualTo("test")); } + [Test(Description = "Perform 2 downloads with the same destination")] + [CancelAfter(30_000)] + public async Task DownloadSameDest(CancellationToken ct) + { + using var httpServer = EchoServer(); + var url0 = new Uri(httpServer.BaseUrl + "/test0"); + var url1 = new Uri(httpServer.BaseUrl + "/test1"); + var destPath = Path.Combine(_tempDir, "test"); + + var manager = new Downloader(NullLogger.Instance); + var startTask0 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url0), destPath, + NullDownloadValidator.Instance, ct); + var startTask1 = manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url1), destPath, + NullDownloadValidator.Instance, ct); + var dlTask0 = await startTask0; + await dlTask0.Task; + Assert.That(dlTask0.TotalBytes, Is.EqualTo(5)); + Assert.That(dlTask0.BytesRead, Is.EqualTo(5)); + Assert.That(dlTask0.Progress, Is.EqualTo(1)); + Assert.That(dlTask0.IsCompleted, Is.True); + var dlTask1 = await startTask1; + await dlTask1.Task; + Assert.That(dlTask1.TotalBytes, Is.EqualTo(5)); + Assert.That(dlTask1.BytesRead, Is.EqualTo(5)); + Assert.That(dlTask1.Progress, Is.EqualTo(1)); + Assert.That(dlTask1.IsCompleted, Is.True); + } + [Test(Description = "Download with custom headers")] [CancelAfter(30_000)] public async Task WithHeaders(CancellationToken ct) @@ -245,17 +375,17 @@ public async Task DownloadExistingDifferentContent(CancellationToken ct) [Test(Description = "Unexpected response code from server")] [CancelAfter(30_000)] - public void UnexpectedResponseCode(CancellationToken ct) + public async Task UnexpectedResponseCode(CancellationToken ct) { using var httpServer = new TestHttpServer(ctx => { ctx.Response.StatusCode = 404; }); var url = new Uri(httpServer.BaseUrl + "/test"); var destPath = Path.Combine(_tempDir, "test"); var manager = new Downloader(NullLogger.Instance); - // The "outer" Task should fail. - var ex = Assert.ThrowsAsync(async () => - await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - NullDownloadValidator.Instance, ct)); + // The "inner" Task should fail. + var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, + NullDownloadValidator.Instance, ct); + var ex = Assert.ThrowsAsync(async () => await dlTask.Task); Assert.That(ex.Message, Does.Contain("404")); } @@ -282,43 +412,58 @@ public async Task MismatchedETag(CancellationToken ct) Assert.That(ex.Message, Does.Contain("ETag does not match SHA1 hash of downloaded file").And.Contains("beef")); } - [Test(Description = "Timeout on response headers")] + [Test(Description = "Timeout waiting for existing download")] [CancelAfter(30_000)] - public void CancelledOuter(CancellationToken ct) + public async Task CancelledWaitingForOther(CancellationToken ct) { - using var httpServer = new TestHttpServer(async _ => { await Task.Delay(TimeSpan.FromSeconds(5), ct); }); - var url = new Uri(httpServer.BaseUrl + "/test"); + var testCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + using var httpServer = new TestHttpServer(async _ => + { + await Task.Delay(TimeSpan.FromSeconds(5), testCts.Token); + }); + var url0 = new Uri(httpServer.BaseUrl + "/test0"); + var url1 = new Uri(httpServer.BaseUrl + "/test1"); var destPath = Path.Combine(_tempDir, "test"); - var manager = new Downloader(NullLogger.Instance); - // The "outer" Task should fail. + + // first outer task succeeds, getting download started + var dlTask0 = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url0), destPath, + NullDownloadValidator.Instance, testCts.Token); + + // The second request fails if the timeout is short var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token; - Assert.ThrowsAsync( - async () => await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - NullDownloadValidator.Instance, smallerCt)); + Assert.ThrowsAsync(async () => await manager.StartDownloadAsync( + new HttpRequestMessage(HttpMethod.Get, url1), destPath, + NullDownloadValidator.Instance, smallerCt)); + await testCts.CancelAsync(); } [Test(Description = "Timeout on response body")] [CancelAfter(30_000)] public async Task CancelledInner(CancellationToken ct) { + var httpCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var taskCts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var httpServer = new TestHttpServer(async ctx => { ctx.Response.StatusCode = 200; - await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), ct); - await ctx.Response.OutputStream.FlushAsync(ct); - await Task.Delay(TimeSpan.FromSeconds(5), ct); + await ctx.Response.OutputStream.WriteAsync("test"u8.ToArray(), httpCts.Token); + await ctx.Response.OutputStream.FlushAsync(httpCts.Token); + // wait up to 5 seconds. + await Task.Delay(TimeSpan.FromSeconds(5), httpCts.Token); }); var url = new Uri(httpServer.BaseUrl + "/test"); var destPath = Path.Combine(_tempDir, "test"); var manager = new Downloader(NullLogger.Instance); // The "inner" Task should fail. - var smallerCt = new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token; + var taskCt = taskCts.Token; var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - NullDownloadValidator.Instance, smallerCt); + NullDownloadValidator.Instance, taskCt); + await taskCts.CancelAsync(); var ex = Assert.ThrowsAsync(async () => await dlTask.Task); - Assert.That(ex.CancellationToken, Is.EqualTo(smallerCt)); + Assert.That(ex.CancellationToken, Is.EqualTo(taskCt)); + await httpCts.CancelAsync(); } [Test(Description = "Validation failure")] @@ -349,12 +494,10 @@ public async Task ValidationFailureExistingFile(CancellationToken ct) await File.WriteAllTextAsync(destPath, "test", ct); var manager = new Downloader(NullLogger.Instance); - // The "outer" Task should fail because the inner task never starts. - var ex = Assert.ThrowsAsync(async () => - { - await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, - new TestDownloadValidator(new Exception("test exception")), ct); - }); + var dlTask = await manager.StartDownloadAsync(new HttpRequestMessage(HttpMethod.Get, url), destPath, + new TestDownloadValidator(new Exception("test exception")), ct); + // The "inner" Task should fail. + var ex = Assert.ThrowsAsync(async () => { await dlTask.Task; }); Assert.That(ex.Message, Does.Contain("Existing file failed validation")); Assert.That(ex.InnerException, Is.Not.Null); Assert.That(ex.InnerException!.Message, Is.EqualTo("test exception")); diff --git a/Tests.Vpn.Service/TelemetryEnricherTest.cs b/Tests.Vpn.Service/TelemetryEnricherTest.cs new file mode 100644 index 0000000..144cd20 --- /dev/null +++ b/Tests.Vpn.Service/TelemetryEnricherTest.cs @@ -0,0 +1,34 @@ +using Coder.Desktop.Vpn.Proto; +using Coder.Desktop.Vpn.Service; + +namespace Coder.Desktop.Tests.Vpn.Service; + +[TestFixture] +public class TelemetryEnricherTest +{ + [Test] + public void EnrichStartRequest() + { + var req = new StartRequest + { + CoderUrl = "https://coder.example.com", + }; + var enricher = new TelemetryEnricher(); + req = enricher.EnrichStartRequest(req); + + // quick sanity check that non-telemetry fields aren't lost or overwritten + Assert.That(req.CoderUrl, Is.EqualTo("https://coder.example.com")); + + Assert.That(req.DeviceOs, Is.EqualTo("Windows")); + // seems that test assemblies always set 1.0.0.0 + Assert.That(req.CoderDesktopVersion, Is.EqualTo("1.0.0.0")); + Assert.That(req.DeviceId, Is.Not.Empty); + var deviceId = req.DeviceId; + + // deviceId is different on different machines, but we can test that + // each instance of the TelemetryEnricher produces the same value. + enricher = new TelemetryEnricher(); + req = enricher.EnrichStartRequest(new StartRequest()); + Assert.That(req.DeviceId, Is.EqualTo(deviceId)); + } +} diff --git a/Tests.Vpn.Service/Tests.Vpn.Service.csproj b/Tests.Vpn.Service/Tests.Vpn.Service.csproj index cc66dd1..c57f85a 100644 --- a/Tests.Vpn.Service/Tests.Vpn.Service.csproj +++ b/Tests.Vpn.Service/Tests.Vpn.Service.csproj @@ -12,6 +12,36 @@ true + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + all diff --git a/Tests.Vpn.Service/packages.lock.json b/Tests.Vpn.Service/packages.lock.json index 9becace..08a9b56 100644 --- a/Tests.Vpn.Service/packages.lock.json +++ b/Tests.Vpn.Service/packages.lock.json @@ -48,305 +48,314 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "resolved": "9.0.4", + "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "resolved": "9.0.4", + "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", + "resolved": "9.0.4", + "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", + "resolved": "9.0.4", + "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", + "resolved": "9.0.4", + "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", + "resolved": "9.0.4", + "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", + "resolved": "9.0.4", + "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", + "resolved": "9.0.4", + "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", + "resolved": "9.0.4", + "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==", + "dependencies": { + "System.Text.Encodings.Web": "9.0.0", + "System.Text.Json": "9.0.0" + } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", + "resolved": "9.0.4", + "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", + "resolved": "9.0.4", + "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==" + "resolved": "9.0.4", + "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" }, "Microsoft.Extensions.Hosting": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Logging.Console": "9.0.1", - "Microsoft.Extensions.Logging.Debug": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "Microsoft.Extensions.Logging.EventSource": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "resolved": "9.0.4", + "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.Configuration.CommandLine": "9.0.4", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4", + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Logging.Console": "9.0.4", + "Microsoft.Extensions.Logging.Debug": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "Microsoft.Extensions.Logging.EventSource": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", + "resolved": "9.0.4", + "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Hosting.WindowsServices": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "FLapgOXQzPjUsbMqjjagCFCiGjroRmrmHQVK3/PEovRIvDU6nLk7KKs4PalzEHaIfqG+PySlY/BeLTyZtjcshg==", + "resolved": "9.0.4", + "contentHash": "QFeUS0NG4Kwq91Mf1WzVZSbBtw+nKxyOQTi4xTRUEQ2gC7HWiyCUiX0arMJxt9lWwbjXxQY9TQjDptm+ct7BkQ==", "dependencies": { - "Microsoft.Extensions.Hosting": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "System.ServiceProcess.ServiceController": "9.0.1" + "Microsoft.Extensions.Hosting": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "System.ServiceProcess.ServiceController": "9.0.4" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", + "resolved": "9.0.4", + "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", + "resolved": "9.0.4", + "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", + "resolved": "9.0.4", + "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", + "resolved": "9.0.4", + "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", + "resolved": "9.0.4", + "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.EventLog": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.EventLog": "9.0.4" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", + "resolved": "9.0.4", + "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", + "resolved": "9.0.4", + "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.DataAnnotations": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "T16k12gDWOoi9W/oueC7knsZxm3ZjqmrQBFLXx9UH3Kv4fbehMyiOdhi5u1Vw7M4g0uMj21InBfgDE0570byEQ==", + "resolved": "9.0.4", + "contentHash": "jJq7xO1PLi//cts59Yp6dKNN07xV0Day/JmVR7aXCdo2rYHAoFlyARyxrfB0CTzsErA+TOhYTz2Ee0poR8SPeQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Microsoft.Security.Extensions": { "type": "Transitive", @@ -409,6 +418,16 @@ "Serilog": "4.2.0" } }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyModel": "9.0.0", + "Serilog": "4.2.0" + } + }, "Serilog.Sinks.Console": { "type": "Transitive", "resolved": "6.0.0", @@ -427,18 +446,18 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==" + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==" + "resolved": "9.0.4", + "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA==" }, "System.Reflection.Metadata": { "type": "Transitive", @@ -447,24 +466,24 @@ }, "System.ServiceProcess.ServiceController": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Ghm4yP29P3cC65Qof8CrgU3WO/q3ERtht6/CrvcUl1FgRs6D7exj75GuG4ciRv0sjygtvyd675924DFsxxnEgA==", + "resolved": "9.0.4", + "contentHash": "j6Z+ED1d/yxe/Cm+UlFf+LNw2HSYBSgtFh71KnEEmUtHIwgoTVQxji5URvXPOAZ7iuKHItjMIzpCLyRZc8OmrQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.1" + "System.Diagnostics.EventLog": "9.0.4" } }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" }, "System.Text.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", + "resolved": "9.0.4", + "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==", "dependencies": { - "System.IO.Pipelines": "9.0.1", - "System.Text.Encodings.Web": "9.0.1" + "System.IO.Pipelines": "9.0.4", + "System.Text.Encodings.Web": "9.0.4" } }, "Coder.Desktop.CoderSdk": { @@ -474,6 +493,8 @@ "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, )" } }, @@ -488,12 +509,13 @@ "dependencies": { "Coder.Desktop.CoderSdk": "[1.0.0, )", "Coder.Desktop.Vpn": "[1.0.0, )", - "Microsoft.Extensions.Hosting": "[9.0.1, )", - "Microsoft.Extensions.Hosting.WindowsServices": "[9.0.1, )", - "Microsoft.Extensions.Options.DataAnnotations": "[9.0.1, )", + "Microsoft.Extensions.Hosting": "[9.0.4, )", + "Microsoft.Extensions.Hosting.WindowsServices": "[9.0.4, )", + "Microsoft.Extensions.Options.DataAnnotations": "[9.0.4, )", "Microsoft.Security.Extensions": "[1.3.0, )", "Semver": "[3.0.0, )", "Serilog.Extensions.Hosting": "[9.0.0, )", + "Serilog.Settings.Configuration": "[9.0.0, )", "Serilog.Sinks.Console": "[6.0.0, )", "Serilog.Sinks.File": "[6.0.0, )" } diff --git a/Tests.Vpn.Service/testdata/.gitignore b/Tests.Vpn.Service/testdata/.gitignore new file mode 100644 index 0000000..1ce9d77 --- /dev/null +++ b/Tests.Vpn.Service/testdata/.gitignore @@ -0,0 +1,2 @@ +*.go +*.pfx diff --git a/Tests.Vpn.Service/testdata/Build-Assets.ps1 b/Tests.Vpn.Service/testdata/Build-Assets.ps1 new file mode 100644 index 0000000..bfbd9fa --- /dev/null +++ b/Tests.Vpn.Service/testdata/Build-Assets.ps1 @@ -0,0 +1,68 @@ +$errorActionPreference = "Stop" + +Set-Location $PSScriptRoot + +# If hello.go does not exist, write it. We don't check it into the repo to avoid +# GitHub showing that the repo contains Go code. +if (-not (Test-Path "hello.go")) { + $helloGo = @" +package main + +func main() { + println("Hello, World!") +} +"@ + Set-Content -Path "hello.go" -Value $helloGo +} + +& go.exe build -ldflags '-w -s' -o hello.exe hello.go +if ($LASTEXITCODE -ne 0) { throw "Failed to build hello.exe" } + +# hello-invalid-version.exe is used for testing versioned binaries with an +# invalid version. +Copy-Item hello.exe hello-invalid-version.exe +& go-winres.exe patch --in winres.json --delete --no-backup --product-version 1-2-3-4 --file-version 1-2-3-4 hello-invalid-version.exe +if ($LASTEXITCODE -ne 0) { throw "Failed to patch hello-invalid-version.exe with go-winres" } + +# hello-self-signed.exe is used for testing untrusted binaries. +Copy-Item hello.exe hello-self-signed.exe +$helloSelfSignedPath = (Get-Item hello-self-signed.exe).FullName + +# Create a self signed certificate for signing and then delete it. +$certStoreLocation = "Cert:\CurrentUser\My" +$password = "password" +$cert = New-SelfSignedCertificate ` + -CertStoreLocation $certStoreLocation ` + -DnsName coder.com ` + -Subject "CN=coder-desktop-windows-self-signed-cert" ` + -Type CodeSigningCert ` + -KeyUsage DigitalSignature ` + -NotAfter (Get-Date).AddDays(3650) +$pfxPath = Join-Path $PSScriptRoot "cert.pfx" +try { + $securePassword = ConvertTo-SecureString -String $password -Force -AsPlainText + Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $securePassword + + # Sign hello-self-signed.exe with the self signed certificate + & "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe" sign /debug /f $pfxPath /p $password /tr "http://timestamp.digicert.com" /td sha256 /fd sha256 $helloSelfSignedPath + if ($LASTEXITCODE -ne 0) { throw "Failed to sign hello-self-signed.exe with signtool" } +} finally { + if ($cert.Thumbprint) { + Remove-Item -Path (Join-Path $certStoreLocation $cert.Thumbprint) -Force + } + if (Test-Path $pfxPath) { + Remove-Item -Path $pfxPath -Force + } +} + +# hello-versioned-signed.exe is used for testing versioned binaries and +# binaries signed by a real EV certificate. +Copy-Item hello.exe hello-versioned-signed.exe + +& go-winres.exe patch --in winres.json --delete --no-backup --product-version 1.2.3.4 --file-version 1.2.3.4 hello-versioned-signed.exe +if ($LASTEXITCODE -ne 0) { throw "Failed to patch hello-versioned-signed.exe with go-winres" } + +# Then sign hello-versioned-signed.exe with the same EV cert as our real +# binaries. Since this is a bit more complicated and requires some extra +# permissions, we don't do this in the build script. +Write-Host "Don't forget to sign hello-versioned-signed.exe with the EV cert!" diff --git a/Tests.Vpn.Service/testdata/README.md b/Tests.Vpn.Service/testdata/README.md new file mode 100644 index 0000000..6e81b37 --- /dev/null +++ b/Tests.Vpn.Service/testdata/README.md @@ -0,0 +1,29 @@ +# Tests.Vpn.Service testdata + +### Executables + +`Build-Assets.ps1` creates `hello.exe` and derivatives. You need `go`, +`go-winres` and Windows 10 SDK 10.0.19041.0 installed to run this. + +You must sign `hello-versioned-signed.exe` yourself with the Coder EV cert after +the script completes. + +These files are checked into the repo so they shouldn't need to be built again. + +### Certificates + +- `coder-ev.crt` is the Extended Validation Code Signing certificate used by + Coder, extracted from a signed release binary on 2025-03-07 +- `google-llc-ev.crt` is the Extended Validation Code Signing certificate used + by Google Chrome, extracted from an official binary on 2025-03-07 +- `mozilla-corporation.crt` is a regular Code Signing certificate used by + Mozilla Firefox, extracted from an official binary on 2025-03-07 +- `self-signed-ev.crt` was generated with `gen-certs.sh` using Linux OpenSSL +- `self-signed.crt` was generated with `gen-certs.sh` using Linux OpenSSL + +You can extract a certificate from an executable with the following PowerShell +one-liner: + +```powershell +Get-AuthenticodeSignature binary.exe | Select-Object -ExpandProperty SignerCertificate | Export-Certificate -Type CERT -FilePath output.crt +``` diff --git a/Tests.Vpn.Service/testdata/coder-ev.crt b/Tests.Vpn.Service/testdata/coder-ev.crt new file mode 100644 index 0000000..223fbf2 Binary files /dev/null and b/Tests.Vpn.Service/testdata/coder-ev.crt differ diff --git a/Tests.Vpn.Service/testdata/gen-certs.sh b/Tests.Vpn.Service/testdata/gen-certs.sh new file mode 100644 index 0000000..c1b9ab0 --- /dev/null +++ b/Tests.Vpn.Service/testdata/gen-certs.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +# Generate a regular code signing certificate without the EV policy OID. +openssl req \ + -x509 \ + -newkey rsa:2048 \ + -keyout /dev/null \ + -out self-signed.crt \ + -days 3650 \ + -nodes \ + -subj "/CN=Coder Self Signed" \ + -addext "keyUsage=digitalSignature" \ + -addext "extendedKeyUsage=codeSigning" + +# Generate an EV code signing certificate by adding the EV policy OID. We add +# a different OID before the EV OID to ensure the validator can handle multiple +# policies. +config=" +[req] +distinguished_name = req_distinguished_name +x509_extensions = v3_req +prompt = no + +[req_distinguished_name] +CN = Coder Self Signed EV + +[v3_req] +keyUsage = digitalSignature +extendedKeyUsage = codeSigning +certificatePolicies = @pol1,@pol2 + +[pol1] +policyIdentifier = 2.23.140.1.4.1 +CPS.1="https://coder.com" + +[pol2] +policyIdentifier = 2.23.140.1.3 +CPS.1="https://coder.com" +" + +openssl req \ + -x509 \ + -newkey rsa:2048 \ + -keyout /dev/null \ + -out self-signed-ev.crt \ + -days 3650 \ + -nodes \ + -config <(echo "$config") diff --git a/Tests.Vpn.Service/testdata/google-llc-ev.crt b/Tests.Vpn.Service/testdata/google-llc-ev.crt new file mode 100644 index 0000000..d214f20 Binary files /dev/null and b/Tests.Vpn.Service/testdata/google-llc-ev.crt differ diff --git a/Tests.Vpn.Service/testdata/hello-invalid-version.exe b/Tests.Vpn.Service/testdata/hello-invalid-version.exe new file mode 100644 index 0000000..3ef2656 Binary files /dev/null and b/Tests.Vpn.Service/testdata/hello-invalid-version.exe differ diff --git a/Tests.Vpn.Service/testdata/hello-self-signed.exe b/Tests.Vpn.Service/testdata/hello-self-signed.exe new file mode 100644 index 0000000..124fb08 Binary files /dev/null and b/Tests.Vpn.Service/testdata/hello-self-signed.exe differ diff --git a/Tests.Vpn.Service/testdata/hello-versioned-signed.exe b/Tests.Vpn.Service/testdata/hello-versioned-signed.exe new file mode 100644 index 0000000..ce8f1d1 Binary files /dev/null and b/Tests.Vpn.Service/testdata/hello-versioned-signed.exe differ diff --git a/Tests.Vpn.Service/testdata/hello.exe b/Tests.Vpn.Service/testdata/hello.exe new file mode 100644 index 0000000..4759d72 Binary files /dev/null and b/Tests.Vpn.Service/testdata/hello.exe differ diff --git a/Tests.Vpn.Service/testdata/mozilla-corporation.crt b/Tests.Vpn.Service/testdata/mozilla-corporation.crt new file mode 100644 index 0000000..6eb3454 Binary files /dev/null and b/Tests.Vpn.Service/testdata/mozilla-corporation.crt differ diff --git a/Tests.Vpn.Service/testdata/self-signed-ev.crt b/Tests.Vpn.Service/testdata/self-signed-ev.crt new file mode 100644 index 0000000..11dfd79 --- /dev/null +++ b/Tests.Vpn.Service/testdata/self-signed-ev.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDcTCCAlmgAwIBAgIUEzhP7oxDynN4aXNmRqGZzPUCkxUwDQYJKoZIhvcNAQEL +BQAwHzEdMBsGA1UEAwwUQ29kZXIgU2VsZiBTaWduZWQgRVYwHhcNMjUwMzA3MDQ0 +NzQ2WhcNMzUwMzA1MDQ0NzQ2WjAfMR0wGwYDVQQDDBRDb2RlciBTZWxmIFNpZ25l +ZCBFVjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALzzLlfpFYwEfQb6 +eizMaUr9/Op2ELLBRjabDT17uXBTPFHVtHewIaZfYPv7aY3B3rQTzHfE0y9YYO0+ +1Zhd2WpNKYO3iQM9OOcd69XYuDeRh09m6vOwcK7gSkSr55D/dUe4+vnjQBG9O6Na +fby/kcJuDVNnR9rTPJpXqfnlgjrO2WNbBn3K0xJcNjVpMqFm2Iw9eYCRTVIUp559 ++iUGnwM+NT0cGAMB8242Jyz6xgEaRnSmddmxLDkfWWfivamSpWaaopR2T5+6txFW +C1vBeZ4Au+9FEne64NVefVDjdeIDU7pgwYxykPDf+Sc704L8Da5X0gEoa82pHOfw +1DNP94kCAwEAAaOBpDCBoTALBgNVHQ8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUH +AwMwXgYDVR0gBFcwVTApBgZngQwBBAEwHzAdBggrBgEFBQcCARYRaHR0cHM6Ly9j +b2Rlci5jb20wKAYFZ4EMAQMwHzAdBggrBgEFBQcCARYRaHR0cHM6Ly9jb2Rlci5j +b20wHQYDVR0OBBYEFL9MxyFpCw/CmWioihuEP6x8XW0zMA0GCSqGSIb3DQEBCwUA +A4IBAQC19lsc4EEOD4H3VNtQpLaBPLmNU4dD4bpWpoqv2YIYFl0T2cSkZZ1ZmSKR +PV+1D3w7HsdmOf+1wXQv8w4POy3Z/7m6pcy/Efw9ImYs5zwRr5AniFJxjRBkUYB2 +i2m3650v5OAab4qay0FWCY4/8MX866fiLrO0oyjFI6tU/Py8kWV7IgOa9RxJpNou +oITfLXLZRgXULiaXaQRA4TdD5zI9Qe/wwvj6wJH3u8qpRq+m+vo0cxfQ47tisL11 +nMM59fUZrypxdOTRK0QiGz5rJlLmZXZO27RNT3ewpJsq4qjQ3CtJ946vdjDc8+kY +ChQ9e6sS5mLBP4JXtuyG+P1Fdp5t +-----END CERTIFICATE----- diff --git a/Tests.Vpn.Service/testdata/self-signed.crt b/Tests.Vpn.Service/testdata/self-signed.crt new file mode 100644 index 0000000..0eca8eb --- /dev/null +++ b/Tests.Vpn.Service/testdata/self-signed.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOzCCAiOgAwIBAgIUJAIgmuCtIc9yUfpTo3h0Srn3C88wDQYJKoZIhvcNAQEL +BQAwHDEaMBgGA1UEAwwRQ29kZXIgU2VsZiBTaWduZWQwHhcNMjUwMzA3MDQ0NzQ2 +WhcNMzUwMzA1MDQ0NzQ2WjAcMRowGAYDVQQDDBFDb2RlciBTZWxmIFNpZ25lZDCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK6uYLDXy/C7TC4XaUbEmBiK +ICUAz/XDFB19aJrvG6LQYmdbR8sCV4uvoZAXYfribD+AGrysEDnpny1BzNORARLd +szBy0m1fOGbWqaBXg7Ot37rHWkU2iT2NEminHinx9UoJZLWxXsT1h1pnyJO4uzBW +jroRgYbOzkaccoDSWebDjnzAy4LA+Sfdzqm4RvJFD+5dhg/EXyJyLQApN22NWOTP +/8UXO0guUwSC+TGNFGRE6DkN96uX851HaCgrflz9zdLN5FSrSqvTsSStMTF9tHU3 +RlZFjTL+pVD6XSRMLn78xbch87sD3egaxaTvKd9Crx88GMwAnmrp8HqFAdUm4WUC +AwEAAaN1MHMwHQYDVR0OBBYEFDJM3fCTrkRAMNKQhoH3mWyLXY0JMB8GA1UdIwQY +MBaAFDJM3fCTrkRAMNKQhoH3mWyLXY0JMA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0P +BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMA0GCSqGSIb3DQEBCwUAA4IBAQCU +xdNbMynw3YexqDjzHaKFGp4p8pXH9lXAa50e2UsYQ2C3HjBRkDQ0VGwqZaWWidMo +ZdL3TiKT3Gvd+eVEvZNVYAhOytevhztGa7RSIC54KbHM9pQiarQcCCpAVR3S1Ced +zGExk2iVL/o4TZKipvv+lj9b+FmavtoWq9kDuO0Suja0rzBfk5/UpATWbVOSGzY9 +1u0Rm2aIGgKxpOVPaxjD8JzJ+47r7Z6tSoYt4PScRy1kgf0VwKeUUdWLiGN4JxoS +dX/onACConyPh4gK1fbHuKaoxVkIV3nFRAk18AwF4jThqFDkCQmUwh7A0DSyiPiD +WeRW1iidZptmwzaOLnwz +-----END CERTIFICATE----- diff --git a/Tests.Vpn.Service/testdata/winres.json b/Tests.Vpn.Service/testdata/winres.json new file mode 100644 index 0000000..807151e --- /dev/null +++ b/Tests.Vpn.Service/testdata/winres.json @@ -0,0 +1,44 @@ +{ + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": {}, + "description": "", + "minimum-os": "win7", + "execution-level": "", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "system", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": false, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": false + } + } + }, + "RT_VERSION": { + "#1": { + "0409": { + "fixed": { + "file_version": "1.2.3.4", + "product_version": "1.2.3.4" + }, + "info": { + "0409": { + "FileDescription": "Coder", + "FileVersion": "1.2.3.4", + "LegalCopyright": "Copyright 2025 Coder Technologies Inc.", + "OriginalFilename": "coder.exe", + "ProductName": "Coder", + "ProductVersion": "1.2.3.4" + } + } + } + } + } +} \ No newline at end of file diff --git a/Tests.Vpn/Tests.Vpn.csproj b/Tests.Vpn/Tests.Vpn.csproj index b1ff6c6..2b9e30f 100644 --- a/Tests.Vpn/Tests.Vpn.csproj +++ b/Tests.Vpn/Tests.Vpn.csproj @@ -3,7 +3,7 @@ Coder.Desktop.Tests.Vpn Coder.Desktop.Tests.Vpn - net8.0 + net8.0-windows enable enable true diff --git a/Tests.Vpn/Utilities/ServerVersionUtilitiesTest.cs b/Tests.Vpn/Utilities/ServerVersionUtilitiesTest.cs new file mode 100644 index 0000000..ac96013 --- /dev/null +++ b/Tests.Vpn/Utilities/ServerVersionUtilitiesTest.cs @@ -0,0 +1,74 @@ +using Coder.Desktop.Vpn.Utilities; +using Semver; + +namespace Coder.Desktop.Tests.Vpn.Utilities; + +[TestFixture] +public class ServerVersionUtilitiesTest +{ + [Test(Description = "Test invalid versions")] + public void InvalidVersions() + { + var invalidVersions = new List<(string, string)> + { + (null!, "Server version is empty"), + ("", "Server version is empty"), + (" ", "Server version is empty"), + ("v", "Could not parse server version"), + ("1", "Could not parse server version"), + ("v1", "Could not parse server version"), + ("1.2", "Could not parse server version"), + ("v1.2", "Could not parse server version"), + ("1.2.3.4", "Could not parse server version"), + ("v1.2.3.4", "Could not parse server version"), + + ("1.2.3", "not within required server version range"), + ("v1.2.3", "not within required server version range"), + ("2.19.0-devel", "not within required server version range"), + ("v2.19.0-devel", "not within required server version range"), + }; + + foreach (var (version, expectedErrorMessage) in invalidVersions) + { + var ex = Assert.Throws(() => + ServerVersionUtilities.ParseAndValidateServerVersion(version)); + Assert.That(ex.Message, Does.Contain(expectedErrorMessage)); + } + } + + [Test(Description = "Test valid versions")] + public void ValidVersions() + { + var validVersions = new List + { + new() + { + RawString = "2.20.0-devel+17f8e93d0", + SemVersion = new SemVersion(2, 20, 0, ["devel"], ["17f8e93d0"]), + }, + new() + { + RawString = "2.20.0", + SemVersion = new SemVersion(2, 20, 0), + }, + new() + { + RawString = "2.21.3", + SemVersion = new SemVersion(2, 21, 3), + }, + new() + { + RawString = "3.0.0", + SemVersion = new SemVersion(3, 0, 0), + }, + }; + + foreach (var version in validVersions) + foreach (var prefix in new[] { "", "v" }) + { + var result = ServerVersionUtilities.ParseAndValidateServerVersion(prefix + version.RawString); + Assert.That(result.RawString, Is.EqualTo(prefix + version.RawString), version.RawString); + Assert.That(result.SemVersion, Is.EqualTo(version.SemVersion), version.RawString); + } + } +} diff --git a/Tests.Vpn/packages.lock.json b/Tests.Vpn/packages.lock.json index 1ba2a4f..725c743 100644 --- a/Tests.Vpn/packages.lock.json +++ b/Tests.Vpn/packages.lock.json @@ -1,7 +1,7 @@ { "version": 1, "dependencies": { - "net8.0": { + "net8.0-windows7.0": { "coverlet.collector": { "type": "Direct", "requested": "[6.0.4, )", @@ -46,6 +46,28 @@ "resolved": "17.12.0", "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + }, "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "17.12.0", @@ -68,6 +90,14 @@ "resolved": "13.0.1", "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" }, + "Semver": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "5.0.1" + } + }, "System.IO.Pipelines": { "type": "Transitive", "resolved": "9.0.1", @@ -82,6 +112,8 @@ "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, )" } }, diff --git a/Vpn.DebugClient/Vpn.DebugClient.csproj b/Vpn.DebugClient/Vpn.DebugClient.csproj index bc81b6b..0eda43d 100644 --- a/Vpn.DebugClient/Vpn.DebugClient.csproj +++ b/Vpn.DebugClient/Vpn.DebugClient.csproj @@ -4,7 +4,7 @@ Coder.Desktop.Vpn.DebugClient Coder.Desktop.Vpn.DebugClient Exe - net8.0 + net8.0-windows enable enable true diff --git a/Vpn.DebugClient/packages.lock.json b/Vpn.DebugClient/packages.lock.json index 93925e9..473422b 100644 --- a/Vpn.DebugClient/packages.lock.json +++ b/Vpn.DebugClient/packages.lock.json @@ -1,12 +1,42 @@ { "version": 1, "dependencies": { - "net8.0": { + "net8.0-windows7.0": { "Google.Protobuf": { "type": "Transitive", "resolved": "3.29.3", "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw==" }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + }, + "Semver": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "5.0.1" + } + }, "System.IO.Pipelines": { "type": "Transitive", "resolved": "9.0.1", @@ -16,6 +46,8 @@ "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, )" } }, diff --git a/Vpn.Proto/RpcVersion.cs b/Vpn.Proto/RpcVersion.cs index 33e0c16..641a10d 100644 --- a/Vpn.Proto/RpcVersion.cs +++ b/Vpn.Proto/RpcVersion.cs @@ -5,7 +5,7 @@ namespace Coder.Desktop.Vpn.Proto; /// public class RpcVersion { - public static readonly RpcVersion Current = new(1, 0); + public static readonly RpcVersion Current = new(1, 1); public ulong Major { get; } public ulong Minor { get; } diff --git a/Vpn.Proto/vpn.proto b/Vpn.Proto/vpn.proto index 8a4800d..2561a4b 100644 --- a/Vpn.Proto/vpn.proto +++ b/Vpn.Proto/vpn.proto @@ -17,75 +17,75 @@ package vpn; // msg_id which it sets on the request, the responder sets response_to that msg_id on the response // message message RPC { - uint64 msg_id = 1; - uint64 response_to = 2; + uint64 msg_id = 1; + uint64 response_to = 2; } // ManagerMessage is a message from the manager (to the tunnel). message ManagerMessage { - RPC rpc = 1; - oneof msg { - GetPeerUpdate get_peer_update = 2; - NetworkSettingsResponse network_settings = 3; - StartRequest start = 4; - StopRequest stop = 5; - } + RPC rpc = 1; + oneof msg { + GetPeerUpdate get_peer_update = 2; + NetworkSettingsResponse network_settings = 3; + StartRequest start = 4; + StopRequest stop = 5; + } } // TunnelMessage is a message from the tunnel (to the manager). message TunnelMessage { - RPC rpc = 1; - oneof msg { - Log log = 2; - PeerUpdate peer_update = 3; - NetworkSettingsRequest network_settings = 4; - StartResponse start = 5; - StopResponse stop = 6; - } + RPC rpc = 1; + oneof msg { + Log log = 2; + PeerUpdate peer_update = 3; + NetworkSettingsRequest network_settings = 4; + StartResponse start = 5; + StopResponse stop = 6; + } } // ClientMessage is a message from the client (to the service). Windows only. message ClientMessage { - RPC rpc = 1; - oneof msg { - StartRequest start = 2; - StopRequest stop = 3; - StatusRequest status = 4; + RPC rpc = 1; + oneof msg { + StartRequest start = 2; + StopRequest stop = 3; + StatusRequest status = 4; } } // ServiceMessage is a message from the service (to the client). Windows only. message ServiceMessage { - RPC rpc = 1; - oneof msg { - StartResponse start = 2; - StopResponse stop = 3; - Status status = 4; // either in reply to a StatusRequest or broadcasted - } + RPC rpc = 1; + oneof msg { + StartResponse start = 2; + StopResponse stop = 3; + Status status = 4; // either in reply to a StatusRequest or broadcasted + } } // Log is a log message generated by the tunnel. The manager should log it to the system log. It is // one-way tunnel -> manager with no response. message Log { - enum Level { - // these are designed to match slog levels - DEBUG = 0; - INFO = 1; - WARN = 2; - ERROR = 3; - CRITICAL = 4; - FATAL = 5; - } - Level level = 1; - - string message = 2; - repeated string logger_names = 3; - - message Field { - string name = 1; - string value = 2; - } - repeated Field fields = 4; + enum Level { + // these are designed to match slog levels + DEBUG = 0; + INFO = 1; + WARN = 2; + ERROR = 3; + CRITICAL = 4; + FATAL = 5; + } + Level level = 1; + + string message = 2; + repeated string logger_names = 3; + + message Field { + string name = 1; + string value = 2; + } + repeated Field fields = 4; } // GetPeerUpdate asks for a PeerUpdate with a full set of data. @@ -95,132 +95,138 @@ message GetPeerUpdate {} // response to GetPeerUpdate (which dumps the full set). It is also generated on any changes (not in // response to any request). message PeerUpdate { - repeated Workspace upserted_workspaces = 1; - repeated Agent upserted_agents = 2; - repeated Workspace deleted_workspaces = 3; - repeated Agent deleted_agents = 4; + repeated Workspace upserted_workspaces = 1; + repeated Agent upserted_agents = 2; + repeated Workspace deleted_workspaces = 3; + repeated Agent deleted_agents = 4; } message Workspace { - bytes id = 1; // UUID - string name = 2; - - enum Status { - UNKNOWN = 0; - PENDING = 1; - STARTING = 2; - RUNNING = 3; - STOPPING = 4; - STOPPED = 5; - FAILED = 6; - CANCELING = 7; - CANCELED = 8; - DELETING = 9; - DELETED = 10; - } - Status status = 3; + bytes id = 1; // UUID + string name = 2; + + enum Status { + UNKNOWN = 0; + PENDING = 1; + STARTING = 2; + RUNNING = 3; + STOPPING = 4; + STOPPED = 5; + FAILED = 6; + CANCELING = 7; + CANCELED = 8; + DELETING = 9; + DELETED = 10; + } + Status status = 3; } message Agent { - bytes id = 1; // UUID - string name = 2; - bytes workspace_id = 3; // UUID - repeated string fqdn = 4; - repeated string ip_addrs = 5; - // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or - // anything longer than 5 minutes ago means there is a problem. - google.protobuf.Timestamp last_handshake = 6; + bytes id = 1; // UUID + string name = 2; + bytes workspace_id = 3; // UUID + repeated string fqdn = 4; + repeated string ip_addrs = 5; + // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or + // anything longer than 5 minutes ago means there is a problem. + google.protobuf.Timestamp last_handshake = 6; } // NetworkSettingsRequest is based on // https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for // macOS. It is a request/response message with response NetworkSettingsResponse message NetworkSettingsRequest { - uint32 tunnel_overhead_bytes = 1; - uint32 mtu = 2; - - message DNSSettings { - repeated string servers = 1; - repeated string search_domains = 2; - // domain_name is the primary domain name of the tunnel - string domain_name = 3; - repeated string match_domains = 4; - // match_domains_no_search specifies if the domains in the matchDomains list should not be - // appended to the resolver’s list of search domains. - bool match_domains_no_search = 5; - } - DNSSettings dns_settings = 3; - - string tunnel_remote_address = 4; - - message IPv4Settings { - repeated string addrs = 1; - repeated string subnet_masks = 2; - // router is the next-hop router in dotted-decimal format - string router = 3; - - message IPv4Route { - string destination = 1; - string mask = 2; - // router is the next-hop router in dotted-decimal format - string router = 3; - } - repeated IPv4Route included_routes = 4; - repeated IPv4Route excluded_routes = 5; - } - IPv4Settings ipv4_settings = 5; - - message IPv6Settings { - repeated string addrs = 1; - repeated uint32 prefix_lengths = 2; - - message IPv6Route { - string destination = 1; - uint32 prefix_length = 2; - // router is the address of the next-hop - string router = 3; - } - repeated IPv6Route included_routes = 3; - repeated IPv6Route excluded_routes = 4; - } - IPv6Settings ipv6_settings = 6; + uint32 tunnel_overhead_bytes = 1; + uint32 mtu = 2; + + message DNSSettings { + repeated string servers = 1; + repeated string search_domains = 2; + // domain_name is the primary domain name of the tunnel + string domain_name = 3; + repeated string match_domains = 4; + // match_domains_no_search specifies if the domains in the matchDomains list should not be + // appended to the resolver’s list of search domains. + bool match_domains_no_search = 5; + } + DNSSettings dns_settings = 3; + + string tunnel_remote_address = 4; + + message IPv4Settings { + repeated string addrs = 1; + repeated string subnet_masks = 2; + // router is the next-hop router in dotted-decimal format + string router = 3; + + message IPv4Route { + string destination = 1; + string mask = 2; + // router is the next-hop router in dotted-decimal format + string router = 3; + } + repeated IPv4Route included_routes = 4; + repeated IPv4Route excluded_routes = 5; + } + IPv4Settings ipv4_settings = 5; + + message IPv6Settings { + repeated string addrs = 1; + repeated uint32 prefix_lengths = 2; + + message IPv6Route { + string destination = 1; + uint32 prefix_length = 2; + // router is the address of the next-hop + string router = 3; + } + repeated IPv6Route included_routes = 3; + repeated IPv6Route excluded_routes = 4; + } + IPv6Settings ipv6_settings = 6; } // NetworkSettingsResponse is the response from the manager to the tunnel for a // NetworkSettingsRequest message NetworkSettingsResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } // StartRequest is a request from the manager to start the tunnel. The tunnel replies with a // StartResponse. message StartRequest { - int32 tunnel_file_descriptor = 1; - string coder_url = 2; - string api_token = 3; - // Additional HTTP headers added to all requests - message Header { - string name = 1; - string value = 2; - } - repeated Header headers = 4; + int32 tunnel_file_descriptor = 1; + string coder_url = 2; + string api_token = 3; + // Additional HTTP headers added to all requests + message Header { + string name = 1; + string value = 2; + } + repeated Header headers = 4; + // Device ID from Coder Desktop + string device_id = 5; + // Device OS from Coder Desktop + string device_os = 6; + // Coder Desktop version + string coder_desktop_version = 7; } message StartResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } -// StopRequest is a request to stop the tunnel. The tunnel replies with a +// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a // StopResponse. message StopRequest {} // StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes // its side of the bidirectional stream for writing. message StopResponse { - bool success = 1; - string error_message = 2; + bool success = 1; + string error_message = 2; } // StatusRequest is a request to get the status of the tunnel. The manager @@ -230,18 +236,18 @@ message StatusRequest {} // Status is sent in response to a StatusRequest or broadcasted to all clients // when the status changes. message Status { - enum Lifecycle { - UNKNOWN = 0; - STARTING = 1; - STARTED = 2; - STOPPING = 3; - STOPPED = 4; - } - Lifecycle lifecycle = 1; - string error_message = 2; - - // This will be a FULL update with all workspaces and agents, so clients - // should replace their current peer state. Only the Upserted fields will - // be populated. - PeerUpdate peer_update = 3; + enum Lifecycle { + UNKNOWN = 0; + STARTING = 1; + STARTED = 2; + STOPPING = 3; + STOPPED = 4; + } + Lifecycle lifecycle = 1; + string error_message = 2; + + // This will be a FULL update with all workspaces and agents, so clients + // should replace their current peer state. Only the Upserted fields will + // be populated. + PeerUpdate peer_update = 3; } diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs index 80b294f..6a3108b 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -1,6 +1,9 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Formats.Asn1; using System.Net; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Coder.Desktop.Vpn.Utilities; @@ -42,6 +45,11 @@ public class AuthenticodeDownloadValidator : IDownloadValidator { public static readonly AuthenticodeDownloadValidator Coder = new("Coder Technologies Inc."); + private static readonly Oid CertificatePoliciesOid = new("2.5.29.32", "Certificate Policies"); + + private static readonly Oid ExtendedValidationCodeSigningOid = + new("2.23.140.1.3", "Extended Validation (EV) code signing"); + private readonly string _expectedName; // ReSharper disable once ConvertToPrimaryConstructor @@ -60,30 +68,150 @@ public async Task ValidateAsync(string path, CancellationToken ct = default) if (fileSigInfo.State != SignatureState.SignedAndTrusted) throw new Exception( - $"File is not signed and trusted with an Authenticode signature: State={fileSigInfo.State}"); + $"File is not signed and trusted with an Authenticode signature: State={fileSigInfo.State}, StateReason={fileSigInfo.StateReason}"); // Coder will only use embedded signatures because we are downloading // individual binaries and not installers which can ship catalog files. if (fileSigInfo.Kind != SignatureKind.Embedded) throw new Exception($"File is not signed with an embedded Authenticode signature: Kind={fileSigInfo.Kind}"); - // TODO: check that it's an extended validation certificate + // We want to wrap any exception from IsExtendedValidationCertificate + // with a nicer error message, but we don't want to wrap the "false" + // result exception. + bool isExtendedValidation; + try + { + isExtendedValidation = IsExtendedValidationCertificate(fileSigInfo.SigningCertificate); + } + catch (Exception e) + { + throw new Exception( + "Could not check if file is signed with an Extended Validation Code Signing certificate", e); + } + + if (!isExtendedValidation) + throw new Exception( + $"File is not signed with an Extended Validation Code Signing certificate (missing policy {ExtendedValidationCodeSigningOid.Value} - {ExtendedValidationCodeSigningOid.FriendlyName})"); var actualName = fileSigInfo.SigningCertificate.GetNameInfo(X509NameType.SimpleName, false); if (actualName != _expectedName) throw new Exception( $"File is signed by an unexpected certificate: ExpectedName='{_expectedName}', ActualName='{actualName}'"); } + + /// + /// Checks if the given certificate is an Extended Validation Code Signing certificate. + /// + /// The cert to test + /// Whether the certificate is an Extended Validation Code Signing certificate + /// If the certificate extensions could not be parsed + private static bool IsExtendedValidationCertificate(X509Certificate2 cert) + { + ArgumentNullException.ThrowIfNull(cert); + + // RFC 5280 4.2: "A certificate MUST NOT include more than one instance + // of a particular extension." + var policyExtensions = cert.Extensions.Where(e => e.Oid?.Value == CertificatePoliciesOid.Value).ToList(); + if (policyExtensions.Count == 0) + return false; + Assert(policyExtensions.Count == 1, "certificate contains more than one CertificatePolicies extension"); + var certificatePoliciesExt = policyExtensions[0]; + + // RFC 5280 4.2.1.4 + // certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation + // + // PolicyInformation ::= SEQUENCE { + // policyIdentifier CertPolicyId, + // policyQualifiers SEQUENCE SIZE (1..MAX) OF PolicyQualifierInfo OPTIONAL + // } + try + { + AsnDecoder.ReadSequence(certificatePoliciesExt.RawData, AsnEncodingRules.DER, out var originalContentOffset, + out var contentLength, out var bytesConsumed); + Assert(bytesConsumed == certificatePoliciesExt.RawData.Length, "incorrect outer sequence length"); + Assert(originalContentOffset >= 0, "invalid outer sequence content offset"); + Assert(contentLength > 0, "invalid outer sequence content length"); + + var contentOffset = originalContentOffset; + var endOffset = originalContentOffset + contentLength; + Assert(endOffset <= certificatePoliciesExt.RawData.Length, "invalid outer sequence end offset"); + + // For each policy... + while (contentOffset < endOffset) + { + // Parse a sequence from [contentOffset:]. + var slice = certificatePoliciesExt.RawData.AsSpan(contentOffset, endOffset - contentOffset); + AsnDecoder.ReadSequence(slice, AsnEncodingRules.DER, out var innerContentOffset, + out var innerContentLength, out var innerBytesConsumed); + Assert(innerBytesConsumed <= slice.Length, "incorrect inner sequence length"); + Assert(innerContentOffset >= 0, "invalid inner sequence content offset"); + Assert(innerContentLength > 0, "invalid inner sequence content length"); + Assert(innerContentOffset + innerContentLength <= slice.Length, "invalid inner sequence end offset"); + + // Advance the outer offset by the consumed bytes. + contentOffset += innerBytesConsumed; + + // Parse the first value in the sequence as an Oid. + slice = slice.Slice(innerContentOffset, innerContentLength); + var oid = AsnDecoder.ReadObjectIdentifier(slice, AsnEncodingRules.DER, out var oidBytesConsumed); + Assert(oidBytesConsumed > 0, "invalid inner sequence OID length"); + Assert(oidBytesConsumed <= slice.Length, "invalid inner sequence OID length"); + if (oid == ExtendedValidationCodeSigningOid.Value) + return true; + + // We don't need to parse the rest of the data in the sequence, + // we can just move on to the next iteration. + } + } + catch (Exception e) + { + throw new Exception( + $"Could not parse {CertificatePoliciesOid.Value} ({CertificatePoliciesOid.FriendlyName}) extension in certificate", + e); + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Assert(bool condition, string message) + { + if (!condition) + throw new Exception("Failed certificate parse assertion: " + message); + } } public class AssemblyVersionDownloadValidator : IDownloadValidator { - private readonly string _expectedAssemblyVersion; + private readonly int _expectedMajor; + private readonly int _expectedMinor; + private readonly int _expectedBuild; + private readonly int _expectedPrivate; + + private readonly Version _expectedVersion; // ReSharper disable once ConvertToPrimaryConstructor - public AssemblyVersionDownloadValidator(string expectedAssemblyVersion) + public AssemblyVersionDownloadValidator(int expectedMajor, int expectedMinor, int expectedBuild = -1, + int expectedPrivate = -1) { - _expectedAssemblyVersion = expectedAssemblyVersion; + _expectedMajor = expectedMajor; + _expectedMinor = expectedMinor; + _expectedBuild = expectedBuild < 0 ? -1 : expectedBuild; + _expectedPrivate = expectedPrivate < 0 ? -1 : expectedPrivate; + if (_expectedBuild == -1 && _expectedPrivate != -1) + throw new ArgumentException("Build must be set if Private is set", nameof(expectedPrivate)); + + // Unfortunately the Version constructor throws an exception if the + // build or revision is -1. You need to use the specific constructor + // with the correct number of parameters. + // + // This is only for error rendering purposes anyways. + if (_expectedBuild == -1) + _expectedVersion = new Version(_expectedMajor, _expectedMinor); + else if (_expectedPrivate == -1) + _expectedVersion = new Version(_expectedMajor, _expectedMinor, _expectedBuild); + else + _expectedVersion = new Version(_expectedMajor, _expectedMinor, _expectedBuild, _expectedPrivate); } public Task ValidateAsync(string path, CancellationToken ct = default) @@ -91,9 +219,16 @@ public Task ValidateAsync(string path, CancellationToken ct = default) var info = FileVersionInfo.GetVersionInfo(path); if (string.IsNullOrEmpty(info.ProductVersion)) throw new Exception("File ProductVersion is empty or null, was the binary compiled correctly?"); - if (info.ProductVersion != _expectedAssemblyVersion) + if (!Version.TryParse(info.ProductVersion, out var productVersion)) + throw new Exception($"File ProductVersion '{info.ProductVersion}' is not a valid version string"); + + // If the build or private are -1 on the expected version, they are ignored. + if (productVersion.Major != _expectedMajor || productVersion.Minor != _expectedMinor || + (_expectedBuild != -1 && productVersion.Build != _expectedBuild) || + (_expectedPrivate != -1 && productVersion.Revision != _expectedPrivate)) throw new Exception( - $"File ProductVersion is '{info.ProductVersion}', but expected '{_expectedAssemblyVersion}'"); + $"File ProductVersion does not match expected version: Actual='{info.ProductVersion}', Expected='{_expectedVersion}'"); + return Task.CompletedTask; } } @@ -103,12 +238,12 @@ public Task ValidateAsync(string path, CancellationToken ct = default) /// public class CombinationDownloadValidator : IDownloadValidator { - private readonly IDownloadValidator[] _validators; + private readonly List _validators; /// Validators to run public CombinationDownloadValidator(params IDownloadValidator[] validators) { - _validators = validators; + _validators = validators.ToList(); } public async Task ValidateAsync(string path, CancellationToken ct = default) @@ -116,6 +251,11 @@ public async Task ValidateAsync(string path, CancellationToken ct = default) foreach (var validator in _validators) await validator.ValidateAsync(path, ct); } + + public void Add(IDownloadValidator validator) + { + _validators.Add(validator); + } } /// @@ -147,9 +287,24 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin { while (true) { + ct.ThrowIfCancellationRequested(); var task = _downloads.GetOrAdd(destinationPath, _ => new DownloadTask(_logger, req, destinationPath, validator)); - await task.EnsureStartedAsync(ct); + // EnsureStarted is a no-op if we didn't create a new DownloadTask. + // So, we will only remove the destination once for each time we start a new task. + task.EnsureStarted(tsk => + { + // remove the key first, before checking the exception, to ensure + // we still clean up. + _downloads.TryRemove(destinationPath, out _); + if (tsk.Exception == null) return; + + if (tsk.Exception.InnerException != null) + ExceptionDispatchInfo.Capture(tsk.Exception.InnerException).Throw(); + + // not sure if this is hittable, but just in case: + throw tsk.Exception; + }, ct); // If the existing (or new) task is for the same URL, return it. if (task.Request.RequestUri == req.RequestUri) @@ -163,7 +318,22 @@ public async Task StartDownloadAsync(HttpRequestMessage req, strin _logger.LogWarning( "Download for '{DestinationPath}' is already in progress, but is for a different Url - awaiting completion", destinationPath); - await task.Task; + await TaskOrCancellation(task.Task, ct); + } + } + + /// + /// TaskOrCancellation waits for either the task to complete, or the given token to be canceled. + /// + internal static async Task TaskOrCancellation(Task task, CancellationToken cancellationToken) + { + var cancellationTask = new TaskCompletionSource(); + await using (cancellationToken.Register(() => cancellationTask.TrySetCanceled())) + { + // Wait for either the task or the cancellation + var completedTask = await Task.WhenAny(task, cancellationTask.Task); + // Await to propagate exceptions, if any + await completedTask; } } } @@ -218,13 +388,11 @@ internal DownloadTask(ILogger logger, HttpRequestMessage req, string destination ".download-" + Path.GetRandomFileName()); } - internal async Task EnsureStartedAsync(CancellationToken ct = default) + internal void EnsureStarted(Action continuation, CancellationToken ct = default) { - using var _ = await _semaphore.LockAsync(ct); + using var _ = _semaphore.Lock(); if (Task == null!) - Task = await StartDownloadAsync(ct); - - return Task; + Task = Start(ct).ContinueWith(continuation, ct); } /// @@ -232,7 +400,7 @@ internal async Task EnsureStartedAsync(CancellationToken ct = default) /// and the download will continue in the background. The provided CancellationToken can be used to cancel the /// download. /// - private async Task StartDownloadAsync(CancellationToken ct = default) + private async Task Start(CancellationToken ct = default) { Directory.CreateDirectory(_destinationDirectory); @@ -259,8 +427,7 @@ private async Task StartDownloadAsync(CancellationToken ct = default) throw new Exception("Existing file failed validation after 304 Not Modified", e); } - Task = Task.CompletedTask; - return Task; + return; } if (res.StatusCode != HttpStatusCode.OK) @@ -281,27 +448,25 @@ private async Task StartDownloadAsync(CancellationToken ct = default) if (res.Content.Headers.ContentLength >= 0) TotalBytes = (ulong)res.Content.Headers.ContentLength; - FileStream tempFile; - try - { - tempFile = File.Create(TempDestinationPath, BufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - } - catch (Exception e) - { - _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath); - throw; - } - - Task = DownloadAsync(res, tempFile, ct); - return Task; + await Download(res, ct); } - private async Task DownloadAsync(HttpResponseMessage res, FileStream tempFile, CancellationToken ct) + private async Task Download(HttpResponseMessage res, CancellationToken ct) { try { var sha1 = res.Headers.Contains("ETag") ? SHA1.Create() : null; + FileStream tempFile; + try + { + tempFile = File.Create(TempDestinationPath, BufferSize, FileOptions.SequentialScan); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath); + throw; + } + await using (tempFile) { var stream = await res.Content.ReadAsStreamAsync(ct); diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 93c08dd..fc014c0 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -1,7 +1,7 @@ using System.Runtime.InteropServices; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Proto; using Coder.Desktop.Vpn.Utilities; -using CoderSdk; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Semver; @@ -16,12 +16,6 @@ public enum TunnelStatus Stopped, } -public class ServerVersion -{ - public required string String { get; set; } - public required SemVersion SemVersion { get; set; } -} - public interface IManager : IDisposable { public Task StopAsync(CancellationToken ct = default); @@ -32,14 +26,12 @@ public interface IManager : IDisposable /// public class Manager : IManager { - // TODO: determine a suitable value for this - private static readonly SemVersionRange ServerVersionRange = SemVersionRange.All; - private readonly ManagerConfig _config; private readonly IDownloader _downloader; private readonly ILogger _logger; private readonly ITunnelSupervisor _tunnelSupervisor; private readonly IManagerRpc _managerRpc; + private readonly ITelemetryEnricher _telemetryEnricher; private volatile TunnelStatus _status = TunnelStatus.Stopped; @@ -55,7 +47,7 @@ public class Manager : IManager // ReSharper disable once ConvertToPrimaryConstructor public Manager(IOptions config, ILogger logger, IDownloader downloader, - ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc) + ITunnelSupervisor tunnelSupervisor, IManagerRpc managerRpc, ITelemetryEnricher telemetryEnricher) { _config = config.Value; _logger = logger; @@ -63,6 +55,7 @@ public Manager(IOptions config, ILogger logger, IDownloa _tunnelSupervisor = tunnelSupervisor; _managerRpc = managerRpc; _managerRpc.OnReceive += HandleClientRpcMessage; + _telemetryEnricher = telemetryEnricher; } public void Dispose() @@ -141,7 +134,7 @@ private async ValueTask HandleClientMessageStart(ClientMessage me var serverVersion = await CheckServerVersionAndCredentials(message.Start.CoderUrl, message.Start.ApiToken, ct); if (_status == TunnelStatus.Started && _lastStartRequest != null && - _lastStartRequest.Equals(message.Start) && _lastServerVersion?.String == serverVersion.String) + _lastStartRequest.Equals(message.Start) && _lastServerVersion?.RawString == serverVersion.RawString) { // The client is requesting to start an identical tunnel while // we're already running it. @@ -168,7 +161,7 @@ await _tunnelSupervisor.StartAsync(_config.TunnelBinaryPath, HandleTunnelRpcMess var reply = await _tunnelSupervisor.SendRequestAwaitReply(new ManagerMessage { - Start = message.Start, + Start = _telemetryEnricher.EnrichStartRequest(message.Start), }, ct); if (reply.MsgCase != TunnelMessage.MsgOneofCase.Start) throw new InvalidOperationException("Tunnel did not reply with a Start response"); @@ -373,20 +366,11 @@ private async ValueTask CheckServerVersionAndCredentials(string b var buildInfo = await client.GetBuildInfo(ct); _logger.LogInformation("Fetched server version '{ServerVersion}'", buildInfo.Version); - if (buildInfo.Version.StartsWith('v')) buildInfo.Version = buildInfo.Version[1..]; - var serverVersion = SemVersion.Parse(buildInfo.Version); - if (!serverVersion.Satisfies(ServerVersionRange)) - throw new InvalidOperationException( - $"Server version '{serverVersion}' is not within required server version range '{ServerVersionRange}'"); - + var serverVersion = ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version); var user = await client.GetUser(User.Me, ct); _logger.LogInformation("Authenticated to server as '{Username}'", user.Username); - return new ServerVersion - { - String = buildInfo.Version, - SemVersion = serverVersion, - }; + return serverVersion; } /// @@ -417,15 +401,30 @@ private async Task DownloadTunnelBinaryAsync(string baseUrl, SemVersion expected _logger.LogInformation("Downloading VPN binary from '{url}' to '{DestinationPath}'", url, _config.TunnelBinaryPath); var req = new HttpRequestMessage(HttpMethod.Get, url); - var validators = new NullDownloadValidator(); - // TODO: re-enable when the binaries are signed and have versions - /* - var validators = new CombinationDownloadValidator( - AuthenticodeDownloadValidator.Coder, - new AssemblyVersionDownloadValidator( - $"{expectedVersion.Major}.{expectedVersion.Minor}.{expectedVersion.Patch}.0") - ); - */ + + var validators = new CombinationDownloadValidator(); + if (!string.IsNullOrEmpty(_config.TunnelBinarySignatureSigner)) + { + _logger.LogDebug("Adding Authenticode signature validator for signer '{Signer}'", + _config.TunnelBinarySignatureSigner); + validators.Add(new AuthenticodeDownloadValidator(_config.TunnelBinarySignatureSigner)); + } + else + { + _logger.LogDebug("Skipping Authenticode signature validation"); + } + + if (!_config.TunnelBinaryAllowVersionMismatch) + { + _logger.LogDebug("Adding version validator for version '{ExpectedVersion}'", expectedVersion); + validators.Add(new AssemblyVersionDownloadValidator((int)expectedVersion.Major, (int)expectedVersion.Minor, + (int)expectedVersion.Patch)); + } + else + { + _logger.LogDebug("Skipping tunnel binary version validation"); + } + var downloadTask = await _downloader.StartDownloadAsync(req, _config.TunnelBinaryPath, validators, ct); // TODO: monitor and report progress when we have a mechanism to do so diff --git a/Vpn.Service/ManagerConfig.cs b/Vpn.Service/ManagerConfig.cs index bfd7ff5..c60b1b8 100644 --- a/Vpn.Service/ManagerConfig.cs +++ b/Vpn.Service/ManagerConfig.cs @@ -2,6 +2,11 @@ namespace Coder.Desktop.Vpn.Service; +// These values are the config option names used in the registry. Any option +// here can be configured with `(Debug)?Manager: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 ManagerConfig { [Required] @@ -10,5 +15,8 @@ public class ManagerConfig [Required] public string TunnelBinaryPath { get; set; } = @"C:\coder-vpn.exe"; - [Required] public string LogFileLocation { get; set; } = @"C:\coder-desktop-service.log"; + // If empty, signatures will not be verified. + [Required] public string TunnelBinarySignatureSigner { get; set; } = "Coder Technologies Inc."; + + [Required] public bool TunnelBinaryAllowVersionMismatch { get; set; } = false; } diff --git a/Vpn.Service/Program.cs b/Vpn.Service/Program.cs index e5a7d80..fc61247 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -3,33 +3,34 @@ using Microsoft.Extensions.Hosting; using Microsoft.Win32; using Serilog; +using ILogger = Serilog.ILogger; namespace Coder.Desktop.Vpn.Service; public static class Program { + // These values are the service name and the prefix on registry value names. + // They should not be changed without backwards compatibility + // considerations. If changed here, they should also be changed in the + // installer. #if !DEBUG private const string ServiceName = "Coder Desktop"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\VpnService"; #else + // This value matches Create-Service.ps1. private const string ServiceName = "Coder Desktop (Debug)"; + private const string ConfigSubKey = @"SOFTWARE\Coder Desktop\DebugVpnService"; #endif - private const string ConsoleOutputTemplate = - "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"; - - private const string FileOutputTemplate = - "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}"; + private const string ManagerConfigSection = "Manager"; private static ILogger MainLogger => Log.ForContext("SourceContext", "Coder.Desktop.Vpn.Service.Program"); - private static LoggerConfiguration BaseLogConfig => new LoggerConfiguration() - .Enrich.FromLogContext() - .MinimumLevel.Debug() - .WriteTo.Console(outputTemplate: ConsoleOutputTemplate); - public static async Task Main(string[] args) { - Log.Logger = BaseLogConfig.CreateLogger(); + // This logger will only be used until we load our full logging configuration and replace it. + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console() + .CreateLogger(); MainLogger.Information("Application is starting"); try { @@ -51,33 +52,33 @@ public static async Task Main(string[] args) private static async Task BuildAndRun(string[] args) { var builder = Host.CreateApplicationBuilder(args); + var configBuilder = builder.Configuration as IConfigurationBuilder; // Configuration sources builder.Configuration.Sources.Clear(); - (builder.Configuration as IConfigurationBuilder).Add( - new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder Desktop")); + AddDefaultConfig(configBuilder); + configBuilder.Add( + new RegistryConfigurationSource(Registry.LocalMachine, ConfigSubKey)); builder.Configuration.AddEnvironmentVariables("CODER_MANAGER_"); builder.Configuration.AddCommandLine(args); // Options types (these get registered as IOptions singletons) builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection("Manager")) - .ValidateDataAnnotations() - .PostConfigure(config => - { - Log.Logger = BaseLogConfig - .WriteTo.File(config.LogFileLocation, outputTemplate: FileOutputTemplate) - .CreateLogger(); - }); + .Bind(builder.Configuration.GetSection(ManagerConfigSection)) + .ValidateDataAnnotations(); // Logging - builder.Services.AddSerilog(); + builder.Services.AddSerilog((_, loggerConfig) => + { + loggerConfig.ReadFrom.Configuration(builder.Configuration); + }); // Singletons builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Services if (!Environment.UserInteractive) @@ -93,6 +94,32 @@ private static async Task BuildAndRun(string[] args) builder.Services.AddHostedService(); builder.Services.AddHostedService(); - await builder.Build().RunAsync(); + var host = builder.Build(); + Log.Logger = (ILogger)host.Services.GetService(typeof(ILogger))!; + MainLogger.Information("Application is starting"); + + await host.RunAsync(); + } + + private static void AddDefaultConfig(IConfigurationBuilder builder) + { + builder.AddInMemoryCollection(new Dictionary + { + ["Serilog:Using:0"] = "Serilog.Sinks.File", + ["Serilog:Using:1"] = "Serilog.Sinks.Console", + + ["Serilog:MinimumLevel"] = "Information", + ["Serilog:Enrich:0"] = "FromLogContext", + + ["Serilog:WriteTo:0:Name"] = "File", + ["Serilog:WriteTo:0:Args:path"] = @"C:\coder-desktop-service.log", + ["Serilog:WriteTo:0:Args:outputTemplate"] = + "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", + ["Serilog:WriteTo:0:Args:rollingInterval"] = "Day", + + ["Serilog:WriteTo:1:Name"] = "Console", + ["Serilog:WriteTo:1:Args:outputTemplate"] = + "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} - {Message:lj}{NewLine}{Exception}", + }); } } diff --git a/Vpn.Service/TelemetryEnricher.cs b/Vpn.Service/TelemetryEnricher.cs new file mode 100644 index 0000000..2169334 --- /dev/null +++ b/Vpn.Service/TelemetryEnricher.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Coder.Desktop.Vpn.Proto; +using Microsoft.Win32; + +namespace Coder.Desktop.Vpn.Service; + +// +// ITelemetryEnricher contains methods for enriching messages with telemetry +// information +// +public interface ITelemetryEnricher +{ + public StartRequest EnrichStartRequest(StartRequest original); +} + +public class TelemetryEnricher : ITelemetryEnricher +{ + private readonly string? _version; + private readonly string? _deviceID; + + public TelemetryEnricher() + { + var assembly = Assembly.GetExecutingAssembly(); + _version = assembly.GetName().Version?.ToString(); + + using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SQMClient"); + if (key != null) + { + // this is the "Device ID" shown in settings. I don't think it's personally + // identifiable, but let's hash it just to be sure. + var deviceID = key.GetValue("MachineId") as string; + if (!string.IsNullOrEmpty(deviceID)) + { + var idBytes = Encoding.UTF8.GetBytes(deviceID); + var hash = SHA256.HashData(idBytes); + _deviceID = Convert.ToBase64String(hash); + } + } + } + + public StartRequest EnrichStartRequest(StartRequest original) + { + var req = original.Clone(); + req.DeviceOs = "Windows"; + if (_version != null) req.CoderDesktopVersion = _version; + if (_deviceID != null) req.DeviceId = _deviceID; + return req; + } +} diff --git a/Vpn.Service/Vpn.Service.csproj b/Vpn.Service/Vpn.Service.csproj index eed5386..aaed3cc 100644 --- a/Vpn.Service/Vpn.Service.csproj +++ b/Vpn.Service/Vpn.Service.csproj @@ -7,6 +7,7 @@ enable enable true + 13 CoderVpnService coder.ico @@ -20,16 +21,17 @@ - + - - - + + + + diff --git a/Vpn.Service/packages.lock.json b/Vpn.Service/packages.lock.json index b2fba99..09c7b76 100644 --- a/Vpn.Service/packages.lock.json +++ b/Vpn.Service/packages.lock.json @@ -4,53 +4,53 @@ "net8.0-windows7.0": { "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "3wZNcVvC8RW44HDqqmIq+BqF5pgmTQdbNdR9NyYw33JSMnJuclwoJ2PEkrJ/KvD1U/hmqHVL3l5If+Hn3D1fWA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.1", - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Logging.Console": "9.0.1", - "Microsoft.Extensions.Logging.Debug": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "Microsoft.Extensions.Logging.EventSource": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "1rZwLE+tTUIyZRUzmlk/DQj+v+Eqox+rjb+X7Fi+cYTbQfIZPYwpf1pVybsV3oje8+Pe4GaNukpBVUlPYeQdeQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.Configuration.CommandLine": "9.0.4", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.Configuration.UserSecrets": "9.0.4", + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Logging.Console": "9.0.4", + "Microsoft.Extensions.Logging.Debug": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "Microsoft.Extensions.Logging.EventSource": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Hosting.WindowsServices": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "FLapgOXQzPjUsbMqjjagCFCiGjroRmrmHQVK3/PEovRIvDU6nLk7KKs4PalzEHaIfqG+PySlY/BeLTyZtjcshg==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "QFeUS0NG4Kwq91Mf1WzVZSbBtw+nKxyOQTi4xTRUEQ2gC7HWiyCUiX0arMJxt9lWwbjXxQY9TQjDptm+ct7BkQ==", "dependencies": { - "Microsoft.Extensions.Hosting": "9.0.1", - "Microsoft.Extensions.Logging.EventLog": "9.0.1", - "System.ServiceProcess.ServiceController": "9.0.1" + "Microsoft.Extensions.Hosting": "9.0.4", + "Microsoft.Extensions.Logging.EventLog": "9.0.4", + "System.ServiceProcess.ServiceController": "9.0.4" } }, "Microsoft.Extensions.Options.DataAnnotations": { "type": "Direct", - "requested": "[9.0.1, )", - "resolved": "9.0.1", - "contentHash": "T16k12gDWOoi9W/oueC7knsZxm3ZjqmrQBFLXx9UH3Kv4fbehMyiOdhi5u1Vw7M4g0uMj21InBfgDE0570byEQ==", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "jJq7xO1PLi//cts59Yp6dKNN07xV0Day/JmVR7aXCdo2rYHAoFlyARyxrfB0CTzsErA+TOhYTz2Ee0poR8SPeQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Security.Extensions": { @@ -81,6 +81,17 @@ "Serilog.Extensions.Logging": "9.0.0" } }, + "Serilog.Settings.Configuration": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "9.0.0", + "Microsoft.Extensions.DependencyModel": "9.0.0", + "Serilog": "4.2.0" + } + }, "Serilog.Sinks.Console": { "type": "Direct", "requested": "[6.0.0, )", @@ -106,257 +117,266 @@ }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "resolved": "9.0.4", + "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "resolved": "9.0.4", + "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", + "resolved": "9.0.4", + "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", + "resolved": "9.0.4", + "contentHash": "TbM2HElARG7z1gxwakdppmOkm1SykPqDcu3EF97daEwSb/+TXnRrFfJtF+5FWWxcsNhbRrmLfS2WszYcab7u1A==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", + "resolved": "9.0.4", + "contentHash": "2IGiG3FtVnD83IA6HYGuNei8dOw455C09yEhGl8bjcY6aGZgoC6yhYvDnozw8wlTowfoG9bxVrdTsr2ACZOYHg==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", + "resolved": "9.0.4", + "contentHash": "UY864WQ3AS2Fkc8fYLombWnjrXwYt+BEHHps0hY4sxlgqaVW06AxbpgRZjfYf8PyRbplJqruzZDB/nSLT+7RLQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", + "resolved": "9.0.4", + "contentHash": "vVXI70CgT/dmXV3MM+n/BR2rLXEoAyoK0hQT+8MrbCMuJBiLRxnTtSrksNiASWCwOtxo/Tyy7CO8AGthbsYxnw==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.FileExtensions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", + "resolved": "9.0.4", + "contentHash": "zuvyC72gJkJyodyGowCuz3EQ1QvzNXJtKusuRzmjoHr17aeB3X0aSiKFB++HMHnQIWWlPOBf9YHTQfEqzbgl1g==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Json": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Physical": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Json": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Physical": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==", + "resolved": "9.0.4", + "contentHash": "f2MTUaS2EQ3lX4325ytPAISZqgBfXmY0WvgD80ji6Z20AoDNiCESxsqo6mFRwHJD/jfVKRw9FsW6+86gNre3ug==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==" + "resolved": "9.0.4", + "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA==", + "dependencies": { + "System.Text.Encodings.Web": "9.0.0", + "System.Text.Json": "9.0.0" + } }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", + "resolved": "9.0.4", + "contentHash": "gQN2o/KnBfVk6Bd71E2YsvO5lsqrqHmaepDGk+FB/C4aiQY9B0XKKNKfl5/TqcNOs9OEithm4opiMHAErMFyEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", + "resolved": "9.0.4", + "contentHash": "qkQ9V7KFZdTWNThT7ke7E/Jad38s46atSs3QUYZB8f3thBTrcrousdY4Y/tyCtcH5YjsPSiByjuN+L8W/ThMQg==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==" + "resolved": "9.0.4", + "contentHash": "05Lh2ItSk4mzTdDWATW9nEcSybwprN8Tz42Fs5B+jwdXUpauktdAQUI1Am4sUQi2C63E5hvQp8gXvfwfg9mQGQ==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", + "resolved": "9.0.4", + "contentHash": "bXkwRPMo4x19YKH6/V9XotU7KYQJlihXhcWO1RDclAY3yfY3XNg4QtSEBvng4kK/DnboE0O/nwSl+6Jiv9P+FA==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", + "resolved": "9.0.4", + "contentHash": "xW6QPYsqhbuWBO9/1oA43g/XPKbohJx+7G8FLQgQXIriYvY7s+gxr2wjQJfRoPO900dvvv2vVH7wZovG+M1m6w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1" + "Microsoft.Extensions.DependencyInjection": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", + "resolved": "9.0.4", + "contentHash": "0MXlimU4Dud6t+iNi5NEz3dO2w1HXdhoOLaYFuLPCjAsvlPQGwOT6V2KZRMLEhCAm/stSZt1AUv0XmDdkjvtbw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "System.Diagnostics.DiagnosticSource": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "System.Diagnostics.DiagnosticSource": "9.0.4" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", + "resolved": "9.0.4", + "contentHash": "/kF+rSnoo3/nIwGzWsR4RgBnoTOdZ3lzz2qFRyp/GgaNid4j6hOAQrs/O+QHXhlcAdZxjg37MvtIE+pAvIgi9g==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", + "resolved": "9.0.4", + "contentHash": "cI0lQe0js65INCTCtAgnlVJWKgzgoRHVAW1B1zwCbmcliO4IZoTf92f1SYbLeLk7FzMJ/GlCvjLvJegJ6kltmQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging.Configuration": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging.Configuration": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", + "resolved": "9.0.4", + "contentHash": "D1jy+jy+huUUxnkZ0H480RZK8vqKn8NsQxYpMpPL/ALPPh1WATVLcr/uXI3RUBB45wMW5265O+hk9x3jnnXFuA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", + "resolved": "9.0.4", + "contentHash": "bApxdklf7QTsONOLR5ow6SdDFXR5ncHvumSEg2+QnCvxvkzc2z5kNn7yQCyupRLRN4jKbnlTkVX8x9qLlwL6Qg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "System.Diagnostics.EventLog": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "System.Diagnostics.EventLog": "9.0.4" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", + "resolved": "9.0.4", + "contentHash": "R600zTxVJNw2IeAEOvdOJGNA1lHr1m3vo460hSF5G1DjwP0FNpyeH4lpLDMuf34diKwB1LTt5hBw1iF1/iuwsQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Logging": "9.0.1", - "Microsoft.Extensions.Logging.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1", - "System.Text.Json": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4", + "System.Text.Json": "9.0.4" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "resolved": "9.0.4", + "contentHash": "fiFI2+58kicqVZyt/6obqoFwHiab7LC4FkQ3mmiBJ28Yy4fAvy2+v9MRnSvvlOO8chTOjKsdafFl/K9veCPo5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", + "resolved": "9.0.4", + "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", - "Microsoft.Extensions.Configuration.Binder": "9.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", - "Microsoft.Extensions.Options": "9.0.1", - "Microsoft.Extensions.Primitives": "9.0.1" + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + "resolved": "9.0.4", + "contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA==" }, "Serilog": { "type": "Transitive", @@ -374,39 +394,39 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==" + "resolved": "9.0.4", + "contentHash": "Be0emq8bRmcK4eeJIFUt9+vYPf7kzuQrFs8Ef1CdGvXpq/uSve22PTSkRF09bF/J7wmYJ2DHf2v7GaT3vMXnwQ==" }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + "resolved": "9.0.4", + "contentHash": "getRQEXD8idlpb1KW56XuxImMy0FKp2WJPDf3Qr0kI/QKxxJSftqfDFVo0DZ3HCJRLU73qHSruv5q2l5O47jQQ==" }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg==" + "resolved": "9.0.4", + "contentHash": "luF2Xba+lTe2GOoNQdZLe8q7K6s7nSpWZl9jIwWNMszN4/Yv0lmxk9HISgMmwdyZ83i3UhAGXaSY9o6IJBUuuA==" }, "System.ServiceProcess.ServiceController": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "Ghm4yP29P3cC65Qof8CrgU3WO/q3ERtht6/CrvcUl1FgRs6D7exj75GuG4ciRv0sjygtvyd675924DFsxxnEgA==", + "resolved": "9.0.4", + "contentHash": "j6Z+ED1d/yxe/Cm+UlFf+LNw2HSYBSgtFh71KnEEmUtHIwgoTVQxji5URvXPOAZ7iuKHItjMIzpCLyRZc8OmrQ==", "dependencies": { - "System.Diagnostics.EventLog": "9.0.1" + "System.Diagnostics.EventLog": "9.0.4" } }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + "resolved": "9.0.4", + "contentHash": "V+5cCPpk1S2ngekUs9nDrQLHGiWFZMg8BthADQr+Fwi59a8DdHFu26S2oi9Bfgv+d67bqmkPqctJXMEXiimXUg==" }, "System.Text.Json": { "type": "Transitive", - "resolved": "9.0.1", - "contentHash": "eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", + "resolved": "9.0.4", + "contentHash": "pYtmpcO6R3Ef1XilZEHgXP2xBPVORbYEzRP7dl0IAAbN8Dm+kfwio8aCKle97rAWXOExr292MuxWYurIuwN62g==", "dependencies": { - "System.IO.Pipelines": "9.0.1", - "System.Text.Encodings.Web": "9.0.1" + "System.IO.Pipelines": "9.0.4", + "System.Text.Encodings.Web": "9.0.4" } }, "Coder.Desktop.CoderSdk": { @@ -416,6 +436,8 @@ "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, )" } }, diff --git a/Vpn.Service/RegistryConfigurationSource.cs b/Vpn/RegistryConfigurationSource.cs similarity index 96% rename from Vpn.Service/RegistryConfigurationSource.cs rename to Vpn/RegistryConfigurationSource.cs index 8e2dd0d..2e67b87 100644 --- a/Vpn.Service/RegistryConfigurationSource.cs +++ b/Vpn/RegistryConfigurationSource.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Win32; -namespace Coder.Desktop.Vpn.Service; +namespace Coder.Desktop.Vpn; public class RegistryConfigurationSource : IConfigurationSource { diff --git a/Vpn/Utilities/RaiiSemaphoreSlim.cs b/Vpn/Utilities/RaiiSemaphoreSlim.cs index e38db6a..25f12bc 100644 --- a/Vpn/Utilities/RaiiSemaphoreSlim.cs +++ b/Vpn/Utilities/RaiiSemaphoreSlim.cs @@ -30,13 +30,13 @@ public IDisposable Lock() return new Locker(_semaphore); } - public async ValueTask LockAsync(CancellationToken ct = default) + public async Task LockAsync(CancellationToken ct = default) { await _semaphore.WaitAsync(ct); return new Locker(_semaphore); } - public async ValueTask LockAsync(TimeSpan timeout, CancellationToken ct = default) + public async Task LockAsync(TimeSpan timeout, CancellationToken ct = default) { if (!await _semaphore.WaitAsync(timeout, ct)) return null; return new Locker(_semaphore); @@ -44,16 +44,16 @@ public async ValueTask LockAsync(CancellationToken ct = default) private class Locker : IDisposable { - private readonly SemaphoreSlim _semaphore1; + private readonly SemaphoreSlim _semaphore; public Locker(SemaphoreSlim semaphore) { - _semaphore1 = semaphore; + _semaphore = semaphore; } public void Dispose() { - _semaphore1.Release(); + _semaphore.Release(); GC.SuppressFinalize(this); } } diff --git a/Vpn/Utilities/ServerVersionUtilities.cs b/Vpn/Utilities/ServerVersionUtilities.cs new file mode 100644 index 0000000..88bca69 --- /dev/null +++ b/Vpn/Utilities/ServerVersionUtilities.cs @@ -0,0 +1,45 @@ +using Semver; + +namespace Coder.Desktop.Vpn.Utilities; + +public class ServerVersion +{ + public required string RawString { get; set; } + public required SemVersion SemVersion { get; set; } +} + +public static class ServerVersionUtilities +{ + // The -0 allows pre-release versions. + private static readonly SemVersionRange ServerVersionRange = SemVersionRange.Parse(">= 2.20.0-0", + SemVersionRangeOptions.IncludeAllPrerelease | SemVersionRangeOptions.AllowV | + SemVersionRangeOptions.AllowMetadata); + + /// + /// Attempts to parse and verify that the server version is within the supported range. + /// + /// + /// The server version to check, optionally with a leading `v` or extra metadata/pre-release + /// tags + /// + /// The parsed server version + /// Could not parse version + /// The server version is not in range + public static ServerVersion ParseAndValidateServerVersion(string versionString) + { + if (string.IsNullOrWhiteSpace(versionString)) + throw new ArgumentException("Server version is empty", nameof(versionString)); + if (!SemVersion.TryParse(versionString, SemVersionStyles.AllowV, out var version)) + throw new ArgumentException($"Could not parse server version '{versionString}'", nameof(versionString)); + if (!version.Satisfies(ServerVersionRange)) + throw new ArgumentException( + $"Server version '{version}' is not within required server version range '{ServerVersionRange}'", + nameof(versionString)); + + return new ServerVersion + { + RawString = versionString, + SemVersion = version, + }; + } +} diff --git a/Vpn/Vpn.csproj b/Vpn/Vpn.csproj index e8016f3..76a72eb 100644 --- a/Vpn/Vpn.csproj +++ b/Vpn/Vpn.csproj @@ -3,7 +3,7 @@ Coder.Desktop.Vpn Coder.Desktop.Vpn - net8.0 + net8.0-windows enable enable true @@ -14,6 +14,8 @@ + + diff --git a/Vpn/packages.lock.json b/Vpn/packages.lock.json index c62e288..8876fe4 100644 --- a/Vpn/packages.lock.json +++ b/Vpn/packages.lock.json @@ -1,7 +1,26 @@ { "version": 1, "dependencies": { - "net8.0": { + "net8.0-windows7.0": { + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[9.0.1, )", + "resolved": "9.0.1", + "contentHash": "VuthqFS+ju6vT8W4wevdhEFiRi1trvQtkzWLonApfF5USVzzDcTBoY3F24WvN/tffLSrycArVfX1bThm/9xY2A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Semver": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "9jZCicsVgTebqkAujRWtC9J1A5EQVlu0TVKHcgoCuv345ve5DYf4D1MjhKEnQjdRZo6x/vdv6QQrYFs7ilGzLA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "5.0.1" + } + }, "System.IO.Pipelines": { "type": "Direct", "requested": "[9.0.1, )", @@ -13,6 +32,19 @@ "resolved": "3.29.3", "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw==" }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "+4hfFIY1UjBCXFTTOd+ojlDPq6mep3h5Vq5SYE3Pjucr7dNXmq4S/6P/LoVnZFz2e/5gWp/om4svUFgznfULcA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + }, "Coder.Desktop.Vpn.Proto": { "type": "Project", "dependencies": { diff --git a/scripts/Get-Mutagen.ps1 b/scripts/Get-Mutagen.ps1 new file mode 100644 index 0000000..8689377 --- /dev/null +++ b/scripts/Get-Mutagen.ps1 @@ -0,0 +1,44 @@ +# Usage: Get-Mutagen.ps1 -arch +param ( + [ValidateSet("x64", "arm64")] + [Parameter(Mandatory = $true)] + [string] $arch +) + +function Download-File([string] $url, [string] $outputPath, [string] $etagFile) { + Write-Host "Downloading '$url' to '$outputPath'" + # We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow. + & curl.exe ` + --progress-bar ` + --show-error ` + --fail ` + --location ` + --etag-compare $etagFile ` + --etag-save $etagFile ` + --output $outputPath ` + $url + if ($LASTEXITCODE -ne 0) { throw "Failed to download $url" } + if (!(Test-Path $outputPath) -or (Get-Item $outputPath).Length -eq 0) { + throw "Failed to download '$url', output file '$outputPath' is missing or empty" + } +} + +$goArch = switch ($arch) { + "x64" { "amd64" } + "arm64" { "arm64" } + default { throw "Unsupported architecture: $arch" } +} + +# Download the mutagen binary from our bucket for this platform if we don't have +# it yet (or it's different). +$mutagenVersion = "v0.18.3" +$mutagenPath = Join-Path $PSScriptRoot "files\mutagen-windows-$($arch).exe" +$mutagenUrl = "https://storage.googleapis.com/coder-desktop/mutagen/$($mutagenVersion)/mutagen-windows-$($goArch).exe" +$mutagenEtagFile = $mutagenPath + ".etag" +Download-File $mutagenUrl $mutagenPath $mutagenEtagFile + +# Download mutagen agents tarball. +$mutagenAgentsPath = Join-Path $PSScriptRoot "files\mutagen-agents.tar.gz" +$mutagenAgentsUrl = "https://storage.googleapis.com/coder-desktop/mutagen/$($mutagenVersion)/mutagen-agents.tar.gz" +$mutagenAgentsEtagFile = $mutagenAgentsPath + ".etag" +Download-File $mutagenAgentsUrl $mutagenAgentsPath $mutagenAgentsEtagFile diff --git a/scripts/Get-WindowsAppSdk.ps1 b/scripts/Get-WindowsAppSdk.ps1 new file mode 100644 index 0000000..a9ca02a --- /dev/null +++ b/scripts/Get-WindowsAppSdk.ps1 @@ -0,0 +1,34 @@ +# Usage: Get-WindowsAppSdk.ps1 -arch +param ( + [ValidateSet("x64", "arm64")] + [Parameter(Mandatory = $true)] + [string] $arch +) + +function Download-File([string] $url, [string] $outputPath, [string] $etagFile) { + Write-Host "Downloading '$url' to '$outputPath'" + # We use `curl.exe` here because `Invoke-WebRequest` is notoriously slow. + & curl.exe ` + --progress-bar ` + -v ` + --show-error ` + --fail ` + --location ` + --etag-compare $etagFile ` + --etag-save $etagFile ` + --output $outputPath ` + $url + if ($LASTEXITCODE -ne 0) { throw "Failed to download $url" } + if (!(Test-Path $outputPath) -or (Get-Item $outputPath).Length -eq 0) { + throw "Failed to download '$url', output file '$outputPath' is missing or empty" + } +} + +# Download the Windows App Sdk binary from Microsoft for this platform if we don't have +# it yet (or it's different). +$windowsAppSdkMajorVersion = "1.6" +$windowsAppSdkFullVersion = "1.6.250228001" +$windowsAppSdkPath = Join-Path $PSScriptRoot "files\windows-app-sdk-$($arch).exe" +$windowsAppSdkUri = "https://aka.ms/windowsappsdk/$($windowsAppSdkMajorVersion)/$($windowsAppSdkFullVersion)/windowsappruntimeinstall-$($arch).exe" +$windowsAppSdkEtagFile = $windowsAppSdkPath + ".etag" +Download-File $windowsAppSdkUri $windowsAppSdkPath $windowsAppSdkEtagFile \ No newline at end of file diff --git a/scripts/Publish.ps1 b/scripts/Publish.ps1 index 08b30bd..4390dfa 100644 --- a/scripts/Publish.ps1 +++ b/scripts/Publish.ps1 @@ -116,13 +116,14 @@ New-Item -ItemType Directory -Path $buildPath -Force & dotnet.exe restore if ($LASTEXITCODE -ne 0) { throw "Failed to dotnet restore" } $servicePublishDir = Join-Path $buildPath "service" -& dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a $arch -o $servicePublishDir +& dotnet.exe publish .\Vpn.Service\Vpn.Service.csproj -c Release -a $arch -o $servicePublishDir /p:Version=$version if ($LASTEXITCODE -ne 0) { throw "Failed to build Vpn.Service" } # App needs to be built with msbuild $appPublishDir = Join-Path $buildPath "app" $msbuildBinary = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe if ($LASTEXITCODE -ne 0) { throw "Failed to find MSBuild" } -& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir +if (-not (Test-Path $msbuildBinary)) { throw "Failed to find MSBuild at $msbuildBinary" } +& $msbuildBinary .\App\App.csproj /p:Configuration=Release /p:Platform=$arch /p:OutputPath=$appPublishDir /p:Version=$version if ($LASTEXITCODE -ne 0) { throw "Failed to build App" } # Find any files in the publish directory recursively that match any of our @@ -144,6 +145,18 @@ if ($null -eq $wintunDllSrc) { $wintunDllDest = Join-Path $vpnFilesPath "wintun.dll" Copy-Item $wintunDllSrc $wintunDllDest +$scriptRoot = Join-Path $repoRoot "scripts" +$getMutagen = Join-Path $scriptRoot "Get-Mutagen.ps1" +& $getMutagen -arch $arch + +$mutagenSrcPath = Join-Path $scriptRoot "files\mutagen-windows-$($arch).exe" +$mutagenDestPath = Join-Path $vpnFilesPath "mutagen.exe" +Copy-Item $mutagenSrcPath $mutagenDestPath + +$mutagenAgentsSrcPath = Join-Path $scriptRoot "files\mutagen-agents.tar.gz" +$mutagenAgentsDestPath = Join-Path $vpnFilesPath "mutagen-agents.tar.gz" +Copy-Item $mutagenAgentsSrcPath $mutagenAgentsDestPath + # Build the MSI installer & dotnet.exe run --project .\Installer\Installer.csproj -c Release -- ` build-msi ` @@ -162,6 +175,10 @@ Copy-Item $wintunDllSrc $wintunDllDest if ($LASTEXITCODE -ne 0) { throw "Failed to build MSI" } Add-CoderSignature $msiOutputPath +$getWindowsAppSdk = Join-Path $scriptRoot "Get-WindowsAppSdk.ps1" +& $getWindowsAppSdk -arch $arch +$windowsAppSdkPath = Join-Path $scriptRoot "files\windows-app-sdk-$($arch).exe" + # Build the bootstrapper & dotnet.exe run --project .\Installer\Installer.csproj -c Release -- ` build-bootstrapper ` @@ -171,6 +188,7 @@ Add-CoderSignature $msiOutputPath --output-path $outputPath ` --icon-file "App\coder.ico" ` --msi-path $msiOutputPath ` + --windows-app-sdk-path $windowsAppSdkPath ` --logo-png "scripts\files\logo.png" if ($LASTEXITCODE -ne 0) { throw "Failed to build bootstrapper" } diff --git a/scripts/files/.gitignore b/scripts/files/.gitignore new file mode 100644 index 0000000..9080d92 --- /dev/null +++ b/scripts/files/.gitignore @@ -0,0 +1,4 @@ +mutagen-*.tar.gz +mutagen-*.exe +*.etag +windows-app-sdk-*.exe \ No newline at end of file