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..2a15166 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -30,7 +30,7 @@ - + @@ -62,11 +62,15 @@ + + + + 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..4a35a0f 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,11 +1,18 @@ using System; +using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; +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.Vpn; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.UI.Xaml; +using Microsoft.Win32; namespace Coder.Desktop.App; @@ -15,17 +22,39 @@ public partial class App : Application private bool _handleWindowClosed = true; +#if !DEBUG + private const string MutagenControllerConfigSection = "AppMutagenController"; +#else + private const string MutagenControllerConfigSection = "DebugAppMutagenController"; +#endif + public App() { - var services = new ServiceCollection(); + var builder = Host.CreateApplicationBuilder(); + + (builder.Configuration as IConfigurationBuilder).Add( + new RegistryConfigurationSource(Registry.LocalMachine, @"SOFTWARE\Coder Desktop")); + + var services = builder.Services; + services.AddSingleton(); services.AddSingleton(); + services.AddOptions() + .Bind(builder.Configuration.GetSection(MutagenControllerConfigSection)); + 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(); + // TrayWindow views and view models + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -45,21 +74,67 @@ public async Task ExitApplication() { _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(); + // Start connecting to the manager in the background. + var rpcController = _services.GetRequiredService(); + if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected) + // Passing in a CT with no cancellation is desired here, because + // the named pipe open will block until the pipe comes up. + // TODO: log + _ = rpcController.Reconnect(CancellationToken.None).ContinueWith(t => + { +#if DEBUG + if (t.Exception != null) + { + Debug.WriteLine(t.Exception); + Debugger.Break(); + } +#endif + }); + + // Load the credentials in the background. + var credentialManagerCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var credentialManager = _services.GetRequiredService(); + _ = credentialManager.LoadCredentials(credentialManagerCts.Token).ContinueWith(t => + { + // TODO: log +#if DEBUG + if (t.Exception != null) + { + 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 => + { + // TODO: log +#if DEBUG + if (t.IsCanceled || t.Exception != null) 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(); }; } 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..8c1570f --- /dev/null +++ b/App/Converters/DependencyObjectSelector.cs @@ -0,0 +1,188 @@ +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; 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/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index a3456b7..41a8dc7 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -6,8 +6,8 @@ using System.Threading; using System.Threading.Tasks; using Coder.Desktop.App.Models; +using Coder.Desktop.CoderSdk; using Coder.Desktop.Vpn.Utilities; -using CoderSdk; namespace Coder.Desktop.App.Services; @@ -18,119 +18,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 + { + 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) { - _latestCredentials = newModel.Clone(); + 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, + }; + } - CredentialsChanged?.Invoke(this, newModel.Clone()); + // 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); }); } +} - private static RawCredentials? ReadCredentials() +public class WindowsCredentialBackend : ICredentialBackend +{ + private readonly string _credentialsTargetName; + + public WindowsCredentialBackend(string credentialsTargetName) { - var raw = NativeApi.ReadCredentials(CredentialsTargetName); - if (raw == null) return null; + _credentialsTargetName = credentialsTargetName; + } + + public Task ReadCredentials(CancellationToken ct = default) + { + var raw = NativeApi.ReadCredentials(_credentialsTargetName); + if (raw == null) return Task.FromResult(null); RawCredentials? credentials; try @@ -139,19 +316,23 @@ 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); + NativeApi.WriteCredentials(_credentialsTargetName, raw); + return Task.CompletedTask; + } + + public Task DeleteCredentials(CancellationToken ct = default) + { + NativeApi.DeleteCredentials(_credentialsTargetName); + return Task.CompletedTask; } private static class NativeApi diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs new file mode 100644 index 0000000..dd489df --- /dev/null +++ b/App/Services/MutagenController.cs @@ -0,0 +1,731 @@ +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.Url; +using Coder.Desktop.Vpn.Utilities; +using Grpc.Core; +using Microsoft.Extensions.Options; +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, 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 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) + { + _mutagenExecutablePath = config.Value.MutagenExecutablePath; + } + + public MutagenController(string executablePath, string dataDirectory) + { + _mutagenExecutablePath = executablePath; + _mutagenDataDirectory = dataDirectory; + } + + 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, CancellationToken ct = default) + { + using var _ = await _lock.LockAsync(ct); + var client = await EnsureDaemon(ct); + + await using var prompter = await Prompter.Create(client, true, ct); + 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 set these at some point + Configuration = new Configuration(), + 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 + { + // ignored + } + + 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) + { + 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); + + _daemonProcess = new Process(); + _daemonProcess.StartInfo.FileName = _mutagenExecutablePath; + _daemonProcess.StartInfo.Arguments = "daemon run"; + _daemonProcess.StartInfo.Environment.Add("MUTAGEN_DATA_DIRECTORY", _mutagenDataDirectory); + // 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; + // TODO: log exited process + // _daemonProcess.Exited += ... + if (!_daemonProcess.Start()) + throw new InvalidOperationException("Failed to start mutagen daemon process, Start returned false"); + + 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) + { + var process = _daemonProcess; + var client = _mutagenClient; + var writer = _logWriter; + _daemonProcess = null; + _mutagenClient = null; + _logWriter = null; + + try + { + if (client == null) + { + if (process == null) return; + process.Kill(true); + } + else + { + try + { + await client.Daemon.TerminateAsync(new DaemonTerminateRequest(), cancellationToken: ct); + } + catch + { + if (process == null) return; + process.Kill(true); + } + } + + if (process == null) return; + var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + await process.WaitForExitAsync(cts.Token); + } + finally + { + client?.Dispose(); + process?.Dispose(); + writer?.Dispose(); + } + } + + private class Prompter : IAsyncDisposable + { + 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"); + + // 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/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/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs new file mode 100644 index 0000000..7fdd881 --- /dev/null +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -0,0 +1,394 @@ +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 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 readonly ISyncSessionController _syncSessionController; + private readonly IRpcController _rpcController; + private readonly ICredentialManager _credentialManager; + + [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 List 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(NewSessionCreateEnabled))] + public partial string NewSessionRemoteHost { get; set; } = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + public partial string NewSessionRemotePath { get; set; } = ""; + // TODO: NewSessionRemotePathDialogOpen for remote path + + 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; + 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) + { + _syncSessionController = syncSessionController; + _rpcController = rpcController; + _credentialManager = credentialManager; + } + + 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; + + 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; + if (oldMessage != null) ReloadSessions(); + } + } + + 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 = ""; + } + + [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; + } + + [RelayCommand] + private void StartCreatingNewSession() + { + ClearNewForm(); + CreatingNewSession = true; + } + + public async Task OpenLocalPathSelectDialog(Window window) + { + 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] + private void CancelNewSession() + { + ClearNewForm(); + } + + [RelayCommand] + private async Task ConfirmNewSession() + { + if (OperationInProgress || !NewSessionCreateEnabled) return; + OperationInProgress = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + 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, + }, + }, 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; + } + } + + 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..532bfe4 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 @@ -179,6 +211,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 +230,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/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..8a409d7 --- /dev/null +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -0,0 +1,23 @@ +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views.Pages; +using Microsoft.UI.Xaml.Media; +using WinUIEx; + +namespace Coder.Desktop.App.Views; + +public sealed partial class FileSyncListWindow : WindowEx +{ + public readonly FileSyncListViewModel ViewModel; + + public FileSyncListWindow(FileSyncListViewModel viewModel) + { + ViewModel = viewModel; + InitializeComponent(); + SystemBackdrop = new DesktopAcrylicBackdrop(); + + ViewModel.Initialize(this, DispatcherQueue); + RootFrame.Content = new FileSyncListMainPage(ViewModel, this); + + this.CenterOnScreen(); + } +} diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml new file mode 100644 index 0000000..d38bc29 --- /dev/null +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs new file mode 100644 index 0000000..c54c29e --- /dev/null +++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs @@ -0,0 +1,40 @@ +using System.Threading.Tasks; +using Coder.Desktop.App.ViewModels; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Views.Pages; + +public sealed partial class FileSyncListMainPage : Page +{ + public FileSyncListViewModel ViewModel; + + private readonly Window _window; + + public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window) + { + ViewModel = viewModel; // already initialized + _window = window; + 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); + } + + [RelayCommand] + public async Task OpenLocalPathSelectDialog() + { + await ViewModel.OpenLocalPathSelectDialog(_window); + } +} diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml index 93a1796..8613f19 100644 --- a/App/Views/Pages/SignInTokenPage.xaml +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -95,10 +95,5 @@ Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}" CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" /> - - diff --git a/App/Views/Pages/SignInUrlPage.xaml b/App/Views/Pages/SignInUrlPage.xaml index 1c12b03..76f6a3a 100644 --- a/App/Views/Pages/SignInUrlPage.xaml +++ b/App/Views/Pages/SignInUrlPage.xaml @@ -46,6 +46,7 @@ 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}" /> diff --git a/App/Views/Pages/TrayWindowDisconnectedPage.xaml b/App/Views/Pages/TrayWindowDisconnectedPage.xaml index cf9e172..6675f8d 100644 --- a/App/Views/Pages/TrayWindowDisconnectedPage.xaml +++ b/App/Views/Pages/TrayWindowDisconnectedPage.xaml @@ -17,12 +17,12 @@ Spacing="10"> + 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..3fe4b5c 100644 --- a/App/Views/SignInWindow.xaml.cs +++ b/App/Views/SignInWindow.xaml.cs @@ -1,18 +1,20 @@ +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; 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 +22,18 @@ public sealed partial class SignInWindow : Window public SignInWindow(SignInViewModel viewModel) { InitializeComponent(); + 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 +41,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..405ea61 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -35,6 +35,46 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1" } }, + "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" + } + }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[9.0.1, )", + "resolved": "9.0.1", + "contentHash": "nggoNKnWcsBIAaOWHA+53XZWrslC7aGeok+aR+epDPRy7HI7GwMnGZE8yEsL2Onw7kMOHVHwKcsDls1INkNUJQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, "Microsoft.WindowsAppSDK": { "type": "Direct", "requested": "[1.6.250108002, )", @@ -45,11 +85,42 @@ "Microsoft.Windows.SDK.BuildTools": "10.0.22621.756" } }, + "WinUIEx": { + "type": "Direct", + "requested": "[2.5.1, )", + "resolved": "2.5.1", + "contentHash": "ihW4bA2quKbwWBOl5Uu80jBZagc4cT4E6CdExmvSZ05Qwz0jgoGyZuSTKcU9Uz7lZlQij3KxNor0dGXNUyIV9Q==", + "dependencies": { + "Microsoft.WindowsAppSDK": "1.6.240829007" + } + }, "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,11 +137,243 @@ "H.GeneratedIcons.System.Drawing": "2.2.0" } }, + "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.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "w7kAyu1Mm7eParRV6WvGNNwA8flPTub16fwH49h7b/yqJZFTgYxnOVCuiah3G2bgseJMEq4DLjjsyQRvsdzRgA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "5WC1OsXfljC1KHEyL0yefpAyt1UZjrZ0/xyOqFowc5VntbE79JpCYOTSYFlxEuXm3Oq5xsgU2YXeZLTgAAX+DA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "5HShUdF8KFAUSzoEu0DOFbX09FlcFtHxEalowyjM7Kji0EjdF0DLjHajb2IBvoqsExAYox+Z2GfbfGF7dH7lKQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "9.0.1" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "QBOI8YVAyKqeshYOyxSe6co22oag431vxMu5xQe1EjXMkYE4xK4J71xLCW3/bWKmr9Aoy1VqGUARSLFnotk4Bg==", + "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.Json": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "z+g+lgPET1JRDjsOkFe51rkkNcnJgvOK5UIpeTfF1iAi0GkBJz5/yUuTa8a9V8HUh4gj4xFT5WGoMoXoSDKfGg==", + "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.UserSecrets": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "esGPOgLZ1tZddEomexhrU+LJ5YIsuJdkh0tU7r4WVpNZ15dLuMPqPW4Xe4txf3T2PDUX2ILe3nYQEDjZjfSEJg==", + "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.DependencyInjection.Abstractions": { "type": "Transitive", "resolved": "9.0.1", "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA==" }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "4ZmP6turxMFsNwK/MCko2fuIITaYYN/eXyyIRq1FjLDKnptdbn6xMb7u0zfSMzCGpzkx4RxH/g1jKN2IchG7uA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.1" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "pfAPuVtHvG6dvZtAa0OQbXdDqq6epnr8z0/IIUjdmV0tMeI8Aj9KxDXvdDvqr+qNHTkmA7pZpChNxwNZt4GXVg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1", + "System.Diagnostics.DiagnosticSource": "9.0.1" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "DguZYt1DWL05+8QKWL3b6bW7A2pC5kYFMY5iXM6W2M23jhvcNa8v6AU8PvVJBcysxHwr9/jax0agnwoBumsSwg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "TKDMNRS66UTMEVT38/tU9hA63UTMvzI3DyNm5mx8+JCf3BaOtxgrvWLCI1y3J52PzT5yNl/T2KN5Z0KbApLZcg==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "9.0.1", + "Microsoft.Extensions.FileSystemGlobbing": "9.0.1", + "Microsoft.Extensions.Primitives": "9.0.1" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "Mxcp9NXuQMvAnudRZcgIb5SqlWrlullQzntBLTwuv0MPIJ5LqiGwbRqiyxgdk+vtCoUkplb0oXy5kAw1t469Ug==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "CwSMhLNe8HLkfbFzdz0CHWJhtWH3TtfZSicLBd/itFD+NqQtfGHmvqXHQbaFFl3mQB5PBb2gxwzWQyW2pIj7PA==", + "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.Logging": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "E/k5r7S44DOW+08xQPnNbO8DKAQHhkspDboTThNJ6Z3/QBb4LC6gStNWzVmy3IvW7sUD+iJKf4fj0xEkqE7vnQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1", + "Microsoft.Extensions.Options": "9.0.1" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "w2gUqXN/jNIuvqYwX3lbXagsizVNXYyt6LlF57+tMve4JYCEgCMMAjRce6uKcDASJgpMbErRT1PfHy2OhbkqEA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "System.Diagnostics.DiagnosticSource": "9.0.1" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "MeZePlyu3/74Wk4FHYSzXijADJUhWa7gxtaphLxhS8zEPWdJuBCrPo0sezdCSZaKCL+cZLSLobrb7xt2zHOxZQ==", + "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.Logging.Console": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "YUzguHYlWfp4upfYlpVe3dnY59P25wc+/YLJ9/NQcblT3EvAB1CObQulClll7NtnFbbx4Js0a0UfyS8SbRsWXQ==", + "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.Logging.Debug": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "pzdyibIV8k4sym0Sszcp2MJCuXrpOGs9qfOvY+hCRu8k4HbdVoeKOLnacxHK6vEPITX5o5FjjsZW2zScLXTjYA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", + "Microsoft.Extensions.Logging": "9.0.1", + "Microsoft.Extensions.Logging.Abstractions": "9.0.1" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "+a4RlbwFWjsMujNNhf1Jy9Nm5CpMT+nxXxfgrkRSloPo0OAWhPSPsrFo6VWpvgIPPS41qmfAVWr3DqAmOoVZgQ==", + "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.Logging.EventSource": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "d47ZRZUOg1dGOX+yisWScQ7w4+92OlR9beS2UXaiadUCA3RFoZzobzVgrzBX7Oo/qefx9LxdRcaeFpWKb3BNBw==", + "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.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "8RRKWtuU4fR+8MQLR/8CqZwZ9yc2xCpllw/WPRY7kskIqEq0hMcEI4AfUJO72yGiK2QJkrsDcUvgB5Yc+3+lyg==", + "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.Primitives": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "bHtTesA4lrSGD1ZUaMIx6frU3wyy0vYtTa/hM6gGQu5QNrydObv8T5COiGUWsisflAfmsaFOe9Xvw5NSO99z0g==" + }, "Microsoft.Web.WebView2": { "type": "Transitive", "resolved": "1.0.2651.64", @@ -86,11 +389,29 @@ "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" + } + }, "System.Collections.Immutable": { "type": "Transitive", "resolved": "9.0.0", "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==" }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "yOcDWx4P/s1I83+7gQlgQLmhny2eNcU0cfo1NBWi+en4EAI38Jau+/neT85gUW6w1s7+FUJc2qNOmmwGLIREqA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + }, "System.Drawing.Common": { "type": "Transitive", "resolved": "9.0.0", @@ -112,13 +433,36 @@ "System.Collections.Immutable": "9.0.0" } }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "eqWHDZqYPv1PvuvoIIx5pF74plL3iEOZOl/0kQP+Y0TEbtgNnM2W6k8h8EPYs+LTJZsXuWa92n5W5sHTWvE3VA==", + "dependencies": { + "System.IO.Pipelines": "9.0.1", + "System.Text.Encodings.Web": "9.0.1" + } + }, "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 +493,16 @@ "type": "Transitive", "resolved": "9.0.0", "contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" } }, "net8.0-windows10.0.19041/win-x64": { @@ -171,6 +525,16 @@ "type": "Transitive", "resolved": "9.0.0", "contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" } }, "net8.0-windows10.0.19041/win-x86": { @@ -193,6 +557,16 @@ "type": "Transitive", "resolved": "9.0.0", "contentHash": "z8FfGIaoeALdD+KF44A2uP8PZIQQtDGiXsOLuN8nohbKhkyKt7zGaZb+fKiCxTuBqG22Q7myIAioSWaIcOOrOw==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "iVnDpgYJsRaRFnk77kcLA3+913WfWDtnAKrQl9tQ5ahqKANTaJKmQdsuPWWiAPWE9pk1Kj4Pg9JGXWfFYYyakQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "9.0.1", + "contentHash": "XkspqduP2t1e1x2vBUAD/xZ5ZDvmywuUwsmB93MvyQLospJfqtX0GsR/kU0vUL2h4kmvf777z3txV2W4NrQ9Qg==" } } } 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/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs index 016998d..df2d923 100644 --- a/CoderSdk/CoderApiClient.cs +++ b/CoderSdk/CoderApiClient.cs @@ -2,7 +2,25 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace CoderSdk; +namespace Coder.Desktop.CoderSdk; + +public interface ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl); +} + +public class CoderApiClientFactory : ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl) + { + return new CoderApiClient(baseUrl); + } +} + +public partial interface ICoderApiClient +{ + public void SetSessionToken(string token); +} /// /// Changes names from PascalCase to snake_case. @@ -18,19 +36,26 @@ public override string ConvertName(string name) } [JsonSerializable(typeof(BuildInfo))] +[JsonSerializable(typeof(Response))] [JsonSerializable(typeof(User))] -public partial class CoderSdkJsonContext : JsonSerializerContext -{ -} +[JsonSerializable(typeof(ValidationError))] +public partial class CoderSdkJsonContext : JsonSerializerContext; /// /// Provides a limited selection of API methods for a Coder instance. /// -public partial class CoderApiClient +public partial class CoderApiClient : ICoderApiClient { + public static readonly JsonSerializerOptions JsonOptions = new() + { + TypeInfoResolver = CoderSdkJsonContext.Default, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + // TODO: allow adding headers private readonly HttpClient _httpClient = new(); - private readonly JsonSerializerOptions _jsonOptions; public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) { @@ -41,13 +66,6 @@ public CoderApiClient(Uri baseUrl) 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, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = new SnakeCaseNamingPolicy(), - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; } public CoderApiClient(string baseUrl, string token) : this(baseUrl) @@ -76,22 +94,26 @@ private async Task SendRequestAsync(HttpMethod m if (payload is not null) { - var json = JsonSerializer.Serialize(payload, typeof(TRequest), _jsonOptions); + var json = JsonSerializer.Serialize(payload, typeof(TRequest), JsonOptions); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } 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); + 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($"Coder API Request failed: {method} {path}", e); } } } diff --git a/CoderSdk/Deployment.cs b/CoderSdk/Deployment.cs index d85a458..e95e039 100644 --- a/CoderSdk/Deployment.cs +++ b/CoderSdk/Deployment.cs @@ -1,16 +1,13 @@ -namespace CoderSdk; +namespace Coder.Desktop.CoderSdk; + +public partial interface ICoderApiClient +{ + public Task GetBuildInfo(CancellationToken ct = default); +} 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 diff --git a/CoderSdk/Errors.cs b/CoderSdk/Errors.cs new file mode 100644 index 0000000..4d79a59 --- /dev/null +++ b/CoderSdk/Errors.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Text.Json; + +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; } = []; +} + +public class CoderApiHttpException : Exception +{ + 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, CoderApiClient.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/Users.cs b/CoderSdk/Users.cs index 2d99e02..fd81b32 100644 --- a/CoderSdk/Users.cs +++ b/CoderSdk/Users.cs @@ -1,10 +1,14 @@ -namespace CoderSdk; +namespace Coder.Desktop.CoderSdk; + +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/Installer/Installer.csproj b/Installer/Installer.csproj index 9425a01..b850f6a 100644 --- a/Installer/Installer.csproj +++ b/Installer/Installer.csproj @@ -1,24 +1,24 @@ - - 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..78965e4 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,8 @@ 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)); } } @@ -250,13 +255,22 @@ private static int BuildMsiPackage(MsiOptions opts) programFiles64Folder.AddDir(installDir); project.AddDir(programFiles64Folder); - // Add registry values that are consumed by the manager. + project.AddRegValues( + // Add registry values that are consumed by the manager. Note that these + // should not be changed. See Vpn.Service/Program.cs and + // Vpn.Service/ManagerConfig.cs for more details. new RegValue(RegistryHive, RegistryKey, "Manager:ServiceRpcPipeName", "Coder.Desktop.Vpn"), new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryPath", $"[INSTALLFOLDER]{opts.VpnDir}\\coder-vpn.exe"), new RegValue(RegistryHive, RegistryKey, "Manager:LogFileLocation", - @"[INSTALLFOLDER]coder-desktop-service.log")); + @"[INSTALLFOLDER]coder-desktop-service.log"), + new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinarySignatureSigner", "Coder Technologies Inc."), + new RegValue(RegistryHive, RegistryKey, "Manager:TunnelBinaryAllowVersionMismatch", "false"), + // Add registry values that are consumed by the App MutagenController. See App/Services/MutagenController.cs + new RegValue(RegistryHive, RegistryKey, "AppMutagenController:MutagenExecutablePath", + @"[INSTALLFOLDER]mutagen.exe") + ); // 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 +342,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 +362,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..ecbaf4a --- /dev/null +++ b/MutagenSdk/Proto/filesystem/behavior/probe_mode.proto @@ -0,0 +1,50 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..55cddb1 --- /dev/null +++ b/MutagenSdk/Proto/selection/selection.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..f810b3e --- /dev/null +++ b/MutagenSdk/Proto/service/daemon/daemon.proto @@ -0,0 +1,53 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..19ea8bb --- /dev/null +++ b/MutagenSdk/Proto/service/prompting/prompting.proto @@ -0,0 +1,81 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..1e3d6b2 --- /dev/null +++ b/MutagenSdk/Proto/service/synchronization/synchronization.proto @@ -0,0 +1,169 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..96f972c --- /dev/null +++ b/MutagenSdk/Proto/synchronization/compression/algorithm.proto @@ -0,0 +1,49 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..3ba7fdc --- /dev/null +++ b/MutagenSdk/Proto/synchronization/configuration.proto @@ -0,0 +1,175 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..3779a25 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/change.proto @@ -0,0 +1,49 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..ea46bef --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/conflict.proto @@ -0,0 +1,53 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..3b937a3 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/entry.proto @@ -0,0 +1,110 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..9a347c8 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/ignore/ignore_vcs_mode.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..7db94d9 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/ignore/syntax.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..56fbea9 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/mode.proto @@ -0,0 +1,70 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..e16648f --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/permissions_mode.proto @@ -0,0 +1,51 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..d58dec1 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/problem.proto @@ -0,0 +1,44 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..31bee64 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/core/symbolic_link_mode.proto @@ -0,0 +1,54 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..1cb2fa1 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/hashing/algorithm.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..7d6b3f2 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/rsync/receive.proto @@ -0,0 +1,57 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..de1777f --- /dev/null +++ b/MutagenSdk/Proto/synchronization/scan_mode.proto @@ -0,0 +1,47 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..c23985f --- /dev/null +++ b/MutagenSdk/Proto/synchronization/session.proto @@ -0,0 +1,101 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..247e0a9 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/stage_mode.proto @@ -0,0 +1,51 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..24d7e3f --- /dev/null +++ b/MutagenSdk/Proto/synchronization/state.proto @@ -0,0 +1,160 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..92a8c62 --- /dev/null +++ b/MutagenSdk/Proto/synchronization/version.proto @@ -0,0 +1,44 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..624aa0b --- /dev/null +++ b/MutagenSdk/Proto/synchronization/watch_mode.proto @@ -0,0 +1,54 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..d514f5b --- /dev/null +++ b/MutagenSdk/Proto/url/url.proto @@ -0,0 +1,91 @@ +/* + * This file was taken from + * https://github.com/mutagen-io/mutagen/tree/v0.18.1/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..33e69e6 --- /dev/null +++ b/MutagenSdk/Update-Proto.ps1 @@ -0,0 +1,147 @@ +# Usage: Update-Proto.ps1 -mutagenTag +param ( + [Parameter(Mandatory = $true)] + [string] $mutagenTag +) + +$ErrorActionPreference = "Stop" + +$repo = "mutagen-io/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 $mutagenTag" } + # 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 +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..2fa4699 --- /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; +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..1605f1c --- /dev/null +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -0,0 +1,289 @@ +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 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, + }, + }, ct); + + 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, + }, + }, 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, + }, + }, 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, + }, + }, 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/Tests.App.csproj b/Tests.App/Tests.App.csproj new file mode 100644 index 0000000..cc01512 --- /dev/null +++ b/Tests.App/Tests.App.csproj @@ -0,0 +1,38 @@ + + + + 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..985e331 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 = "Invalid version on binary")] + [CancelAfter(30_000)] + public void InvalidVersion(CancellationToken ct) + { + 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")] + [Test(Description = "Version mismatch with full version check")] [CancelAfter(30_000)] - public void VersionMismatch(CancellationToken ct) + public void VersionMismatchFull(CancellationToken ct) { - // TODO: this + 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); } } 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..7ba4c03 100644 --- a/Tests.Vpn.Service/packages.lock.json +++ b/Tests.Vpn.Service/packages.lock.json @@ -474,6 +474,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/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..a37a1ec 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Formats.Asn1; using System.Net; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Coder.Desktop.Vpn.Utilities; @@ -42,6 +44,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 +67,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 +218,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 +237,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 +250,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); + } } /// diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 93c08dd..1eca8bf 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -1,7 +1,7 @@ using System.Runtime.InteropServices; +using Coder.Desktop.CoderSdk; 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..c7f8863 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] @@ -11,4 +16,9 @@ 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..69b6ea8 100644 --- a/Vpn.Service/Program.cs +++ b/Vpn.Service/Program.cs @@ -8,10 +8,17 @@ 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 ManagerConfigSection = "Manager"; #else + // This value matches Create-Service.ps1. private const string ServiceName = "Coder Desktop (Debug)"; + private const string ManagerConfigSection = "DebugManager"; #endif private const string ConsoleOutputTemplate = @@ -61,7 +68,7 @@ private static async Task BuildAndRun(string[] args) // Options types (these get registered as IOptions singletons) builder.Services.AddOptions() - .Bind(builder.Configuration.GetSection("Manager")) + .Bind(builder.Configuration.GetSection(ManagerConfigSection)) .ValidateDataAnnotations() .PostConfigure(config => { @@ -78,6 +85,7 @@ private static async Task BuildAndRun(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Services if (!Environment.UserInteractive) 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..acaeb3c 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,7 +21,7 @@ - + diff --git a/Vpn.Service/packages.lock.json b/Vpn.Service/packages.lock.json index b2fba99..fb4185a 100644 --- a/Vpn.Service/packages.lock.json +++ b/Vpn.Service/packages.lock.json @@ -416,6 +416,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..c540809 --- /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.1" +$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