/ src / modules / previewpane / MarkdownPreviewHandler / MarkdownPreviewHandlerControl.cs
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  }