diff --git a/.idea/.idea.Coder.Desktop/.idea/codeStyles/Project.xml b/.idea/.idea.Coder.Desktop/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..d394fd8
--- /dev/null
+++ b/.idea/.idea.Coder.Desktop/.idea/codeStyles/Project.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Coder.Desktop/.idea/codeStyles/codeStyleConfig.xml b/.idea/.idea.Coder.Desktop/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..79ee123
--- /dev/null
+++ b/.idea/.idea.Coder.Desktop/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/App/App.csproj b/App/App.csproj
index cae1812..2adf3f7 100644
--- a/App/App.csproj
+++ b/App/App.csproj
@@ -12,6 +12,8 @@
enabletruetrue
+
+ preview
@@ -37,22 +39,11 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
-
-
- MSBuild:Compile
-
-
-
-
-
- MSBuild:Compile
-
-
-
-
- FileSystem
- ARM64
- win-arm64
- bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
- true
- False
-
-
\ No newline at end of file
+
+ FileSystem
+ ARM64
+ win-arm64
+ bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
+ true
+ False
+
+
diff --git a/App/Properties/PublishProfiles/win-x64.pubxml b/App/Properties/PublishProfiles/win-x64.pubxml
index cd99561..d6e3ca5 100644
--- a/App/Properties/PublishProfiles/win-x64.pubxml
+++ b/App/Properties/PublishProfiles/win-x64.pubxml
@@ -3,12 +3,12 @@
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
-
- FileSystem
- x64
- win-x64
- bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
- true
- False
-
-
\ No newline at end of file
+
+ FileSystem
+ x64
+ win-x64
+ bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
+ true
+ False
+
+
diff --git a/App/Properties/PublishProfiles/win-x86.pubxml b/App/Properties/PublishProfiles/win-x86.pubxml
index a70c694..084c7fe 100644
--- a/App/Properties/PublishProfiles/win-x86.pubxml
+++ b/App/Properties/PublishProfiles/win-x86.pubxml
@@ -3,12 +3,12 @@
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
-
- FileSystem
- x86
- win-x86
- bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
- true
- False
-
-
\ No newline at end of file
+
+ FileSystem
+ x86
+ win-x86
+ bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\
+ true
+ False
+
+
diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs
new file mode 100644
index 0000000..ad2f366
--- /dev/null
+++ b/App/Services/CredentialManager.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.Vpn.Utilities;
+using CoderSdk;
+
+namespace Coder.Desktop.App.Services;
+
+public interface ICredentialManager
+{
+ public event EventHandler CredentialsChanged;
+
+ public CredentialModel GetCredentials();
+
+ public Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default);
+
+ public void ClearCredentials();
+}
+
+public class CredentialManager : ICredentialManager
+{
+ private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";
+
+ private readonly RaiiSemaphoreSlim _lock = new(1, 1);
+ private CredentialModel? _latestCredentials;
+
+ public event EventHandler? CredentialsChanged;
+
+ public CredentialModel GetCredentials()
+ {
+ using var _ = _lock.Lock();
+ if (_latestCredentials != null) return _latestCredentials.Clone();
+
+ 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();
+ }
+
+ public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default)
+ {
+ 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 (!Uri.TryCreate(coderUrl, UriKind.Absolute, out var uri))
+ throw new ArgumentException($"Coder URL '{coderUrl}' is not a valid URL", 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 sdkClient = new CoderApiClient(uri);
+ // TODO: we should probably perform a version check here too,
+ // rather than letting the service do it on Start
+ _ = await sdkClient.GetBuildInfo(ct);
+ _ = await sdkClient.GetUser(User.Me, ct);
+ }
+ catch (Exception e)
+ {
+ throw new InvalidOperationException("Could not connect to or verify Coder server", e);
+ }
+
+ WriteCredentials(new RawCredentials
+ {
+ CoderUrl = coderUrl,
+ ApiToken = apiToken,
+ });
+
+ UpdateState(new CredentialModel
+ {
+ State = CredentialState.Valid,
+ CoderUrl = coderUrl,
+ ApiToken = apiToken,
+ });
+ }
+
+ public void ClearCredentials()
+ {
+ NativeApi.DeleteCredentials(CredentialsTargetName);
+ UpdateState(new CredentialModel
+ {
+ State = CredentialState.Invalid,
+ CoderUrl = null,
+ ApiToken = null,
+ });
+ }
+
+ private void UpdateState(CredentialModel newModel)
+ {
+ using (_lock.Lock())
+ {
+ _latestCredentials = newModel.Clone();
+ }
+
+ CredentialsChanged?.Invoke(this, newModel.Clone());
+ }
+
+ private static RawCredentials? ReadCredentials()
+ {
+ var raw = NativeApi.ReadCredentials(CredentialsTargetName);
+ if (raw == null) return null;
+
+ RawCredentials? credentials;
+ try
+ {
+ credentials = JsonSerializer.Deserialize(raw);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+
+ if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
+ string.IsNullOrWhiteSpace(credentials.ApiToken)) return null;
+
+ return credentials;
+ }
+
+ private static void WriteCredentials(RawCredentials credentials)
+ {
+ var raw = JsonSerializer.Serialize(credentials);
+ NativeApi.WriteCredentials(CredentialsTargetName, raw);
+ }
+
+ private class RawCredentials
+ {
+ public required string CoderUrl { get; set; }
+ public required string ApiToken { get; set; }
+ }
+
+ private static class NativeApi
+ {
+ private const int CredentialTypeGeneric = 1;
+ private const int PersistenceTypeLocalComputer = 2;
+ private const int ErrorNotFound = 1168;
+ private const int CredMaxCredentialBlobSize = 5 * 512;
+
+ public static string? ReadCredentials(string targetName)
+ {
+ if (!CredReadW(targetName, CredentialTypeGeneric, 0, out var credentialPtr))
+ {
+ var error = Marshal.GetLastWin32Error();
+ if (error == ErrorNotFound) return null;
+ throw new InvalidOperationException($"Failed to read credentials (Error {error})");
+ }
+
+ try
+ {
+ var cred = Marshal.PtrToStructure(credentialPtr);
+ return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / sizeof(char));
+ }
+ finally
+ {
+ CredFree(credentialPtr);
+ }
+ }
+
+ public static void WriteCredentials(string targetName, string secret)
+ {
+ var byteCount = Encoding.Unicode.GetByteCount(secret);
+ if (byteCount > CredMaxCredentialBlobSize)
+ throw new ArgumentOutOfRangeException(nameof(secret),
+ $"The secret is greater than {CredMaxCredentialBlobSize} bytes");
+
+ var credentialBlob = Marshal.StringToHGlobalUni(secret);
+ var cred = new CREDENTIAL
+ {
+ Type = CredentialTypeGeneric,
+ TargetName = targetName,
+ CredentialBlobSize = byteCount,
+ CredentialBlob = credentialBlob,
+ Persist = PersistenceTypeLocalComputer,
+ };
+ try
+ {
+ if (!CredWriteW(ref cred, 0))
+ {
+ var error = Marshal.GetLastWin32Error();
+ throw new InvalidOperationException($"Failed to write credentials (Error {error})");
+ }
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(credentialBlob);
+ }
+ }
+
+ public static void DeleteCredentials(string targetName)
+ {
+ if (!CredDeleteW(targetName, CredentialTypeGeneric, 0))
+ {
+ var error = Marshal.GetLastWin32Error();
+ if (error == ErrorNotFound) return;
+ throw new InvalidOperationException($"Failed to delete credentials (Error {error})");
+ }
+ }
+
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredReadW(string target, int type, int reservedFlag, out IntPtr credentialPtr);
+
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredWriteW([In] ref CREDENTIAL userCredential, [In] uint flags);
+
+ [DllImport("Advapi32.dll", SetLastError = true)]
+ private static extern void CredFree([In] IntPtr cred);
+
+ [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern bool CredDeleteW(string target, int type, int flags);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CREDENTIAL
+ {
+ public int Flags;
+ public int Type;
+
+ [MarshalAs(UnmanagedType.LPWStr)]
+ public string TargetName;
+
+ [MarshalAs(UnmanagedType.LPWStr)]
+ public string Comment;
+
+ public long LastWritten;
+ public int CredentialBlobSize;
+ public IntPtr CredentialBlob;
+ public int Persist;
+ public int AttributeCount;
+ public IntPtr Attributes;
+
+ [MarshalAs(UnmanagedType.LPWStr)]
+ public string TargetAlias;
+
+ [MarshalAs(UnmanagedType.LPWStr)]
+ public string UserName;
+ }
+ }
+}
diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs
new file mode 100644
index 0000000..70ae8f3
--- /dev/null
+++ b/App/Services/RpcController.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Diagnostics;
+using System.IO.Pipes;
+using System.Threading;
+using System.Threading.Tasks;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.Vpn;
+using Coder.Desktop.Vpn.Proto;
+using Coder.Desktop.Vpn.Utilities;
+
+namespace Coder.Desktop.App.Services;
+
+public class RpcOperationException : Exception
+{
+ public RpcOperationException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+
+ public RpcOperationException(string message) : base(message)
+ {
+ }
+}
+
+public class VpnLifecycleException : Exception
+{
+ public VpnLifecycleException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+
+ public VpnLifecycleException(string message) : base(message)
+ {
+ }
+}
+
+public interface IRpcController
+{
+ public event EventHandler StateChanged;
+
+ ///
+ /// Get the current state of the RpcController and the latest state received from the service.
+ ///
+ public RpcModel GetState();
+
+ ///
+ /// Disconnect from and reconnect to the RPC server.
+ ///
+ /// Another operation is in progress
+ /// Throws an exception if reconnection fails. Exceptions from disconnection are ignored.
+ public Task Reconnect(CancellationToken ct = default);
+
+ ///
+ /// Start the VPN using the stored credentials in the ICredentialManager. If the VPN is already running, this
+ /// may have no effect.
+ ///
+ /// Another operation is in progress
+ /// If the sending of the start command fails
+ /// If the service reports that the VPN failed to start
+ public Task StartVpn(CancellationToken ct = default);
+
+ ///
+ /// Stop the VPN. If the VPN is already not running, this may have no effect.
+ ///
+ /// Another operation is in progress
+ /// If the sending of the stop command fails
+ /// If the service reports that the VPN failed to stop
+ public Task StopVpn(CancellationToken ct = default);
+}
+
+public class RpcController : IRpcController
+{
+ private readonly ICredentialManager _credentialManager;
+
+ private readonly RaiiSemaphoreSlim _operationLock = new(1, 1);
+ private Speaker? _speaker;
+
+ private readonly RaiiSemaphoreSlim _stateLock = new(1, 1);
+ private readonly RpcModel _state = new();
+
+ public RpcController(ICredentialManager credentialManager)
+ {
+ _credentialManager = credentialManager;
+ }
+
+ public event EventHandler? StateChanged;
+
+ public RpcModel GetState()
+ {
+ using var _ = _stateLock.Lock();
+ return _state.Clone();
+ }
+
+ public async Task Reconnect(CancellationToken ct = default)
+ {
+ using var _ = await AcquireOperationLockNowAsync();
+ MutateState(state =>
+ {
+ state.RpcLifecycle = RpcLifecycle.Connecting;
+ state.VpnLifecycle = VpnLifecycle.Stopped;
+ state.Agents.Clear();
+ });
+
+ if (_speaker != null)
+ try
+ {
+ await DisposeSpeaker();
+ }
+ catch (Exception e)
+ {
+ // TODO: log/notify?
+ Debug.WriteLine($"Error disposing existing Speaker: {e}");
+ }
+
+ try
+ {
+ var client =
+ new NamedPipeClientStream(".", "Coder.Desktop.Vpn", PipeDirection.InOut, PipeOptions.Asynchronous);
+ await client.ConnectAsync(ct);
+ _speaker = new Speaker(client);
+ _speaker.Receive += SpeakerOnReceive;
+ _speaker.Error += SpeakerOnError;
+ await _speaker.StartAsync(ct);
+ }
+ catch (Exception e)
+ {
+ MutateState(state =>
+ {
+ state.RpcLifecycle = RpcLifecycle.Disconnected;
+ state.VpnLifecycle = VpnLifecycle.Stopped;
+ state.Agents.Clear();
+ });
+ throw new RpcOperationException("Failed to reconnect to the RPC server", e);
+ }
+
+ MutateState(state =>
+ {
+ state.RpcLifecycle = RpcLifecycle.Connected;
+ // TODO: fetch current state
+ state.VpnLifecycle = VpnLifecycle.Stopped;
+ state.Agents.Clear();
+ });
+ }
+
+ public async Task StartVpn(CancellationToken ct = default)
+ {
+ using var _ = await AcquireOperationLockNowAsync();
+ AssertRpcConnected();
+
+ var credentials = _credentialManager.GetCredentials();
+ if (credentials.State != CredentialState.Valid)
+ throw new RpcOperationException("Cannot start VPN without valid credentials");
+
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Starting; });
+
+ ServiceMessage reply;
+ try
+ {
+ reply = await _speaker!.SendRequestAwaitReply(new ClientMessage
+ {
+ Start = new StartRequest
+ {
+ CoderUrl = credentials.CoderUrl,
+ ApiToken = credentials.ApiToken,
+ },
+ }, ct);
+ if (reply.MsgCase != ServiceMessage.MsgOneofCase.Start)
+ throw new InvalidOperationException($"Unexpected reply message type: {reply.MsgCase}");
+ }
+ catch (Exception e)
+ {
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
+ throw new RpcOperationException("Failed to send start command to service", e);
+ }
+
+ if (!reply.Start.Success)
+ {
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
+ throw new VpnLifecycleException("Failed to start VPN",
+ new InvalidOperationException($"Service reported failure: {reply.Start.ErrorMessage}"));
+ }
+
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Started; });
+ }
+
+ public async Task StopVpn(CancellationToken ct = default)
+ {
+ using var _ = await AcquireOperationLockNowAsync();
+ AssertRpcConnected();
+
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopping; });
+
+ ServiceMessage reply;
+ try
+ {
+ reply = await _speaker!.SendRequestAwaitReply(new ClientMessage
+ {
+ Stop = new StopRequest(),
+ }, ct);
+ }
+ catch (Exception e)
+ {
+ throw new RpcOperationException("Failed to send stop command to service", e);
+ }
+ finally
+ {
+ // Technically the state is unknown now.
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
+ }
+
+ if (reply.MsgCase != ServiceMessage.MsgOneofCase.Stop)
+ throw new VpnLifecycleException("Failed to stop VPN",
+ new InvalidOperationException($"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}"));
+ }
+
+ private void MutateState(Action mutator)
+ {
+ RpcModel newState;
+ using (_stateLock.Lock())
+ {
+ mutator(_state);
+ newState = _state.Clone();
+ }
+
+ StateChanged?.Invoke(this, newState);
+ }
+
+ private async Task AcquireOperationLockNowAsync()
+ {
+ var locker = await _operationLock.LockAsync(TimeSpan.Zero);
+ if (locker == null)
+ throw new InvalidOperationException("Cannot perform operation while another operation is in progress");
+ return locker;
+ }
+
+ private void SpeakerOnReceive(ReplyableRpcMessage message)
+ {
+ // TODO: this
+ }
+
+ private async Task DisposeSpeaker()
+ {
+ if (_speaker == null) return;
+ _speaker.Receive -= SpeakerOnReceive;
+ _speaker.Error -= SpeakerOnError;
+ await _speaker.DisposeAsync();
+ _speaker = null;
+ }
+
+ private void SpeakerOnError(Exception e)
+ {
+ Debug.WriteLine($"Error: {e}");
+ Reconnect(CancellationToken.None).Wait();
+ }
+
+ private void AssertRpcConnected()
+ {
+ if (_speaker == null)
+ throw new InvalidOperationException("Not connected to the RPC server");
+ }
+}
diff --git a/App/TrayWindow.xaml b/App/TrayWindow.xaml
deleted file mode 100644
index a779bb5..0000000
--- a/App/TrayWindow.xaml
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs
new file mode 100644
index 0000000..c084d31
--- /dev/null
+++ b/App/ViewModels/AgentViewModel.cs
@@ -0,0 +1,51 @@
+using Windows.ApplicationModel.DataTransfer;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public enum AgentConnectionStatus
+{
+ Green,
+ Red,
+ Gray,
+}
+
+public partial class AgentViewModel
+{
+ public required string Hostname { get; set; }
+
+ public required string HostnameSuffix { get; set; } // including leading dot
+
+ public required AgentConnectionStatus ConnectionStatus { get; set; }
+
+ public string FullHostname => Hostname + HostnameSuffix;
+
+ public required string DashboardUrl { get; set; }
+
+ [RelayCommand]
+ private void CopyHostname(object parameter)
+ {
+ var dataPackage = new DataPackage
+ {
+ RequestedOperation = DataPackageOperation.Copy,
+ };
+ dataPackage.SetText(FullHostname);
+ Clipboard.SetContent(dataPackage);
+
+ if (parameter is not FrameworkElement frameworkElement) return;
+
+ var flyout = new Flyout
+ {
+ Content = new TextBlock
+ {
+ Text = "DNS Copied",
+ Margin = new Thickness(4),
+ },
+ };
+ FlyoutBase.SetAttachedFlyout(frameworkElement, flyout);
+ FlyoutBase.ShowAttachedFlyout(frameworkElement);
+ }
+}
diff --git a/App/ViewModels/TrayWindowDisconnectedViewModel.cs b/App/ViewModels/TrayWindowDisconnectedViewModel.cs
new file mode 100644
index 0000000..6720d50
--- /dev/null
+++ b/App/ViewModels/TrayWindowDisconnectedViewModel.cs
@@ -0,0 +1,32 @@
+using System.Threading.Tasks;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.App.Services;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public partial class TrayWindowDisconnectedViewModel : ObservableObject
+{
+ private readonly IRpcController _rpcController;
+
+ [ObservableProperty]
+ public partial bool ReconnectButtonEnabled { get; set; } = true;
+
+ public TrayWindowDisconnectedViewModel(IRpcController rpcController)
+ {
+ _rpcController = rpcController;
+ _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel);
+ }
+
+ private void UpdateFromRpcModel(RpcModel rpcModel)
+ {
+ ReconnectButtonEnabled = rpcModel.RpcLifecycle != RpcLifecycle.Disconnected;
+ }
+
+ [RelayCommand]
+ public async Task Reconnect()
+ {
+ await _rpcController.Reconnect();
+ }
+}
diff --git a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
new file mode 100644
index 0000000..c63b8f4
--- /dev/null
+++ b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
@@ -0,0 +1,13 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace Coder.Desktop.App.ViewModels;
+
+public partial class TrayWindowLoginRequiredViewModel : ObservableObject
+{
+ [RelayCommand]
+ public void Login()
+ {
+ // TODO: open the login window
+ }
+}
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
new file mode 100644
index 0000000..a32f24d
--- /dev/null
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -0,0 +1,168 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.App.Services;
+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 TrayWindowViewModel : ObservableObject
+{
+ private const int MaxAgents = 5;
+
+ private readonly IRpcController _rpcController;
+ private readonly ICredentialManager _credentialManager;
+
+ [ObservableProperty]
+ public partial VpnLifecycle VpnLifecycle { get; set; } =
+ VpnLifecycle.Stopping; // to prevent interaction until we get the real state
+
+ // VpnSwitchOn needs to be its own property as it is a two-way binding
+ [ObservableProperty]
+ public partial bool VpnSwitchOn { get; set; } = false;
+
+ [ObservableProperty]
+ public partial string? VpnFailedMessage { get; set; } = null;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(NoAgents))]
+ [NotifyPropertyChangedFor(nameof(AgentOverflow))]
+ [NotifyPropertyChangedFor(nameof(VisibleAgents))]
+ public partial ObservableCollection Agents { get; set; } = [];
+
+ public bool NoAgents => Agents.Count == 0;
+
+ public bool AgentOverflow => Agents.Count > MaxAgents;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(VisibleAgents))]
+ public partial bool ShowAllAgents { get; set; } = false;
+
+ public IEnumerable VisibleAgents => ShowAllAgents ? Agents : Agents.Take(MaxAgents);
+
+ [ObservableProperty]
+ public partial string DashboardUrl { get; set; } = "https://coder.com";
+
+ public TrayWindowViewModel(IRpcController rpcController, ICredentialManager credentialManager)
+ {
+ _rpcController = rpcController;
+ _credentialManager = credentialManager;
+
+ _rpcController.StateChanged += (_, rpcModel) => UpdateFromRpcModel(rpcModel);
+ UpdateFromRpcModel(_rpcController.GetState());
+
+ _credentialManager.CredentialsChanged += (_, credentialModel) => UpdateFromCredentialsModel(credentialModel);
+ UpdateFromCredentialsModel(_credentialManager.GetCredentials());
+ }
+
+ private void UpdateFromRpcModel(RpcModel rpcModel)
+ {
+ // As a failsafe, if RPC is disconnected we disable the switch. The
+ // Window should not show the current Page if the RPC is disconnected.
+ if (rpcModel.RpcLifecycle is RpcLifecycle.Disconnected)
+ {
+ VpnLifecycle = VpnLifecycle.Stopping;
+ VpnSwitchOn = false;
+ Agents = [];
+ return;
+ }
+
+ VpnLifecycle = rpcModel.VpnLifecycle;
+ VpnSwitchOn = rpcModel.VpnLifecycle is VpnLifecycle.Starting or VpnLifecycle.Started;
+ // TODO: convert from RpcModel once we send agent data
+ Agents =
+ [
+ new AgentViewModel
+ {
+ Hostname = "pog",
+ HostnameSuffix = ".coder",
+ ConnectionStatus = AgentConnectionStatus.Green,
+ DashboardUrl = "https://dev.coder.com/@dean/pog",
+ },
+ new AgentViewModel
+ {
+ Hostname = "pog2",
+ HostnameSuffix = ".coder",
+ ConnectionStatus = AgentConnectionStatus.Gray,
+ DashboardUrl = "https://dev.coder.com/@dean/pog2",
+ },
+ new AgentViewModel
+ {
+ Hostname = "pog3",
+ HostnameSuffix = ".coder",
+ ConnectionStatus = AgentConnectionStatus.Red,
+ DashboardUrl = "https://dev.coder.com/@dean/pog3",
+ },
+ new AgentViewModel
+ {
+ Hostname = "pog4",
+ HostnameSuffix = ".coder",
+ ConnectionStatus = AgentConnectionStatus.Red,
+ DashboardUrl = "https://dev.coder.com/@dean/pog4",
+ },
+ new AgentViewModel
+ {
+ Hostname = "pog5",
+ HostnameSuffix = ".coder",
+ ConnectionStatus = AgentConnectionStatus.Red,
+ DashboardUrl = "https://dev.coder.com/@dean/pog5",
+ },
+ new AgentViewModel
+ {
+ Hostname = "pog6",
+ HostnameSuffix = ".coder",
+ ConnectionStatus = AgentConnectionStatus.Red,
+ DashboardUrl = "https://dev.coder.com/@dean/pog6",
+ },
+ ];
+
+ if (Agents.Count < MaxAgents) ShowAllAgents = false;
+ }
+
+ private void UpdateFromCredentialsModel(CredentialModel credentialModel)
+ {
+ // HACK: the HyperlinkButton crashes the whole app if the initial URI
+ // or this URI is invalid. CredentialModel.CoderUrl should never be
+ // null while the Page is active as the Page is only displayed when
+ // CredentialModel.Status == Valid.
+ DashboardUrl = credentialModel.CoderUrl ?? "https://coder.com";
+ }
+
+ // VpnSwitch_Toggled is handled separately than just listening to the
+ // property change as we need to be able to tell the difference between the
+ // user toggling the switch and the switch being toggled by code.
+ public void VpnSwitch_Toggled(object sender, RoutedEventArgs e)
+ {
+ if (sender is not ToggleSwitch toggleSwitch) return;
+
+ VpnFailedMessage = "";
+ try
+ {
+ if (toggleSwitch.IsOn)
+ _rpcController.StartVpn();
+ else
+ _rpcController.StopVpn();
+ }
+ catch
+ {
+ VpnFailedMessage = e.ToString();
+ }
+ }
+
+ [RelayCommand]
+ public void ToggleShowAllAgents()
+ {
+ ShowAllAgents = !ShowAllAgents;
+ }
+
+ [RelayCommand]
+ public void SignOut()
+ {
+ // TODO: this should either be blocked until the VPN is stopped or it should stop the VPN
+ _credentialManager.ClearCredentials();
+ }
+}
diff --git a/App/Views/Pages/TrayWindowDisconnectedPage.xaml b/App/Views/Pages/TrayWindowDisconnectedPage.xaml
new file mode 100644
index 0000000..cf9e172
--- /dev/null
+++ b/App/Views/Pages/TrayWindowDisconnectedPage.xaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/TrayWindowDisconnectedPage.xaml.cs b/App/Views/Pages/TrayWindowDisconnectedPage.xaml.cs
new file mode 100644
index 0000000..feafbc6
--- /dev/null
+++ b/App/Views/Pages/TrayWindowDisconnectedPage.xaml.cs
@@ -0,0 +1,15 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class TrayWindowDisconnectedPage : Page
+{
+ public TrayWindowDisconnectedViewModel ViewModel { get; }
+
+ public TrayWindowDisconnectedPage(TrayWindowDisconnectedViewModel viewModel)
+ {
+ InitializeComponent();
+ ViewModel = viewModel;
+ }
+}
diff --git a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
new file mode 100644
index 0000000..62db4d7
--- /dev/null
+++ b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml.cs b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml.cs
new file mode 100644
index 0000000..38698ea
--- /dev/null
+++ b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml.cs
@@ -0,0 +1,15 @@
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class TrayWindowLoginRequiredPage : Page
+{
+ public TrayWindowLoginRequiredViewModel ViewModel { get; }
+
+ public TrayWindowLoginRequiredPage(TrayWindowLoginRequiredViewModel viewModel)
+ {
+ InitializeComponent();
+ ViewModel = viewModel;
+ }
+}
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml
new file mode 100644
index 0000000..e278123
--- /dev/null
+++ b/App/Views/Pages/TrayWindowMainPage.xaml
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml.cs b/App/Views/Pages/TrayWindowMainPage.xaml.cs
new file mode 100644
index 0000000..913de6b
--- /dev/null
+++ b/App/Views/Pages/TrayWindowMainPage.xaml.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using Coder.Desktop.App.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Documents;
+
+namespace Coder.Desktop.App.Views.Pages;
+
+public sealed partial class TrayWindowMainPage : Page
+{
+ public TrayWindowViewModel ViewModel { get; }
+
+ public TrayWindowMainPage(TrayWindowViewModel viewModel)
+ {
+ InitializeComponent();
+ ViewModel = viewModel;
+ }
+
+ // HACK: using XAML to populate the text Runs results in an additional
+ // whitespace Run being inserted between the Hostname and the
+ // HostnameSuffix. You might think, "OK let's populate the entire TextBlock
+ // content from code then!", but this results in the ItemsRepeater
+ // corrupting it and firing events off to the wrong AgentModel.
+ //
+ // This is the best solution I came up with that worked.
+ public void AgentHostnameText_OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not TextBlock textBlock) return;
+
+ var nonEmptyRuns = new List();
+ foreach (var inline in textBlock.Inlines)
+ if (inline is Run run && !string.IsNullOrWhiteSpace(run.Text))
+ nonEmptyRuns.Add(run);
+
+ textBlock.Inlines.Clear();
+ foreach (var run in nonEmptyRuns) textBlock.Inlines.Add(run);
+ }
+}
diff --git a/App/Views/TrayWindow.xaml b/App/Views/TrayWindow.xaml
new file mode 100644
index 0000000..c9aa24b
--- /dev/null
+++ b/App/Views/TrayWindow.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs
similarity index 61%
rename from App/TrayWindow.xaml.cs
rename to App/Views/TrayWindow.xaml.cs
index fcd1c0e..0a1744d 100644
--- a/App/TrayWindow.xaml.cs
+++ b/App/Views/TrayWindow.xaml.cs
@@ -1,109 +1,24 @@
using System;
-using System.Collections.ObjectModel;
-using System.Diagnostics;
using System.Runtime.InteropServices;
-using Windows.ApplicationModel.DataTransfer;
+using System.Threading;
using Windows.Foundation;
using Windows.Graphics;
using Windows.System;
-using Windows.UI;
using Windows.UI.Core;
+using Coder.Desktop.App.Models;
+using Coder.Desktop.App.Services;
+using Coder.Desktop.App.Views.Pages;
using CommunityToolkit.Mvvm.Input;
using Microsoft.UI;
using Microsoft.UI.Input;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Controls.Primitives;
-using Microsoft.UI.Xaml.Documents;
using Microsoft.UI.Xaml.Media;
using WinRT.Interop;
using WindowActivatedEventArgs = Microsoft.UI.Xaml.WindowActivatedEventArgs;
-namespace Coder.Desktop.App;
-
-public enum AgentStatus
-{
- Green,
- Red,
- Gray,
-}
-
-public partial class Agent
-{
- public required string Hostname { get; set; } // without suffix
- public required string Suffix { get; set; }
- public AgentStatus Status { get; set; }
-
- public Brush StatusColor => Status switch
- {
- AgentStatus.Green => new SolidColorBrush(Color.FromArgb(255, 52, 199, 89)),
- AgentStatus.Red => new SolidColorBrush(Color.FromArgb(255, 255, 59, 48)),
- _ => new SolidColorBrush(Color.FromArgb(255, 142, 142, 147)),
- };
-
- [RelayCommand]
- private void AgentHostnameButton_Click()
- {
- try
- {
- Process.Start(new ProcessStartInfo
- {
- // TODO: this should probably be more robust instead of just joining strings
- FileName = "http://" + Hostname + Suffix,
- UseShellExecute = true,
- });
- }
- catch
- {
- // TODO: log (notify?)
- }
- }
-
- [RelayCommand]
- private void AgentHostnameCopyButton_Click(object parameter)
- {
- var dataPackage = new DataPackage
- {
- RequestedOperation = DataPackageOperation.Copy,
- };
- dataPackage.SetText(Hostname + Suffix);
- Clipboard.SetContent(dataPackage);
-
- if (parameter is not FrameworkElement frameworkElement) return;
-
- var flyout = new Flyout
- {
- Content = new TextBlock
- {
- Text = "DNS Copied",
- Margin = new Thickness(4),
- },
- };
- FlyoutBase.SetAttachedFlyout(frameworkElement, flyout);
- FlyoutBase.ShowAttachedFlyout(frameworkElement);
- }
-
- public void AgentHostnameText_OnLoaded(object sender, RoutedEventArgs e)
- {
- if (sender is not TextBlock textBlock) return;
- textBlock.Inlines.Clear();
- textBlock.Inlines.Add(new Run
- {
- Text = Hostname,
- Foreground =
- (SolidColorBrush)Application.Current.Resources.ThemeDictionaries[
- "DefaultTextForegroundThemeBrush"],
- });
- textBlock.Inlines.Add(new Run
- {
- Text = Suffix,
- Foreground =
- (SolidColorBrush)Application.Current.Resources.ThemeDictionaries[
- "SystemControlForegroundBaseMediumBrush"],
- });
- }
-}
+namespace Coder.Desktop.App.Views;
public sealed partial class TrayWindow : Window
{
@@ -111,50 +26,37 @@ public sealed partial class TrayWindow : Window
private NativeApi.POINT? _lastActivatePosition;
- public ObservableCollection Agents =
- [
- new()
- {
- Hostname = "coder2",
- Suffix = ".coder",
- Status = AgentStatus.Green,
- },
- new()
- {
- Hostname = "coder3",
- Suffix = ".coder",
- Status = AgentStatus.Red,
- },
- new()
- {
- Hostname = "coder4",
- Suffix = ".coder",
- Status = AgentStatus.Gray,
- },
- new()
- {
- Hostname = "superlongworkspacenamewhyisitsolong",
- Suffix = ".coder",
- Status = AgentStatus.Gray,
- },
- ];
+ private readonly IRpcController _rpcController;
+ private readonly ICredentialManager _credentialManager;
+ private readonly TrayWindowDisconnectedPage _disconnectedPage;
+ private readonly TrayWindowLoginRequiredPage _loginRequiredPage;
+ private readonly TrayWindowMainPage _mainPage;
- public TrayWindow()
+ public TrayWindow(IRpcController rpcController, ICredentialManager credentialManager,
+ TrayWindowDisconnectedPage disconnectedPage, TrayWindowLoginRequiredPage loginRequiredPage,
+ TrayWindowMainPage mainPage)
{
+ _rpcController = rpcController;
+ _credentialManager = credentialManager;
+ _disconnectedPage = disconnectedPage;
+ _loginRequiredPage = loginRequiredPage;
+ _mainPage = mainPage;
+
InitializeComponent();
AppWindow.Hide();
SystemBackdrop = new DesktopAcrylicBackdrop();
Activated += Window_Activated;
+ rpcController.StateChanged += RpcController_StateChanged;
+ credentialManager.CredentialsChanged += CredentialManager_CredentialsChanged;
+ SetPageByState(rpcController.GetState(), credentialManager.GetCredentials());
+
+ _rpcController.Reconnect(CancellationToken.None);
+
// Setting OpenCommand and ExitCommand directly in the .xaml doesn't seem to work for whatever reason.
TrayIcon.OpenCommand = Tray_OpenCommand;
TrayIcon.ExitCommand = Tray_ExitCommand;
- if (Content is FrameworkElement frameworkElement)
- frameworkElement.SizeChanged += Content_SizeChanged;
- else
- throw new Exception("Failed to get Content as FrameworkElement for window");
-
// Hide the title bar and buttons. WinUi 3 provides a method to do this with
// `ExtendsContentIntoTitleBar = true;`, but it automatically adds emulated title bar buttons that cannot be
// removed.
@@ -174,6 +76,55 @@ public TrayWindow()
if (result != 0) throw new Exception("Failed to set window corner preference");
}
+ private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel)
+ {
+ switch (rpcModel.RpcLifecycle)
+ {
+ case RpcLifecycle.Connected:
+ if (credentialModel.State == CredentialState.Valid)
+ SetRootFrame(_mainPage);
+ else
+ SetRootFrame(_loginRequiredPage);
+ break;
+ case RpcLifecycle.Disconnected:
+ case RpcLifecycle.Connecting:
+ default:
+ SetRootFrame(_disconnectedPage);
+ break;
+ }
+ }
+
+ private void RpcController_StateChanged(object? _, RpcModel model)
+ {
+ SetPageByState(model, _credentialManager.GetCredentials());
+ }
+
+ private void CredentialManager_CredentialsChanged(object? _, CredentialModel model)
+ {
+ SetPageByState(_rpcController.GetState(), model);
+ }
+
+ // Sadly this is necessary because Window.Content.SizeChanged doesn't
+ // trigger when the Page's content changes.
+ public void SetRootFrame(Page page)
+ {
+ 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();
+ }
+
private void Content_SizeChanged(object sender, SizeChangedEventArgs e)
{
ResizeWindow();
@@ -182,16 +133,15 @@ private void Content_SizeChanged(object sender, SizeChangedEventArgs e)
private void ResizeWindow()
{
- if (Content is not FrameworkElement content)
+ 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
- content.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
- var desiredSize = content.DesiredSize;
+ frameworkElement.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
// Adjust the AppWindow size
var scale = GetDisplayScale();
- var height = (int)(desiredSize.Height * scale);
+ var height = (int)(frameworkElement.ActualHeight * scale);
var width = (int)(WIDTH * scale);
AppWindow.Resize(new SizeInt32(width, height));
}
@@ -280,16 +230,6 @@ private void Window_Activated(object sender, WindowActivatedEventArgs e)
AppWindow.Hide();
}
- private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
- {
- Agents.Add(new Agent
- {
- Hostname = "cool",
- Suffix = ".coder",
- Status = AgentStatus.Gray,
- });
- }
-
[RelayCommand]
private void Tray_Open()
{
diff --git a/App/app.manifest b/App/app.manifest
index 28bdcff..f90a589 100644
--- a/App/app.manifest
+++ b/App/app.manifest
@@ -1,13 +1,13 @@
-
+
-
+
diff --git a/App/packages.lock.json b/App/packages.lock.json
index 14115ab..66a2a84 100644
--- a/App/packages.lock.json
+++ b/App/packages.lock.json
@@ -26,6 +26,15 @@
"System.Reflection.Metadata": "9.0.0"
}
},
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Direct",
+ "requested": "[9.0.1, )",
+ "resolved": "9.0.1",
+ "contentHash": "qZI42ASAe3hr2zMSA6UjM92pO1LeDq5DcwkgSowXXPY8I56M76pEKrnmsKKbxagAf39AJxkH2DY4sb72ixyOrg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1"
+ }
+ },
"Microsoft.Windows.SDK.BuildTools": {
"type": "Direct",
"requested": "[10.0.26100.1742, )",
@@ -42,6 +51,11 @@
"Microsoft.Windows.SDK.BuildTools": "10.0.22621.756"
}
},
+ "Google.Protobuf": {
+ "type": "Transitive",
+ "resolved": "3.29.3",
+ "contentHash": "t7nZFFUFwigCwZ+nIXHDLweXvwIpsOXi+P7J7smPT/QjI3EKxnCzTQOhBqyEh6XEzc/pNH+bCFOOSjatrPt6Tw=="
+ },
"H.GeneratedIcons.System.Drawing": {
"type": "Transitive",
"resolved": "2.2.0",
@@ -58,6 +72,11 @@
"H.GeneratedIcons.System.Drawing": "2.2.0"
}
},
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "9.0.1",
+ "contentHash": "Tr74eP0oQ3AyC24ch17N8PuEkrPbD0JqIfENCYqmgKYNOmL8wQKzLJu3ObxTUDrjnn4rHoR1qKa37/eQyHmCDA=="
+ },
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.2651.64",
@@ -81,6 +100,11 @@
"Microsoft.Win32.SystemEvents": "9.0.0"
}
},
+ "System.IO.Pipelines": {
+ "type": "Transitive",
+ "resolved": "9.0.1",
+ "contentHash": "uXf5o8eV/gtzDQY4lGROLFMWQvcViKcF8o4Q6KpIOjloAQXrnscQSu6gTxYJMHuNJnh7szIF9AzkaEq+zDLoEg=="
+ },
"System.Reflection.Metadata": {
"type": "Transitive",
"resolved": "9.0.0",
@@ -88,6 +112,22 @@
"dependencies": {
"System.Collections.Immutable": "9.0.0"
}
+ },
+ "codersdk": {
+ "type": "Project"
+ },
+ "vpn": {
+ "type": "Project",
+ "dependencies": {
+ "System.IO.Pipelines": "[9.0.1, )",
+ "Vpn.Proto": "[1.0.0, )"
+ }
+ },
+ "vpn.proto": {
+ "type": "Project",
+ "dependencies": {
+ "Google.Protobuf": "[3.29.3, )"
+ }
}
},
"net8.0-windows10.0.19041/win-arm64": {
diff --git a/Coder.Desktop.sln.DotSettings b/Coder.Desktop.sln.DotSettings
index 9804cf1..bf138c2 100644
--- a/Coder.Desktop.sln.DotSettings
+++ b/Coder.Desktop.sln.DotSettings
@@ -1,4 +1,10 @@
+ True
+ True
+ True
+ True
+ True
+ True<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns">
<TypePattern DisplayName="Non-reorderable types" Priority="99999999">
<TypePattern.Match>
@@ -30,7 +36,7 @@
</HasMember>
</And>
</TypePattern.Match>
-
+
<Entry DisplayName="Fields">
<Entry.Match>
<And>
@@ -146,6 +152,12 @@
</Entry.Match>
</Entry>
+ <Entry DisplayName="Events">
+ <Entry.Match>
+ <Kind Is="Event" />
+ </Entry.Match>
+ </Entry>
+
<Entry DisplayName="Public Enums" Priority="100">
<Entry.Match>
<And>
@@ -186,12 +198,15 @@
</And>
</Entry.Match>
</Entry>
-
- <Entry DisplayName="Events">
- <Entry.Match>
- <Kind Is="Event" />
- </Entry.Match>
- </Entry>
+
+ <Entry DisplayName="Properties, Indexers">
+ <Entry.Match>
+ <Or>
+ <Kind Is="Property" />
+ <Kind Is="Indexer" />
+ </Or>
+ </Entry.Match>
+ </Entry>
<Entry DisplayName="Constructors">
<Entry.Match>
@@ -202,16 +217,7 @@
<Static/>
</Entry.SortBy>
</Entry>
-
- <Entry DisplayName="Properties, Indexers">
- <Entry.Match>
- <Or>
- <Kind Is="Property" />
- <Kind Is="Indexer" />
- </Or>
- </Entry.Match>
- </Entry>
-
+
<Entry DisplayName="Interface Implementations" Priority="100">
<Entry.Match>
<And>
@@ -235,7 +241,11 @@
</TypePattern>
</Patterns>
+ True
+ True
+ TrueTrue
+ TrueTrueTrueTrue
diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs
index cfcfdb7..34863f1 100644
--- a/CoderSdk/CoderApiClient.cs
+++ b/CoderSdk/CoderApiClient.cs
@@ -26,12 +26,15 @@ public partial class CoderApiClient
private readonly HttpClient _httpClient = new();
private readonly JsonSerializerOptions _jsonOptions;
- public CoderApiClient(string baseUrl)
+ public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute))
{
- var url = new Uri(baseUrl, UriKind.Absolute);
- if (url.PathAndQuery != "/")
+ }
+
+ public CoderApiClient(Uri baseUrl)
+ {
+ if (baseUrl.PathAndQuery != "/")
throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl));
- _httpClient.BaseAddress = url;
+ _httpClient.BaseAddress = baseUrl;
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
diff --git a/Tests.Vpn.Service/TestHttpServer.cs b/Tests.Vpn.Service/TestHttpServer.cs
index 4129b0d..d33697f 100644
--- a/Tests.Vpn.Service/TestHttpServer.cs
+++ b/Tests.Vpn.Service/TestHttpServer.cs
@@ -15,6 +15,8 @@ public class TestHttpServer : IDisposable
private readonly HttpListener _listener;
private readonly Thread _listenerThread;
+ public string BaseUrl { get; private set; }
+
public TestHttpServer(Action handler) : this(ctx =>
{
handler(ctx);
@@ -75,8 +77,6 @@ public TestHttpServer(Func handler)
_listenerThread.Start();
}
- public string BaseUrl { get; private set; }
-
public void Dispose()
{
_cts.Cancel();
diff --git a/Tests.Vpn/SpeakerTest.cs b/Tests.Vpn/SpeakerTest.cs
index 0b1552b..51950f7 100644
--- a/Tests.Vpn/SpeakerTest.cs
+++ b/Tests.Vpn/SpeakerTest.cs
@@ -16,13 +16,6 @@ internal class FailableStream : Stream
private readonly TaskCompletionSource _writeTcs = new();
- public FailableStream(Stream inner, Exception? writeException, Exception? readException)
- {
- _inner = inner;
- if (writeException != null) _writeTcs.SetException(writeException);
- if (readException != null) _readTcs.SetException(readException);
- }
-
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => _inner.CanWrite;
@@ -34,6 +27,13 @@ public override long Position
set => _inner.Position = value;
}
+ public FailableStream(Stream inner, Exception? writeException, Exception? readException)
+ {
+ _inner = inner;
+ if (writeException != null) _writeTcs.SetException(writeException);
+ if (readException != null) _readTcs.SetException(readException);
+ }
+
public void SetWriteException(Exception ex)
{
_writeTcs.SetException(ex);
diff --git a/Vpn.DebugClient/Vpn.DebugClient.csproj b/Vpn.DebugClient/Vpn.DebugClient.csproj
index b20654b..e9c4cb5 100644
--- a/Vpn.DebugClient/Vpn.DebugClient.csproj
+++ b/Vpn.DebugClient/Vpn.DebugClient.csproj
@@ -10,8 +10,8 @@
-
-
+
+
diff --git a/Vpn.Proto/RpcHeader.cs b/Vpn.Proto/RpcHeader.cs
index c81eb1d..cf7ffcc 100644
--- a/Vpn.Proto/RpcHeader.cs
+++ b/Vpn.Proto/RpcHeader.cs
@@ -9,6 +9,9 @@ public class RpcHeader
{
private const string Preamble = "codervpn";
+ public string Role { get; }
+ public RpcVersionList VersionList { get; }
+
/// Role of the peer
/// Version of the peer
public RpcHeader(string role, RpcVersionList versionList)
@@ -17,9 +20,6 @@ public RpcHeader(string role, RpcVersionList versionList)
VersionList = versionList;
}
- public string Role { get; }
- public RpcVersionList VersionList { get; }
-
///
/// Parse a header string into a SpeakerHeader.
///
diff --git a/Vpn.Proto/RpcMessage.cs b/Vpn.Proto/RpcMessage.cs
index 8a4ea26..a46d96b 100644
--- a/Vpn.Proto/RpcMessage.cs
+++ b/Vpn.Proto/RpcMessage.cs
@@ -6,12 +6,12 @@ namespace Coder.Desktop.Vpn.Proto;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class RpcRoleAttribute : Attribute
{
+ public string Role { get; }
+
public RpcRoleAttribute(string role)
{
Role = role;
}
-
- public string Role { get; }
}
///
diff --git a/Vpn.Proto/RpcVersion.cs b/Vpn.Proto/RpcVersion.cs
index d5d0520..33e0c16 100644
--- a/Vpn.Proto/RpcVersion.cs
+++ b/Vpn.Proto/RpcVersion.cs
@@ -7,6 +7,9 @@ public class RpcVersion
{
public static readonly RpcVersion Current = new(1, 0);
+ public ulong Major { get; }
+ public ulong Minor { get; }
+
/// The major version of the peer
/// The minor version of the peer
public RpcVersion(ulong major, ulong minor)
@@ -15,9 +18,6 @@ public RpcVersion(ulong major, ulong minor)
Minor = minor;
}
- public ulong Major { get; }
- public ulong Minor { get; }
-
///
/// Parse a string in the format "major.minor" into an ApiVersion.
///
diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs
index a0a1359..80b294f 100644
--- a/Vpn.Service/Downloader.cs
+++ b/Vpn.Service/Downloader.cs
@@ -189,6 +189,13 @@ public class DownloadTask
public readonly HttpRequestMessage Request;
public readonly string TempDestinationPath;
+ public ulong? TotalBytes { get; private set; }
+ public ulong BytesRead { get; private set; }
+ public Task Task { get; private set; } = null!; // Set in EnsureStartedAsync
+
+ public double? Progress => TotalBytes == null ? null : (double)BytesRead / TotalBytes.Value;
+ public bool IsCompleted => Task.IsCompleted;
+
internal DownloadTask(ILogger logger, HttpRequestMessage req, string destinationPath, IDownloadValidator validator)
{
_logger = logger;
@@ -211,13 +218,6 @@ internal DownloadTask(ILogger logger, HttpRequestMessage req, string destination
".download-" + Path.GetRandomFileName());
}
- public ulong? TotalBytes { get; private set; }
- public ulong BytesRead { get; private set; }
- public Task Task { get; private set; } = null!; // Set in EnsureStartedAsync
-
- public double? Progress => TotalBytes == null ? null : (double)BytesRead / TotalBytes.Value;
- public bool IsCompleted => Task.IsCompleted;
-
internal async Task EnsureStartedAsync(CancellationToken ct = default)
{
using var _ = await _semaphore.LockAsync(ct);
diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs
index 882a75f..2a7fcca 100644
--- a/Vpn.Service/Manager.cs
+++ b/Vpn.Service/Manager.cs
@@ -283,12 +283,15 @@ 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 CombinationDownloadValidator(
+ var validators = new NullDownloadValidator();
// TODO: re-enable when the binaries are signed and have versions
- //AuthenticodeDownloadValidator.Coder,
- //new AssemblyVersionDownloadValidator(
- //$"{expectedVersion.Major}.{expectedVersion.Minor}.{expectedVersion.Patch}.0")
+ /*
+ var validators = new CombinationDownloadValidator(
+ AuthenticodeDownloadValidator.Coder,
+ new AssemblyVersionDownloadValidator(
+ $"{expectedVersion.Major}.{expectedVersion.Minor}.{expectedVersion.Patch}.0")
);
+ */
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/Vpn.Service.csproj b/Vpn.Service/Vpn.Service.csproj
index 136af17..f3a18e6 100644
--- a/Vpn.Service/Vpn.Service.csproj
+++ b/Vpn.Service/Vpn.Service.csproj
@@ -15,9 +15,9 @@
-
-
-
+
+
+
diff --git a/Vpn/Speaker.cs b/Vpn/Speaker.cs
index f2f2f68..d113a50 100644
--- a/Vpn/Speaker.cs
+++ b/Vpn/Speaker.cs
@@ -27,14 +27,6 @@ public class ReplyableRpcMessage : RpcMessage
private readonly TR _message;
private readonly Speaker _speaker;
- /// Speaker to use for sending reply
- /// Original received message
- public ReplyableRpcMessage(Speaker speaker, TR message)
- {
- _speaker = speaker;
- _message = message;
- }
-
public override RPC? RpcField
{
get => _message.RpcField;
@@ -43,6 +35,14 @@ public override RPC? RpcField
public override TR Message => _message;
+ /// Speaker to use for sending reply
+ /// Original received message
+ public ReplyableRpcMessage(Speaker speaker, TR message)
+ {
+ _speaker = speaker;
+ _message = message;
+ }
+
public override void Validate()
{
_message.Validate();
@@ -72,6 +72,17 @@ public class Speaker : IAsyncDisposable
public delegate void OnReceiveDelegate(ReplyableRpcMessage message);
+ ///
+ /// Event that is triggered when an error occurs. The handling code should dispose the Speaker after this event is
+ /// triggered.
+ ///
+ public event OnErrorDelegate? Error;
+
+ ///
+ /// Event that is triggered when a message is received.
+ ///
+ public event OnReceiveDelegate? Receive;
+
private readonly Stream _conn;
// _cts is cancelled when Dispose is called and will cause all ongoing I/O
@@ -85,17 +96,6 @@ public class Speaker : IAsyncDisposable
private ulong _lastRequestId;
private Task? _receiveTask;
- ///
- /// Event that is triggered when an error occurs. The handling code should dispose the Speaker after this event is
- /// triggered.
- ///
- public event OnErrorDelegate? Error;
-
- ///
- /// Event that is triggered when a message is received.
- ///
- public event OnReceiveDelegate? Receive;
-
///
/// Instantiates a speaker. The speaker will not perform any I/O until StartAsync is called.
///
diff --git a/Vpn/Utilities/BidirectionalPipe.cs b/Vpn/Utilities/BidirectionalPipe.cs
index 0792ce8..72e633b 100644
--- a/Vpn/Utilities/BidirectionalPipe.cs
+++ b/Vpn/Utilities/BidirectionalPipe.cs
@@ -10,14 +10,6 @@ public class BidirectionalPipe : Stream
private readonly Stream _reader;
private readonly Stream _writer;
- /// The stream to perform reads from
- /// The stream to write data to
- public BidirectionalPipe(Stream reader, Stream writer)
- {
- _reader = reader;
- _writer = writer;
- }
-
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
@@ -29,6 +21,14 @@ public override long Position
set => throw new NotImplementedException("BidirectionalPipe does not support setting position");
}
+ /// The stream to perform reads from
+ /// The stream to write data to
+ public BidirectionalPipe(Stream reader, Stream writer)
+ {
+ _reader = reader;
+ _writer = writer;
+ }
+
///
/// Creates a new pair of BidirectionalPipes that are connected to each other using buffered in-memory pipes.
///
diff --git a/Vpn/Utilities/RaiiSemaphoreSlim.cs b/Vpn/Utilities/RaiiSemaphoreSlim.cs
index bbb76cb..e38db6a 100644
--- a/Vpn/Utilities/RaiiSemaphoreSlim.cs
+++ b/Vpn/Utilities/RaiiSemaphoreSlim.cs
@@ -18,24 +18,35 @@ public void Dispose()
GC.SuppressFinalize(this);
}
+ public IDisposable Lock()
+ {
+ _semaphore.Wait();
+ return new Locker(_semaphore);
+ }
+
+ public IDisposable? Lock(TimeSpan timeout)
+ {
+ if (!_semaphore.Wait(timeout)) return null;
+ return new Locker(_semaphore);
+ }
+
public async ValueTask LockAsync(CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct);
- return new Lock(_semaphore);
+ return new Locker(_semaphore);
}
public async ValueTask LockAsync(TimeSpan timeout, CancellationToken ct = default)
{
- if (await _semaphore.WaitAsync(timeout, ct)) return null;
-
- return new Lock(_semaphore);
+ if (!await _semaphore.WaitAsync(timeout, ct)) return null;
+ return new Locker(_semaphore);
}
- private class Lock : IDisposable
+ private class Locker : IDisposable
{
private readonly SemaphoreSlim _semaphore1;
- public Lock(SemaphoreSlim semaphore)
+ public Locker(SemaphoreSlim semaphore)
{
_semaphore1 = semaphore;
}