diff --git a/App/App.csproj b/App/App.csproj index 4d049fd..982612f 100644 --- a/App/App.csproj +++ b/App/App.csproj @@ -56,6 +56,7 @@ <ItemGroup> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" /> <PackageReference Include="DependencyPropertyGenerator" Version="1.5.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> diff --git a/App/App.xaml.cs b/App/App.xaml.cs index c6f22b4..2c7e87e 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -1,24 +1,26 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; +using Windows.ApplicationModel.Activation; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; using Coder.Desktop.App.ViewModels; using Coder.Desktop.App.Views; using Coder.Desktop.App.Views.Pages; +using Coder.Desktop.CoderSdk.Agent; using Coder.Desktop.Vpn; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml; using Microsoft.Win32; using Microsoft.Windows.AppLifecycle; -using Windows.ApplicationModel.Activation; -using Microsoft.Extensions.Logging; using Serilog; -using System.Collections.Generic; +using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs; namespace Coder.Desktop.App; @@ -60,6 +62,8 @@ public App() loggerConfig.ReadFrom.Configuration(builder.Configuration); }); + services.AddSingleton<IAgentApiClientFactory, AgentApiClientFactory>(); + services.AddSingleton<ICredentialManager, CredentialManager>(); services.AddSingleton<IRpcController, RpcController>(); @@ -76,6 +80,8 @@ public App() // FileSyncListMainPage is created by FileSyncListWindow. services.AddTransient<FileSyncListWindow>(); + // DirectoryPickerWindow views and view models are created by FileSyncListViewModel. + // TrayWindow views and view models services.AddTransient<TrayWindowLoadingPage>(); services.AddTransient<TrayWindowDisconnectedViewModel>(); @@ -89,7 +95,7 @@ public App() services.AddTransient<TrayWindow>(); _services = services.BuildServiceProvider(); - _logger = (ILogger<App>)(_services.GetService(typeof(ILogger<App>))!); + _logger = (ILogger<App>)_services.GetService(typeof(ILogger<App>))!; InitializeComponent(); } @@ -107,7 +113,7 @@ public async Task ExitApplication() Environment.Exit(0); } - protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + protected override void OnLaunched(LaunchActivatedEventArgs args) { _logger.LogInformation("new instance launched"); // Start connecting to the manager in the background. diff --git a/App/Converters/DependencyObjectSelector.cs b/App/Converters/DependencyObjectSelector.cs index 8c1570f..a31c33b 100644 --- a/App/Converters/DependencyObjectSelector.cs +++ b/App/Converters/DependencyObjectSelector.cs @@ -186,3 +186,7 @@ private static void SelectedKeyPropertyChanged(DependencyObject obj, DependencyP public sealed class StringToBrushSelectorItem : DependencyObjectSelectorItem<string, Brush>; public sealed class StringToBrushSelector : DependencyObjectSelector<string, Brush>; + +public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem<string, string>; + +public sealed class StringToStringSelector : DependencyObjectSelector<string, string>; diff --git a/App/Program.cs b/App/Program.cs index 2ad863d..1a54b2b 100644 --- a/App/Program.cs +++ b/App/Program.cs @@ -27,7 +27,7 @@ private static void Main(string[] args) try { ComWrappersSupport.InitializeComWrappers(); - AppInstance mainInstance = GetMainInstance(); + var mainInstance = GetMainInstance(); if (!mainInstance.IsCurrent) { var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index 41a8dc7..a2f6567 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Utilities; namespace Coder.Desktop.App.Services; diff --git a/App/Services/MutagenController.cs b/App/Services/MutagenController.cs index 3a68962..5b85b2c 100644 --- a/App/Services/MutagenController.cs +++ b/App/Services/MutagenController.cs @@ -12,6 +12,7 @@ using Coder.Desktop.MutagenSdk.Proto.Service.Prompting; using Coder.Desktop.MutagenSdk.Proto.Service.Synchronization; using Coder.Desktop.MutagenSdk.Proto.Synchronization; +using Coder.Desktop.MutagenSdk.Proto.Synchronization.Core.Ignore; using Coder.Desktop.MutagenSdk.Proto.Url; using Coder.Desktop.Vpn.Utilities; using Grpc.Core; @@ -85,7 +86,9 @@ public interface ISyncSessionController : IAsyncDisposable /// </summary> Task<SyncSessionControllerStateModel> RefreshState(CancellationToken ct = default); - Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, Action<string> progressCallback, CancellationToken ct = default); + Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, Action<string> progressCallback, + CancellationToken ct = default); + Task<SyncSessionModel> PauseSyncSession(string identifier, CancellationToken ct = default); Task<SyncSessionModel> ResumeSyncSession(string identifier, CancellationToken ct = default); Task TerminateSyncSession(string identifier, CancellationToken ct = default); @@ -200,7 +203,8 @@ public async Task<SyncSessionControllerStateModel> RefreshState(CancellationToke return state; } - public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, Action<string>? progressCallback = null, CancellationToken ct = default) + public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest req, + Action<string>? progressCallback = null, CancellationToken ct = default) { using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); @@ -216,8 +220,11 @@ public async Task<SyncSessionModel> CreateSyncSession(CreateSyncSessionRequest r { Alpha = req.Alpha.MutagenUrl, Beta = req.Beta.MutagenUrl, - // TODO: probably should set these at some point - Configuration = new Configuration(), + // TODO: probably should add a configuration page for these at some point + Configuration = new Configuration + { + IgnoreVCSMode = IgnoreVCSMode.Ignore, + }, ConfigurationAlpha = new Configuration(), ConfigurationBeta = new Configuration(), }, diff --git a/App/ViewModels/DirectoryPickerViewModel.cs b/App/ViewModels/DirectoryPickerViewModel.cs new file mode 100644 index 0000000..131934f --- /dev/null +++ b/App/ViewModels/DirectoryPickerViewModel.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.CoderSdk.Agent; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.ViewModels; + +public class DirectoryPickerBreadcrumb +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required string Name { get; init; } + + public required IReadOnlyList<string> AbsolutePathSegments { get; init; } + + // HACK: we need to know which one is first so we don't prepend an arrow + // icon. You can't get the index of the current ItemsRepeater item in XAML. + public required bool IsFirst { get; init; } +} + +public enum DirectoryPickerItemKind +{ + ParentDirectory, // aka. ".." + Directory, + File, // includes everything else +} + +public class DirectoryPickerItem +{ + // HACK: you cannot access the parent context when inside an ItemsRepeater. + public required DirectoryPickerViewModel ViewModel; + + public required DirectoryPickerItemKind Kind { get; init; } + public required string Name { get; init; } + public required IReadOnlyList<string> AbsolutePathSegments { get; init; } + + public bool Selectable => Kind is DirectoryPickerItemKind.ParentDirectory or DirectoryPickerItemKind.Directory; +} + +public partial class DirectoryPickerViewModel : ObservableObject +{ + // PathSelected will be called ONCE when the user either cancels or selects + // a directory. If the user cancelled, the path will be null. + public event EventHandler<string?>? PathSelected; + + private const int RequestTimeoutMilliseconds = 15_000; + + private readonly IAgentApiClient _client; + + private Window? _window; + private DispatcherQueue? _dispatcherQueue; + + public readonly string AgentFqdn; + + // The initial loading screen is differentiated from subsequent loading + // screens because: + // 1. We don't want to show a broken state while the page is loading. + // 2. An error dialog allows the user to get to a broken state with no + // breadcrumbs, no items, etc. with no chance to reload. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial bool InitialLoading { get; set; } = true; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowLoadingScreen))] + [NotifyPropertyChangedFor(nameof(ShowErrorScreen))] + [NotifyPropertyChangedFor(nameof(ShowListScreen))] + public partial string? InitialLoadError { get; set; } = null; + + [ObservableProperty] public partial bool NavigatingLoading { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSelectable))] + public partial string CurrentDirectory { get; set; } = ""; + + [ObservableProperty] public partial IReadOnlyList<DirectoryPickerBreadcrumb> Breadcrumbs { get; set; } = []; + + [ObservableProperty] public partial IReadOnlyList<DirectoryPickerItem> Items { get; set; } = []; + + public bool ShowLoadingScreen => InitialLoadError == null && InitialLoading; + public bool ShowErrorScreen => InitialLoadError != null; + public bool ShowListScreen => InitialLoadError == null && !InitialLoading; + + // The "root" directory on Windows isn't a real thing, but in our model + // it's a drive listing. We don't allow users to select the fake drive + // listing directory. + // + // On Linux, this will never be empty since the highest you can go is "/". + public bool IsSelectable => CurrentDirectory != ""; + + public DirectoryPickerViewModel(IAgentApiClientFactory clientFactory, string agentFqdn) + { + _client = clientFactory.Create(agentFqdn); + AgentFqdn = agentFqdn; + } + + public void Initialize(Window window, DispatcherQueue dispatcherQueue) + { + _window = window; + _dispatcherQueue = dispatcherQueue; + if (!_dispatcherQueue.HasThreadAccess) + throw new InvalidOperationException("Initialize must be called from the UI thread"); + + InitialLoading = true; + InitialLoadError = null; + // Initial load is in the home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Home, []).ContinueWith(ContinueInitialLoad); + } + + [RelayCommand] + private void RetryLoad() + { + InitialLoading = true; + InitialLoadError = null; + // Subsequent loads after the initial failure are always in the root + // directory in case there's a permanent issue preventing listing the + // home directory. + _ = BackgroundLoad(ListDirectoryRelativity.Root, []).ContinueWith(ContinueInitialLoad); + } + + private async Task<ListDirectoryResponse> BackgroundLoad(ListDirectoryRelativity relativity, List<string> path) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + return await _client.ListDirectory(new ListDirectoryRequest + { + Path = path, + Relativity = relativity, + }, cts.Token); + } + + private void ContinueInitialLoad(Task<ListDirectoryResponse> task) + { + // Ensure we're on the UI thread. + if (_dispatcherQueue == null) return; + if (!_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(() => ContinueInitialLoad(task)); + return; + } + + if (task.IsCompletedSuccessfully) + { + ProcessResponse(task.Result); + return; + } + + InitialLoadError = "Could not list home directory in workspace: "; + if (task.IsCanceled) InitialLoadError += new TaskCanceledException(); + else if (task.IsFaulted) InitialLoadError += task.Exception; + else InitialLoadError += "no successful result or error"; + InitialLoading = false; + } + + [RelayCommand] + public async Task ListPath(IReadOnlyList<string> path) + { + if (_window is null || NavigatingLoading) return; + NavigatingLoading = true; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(RequestTimeoutMilliseconds)); + try + { + var res = await _client.ListDirectory(new ListDirectoryRequest + { + Path = path.ToList(), + Relativity = ListDirectoryRelativity.Root, + }, cts.Token); + ProcessResponse(res); + } + catch (Exception e) + { + // Subsequent listing errors are just shown as dialog boxes. + var dialog = new ContentDialog + { + Title = "Failed to list remote directory", + Content = $"{e}", + CloseButtonText = "Ok", + XamlRoot = _window.Content.XamlRoot, + }; + _ = await dialog.ShowAsync(); + } + finally + { + NavigatingLoading = false; + } + } + + [RelayCommand] + public void Cancel() + { + PathSelected?.Invoke(this, null); + _window?.Close(); + } + + [RelayCommand] + public void Select() + { + if (CurrentDirectory == "") return; + PathSelected?.Invoke(this, CurrentDirectory); + _window?.Close(); + } + + private void ProcessResponse(ListDirectoryResponse res) + { + InitialLoading = false; + InitialLoadError = null; + NavigatingLoading = false; + + var breadcrumbs = new List<DirectoryPickerBreadcrumb>(res.AbsolutePath.Count + 1) + { + new() + { + Name = "🖥️", + AbsolutePathSegments = [], + IsFirst = true, + ViewModel = this, + }, + }; + for (var i = 0; i < res.AbsolutePath.Count; i++) + breadcrumbs.Add(new DirectoryPickerBreadcrumb + { + Name = res.AbsolutePath[i], + AbsolutePathSegments = res.AbsolutePath[..(i + 1)], + IsFirst = false, + ViewModel = this, + }); + + var items = new List<DirectoryPickerItem>(res.Contents.Count + 1); + if (res.AbsolutePath.Count != 0) + items.Add(new DirectoryPickerItem + { + Kind = DirectoryPickerItemKind.ParentDirectory, + Name = "..", + AbsolutePathSegments = res.AbsolutePath[..^1], + ViewModel = this, + }); + + foreach (var item in res.Contents) + { + if (item.Name.StartsWith(".")) continue; + items.Add(new DirectoryPickerItem + { + Kind = item.IsDir ? DirectoryPickerItemKind.Directory : DirectoryPickerItemKind.File, + Name = item.Name, + AbsolutePathSegments = res.AbsolutePath.Append(item.Name).ToList(), + ViewModel = this, + }); + } + + CurrentDirectory = res.AbsolutePathString; + Breadcrumbs = breadcrumbs; + Items = items; + } +} diff --git a/App/ViewModels/FileSyncListViewModel.cs b/App/ViewModels/FileSyncListViewModel.cs index d01338c..9235141 100644 --- a/App/ViewModels/FileSyncListViewModel.cs +++ b/App/ViewModels/FileSyncListViewModel.cs @@ -6,6 +6,8 @@ using Windows.Storage.Pickers; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; +using Coder.Desktop.App.Views; +using Coder.Desktop.CoderSdk.Agent; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Dispatching; @@ -19,10 +21,12 @@ public partial class FileSyncListViewModel : ObservableObject { private Window? _window; private DispatcherQueue? _dispatcherQueue; + private DirectoryPickerWindow? _remotePickerWindow; private readonly ISyncSessionController _syncSessionController; private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; + private readonly IAgentApiClientFactory _agentApiClientFactory; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowUnavailable))] @@ -46,7 +50,7 @@ public partial class FileSyncListViewModel : ObservableObject [ObservableProperty] public partial bool OperationInProgress { get; set; } = false; - [ObservableProperty] public partial List<SyncSessionViewModel> Sessions { get; set; } = []; + [ObservableProperty] public partial IReadOnlyList<SyncSessionViewModel> Sessions { get; set; } = []; [ObservableProperty] public partial bool CreatingNewSession { get; set; } = false; @@ -58,17 +62,30 @@ public partial class FileSyncListViewModel : ObservableObject [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] public partial bool NewSessionLocalPathDialogOpen { get; set; } = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(NewSessionRemoteHostEnabled))] + public partial IReadOnlyList<string> AvailableHosts { get; set; } = []; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] - public partial string NewSessionRemoteHost { get; set; } = ""; + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial string? NewSessionRemoteHost { get; set; } = null; [ObservableProperty] [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] public partial string NewSessionRemotePath { get; set; } = ""; - // TODO: NewSessionRemotePathDialogOpen for remote path [ObservableProperty] - public partial string NewSessionStatus { get; set; } = ""; + [NotifyPropertyChangedFor(nameof(NewSessionCreateEnabled))] + [NotifyPropertyChangedFor(nameof(NewSessionRemotePathDialogEnabled))] + public partial bool NewSessionRemotePathDialogOpen { get; set; } = false; + + public bool NewSessionRemoteHostEnabled => AvailableHosts.Count > 0; + + public bool NewSessionRemotePathDialogEnabled => + !string.IsNullOrWhiteSpace(NewSessionRemoteHost) && !NewSessionRemotePathDialogOpen; + + [ObservableProperty] public partial string NewSessionStatus { get; set; } = ""; public bool NewSessionCreateEnabled { @@ -78,6 +95,7 @@ public bool NewSessionCreateEnabled if (NewSessionLocalPathDialogOpen) return false; if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) return false; if (string.IsNullOrWhiteSpace(NewSessionRemotePath)) return false; + if (NewSessionRemotePathDialogOpen) return false; return true; } } @@ -89,11 +107,12 @@ public bool NewSessionCreateEnabled public bool ShowSessions => !Loading && UnavailableMessage == null && Error == null; public FileSyncListViewModel(ISyncSessionController syncSessionController, IRpcController rpcController, - ICredentialManager credentialManager) + ICredentialManager credentialManager, IAgentApiClientFactory agentApiClientFactory) { _syncSessionController = syncSessionController; _rpcController = rpcController; _credentialManager = credentialManager; + _agentApiClientFactory = agentApiClientFactory; } public void Initialize(Window window, DispatcherQueue dispatcherQueue) @@ -106,6 +125,14 @@ public void Initialize(Window window, DispatcherQueue dispatcherQueue) _rpcController.StateChanged += RpcControllerStateChanged; _credentialManager.CredentialsChanged += CredentialManagerCredentialsChanged; _syncSessionController.StateChanged += SyncSessionStateChanged; + _window.Closed += (_, _) => + { + _remotePickerWindow?.Close(); + + _rpcController.StateChanged -= RpcControllerStateChanged; + _credentialManager.CredentialsChanged -= CredentialManagerCredentialsChanged; + _syncSessionController.StateChanged -= SyncSessionStateChanged; + }; var rpcModel = _rpcController.GetState(); var credentialModel = _credentialManager.GetCachedCredentials(); @@ -174,8 +201,13 @@ private void MaybeSetUnavailableMessage(RpcModel rpcModel, CredentialModel crede else { UnavailableMessage = null; + // Reload if we transitioned from unavailable to available. if (oldMessage != null) ReloadSessions(); } + + // When transitioning from available to unavailable: + if (oldMessage == null && UnavailableMessage != null) + ClearNewForm(); } private void UpdateSyncSessionState(SyncSessionControllerStateModel syncSessionState) @@ -191,6 +223,7 @@ private void ClearNewForm() NewSessionRemoteHost = ""; NewSessionRemotePath = ""; NewSessionStatus = ""; + _remotePickerWindow?.Close(); } [RelayCommand] @@ -227,21 +260,50 @@ private void HandleRefresh(Task<SyncSessionControllerStateModel> t) Loading = false; } + // Overriding AvailableHosts seems to make the ComboBox clear its value, so + // we only do this while the create form is not open. + // Must be called in UI thread. + private void SetAvailableHostsFromRpcModel(RpcModel rpcModel) + { + var hosts = new List<string>(rpcModel.Agents.Count); + // Agents will only contain started agents. + foreach (var agent in rpcModel.Agents) + { + var fqdn = agent.Fqdn + .Select(a => a.Trim('.')) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Aggregate((a, b) => a.Count(c => c == '.') < b.Count(c => c == '.') ? a : b); + if (string.IsNullOrWhiteSpace(fqdn)) + continue; + hosts.Add(fqdn); + } + + NewSessionRemoteHost = null; + AvailableHosts = hosts; + } + [RelayCommand] private void StartCreatingNewSession() { ClearNewForm(); + // Ensure we have a fresh hosts list before we open the form. We don't + // bind directly to the list on RPC state updates as updating the list + // while in use seems to break it. + SetAvailableHostsFromRpcModel(_rpcController.GetState()); CreatingNewSession = true; } - public async Task OpenLocalPathSelectDialog(Window window) + [RelayCommand] + public async Task OpenLocalPathSelectDialog() { + if (_window is null) return; + var picker = new FolderPicker { SuggestedStartLocation = PickerLocationId.ComputerFolder, }; - var hwnd = WindowNative.GetWindowHandle(window); + var hwnd = WindowNative.GetWindowHandle(_window); InitializeWithWindow.Initialize(picker, hwnd); NewSessionLocalPathDialogOpen = true; @@ -261,6 +323,40 @@ public async Task OpenLocalPathSelectDialog(Window window) } } + [RelayCommand] + public void OpenRemotePathSelectDialog() + { + if (string.IsNullOrWhiteSpace(NewSessionRemoteHost)) + return; + if (_remotePickerWindow is not null) + { + _remotePickerWindow.Activate(); + return; + } + + NewSessionRemotePathDialogOpen = true; + var pickerViewModel = new DirectoryPickerViewModel(_agentApiClientFactory, NewSessionRemoteHost); + pickerViewModel.PathSelected += OnRemotePathSelected; + + _remotePickerWindow = new DirectoryPickerWindow(pickerViewModel); + _remotePickerWindow.SetParent(_window); + _remotePickerWindow.Closed += (_, _) => + { + _remotePickerWindow = null; + NewSessionRemotePathDialogOpen = false; + }; + _remotePickerWindow.Activate(); + } + + private void OnRemotePathSelected(object? sender, string? path) + { + if (sender is not DirectoryPickerViewModel pickerViewModel) return; + pickerViewModel.PathSelected -= OnRemotePathSelected; + + if (path == null) return; + NewSessionRemotePath = path; + } + [RelayCommand] private void CancelNewSession() { @@ -300,7 +396,7 @@ await _syncSessionController.CreateSyncSession(new CreateSyncSessionRequest Beta = new CreateSyncSessionRequest.Endpoint { Protocol = CreateSyncSessionRequest.Endpoint.ProtocolKind.Ssh, - Host = NewSessionRemoteHost, + Host = NewSessionRemoteHost!, Path = NewSessionRemotePath, }, }, OnCreateSessionProgress, cts.Token); diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 532bfe4..f845521 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -178,6 +178,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) { // We just assume that it's a single-agent workspace. Hostname = workspace.Name, + // TODO: this needs to get the suffix from the server HostnameSuffix = ".coder", ConnectionStatus = AgentConnectionStatus.Gray, DashboardUrl = WorkspaceUri(coderUri, workspace.Name), diff --git a/App/Views/DirectoryPickerWindow.xaml b/App/Views/DirectoryPickerWindow.xaml new file mode 100644 index 0000000..8a107cb --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> + +<winuiex:WindowEx + x:Class="Coder.Desktop.App.Views.DirectoryPickerWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:winuiex="using:WinUIEx" + mc:Ignorable="d" + Title="Directory Picker" + Width="400" Height="600" + MinWidth="400" MinHeight="600"> + + <Window.SystemBackdrop> + <DesktopAcrylicBackdrop /> + </Window.SystemBackdrop> + + <Frame x:Name="RootFrame" /> +</winuiex:WindowEx> diff --git a/App/Views/DirectoryPickerWindow.xaml.cs b/App/Views/DirectoryPickerWindow.xaml.cs new file mode 100644 index 0000000..6ed5f43 --- /dev/null +++ b/App/Views/DirectoryPickerWindow.xaml.cs @@ -0,0 +1,93 @@ +using System; +using System.Runtime.InteropServices; +using Windows.Graphics; +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views.Pages; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using WinRT.Interop; +using WinUIEx; + +namespace Coder.Desktop.App.Views; + +public sealed partial class DirectoryPickerWindow : WindowEx +{ + public DirectoryPickerWindow(DirectoryPickerViewModel viewModel) + { + InitializeComponent(); + SystemBackdrop = new DesktopAcrylicBackdrop(); + + viewModel.Initialize(this, DispatcherQueue); + RootFrame.Content = new DirectoryPickerMainPage(viewModel); + + // This will be moved to the center of the parent window in SetParent. + this.CenterOnScreen(); + } + + public void SetParent(Window parentWindow) + { + // Move the window to the center of the parent window. + var scale = DisplayScale.WindowScale(parentWindow); + var windowPos = new PointInt32( + parentWindow.AppWindow.Position.X + parentWindow.AppWindow.Size.Width / 2 - AppWindow.Size.Width / 2, + parentWindow.AppWindow.Position.Y + parentWindow.AppWindow.Size.Height / 2 - AppWindow.Size.Height / 2 + ); + + // Ensure we stay within the display. + var workArea = DisplayArea.GetFromPoint(parentWindow.AppWindow.Position, DisplayAreaFallback.Primary).WorkArea; + if (windowPos.X + AppWindow.Size.Width > workArea.X + workArea.Width) // right edge + windowPos.X = workArea.X + workArea.Width - AppWindow.Size.Width; + if (windowPos.Y + AppWindow.Size.Height > workArea.Y + workArea.Height) // bottom edge + windowPos.Y = workArea.Y + workArea.Height - AppWindow.Size.Height; + if (windowPos.X < workArea.X) // left edge + windowPos.X = workArea.X; + if (windowPos.Y < workArea.Y) // top edge + windowPos.Y = workArea.Y; + + AppWindow.Move(windowPos); + + var parentHandle = WindowNative.GetWindowHandle(parentWindow); + var thisHandle = WindowNative.GetWindowHandle(this); + + // Set the parent window in win API. + NativeApi.SetWindowParent(thisHandle, parentHandle); + + // Override the presenter, which allows us to enable modal-like + // behavior for this window: + // - Disables the parent window + // - Any activations of the parent window will play a bell sound and + // focus the modal window + // + // This behavior is very similar to the native file/directory picker on + // Windows. + var presenter = OverlappedPresenter.CreateForDialog(); + presenter.IsModal = true; + AppWindow.SetPresenter(presenter); + AppWindow.Show(); + + // Cascade close events. + parentWindow.Closed += OnParentWindowClosed; + Closed += (_, _) => + { + parentWindow.Closed -= OnParentWindowClosed; + parentWindow.Activate(); + }; + } + + private void OnParentWindowClosed(object? sender, WindowEventArgs e) + { + Close(); + } + + private static class NativeApi + { + [DllImport("user32.dll")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + public static void SetWindowParent(IntPtr window, IntPtr parent) + { + SetWindowLongPtr(window, -8, parent); + } + } +} diff --git a/App/Views/FileSyncListWindow.xaml.cs b/App/Views/FileSyncListWindow.xaml.cs index 8a409d7..428363b 100644 --- a/App/Views/FileSyncListWindow.xaml.cs +++ b/App/Views/FileSyncListWindow.xaml.cs @@ -16,7 +16,7 @@ public FileSyncListWindow(FileSyncListViewModel viewModel) SystemBackdrop = new DesktopAcrylicBackdrop(); ViewModel.Initialize(this, DispatcherQueue); - RootFrame.Content = new FileSyncListMainPage(ViewModel, this); + RootFrame.Content = new FileSyncListMainPage(ViewModel); this.CenterOnScreen(); } diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml b/App/Views/Pages/DirectoryPickerMainPage.xaml new file mode 100644 index 0000000..dd08c46 --- /dev/null +++ b/App/Views/Pages/DirectoryPickerMainPage.xaml @@ -0,0 +1,179 @@ +<?xml version="1.0" encoding="utf-8"?> + +<Page + x:Class="Coder.Desktop.App.Views.Pages.DirectoryPickerMainPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:converters="using:Coder.Desktop.App.Converters" + xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" + xmlns:viewmodels="using:Coder.Desktop.App.ViewModels" + mc:Ignorable="d" + Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + + <Grid> + <Grid + Visibility="{x:Bind ViewModel.ShowLoadingScreen, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" + Padding="60,60" + HorizontalAlignment="Center" + VerticalAlignment="Center"> + + <ProgressRing + Width="32" + Height="32" + Margin="0,30" + HorizontalAlignment="Center" /> + + <TextBlock HorizontalAlignment="Center" Text="Loading home directory..." /> + </Grid> + + <Grid + Visibility="{x:Bind ViewModel.ShowErrorScreen, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" + Padding="20"> + + <Grid.RowDefinitions> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + + <ScrollView Grid.Row="0"> + <TextBlock + Margin="0,0,0,20" + Foreground="Red" + TextWrapping="Wrap" + Text="{x:Bind ViewModel.InitialLoadError, Mode=OneWay}" /> + </ScrollView> + + <Button Grid.Row="1" Command="{x:Bind ViewModel.RetryLoadCommand, Mode=OneWay}"> + <TextBlock Text="Reload" /> + </Button> + </Grid> + + <Grid + Visibility="{x:Bind ViewModel.ShowListScreen, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" + Padding="20"> + + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + + <Grid Grid.Row="0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + + <TextBlock + Grid.Column="0" + Text="{x:Bind ViewModel.AgentFqdn}" + Style="{StaticResource SubtitleTextBlockStyle}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" + Margin="0,0,0,10" /> + <ProgressRing + Grid.Column="1" + IsActive="{x:Bind ViewModel.NavigatingLoading, Mode=OneWay}" + Width="24" + Height="24" + Margin="10,0" + HorizontalAlignment="Right" /> + </Grid> + + <ItemsRepeater + Grid.Row="1" + Margin="-4,0,0,15" + ItemsSource="{x:Bind ViewModel.Breadcrumbs, Mode=OneWay}"> + + <ItemsRepeater.Layout> + <toolkit:WrapLayout Orientation="Horizontal" /> + </ItemsRepeater.Layout> + + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="viewmodels:DirectoryPickerBreadcrumb"> + <StackPanel Orientation="Horizontal"> + <!-- Add a chevron before each item except the "root" item --> + <FontIcon + Glyph="" + FontSize="14" + Visibility="{x:Bind IsFirst, Converter={StaticResource InverseBoolToVisibilityConverter}}" /> + <HyperlinkButton + Content="{x:Bind Name}" + Command="{x:Bind ViewModel.ListPathCommand}" + CommandParameter="{x:Bind AbsolutePathSegments}" + Padding="2,-1,2,0" /> + </StackPanel> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + + <ScrollView Grid.Row="2" Margin="-12,0,-12,15"> + <ItemsRepeater ItemsSource="{x:Bind ViewModel.Items, Mode=OneWay}"> + <ItemsRepeater.Layout> + <StackLayout Orientation="Vertical" /> + </ItemsRepeater.Layout> + + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="viewmodels:DirectoryPickerItem"> + <HyperlinkButton + IsEnabled="{x:Bind Selectable}" + Command="{x:Bind ViewModel.ListPathCommand}" + CommandParameter="{x:Bind AbsolutePathSegments}" + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Left"> + + <Grid> + <Grid.Resources> + <converters:StringToStringSelector x:Key="Icon" + SelectedKey="{x:Bind Path=Kind}"> + <converters:StringToStringSelectorItem Value="" /> + <!-- Document --> + <converters:StringToStringSelectorItem Key="ParentDirectory" + Value="" /> <!-- Back --> + <converters:StringToStringSelectorItem Key="Directory" Value="" /> + <!-- Folder --> + <converters:StringToStringSelectorItem Key="File" Value="" /> + <!-- Document --> + </converters:StringToStringSelector> + </Grid.Resources> + + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + + <!-- The accent-colored icon actually looks nice here, so we don't override it --> + <FontIcon + Grid.Column="0" + Glyph="{Binding Source={StaticResource Icon}, Path=SelectedObject}" + Margin="0,0,10,0" FontSize="16" /> + <TextBlock + Grid.Column="1" + Text="{x:Bind Name}" + Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" + TextTrimming="CharacterEllipsis" + IsTextTrimmedChanged="TooltipText_IsTextTrimmedChanged" /> + </Grid> + </HyperlinkButton> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + </ScrollView> + + <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right"> + <Button + Content="Cancel" + Command="{x:Bind ViewModel.CancelCommand}" + Margin="0,0,10,0" /> + <Button + IsEnabled="{x:Bind ViewModel.IsSelectable, Mode=OneWay}" + Content="Use This Directory" + Command="{x:Bind ViewModel.SelectCommand}" + Style="{StaticResource AccentButtonStyle}" /> + </StackPanel> + </Grid> + </Grid> +</Page> diff --git a/App/Views/Pages/DirectoryPickerMainPage.xaml.cs b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs new file mode 100644 index 0000000..4e26200 --- /dev/null +++ b/App/Views/Pages/DirectoryPickerMainPage.xaml.cs @@ -0,0 +1,27 @@ +using Coder.Desktop.App.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Views.Pages; + +public sealed partial class DirectoryPickerMainPage : Page +{ + public readonly DirectoryPickerViewModel ViewModel; + + public DirectoryPickerMainPage(DirectoryPickerViewModel viewModel) + { + ViewModel = viewModel; + InitializeComponent(); + } + + private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedChangedEventArgs e) + { + ToolTipService.SetToolTip(sender, null); + if (!sender.IsTextTrimmed) return; + + var toolTip = new ToolTip + { + Content = sender.Text, + }; + ToolTipService.SetToolTip(sender, toolTip); + } +} diff --git a/App/Views/Pages/FileSyncListMainPage.xaml b/App/Views/Pages/FileSyncListMainPage.xaml index 5a96898..cb9f2bb 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml +++ b/App/Views/Pages/FileSyncListMainPage.xaml @@ -38,21 +38,27 @@ <TextBlock HorizontalAlignment="Center" Text="Loading sync sessions..." /> </Grid> - <StackPanel + <Grid Visibility="{x:Bind ViewModel.ShowError, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" - Orientation="Vertical" Padding="20"> - <TextBlock - Margin="0,0,0,20" - Foreground="Red" - TextWrapping="Wrap" - Text="{x:Bind ViewModel.Error, Mode=OneWay}" /> + <Grid.RowDefinitions> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + + <ScrollView Grid.Row="0"> + <TextBlock + Margin="0,0,0,20" + Foreground="Red" + TextWrapping="Wrap" + Text="{x:Bind ViewModel.Error, Mode=OneWay}" /> + </ScrollView> - <Button Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}"> + <Button Grid.Row="1" Command="{x:Bind ViewModel.ReloadSessionsCommand, Mode=OneWay}"> <TextBlock Text="Reload" /> </Button> - </StackPanel> + </Grid> <!-- This grid lets us fix the header and only scroll the content. --> <Grid @@ -80,7 +86,7 @@ <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> </Style> <Style TargetType="Border"> - <Setter Property="Padding" Value="40,0,0,0" /> + <Setter Property="Padding" Value="30,0,0,0" /> </Style> </Grid.Resources> @@ -132,7 +138,7 @@ <!-- These are (mostly) from the header Grid and should be copied here --> <Grid.Resources> <Style TargetType="Border"> - <Setter Property="Padding" Value="40,0,0,0" /> + <Setter Property="Padding" Value="30,0,0,0" /> </Style> </Grid.Resources> <Grid.ColumnDefinitions> @@ -266,7 +272,7 @@ <!-- These are (mostly) from the header Grid and should be copied here --> <Grid.Resources> <Style TargetType="Border"> - <Setter Property="Padding" Value="40,0,0,0" /> + <Setter Property="Padding" Value="30,0,0,0" /> </Style> </Grid.Resources> <Grid.ColumnDefinitions> @@ -317,7 +323,7 @@ <Button Grid.Column="1" IsEnabled="{x:Bind ViewModel.NewSessionLocalPathDialogOpen, Converter={StaticResource InverseBoolConverter}, Mode=OneWay}" - Command="{x:Bind OpenLocalPathSelectDialogCommand}" + Command="{x:Bind ViewModel.OpenLocalPathSelectDialogCommand}" VerticalAlignment="Stretch"> <FontIcon Glyph="" FontSize="13" /> @@ -325,23 +331,36 @@ </Grid> </Border> <Border Grid.Column="2"> - <!-- TODO: use a combo box for workspace agents --> - <!-- <ComboBox - ItemsSource="{x:Bind WorkspaceAgents}" + IsEnabled="{x:Bind ViewModel.NewSessionRemoteHostEnabled, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.AvailableHosts, Mode=OneWay}" + SelectedItem="{x:Bind ViewModel.NewSessionRemoteHost, Mode=TwoWay}" + ToolTipService.ToolTip="{x:Bind ViewModel.NewSessionRemoteHost, Mode=OneWay}" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" /> - --> - <TextBox - VerticalAlignment="Stretch" - HorizontalAlignment="Stretch" - Text="{x:Bind ViewModel.NewSessionRemoteHost, Mode=TwoWay}" /> </Border> <Border Grid.Column="3"> - <TextBox - VerticalAlignment="Stretch" - HorizontalAlignment="Stretch" - Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" /> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + + <TextBox + Grid.Column="0" + Margin="0,0,5,0" + VerticalAlignment="Stretch" + Text="{x:Bind ViewModel.NewSessionRemotePath, Mode=TwoWay}" /> + + <Button + Grid.Column="1" + IsEnabled="{x:Bind ViewModel.NewSessionRemotePathDialogEnabled, Mode=OneWay}" + Command="{x:Bind ViewModel.OpenRemotePathSelectDialogCommand}" + VerticalAlignment="Stretch"> + + <FontIcon Glyph="" FontSize="13" /> + </Button> + </Grid> </Border> <Border Grid.Column="4"> <TextBlock diff --git a/App/Views/Pages/FileSyncListMainPage.xaml.cs b/App/Views/Pages/FileSyncListMainPage.xaml.cs index c54c29e..a677522 100644 --- a/App/Views/Pages/FileSyncListMainPage.xaml.cs +++ b/App/Views/Pages/FileSyncListMainPage.xaml.cs @@ -1,7 +1,4 @@ -using System.Threading.Tasks; using Coder.Desktop.App.ViewModels; -using CommunityToolkit.Mvvm.Input; -using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace Coder.Desktop.App.Views.Pages; @@ -10,12 +7,9 @@ public sealed partial class FileSyncListMainPage : Page { public FileSyncListViewModel ViewModel; - private readonly Window _window; - - public FileSyncListMainPage(FileSyncListViewModel viewModel, Window window) + public FileSyncListMainPage(FileSyncListViewModel viewModel) { ViewModel = viewModel; // already initialized - _window = window; InitializeComponent(); } @@ -31,10 +25,4 @@ private void TooltipText_IsTextTrimmedChanged(TextBlock sender, IsTextTrimmedCha }; ToolTipService.SetToolTip(sender, toolTip); } - - [RelayCommand] - public async Task OpenLocalPathSelectDialog() - { - await ViewModel.OpenLocalPathSelectDialog(_window); - } } diff --git a/App/packages.lock.json b/App/packages.lock.json index 5561686..1541d01 100644 --- a/App/packages.lock.json +++ b/App/packages.lock.json @@ -8,6 +8,16 @@ "resolved": "8.4.0", "contentHash": "tqVU8yc/ADO9oiTRyTnwhFN68hCwvkliMierptWOudIAvWY1mWCh5VFh+guwHJmpMwfg0J0rY+yyd5Oy7ty9Uw==" }, + "CommunityToolkit.WinUI.Controls.Primitives": { + "type": "Direct", + "requested": "[8.2.250402, )", + "resolved": "8.2.250402", + "contentHash": "Wx3t1zADrzBWDar45uRl+lmSxDO5Vx7tTMFm/mNgl3fs5xSQ1ySPdGqD10EFov3rkKc5fbpHGW5xj8t62Yisvg==", + "dependencies": { + "CommunityToolkit.WinUI.Extensions": "8.2.250402", + "Microsoft.WindowsAppSDK": "1.6.250108002" + } + }, "DependencyPropertyGenerator": { "type": "Direct", "requested": "[1.5.0, )", @@ -127,6 +137,20 @@ "Microsoft.WindowsAppSDK": "1.6.240829007" } }, + "CommunityToolkit.Common": { + "type": "Transitive", + "resolved": "8.2.1", + "contentHash": "LWuhy8cQKJ/MYcy3XafJ916U3gPH/YDvYoNGWyQWN11aiEKCZszzPOTJAOvBjP9yG8vHmIcCyPUt4L82OK47Iw==" + }, + "CommunityToolkit.WinUI.Extensions": { + "type": "Transitive", + "resolved": "8.2.250402", + "contentHash": "rAOYzNX6kdUeeE1ejGd6Q8B+xmyZvOrWFUbqCgOtP8OQsOL66en9ZQTtzxAlaaFC4qleLvnKcn8FJFBezujOlw==", + "dependencies": { + "CommunityToolkit.Common": "8.2.1", + "Microsoft.WindowsAppSDK": "1.6.250108002" + } + }, "Google.Protobuf": { "type": "Transitive", "resolved": "3.29.3", diff --git a/CoderSdk/Agent/AgentApiClient.cs b/CoderSdk/Agent/AgentApiClient.cs new file mode 100644 index 0000000..27eaea3 --- /dev/null +++ b/CoderSdk/Agent/AgentApiClient.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; + +namespace Coder.Desktop.CoderSdk.Agent; + +public interface IAgentApiClientFactory +{ + public IAgentApiClient Create(string hostname); +} + +public class AgentApiClientFactory : IAgentApiClientFactory +{ + public IAgentApiClient Create(string hostname) + { + return new AgentApiClient(hostname); + } +} + +public partial interface IAgentApiClient +{ +} + +[JsonSerializable(typeof(ListDirectoryRequest))] +[JsonSerializable(typeof(ListDirectoryResponse))] +[JsonSerializable(typeof(Response))] +public partial class AgentApiJsonContext : JsonSerializerContext; + +public partial class AgentApiClient : IAgentApiClient +{ + private const int AgentApiPort = 4; + + private readonly JsonHttpClient _httpClient; + + public AgentApiClient(string hostname) : this(new UriBuilder + { + Scheme = "http", + Host = hostname, + Port = AgentApiPort, + Path = "/", + }.Uri) + { + } + + public AgentApiClient(Uri baseUrl) + { + if (baseUrl.PathAndQuery != "/") + throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); + _httpClient = new JsonHttpClient(baseUrl, AgentApiJsonContext.Default); + } + + private async Task<TResponse> SendRequestNoBodyAsync<TResponse>(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync<object, TResponse>(method, path, null, ct); + } + + private Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync<TRequest, TResponse>(method, path, payload, ct); + } +} diff --git a/CoderSdk/Agent/ListDirectory.cs b/CoderSdk/Agent/ListDirectory.cs new file mode 100644 index 0000000..72e4a15 --- /dev/null +++ b/CoderSdk/Agent/ListDirectory.cs @@ -0,0 +1,54 @@ +namespace Coder.Desktop.CoderSdk.Agent; + +public partial interface IAgentApiClient +{ + public Task<ListDirectoryResponse> ListDirectory(ListDirectoryRequest req, CancellationToken ct = default); +} + +public enum ListDirectoryRelativity +{ + // Root means `/` on Linux, and lists drive letters on Windows. + Root, + + // Home means the user's home directory, usually `/home/xyz` or + // `C:\Users\xyz`. + Home, +} + +public class ListDirectoryRequest +{ + // Path segments like ["home", "coder", "repo"] or even just [] + public List<string> Path { get; set; } = []; + + // Where the path originates, either in the home directory or on the root + // of the system + public ListDirectoryRelativity Relativity { get; set; } = ListDirectoryRelativity.Root; +} + +public class ListDirectoryItem +{ + public required string Name { get; init; } + public required string AbsolutePathString { get; init; } + public required bool IsDir { get; init; } +} + +public class ListDirectoryResponse +{ + // The resolved absolute path (always from root) for future requests. + // E.g. if you did a request like `home: ["repo"]`, + // this would return ["home", "coder", "repo"] and "/home/coder/repo" + public required List<string> AbsolutePath { get; init; } + + // e.g. "C:\\Users\\coder\\repo" or "/home/coder/repo" + public required string AbsolutePathString { get; init; } + public required List<ListDirectoryItem> Contents { get; init; } +} + +public partial class AgentApiClient +{ + public Task<ListDirectoryResponse> ListDirectory(ListDirectoryRequest req, CancellationToken ct = default) + { + return SendRequestAsync<ListDirectoryRequest, ListDirectoryResponse>(HttpMethod.Post, "/api/v0/list-directory", + req, ct); + } +} diff --git a/CoderSdk/Coder/CoderApiClient.cs b/CoderSdk/Coder/CoderApiClient.cs new file mode 100644 index 0000000..79c5c2f --- /dev/null +++ b/CoderSdk/Coder/CoderApiClient.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace Coder.Desktop.CoderSdk.Coder; + +public interface ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl); +} + +public class CoderApiClientFactory : ICoderApiClientFactory +{ + public ICoderApiClient Create(string baseUrl) + { + return new CoderApiClient(baseUrl); + } +} + +public partial interface ICoderApiClient +{ + public void SetSessionToken(string token); +} + +[JsonSerializable(typeof(BuildInfo))] +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(User))] +[JsonSerializable(typeof(ValidationError))] +public partial class CoderApiJsonContext : JsonSerializerContext; + +/// <summary> +/// Provides a limited selection of API methods for a Coder instance. +/// </summary> +public partial class CoderApiClient : ICoderApiClient +{ + private const string SessionTokenHeader = "Coder-Session-Token"; + + private readonly JsonHttpClient _httpClient; + + public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) + { + } + + public CoderApiClient(Uri baseUrl) + { + if (baseUrl.PathAndQuery != "/") + throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); + _httpClient = new JsonHttpClient(baseUrl, CoderApiJsonContext.Default); + } + + public CoderApiClient(string baseUrl, string token) : this(baseUrl) + { + SetSessionToken(token); + } + + public void SetSessionToken(string token) + { + _httpClient.RemoveHeader(SessionTokenHeader); + _httpClient.SetHeader(SessionTokenHeader, token); + } + + private async Task<TResponse> SendRequestNoBodyAsync<TResponse>(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync<object, TResponse>(method, path, null, ct); + } + + private Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync<TRequest, TResponse>(method, path, payload, ct); + } +} diff --git a/CoderSdk/Deployment.cs b/CoderSdk/Coder/Deployment.cs similarity index 91% rename from CoderSdk/Deployment.cs rename to CoderSdk/Coder/Deployment.cs index e95e039..978d79d 100644 --- a/CoderSdk/Deployment.cs +++ b/CoderSdk/Coder/Deployment.cs @@ -1,4 +1,4 @@ -namespace Coder.Desktop.CoderSdk; +namespace Coder.Desktop.CoderSdk.Coder; public partial interface ICoderApiClient { diff --git a/CoderSdk/Users.cs b/CoderSdk/Coder/Users.cs similarity index 91% rename from CoderSdk/Users.cs rename to CoderSdk/Coder/Users.cs index fd81b32..6d1914b 100644 --- a/CoderSdk/Users.cs +++ b/CoderSdk/Coder/Users.cs @@ -1,4 +1,4 @@ -namespace Coder.Desktop.CoderSdk; +namespace Coder.Desktop.CoderSdk.Coder; public partial interface ICoderApiClient { diff --git a/CoderSdk/CoderApiClient.cs b/CoderSdk/CoderApiClient.cs deleted file mode 100644 index df2d923..0000000 --- a/CoderSdk/CoderApiClient.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Coder.Desktop.CoderSdk; - -public interface ICoderApiClientFactory -{ - public ICoderApiClient Create(string baseUrl); -} - -public class CoderApiClientFactory : ICoderApiClientFactory -{ - public ICoderApiClient Create(string baseUrl) - { - return new CoderApiClient(baseUrl); - } -} - -public partial interface ICoderApiClient -{ - public void SetSessionToken(string token); -} - -/// <summary> -/// Changes names from PascalCase to snake_case. -/// </summary> -internal class SnakeCaseNamingPolicy : JsonNamingPolicy -{ - public override string ConvertName(string name) - { - return string.Concat( - name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + char.ToLower(x) : char.ToLower(x).ToString()) - ); - } -} - -[JsonSerializable(typeof(BuildInfo))] -[JsonSerializable(typeof(Response))] -[JsonSerializable(typeof(User))] -[JsonSerializable(typeof(ValidationError))] -public partial class CoderSdkJsonContext : JsonSerializerContext; - -/// <summary> -/// Provides a limited selection of API methods for a Coder instance. -/// </summary> -public partial class CoderApiClient : ICoderApiClient -{ - public static readonly JsonSerializerOptions JsonOptions = new() - { - TypeInfoResolver = CoderSdkJsonContext.Default, - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = new SnakeCaseNamingPolicy(), - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - // TODO: allow adding headers - private readonly HttpClient _httpClient = new(); - - public CoderApiClient(string baseUrl) : this(new Uri(baseUrl, UriKind.Absolute)) - { - } - - public CoderApiClient(Uri baseUrl) - { - if (baseUrl.PathAndQuery != "/") - throw new ArgumentException($"Base URL '{baseUrl}' must not contain a path", nameof(baseUrl)); - _httpClient.BaseAddress = baseUrl; - } - - public CoderApiClient(string baseUrl, string token) : this(baseUrl) - { - SetSessionToken(token); - } - - public void SetSessionToken(string token) - { - _httpClient.DefaultRequestHeaders.Remove("Coder-Session-Token"); - _httpClient.DefaultRequestHeaders.Add("Coder-Session-Token", token); - } - - private async Task<TResponse> SendRequestNoBodyAsync<TResponse>(HttpMethod method, string path, - CancellationToken ct = default) - { - return await SendRequestAsync<object, TResponse>(method, path, null, ct); - } - - private async Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod method, string path, - TRequest? payload, CancellationToken ct = default) - { - try - { - var request = new HttpRequestMessage(method, path); - - if (payload is not null) - { - var json = JsonSerializer.Serialize(payload, typeof(TRequest), JsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - - var res = await _httpClient.SendAsync(request, ct); - if (!res.IsSuccessStatusCode) - throw await CoderApiHttpException.FromResponse(res, ct); - - var content = await res.Content.ReadAsStringAsync(ct); - var data = JsonSerializer.Deserialize<TResponse>(content, JsonOptions); - if (data is null) throw new JsonException("Deserialized response is null"); - return data; - } - catch (CoderApiHttpException) - { - throw; - } - catch (Exception e) - { - throw new Exception($"Coder API Request failed: {method} {path}", e); - } - } -} diff --git a/CoderSdk/Errors.cs b/CoderSdk/Errors.cs index 4d79a59..a7c56c0 100644 --- a/CoderSdk/Errors.cs +++ b/CoderSdk/Errors.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using System.Text.Json.Serialization; namespace Coder.Desktop.CoderSdk; @@ -16,8 +17,20 @@ public class Response public List<ValidationError> Validations { get; set; } = []; } +[JsonSerializable(typeof(Response))] +[JsonSerializable(typeof(ValidationError))] +public partial class ErrorJsonContext : JsonSerializerContext; + public class CoderApiHttpException : Exception { + private static readonly JsonSerializerOptions JsonOptions = new() + { + TypeInfoResolver = ErrorJsonContext.Default, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + private static readonly Dictionary<HttpStatusCode, string> Helpers = new() { { HttpStatusCode.Unauthorized, "Try signing in again" }, @@ -45,7 +58,7 @@ public static async Task<CoderApiHttpException> FromResponse(HttpResponseMessage Response? responseObject; try { - responseObject = JsonSerializer.Deserialize<Response>(content, CoderApiClient.JsonOptions); + responseObject = JsonSerializer.Deserialize<Response>(content, JsonOptions); } catch (JsonException) { diff --git a/CoderSdk/JsonHttpClient.cs b/CoderSdk/JsonHttpClient.cs new file mode 100644 index 0000000..362391e --- /dev/null +++ b/CoderSdk/JsonHttpClient.cs @@ -0,0 +1,82 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Coder.Desktop.CoderSdk; + +/// <summary> +/// Changes names from PascalCase to snake_case. +/// </summary> +internal class SnakeCaseNamingPolicy : JsonNamingPolicy +{ + public override string ConvertName(string name) + { + return string.Concat( + name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + char.ToLower(x) : char.ToLower(x).ToString()) + ); + } +} + +internal class JsonHttpClient +{ + private readonly JsonSerializerOptions _jsonOptions; + + // TODO: allow users to add headers + private readonly HttpClient _httpClient = new(); + + public JsonHttpClient(Uri baseUri, IJsonTypeInfoResolver typeResolver) + { + _jsonOptions = new JsonSerializerOptions + { + TypeInfoResolver = typeResolver, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + _jsonOptions.Converters.Add(new JsonStringEnumConverter(new SnakeCaseNamingPolicy(), false)); + _httpClient.BaseAddress = baseUri; + } + + public void RemoveHeader(string key) + { + _httpClient.DefaultRequestHeaders.Remove(key); + } + + public void SetHeader(string key, string value) + { + _httpClient.DefaultRequestHeaders.Add(key, value); + } + + public async Task<TResponse> SendRequestAsync<TRequest, TResponse>(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + try + { + var request = new HttpRequestMessage(method, path); + + if (payload is not null) + { + var json = JsonSerializer.Serialize(payload, typeof(TRequest), _jsonOptions); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + var res = await _httpClient.SendAsync(request, ct); + if (!res.IsSuccessStatusCode) + throw await CoderApiHttpException.FromResponse(res, ct); + + var content = await res.Content.ReadAsStringAsync(ct); + var data = JsonSerializer.Deserialize<TResponse>(content, _jsonOptions); + if (data is null) throw new JsonException("Deserialized response is null"); + return data; + } + catch (CoderApiHttpException) + { + throw; + } + catch (Exception e) + { + throw new Exception($"API Request failed: {method} {path}", e); + } + } +} diff --git a/Installer/Program.cs b/Installer/Program.cs index 1894a2d..10a09a7 100644 --- a/Installer/Program.cs +++ b/Installer/Program.cs @@ -2,9 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using CommandLine; -using Microsoft.Extensions.Configuration; using WixSharp; using WixSharp.Bootstrapper; using WixSharp.CommonTasks; @@ -389,8 +387,8 @@ private static int BuildBundle(BootstrapperOptions opts) [ new ExePackagePayload { - SourceFile = opts.WindowsAppSdkPath - } + SourceFile = opts.WindowsAppSdkPath, + }, ], }, new MsiPackage(opts.MsiPath) diff --git a/Tests.App/Services/CredentialManagerTest.cs b/Tests.App/Services/CredentialManagerTest.cs index 2fa4699..9d00cf2 100644 --- a/Tests.App/Services/CredentialManagerTest.cs +++ b/Tests.App/Services/CredentialManagerTest.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; -using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Moq; namespace Coder.Desktop.Tests.App.Services; diff --git a/Tests.App/Services/MutagenControllerTest.cs b/Tests.App/Services/MutagenControllerTest.cs index c834009..2c97515 100644 --- a/Tests.App/Services/MutagenControllerTest.cs +++ b/Tests.App/Services/MutagenControllerTest.cs @@ -113,6 +113,7 @@ public async Task Ok(CancellationToken ct) await AssertDaemonStopped(dataDirectory, ct); var progressMessages = new List<string>(); + void OnProgress(string message) { TestContext.Out.WriteLine("Create session progress: " + message); diff --git a/Vpn.Service/Downloader.cs b/Vpn.Service/Downloader.cs index c7b94c6..6a3108b 100644 --- a/Vpn.Service/Downloader.cs +++ b/Vpn.Service/Downloader.cs @@ -297,15 +297,10 @@ public async Task<DownloadTask> StartDownloadAsync(HttpRequestMessage req, strin // remove the key first, before checking the exception, to ensure // we still clean up. _downloads.TryRemove(destinationPath, out _); - if (tsk.Exception == null) - { - return; - } + if (tsk.Exception == null) return; if (tsk.Exception.InnerException != null) - { ExceptionDispatchInfo.Capture(tsk.Exception.InnerException).Throw(); - } // not sure if this is hittable, but just in case: throw tsk.Exception; @@ -328,7 +323,7 @@ public async Task<DownloadTask> StartDownloadAsync(HttpRequestMessage req, strin } /// <summary> - /// TaskOrCancellation waits for either the task to complete, or the given token to be canceled. + /// TaskOrCancellation waits for either the task to complete, or the given token to be canceled. /// </summary> internal static async Task TaskOrCancellation(Task task, CancellationToken cancellationToken) { @@ -454,7 +449,6 @@ private async Task Start(CancellationToken ct = default) TotalBytes = (ulong)res.Content.Headers.ContentLength; await Download(res, ct); - return; } private async Task Download(HttpResponseMessage res, CancellationToken ct) @@ -472,6 +466,7 @@ private async Task Download(HttpResponseMessage res, CancellationToken ct) _logger.LogError(e, "Failed to create temporary file '{TempDestinationPath}'", TempDestinationPath); throw; } + await using (tempFile) { var stream = await res.Content.ReadAsStreamAsync(ct); diff --git a/Vpn.Service/Manager.cs b/Vpn.Service/Manager.cs index 1eca8bf..fc014c0 100644 --- a/Vpn.Service/Manager.cs +++ b/Vpn.Service/Manager.cs @@ -1,5 +1,5 @@ using System.Runtime.InteropServices; -using Coder.Desktop.CoderSdk; +using Coder.Desktop.CoderSdk.Coder; using Coder.Desktop.Vpn.Proto; using Coder.Desktop.Vpn.Utilities; using Microsoft.Extensions.Logging;