/ 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 }