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 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive 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(); + services.AddSingleton(); services.AddSingleton(); @@ -76,6 +80,8 @@ public App() // FileSyncListMainPage is created by FileSyncListWindow. services.AddTransient(); + // DirectoryPickerWindow views and view models are created by FileSyncListViewModel. + // TrayWindow views and view models services.AddTransient(); services.AddTransient(); @@ -89,7 +95,7 @@ public App() services.AddTransient(); _services = services.BuildServiceProvider(); - _logger = (ILogger)(_services.GetService(typeof(ILogger))!); + _logger = (ILogger)_services.GetService(typeof(ILogger))!; 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; public sealed class StringToBrushSelector : DependencyObjectSelector; + +public sealed class StringToStringSelectorItem : DependencyObjectSelectorItem; + +public sealed class StringToStringSelector : DependencyObjectSelector; 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 /// Task RefreshState(CancellationToken ct = default); - Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback, CancellationToken ct = default); + Task CreateSyncSession(CreateSyncSessionRequest req, Action progressCallback, + CancellationToken ct = default); + Task PauseSyncSession(string identifier, CancellationToken ct = default); Task ResumeSyncSession(string identifier, CancellationToken ct = default); Task TerminateSyncSession(string identifier, CancellationToken ct = default); @@ -200,7 +203,8 @@ public async Task RefreshState(CancellationToke return state; } - public async Task CreateSyncSession(CreateSyncSessionRequest req, Action? progressCallback = null, CancellationToken ct = default) + public async Task CreateSyncSession(CreateSyncSessionRequest req, + Action? progressCallback = null, CancellationToken ct = default) { using var _ = await _lock.LockAsync(ct); var client = await EnsureDaemon(ct); @@ -216,8 +220,11 @@ public async Task 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 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 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? 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 Breadcrumbs { get; set; } = []; + + [ObservableProperty] public partial IReadOnlyList 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 BackgroundLoad(ListDirectoryRelativity relativity, List 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 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 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(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(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 Sessions { get; set; } = []; + [ObservableProperty] public partial IReadOnlyList 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 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 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(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 @@ + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -132,7 +138,7 @@ @@ -266,7 +272,7 @@ @@ -317,7 +323,7 @@ + SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(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 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 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 AbsolutePath { get; init; } + + // e.g. "C:\\Users\\coder\\repo" or "/home/coder/repo" + public required string AbsolutePathString { get; init; } + public required List Contents { get; init; } +} + +public partial class AgentApiClient +{ + public Task ListDirectory(ListDirectoryRequest req, CancellationToken ct = default) + { + return SendRequestAsync(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; + +/// +/// Provides a limited selection of API methods for a Coder instance. +/// +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 SendRequestNoBodyAsync(HttpMethod method, string path, + CancellationToken ct = default) + { + return await SendRequestAsync(method, path, null, ct); + } + + private Task SendRequestAsync(HttpMethod method, string path, + TRequest? payload, CancellationToken ct = default) + { + return _httpClient.SendRequestAsync(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); -} - -/// -/// Changes names from PascalCase to snake_case. -/// -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; - -/// -/// Provides a limited selection of API methods for a Coder instance. -/// -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 SendRequestNoBodyAsync(HttpMethod method, string path, - CancellationToken ct = default) - { - return await SendRequestAsync(method, path, null, ct); - } - - private async Task SendRequestAsync(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(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 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 Helpers = new() { { HttpStatusCode.Unauthorized, "Try signing in again" }, @@ -45,7 +58,7 @@ public static async Task FromResponse(HttpResponseMessage Response? responseObject; try { - responseObject = JsonSerializer.Deserialize(content, CoderApiClient.JsonOptions); + responseObject = JsonSerializer.Deserialize(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; + +/// +/// Changes names from PascalCase to snake_case. +/// +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 SendRequestAsync(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(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(); + 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 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 StartDownloadAsync(HttpRequestMessage req, strin } /// - /// 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. /// 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;