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.VpnCoder.Desktop.Tests.Vpn
- net8.0
+ net8.0-windowsenableenabletrue
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.DebugClientCoder.Desktop.Vpn.DebugClientExe
- net8.0
+ net8.0-windowsenableenabletrue
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 @@
enableenabletrue
+ 13CoderVpnServicecoder.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.VpnCoder.Desktop.Vpn
- net8.0
+ net8.0-windowsenableenabletrue
@@ -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