diff --git a/App/App.xaml.cs b/App/App.xaml.cs index 29e775d..cce3650 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -12,7 +12,6 @@ namespace Coder.Desktop.App; public partial class App : Application { private readonly IServiceProvider _services; - private TrayWindow? _trayWindow; private readonly bool _handleClosedEvents = true; public App() @@ -21,6 +20,11 @@ public App() services.AddSingleton<ICredentialManager, CredentialManager>(); services.AddSingleton<IRpcController, RpcController>(); + // SignInWindow views and view models + services.AddTransient<SignInViewModel>(); + services.AddTransient<SignInWindow>(); + + // TrayWindow views and view models services.AddTransient<TrayWindowDisconnectedViewModel>(); services.AddTransient<TrayWindowDisconnectedPage>(); services.AddTransient<TrayWindowLoginRequiredViewModel>(); @@ -42,14 +46,14 @@ public App() protected override void OnLaunched(LaunchActivatedEventArgs args) { - _trayWindow = _services.GetRequiredService<TrayWindow>(); - _trayWindow.Closed += (sender, args) => + var trayWindow = _services.GetRequiredService<TrayWindow>(); + trayWindow.Closed += (sender, args) => { // TODO: wire up HandleClosedEvents properly if (_handleClosedEvents) { args.Handled = true; - _trayWindow.AppWindow.Hide(); + trayWindow.AppWindow.Hide(); } }; } diff --git a/App/DisplayScale.cs b/App/DisplayScale.cs new file mode 100644 index 0000000..cd5101c --- /dev/null +++ b/App/DisplayScale.cs @@ -0,0 +1,26 @@ +using System; +using System.Runtime.InteropServices; +using Microsoft.UI.Xaml; +using WinRT.Interop; + +namespace Coder.Desktop.App; + +/// <summary> +/// A static utility class to house methods related to the visual scale of the display monitor. +/// </summary> +public static class DisplayScale +{ + public static double WindowScale(Window win) + { + var hwnd = WindowNative.GetWindowHandle(win); + var dpi = NativeApi.GetDpiForWindow(hwnd); + if (dpi == 0) return 1; // assume scale of 1 + return dpi / 96.0; // 96 DPI == 1 + } + + public class NativeApi + { + [DllImport("user32.dll")] + public static extern int GetDpiForWindow(IntPtr hwnd); + } +} diff --git a/App/Services/CredentialManager.cs b/App/Services/CredentialManager.cs index ad2f366..af1dbae 100644 --- a/App/Services/CredentialManager.cs +++ b/App/Services/CredentialManager.cs @@ -67,6 +67,7 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT try { var sdkClient = new CoderApiClient(uri); + sdkClient.SetSessionToken(apiToken); // TODO: we should probably perform a version check here too, // rather than letting the service do it on Start _ = await sdkClient.GetBuildInfo(ct); diff --git a/App/ViewModels/SignInViewModel.cs b/App/ViewModels/SignInViewModel.cs new file mode 100644 index 0000000..3dc162c --- /dev/null +++ b/App/ViewModels/SignInViewModel.cs @@ -0,0 +1,138 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Coder.Desktop.App.Services; +using Coder.Desktop.App.Views; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.UI.Xaml; + +namespace Coder.Desktop.App.ViewModels; + +/// <summary> +/// The View Model backing the sign in window and all its associated pages. +/// </summary> +public partial class SignInViewModel : ObservableObject +{ + private readonly ICredentialManager _credentialManager; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CoderUrlError))] + [NotifyPropertyChangedFor(nameof(GenTokenUrl))] + public partial string CoderUrl { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CoderUrlError))] + public partial bool CoderUrlTouched { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ApiTokenError))] + public partial string ApiToken { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ApiTokenError))] + public partial bool ApiTokenTouched { get; set; } = false; + + [ObservableProperty] + public partial string? SignInError { get; set; } = null; + + [ObservableProperty] + public partial bool SignInLoading { get; set; } = false; + + public string? CoderUrlError => CoderUrlTouched ? _coderUrlError : null; + + private string? _coderUrlError + { + get + { + if (!Uri.TryCreate(CoderUrl, UriKind.Absolute, out var uri)) + return "Invalid URL"; + if (uri.Scheme is not "http" and not "https") + return "Must be a HTTP or HTTPS URL"; + if (uri.PathAndQuery != "/") + return "Must be a root URL with no path or query"; + return null; + } + } + + public string? ApiTokenError => ApiTokenTouched ? _apiTokenError : null; + + private string? _apiTokenError => string.IsNullOrWhiteSpace(ApiToken) ? "Invalid token" : null; + + public Uri GenTokenUrl + { + get + { + // In case somehow the URL is invalid, just default to coder.com. + // The HyperlinkButton will crash the entire app if the URL is + // invalid. + try + { + var baseUri = new Uri(CoderUrl.Trim()); + var cliAuthUri = new Uri(baseUri, "/cli-auth"); + return cliAuthUri; + } + catch + { + return new Uri("https://coder.com"); + } + } + } + + public SignInViewModel(ICredentialManager credentialManager) + { + _credentialManager = credentialManager; + } + + public void CoderUrl_FocusLost(object sender, RoutedEventArgs e) + { + CoderUrlTouched = true; + } + + public void ApiToken_FocusLost(object sender, RoutedEventArgs e) + { + ApiTokenTouched = true; + } + + [RelayCommand] + public void UrlPage_Next(SignInWindow signInWindow) + { + CoderUrlTouched = true; + if (_coderUrlError != null) return; + signInWindow.NavigateToTokenPage(); + } + + [RelayCommand] + public void TokenPage_Back(SignInWindow signInWindow) + { + ApiToken = ""; + signInWindow.NavigateToUrlPage(); + } + + [RelayCommand] + public async Task TokenPage_SignIn(SignInWindow signInWindow) + { + CoderUrlTouched = true; + ApiTokenTouched = true; + if (_coderUrlError != null || _apiTokenError != null) return; + + try + { + SignInLoading = true; + SignInError = null; + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await _credentialManager.SetCredentials(CoderUrl.Trim(), ApiToken.Trim(), cts.Token); + + signInWindow.Close(); + } + catch (Exception e) + { + SignInError = $"Failed to sign in: {e}"; + } + finally + { + SignInLoading = false; + } + } +} diff --git a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs index c63b8f4..628be72 100644 --- a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs +++ b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs @@ -1,13 +1,34 @@ -using CommunityToolkit.Mvvm.ComponentModel; +using System; +using Coder.Desktop.App.Views; using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.DependencyInjection; namespace Coder.Desktop.App.ViewModels; -public partial class TrayWindowLoginRequiredViewModel : ObservableObject +public partial class TrayWindowLoginRequiredViewModel { + private readonly IServiceProvider _services; + + private SignInWindow? _signInWindow; + + public TrayWindowLoginRequiredViewModel(IServiceProvider services) + { + _services = services; + } + [RelayCommand] public void Login() { - // TODO: open the login window + // This is safe against concurrent access since it all happens in the + // UI thread. + if (_signInWindow != null) + { + _signInWindow.Activate(); + return; + } + + _signInWindow = _services.GetRequiredService<SignInWindow>(); + _signInWindow.Closed += (_, _) => _signInWindow = null; + _signInWindow.Activate(); } } diff --git a/App/Views/Pages/SignInTokenPage.xaml b/App/Views/Pages/SignInTokenPage.xaml new file mode 100644 index 0000000..dde2d5c --- /dev/null +++ b/App/Views/Pages/SignInTokenPage.xaml @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="utf-8"?> + +<Page + x:Class="Coder.Desktop.App.Views.Pages.SignInTokenPage" + 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" + mc:Ignorable="d" + Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + + <StackPanel + Orientation="Vertical" + HorizontalAlignment="Stretch" + VerticalAlignment="Top" + Padding="20" + Spacing="10"> + + <TextBlock + Text="Coder Desktop" + FontSize="24" + VerticalAlignment="Center" + HorizontalAlignment="Center" /> + + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="1*" /> + <RowDefinition Height="1*" /> + <RowDefinition Height="1*" /> + <RowDefinition Height="1*" /> + <RowDefinition Height="1*" /> + </Grid.RowDefinitions> + + <TextBlock + Grid.Column="0" + Grid.Row="0" + Text="Server URL" + HorizontalAlignment="Right" + Padding="10" /> + + <TextBlock + Grid.Column="1" + Grid.Row="0" + HorizontalAlignment="Stretch" + VerticalAlignment="Center" + Padding="10" + Text="{x:Bind ViewModel.CoderUrl, Mode=OneWay}" /> + + <TextBlock + Grid.Column="0" + Grid.Row="2" + Text="Session Token" + HorizontalAlignment="Right" + Padding="10" /> + + <TextBox + Grid.Column="1" + Grid.Row="2" + HorizontalAlignment="Stretch" + PlaceholderText="Paste your token here" + LostFocus="{x:Bind ViewModel.ApiToken_FocusLost, Mode=OneWay}" + Text="{x:Bind ViewModel.ApiToken, Mode=TwoWay}" /> + + <TextBlock + Grid.Column="1" + Grid.Row="3" + Text="{x:Bind ViewModel.ApiTokenError, Mode=OneWay}" + Foreground="Red" /> + + <HyperlinkButton + Grid.Column="1" + Grid.Row="4" + Content="Generate a token via the Web UI" + NavigateUri="{x:Bind ViewModel.GenTokenUrl, Mode=OneWay}" /> + </Grid> + + <StackPanel + Orientation="Horizontal" + HorizontalAlignment="Center" + Spacing="10"> + + <Button + Content="Back" HorizontalAlignment="Right" + Command="{x:Bind ViewModel.TokenPage_BackCommand, Mode=OneWay}" + CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" /> + + <Button + Content="Sign In" + HorizontalAlignment="Left" + Style="{StaticResource AccentButtonStyle}" + Command="{x:Bind ViewModel.TokenPage_SignInCommand, Mode=OneWay}" + CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" /> + </StackPanel> + + <TextBlock + Text="{x:Bind ViewModel.SignInError, Mode=OneWay}" + HorizontalAlignment="Center" + Foreground="Red" /> + </StackPanel> +</Page> diff --git a/App/Views/Pages/SignInTokenPage.xaml.cs b/App/Views/Pages/SignInTokenPage.xaml.cs new file mode 100644 index 0000000..1219508 --- /dev/null +++ b/App/Views/Pages/SignInTokenPage.xaml.cs @@ -0,0 +1,20 @@ +using Coder.Desktop.App.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Views.Pages; + +/// <summary> +/// A sign in page to accept the user's Coder token. +/// </summary> +public sealed partial class SignInTokenPage : Page +{ + public readonly SignInViewModel ViewModel; + public readonly SignInWindow SignInWindow; + + public SignInTokenPage(SignInWindow parent, SignInViewModel viewModel) + { + InitializeComponent(); + ViewModel = viewModel; + SignInWindow = parent; + } +} diff --git a/App/Views/Pages/SignInUrlPage.xaml b/App/Views/Pages/SignInUrlPage.xaml new file mode 100644 index 0000000..1c12b03 --- /dev/null +++ b/App/Views/Pages/SignInUrlPage.xaml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="utf-8"?> + +<Page + x:Class="Coder.Desktop.App.Views.Pages.SignInUrlPage" + 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" + mc:Ignorable="d" + Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> + + <StackPanel + Orientation="Vertical" + HorizontalAlignment="Stretch" + VerticalAlignment="Top" + Padding="20" + Spacing="10"> + + <TextBlock + Text="Coder Desktop" + FontSize="24" + VerticalAlignment="Center" + HorizontalAlignment="Center" /> + + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="1*" /> + <RowDefinition Height="1*" /> + <RowDefinition Height="1*" /> + <RowDefinition Height="1*" /> + </Grid.RowDefinitions> + + <TextBlock + Grid.Column="0" + Grid.Row="0" + Text="Server URL" + HorizontalAlignment="Right" + Padding="10 " /> + + <TextBox + Grid.Column="1" + Grid.Row="0" + HorizontalAlignment="Stretch" + PlaceholderText="https://coder.example.com" + LostFocus="{x:Bind ViewModel.CoderUrl_FocusLost, Mode=OneWay}" + Text="{x:Bind ViewModel.CoderUrl, Mode=TwoWay}" /> + + <TextBlock + Grid.Column="1" + Grid.Row="1" + Text="{x:Bind ViewModel.CoderUrlError, Mode=OneWay}" + Foreground="Red" /> + </Grid> + + <Button + Content="Next" + HorizontalAlignment="Center" + Command="{x:Bind ViewModel.UrlPage_NextCommand, Mode=OneWay}" + CommandParameter="{x:Bind SignInWindow, Mode=OneWay}" + Style="{StaticResource AccentButtonStyle}" /> + </StackPanel> +</Page> diff --git a/App/Views/Pages/SignInUrlPage.xaml.cs b/App/Views/Pages/SignInUrlPage.xaml.cs new file mode 100644 index 0000000..175a8c2 --- /dev/null +++ b/App/Views/Pages/SignInUrlPage.xaml.cs @@ -0,0 +1,20 @@ +using Coder.Desktop.App.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Coder.Desktop.App.Views.Pages; + +/// <summary> +/// A login page to enter the Coder Server URL +/// </summary> +public sealed partial class SignInUrlPage : Page +{ + public readonly SignInViewModel ViewModel; + public readonly SignInWindow SignInWindow; + + public SignInUrlPage(SignInWindow parent, SignInViewModel viewModel) + { + InitializeComponent(); + ViewModel = viewModel; + SignInWindow = parent; + } +} diff --git a/App/Views/SignInWindow.xaml b/App/Views/SignInWindow.xaml new file mode 100644 index 0000000..299c0c1 --- /dev/null +++ b/App/Views/SignInWindow.xaml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> + +<Window + x:Class="Coder.Desktop.App.Views.SignInWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" + Title="Sign in to Coder"> + + <Window.SystemBackdrop> + <DesktopAcrylicBackdrop /> + </Window.SystemBackdrop> + + <Frame x:Name="RootFrame" /> +</Window> diff --git a/App/Views/SignInWindow.xaml.cs b/App/Views/SignInWindow.xaml.cs new file mode 100644 index 0000000..5549e7c --- /dev/null +++ b/App/Views/SignInWindow.xaml.cs @@ -0,0 +1,46 @@ +using Windows.Graphics; +using Coder.Desktop.App.ViewModels; +using Coder.Desktop.App.Views.Pages; +using Microsoft.UI.Xaml; + +namespace Coder.Desktop.App.Views; + +/// <summary> +/// The dialog window to allow the user to sign into their Coder server. +/// </summary> +public sealed partial class SignInWindow : Window +{ + private const double WIDTH = 600.0; + private const double HEIGHT = 300.0; + + private readonly SignInUrlPage _signInUrlPage; + private readonly SignInTokenPage _signInTokenPage; + + public SignInWindow(SignInViewModel viewModel) + { + InitializeComponent(); + _signInUrlPage = new SignInUrlPage(this, viewModel); + _signInTokenPage = new SignInTokenPage(this, viewModel); + + NavigateToUrlPage(); + ResizeWindow(); + } + + public void NavigateToTokenPage() + { + RootFrame.Content = _signInTokenPage; + } + + public void NavigateToUrlPage() + { + RootFrame.Content = _signInUrlPage; + } + + private void ResizeWindow() + { + var scale = DisplayScale.WindowScale(this); + var height = (int)(HEIGHT * scale); + var width = (int)(WIDTH * scale); + AppWindow.Resize(new SizeInt32(width, height)); + } +}