// Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. using System.IO.Abstractions; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using Common; using Microsoft.PowerToys.PreviewHandler.Markdown.Properties; using Microsoft.PowerToys.PreviewHandler.Markdown.Telemetry.Events; using Microsoft.PowerToys.Telemetry; using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.WinForms; using Windows.System; namespace Microsoft.PowerToys.PreviewHandler.Markdown { /// /// Win Form Implementation for Markdown Preview Handler. /// public class MarkdownPreviewHandlerControl : FormHandlerControl { private static readonly IFileSystem FileSystem = new FileSystem(); private static readonly IPath Path = FileSystem.Path; private static readonly IFile File = FileSystem.File; /// /// RichTextBox control to display if external images are blocked. /// private RichTextBox _infoBar; /// /// Extended Browser Control to display markdown html. /// private WebView2 _browser; /// /// WebView2 Environment /// private CoreWebView2Environment _webView2Environment; /// /// Name of the virtual host /// public const string VirtualHostName = "PowerToysLocalMarkdown"; /// /// URI of the local file saved with the contents /// private Uri _localFileURI; /// /// True if external image is blocked, false otherwise. /// private bool _infoBarDisplayed; /// /// Gets the path of the current assembly. /// /// /// Source: https://stackoverflow.com/a/283917/14774889 /// public static string AssemblyDirectory { get { string codeBase = Assembly.GetExecutingAssembly().Location; UriBuilder uri = new UriBuilder(codeBase); string path = Uri.UnescapeDataString(uri.Path); return Path.GetDirectoryName(path); } } /// /// Represent WebView2 user data folder path. /// private string _webView2UserDataFolder = System.Environment.GetEnvironmentVariable("USERPROFILE") + "\\AppData\\LocalLow\\Microsoft\\PowerToys\\MarkdownPreview-Temp"; /// /// Initializes a new instance of the class. /// public MarkdownPreviewHandlerControl() { this.SetBackgroundColor(Settings.BackgroundColor); } /// /// Start the preview on the Control. /// /// Path to the file. public override void DoPreview(T dataSource) { if (global::PowerToys.GPOWrapper.GPOWrapper.GetConfiguredMarkdownPreviewEnabledValue() == global::PowerToys.GPOWrapper.GpoRuleConfigured.Disabled) { // GPO is disabling this utility. Show an error message instead. _infoBarDisplayed = true; _infoBar = GetTextBoxControl(Resources.GpoDisabledErrorText); Resize += FormResized; Controls.Add(_infoBar); base.DoPreview(dataSource); return; } FilePreviewCommon.Helper.CleanupTempDir(_webView2UserDataFolder); _infoBarDisplayed = false; try { if (!(dataSource is string filePath)) { throw new ArgumentException($"{nameof(dataSource)} for {nameof(MarkdownPreviewHandlerControl)} must be a string but was a '{typeof(T)}'"); } string fileText = File.ReadAllText(filePath); Regex imageTagRegex = new Regex(@"<[ ]*img.*>"); if (imageTagRegex.IsMatch(fileText)) { _infoBarDisplayed = true; } string markdownHTML = FilePreviewCommon.MarkdownHelper.MarkdownHtml(fileText, Settings.GetTheme(), filePath, ImagesBlockedCallBack); _browser = new WebView2() { Dock = DockStyle.Fill, DefaultBackgroundColor = Color.Transparent, }; var webView2Options = new CoreWebView2EnvironmentOptions("--block-new-web-contents"); ConfiguredTaskAwaitable.ConfiguredTaskAwaiter webView2EnvironmentAwaiter = CoreWebView2Environment .CreateAsync(userDataFolder: _webView2UserDataFolder, options: webView2Options) .ConfigureAwait(true).GetAwaiter(); webView2EnvironmentAwaiter.OnCompleted(async () => { try { _webView2Environment = webView2EnvironmentAwaiter.GetResult(); await _browser.EnsureCoreWebView2Async(_webView2Environment).ConfigureAwait(true); _browser.CoreWebView2.SetVirtualHostNameToFolderMapping(VirtualHostName, AssemblyDirectory, CoreWebView2HostResourceAccessKind.Deny); _browser.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = false; _browser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true; _browser.CoreWebView2.Settings.AreDevToolsEnabled = false; _browser.CoreWebView2.Settings.AreHostObjectsAllowed = false; _browser.CoreWebView2.Settings.IsGeneralAutofillEnabled = false; _browser.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false; _browser.CoreWebView2.Settings.IsScriptEnabled = false; _browser.CoreWebView2.Settings.IsWebMessageEnabled = false; // Don't load any resources. _browser.CoreWebView2.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All); _browser.CoreWebView2.WebResourceRequested += (object sender, CoreWebView2WebResourceRequestedEventArgs e) => { // Show local file we've saved with the markdown contents. Block all else. if (new Uri(e.Request.Uri) != _localFileURI) { e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null); } }; _browser.CoreWebView2.ContextMenuRequested += (object sender, CoreWebView2ContextMenuRequestedEventArgs args) => { var menuItems = args.MenuItems; if (!menuItems.IsReadOnly) { var copyMenuItem = menuItems.FirstOrDefault(menuItem => menuItem.Name == "copy"); menuItems.Clear(); if (copyMenuItem != null) { menuItems.Add(copyMenuItem); } } }; // WebView2.NavigateToString() limitation // See https://learn.microsoft.com/dotnet/api/microsoft.web.webview2.core.corewebview2.navigatetostring?view=webview2-dotnet-1.0.864.35#remarks // While testing the limit, it turned out it is ~1.5MB, so to be on a safe side we go for 1.5m bytes if (markdownHTML.Length > 1_500_000) { string filename = _webView2UserDataFolder + "\\" + Guid.NewGuid().ToString() + ".html"; File.WriteAllText(filename, markdownHTML); _localFileURI = new Uri(filename); _browser.Source = _localFileURI; } else { _browser.NavigateToString(markdownHTML); } Controls.Add(_browser); _browser.NavigationStarting += async (object sender, CoreWebView2NavigationStartingEventArgs args) => { if (args.Uri != null && args.Uri != _localFileURI?.ToString() && args.IsUserInitiated) { args.Cancel = true; await Launcher.LaunchUriAsync(new Uri(args.Uri)); } }; if (_infoBarDisplayed) { _infoBar = GetTextBoxControl(Resources.BlockedImageInfoText); Resize += FormResized; Controls.Add(_infoBar); } } catch (NullReferenceException) { } }); try { PowerToysTelemetry.Log.WriteEvent(new MarkdownFilePreviewed()); } catch { // Should not crash if sending telemetry is failing. Ignore the exception. } } catch (Exception ex) { try { PowerToysTelemetry.Log.WriteEvent(new MarkdownFilePreviewError { Message = ex.Message }); } catch { // Should not crash if sending telemetry is failing. Ignore the exception. } Controls.Clear(); _infoBarDisplayed = true; _infoBar = GetTextBoxControl(Resources.MarkdownNotPreviewedError); Resize += FormResized; Controls.Add(_infoBar); } finally { base.DoPreview(dataSource); } } /// /// Gets a textbox control. /// /// Message to be displayed in textbox. /// An object of type . private RichTextBox GetTextBoxControl(string message) { RichTextBox richTextBox = new RichTextBox { Text = message, BackColor = Color.LightYellow, Multiline = true, Dock = DockStyle.Top, ReadOnly = true, }; richTextBox.ContentsResized += RTBContentsResized; richTextBox.ScrollBars = RichTextBoxScrollBars.None; richTextBox.BorderStyle = BorderStyle.None; return richTextBox; } /// /// Callback when RichTextBox is resized. /// /// Reference to resized control. /// Provides data for the resize event. private void RTBContentsResized(object sender, ContentsResizedEventArgs e) { RichTextBox richTextBox = (RichTextBox)sender; richTextBox.Height = e.NewRectangle.Height + 5; } /// /// Callback when form is resized. /// /// Reference to resized control. /// Provides data for the event. private void FormResized(object sender, EventArgs e) { if (_infoBarDisplayed) { _infoBar.Width = Width; } } /// /// Callback when image is blocked by extension. /// private void ImagesBlockedCallBack() { _infoBarDisplayed = true; } } }