/ src / modules / launcher / Plugins / Microsoft.Plugin.Program / Storage / Win32ProgramRepository.cs
Win32ProgramRepository.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;
  6  using System.Collections.Concurrent;
  7  using System.Collections.Generic;
  8  using System.Collections.ObjectModel;
  9  using System.IO;
 10  using System.IO.Abstractions;
 11  using System.Threading.Tasks;
 12  
 13  using Wox.Infrastructure.Storage;
 14  using Wox.Plugin.Logger;
 15  
 16  using Win32Program = Microsoft.Plugin.Program.Programs.Win32Program;
 17  
 18  namespace Microsoft.Plugin.Program.Storage
 19  {
 20      internal class Win32ProgramRepository : ListRepository<Programs.Win32Program>, IProgramRepository
 21      {
 22          private static readonly IFileSystem FileSystem = new FileSystem();
 23          private static readonly IPath Path = FileSystem.Path;
 24  
 25          private const string LnkExtension = ".lnk";
 26          private const string UrlExtension = ".url";
 27  
 28          private ProgramPluginSettings _settings;
 29          private IList<IFileSystemWatcherWrapper> _fileSystemWatcherHelpers;
 30          private string[] _pathsToWatch;
 31          private int _numberOfPathsToWatch;
 32          private Collection<string> extensionsToWatch = new Collection<string> { "*.exe", $"*{LnkExtension}", "*.appref-ms", $"*{UrlExtension}" };
 33  
 34          private static ConcurrentQueue<string> commonEventHandlingQueue = new ConcurrentQueue<string>();
 35  
 36          public static readonly int OnRenamedEventWaitTime = 1000;
 37  
 38          public Win32ProgramRepository(IList<IFileSystemWatcherWrapper> fileSystemWatcherHelpers, ProgramPluginSettings settings, string[] pathsToWatch)
 39          {
 40              _fileSystemWatcherHelpers = fileSystemWatcherHelpers;
 41              _settings = settings ?? throw new ArgumentNullException(nameof(settings), "Win32ProgramRepository requires an initialized settings object");
 42              _pathsToWatch = pathsToWatch;
 43              _numberOfPathsToWatch = pathsToWatch.Length;
 44              InitializeFileSystemWatchers();
 45  
 46              // This task would always run in the background trying to dequeue file paths from the queue at regular intervals.
 47              _ = Task.Run(async () =>
 48              {
 49                  while (true)
 50                  {
 51                      int dequeueDelay = 500;
 52                      string appPath = await EventHandler.GetAppPathFromQueueAsync(commonEventHandlingQueue, dequeueDelay).ConfigureAwait(false);
 53  
 54                      // To allow for the installation process to finish.
 55                      await Task.Delay(5000).ConfigureAwait(false);
 56  
 57                      if (!string.IsNullOrEmpty(appPath))
 58                      {
 59                          Programs.Win32Program app = Programs.Win32Program.GetAppFromPath(appPath);
 60                          if (app != null)
 61                          {
 62                              Add(app);
 63                          }
 64                      }
 65                  }
 66              }).ConfigureAwait(false);
 67          }
 68  
 69          private void InitializeFileSystemWatchers()
 70          {
 71              for (int index = 0; index < _numberOfPathsToWatch; index++)
 72              {
 73                  // To set the paths to monitor
 74                  _fileSystemWatcherHelpers[index].Path = _pathsToWatch[index];
 75  
 76                  // to be notified when there is a change to a file
 77                  _fileSystemWatcherHelpers[index].NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite;
 78  
 79                  // filtering the app types that we want to monitor
 80                  _fileSystemWatcherHelpers[index].Filters = extensionsToWatch;
 81  
 82                  // Registering the event handlers
 83                  _fileSystemWatcherHelpers[index].Created += OnAppCreated;
 84                  _fileSystemWatcherHelpers[index].Deleted += OnAppDeleted;
 85                  _fileSystemWatcherHelpers[index].Renamed += OnAppRenamed;
 86                  _fileSystemWatcherHelpers[index].Changed += OnAppChanged;
 87  
 88                  // Enable the file system watcher
 89                  _fileSystemWatcherHelpers[index].EnableRaisingEvents = true;
 90  
 91                  // Enable it to search in sub folders as well
 92                  _fileSystemWatcherHelpers[index].IncludeSubdirectories = true;
 93              }
 94          }
 95  
 96          private async Task DoOnAppRenamedAsync(object sender, RenamedEventArgs e)
 97          {
 98              string oldPath = e.OldFullPath;
 99              string newPath = e.FullPath;
100  
101              // fix for https://github.com/microsoft/PowerToys/issues/34391
102              // the msi installer creates a shortcut, which is detected by the PT Run and ends up in calling this OnAppRenamed method
103              // the thread needs to be halted for a short time to avoid locking the new shortcut file as we read it; otherwise, the lock causes
104              // in the issue scenario that a warning is popping up during the msi install process.
105              await Task.Delay(OnRenamedEventWaitTime).ConfigureAwait(false);
106  
107              string extension = Path.GetExtension(newPath);
108              Win32Program.ApplicationType oldAppType = Win32Program.GetAppTypeFromPath(oldPath);
109              Programs.Win32Program newApp = Win32Program.GetAppFromPath(newPath);
110              Programs.Win32Program oldApp = null;
111  
112              // Once the shortcut application is renamed, the old app does not exist and therefore when we try to get the FullPath we get the lnk path instead of the exe path
113              // This changes the hashCode() of the old application.
114              // Therefore, instead of retrieving the old app using the GetAppFromPath(), we construct the application ourself
115              // This situation is not encountered for other application types because the fullPath is the path itself, instead of being computed by using the path to the app.
116              try
117              {
118                  if (oldAppType == Win32Program.ApplicationType.ShortcutApplication || oldAppType == Win32Program.ApplicationType.InternetShortcutApplication)
119                  {
120                      oldApp = new Win32Program() { Name = Path.GetFileNameWithoutExtension(e.OldName), ExecutableName = Path.GetFileName(e.OldName), FullPath = newApp?.FullPath ?? oldPath };
121                  }
122                  else
123                  {
124                      oldApp = Win32Program.GetAppFromPath(oldPath);
125                  }
126              }
127              catch (Exception ex)
128              {
129                  Log.Exception($"DoOnAppRenamedAsync-{extension} Program|{e.OldName}|Unable to create program from {oldPath}", ex, GetType());
130              }
131  
132              // To remove the old app which has been renamed and to add the new application.
133              if (oldApp != null)
134              {
135                  if (string.IsNullOrWhiteSpace(oldApp.Name) || string.IsNullOrWhiteSpace(oldApp.ExecutableName) || string.IsNullOrWhiteSpace(oldApp.FullPath))
136                  {
137                      Log.Warn($"Old app data was not initialized properly for removal after file renaming. This likely means it was not a valid app to begin with and removal is not needed. OldFullPath: {e.OldFullPath}; OldName: {e.OldName}; FullPath: {e.FullPath}", GetType());
138                  }
139                  else
140                  {
141                      Remove(oldApp);
142                  }
143              }
144  
145              if (newApp != null)
146              {
147                  Add(newApp);
148              }
149          }
150  
151          private void OnAppRenamed(object sender, RenamedEventArgs e)
152          {
153              Task.Run(async () =>
154              {
155                  try
156                  {
157                      await DoOnAppRenamedAsync(sender, e).ConfigureAwait(false);
158                  }
159                  catch (Exception e)
160                  {
161                      Log.Exception($"OnAppRenamed throw exception.", e, e.GetType());
162                  }
163              }).ConfigureAwait(false);
164          }
165  
166          private void OnAppDeleted(object sender, FileSystemEventArgs e)
167          {
168              string path = e.FullPath;
169              string extension = Path.GetExtension(path);
170              Programs.Win32Program app = null;
171  
172              try
173              {
174                  // To mitigate the issue of not having a FullPath for a shortcut app, we iterate through the items and find the app with the same hashcode.
175                  // Using OrdinalIgnoreCase since this is used internally
176                  if (extension.Equals(LnkExtension, StringComparison.OrdinalIgnoreCase))
177                  {
178                      app = GetAppWithSameLnkFilePath(path);
179                      if (app == null)
180                      {
181                          // Cancelled links won't have a resolved path.
182                          app = GetAppWithSameNameAndExecutable(Path.GetFileNameWithoutExtension(path), Path.GetFileName(path));
183                      }
184                  }
185                  else if (extension.Equals(UrlExtension, StringComparison.OrdinalIgnoreCase))
186                  {
187                      app = GetAppWithSameNameAndExecutable(Path.GetFileNameWithoutExtension(path), Path.GetFileName(path));
188                  }
189                  else
190                  {
191                      app = Programs.Win32Program.GetAppFromPath(path);
192                  }
193              }
194              catch (Exception ex)
195              {
196                  Log.Exception($"OnAppDeleted-{extension}Program|{path}|Unable to create program from {path}", ex, GetType());
197              }
198  
199              if (app != null)
200              {
201                  Remove(app);
202              }
203          }
204  
205          // When a URL application is deleted, we can no longer get the HashCode directly from the path because the FullPath a Url app is the URL obtained from reading the file
206          [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1309:Use ordinal string comparison", Justification = "Using CurrentCultureIgnoreCase since application names could be dependent on current culture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190")]
207          private Win32Program GetAppWithSameNameAndExecutable(string name, string executableName)
208          {
209              foreach (Win32Program app in Items)
210              {
211                  // Using CurrentCultureIgnoreCase since application names could be dependent on current culture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190
212                  if (name.Equals(app.Name, StringComparison.CurrentCultureIgnoreCase) && executableName.Equals(app.ExecutableName, StringComparison.CurrentCultureIgnoreCase))
213                  {
214                      return app;
215                  }
216              }
217  
218              return null;
219          }
220  
221          // To mitigate the issue faced (as stated above) when a shortcut application is renamed, the Exe FullPath and executable name must be obtained.
222          // Unlike the rename event args, since we do not have a newPath, we iterate through all the programs and find the one with the same LnkResolved path.
223          private Programs.Win32Program GetAppWithSameLnkFilePath(string lnkFilePath)
224          {
225              foreach (Programs.Win32Program app in Items)
226              {
227                  // Using Invariant / OrdinalIgnoreCase since we're comparing paths
228                  if (lnkFilePath.ToUpperInvariant().Equals(app.LnkFilePath, StringComparison.OrdinalIgnoreCase))
229                  {
230                      return app;
231                  }
232              }
233  
234              return null;
235          }
236  
237          private void OnAppCreated(object sender, FileSystemEventArgs e)
238          {
239              string path = e.FullPath;
240  
241              // Using OrdinalIgnoreCase since we're comparing extensions
242              if (!Path.GetExtension(path).Equals(UrlExtension, StringComparison.OrdinalIgnoreCase) && !Path.GetExtension(path).Equals(LnkExtension, StringComparison.OrdinalIgnoreCase))
243              {
244                  Programs.Win32Program app = Programs.Win32Program.GetAppFromPath(path);
245                  if (app != null)
246                  {
247                      Add(app);
248                  }
249              }
250          }
251  
252          private void OnAppChanged(object sender, FileSystemEventArgs e)
253          {
254              string path = e.FullPath;
255  
256              // Using OrdinalIgnoreCase since we're comparing extensions
257              if (Path.GetExtension(path).Equals(UrlExtension, StringComparison.OrdinalIgnoreCase) || Path.GetExtension(path).Equals(LnkExtension, StringComparison.OrdinalIgnoreCase))
258              {
259                  // When a url or lnk app is installed, multiple created and changed events are triggered.
260                  // To prevent the code from acting on the first such event (which may still be during app installation), the events are added a common queue and dequeued by a background task at regular intervals - https://github.com/microsoft/PowerToys/issues/6429.
261                  commonEventHandlingQueue.Enqueue(path);
262              }
263          }
264  
265          public void IndexPrograms()
266          {
267              var applications = Programs.Win32Program.All(_settings);
268              Log.Info($"Indexed {applications.Count} win32 applications", GetType());
269              SetList(applications);
270          }
271      }
272  }