From 82fba7e75271341c2238118ee56b033da9e788e1 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Tue, 11 Apr 2023 11:42:12 -0600 Subject: [PATCH 1/4] Grid refresh performance and behavior improvements --- Source/LibationUiBase/GridView/QueryExtensions.cs | 4 ++-- .../GridView/GridEntryBindingList.cs | 15 +++++++++++++-- Source/LibationWinForms/GridView/ProductsGrid.cs | 9 +++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Source/LibationUiBase/GridView/QueryExtensions.cs b/Source/LibationUiBase/GridView/QueryExtensions.cs index 77cf2a8a..571c0a3f 100644 --- a/Source/LibationUiBase/GridView/QueryExtensions.cs +++ b/Source/LibationUiBase/GridView/QueryExtensions.cs @@ -53,10 +53,10 @@ namespace LibationUiBase.GridView var searchResultSet = SearchEngineCommands.Search(searchString); - var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe); + var booksFilteredIn = entries.IntersectBy(searchResultSet.Docs.Select(d => d.ProductId), l => l.AudibleProductId); //Find all series containing children that match the search criteria - var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); + var seriesFilteredIn = booksFilteredIn.OfType().Where(lbe => lbe.Parent is not null).Select(lbe => lbe.Parent).Distinct(); return booksFilteredIn.Concat(seriesFilteredIn).ToHashSet(); } diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 25f878ff..571db313 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -97,6 +97,9 @@ namespace LibationWinForms.GridView /// private void refreshEntries() { + var priorState = RaiseListChangedEvents; + RaiseListChangedEvents = false; + if (FilteredInGridEntries is null) { addRemovedItemsBack(FilterRemoved.ToList()); @@ -117,7 +120,8 @@ namespace LibationWinForms.GridView SortInternal(); - OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + ResetList(); + RaiseListChangedEvents = priorState; void addRemovedItemsBack(List addBackEntries) { @@ -200,7 +204,7 @@ namespace LibationWinForms.GridView propertyDescriptor = property; isSorted = true; - OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + ResetList(); } private void SortInternal() @@ -224,8 +228,15 @@ namespace LibationWinForms.GridView isSorted = false; propertyDescriptor = base.SortPropertyCore; Comparer.SortOrder = base.SortDirectionCore; + ResetList(); + } + private void ResetList() + { + var priorState = RaiseListChangedEvents; + RaiseListChangedEvents = true; OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + RaiseListChangedEvents = priorState; } } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 95d287e5..dfe6f530 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -189,6 +189,9 @@ namespace LibationWinForms.GridView internal void UpdateGrid(List dbBooks) { + //First row that is in view in the DataGridView + var topRow = gridEntryDataGridView.Rows.Cast().FirstOrDefault(r => r.Displayed)?.Index ?? 0; + #region Add new or update existing grid entries //Remove filter prior to adding/updating boooks @@ -201,6 +204,7 @@ namespace LibationWinForms.GridView var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet(); + bindingList.RaiseListChangedEvents = false; foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) { var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); @@ -216,8 +220,11 @@ namespace LibationWinForms.GridView AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); } } + bindingList.RaiseListChangedEvents = true; //Re-apply filter after adding new/updating existing books to capture any changes + //The Filter call also ensures that the binding list is reset so the DataGridView + //is made aware of all changes that were made while RaiseListChangedEvents was false Filter(existingFilter); #endregion @@ -231,6 +238,8 @@ namespace LibationWinForms.GridView .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); RemoveBooks(removedBooks); + + gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; } public void RemoveBooks(IEnumerable removedBooks) From 53b5c1b90262ea9bb0494826ab354047b78ab3ff Mon Sep 17 00:00:00 2001 From: Mbucari Date: Tue, 11 Apr 2023 14:43:01 -0600 Subject: [PATCH 2/4] Fix rare bug where episode may not sort beneath its parent --- Source/LibationUiBase/GridView/RowComparerBase.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/LibationUiBase/GridView/RowComparerBase.cs b/Source/LibationUiBase/GridView/RowComparerBase.cs index a24cf5d3..eefaf34b 100644 --- a/Source/LibationUiBase/GridView/RowComparerBase.cs +++ b/Source/LibationUiBase/GridView/RowComparerBase.cs @@ -80,7 +80,12 @@ namespace LibationUiBase.GridView var val1 = x.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName); - return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); ; + var compare = x.GetMemberComparer(val1.GetType()).Compare(val1, val2); + + return compare == 0 && x.Liberate.IsSeries && y.Liberate.IsSeries + //Both a and b are series parents and compare as equal, so break the tie. + ? x.AudibleProductId.CompareTo(y.AudibleProductId) + : compare; } public int Compare(IGridEntry x, IGridEntry y) From df2936e0b6f4c6dfd788c49192050ab041f8e129 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Wed, 12 Apr 2023 10:40:32 -0600 Subject: [PATCH 3/4] Use WebLoginDialog as primary login method on Win10+ --- .../Dialogs/Login/WebLoginDialog.Designer.cs | 46 +++++++++++++ .../Dialogs/Login/WebLoginDialog.cs | 67 +++++++++++++++++++ .../Dialogs/Login/WebLoginDialog.resx | 60 +++++++++++++++++ .../Dialogs/Login/WinformLoginBase.cs | 15 +++-- .../Dialogs/Login/WinformLoginCallback.cs | 3 +- .../Dialogs/Login/WinformLoginChoiceEager.cs | 24 +++++-- Source/LibationWinForms/Form1.ScanAuto.cs | 2 +- Source/LibationWinForms/Form1.ScanManual.cs | 2 +- .../GridView/ProductsDisplay.cs | 2 +- .../LibationWinForms/LibationWinForms.csproj | 1 + 10 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 Source/LibationWinForms/Dialogs/Login/WebLoginDialog.Designer.cs create mode 100644 Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs create mode 100644 Source/LibationWinForms/Dialogs/Login/WebLoginDialog.resx diff --git a/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.Designer.cs b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.Designer.cs new file mode 100644 index 00000000..dc75e648 --- /dev/null +++ b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.Designer.cs @@ -0,0 +1,46 @@ +namespace LibationWinForms.Login +{ + partial class WebLoginDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + SuspendLayout(); + // + // WebLoginDialog + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(484, 761); + Name = "WebLoginDialog"; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Audible Login"; + ResumeLayout(false); + } + + #endregion + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs new file mode 100644 index 00000000..75abedd4 --- /dev/null +++ b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.cs @@ -0,0 +1,67 @@ +using Dinah.Core; +using Microsoft.Web.WebView2.WinForms; +using System; +using System.Windows.Forms; + +namespace LibationWinForms.Login +{ + public partial class WebLoginDialog : Form + { + public string ResponseUrl { get; private set; } + private readonly string loginUrl; + private readonly string accountID; + private readonly WebView2 webView = new(); + public WebLoginDialog() + { + InitializeComponent(); + webView.Dock = DockStyle.Fill; + Controls.Add(webView); + Shown += WebLoginDialog_Shown; + this.SetLibationIcon(); + } + + public WebLoginDialog(string accountID, string loginUrl) : this() + { + this.accountID = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountID, nameof(accountID)); + this.loginUrl = ArgumentValidator.EnsureNotNullOrWhiteSpace(loginUrl, nameof(loginUrl)); + } + + private async void WebLoginDialog_Shown(object sender, EventArgs e) + { + await webView.EnsureCoreWebView2Async(); + webView.CoreWebView2.NavigationStarting += CoreWebView2_NavigationStarting; + webView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded; + webView.CoreWebView2.Navigate(loginUrl); + } + + private async void CoreWebView2_DOMContentLoaded(object sender, Microsoft.Web.WebView2.Core.CoreWebView2DOMContentLoadedEventArgs e) + { + await webView.CoreWebView2.ExecuteScriptAsync(getScript(accountID)); + } + + private static string getScript(string accountID) => $$""" + (function() { + var inputs = document.getElementsByTagName('input'); + for (index = 0; index < inputs.length; ++index) { + if (inputs[index].name.includes('email')) { + inputs[index].value = '{{accountID}}'; + } + if (inputs[index].name.includes('password')) { + inputs[index].focus(); + } + } + })() + """; + + private void CoreWebView2_NavigationStarting(object sender, Microsoft.Web.WebView2.Core.CoreWebView2NavigationStartingEventArgs e) + { + if (new Uri(e.Uri).AbsolutePath.Contains("/ap/maplanding")) + { + ResponseUrl = e.Uri; + e.Cancel = true; + DialogResult = DialogResult.OK; + Close(); + } + } + } +} diff --git a/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.resx b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.resx new file mode 100644 index 00000000..f298a7be --- /dev/null +++ b/Source/LibationWinForms/Dialogs/Login/WebLoginDialog.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/Login/WinformLoginBase.cs b/Source/LibationWinForms/Dialogs/Login/WinformLoginBase.cs index 1de76464..790df589 100644 --- a/Source/LibationWinForms/Dialogs/Login/WinformLoginBase.cs +++ b/Source/LibationWinForms/Dialogs/Login/WinformLoginBase.cs @@ -1,15 +1,22 @@ using System; +using System.Windows.Forms; namespace LibationWinForms.Dialogs.Login { public abstract class WinformLoginBase { - /// True if ShowDialog's DialogResult == OK - protected static bool ShowDialog(System.Windows.Forms.Form dialog) + private readonly IWin32Window _owner; + protected WinformLoginBase(IWin32Window owner) { - var result = dialog.ShowDialog(); + _owner = owner; + } + + /// True if ShowDialog's DialogResult == OK + protected bool ShowDialog(Form dialog) + { + var result = dialog.ShowDialog(_owner); Serilog.Log.Logger.Debug("{@DebugInfo}", new { DialogResult = result }); - return result == System.Windows.Forms.DialogResult.OK; + return result == DialogResult.OK; } } } diff --git a/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs b/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs index fd3e0e83..92c36c03 100644 --- a/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs +++ b/Source/LibationWinForms/Dialogs/Login/WinformLoginCallback.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using System.Windows.Forms; using AudibleApi; using AudibleUtilities; using LibationWinForms.Dialogs.Login; @@ -12,7 +13,7 @@ namespace LibationWinForms.Login public string DeviceName { get; } = "Libation"; - public WinformLoginCallback(Account account) + public WinformLoginCallback(Account account, IWin32Window owner) : base(owner) { _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account)); } diff --git a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs index 062893dd..96c8425e 100644 --- a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs +++ b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using System.Windows.Forms; using AudibleApi; using AudibleUtilities; using LibationWinForms.Dialogs.Login; @@ -9,20 +10,31 @@ namespace LibationWinForms.Login public class WinformLoginChoiceEager : WinformLoginBase, ILoginChoiceEager { /// Convenience method. Recommended when wiring up Winforms to - public static async Task ApiExtendedFunc(Account account) => await ApiExtended.CreateAsync(account, new WinformLoginChoiceEager(account)); + public static Func> CreateApiExtendedFunc(IWin32Window owner) => a => ApiExtendedFunc(a, owner); + + private static async Task ApiExtendedFunc(Account account, IWin32Window owner) + => await ApiExtended.CreateAsync(account, new WinformLoginChoiceEager(account, owner)); public ILoginCallback LoginCallback { get; private set; } private Account _account { get; } - public WinformLoginChoiceEager(Account account) + private WinformLoginChoiceEager(Account account, IWin32Window owner) : base(owner) { _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account)); - LoginCallback = new WinformLoginCallback(_account); + LoginCallback = new WinformLoginCallback(_account, owner); } public Task StartAsync(ChoiceIn choiceIn) { + if (Environment.OSVersion.Version.Major >= 10) + { + using var browserLogin = new WebLoginDialog(_account.AccountId, choiceIn.LoginUrl); + + if (ShowDialog(browserLogin)) + return Task.FromResult(ChoiceOut.External(browserLogin.ResponseUrl)); + } + using var dialog = new LoginChoiceEagerDialog(_account); if (!ShowDialog(dialog) || (dialog.LoginMethod is LoginMethod.Api && string.IsNullOrWhiteSpace(dialog.Password))) @@ -33,13 +45,13 @@ namespace LibationWinForms.Login case LoginMethod.Api: return Task.FromResult(ChoiceOut.WithApi(dialog.Email, dialog.Password)); case LoginMethod.External: - { - using var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl); + { + using var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl); return Task.FromResult( ShowDialog(externalDialog) ? ChoiceOut.External(externalDialog.ResponseUrl) : null); - } + } default: throw new Exception($"Unknown {nameof(LoginMethod)} value"); } diff --git a/Source/LibationWinForms/Form1.ScanAuto.cs b/Source/LibationWinForms/Form1.ScanAuto.cs index ca91c770..6b671c6a 100644 --- a/Source/LibationWinForms/Form1.ScanAuto.cs +++ b/Source/LibationWinForms/Form1.ScanAuto.cs @@ -32,7 +32,7 @@ namespace LibationWinForms // in autoScan, new books SHALL NOT show dialog try { - Task importAsync() => LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.ApiExtendedFunc, accounts); + Task importAsync() => LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), accounts); if (InvokeRequired) await Invoke(importAsync); else diff --git a/Source/LibationWinForms/Form1.ScanManual.cs b/Source/LibationWinForms/Form1.ScanManual.cs index d745c6d2..83f5149f 100644 --- a/Source/LibationWinForms/Form1.ScanManual.cs +++ b/Source/LibationWinForms/Form1.ScanManual.cs @@ -74,7 +74,7 @@ namespace LibationWinForms { try { - var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.ApiExtendedFunc, accounts); + var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), accounts); // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop if (Configuration.Instance.ShowImportedStats && newAdded > 0) diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 6196b6a6..95c1ea7f 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -279,7 +279,7 @@ namespace LibationWinForms.GridView .Select(lbe => lbe.LibraryBook) .Where(lb => !lb.Book.HasLiberated()); - var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts); + var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), lib, accounts); var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index c482fe34..b051ac6e 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -38,6 +38,7 @@ + From 44564321169d93d572f2b529aecf61625a1db850 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Thu, 13 Apr 2023 13:33:29 -0600 Subject: [PATCH 4/4] Add WebLoginDialog for Windows Chardonnay --- .github/workflows/build-windows.yml | 2 +- .../Controls/NativeWebView.cs | 176 ++++++++++++++++++ .../Dialogs/Login/ApprovalNeededDialog.axaml | 1 + .../Login/ApprovalNeededDialog.axaml.cs | 2 +- .../Dialogs/Login/AvaloniaLoginChoiceEager.cs | 16 ++ .../Dialogs/Login/CaptchaDialog.axaml | 1 + .../Dialogs/Login/CaptchaDialog.axaml.cs | 2 +- .../Dialogs/Login/LoginCallbackDialog.axaml | 1 + .../Login/LoginCallbackDialog.axaml.cs | 2 +- .../Login/LoginChoiceEagerDialog.axaml | 8 +- .../Login/LoginChoiceEagerDialog.axaml.cs | 2 +- .../Login/LoginExternalDialog.axaml.cs | 2 +- .../Dialogs/Login/MfaDialog.axaml | 1 + .../Dialogs/Login/MfaDialog.axaml.cs | 2 +- .../Dialogs/Login/WebLoginDialog.axaml | 13 ++ .../Dialogs/Login/WebLoginDialog.axaml.cs | 54 ++++++ .../Dialogs/Login/_2faCodeDialog.axaml | 1 + .../Dialogs/Login/_2faCodeDialog.axaml.cs | 2 +- .../LibationAvalonia/LibationAvalonia.csproj | 4 +- Source/LibationAvalonia/Program.cs | 1 + .../PublishProfiles/WindowsProfile.pubxml | 2 +- Source/LibationAvalonia/app.manifest | 79 ++++++++ .../LibationFileManager/IInteropFunctions.cs | 44 ++++- .../NullInteropFunctions.cs | 5 +- .../Dialogs/Login/WebLoginDialog.cs | 45 ++--- .../Dialogs/Login/WinformLoginChoiceEager.cs | 14 +- .../LibationWinForms/LibationWinForms.csproj | 1 - .../LoadByOS/LinuxConfigApp/LinuxInterop.cs | 1 + .../MacOSConfigApp/MacOSConfigApp.csproj | 1 + .../LoadByOS/MacOSConfigApp/MacOSInterop.cs | 1 + .../MacOSConfigApp/MacWebViewAdapter.cs | 134 +++++++++++++ .../LoadByOS/WindowsConfigApp/WinInterop.cs | 7 +- .../WindowsConfigApp/WindowsConfigApp.csproj | 10 +- .../WindowsWebView2Adapter.cs | 114 ++++++++++++ 34 files changed, 704 insertions(+), 47 deletions(-) create mode 100644 Source/LibationAvalonia/Controls/NativeWebView.cs create mode 100644 Source/LibationAvalonia/Dialogs/Login/WebLoginDialog.axaml create mode 100644 Source/LibationAvalonia/Dialogs/Login/WebLoginDialog.axaml.cs create mode 100644 Source/LibationAvalonia/app.manifest create mode 100644 Source/LoadByOS/MacOSConfigApp/MacWebViewAdapter.cs create mode 100644 Source/LoadByOS/WindowsConfigApp/WindowsWebView2Adapter.cs diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index caa91e33..605016a1 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -69,7 +69,7 @@ jobs: LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj ` --configuration ${{ env.DOTNET_CONFIGURATION }} ` --output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` - -p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml + -p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml dotnet publish ` LibationCli/LibationCli.csproj ` --configuration ${{ env.DOTNET_CONFIGURATION }} ` diff --git a/Source/LibationAvalonia/Controls/NativeWebView.cs b/Source/LibationAvalonia/Controls/NativeWebView.cs new file mode 100644 index 00000000..0aad165d --- /dev/null +++ b/Source/LibationAvalonia/Controls/NativeWebView.cs @@ -0,0 +1,176 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Platform; +using Avalonia; +using LibationFileManager; +using System; +using System.Threading.Tasks; + +namespace LibationAvalonia.Controls; + +#nullable enable +public class NativeWebView : NativeControlHost, IWebView +{ + private IWebViewAdapter? _webViewAdapter; + private Uri? _delayedSource; + private TaskCompletionSource _webViewReadyCompletion = new(); + + public event EventHandler? NavigationCompleted; + + public event EventHandler? NavigationStarted; + public event EventHandler? DOMContentLoaded; + + public bool CanGoBack => _webViewAdapter?.CanGoBack ?? false; + + public bool CanGoForward => _webViewAdapter?.CanGoForward ?? false; + + public Uri? Source + { + get => _webViewAdapter?.Source ?? throw new InvalidOperationException("Control was not initialized"); + set + { + if (_webViewAdapter is null) + { + _delayedSource = value; + return; + } + _webViewAdapter.Source = value; + } + } + + + public bool GoBack() + { + return _webViewAdapter?.GoBack() ?? throw new InvalidOperationException("Control was not initialized"); + } + + public bool GoForward() + { + return _webViewAdapter?.GoForward() ?? throw new InvalidOperationException("Control was not initialized"); + } + + public Task InvokeScriptAsync(string scriptName) + { + return _webViewAdapter is null + ? throw new InvalidOperationException("Control was not initialized") + : _webViewAdapter.InvokeScriptAsync(scriptName); + } + + public void Navigate(Uri url) + { + (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) + .Navigate(url); + } + + public Task NavigateToString(string text) + { + return (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) + .NavigateToString(text); + } + + public void Refresh() + { + (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) + .Refresh(); + } + + public void Stop() + { + (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) + .Stop(); + } + + public Task WaitForNativeHost() + { + return _webViewReadyCompletion.Task; + } + + private class PlatformHandle : IPlatformHandle + { + public nint Handle { get; init; } + + public string? HandleDescriptor { get; init; } + } + + protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent) + { + _webViewAdapter = InteropFactory.Create().CreateWebViewAdapter(); + + if (_webViewAdapter is null) + return base.CreateNativeControlCore(parent); + else + { + SubscribeOnEvents(); + var handle = new PlatformHandle + { + Handle = _webViewAdapter.PlatformHandle.Handle, + HandleDescriptor = _webViewAdapter.PlatformHandle.HandleDescriptor + }; + + if (_delayedSource is not null) + { + _webViewAdapter.Source = _delayedSource; + } + + _webViewReadyCompletion.TrySetResult(); + + return handle; + } + } + + private void SubscribeOnEvents() + { + if (_webViewAdapter is not null) + { + _webViewAdapter.NavigationStarted += WebViewAdapterOnNavigationStarted; + _webViewAdapter.NavigationCompleted += WebViewAdapterOnNavigationCompleted; + _webViewAdapter.DOMContentLoaded += _webViewAdapter_DOMContentLoaded; + } + } + + private void _webViewAdapter_DOMContentLoaded(object? sender, EventArgs e) + { + DOMContentLoaded?.Invoke(this, e); + } + + private void WebViewAdapterOnNavigationStarted(object? sender, WebViewNavigationEventArgs e) + { + NavigationStarted?.Invoke(this, e); + } + + private void WebViewAdapterOnNavigationCompleted(object? sender, WebViewNavigationEventArgs e) + { + NavigationCompleted?.Invoke(this, e); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == BoundsProperty && change.NewValue is Rect rect) + { + var scaling = (float)(VisualRoot?.RenderScaling ?? 1.0f); + _webViewAdapter?.HandleResize((int)(rect.Width * scaling), (int)(rect.Height * scaling), scaling); + } + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (_webViewAdapter != null) + { + e.Handled = _webViewAdapter.HandleKeyDown((uint)e.Key, (uint)e.KeyModifiers); + } + + base.OnKeyDown(e); + } + + protected override void DestroyNativeControlCore(IPlatformHandle control) + { + if (_webViewAdapter is not null) + { + _webViewReadyCompletion = new TaskCompletionSource(); + _webViewAdapter.NavigationStarted -= WebViewAdapterOnNavigationStarted; + _webViewAdapter.NavigationCompleted -= WebViewAdapterOnNavigationCompleted; + (_webViewAdapter as IDisposable)?.Dispose(); + } + } +} diff --git a/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml b/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml index 10cde367..1a6476f2 100644 --- a/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml @@ -6,6 +6,7 @@ MinWidth="240" MinHeight="140" MaxWidth="240" MaxHeight="140" Width="240" Height="140" + WindowStartupLocation="CenterOwner" x:Class="LibationAvalonia.Dialogs.Login.ApprovalNeededDialog" Title="Approval Alert Detected" Icon="/Assets/libation.ico"> diff --git a/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml.cs index c1f3a4b7..5bab6bb3 100644 --- a/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml.cs @@ -4,7 +4,7 @@ namespace LibationAvalonia.Dialogs.Login { public partial class ApprovalNeededDialog : DialogWindow { - public ApprovalNeededDialog() + public ApprovalNeededDialog() : base(saveAndRestorePosition: false) { InitializeComponent(); } diff --git a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs index 143b31ca..f90fe55d 100644 --- a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs +++ b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs @@ -1,5 +1,7 @@ using AudibleApi; using AudibleUtilities; +using Avalonia.Threading; +using LibationFileManager; using System; using System.Threading.Tasks; @@ -23,6 +25,20 @@ namespace LibationAvalonia.Dialogs.Login public async Task StartAsync(ChoiceIn choiceIn) { + if (Configuration.IsWindows && Environment.OSVersion.Version.Major >= 10) + { + try + { + var weblogin = new WebLoginDialog(_account.AccountId, choiceIn.LoginUrl); + if (await weblogin.ShowDialog(App.MainWindow) is DialogResult.OK) + return ChoiceOut.External(weblogin.ResponseUrl); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, $"Failed to run {nameof(WebLoginDialog)}"); + } + } + var dialog = new LoginChoiceEagerDialog(_account); if (await dialog.ShowDialogAsync() is not DialogResult.OK || diff --git a/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml b/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml index 363d0f8c..1ab14620 100644 --- a/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml @@ -6,6 +6,7 @@ MinWidth="220" MinHeight="250" MaxWidth="220" MaxHeight="250" Width="220" Height="250" + WindowStartupLocation="CenterOwner" x:Class="LibationAvalonia.Dialogs.Login.CaptchaDialog" Title="CAPTCHA" Icon="/Assets/libation.ico"> diff --git a/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml.cs index df681ae3..c079332b 100644 --- a/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/Login/CaptchaDialog.axaml.cs @@ -13,7 +13,7 @@ namespace LibationAvalonia.Dialogs.Login public string Answer => _viewModel.Answer; private readonly CaptchaDialogViewModel _viewModel; - public CaptchaDialog() + public CaptchaDialog() : base(saveAndRestorePosition: false) { InitializeComponent(); passwordBox = this.FindControl(nameof(passwordBox)); diff --git a/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml b/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml index 0a27e987..7531be98 100644 --- a/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml @@ -5,6 +5,7 @@ mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="120" MinWidth="300" MinHeight="120" Width="300" Height="120" + WindowStartupLocation="CenterOwner" x:Class="LibationAvalonia.Dialogs.Login.LoginCallbackDialog" Title="Audible Login" Icon="/Assets/libation.ico"> diff --git a/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml.cs index 35ed88b5..faf919fc 100644 --- a/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/Login/LoginCallbackDialog.axaml.cs @@ -11,7 +11,7 @@ namespace LibationAvalonia.Dialogs.Login public Account Account { get; } public string Password { get; set; } - public LoginCallbackDialog() + public LoginCallbackDialog() : base(saveAndRestorePosition: false) { InitializeComponent(); diff --git a/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml b/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml index e84e40c4..cc9c3448 100644 --- a/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml @@ -35,7 +35,7 @@ Grid.Row="2" Grid.Column="0" Margin="0,5,0,5" - ColumnDefinitions="Auto,*"> + ColumnDefinitions="Auto,*,Auto"> +