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 @@ enable true true + + 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 + True True + True True True True 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; }