From e78065f5f3b899ea27362226d175d5650b9e55cd Mon Sep 17 00:00:00 2001 From: Pieter van Ginkel Date: Thu, 28 Dec 2023 13:25:16 +0100 Subject: [PATCH] Browser based interactive authentication (#209) * Added clarification on testing the out of the box experience. Closes #195. * Added support for browser based interactive authentication using an embedded browser. * Improved UX of interactive authentication. * Ensure that the main window is available when showing a modal dialog. * Reorganization. --- .../IInteractiveAuthentication.cs | 46 ------- src/Tql.Abstractions/IUI.cs | 87 ++++++++++++-- src/Tql.App/App.xaml.cs | 3 + src/Tql.App/MainWindow.xaml.cs | 15 +-- src/Tql.App/QuickStart/QuickStartManager.cs | 1 + src/Tql.App/Resources/Radar.png | Bin 3399 -> 0 bytes src/Tql.App/Search/SearchManager.cs | 7 +- src/Tql.App/Services/PluginManager.cs | 9 +- .../TermsOfServiceNotificationService.cs | 7 +- src/Tql.App/Services/UINotification.cs | 3 - ...BasedInteractiveAuthenticationException.cs | 15 +++ ...rBasedInteractiveAuthenticationWindow.xaml | 53 +++++++++ ...sedInteractiveAuthenticationWindow.xaml.cs | 108 +++++++++++++++++ .../InteractiveAuthenticationWindow.xaml | 6 +- .../InteractiveAuthenticationWindow.xaml.cs | 27 ++++- .../UIService/SimpleHttpRequestEventArgs.cs | 9 ++ .../Services/UIService/SimpleHttpServer.cs | 64 ++++++++++ src/Tql.App/Services/{ => UIService}/UI.cs | 112 ++++++++++++++---- .../Services/UIService/UINotification.cs | 10 ++ .../Support/NotificationBarUserControl.xaml | 4 +- .../NotificationBarUserControl.xaml.cs | 1 + src/Tql.App/Support/ProgressWindow.xaml.cs | 1 + src/Tql.App/Support/TaskUtils.cs | 5 + .../Support/UINotificationEventArgs.cs | 1 + src/Tql.App/Tql.App.csproj | 1 + src/Tql.App/packages.lock.json | 6 + src/Tql.Benchmarks/packages.lock.json | 6 + src/Tql.DebugApp/packages.lock.json | 5 +- src/Tql.Plugins.Azure/Services/AzureApi.cs | 30 ++--- .../Services/AzureDevOpsApi.cs | 2 +- .../Services/ConfluenceClient.cs | 2 +- .../Categories/NewPullRequestMatch.cs | 1 - src/Tql.Plugins.GitHub/Labels.Designer.cs | 9 -- src/Tql.Plugins.GitHub/Labels.nl.resx | 4 - src/Tql.Plugins.GitHub/Labels.resx | 3 - src/Tql.Plugins.GitHub/Services/GitHubApi.cs | 66 +++++------ .../Services/GitHubOAuthWorkflow.cs | 50 -------- .../Services/SimpleHttpServer.cs | 55 --------- src/Tql.Plugins.Jira/Services/JiraClient.cs | 4 +- src/Tql.Utilities/UIExtensions.cs | 44 +++++++ tests/Tql.App.Test/packages.lock.json | 6 + .../Tql.PluginTestSupport/Services/TestUI.cs | 25 +++- .../Tql.PluginTestSupport/packages.lock.json | 6 + .../Tql.Plugins.Demo.Test/packages.lock.json | 6 + .../packages.lock.json | 6 + 45 files changed, 627 insertions(+), 304 deletions(-) delete mode 100644 src/Tql.Abstractions/IInteractiveAuthentication.cs delete mode 100644 src/Tql.App/Resources/Radar.png delete mode 100644 src/Tql.App/Services/UINotification.cs create mode 100644 src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationException.cs create mode 100644 src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml create mode 100644 src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml.cs rename src/Tql.App/Services/{ => UIService}/InteractiveAuthenticationWindow.xaml (88%) rename src/Tql.App/Services/{ => UIService}/InteractiveAuthenticationWindow.xaml.cs (55%) create mode 100644 src/Tql.App/Services/UIService/SimpleHttpRequestEventArgs.cs create mode 100644 src/Tql.App/Services/UIService/SimpleHttpServer.cs rename src/Tql.App/Services/{ => UIService}/UI.cs (70%) create mode 100644 src/Tql.App/Services/UIService/UINotification.cs delete mode 100644 src/Tql.Plugins.GitHub/Services/GitHubOAuthWorkflow.cs delete mode 100644 src/Tql.Plugins.GitHub/Services/SimpleHttpServer.cs diff --git a/src/Tql.Abstractions/IInteractiveAuthentication.cs b/src/Tql.Abstractions/IInteractiveAuthentication.cs deleted file mode 100644 index dbe1490b..00000000 --- a/src/Tql.Abstractions/IInteractiveAuthentication.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Windows.Forms; - -namespace Tql.Abstractions; - -/// -/// Represents an interactive authentication operation. -/// -/// -/// -/// If you can, you should use interactive authentication to -/// authenticate the user with online resources. -/// -/// -/// -/// Interactive authentication is initiated by calling -/// . -/// This will show a dialog to the user telling him that he needs -/// to enter credentials. The is used -/// to tell the user what resource is being authenticated. -/// You should include the name of a connect in this to ensure -/// the user knows what credentials he needs to enter. If the -/// user proceeds to authenticate, the -/// method is called to allow you to show UI to the user. -/// -/// -public interface IInteractiveAuthentication -{ - /// - /// Gets the name of the resource that requires authentication. - /// - string ResourceName { get; } - - /// - /// Show UI to the user to allow him to authenticate with the resource. - /// - /// - /// If you're using a library to talk to the online resource, - /// there's a good chance the interactive authentication functionality - /// takes a as the owner. However, if you're - /// creating your own WPF UI, you should use - /// to set the owner of your window to the provided handle. - /// - /// Window to use as the owner for the authentication window. - /// Task representing the operation. - Task Authenticate(IWin32Window owner); -} diff --git a/src/Tql.Abstractions/IUI.cs b/src/Tql.Abstractions/IUI.cs index f215b5af..0ad6b110 100644 --- a/src/Tql.Abstractions/IUI.cs +++ b/src/Tql.Abstractions/IUI.cs @@ -1,4 +1,5 @@ -using System.Windows.Forms; +using System.Collections.Specialized; +using System.Windows.Forms; namespace Tql.Abstractions; @@ -23,14 +24,59 @@ public interface IUI /// Perform interactive authentication. /// /// - /// If the tool you're connecting to supports interactive authentication, - /// you can integrate this into TQL using this method and the - /// interface. See the documentation - /// for this interface for more information. + /// + /// This method can be used to perform interactive authentication. + /// A dialog will be shown to the user telling him that he needs + /// to enter credentials. The information in + /// is used to tell the user what resource is being authenticated. + /// You should include the name of a connection in this to ensure + /// the user knows what credentials he needs to enter. If the + /// user proceeds to authenticate, the + /// parameter is used to show UI to the user. + /// + /// + /// + /// If you're using a library to talk to the online resource, + /// there's a good chance the interactive authentication functionality + /// takes a as the owner. However, if you're + /// creating your own WPF UI, you should use + /// to set the owner of your window to the provided handle. + /// /// - /// Pending interactive authentication. + /// Information about the resource being authenticated. + /// Action called to authenticate the resource. /// Task representing the operation. - Task PerformInteractiveAuthentication(IInteractiveAuthentication interactiveAuthentication); + Task PerformInteractiveAuthentication( + InteractiveAuthenticationResource resource, + Func action + ); + + /// + /// Perform browser based interactive authentication. + /// + /// + /// + /// This can be used to implement web based interactive authentication workflows + /// while staying in the app. The redirect URL is the URL configured with the authentication + /// provider. TQL will start a web server at that URL and wait for an incoming request. + /// The result of this method includes the parameters sent when the redirect URL + /// was called. + /// + /// + /// + /// The OAuth2 NuGet package is a good option to implement OAuth2 in a TQL plugin. This + /// will provide you with a login URL you can pass into this method. + /// + /// + /// Information about the resource being authenticated. + /// The URL to load in the browser. + /// The URL that's expected to be called. + /// The parameters of the redirect URI request or an exception on failure. + Task PerformBrowserBasedInteractiveAuthentication( + InteractiveAuthenticationResource resource, + string loginUrl, + string redirectUrl + ); /// /// Opens a URL in the default browser. @@ -100,8 +146,8 @@ DialogResult ShowException( void ShowNotificationBar( string key, string message, - Action? activate = null, - Action? dismiss = null + Action? activate = null, + Action? dismiss = null ); /// @@ -131,6 +177,27 @@ void ShowNotificationBar( /// event to force a restart of the app if the user changes /// configuration that requires a restart of the app. /// - /// Mode in which to shutdown the app. + /// Mode used when shutting down the app. void Shutdown(RestartMode mode); } + +/// +/// Describes a resource (e.g. a connection) for interactive authentication. +/// +/// ID of the plugin that the resource belongs to. +/// Unique ID of the resource. +/// Name of the resource. +/// Icon of the resource. +public record InteractiveAuthenticationResource( + Guid PluginId, + Guid ResourceId, + string ResourceName, + ImageSource ResourceIcon +); + +/// +/// Result of a browser based interactive authentication request. +/// +/// The called URL. +/// The parsed parameters of the called URI. +public record BrowserBasedInteractiveAuthenticationResult(Uri Url, NameValueCollection QueryString); diff --git a/src/Tql.App/App.xaml.cs b/src/Tql.App/App.xaml.cs index 810e7e43..3480c2b7 100644 --- a/src/Tql.App/App.xaml.cs +++ b/src/Tql.App/App.xaml.cs @@ -19,6 +19,7 @@ using Tql.App.Services.Profiles; using Tql.App.Services.Synchronization; using Tql.App.Services.Telemetry; +using Tql.App.Services.UIService; using Tql.App.Services.Updates; using Tql.App.Support; using Application = System.Windows.Application; @@ -406,6 +407,8 @@ private static void ConfigureServices(IServiceCollection builder) builder.AddSingleton(); builder.AddSingleton(); builder.AddHostedService(); + builder.AddSingleton(); + builder.AddSingleton(); builder.AddSingleton(p => p.GetRequiredService()); builder.AddSingleton(p => p.GetRequiredService()); diff --git a/src/Tql.App/MainWindow.xaml.cs b/src/Tql.App/MainWindow.xaml.cs index 4065f9d8..913a0c98 100644 --- a/src/Tql.App/MainWindow.xaml.cs +++ b/src/Tql.App/MainWindow.xaml.cs @@ -9,6 +9,7 @@ using Tql.App.Services; using Tql.App.Services.Database; using Tql.App.Services.Telemetry; +using Tql.App.Services.UIService; using Tql.App.Support; using Tql.Utilities; @@ -354,8 +355,6 @@ private void DoShow(bool force) RenderStack(); - _ui.SetMainWindow(this); - UpdateClearVisibility(); ReloadNotifications(); @@ -405,7 +404,7 @@ public void SetShowInTaskbar(bool visible) // the taskbar button re-appearing next time the app is opened. // Maybe that's the bug they mean. // - // The work around is that the next time we show the app, we + // The workaround is that the next time we show the app, we // reset the ShowInTaskbar property "properly" (see the // ResetShowInTaskbar method). This seems to work fine. @@ -533,8 +532,6 @@ var window in Application.Current.Windows.OfType().Where(p => p.Owner == if (haveQuickStartChildWindow) _quickStartManager.Close(); - _ui.SetMainWindow(null); - Visibility = Visibility.Hidden; DisposeSearchManager(); @@ -952,7 +949,9 @@ private void NotificationBarUserControl_Activated(object? sender, UINotification ReloadNotifications(); - e.Notification.Activate?.Invoke(); + var handle = new WindowInteropHelper(this).Handle; + + e.Notification.Activate?.Invoke(new Win32Window(handle)); } private void NotificationBarUserControl_Dismissed(object? sender, UINotificationEventArgs e) @@ -961,7 +960,9 @@ private void NotificationBarUserControl_Dismissed(object? sender, UINotification ReloadNotifications(); - e.Notification.Dismiss?.Invoke(); + var handle = new WindowInteropHelper(this).Handle; + + e.Notification.Dismiss?.Invoke(new Win32Window(handle)); } private void _results_SelectionChanged(object sender, SelectionChangedEventArgs e) diff --git a/src/Tql.App/QuickStart/QuickStartManager.cs b/src/Tql.App/QuickStart/QuickStartManager.cs index 6909d538..ba64c9c7 100644 --- a/src/Tql.App/QuickStart/QuickStartManager.cs +++ b/src/Tql.App/QuickStart/QuickStartManager.cs @@ -2,6 +2,7 @@ using System.Windows.Interop; using Tql.Abstractions; using Tql.App.Services; +using Tql.App.Services.UIService; using Tql.App.Support; using Tql.Utilities; diff --git a/src/Tql.App/Resources/Radar.png b/src/Tql.App/Resources/Radar.png deleted file mode 100644 index 7c0d13041b5a0f7e23bef5ed52a30c5706052316..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3399 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 - _ui.ShowException( - ((UI)_ui).MainWindow!, - Labels.SearchManager_SearchFailedWithError, - ex - ) + p => _ui.ShowException(p, Labels.SearchManager_SearchFailedWithError, ex) ); } } diff --git a/src/Tql.App/Services/PluginManager.cs b/src/Tql.App/Services/PluginManager.cs index 8645796a..49ae9817 100644 --- a/src/Tql.App/Services/PluginManager.cs +++ b/src/Tql.App/Services/PluginManager.cs @@ -3,6 +3,7 @@ using Tql.Abstractions; using Tql.App.Services.Packages; using Tql.App.Services.Packages.PackageStore; +using Tql.App.Services.UIService; using Tql.App.Support; using Tql.Utilities; @@ -122,10 +123,10 @@ private void ShowLoadErrors(IServiceProvider serviceProvider) Activate ); - void Activate() + void Activate(IWin32Window owner) { var result = ui.ShowConfirmation( - ui.MainWindow!, + owner, string.Format(Labels.PluginManager_PackageFailedToLoad, package.Id.Id), string.Format( Labels.PluginManager_PackageFailedToLoadSubtitle, @@ -149,10 +150,10 @@ void Activate() Activate ); - void Activate() + void Activate(IWin32Window owner) { var result = ui.ShowConfirmation( - ui.MainWindow!, + owner, string.Format( Labels.PluginManager_PluginFailedToLoad, plugin.Plugin.Title diff --git a/src/Tql.App/Services/TermsOfServiceNotificationService.cs b/src/Tql.App/Services/TermsOfServiceNotificationService.cs index 32049830..c28c5920 100644 --- a/src/Tql.App/Services/TermsOfServiceNotificationService.cs +++ b/src/Tql.App/Services/TermsOfServiceNotificationService.cs @@ -71,16 +71,11 @@ private async Task CheckTermsOfService() ui.ShowNotificationBar( GetType().FullName!, Labels.TermsOfServiceNotificationService_Updated, - OpenTermsOfService + _ => ui.OpenUrl(Constants.TermsOfServiceUrl) ); } settings.LastTermsOfServiceHash = hash; } } - - private void OpenTermsOfService() - { - ui.OpenUrl(Constants.TermsOfServiceUrl); - } } diff --git a/src/Tql.App/Services/UINotification.cs b/src/Tql.App/Services/UINotification.cs deleted file mode 100644 index 0c7c9364..00000000 --- a/src/Tql.App/Services/UINotification.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Tql.App.Services; - -internal record UINotification(string Key, string Message, Action? Activate, Action? Dismiss); diff --git a/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationException.cs b/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationException.cs new file mode 100644 index 00000000..63d646bd --- /dev/null +++ b/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationException.cs @@ -0,0 +1,15 @@ +namespace Tql.App.Services.UIService; + +internal class BrowserBasedInteractiveAuthenticationException : Exception +{ + public BrowserBasedInteractiveAuthenticationException() { } + + public BrowserBasedInteractiveAuthenticationException(string? message) + : base(message) { } + + public BrowserBasedInteractiveAuthenticationException( + string? message, + Exception? innerException + ) + : base(message, innerException) { } +} diff --git a/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml b/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml new file mode 100644 index 00000000..2acb3712 --- /dev/null +++ b/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + RESOURCE NAME + + + + + + diff --git a/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml.cs b/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml.cs new file mode 100644 index 00000000..c7d801de --- /dev/null +++ b/src/Tql.App/Services/UIService/BrowserBasedInteractiveAuthenticationWindow.xaml.cs @@ -0,0 +1,108 @@ +using System.Collections.Specialized; +using System.IO; +using Microsoft.Extensions.Logging; +using Microsoft.Web.WebView2.Core; +using Tql.Abstractions; +using Path = System.IO.Path; + +namespace Tql.App.Services.UIService; + +internal partial class BrowserBasedInteractiveAuthenticationWindow +{ + public class Factory( + IStore store, + IPluginManager pluginManager, + ILogger logger + ) + { + public BrowserBasedInteractiveAuthenticationWindow CreateInstance( + InteractiveAuthenticationResource resource, + string loginUrl, + string redirectUrl, + TaskCompletionSource tcs + ) => new(resource, loginUrl, redirectUrl, tcs, store, pluginManager, logger); + } + + private readonly InteractiveAuthenticationResource _resource; + private readonly string _loginUrl; + private readonly string _redirectUrl; + private readonly IStore _store; + private readonly ILogger _logger; + private SimpleHttpServer? _server; + private readonly TaskCompletionSource _tcs; + + public BrowserBasedInteractiveAuthenticationWindow( + InteractiveAuthenticationResource resource, + string loginUrl, + string redirectUrl, + TaskCompletionSource tcs, + IStore store, + IPluginManager pluginManager, + ILogger logger + ) + { + _resource = resource; + _loginUrl = loginUrl; + _redirectUrl = redirectUrl; + _tcs = tcs; + _store = store; + _logger = logger; + + InitializeComponent(); + + var plugin = pluginManager.Plugins.Single(p => p.Id == resource.PluginId); + + _resourceName.Text = $"{plugin.Title} - {resource.ResourceName}"; + _resourceIcon.Source = resource.ResourceIcon; + } + + private async void BaseWindow_Loaded(object sender, RoutedEventArgs e) + { + try + { + var redirectUri = new Uri(_redirectUrl); + + var prefix = $"{redirectUri.Scheme}://{redirectUri.Authority}/"; + + _server = new SimpleHttpServer(prefix, _logger); + + _server.RequestReceived += _server_RequestReceived; + + var browserCacheFolder = Path.Combine( + _store.GetCacheFolder(_resource.PluginId), + "Browser Based Interactive Authentication", + _resource.ResourceId.ToString() + ); + Directory.CreateDirectory(browserCacheFolder); + + var environment = await CoreWebView2Environment.CreateAsync(null, browserCacheFolder); + + await _webView.EnsureCoreWebView2Async(environment); + + _webView.Source = new Uri(_loginUrl); + } + catch (Exception ex) + { + _tcs.SetException(ex); + + Close(); + } + } + + private void _server_RequestReceived(object? sender, SimpleHttpRequestEventArgs e) + { + Dispatcher.BeginInvoke(() => HandleRequest(e.Uri, e.QueryString)); + } + + private void HandleRequest(Uri uri, NameValueCollection queryString) + { + _tcs.SetResult(new BrowserBasedInteractiveAuthenticationResult(uri, queryString)); + + Close(); + } + + private void BaseWindow_Unloaded(object sender, RoutedEventArgs e) + { + _server?.Dispose(); + } +} diff --git a/src/Tql.App/Services/InteractiveAuthenticationWindow.xaml b/src/Tql.App/Services/UIService/InteractiveAuthenticationWindow.xaml similarity index 88% rename from src/Tql.App/Services/InteractiveAuthenticationWindow.xaml rename to src/Tql.App/Services/UIService/InteractiveAuthenticationWindow.xaml index 807f580b..236c5aa7 100644 --- a/src/Tql.App/Services/InteractiveAuthenticationWindow.xaml +++ b/src/Tql.App/Services/UIService/InteractiveAuthenticationWindow.xaml @@ -1,5 +1,5 @@  @@ -36,7 +36,7 @@ Margin="0,0,0,4" FontWeight="Bold" Text="{x:Static app:Labels.InteractiveAuthenticationWindow_TheFollowingResourceRequiresYourCredentialsLabel}" /> - PLUGIN + RESOURCE NAME action, + IUI ui + ) => new(resource, action, pluginManager, ui); + } + + private readonly Func _action; private readonly IUI _ui; public Exception? Exception { get; private set; } public InteractiveAuthenticationWindow( - IInteractiveAuthentication interactiveAuthentication, + InteractiveAuthenticationResource resource, + Func action, + IPluginManager pluginManager, IUI ui ) { - _interactiveAuthentication = interactiveAuthentication; + _action = action; _ui = ui; InitializeComponent(); - _plugin.Text = interactiveAuthentication.ResourceName; + var plugin = pluginManager.Plugins.Single(p => p.Id == resource.PluginId); + + _resourceName.Text = $"{plugin.Title} - {resource.ResourceName}"; + _resourceIcon.Source = resource.ResourceIcon; } private async void _acceptButton_Click(object? sender, RoutedEventArgs e) @@ -33,7 +48,7 @@ private async void _acceptButton_Click(object? sender, RoutedEventArgs e) { try { - await _interactiveAuthentication.Authenticate(new Win32Window(handle)); + await _action(new Win32Window(handle)); Close(); return; diff --git a/src/Tql.App/Services/UIService/SimpleHttpRequestEventArgs.cs b/src/Tql.App/Services/UIService/SimpleHttpRequestEventArgs.cs new file mode 100644 index 00000000..371f6b04 --- /dev/null +++ b/src/Tql.App/Services/UIService/SimpleHttpRequestEventArgs.cs @@ -0,0 +1,9 @@ +using System.Collections.Specialized; + +namespace Tql.App.Services.UIService; + +internal class SimpleHttpRequestEventArgs(Uri uri, NameValueCollection queryString) +{ + public Uri Uri { get; } = uri; + public NameValueCollection QueryString { get; } = queryString; +} diff --git a/src/Tql.App/Services/UIService/SimpleHttpServer.cs b/src/Tql.App/Services/UIService/SimpleHttpServer.cs new file mode 100644 index 00000000..6b6564e2 --- /dev/null +++ b/src/Tql.App/Services/UIService/SimpleHttpServer.cs @@ -0,0 +1,64 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Tql.App.Support; + +namespace Tql.App.Services.UIService; + +internal class SimpleHttpServer : IDisposable +{ + private readonly ILogger _logger; + private readonly HttpListener _http; + + public event EventHandler? RequestReceived; + + public SimpleHttpServer(string prefix, ILogger logger) + { + _logger = logger; + _http = new HttpListener(); + + _http.Prefixes.Add(prefix); + + logger.LogInformation("Listing for incoming requests"); + + _http.Start(); + + TaskUtils.RunBackground(GetRequest); + } + + private async Task GetRequest() + { + try + { + var context = await _http.GetContextAsync(); + + var e = new SimpleHttpRequestEventArgs( + context.Request.Url!, + context.Request.QueryString + ); + + context.Response.ContentLength64 = 0; + context.Response.StatusCode = (int)HttpStatusCode.NoContent; + context.Response.StatusDescription = "No Content"; + + context.Response.Close(); + + OnRequestReceived(e); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while waiting for request"); + } + } + + public void Dispose() + { + _http.Stop(); + + _logger.LogInformation("HTTP server stopped"); + + ((IDisposable)_http).Dispose(); + } + + protected virtual void OnRequestReceived(SimpleHttpRequestEventArgs e) => + RequestReceived?.Invoke(this, e); +} diff --git a/src/Tql.App/Services/UI.cs b/src/Tql.App/Services/UIService/UI.cs similarity index 70% rename from src/Tql.App/Services/UI.cs rename to src/Tql.App/Services/UIService/UI.cs index 8009f9df..f3404606 100644 --- a/src/Tql.App/Services/UI.cs +++ b/src/Tql.App/Services/UIService/UI.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Reflection; using System.Windows.Forms; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Tql.Abstractions; using Tql.App.Support; @@ -8,19 +9,21 @@ using Clipboard = System.Windows.Forms.Clipboard; using IWin32Window = System.Windows.Forms.IWin32Window; -namespace Tql.App.Services; +namespace Tql.App.Services.UIService; internal class UI : IUI { private SynchronizationContext? _synchronizationContext; - private volatile List _notifications = new(); + private volatile List _notifications = []; private readonly object _syncRoot = new(); private int _modalDialogShowing; private readonly ILogger _logger; + private readonly InteractiveAuthenticationWindow.Factory _interactiveAuthenticationWindowFactory; + private readonly BrowserBasedInteractiveAuthenticationWindow.Factory _browserBasedInteractiveAuthenticationWindowFactory; + private readonly IServiceProvider _serviceProvider; private volatile RestartMode _restartMode = RestartMode.Shutdown; public RestartMode RestartMode => _restartMode; - public MainWindow? MainWindow { get; private set; } public bool IsModalDialogShowing => _modalDialogShowing > 0; // This uses the safe publication pattern. @@ -30,9 +33,19 @@ internal class UI : IUI public event EventHandler? UINotificationsChanged; public event EventHandler? ConfigurationUIRequested; - public UI(ILifecycleService lifecycleService, ILogger logger) + public UI( + ILifecycleService lifecycleService, + ILogger logger, + InteractiveAuthenticationWindow.Factory interactiveAuthenticationWindowFactory, + BrowserBasedInteractiveAuthenticationWindow.Factory browserBasedInteractiveAuthenticationWindowFactory, + IServiceProvider serviceProvider + ) { _logger = logger; + _interactiveAuthenticationWindowFactory = interactiveAuthenticationWindowFactory; + _browserBasedInteractiveAuthenticationWindowFactory = + browserBasedInteractiveAuthenticationWindowFactory; + _serviceProvider = serviceProvider; lifecycleService.RegisterBeforeShutdown(BeforeShutdown); } @@ -69,20 +82,22 @@ public void SetSynchronizationContext(SynchronizationContext? synchronizationCon } public Task PerformInteractiveAuthentication( - IInteractiveAuthentication interactiveAuthentication + InteractiveAuthenticationResource resource, + Func action ) { - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(); _synchronizationContext!.Post( _ => { - var window = new InteractiveAuthenticationWindow(interactiveAuthentication, this) - { - Owner = MainWindow - }; + var window = _interactiveAuthenticationWindowFactory.CreateInstance( + resource, + action, + this + ); - EnterModalDialog(); + window.Owner = EnterModalDialog(); try { window.ShowDialog(); @@ -95,7 +110,50 @@ IInteractiveAuthentication interactiveAuthentication if (window.Exception != null) tcs.SetException(window.Exception); else - tcs.SetResult(true); + tcs.SetResult(); + }, + null + ); + + return tcs.Task; + } + + public Task PerformBrowserBasedInteractiveAuthentication( + InteractiveAuthenticationResource resource, + string loginUrl, + string redirectUrl + ) + { + var tcs = new TaskCompletionSource(); + + _synchronizationContext!.Post( + _ => + { + var window = _browserBasedInteractiveAuthenticationWindowFactory.CreateInstance( + resource, + loginUrl, + redirectUrl, + tcs + ); + + window.Owner = EnterModalDialog(); + try + { + window.ShowDialog(); + } + finally + { + ExitModalDialog(); + } + + if (!tcs.Task.IsCompleted) + { + tcs.SetException( + new BrowserBasedInteractiveAuthenticationException( + "Interactive authentication was aborted by the user" + ) + ); + } }, null ); @@ -115,11 +173,6 @@ public void OpenUrl(string url) } } - public void SetMainWindow(MainWindow? mainWindow) - { - MainWindow = mainWindow; - } - public void Shutdown(RestartMode mode) { Application.Current.Dispatcher.BeginInvoke(() => @@ -265,8 +318,8 @@ private static void TranslateButtons(TaskDialogPage page, DialogCommonButtons bu public void ShowNotificationBar( string key, string message, - Action? activate = null, - Action? dismiss = null + Action? activate = null, + Action? dismiss = null ) { lock (_syncRoot) @@ -302,20 +355,31 @@ public void OpenConfiguration(Guid id) OnConfigurationUIRequested(new ConfigurationUIEventArgs(id)); } - public void EnterModalDialog() + public Window EnterModalDialog() { - if (_modalDialogShowing == 0 && MainWindow != null) - MainWindow.SetShowInTaskbar(true); + var mainWindow = _serviceProvider.GetRequiredService(); + if (!mainWindow.IsVisible) + mainWindow.DoShow(); + + if (_modalDialogShowing == 0) + mainWindow.SetShowInTaskbar(true); + _modalDialogShowing++; + + return mainWindow; } public void ExitModalDialog() { + var mainWindow = _serviceProvider.GetRequiredService(); + _modalDialogShowing--; - if (_modalDialogShowing == 0 && MainWindow != null) - MainWindow.SetShowInTaskbar(false); + if (_modalDialogShowing == 0) + mainWindow.SetShowInTaskbar(false); } + public void ShowModalDialog(Action action) { } + protected virtual void OnUINotificationsChanged() => UINotificationsChanged?.Invoke(this, EventArgs.Empty); diff --git a/src/Tql.App/Services/UIService/UINotification.cs b/src/Tql.App/Services/UIService/UINotification.cs new file mode 100644 index 00000000..baad807d --- /dev/null +++ b/src/Tql.App/Services/UIService/UINotification.cs @@ -0,0 +1,10 @@ +using System.Windows.Forms; + +namespace Tql.App.Services.UIService; + +internal record UINotification( + string Key, + string Message, + Action? Activate, + Action? Dismiss +); diff --git a/src/Tql.App/Support/NotificationBarUserControl.xaml b/src/Tql.App/Support/NotificationBarUserControl.xaml index a4cb6a33..316ef992 100644 --- a/src/Tql.App/Support/NotificationBarUserControl.xaml +++ b/src/Tql.App/Support/NotificationBarUserControl.xaml @@ -5,12 +5,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:services="clr-namespace:Tql.App.Services" xmlns:utilities="clr-namespace:Tql.Utilities;assembly=Tql.Utilities" + xmlns:uiService="clr-namespace:Tql.App.Services.UIService" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800" - d:DataContext="{d:DesignInstance Type=services:UINotification}"> + d:DataContext="{d:DesignInstance Type=uiService:UINotification}">