diff --git a/Directory.Build.props b/Directory.Build.props index cc3e70cd..2ed049fc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,13 +4,13 @@ net8.0 x64;ARM64 11.0.10 - 120.6099.207 + 120.6099.210 2.0.0.0 2.0.0.0 - 3.120.8 + 4.120.0 OutSystems WebViewControl Copyright © OutSystems 2023 diff --git a/Directory.Packages.props b/Directory.Packages.props index 4c06c172..505c3456 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,39 +1,33 @@ - - true - - - - - - $(PrivateAssets);compile - - - - - - - - - - - - - - - - - - - - - - - - - + + $(PrivateAssets);compile + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SampleWebView.Avalonia/MainWindow.xaml b/SampleWebView.Avalonia/MainWindow.xaml index dc98c1c3..311d0179 100755 --- a/SampleWebView.Avalonia/MainWindow.xaml +++ b/SampleWebView.Avalonia/MainWindow.xaml @@ -7,35 +7,6 @@ ExtendClientAreaChromeHints="PreferSystemChrome" TransparencyLevelHint="AcrylicBlur" Background="Transparent"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -47,14 +18,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + diff --git a/SampleWebView.Avalonia/MainWindowV1.xaml b/SampleWebView.Avalonia/MainWindowV1.xaml new file mode 100644 index 00000000..28c1671d --- /dev/null +++ b/SampleWebView.Avalonia/MainWindowV1.xaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Text + + + + + Source + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/SampleWebView.Avalonia/MainWindowV1.xaml.cs b/SampleWebView.Avalonia/MainWindowV1.xaml.cs new file mode 100644 index 00000000..b5d99596 --- /dev/null +++ b/SampleWebView.Avalonia/MainWindowV1.xaml.cs @@ -0,0 +1,16 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using WebViewControl; + +namespace SampleWebView.Avalonia { + + internal partial class MainWindowV1 : Window { + + public MainWindowV1() { + WebView.Settings.LogFile = "ceflog.txt"; + AvaloniaXamlLoader.Load(this); + + DataContext = new MainWindowV1ViewModel(this.FindControl("webview")); + } + } +} \ No newline at end of file diff --git a/SampleWebView.Avalonia/MainWindowV1ViewModel.cs b/SampleWebView.Avalonia/MainWindowV1ViewModel.cs new file mode 100644 index 00000000..3c5d70c4 --- /dev/null +++ b/SampleWebView.Avalonia/MainWindowV1ViewModel.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using ReactiveUI; +using WebViewControl; + +namespace SampleWebView.Avalonia; + +public class MainWindowV1ViewModel : ReactiveObject { + #region Fields + + private readonly Dictionary downloads = new(); + private readonly WebView webView; + + private readonly ReactiveCommand navigateCommand; + private readonly ReactiveCommand showDevToolsCommand; + private readonly ReactiveCommand cutCommand; + private readonly ReactiveCommand copyCommand; + private readonly ReactiveCommand pasteCommand; + private readonly ReactiveCommand undoCommand; + private readonly ReactiveCommand redoCommand; + private readonly ReactiveCommand selectAllCommand; + private readonly ReactiveCommand deleteCommand; + private readonly ReactiveCommand backCommand; + private readonly ReactiveCommand forwardCommand; + private readonly ReactiveCommand getSourceCommand; + private readonly ReactiveCommand getTextCommand; + + private string address; + private string currentAddress; + + private bool isDownloading; + private bool isDownloadDeterminate; + private double downloadPercentage; + private string downloadMessage; + private string downloadProgress; + + private string source; + private bool sourceAvailable; + private string text; + private bool textAvailable; + + #endregion Fields + + public MainWindowV1ViewModel(WebView webview) { + Address = CurrentAddress = "http://www.google.com/"; + //Address = CurrentAddress = "http://www.testfile.org/"; + webView = webview; + + webview.AllowDeveloperTools = true; + + webview.Navigated += OnNavigated; + + webview.DownloadCancelled += OnDownloadCancelled; + webview.DownloadCompleted += OnDownloadCompleted; + webview.DownloadProgressChanged += OnDownloadProgressChanged; + + navigateCommand = ReactiveCommand.Create(() => { + CurrentAddress = Address; + }); + + showDevToolsCommand = ReactiveCommand.Create(webview.ShowDeveloperTools); + + cutCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Cut(); + }); + + copyCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Copy(); + }); + + pasteCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Paste(); + }); + + undoCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Undo(); + }); + + redoCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Redo(); + }); + + selectAllCommand = ReactiveCommand.Create(() => { + webview.EditCommands.SelectAll(); + }); + + deleteCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Delete(); + }); + + backCommand = ReactiveCommand.Create(webview.GoBack); + + forwardCommand = ReactiveCommand.Create(webview.GoForward); + + getTextCommand = ReactiveCommand.Create(() => { + webview.GetText(OnTextAvailable); + }); + + getSourceCommand = ReactiveCommand.Create(() => { + webview.GetSource(OnSourceAvailable); + }); + + PropertyChanged += OnPropertyChanged; + + + } + + public ReactiveCommand NavigateCommand => navigateCommand; + + public ReactiveCommand ShowDevToolsCommand => showDevToolsCommand; + + public ReactiveCommand CutCommand => cutCommand; + + public ReactiveCommand CopyCommand => copyCommand; + + public ReactiveCommand PasteCommand => pasteCommand; + + public ReactiveCommand UndoCommand => undoCommand; + + public ReactiveCommand RedoCommand => redoCommand; + + public ReactiveCommand SelectAllCommand => selectAllCommand; + + public ReactiveCommand DeleteCommand => deleteCommand; + + public ReactiveCommand BackCommand => backCommand; + + public ReactiveCommand ForwardCommand => forwardCommand; + + public ReactiveCommand GetSourceCommand => getSourceCommand; + + public ReactiveCommand GetTextCommand => getTextCommand; + + public string Address { + get => address; + set => this.RaiseAndSetIfChanged(ref address, value); + } + + public string CurrentAddress { + get => currentAddress; + set => this.RaiseAndSetIfChanged(ref currentAddress, value); + } + + public bool IsDownloading { + get => isDownloading; + set => this.RaiseAndSetIfChanged(ref isDownloading, value); + } + + public bool IsDownloadDeterminate { + get => isDownloadDeterminate; + set => this.RaiseAndSetIfChanged(ref isDownloadDeterminate, value); + } + + public double DownloadPercentage { + get => downloadPercentage; + set => this.RaiseAndSetIfChanged(ref downloadPercentage, value); + } + + public string DownloadMessage { + get => downloadMessage; + set => this.RaiseAndSetIfChanged(ref downloadMessage, value); + } + + public string DownloadProgress { + get => downloadProgress; + set => this.RaiseAndSetIfChanged(ref downloadProgress, value); + } + + public string Source { + get => source; + set => this.RaiseAndSetIfChanged(ref source, value); + } + + public bool SourceAvailable { + get => sourceAvailable; + set => this.RaiseAndSetIfChanged(ref sourceAvailable, value); + } + + public string Text { + get => text; + set => this.RaiseAndSetIfChanged(ref text, value); + } + + public bool TextAvailable { + get => textAvailable; + set => this.RaiseAndSetIfChanged(ref textAvailable, value); + } + + private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(CurrentAddress)) { + Address = CurrentAddress; + } + } + + private void OnNavigated(string url, string frameName) { + if (!string.IsNullOrWhiteSpace(frameName)) { + return; + } + + Text = ""; + TextAvailable = false; + webView.GetText(OnTextAvailable); + + Source = ""; + SourceAvailable = false; + } + + private void OnSourceAvailable(string str) { + Source = str; + SourceAvailable = true; + } + + private void OnTextAvailable(string str) { + Text = str; + TextAvailable = true; + } + + #region Download V1 Event Implementation + + private void OnDownloadProgressChanged(string resourcePath, long receivedBytes, long totalBytes) { + // Tracking multiple file downloads at once is potentially wonky due to not being given the downloadItem.Id + + // Download Started + if (receivedBytes == 0) { + // Since the File Dialog is Modal we know that there can only ever be one downloadItem = "" at a time + downloads.Add("", new DownloadItemModel("", receivedBytes, totalBytes)); + } + + // Progress Changed + DownloadItemModel downloadItem;//= new DownloadItem(resourcePath, receivedBytes, totalBytes); + // The download proceeds in the background while waiting for the resourcePath from the user + if (!string.IsNullOrWhiteSpace(resourcePath)) { + // Try to get the downloadItem by resourcePath + if (!downloads.TryGetValue(resourcePath, out downloadItem)) { + // Not found, so get the downloadItem = "" + if (downloads.TryGetValue("", out downloadItem)) { + // Now we need to first remove it from the collection as "", then put it back in the collection as resourcePath + downloads.Remove(""); + downloadItem.FullPath = resourcePath; + downloads.Add(resourcePath, downloadItem); + } + } + } else { + // Get the downloadItem = "" + downloads.TryGetValue("", out downloadItem); + } + + // Now update the downloadItem... + downloadItem?.Update(resourcePath, receivedBytes, totalBytes); + + UpdateDownloadPanel(); + + // Download Completed + if (receivedBytes == totalBytes && !string.IsNullOrWhiteSpace(resourcePath)) { + // We have to stop tracking here because the resourcePath could change between now and the DownloadCompleted firing + // the PropertyChanged Event will fire two more time after this???, so rather than remove it now we flag it for removal by the Completed Event + downloadItem?.SetCompleted(); + } + + //Debug.WriteLine($"{nameof(OnDownloadProgressChanged)}( count: {downloads.Count}, downloadItem: ( fullPath: {downloadItem.FullPath}, receivedBytes: {downloadItem.ReceivedBytes}, totalBytes {downloadItem.TotalBytes}, percentage {downloadItem.PercentComplete}, isCompleted {downloadItem.IsCompleted} ))"); + } + + private void OnDownloadCompleted(string resourcePath) { + // Here the resourcePath may be different from the resourcePath passed to PropertyChanged + + // Remove the Completed DownloadItems + var completed = downloads.Values + .Where(x => x.IsCompleted) + .ToList(); + + if (completed.Count == 1) { + var downloadItem = completed[0]; + downloadItem.Update(resourcePath); + downloads.Remove(downloadItem.FullPath); + completed.Add(downloadItem); + } + foreach (var item in completed) { + downloads.Remove(item.FullPath); + } + + DownloadMessage = $"Download Completed: {Path.GetFileName(resourcePath)}"; + DownloadPercentage = 100.0; + IsDownloadDeterminate = true; + + CloseDownloadPanel(); + } + + private void OnDownloadCancelled(string resourcePath) { + // Here the resourcePath probably will be null + DownloadItemModel downloadItem; + if (string.IsNullOrWhiteSpace(resourcePath)) { + downloads.Remove("", out downloadItem); + } else { + downloads.Remove(resourcePath, out downloadItem); + } + + downloadItem?.SetCancelled(resourcePath); + + DownloadMessage = "Download Cancelled"; + IsDownloadDeterminate = false; + + CloseDownloadPanel(); + } + + private void CloseDownloadPanel() { + Debug.WriteLine($"{nameof(CloseDownloadPanel)}: ({downloads.Count})"); + if (downloads.Count != 0) { + return; + } + + Task.Delay(2000) + .ContinueWith(t => { + if (downloads.Count != 0) { + return; + } + + IsDownloading = false; + }); + } + + private void UpdateDownloadPanel() { + var downloadItem = downloads.Values + .OrderByDescending(x => x.PercentComplete) + .First(); + + IsDownloading = true; + IsDownloadDeterminate = true; + DownloadPercentage = downloadItem.PercentComplete; + DownloadMessage = $"Downloading: {downloadItem.FullPath}"; + + var estimated = downloadItem.CurrentSpeed == 0 ? + "Unknown" : downloadItem.RemainingBytes == 0 ? + "None" : + $"{downloadItem.EstimatedTimeRemaining} sec."; + + DownloadProgress = $"{downloadItem.ReceivedBytes}/{downloadItem.TotalBytes} bytes, Time Remaining: {estimated}"; + } + + private class DownloadItemModel(string fullPath, long receivedBytes, long totalBytes) { + private readonly Stopwatch elapsedTime = Stopwatch.StartNew(); + + public string FullPath { get; set; } = fullPath; + + public string FileName { + get { + return Path.GetFileName(FullPath); + } + } + + public long ReceivedBytes { get; set; } = receivedBytes; + + public long TotalBytes { get; set; } = totalBytes; + + public double PercentComplete { + get { + return (double)ReceivedBytes / TotalBytes * 100.0; + } + } + + public long RemainingBytes { + get { + return TotalBytes - ReceivedBytes; + } + } + + public long CurrentSpeed { + get { + return (ReceivedBytes / elapsedTime.Elapsed.Seconds); + } + } // BytesPerSecond + + public long EstimatedTimeRemaining { + get; + set; + } // Seconds + + public string StoppedReason { get; set; } + + public bool IsCompleted { get; set; } + + public void Update(string fullPath, long receivedBytes, long totalBytes) { + FullPath = fullPath; + ReceivedBytes = receivedBytes; + TotalBytes = totalBytes; + + if (RemainingBytes == 0) { + elapsedTime.Stop(); + } + + if (CurrentSpeed > 0) { + EstimatedTimeRemaining = (long)((double)RemainingBytes / CurrentSpeed); + } + } + + public void Update(string fullPath) { + FullPath = fullPath; + } + + public void SetCompleted() { + elapsedTime.Stop(); + IsCompleted = true; + } + + public void SetCancelled(string fullPath) { + elapsedTime.Stop(); + FullPath = fullPath; + StoppedReason = "UserCancelled"; + ReceivedBytes = 0; + } + } + + #endregion Download V1 Event Implementation +} \ No newline at end of file diff --git a/SampleWebView.Avalonia/MainWindowViewModel.cs b/SampleWebView.Avalonia/MainWindowViewModel.cs index 2d3ce863..d970449d 100755 --- a/SampleWebView.Avalonia/MainWindowViewModel.cs +++ b/SampleWebView.Avalonia/MainWindowViewModel.cs @@ -1,100 +1,356 @@ +using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; using System.Reactive; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; using ReactiveUI; using WebViewControl; -namespace SampleWebView.Avalonia { - class MainWindowViewModel : ReactiveObject { +namespace SampleWebView.Avalonia; - private string address; - private string currentAddress; +public class MainWindowViewModel : ReactiveObject { + #region Fields - public MainWindowViewModel(WebView webview) { - Address = CurrentAddress = "http://www.google.com/"; + private readonly Dictionary downloads = new(); + private readonly List completedDownloads = new(); + private readonly WebView webView; - NavigateCommand = ReactiveCommand.Create(() => { - CurrentAddress = Address; - }); + private readonly ReactiveCommand navigateCommand; + private readonly ReactiveCommand showDevToolsCommand; + private readonly ReactiveCommand cutCommand; + private readonly ReactiveCommand copyCommand; + private readonly ReactiveCommand pasteCommand; + private readonly ReactiveCommand undoCommand; + private readonly ReactiveCommand redoCommand; + private readonly ReactiveCommand selectAllCommand; + private readonly ReactiveCommand deleteCommand; + private readonly ReactiveCommand backCommand; + private readonly ReactiveCommand forwardCommand; + private readonly ReactiveCommand getSourceCommand; + private readonly ReactiveCommand getTextCommand; + private readonly ReactiveCommand exitCommand; - ShowDevToolsCommand = ReactiveCommand.Create(() => { - webview.ShowDeveloperTools(); - }); + private string address; + private string currentAddress; + + private double downloadPercentage; + private bool isDownloading; + private bool isDownloadDeterminate; + private string downloadMessage; + private string downloadProgress; - CutCommand = ReactiveCommand.Create(() => { - webview.EditCommands.Cut(); - }); + private string source; + private bool sourceAvailable; + private string text; + private bool textAvailable; - CopyCommand = ReactiveCommand.Create(() => { - webview.EditCommands.Copy(); - }); + #endregion Fields - PasteCommand = ReactiveCommand.Create(() => { - webview.EditCommands.Paste(); - }); + public MainWindowViewModel(WebView webview) { + Address = CurrentAddress = "http://www.google.com/"; + //Address = CurrentAddress = "http://www.testfile.org/"; + webView = webview; - UndoCommand = ReactiveCommand.Create(() => { - webview.EditCommands.Undo(); - }); + webview.AllowDeveloperTools = true; - RedoCommand = ReactiveCommand.Create(() => { - webview.EditCommands.Redo(); - }); + webview.Navigated += OnNavigated; - SelectAllCommand = ReactiveCommand.Create(() => { - webview.EditCommands.SelectAll(); - }); + webView.PopupOpening += OnPopupOpening; - DeleteCommand = ReactiveCommand.Create(() => { - webview.EditCommands.Delete(); - }); - - BackCommand = ReactiveCommand.Create(() => { - webview.GoBack(); - }); - - ForwardCommand = ReactiveCommand.Create(() => { - webview.GoForward(); - }); + webview.DownloadItemStarted += DownloadItemStarted; + webview.DownloadItemProgressChanged += DownloadItemProgressChanged; + webView.DownloadItemCompleted += DownloadItemCompleted; + webview.DownloadItemStopped += DownloadItemStopped; - PropertyChanged += OnPropertyChanged; - } + navigateCommand = ReactiveCommand.Create(() => { + CurrentAddress = Address; + }); + + showDevToolsCommand = ReactiveCommand.Create(webview.ShowDeveloperTools); + + cutCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Cut(); + }); - private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(CurrentAddress)) { - Address = CurrentAddress; + copyCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Copy(); + }); + + pasteCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Paste(); + }); + + undoCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Undo(); + }); + + redoCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Redo(); + }); + + selectAllCommand = ReactiveCommand.Create(() => { + webview.EditCommands.SelectAll(); + }); + + deleteCommand = ReactiveCommand.Create(() => { + webview.EditCommands.Delete(); + }); + + backCommand = ReactiveCommand.Create(webview.GoBack); + + forwardCommand = ReactiveCommand.Create(webview.GoForward); + + getTextCommand = ReactiveCommand.Create(() => { + webview.GetText(OnTextAvailable); + }); + + getSourceCommand = ReactiveCommand.Create(() => { + webview.GetSource(OnSourceAvailable); + }); + + exitCommand = ReactiveCommand.Create(() => { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopApp) { + desktopApp.Shutdown(); } + }); + + PropertyChanged += OnPropertyChanged; + } + + public ReactiveCommand NavigateCommand => navigateCommand; + + public ReactiveCommand ShowDevToolsCommand => showDevToolsCommand; + + public ReactiveCommand CutCommand => cutCommand; + + public ReactiveCommand CopyCommand => copyCommand; + + public ReactiveCommand PasteCommand => pasteCommand; + + public ReactiveCommand UndoCommand => undoCommand; + + public ReactiveCommand RedoCommand => redoCommand; + + public ReactiveCommand SelectAllCommand => selectAllCommand; + + public ReactiveCommand DeleteCommand => deleteCommand; + + public ReactiveCommand BackCommand => backCommand; + + public ReactiveCommand ForwardCommand => forwardCommand; + + public ReactiveCommand GetSourceCommand => getSourceCommand; + + public ReactiveCommand GetTextCommand => getTextCommand; + + public ReactiveCommand ExitCommand => exitCommand; + + public string Address { + get => address; + set => this.RaiseAndSetIfChanged(ref address, value); + } + + public string CurrentAddress { + get => currentAddress; + set => this.RaiseAndSetIfChanged(ref currentAddress, value); + } + + public bool IsDownloading { + get => isDownloading; + set => this.RaiseAndSetIfChanged(ref isDownloading, value); + } + + public bool IsDownloadDeterminate { + get => isDownloadDeterminate; + set => this.RaiseAndSetIfChanged(ref isDownloadDeterminate, value); + } + + public double DownloadPercentage { + get => downloadPercentage; + set => this.RaiseAndSetIfChanged(ref downloadPercentage, value); + } + + public string DownloadMessage { + get => downloadMessage; + set => this.RaiseAndSetIfChanged(ref downloadMessage, value); + } + + public string DownloadProgress { + get => downloadProgress; + set => this.RaiseAndSetIfChanged(ref downloadProgress, value); + } + + public string Source { + get => source; + set => this.RaiseAndSetIfChanged(ref source, value); + } + + public bool SourceAvailable { + get => sourceAvailable; + set => this.RaiseAndSetIfChanged(ref sourceAvailable, value); + } + + public string Text { + get => text; + set => this.RaiseAndSetIfChanged(ref text, value); + } + + public bool TextAvailable { + get => textAvailable; + set => this.RaiseAndSetIfChanged(ref textAvailable, value); + } + + private void OnPropertyChanged(object sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(CurrentAddress)) { + Address = CurrentAddress; + } + } + + private void OnPopupOpening(string url) { + // Catch window.open(), since we are a SDI browser we will simply Navigate to the new url + CurrentAddress = url; + } + + private void OnNavigated(string url, string frameName) { + if (!string.IsNullOrWhiteSpace(frameName)) { + return; } - public string Address { - get => address; - set => this.RaiseAndSetIfChanged(ref address, value); + Text = ""; + TextAvailable = false; + //webView.GetText(OnTextAvailable); + + Source = ""; + SourceAvailable = false; + } + + private void OnSourceAvailable(string str) { + Source = str; + SourceAvailable = true; + + var sourceFilePath = SaveAs("Data\\sample.html", str); + + Debug.WriteLine($"Page source saved successfully to {sourceFilePath}"); + } + + private void OnTextAvailable(string str) { + Text = str; + TextAvailable = true; + + var textFilePath = SaveAs("Data\\sample.txt", str); + + Debug.WriteLine($"Page text saved successfully to {textFilePath}"); + } + + private string SaveAs(string fileName, string fileContents) { + if (string.IsNullOrWhiteSpace(fileName) || fileName == "/" || fileName == "\\") { + return ""; } - public string CurrentAddress { - get => currentAddress; - set => this.RaiseAndSetIfChanged(ref currentAddress, value); + try { + var fileInfo = new FileInfo(fileName); + + if (fileInfo.Directory is null || fileInfo.Attributes == FileAttributes.Directory) { + return ""; + } + + if (!fileInfo.Directory.Exists) { + fileInfo.Directory.Create(); + } + + if (fileInfo.Exists && fileInfo.IsReadOnly) { + return ""; + } + + using var sourceStreamWriter = File.CreateText(fileName); + sourceStreamWriter.Write(fileContents); + sourceStreamWriter.Close(); + + return fileInfo.FullName; + } catch { + return ""; } + } + + #region Download V2 Event Implementation + + private void DownloadItemStarted(DownloadItem item) { + downloads.Add(item.Id, item); + + IsDownloading = true; + } - public ReactiveCommand NavigateCommand { get; } + private void DownloadItemProgressChanged(DownloadItem item) { + downloads[item.Id] = item; - public ReactiveCommand ShowDevToolsCommand { get; } + UpdateDownloadPanel(); + } - public ReactiveCommand CutCommand { get; } + private void DownloadItemCompleted(DownloadItem item) { + downloads.Remove(item.Id); + completedDownloads.Add(item); - public ReactiveCommand CopyCommand { get; } + // Manage UI + IsDownloadDeterminate = true; + DownloadPercentage = 100.0; + DownloadMessage = $"Download Completed: {item.FullPath}"; + DownloadProgress = $"{item.ReceivedBytes}/{item.TotalBytes}, Time Remaining: None"; - public ReactiveCommand PasteCommand { get; } + CloseDownloadPanel(); + } - public ReactiveCommand UndoCommand { get; } + private void DownloadItemStopped(DownloadItem item) { + downloads.Remove(item.Id); - public ReactiveCommand RedoCommand { get; } + // Manage UI + IsDownloadDeterminate = false; + DownloadProgress = ""; + DownloadMessage = $"Download Stopped ({item.InterruptReason}): {item.FullPath}"; - public ReactiveCommand SelectAllCommand { get; } + CloseDownloadPanel(); + } - public ReactiveCommand DeleteCommand { get; } + private void CloseDownloadPanel() { + Debug.WriteLine($"{nameof(CloseDownloadPanel)}: ({downloads.Count})"); - public ReactiveCommand BackCommand { get; } - - public ReactiveCommand ForwardCommand { get; } + if (downloads.Count != 0) { + return; + } + + Task.Delay(2000) + .ContinueWith(t => { + if (downloads.Count != 0) { + return; + } + + IsDownloading = false; + }); } -} + + private void UpdateDownloadPanel() { + var downloadItem = downloads.Values + .OrderByDescending(x => x.PercentComplete) + .First(); + + IsDownloading = true; + IsDownloadDeterminate = true; + DownloadPercentage = downloadItem.PercentComplete; + DownloadMessage = $"Downloading: {downloadItem.FullPath}"; + + var estimated = downloadItem.CurrentSpeed == 0 ? + "Unknown" : downloadItem.RemainingBytes == 0 ? + "None" : + $"{downloadItem.EstimatedTimeRemaining} sec."; + + DownloadProgress = $"{downloadItem.ReceivedBytes}/{downloadItem.TotalBytes} bytes, Time Remaining: {estimated}"; + } + + #endregion Download V2 Event Implementation +} \ No newline at end of file diff --git a/SampleWebView.Avalonia/SampleWebView.Avalonia.csproj b/SampleWebView.Avalonia/SampleWebView.Avalonia.csproj index 18bb2319..08d007fb 100755 --- a/SampleWebView.Avalonia/SampleWebView.Avalonia.csproj +++ b/SampleWebView.Avalonia/SampleWebView.Avalonia.csproj @@ -27,16 +27,23 @@ + - + MSBuild:Compile - - + + MSBuild:Compile - + + + + + + MSBuild:Compile + diff --git a/WebViewControl.Avalonia/WebView.Avalonia.cs b/WebViewControl.Avalonia/WebView.Avalonia.cs index 79ef1179..387be777 100644 --- a/WebViewControl.Avalonia/WebView.Avalonia.cs +++ b/WebViewControl.Avalonia/WebView.Avalonia.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Runtime.ExceptionServices; using Avalonia; using Avalonia.Controls; @@ -35,10 +36,11 @@ partial void ExtraInitialize() { } protected override void OnKeyDown(KeyEventArgs e) { - if (AllowDeveloperTools && e.Key == Key.F12) { - ToggleDeveloperTools(); - e.Handled = true; - } + // See WebView.InternalKeyboardHandler + //if (AllowDeveloperTools && e.Key == Key.F12) { + // ToggleDeveloperTools(); + // e.Handled = true; + //} } protected override void OnGotFocus(GotFocusEventArgs e) { @@ -68,6 +70,24 @@ private T ExecuteInUI(Func action) { return Dispatcher.UIThread.InvokeAsync(action).Result; } + private void AsyncExecuteInUI(Action action, T value) { + if (isDisposing) { + return; + } + // use async call to avoid dead-locks, otherwise if the source action tries to to evaluate js it would block + Dispatcher.UIThread.InvokeAsync( + () => { + if (!isDisposing) { + try { + action?.Invoke(value); + } catch (Exception ex) { + ForwardUnhandledAsyncException(ex); + } + } + }, + DispatcherPriority.Normal); + } + private void AsyncExecuteInUI(Action action) { if (isDisposing) { return; diff --git a/WebViewControl.Avalonia/WebViewControl.Avalonia.csproj b/WebViewControl.Avalonia/WebViewControl.Avalonia.csproj index 1fca803d..49d6bb52 100644 --- a/WebViewControl.Avalonia/WebViewControl.Avalonia.csproj +++ b/WebViewControl.Avalonia/WebViewControl.Avalonia.csproj @@ -59,6 +59,8 @@ + + diff --git a/WebViewControl/DelegateStringVisitor.cs b/WebViewControl/DelegateStringVisitor.cs new file mode 100644 index 00000000..3063f1a9 --- /dev/null +++ b/WebViewControl/DelegateStringVisitor.cs @@ -0,0 +1,14 @@ +using System; +using System.Diagnostics; +using Xilium.CefGlue; + +namespace WebViewControl; + +public class DelegateStringVisitor(Action action, string frameName) : CefStringVisitor { + public string FrameName { get; } = frameName; + + protected override void Visit(string value) { + // This is not guaranteed to be called from the UI thread... + action?.Invoke(value); + } +} \ No newline at end of file diff --git a/WebViewControl/ThreadSafeDelegateStringVisitor.cs b/WebViewControl/ThreadSafeDelegateStringVisitor.cs new file mode 100644 index 00000000..42fbd374 --- /dev/null +++ b/WebViewControl/ThreadSafeDelegateStringVisitor.cs @@ -0,0 +1,14 @@ +using System; + +namespace WebViewControl; + +public class ThreadSafeDelegateStringVisitor( + Action, string> executor, + Action action, + string frameName) : DelegateStringVisitor(action, frameName) { + + protected override void Visit(string value) { + // Use WebView.AsyncExecuteInUI to Invoke the delegate on the UI thread! + executor.Invoke(action, value); + } +} \ No newline at end of file diff --git a/WebViewControl/WebView.InternalDownloadHandler.cs b/WebViewControl/WebView.InternalDownloadHandler.cs index f58d239c..a2ed0f04 100644 --- a/WebViewControl/WebView.InternalDownloadHandler.cs +++ b/WebViewControl/WebView.InternalDownloadHandler.cs @@ -1,4 +1,9 @@ -using Xilium.CefGlue; +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices.Marshalling; +using Xilium.CefGlue; using Xilium.CefGlue.Common.Handlers; namespace WebViewControl { @@ -18,20 +23,154 @@ protected override void OnBeforeDownload(CefBrowser browser, CefDownloadItem dow } protected override void OnDownloadUpdated(CefBrowser browser, CefDownloadItem downloadItem, CefDownloadItemCallback callback) { + var item = new DownloadItem(downloadItem); + if (downloadItem.IsComplete) { - if (OwnerWebView.DownloadCompleted != null) { - OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadCompleted?.Invoke(downloadItem.FullPath)); - } + // DownloadCompleted + FireDownloadCompletedEvents(item); } else if (downloadItem.IsCanceled) { - if (OwnerWebView.DownloadCancelled != null) { - OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadCancelled?.Invoke(downloadItem.FullPath)); - } + // DownloadCancelled + FireDownloadCancelledEvents(item); + } else if (downloadItem.IsInterrupted) { + // Interrupted + FireDownloadStoppedEvent(item); } else { - if (OwnerWebView.DownloadProgressChanged != null) { - OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadProgressChanged?.Invoke(downloadItem.FullPath, downloadItem.ReceivedBytes, downloadItem.TotalBytes)); - } + // ProgressChanged + FireDownloadPropertyChangedEvents(item); } } + + private void FireDownloadStartedEvents(DownloadItem downloadItem) { + if (OwnerWebView.DownloadItemStarted != null) { + OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadItemStarted?.Invoke(downloadItem)); + } + } + + private void FireDownloadPropertyChangedEvents(DownloadItem downloadItem) { + if (OwnerWebView.DownloadProgressChanged != null) { + OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadProgressChanged?.Invoke(downloadItem.FullPath, downloadItem.ReceivedBytes, downloadItem.TotalBytes)); + } + + if (downloadItem.ReceivedBytes == 0) { + // DownloadStarted + // We fire this here because CEF actually calls OnDownloadUpdated before OnBeforeDownload + FireDownloadStartedEvents(downloadItem); + } + + if (OwnerWebView.DownloadItemProgressChanged != null) { + OwnerWebView.AsyncExecuteInUI(() => + OwnerWebView.DownloadItemProgressChanged?.Invoke(downloadItem)); + } + } + + private void FireDownloadCancelledEvents(DownloadItem downloadItem) { + if (OwnerWebView.DownloadCancelled != null) { + OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadCancelled?.Invoke(downloadItem.FullPath)); + } + FireDownloadStoppedEvent(downloadItem); + } + + private void FireDownloadStoppedEvent(DownloadItem downloadItem) { + if (OwnerWebView.DownloadItemStopped != null) { + OwnerWebView.AsyncExecuteInUI(() => + OwnerWebView.DownloadItemStopped?.Invoke(downloadItem)); + } + } + + private void FireDownloadCompletedEvents(DownloadItem downloadItem) { + if (OwnerWebView.DownloadCompleted != null) { + OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadCompleted?.Invoke(downloadItem.FullPath)); + } + + if (OwnerWebView.DownloadItemCompleted != null) { + OwnerWebView.AsyncExecuteInUI(() => OwnerWebView.DownloadItemCompleted?.Invoke(downloadItem)); + } + } + } + } + + public enum DownloadItemState { + InProgress = 0, Complete = 1, Cancelled = 2, Interrupted = 3 + } + + public enum DownloadItemInterruptReason { + None = 0, + FileFailed = 1, + FileAccessDenied = 2, + FileNoSpace = 3, + FileNameTooLong = 5, + FileTooLarge = 6, + FileVirusInfected = 7, + FileTransientError = 10, // 0x0000000A + FileBlocked = 11, // 0x0000000B + FileSecurityCheckFailed = 12, // 0x0000000C + FileTooShort = 13, // 0x0000000D + FileHashMismatch = 14, // 0x0000000E + FileSameAsSource = 15, // 0x0000000F + NetworkFailed = 20, // 0x00000014 + NetworkTimeout = 21, // 0x00000015 + NetworkDisconnected = 22, // 0x00000016 + NetworkServerDown = 23, // 0x00000017 + NetworkInvalidRequest = 24, // 0x00000018 + ServerFailed = 30, // 0x0000001E + ServerNoRange = 31, // 0x0000001F + ServerBadContent = 33, // 0x00000021 + ServerUnauthorized = 34, // 0x00000022 + ServerCertProblem = 35, // 0x00000023 + ServerForbidden = 36, // 0x00000024 + ServerUnreachable = 37, // 0x00000025 + ServerContentLengthMismatch = 38, // 0x00000026 + ServerCrossOriginRedirect = 39, // 0x00000027 + UserCanceled = 40, // 0x00000028 + UserShutdown = 41, // 0x00000029 + Crash = 50, // 0x00000032 + } + + public sealed record DownloadItem { + public DownloadItem(CefDownloadItem cefDownloadItem) { + Id = cefDownloadItem.Id; + TotalBytes = cefDownloadItem.TotalBytes; + ReceivedBytes = cefDownloadItem.ReceivedBytes; + CurrentSpeed = cefDownloadItem.CurrentSpeed; + PercentComplete = cefDownloadItem.PercentComplete; + Url = cefDownloadItem.Url; + OriginalUrl = cefDownloadItem.OriginalUrl; + FullPath = cefDownloadItem.FullPath ?? ""; + MimeType = cefDownloadItem.MimeType; + InterruptReason = (DownloadItemInterruptReason)((int)cefDownloadItem.InterruptReason); + + if (CurrentSpeed > 0) { + EstimatedTimeRemaining = RemainingBytes / CurrentSpeed; + } + + if (cefDownloadItem.IsInProgress) { + State = DownloadItemState.InProgress; + } else if (cefDownloadItem.IsComplete) { + State = DownloadItemState.Complete; + } else if (cefDownloadItem.IsCanceled) { + State = DownloadItemState.Cancelled; + } else if (cefDownloadItem.IsInterrupted) { + State = DownloadItemState.Interrupted; + } } + + public uint Id { get; } + public long TotalBytes { get; } + public long ReceivedBytes { get; } + public long CurrentSpeed { get; } + public int PercentComplete { get; } + + public string Url { get; } = ""; + public string OriginalUrl { get; } = ""; + public string FullPath { get; } = ""; + public string MimeType { get; } = ""; + + public string FileName => Path.GetFileName(FullPath); + public string FileExtension => Path.GetExtension(FullPath); + public long RemainingBytes => TotalBytes - ReceivedBytes; + public long EstimatedTimeRemaining { get; } + + public DownloadItemInterruptReason InterruptReason { get; } + public DownloadItemState State { get; } } } diff --git a/WebViewControl/WebView.InternalJsDialogHandler.cs b/WebViewControl/WebView.InternalJsDialogHandler.cs index 54cb9ccc..8a11749e 100644 --- a/WebViewControl/WebView.InternalJsDialogHandler.cs +++ b/WebViewControl/WebView.InternalJsDialogHandler.cs @@ -16,8 +16,8 @@ public InternalJsDialogHandler(WebView webView) { protected override bool OnJSDialog(CefBrowser browser, string originUrl, CefJSDialogType dialogType, string message_text, string default_prompt_text, CefJSDialogCallback callback, out bool suppress_message) { suppress_message = false; - var javacriptDialogShown = OwnerWebView.JavacriptDialogShown; - if (javacriptDialogShown == null) { + var javascriptDialogShown = OwnerWebView.JavascriptDialogShown; + if (javascriptDialogShown == null) { return false; } @@ -26,7 +26,7 @@ void Close() { callback.Dispose(); } - javacriptDialogShown.Invoke(message_text, Close); + javascriptDialogShown.Invoke(message_text, Close); return true; } diff --git a/WebViewControl/WebView.InternalKeyboardHandler.cs b/WebViewControl/WebView.InternalKeyboardHandler.cs index fed7e771..b4e47fb0 100644 --- a/WebViewControl/WebView.InternalKeyboardHandler.cs +++ b/WebViewControl/WebView.InternalKeyboardHandler.cs @@ -2,27 +2,37 @@ using Xilium.CefGlue; using Xilium.CefGlue.Common.Handlers; -namespace WebViewControl { +namespace WebViewControl; - partial class WebView { - - private class InternalKeyboardHandler : KeyboardHandler { +partial class WebView { + private class InternalKeyboardHandler : KeyboardHandler { + public InternalKeyboardHandler(WebView webView) { + OwnerWebView = webView; + } - private WebView OwnerWebView { get; } + private WebView OwnerWebView { get; } - public InternalKeyboardHandler(WebView webView) { - OwnerWebView = webView; + protected override bool OnPreKeyEvent(CefBrowser browser, CefKeyEvent keyEvent, IntPtr os_event, + out bool isKeyboardShortcut) { + KeyPressedEventHandler handler = OwnerWebView.KeyPressed; + if (handler != null && !browser.IsPopup) { + handler(keyEvent, out bool handled); + isKeyboardShortcut = false; + return handled; } - protected override bool OnPreKeyEvent(CefBrowser browser, CefKeyEvent keyEvent, IntPtr os_event, out bool isKeyboardShortcut) { - var handler = OwnerWebView.KeyPressed; - if (handler != null && !browser.IsPopup) { - handler(keyEvent, out var handled); - isKeyboardShortcut = false; - return handled; - } - return base.OnPreKeyEvent(browser, keyEvent, os_event, out isKeyboardShortcut); + if (OwnerWebView.AllowDeveloperTools && keyEvent.WindowsKeyCode == (int)KnownWindowsKeyCodes.F12) { + // F12 Pressed + OwnerWebView.ToggleDeveloperTools(); + isKeyboardShortcut = true; + return true; } + + return base.OnPreKeyEvent(browser, keyEvent, os_event, out isKeyboardShortcut); } } -} + + private enum KnownWindowsKeyCodes { + F12 = 123 + } +} \ No newline at end of file diff --git a/WebViewControl/WebView.Wpf.cs b/WebViewControl/WebView.Wpf.cs index b60811b3..54e4e6d7 100644 --- a/WebViewControl/WebView.Wpf.cs +++ b/WebViewControl/WebView.Wpf.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using System.Diagnostics; using System.Runtime.ExceptionServices; using System.Windows; using System.Windows.Controls; @@ -52,10 +53,11 @@ partial void ExtraInitialize() { } protected override void OnPreviewKeyDown(KeyEventArgs e) { - if (AllowDeveloperTools && e.Key == Key.F12) { - ToggleDeveloperTools(); - e.Handled = true; - } + // See WebView.InternalKeyboardHandler + //if (AllowDeveloperTools && e.Key == Key.F12) { + // ToggleDeveloperTools(); + // e.Handled = true; + //} } private void OnHostWindowClosed(object sender, EventArgs e) { @@ -76,6 +78,25 @@ private T ExecuteInUI(Func action) { return Dispatcher.Invoke(action); } + private void AsyncExecuteInUI(Action action, T value) { + if (isDisposing) { + return; + } + // use async call to avoid dead-locks, otherwise if the source action tries to to evaluate js it would block + Dispatcher.InvokeAsync( + () => { + if (!isDisposing) { + try { + action?.Invoke(value); + } catch (Exception ex) { + ForwardUnhandledAsyncException(ex); + } + } + }, + DispatcherPriority.Normal, + AsyncCancellationTokenSource.Token); + } + private void AsyncExecuteInUI(Action action) { if (isDisposing) { return; diff --git a/WebViewControl/WebView.cs b/WebViewControl/WebView.cs index 25b0551f..b69a5e37 100644 --- a/WebViewControl/WebView.cs +++ b/WebViewControl/WebView.cs @@ -23,7 +23,9 @@ namespace WebViewControl { public delegate void FilesDraggingEventHandler(string[] fileNames); public delegate void TextDraggingEventHandler(string textContent); - internal delegate void JavacriptDialogShowEventHandler(string text, Action closeDialog); + public delegate void DownloadItemEventHandler(DownloadItem downloadItem); + + internal delegate void JavascriptDialogShownEventHandler(string text, Action closeDialog); internal delegate void JavascriptContextReleasedEventHandler(string frameName); internal delegate void KeyPressedEventHandler(CefKeyEvent keyEvent, out bool handled); @@ -65,9 +67,18 @@ public partial class WebView : IDisposable { public event NavigatedEventHandler Navigated; public event LoadFailedEventHandler LoadFailed; public event ResourceLoadFailedEventHandler ResourceLoadFailed; + + // V1 Download Events public event DownloadProgressChangedEventHandler DownloadProgressChanged; public event DownloadStatusChangedEventHandler DownloadCompleted; public event DownloadStatusChangedEventHandler DownloadCancelled; + + // V2 Download Events + public event DownloadItemEventHandler DownloadItemStarted; + public event DownloadItemEventHandler DownloadItemProgressChanged; + public event DownloadItemEventHandler DownloadItemStopped; + public event DownloadItemEventHandler DownloadItemCompleted; + public event JavascriptContextCreatedEventHandler JavascriptContextCreated; public event Action TitleChanged; public event UnhandledAsyncExceptionEventHandler UnhandledAsyncException; @@ -75,7 +86,7 @@ public partial class WebView : IDisposable { internal event Action Disposed; internal event JavascriptContextReleasedEventHandler JavascriptContextReleased; - internal event JavacriptDialogShowEventHandler JavacriptDialogShown; + internal event JavascriptDialogShownEventHandler JavascriptDialogShown; internal event FilesDraggingEventHandler FilesDragging; internal event TextDraggingEventHandler TextDragging; internal event KeyPressedEventHandler KeyPressed; @@ -124,10 +135,11 @@ private void Initialize() { chromium.BrowserInitialized += OnWebViewBrowserInitialized; chromium.LoadEnd += OnWebViewLoadEnd; chromium.LoadError += OnWebViewLoadError; + chromium.TitleChanged += delegate { TitleChanged?.Invoke(); }; chromium.JavascriptContextCreated += OnJavascriptContextCreated; chromium.JavascriptContextReleased += OnJavascriptContextReleased; - chromium.JavascriptUncaughException += OnJavascriptUncaughException; + chromium.JavascriptUncaughException += OnJavascriptUncaughtException; chromium.UnhandledException += (o, e) => ForwardUnhandledAsyncException(e.Exception); chromium.RequestHandler = new InternalRequestHandler(this); @@ -229,6 +241,9 @@ void InternalDispose() { internal bool IsDisposing => isDisposing; + /// + /// Allow F12 to ToggleDeveloperTools + /// public bool AllowDeveloperTools { get; set; } private string InternalAddress { @@ -277,6 +292,9 @@ public double ZoomPercentage { set { ExecuteWhenInitialized(() => chromium.ZoomLevel = Math.Log(value, PercentageToZoomFactor)); } } + /// + /// Ignores AllowDeveloperTools + /// public void ShowDeveloperTools() { ExecuteWhenInitialized(() => { chromium.ShowDeveloperTools(); @@ -284,6 +302,9 @@ public void ShowDeveloperTools() { }); } + /// + /// Ignores AllowDeveloperTools + /// public void CloseDeveloperTools() { if (isDeveloperToolsOpened) { chromium.CloseDeveloperTools(); @@ -291,6 +312,9 @@ public void CloseDeveloperTools() { } } + /// + /// Ignores AllowDeveloperTools + /// private void ToggleDeveloperTools() { if (isDeveloperToolsOpened) { CloseDeveloperTools(); @@ -299,6 +323,24 @@ private void ToggleDeveloperTools() { } } + public void GetText(Action action, string frameName = MainFrameName) { + if (string.IsNullOrWhiteSpace(frameName)) { + frameName = ""; + } + + var visitor = new ThreadSafeDelegateStringVisitor(AsyncExecuteInUI, action, frameName); + this.GetFrame(frameName).GetText(visitor); + } + + public void GetSource(Action action, string frameName = MainFrameName) { + if (string.IsNullOrWhiteSpace(frameName)) { + frameName = ""; + } + + var visitor = new ThreadSafeDelegateStringVisitor(AsyncExecuteInUI, action, frameName); + this.GetFrame(frameName).GetSource(visitor); + } + public void LoadUrl(string address, string frameName = MainFrameName) { if (this.IsMainFrame(frameName) && address != DefaultLocalUrl) { htmlToLoad = null; @@ -491,7 +533,7 @@ private void OnJavascriptContextReleased(object sender, JavascriptContextLifetim }); } - private void OnJavascriptUncaughException(object sender, JavascriptUncaughtExceptionEventArgs e) { + private void OnJavascriptUncaughtException(object sender, JavascriptUncaughtExceptionEventArgs e) { if (JavascriptExecutor.IsInternalException(e.Message)) { // ignore internal exceptions, they will be handled by the EvaluateScript caller return;