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(); services.AddSingleton(); + // SignInWindow views and view models + services.AddTransient(); + services.AddTransient(); + + // TrayWindow views and view models services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -42,14 +46,14 @@ public App() protected override void OnLaunched(LaunchActivatedEventArgs args) { - _trayWindow = _services.GetRequiredService(); - _trayWindow.Closed += (sender, args) => + var trayWindow = _services.GetRequiredService(); + 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; + +/// +/// A static utility class to house methods related to the visual scale of the display monitor. +/// +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; + +/// +/// The View Model backing the sign in window and all its associated pages. +/// +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.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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +