MarkdownPreviewHandlerControl.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System.IO.Abstractions; 6 using System.Reflection; 7 using System.Runtime.CompilerServices; 8 using System.Text.RegularExpressions; 9 10 using Common; 11 using Microsoft.PowerToys.PreviewHandler.Markdown.Properties; 12 using Microsoft.PowerToys.PreviewHandler.Markdown.Telemetry.Events; 13 using Microsoft.PowerToys.Telemetry; 14 using Microsoft.Web.WebView2.Core; 15 using Microsoft.Web.WebView2.WinForms; 16 using Windows.System; 17 18 namespace Microsoft.PowerToys.PreviewHandler.Markdown 19 { 20 /// <summary> 21 /// Win Form Implementation for Markdown Preview Handler. 22 /// </summary> 23 public partial class MarkdownPreviewHandlerControl : FormHandlerControl 24 { 25 private static readonly IFileSystem FileSystem = new FileSystem(); 26 private static readonly IPath Path = FileSystem.Path; 27 private static readonly IFile File = FileSystem.File; 28 29 /// <summary> 30 /// RichTextBox control to display if external images are blocked. 31 /// </summary> 32 private RichTextBox _infoBar; 33 34 /// <summary> 35 /// Extended Browser Control to display markdown html. 36 /// </summary> 37 private WebView2 _browser; 38 39 /// <summary> 40 /// WebView2 Environment 41 /// </summary> 42 private CoreWebView2Environment _webView2Environment; 43 44 /// <summary> 45 /// Name of the virtual host 46 /// </summary> 47 public const string VirtualHostName = "PowerToysLocalMarkdown"; 48 49 /// <summary> 50 /// URI of the local file saved with the contents 51 /// </summary> 52 private Uri _localFileURI; 53 54 /// <summary> 55 /// True if external image is blocked, false otherwise. 56 /// </summary> 57 private bool _infoBarDisplayed; 58 59 /// <summary> 60 /// Gets the path of the current assembly. 61 /// </summary> 62 /// <remarks> 63 /// Source: https://stackoverflow.com/a/283917/14774889 64 /// </remarks> 65 public static string AssemblyDirectory 66 { 67 get 68 { 69 string codeBase = AppContext.BaseDirectory; 70 UriBuilder uri = new UriBuilder(codeBase); 71 string path = Uri.UnescapeDataString(uri.Path); 72 return Path.GetDirectoryName(path); 73 } 74 } 75 76 /// <summary> 77 /// Represent WebView2 user data folder path. 78 /// </summary> 79 private string _webView2UserDataFolder = System.Environment.GetEnvironmentVariable("USERPROFILE") + 80 "\\AppData\\LocalLow\\Microsoft\\PowerToys\\MarkdownPreview-Temp"; 81 82 /// <summary> 83 /// Initializes a new instance of the <see cref="MarkdownPreviewHandlerControl"/> class. 84 /// </summary> 85 public MarkdownPreviewHandlerControl() 86 { 87 this.SetBackgroundColor(Settings.BackgroundColor); 88 } 89 90 /// <summary> 91 /// Start the preview on the Control. 92 /// </summary> 93 /// <param name="dataSource">Path to the file.</param> 94 public override void DoPreview<T>(T dataSource) 95 { 96 if (global::PowerToys.GPOWrapper.GPOWrapper.GetConfiguredMarkdownPreviewEnabledValue() == global::PowerToys.GPOWrapper.GpoRuleConfigured.Disabled) 97 { 98 // GPO is disabling this utility. Show an error message instead. 99 _infoBarDisplayed = true; 100 _infoBar = GetTextBoxControl(Resources.GpoDisabledErrorText); 101 Resize += FormResized; 102 Controls.Add(_infoBar); 103 base.DoPreview(dataSource); 104 105 return; 106 } 107 108 FilePreviewCommon.Helper.CleanupTempDir(_webView2UserDataFolder); 109 110 _infoBarDisplayed = false; 111 112 try 113 { 114 if (!(dataSource is string filePath)) 115 { 116 throw new ArgumentException($"{nameof(dataSource)} for {nameof(MarkdownPreviewHandlerControl)} must be a string but was a '{typeof(T)}'"); 117 } 118 119 string fileText = File.ReadAllText(filePath); 120 Regex imageTagRegex = new Regex(@"<[ ]*img.*>"); 121 if (imageTagRegex.IsMatch(fileText)) 122 { 123 _infoBarDisplayed = true; 124 } 125 126 string markdownHTML = FilePreviewCommon.MarkdownHelper.MarkdownHtml(fileText, Settings.GetTheme(), filePath, ImagesBlockedCallBack); 127 128 _browser = new WebView2() 129 { 130 Dock = DockStyle.Fill, 131 DefaultBackgroundColor = Color.Transparent, 132 }; 133 134 var webView2Options = new CoreWebView2EnvironmentOptions("--block-new-web-contents"); 135 ConfiguredTaskAwaitable<CoreWebView2Environment>.ConfiguredTaskAwaiter 136 webView2EnvironmentAwaiter = CoreWebView2Environment 137 .CreateAsync(userDataFolder: _webView2UserDataFolder, options: webView2Options) 138 .ConfigureAwait(true).GetAwaiter(); 139 webView2EnvironmentAwaiter.OnCompleted(async () => 140 { 141 try 142 { 143 _webView2Environment = webView2EnvironmentAwaiter.GetResult(); 144 await _browser.EnsureCoreWebView2Async(_webView2Environment).ConfigureAwait(true); 145 _browser.CoreWebView2.SetVirtualHostNameToFolderMapping(VirtualHostName, AssemblyDirectory, CoreWebView2HostResourceAccessKind.Deny); 146 _browser.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = false; 147 _browser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true; 148 _browser.CoreWebView2.Settings.AreDevToolsEnabled = false; 149 _browser.CoreWebView2.Settings.AreHostObjectsAllowed = false; 150 _browser.CoreWebView2.Settings.IsGeneralAutofillEnabled = false; 151 _browser.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false; 152 _browser.CoreWebView2.Settings.IsScriptEnabled = false; 153 _browser.CoreWebView2.Settings.IsWebMessageEnabled = false; 154 155 // Don't load any resources. 156 _browser.CoreWebView2.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All); 157 _browser.CoreWebView2.WebResourceRequested += (object sender, CoreWebView2WebResourceRequestedEventArgs e) => 158 { 159 // Show local file we've saved with the markdown contents. Block all else. 160 if (new Uri(e.Request.Uri) != _localFileURI) 161 { 162 e.Response = _browser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null); 163 } 164 }; 165 166 _browser.CoreWebView2.ContextMenuRequested += (object sender, CoreWebView2ContextMenuRequestedEventArgs args) => 167 { 168 var menuItems = args.MenuItems; 169 170 if (!menuItems.IsReadOnly) 171 { 172 var copyMenuItem = menuItems.FirstOrDefault(menuItem => menuItem.Name == "copy"); 173 174 menuItems.Clear(); 175 176 if (copyMenuItem != null) 177 { 178 menuItems.Add(copyMenuItem); 179 } 180 } 181 }; 182 183 // WebView2.NavigateToString() limitation 184 // See https://learn.microsoft.com/dotnet/api/microsoft.web.webview2.core.corewebview2.navigatetostring?view=webview2-dotnet-1.0.864.35#remarks 185 // While testing the limit, it turned out it is ~1.5MB, so to be on a safe side we go for 1.5m bytes 186 if (markdownHTML.Length > 1_500_000) 187 { 188 string filename = _webView2UserDataFolder + "\\" + Guid.NewGuid().ToString() + ".html"; 189 File.WriteAllText(filename, markdownHTML); 190 _localFileURI = new Uri(filename); 191 _browser.Source = _localFileURI; 192 } 193 else 194 { 195 _browser.NavigateToString(markdownHTML); 196 } 197 198 Controls.Add(_browser); 199 200 _browser.NavigationStarting += async (object sender, CoreWebView2NavigationStartingEventArgs args) => 201 { 202 if (args.Uri != null && args.Uri != _localFileURI?.ToString() && args.IsUserInitiated) 203 { 204 args.Cancel = true; 205 await Launcher.LaunchUriAsync(new Uri(args.Uri)); 206 } 207 }; 208 209 if (_infoBarDisplayed) 210 { 211 _infoBar = GetTextBoxControl(Resources.BlockedImageInfoText); 212 Resize += FormResized; 213 Controls.Add(_infoBar); 214 } 215 } 216 catch (NullReferenceException) 217 { 218 } 219 }); 220 221 try 222 { 223 PowerToysTelemetry.Log.WriteEvent(new MarkdownFilePreviewed()); 224 } 225 catch 226 { // Should not crash if sending telemetry is failing. Ignore the exception. 227 } 228 } 229 catch (Exception ex) 230 { 231 try 232 { 233 PowerToysTelemetry.Log.WriteEvent(new MarkdownFilePreviewError { Message = ex.Message }); 234 } 235 catch 236 { // Should not crash if sending telemetry is failing. Ignore the exception. 237 } 238 239 Controls.Clear(); 240 _infoBarDisplayed = true; 241 _infoBar = GetTextBoxControl(Resources.MarkdownNotPreviewedError); 242 Resize += FormResized; 243 Controls.Add(_infoBar); 244 } 245 finally 246 { 247 base.DoPreview(dataSource); 248 } 249 } 250 251 /// <summary> 252 /// Gets a textbox control. 253 /// </summary> 254 /// <param name="message">Message to be displayed in textbox.</param> 255 /// <returns>An object of type <see cref="RichTextBox"/>.</returns> 256 private RichTextBox GetTextBoxControl(string message) 257 { 258 RichTextBox richTextBox = new RichTextBox 259 { 260 Text = message, 261 BackColor = Color.LightYellow, 262 Multiline = true, 263 Dock = DockStyle.Top, 264 ReadOnly = true, 265 }; 266 richTextBox.ContentsResized += RTBContentsResized; 267 richTextBox.ScrollBars = RichTextBoxScrollBars.None; 268 richTextBox.BorderStyle = BorderStyle.None; 269 270 return richTextBox; 271 } 272 273 /// <summary> 274 /// Callback when RichTextBox is resized. 275 /// </summary> 276 /// <param name="sender">Reference to resized control.</param> 277 /// <param name="e">Provides data for the resize event.</param> 278 private void RTBContentsResized(object sender, ContentsResizedEventArgs e) 279 { 280 RichTextBox richTextBox = (RichTextBox)sender; 281 richTextBox.Height = e.NewRectangle.Height + 5; 282 } 283 284 /// <summary> 285 /// Callback when form is resized. 286 /// </summary> 287 /// <param name="sender">Reference to resized control.</param> 288 /// <param name="e">Provides data for the event.</param> 289 private void FormResized(object sender, EventArgs e) 290 { 291 if (_infoBarDisplayed) 292 { 293 _infoBar.Width = Width; 294 } 295 } 296 297 /// <summary> 298 /// Callback when image is blocked by extension. 299 /// </summary> 300 private void ImagesBlockedCallBack() 301 { 302 _infoBarDisplayed = true; 303 } 304 } 305 }