Win32Program.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.Generic; 7 using System.Diagnostics; 8 using System.Globalization; 9 using System.IO; 10 using System.IO.Abstractions; 11 using System.Linq; 12 using System.Reflection; 13 using System.Security; 14 using System.Text.RegularExpressions; 15 using System.Threading.Tasks; 16 using System.Windows.Input; 17 18 using Microsoft.Plugin.Program.Logger; 19 using Microsoft.Plugin.Program.Utils; 20 using Microsoft.Win32; 21 using Wox.Infrastructure; 22 using Wox.Infrastructure.FileSystemHelper; 23 using Wox.Plugin; 24 using Wox.Plugin.Logger; 25 26 using DirectoryWrapper = Wox.Infrastructure.FileSystemHelper.DirectoryWrapper; 27 28 namespace Microsoft.Plugin.Program.Programs 29 { 30 [Serializable] 31 public class Win32Program : IProgram 32 { 33 public static readonly Win32Program InvalidProgram = new Win32Program { Valid = false, Enabled = false }; 34 35 private static readonly IFileSystem FileSystem = new FileSystem(); 36 private static readonly IPath Path = FileSystem.Path; 37 private static readonly IFile File = FileSystem.File; 38 private static readonly IDirectory Directory = FileSystem.Directory; 39 40 public string Name { get; set; } 41 42 // Localized name based on windows display language 43 public string NameLocalized { get; set; } = string.Empty; 44 45 public string UniqueIdentifier { get; set; } 46 47 public string IcoPath { get; set; } 48 49 public string Description { get; set; } = string.Empty; 50 51 // Path of app executable or lnk target executable 52 public string FullPath { get; set; } 53 54 // Localized path based on windows display language 55 public string FullPathLocalized { get; set; } = string.Empty; 56 57 public string ParentDirectory { get; set; } 58 59 public string ExecutableName { get; set; } 60 61 // Localized executable name based on windows display language 62 public string ExecutableNameLocalized { get; set; } = string.Empty; 63 64 // Path to the lnk file on LnkProgram 65 public string LnkFilePath { get; set; } 66 67 public string LnkResolvedExecutableName { get; set; } 68 69 // Localized path based on windows display language 70 public string LnkResolvedExecutableNameLocalized { get; set; } = string.Empty; 71 72 public bool Valid { get; set; } 73 74 public bool Enabled { get; set; } 75 76 public bool HasArguments => !string.IsNullOrEmpty(Arguments); 77 78 public string Arguments { get; set; } = string.Empty; 79 80 public string Location => ParentDirectory; 81 82 public ApplicationType AppType { get; set; } 83 84 // Wrappers for File Operations 85 public static IFileVersionInfoWrapper FileVersionInfoWrapper { get; set; } = new Wox.Infrastructure.FileSystemHelper.FileVersionInfoWrapper(); 86 87 public static IFile FileWrapper { get; set; } = new FileSystem().File; 88 89 public static IShellLinkHelper ShellLinkHelper { get; set; } = new ShellLinkHelper(); 90 91 public static IDirectoryWrapper DirectoryWrapper { get; set; } = new DirectoryWrapper(); 92 93 private const string ShortcutExtension = "lnk"; 94 private const string ApplicationReferenceExtension = "appref-ms"; 95 private const string InternetShortcutExtension = "url"; 96 private static readonly HashSet<string> ExecutableApplicationExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "exe", "bat", "bin", "com", "cpl", "msc", "msi", "cmd", "ps1", "job", "msp", "mst", "sct", "ws", "wsh", "wsf" }; 97 98 private const string ProxyWebApp = "_proxy.exe"; 99 private const string AppIdArgument = "--app-id"; 100 101 public enum ApplicationType 102 { 103 WebApplication = 0, 104 InternetShortcutApplication = 1, 105 Win32Application = 2, 106 ShortcutApplication = 3, 107 ApprefApplication = 4, 108 RunCommand = 5, 109 Folder = 6, 110 GenericFile = 7, 111 } 112 113 // Function to calculate the score of a result 114 private int Score(string query) 115 { 116 var nameMatch = StringMatcher.FuzzySearch(query, Name); 117 var locNameMatch = StringMatcher.FuzzySearch(query, NameLocalized); 118 var descriptionMatch = StringMatcher.FuzzySearch(query, Description); 119 var executableNameMatch = StringMatcher.FuzzySearch(query, ExecutableName); 120 var locExecutableNameMatch = StringMatcher.FuzzySearch(query, ExecutableNameLocalized); 121 var lnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableName); 122 var locLnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableNameLocalized); 123 var score = new[] { nameMatch.Score, locNameMatch.Score, descriptionMatch.Score / 2, executableNameMatch.Score, locExecutableNameMatch.Score, lnkResolvedExecutableNameMatch.Score, locLnkResolvedExecutableNameMatch.Score }.Max(); 124 return score; 125 } 126 127 public bool IsWebApplication() 128 { 129 // To Filter PWAs when the user searches for the main application 130 // All Chromium based applications contain the --app-id argument 131 // Reference : https://codereview.chromium.org/399045 132 // Using Ordinal IgnoreCase since this is used internally 133 return !string.IsNullOrEmpty(FullPath) && 134 !string.IsNullOrEmpty(Arguments) && 135 FullPath.Contains(ProxyWebApp, StringComparison.OrdinalIgnoreCase) && 136 Arguments.Contains(AppIdArgument, StringComparison.OrdinalIgnoreCase); 137 } 138 139 // Condition to Filter pinned Web Applications or PWAs when searching for the main application 140 public bool FilterWebApplication(string query) 141 { 142 // If the app is not a web application, then do not filter it 143 if (!IsWebApplication()) 144 { 145 return false; 146 } 147 148 string[] subqueries = query?.Split() ?? Array.Empty<string>(); 149 bool nameContainsQuery = false; 150 bool pathContainsQuery = false; 151 152 // check if any space separated query is a part of the app name or path name 153 foreach (var subquery in subqueries) 154 { 155 // Using OrdinalIgnoreCase since these are used internally 156 if (FullPath.Contains(subquery, StringComparison.OrdinalIgnoreCase)) 157 { 158 pathContainsQuery = true; 159 } 160 161 if (Name.Contains(subquery, StringComparison.OrdinalIgnoreCase)) 162 { 163 nameContainsQuery = true; 164 } 165 } 166 167 return pathContainsQuery && !nameContainsQuery; 168 } 169 170 // Function to set the subtitle based on the Type of application 171 private string GetSubtitle() 172 { 173 switch (AppType) 174 { 175 case ApplicationType.Win32Application: 176 case ApplicationType.ShortcutApplication: 177 case ApplicationType.ApprefApplication: 178 return Properties.Resources.powertoys_run_plugin_program_win32_application; 179 case ApplicationType.InternetShortcutApplication: 180 return Properties.Resources.powertoys_run_plugin_program_internet_shortcut_application; 181 case ApplicationType.WebApplication: 182 return Properties.Resources.powertoys_run_plugin_program_web_application; 183 case ApplicationType.RunCommand: 184 return Properties.Resources.powertoys_run_plugin_program_run_command; 185 case ApplicationType.Folder: 186 return Properties.Resources.powertoys_run_plugin_program_folder_type; 187 case ApplicationType.GenericFile: 188 return Properties.Resources.powertoys_run_plugin_program_generic_file_type; 189 default: 190 return string.Empty; 191 } 192 } 193 194 public bool QueryEqualsNameForRunCommands(string query) 195 { 196 if (query != null && AppType == ApplicationType.RunCommand) 197 { 198 // Using OrdinalIgnoreCase since this is used internally 199 if (!query.Equals(Name, StringComparison.OrdinalIgnoreCase) && !query.Equals(ExecutableName, StringComparison.OrdinalIgnoreCase)) 200 { 201 return false; 202 } 203 } 204 205 return true; 206 } 207 208 public Result Result(string query, string queryArguments, IPublicAPI api) 209 { 210 ArgumentNullException.ThrowIfNull(api); 211 212 var score = Score(query); 213 if (score <= 0) 214 { // no need to create result if this is zero 215 return null; 216 } 217 218 if (!HasArguments) 219 { 220 var noArgumentScoreModifier = 5; 221 score += noArgumentScoreModifier; 222 } 223 else 224 { 225 // Filter Web Applications when searching for the main application 226 if (FilterWebApplication(query)) 227 { 228 return null; 229 } 230 } 231 232 // NOTE: This is to display run commands only when there is an exact match, like in start menu 233 if (!QueryEqualsNameForRunCommands(query)) 234 { 235 return null; 236 } 237 238 var result = new Result 239 { 240 // To set the title for the result to always be the name of the application 241 Title = !string.IsNullOrEmpty(NameLocalized) ? NameLocalized : Name, 242 SubTitle = GetSubtitle(), 243 IcoPath = IcoPath, 244 Score = score, 245 ContextData = this, 246 ProgramArguments = queryArguments, 247 Action = e => 248 { 249 var info = GetProcessStartInfo(queryArguments); 250 251 Main.StartProcess(Process.Start, info); 252 253 return true; 254 }, 255 }; 256 257 // Adjust title of RunCommand result 258 if (AppType == ApplicationType.RunCommand) 259 { 260 result.Title = ExecutableName; 261 } 262 263 result.TitleHighlightData = StringMatcher.FuzzySearch(query, result.Title).MatchData; 264 265 // Using CurrentCulture since this is user facing 266 var toolTipTitle = result.Title; 267 string filePath = !string.IsNullOrEmpty(FullPathLocalized) ? FullPathLocalized : FullPath; 268 var toolTipText = filePath; 269 result.ToolTipData = new ToolTipData(toolTipTitle, toolTipText); 270 271 return result; 272 } 273 274 public List<ContextMenuResult> ContextMenus(string queryArguments, IPublicAPI api) 275 { 276 ArgumentNullException.ThrowIfNull(api); 277 278 var contextMenus = new List<ContextMenuResult>(); 279 280 if (AppType != ApplicationType.InternetShortcutApplication && AppType != ApplicationType.Folder && AppType != ApplicationType.GenericFile) 281 { 282 contextMenus.Add(new ContextMenuResult 283 { 284 PluginName = Assembly.GetExecutingAssembly().GetName().Name, 285 Title = Properties.Resources.wox_plugin_program_run_as_administrator, 286 Glyph = "\xE7EF", 287 FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets", 288 AcceleratorKey = Key.Enter, 289 AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift, 290 Action = _ => 291 { 292 var info = GetProcessStartInfo(queryArguments, RunAsType.Administrator); 293 Task.Run(() => Main.StartProcess(Process.Start, info)); 294 295 return true; 296 }, 297 }); 298 299 contextMenus.Add(new ContextMenuResult 300 { 301 PluginName = Assembly.GetExecutingAssembly().GetName().Name, 302 Title = Properties.Resources.wox_plugin_program_run_as_user, 303 Glyph = "\xE7EE", 304 FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets", 305 AcceleratorKey = Key.U, 306 AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift, 307 Action = _ => 308 { 309 var info = GetProcessStartInfo(queryArguments, RunAsType.OtherUser); 310 Task.Run(() => Main.StartProcess(Process.Start, info)); 311 312 return true; 313 }, 314 }); 315 } 316 317 contextMenus.Add( 318 new ContextMenuResult 319 { 320 PluginName = Assembly.GetExecutingAssembly().GetName().Name, 321 Title = Properties.Resources.wox_plugin_program_open_containing_folder, 322 Glyph = "\xE838", 323 FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets", 324 AcceleratorKey = Key.E, 325 AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift, 326 Action = _ => 327 { 328 Helper.OpenInShell(ParentDirectory); 329 return true; 330 }, 331 }); 332 333 contextMenus.Add( 334 new ContextMenuResult 335 { 336 PluginName = Assembly.GetExecutingAssembly().GetName().Name, 337 Title = Properties.Resources.wox_plugin_program_open_in_console, 338 Glyph = "\xE756", 339 FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets", 340 AcceleratorKey = Key.C, 341 AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift, 342 Action = (context) => 343 { 344 try 345 { 346 Helper.OpenInConsole(ParentDirectory); 347 return true; 348 } 349 catch (Exception e) 350 { 351 Log.Exception($"|Failed to open {Name} in console, {e.Message}", e, GetType()); 352 return false; 353 } 354 }, 355 }); 356 357 return contextMenus; 358 } 359 360 private ProcessStartInfo GetProcessStartInfo(string programArguments, RunAsType runAs = RunAsType.None) 361 { 362 return new ProcessStartInfo 363 { 364 FileName = LnkFilePath ?? FullPath, 365 WorkingDirectory = ParentDirectory, 366 UseShellExecute = true, 367 Arguments = programArguments, 368 Verb = runAs == RunAsType.Administrator ? "runAs" : runAs == RunAsType.OtherUser ? "runAsUser" : string.Empty, 369 }; 370 } 371 372 private enum RunAsType 373 { 374 None, 375 Administrator, 376 OtherUser, 377 } 378 379 public override string ToString() 380 { 381 return ExecutableName; 382 } 383 384 private static Win32Program CreateWin32Program(string path) 385 { 386 try 387 { 388 return new Win32Program 389 { 390 Name = Path.GetFileNameWithoutExtension(path), 391 ExecutableName = Path.GetFileName(path), 392 IcoPath = path, 393 394 // Using InvariantCulture since this is user facing 395 FullPath = path, 396 UniqueIdentifier = path, 397 ParentDirectory = Directory.GetParent(path).FullName, 398 Description = string.Empty, 399 Valid = true, 400 Enabled = true, 401 AppType = ApplicationType.Win32Application, 402 403 // Localized name, path and executable based on windows display language 404 NameLocalized = Main.ShellLocalizationHelper.GetLocalizedName(path), 405 FullPathLocalized = Main.ShellLocalizationHelper.GetLocalizedPath(path), 406 ExecutableNameLocalized = Path.GetFileName(Main.ShellLocalizationHelper.GetLocalizedPath(path)), 407 }; 408 } 409 catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) 410 { 411 ProgramLogger.Warn($"|Permission denied when trying to load the program from {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 412 413 return InvalidProgram; 414 } 415 catch (Exception e) 416 { 417 ProgramLogger.Exception($"|An unexpected error occurred in the calling method CreateWin32Program at {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 418 419 return InvalidProgram; 420 } 421 } 422 423 private static readonly Regex InternetShortcutURLPrefixes = new Regex(@"^steam:\/\/(rungameid|run|open)\/|^com\.epicgames\.launcher:\/\/apps\/", RegexOptions.Compiled); 424 425 // This function filters Internet Shortcut programs 426 private static Win32Program InternetShortcutProgram(string path) 427 { 428 try 429 { 430 // We don't want to read the whole file if we don't need to 431 var lines = FileWrapper.ReadLines(path); 432 string iconPath = string.Empty; 433 string urlPath = string.Empty; 434 bool validApp = false; 435 436 const string urlPrefix = "URL="; 437 const string iconFilePrefix = "IconFile="; 438 439 foreach (string line in lines) 440 { 441 // Using OrdinalIgnoreCase since this is used internally 442 if (line.StartsWith(urlPrefix, StringComparison.OrdinalIgnoreCase)) 443 { 444 urlPath = line.Substring(urlPrefix.Length); 445 446 if (!Uri.TryCreate(urlPath, UriKind.RelativeOrAbsolute, out Uri _)) 447 { 448 ProgramLogger.Warn("url could not be parsed", null, MethodBase.GetCurrentMethod().DeclaringType, urlPath); 449 return InvalidProgram; 450 } 451 452 // To filter out only those steam shortcuts which have 'run' or 'rungameid' as the hostname 453 if (InternetShortcutURLPrefixes.Match(urlPath).Success) 454 { 455 validApp = true; 456 } 457 } 458 else if (line.StartsWith(iconFilePrefix, StringComparison.OrdinalIgnoreCase)) 459 { 460 iconPath = line.Substring(iconFilePrefix.Length); 461 } 462 463 // If we resolved an urlPath & and an iconPath quit reading the file 464 if (!string.IsNullOrEmpty(urlPath) && !string.IsNullOrEmpty(iconPath)) 465 { 466 break; 467 } 468 } 469 470 if (!validApp) 471 { 472 return InvalidProgram; 473 } 474 475 try 476 { 477 return new Win32Program 478 { 479 Name = Path.GetFileNameWithoutExtension(path), 480 ExecutableName = Path.GetFileName(path), 481 IcoPath = iconPath, 482 FullPath = urlPath, 483 UniqueIdentifier = path, 484 ParentDirectory = Directory.GetParent(path).FullName, 485 Valid = true, 486 Enabled = true, 487 AppType = ApplicationType.InternetShortcutApplication, 488 }; 489 } 490 catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) 491 { 492 ProgramLogger.Warn($"|Permission denied when trying to load the program from {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 493 494 return InvalidProgram; 495 } 496 } 497 catch (Exception e) 498 { 499 ProgramLogger.Exception($"|An unexpected error occurred in the calling method InternetShortcutProgram at {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 500 501 return InvalidProgram; 502 } 503 } 504 505 private static Win32Program LnkProgram(string path) 506 { 507 try 508 { 509 var program = CreateWin32Program(path); 510 string target = ShellLinkHelper.RetrieveTargetPath(path); 511 512 if (!string.IsNullOrEmpty(target)) 513 { 514 if (!(File.Exists(target) || Directory.Exists(target))) 515 { 516 // If the link points nowhere, consider it invalid. 517 return InvalidProgram; 518 } 519 520 program.LnkFilePath = program.FullPath; 521 program.LnkResolvedExecutableName = Path.GetFileName(target); 522 program.LnkResolvedExecutableNameLocalized = Path.GetFileName(Main.ShellLocalizationHelper.GetLocalizedPath(target)); 523 524 // Using CurrentCulture since this is user facing 525 program.FullPath = Path.GetFullPath(target); 526 program.FullPathLocalized = Main.ShellLocalizationHelper.GetLocalizedPath(target); 527 528 program.Arguments = ShellLinkHelper.Arguments; 529 530 // A .lnk could be a (Chrome) PWA, set correct AppType 531 program.AppType = program.IsWebApplication() 532 ? ApplicationType.WebApplication 533 : GetAppTypeFromPath(target); 534 535 var description = ShellLinkHelper.Description; 536 if (!string.IsNullOrEmpty(description)) 537 { 538 program.Description = description; 539 } 540 else 541 { 542 var info = FileVersionInfoWrapper.GetVersionInfo(target); 543 if (!string.IsNullOrEmpty(info?.FileDescription)) 544 { 545 program.Description = info.FileDescription; 546 } 547 } 548 } 549 550 return program; 551 } 552 catch (System.IO.FileLoadException e) 553 { 554 ProgramLogger.Warn($"Couldn't load the link file at {path}. This might be caused by a new link being created and locked by the OS.", e, MethodBase.GetCurrentMethod().DeclaringType, path); 555 return InvalidProgram; 556 } 557 558 // Only do a catch all in production. This is so make developer aware of any unhandled exception and add the exception handling in. 559 // Error caused likely due to trying to get the description of the program 560 catch (Exception e) 561 { 562 ProgramLogger.Exception($"|An unexpected error occurred in the calling method LnkProgram at {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 563 564 return InvalidProgram; 565 } 566 } 567 568 private static Win32Program ExeProgram(string path) 569 { 570 try 571 { 572 var program = CreateWin32Program(path); 573 var info = FileVersionInfoWrapper.GetVersionInfo(path); 574 if (!string.IsNullOrEmpty(info?.FileDescription)) 575 { 576 program.Description = info.FileDescription; 577 } 578 579 return program; 580 } 581 catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) 582 { 583 ProgramLogger.Warn($"|Permission denied when trying to load the program from {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 584 585 return InvalidProgram; 586 } 587 catch (FileNotFoundException e) 588 { 589 ProgramLogger.Warn($"|Unable to locate exe file at {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 590 591 return InvalidProgram; 592 } 593 catch (Exception e) 594 { 595 ProgramLogger.Exception($"|An unexpected error occurred in the calling method ExeProgram at {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 596 597 return InvalidProgram; 598 } 599 } 600 601 // Function to get the application type, given the path to the application 602 public static ApplicationType GetAppTypeFromPath(string path) 603 { 604 ArgumentNullException.ThrowIfNull(path); 605 606 string extension = Extension(path); 607 608 // Using OrdinalIgnoreCase since these are used internally with paths 609 if (ExecutableApplicationExtensions.Contains(extension)) 610 { 611 return ApplicationType.Win32Application; 612 } 613 else if (extension.Equals(ShortcutExtension, StringComparison.OrdinalIgnoreCase)) 614 { 615 return ApplicationType.ShortcutApplication; 616 } 617 else if (extension.Equals(ApplicationReferenceExtension, StringComparison.OrdinalIgnoreCase)) 618 { 619 return ApplicationType.ApprefApplication; 620 } 621 else if (extension.Equals(InternetShortcutExtension, StringComparison.OrdinalIgnoreCase)) 622 { 623 return ApplicationType.InternetShortcutApplication; 624 } 625 else if (string.IsNullOrEmpty(extension) && DirectoryWrapper.Exists(path)) 626 { 627 return ApplicationType.Folder; 628 } 629 630 return ApplicationType.GenericFile; 631 } 632 633 // Function to get the Win32 application, given the path to the application 634 public static Win32Program GetAppFromPath(string path) 635 { 636 ArgumentNullException.ThrowIfNull(path); 637 638 Win32Program app; 639 switch (GetAppTypeFromPath(path)) 640 { 641 case ApplicationType.Win32Application: 642 app = ExeProgram(path); 643 break; 644 case ApplicationType.ShortcutApplication: 645 app = LnkProgram(path); 646 break; 647 case ApplicationType.ApprefApplication: 648 app = CreateWin32Program(path); 649 app.AppType = ApplicationType.ApprefApplication; 650 break; 651 case ApplicationType.InternetShortcutApplication: 652 app = InternetShortcutProgram(path); 653 break; 654 case ApplicationType.WebApplication: 655 case ApplicationType.RunCommand: 656 case ApplicationType.Folder: 657 case ApplicationType.GenericFile: 658 default: 659 app = null; 660 break; 661 } 662 663 // if the app is valid, only then return the application, else return null 664 return app?.Valid == true 665 ? app 666 : null; 667 } 668 669 private static IEnumerable<string> ProgramPaths(string directory, IList<string> suffixes, bool recursiveSearch = true) 670 { 671 if (!Directory.Exists(directory)) 672 { 673 return Array.Empty<string>(); 674 } 675 676 var files = new List<string>(); 677 var folderQueue = new Queue<string>(); 678 folderQueue.Enqueue(directory); 679 680 // Keep track of already visited directories to avoid cycles. 681 var alreadyVisited = new HashSet<string>(); 682 683 do 684 { 685 var currentDirectory = folderQueue.Dequeue(); 686 687 if (alreadyVisited.Contains(currentDirectory)) 688 { 689 continue; 690 } 691 692 alreadyVisited.Add(currentDirectory); 693 694 try 695 { 696 foreach (var suffix in suffixes) 697 { 698 try 699 { 700 files.AddRange(Directory.EnumerateFiles(currentDirectory, $"*.{suffix}", SearchOption.TopDirectoryOnly)); 701 } 702 catch (DirectoryNotFoundException e) 703 { 704 ProgramLogger.Warn("|The directory trying to load the program from does not exist", e, MethodBase.GetCurrentMethod().DeclaringType, currentDirectory); 705 } 706 } 707 } 708 catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) 709 { 710 ProgramLogger.Warn($"|Permission denied when trying to load programs from {currentDirectory}", e, MethodBase.GetCurrentMethod().DeclaringType, currentDirectory); 711 } 712 catch (Exception e) 713 { 714 ProgramLogger.Exception($"|An unexpected error occurred in the calling method ProgramPaths at {currentDirectory}", e, MethodBase.GetCurrentMethod().DeclaringType, currentDirectory); 715 } 716 717 try 718 { 719 // If the search is set to be non-recursive, then do not enqueue the child directories. 720 if (!recursiveSearch) 721 { 722 continue; 723 } 724 725 foreach (var childDirectory in Directory.EnumerateDirectories(currentDirectory, "*", new EnumerationOptions() 726 { 727 // https://learn.microsoft.com/dotnet/api/system.io.enumerationoptions?view=net-6.0 728 // Exclude directories with the Reparse Point file attribute, to avoid loops due to symbolic links / directory junction / mount points. 729 AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint, 730 RecurseSubdirectories = false, 731 })) 732 { 733 folderQueue.Enqueue(childDirectory); 734 } 735 } 736 catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) 737 { 738 ProgramLogger.Warn($"|Permission denied when trying to load programs from {currentDirectory}", e, MethodBase.GetCurrentMethod().DeclaringType, currentDirectory); 739 } 740 catch (Exception e) 741 { 742 ProgramLogger.Exception($"|An unexpected error occurred in the calling method ProgramPaths at {currentDirectory}", e, MethodBase.GetCurrentMethod().DeclaringType, currentDirectory); 743 } 744 } 745 while (folderQueue.Count > 0); 746 747 return files; 748 } 749 750 private static string Extension(string path) 751 { 752 // Using InvariantCulture since this is user facing 753 var extension = Path.GetExtension(path)?.ToLowerInvariant(); 754 755 return !string.IsNullOrEmpty(extension) 756 ? extension.Substring(1) 757 : string.Empty; 758 } 759 760 private static IEnumerable<string> CustomProgramPaths(IEnumerable<ProgramSource> sources, IList<string> suffixes) 761 => sources?.Where(programSource => Directory.Exists(programSource.Location) && programSource.Enabled) 762 .SelectMany(programSource => ProgramPaths(programSource.Location, suffixes)) 763 .ToList() ?? Enumerable.Empty<string>(); 764 765 // Function to obtain the list of applications, the locations of which have been added to the env variable PATH 766 private static List<string> PathEnvironmentProgramPaths(IList<string> suffixes) 767 { 768 // To get all the locations stored in the PATH env variable 769 var pathEnvVariable = Environment.GetEnvironmentVariable("PATH"); 770 string[] searchPaths = pathEnvVariable.Split(Path.PathSeparator); 771 var toFilterAllPaths = new List<string>(); 772 bool isRecursiveSearch = true; 773 774 foreach (string path in searchPaths) 775 { 776 if (path.Length > 0) 777 { 778 // to expand any environment variables present in the path 779 string directory = Environment.ExpandEnvironmentVariables(path); 780 var paths = ProgramPaths(directory, suffixes, !isRecursiveSearch); 781 toFilterAllPaths.AddRange(paths); 782 } 783 } 784 785 return toFilterAllPaths; 786 } 787 788 private static List<string> IndexPath(IList<string> suffixes, List<string> indexLocations) 789 => indexLocations 790 .SelectMany(indexLocation => ProgramPaths(indexLocation, suffixes)) 791 .ToList(); 792 793 private static List<string> StartMenuProgramPaths(IList<string> suffixes) 794 { 795 var directory1 = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu); 796 var directory2 = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu); 797 var indexLocation = new List<string>() { directory1, directory2 }; 798 799 return IndexPath(suffixes, indexLocation); 800 } 801 802 private static List<string> DesktopProgramPaths(IList<string> suffixes) 803 { 804 var directory1 = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); 805 var directory2 = Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory); 806 807 var indexLocation = new List<string>() { directory1, directory2 }; 808 809 return IndexPath(suffixes, indexLocation); 810 } 811 812 private static List<string> RegistryAppProgramPaths(IList<string> suffixes) 813 { 814 // https://msdn.microsoft.com/library/windows/desktop/ee872121 815 const string appPaths = @"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths"; 816 var paths = new List<string>(); 817 using (var root = Registry.LocalMachine.OpenSubKey(appPaths)) 818 { 819 if (root != null) 820 { 821 paths.AddRange(GetPathsFromRegistry(root)); 822 } 823 } 824 825 using (var root = Registry.CurrentUser.OpenSubKey(appPaths)) 826 { 827 if (root != null) 828 { 829 paths.AddRange(GetPathsFromRegistry(root)); 830 } 831 } 832 833 return paths 834 .Where(path => suffixes.Any(suffix => path.EndsWith(suffix, StringComparison.InvariantCultureIgnoreCase))) 835 .Select(ExpandEnvironmentVariables) 836 .ToList(); 837 } 838 839 private static IEnumerable<string> GetPathsFromRegistry(RegistryKey root) 840 => root 841 .GetSubKeyNames() 842 .Select(x => GetPathFromRegistrySubkey(root, x)); 843 844 private static string GetPathFromRegistrySubkey(RegistryKey root, string subkey) 845 { 846 var path = string.Empty; 847 try 848 { 849 using (var key = root.OpenSubKey(subkey)) 850 { 851 if (key == null) 852 { 853 return string.Empty; 854 } 855 856 var defaultValue = string.Empty; 857 path = key.GetValue(defaultValue) as string; 858 } 859 860 if (string.IsNullOrEmpty(path)) 861 { 862 return string.Empty; 863 } 864 865 // fix path like this: ""\"C:\\folder\\executable.exe\"" 866 return path = path.Trim('"', ' '); 867 } 868 catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) 869 { 870 ProgramLogger.Warn($"|Permission denied when trying to load the program from {path}", e, MethodBase.GetCurrentMethod().DeclaringType, path); 871 872 return string.Empty; 873 } 874 } 875 876 private static string ExpandEnvironmentVariables(string path) => 877 path != null 878 ? Environment.ExpandEnvironmentVariables(path) 879 : null; 880 881 // Overriding the object.GetHashCode() function to aid in removing duplicates while adding and removing apps from the concurrent dictionary storage 882 public override int GetHashCode() 883 => Win32ProgramEqualityComparer.Default.GetHashCode(this); 884 885 public override bool Equals(object obj) 886 => obj is Win32Program win32Program && Win32ProgramEqualityComparer.Default.Equals(this, win32Program); 887 888 private class Win32ProgramEqualityComparer : IEqualityComparer<Win32Program> 889 { 890 public static readonly Win32ProgramEqualityComparer Default = new Win32ProgramEqualityComparer(); 891 892 public bool Equals(Win32Program app1, Win32Program app2) 893 { 894 if (app1 == null && app2 == null) 895 { 896 return true; 897 } 898 899 return app1 != null 900 && app2 != null 901 && (app1.Name?.ToUpperInvariant(), app1.ExecutableName?.ToUpperInvariant(), app1.FullPath?.ToUpperInvariant()) 902 .Equals((app2.Name?.ToUpperInvariant(), app2.ExecutableName?.ToUpperInvariant(), app2.FullPath?.ToUpperInvariant())); 903 } 904 905 public int GetHashCode(Win32Program obj) 906 => (obj.Name?.ToUpperInvariant(), obj.ExecutableName?.ToUpperInvariant(), obj.FullPath?.ToUpperInvariant()).GetHashCode(); 907 } 908 909 public static List<Win32Program> DeduplicatePrograms(IEnumerable<Win32Program> programs) 910 => new HashSet<Win32Program>(programs, Win32ProgramEqualityComparer.Default).ToList(); 911 912 private static Win32Program GetProgramFromPath(string path) 913 { 914 var extension = Extension(path); 915 if (ExecutableApplicationExtensions.Contains(extension)) 916 { 917 return ExeProgram(path); 918 } 919 920 switch (extension) 921 { 922 case ShortcutExtension: 923 return LnkProgram(path); 924 case ApplicationReferenceExtension: 925 return CreateWin32Program(path); 926 case InternetShortcutExtension: 927 return InternetShortcutProgram(path); 928 default: 929 return null; 930 } 931 } 932 933 private static bool TryGetIcoPathForRunCommandProgram(Win32Program program, out string icoPath) 934 { 935 icoPath = null; 936 937 if (program.AppType != ApplicationType.RunCommand) 938 { 939 return false; 940 } 941 942 if (string.IsNullOrEmpty(program.FullPath)) 943 { 944 return false; 945 } 946 947 // https://msdn.microsoft.com/library/windows/desktop/ee872121 948 try 949 { 950 var redirectionPath = ReparsePoint.GetTarget(program.FullPath); 951 if (string.IsNullOrEmpty(redirectionPath)) 952 { 953 return false; 954 } 955 956 icoPath = ExpandEnvironmentVariables(redirectionPath); 957 return true; 958 } 959 catch (IOException e) 960 { 961 ProgramLogger.Warn($"|Error whilst retrieving the redirection path from app execution alias {program.FullPath}", e, MethodBase.GetCurrentMethod().DeclaringType, program.FullPath); 962 } 963 964 icoPath = null; 965 return false; 966 } 967 968 private static Win32Program GetRunCommandProgramFromPath(string path) 969 { 970 var program = GetProgramFromPath(path); 971 program.AppType = ApplicationType.RunCommand; 972 973 if (TryGetIcoPathForRunCommandProgram(program, out var icoPath)) 974 { 975 program.IcoPath = icoPath; 976 } 977 978 return program; 979 } 980 981 public static IList<Win32Program> All(ProgramPluginSettings settings) 982 { 983 ArgumentNullException.ThrowIfNull(settings); 984 985 try 986 { 987 // Set an initial size to an expected size to prevent multiple hashSet resizes 988 const int defaultHashsetSize = 1000; 989 990 // Multiple paths could have the same programPaths and we don't want to resolve / lookup them multiple times 991 var paths = new HashSet<string>(defaultHashsetSize); 992 var runCommandPaths = new HashSet<string>(defaultHashsetSize); 993 994 // Parallelize multiple sources, and priority based on paths which most likely contain .lnk files which are formatted 995 var sources = new (bool IsEnabled, Func<IEnumerable<string>> GetPaths)[] 996 { 997 (true, () => CustomProgramPaths(settings.ProgramSources, settings.ProgramSuffixes)), 998 (settings.EnableStartMenuSource, () => StartMenuProgramPaths(settings.ProgramSuffixes)), 999 (settings.EnableDesktopSource, () => DesktopProgramPaths(settings.ProgramSuffixes)), 1000 (settings.EnableRegistrySource, () => RegistryAppProgramPaths(settings.ProgramSuffixes)), 1001 }; 1002 1003 // Run commands are always set as AppType "RunCommand" 1004 var runCommandSources = new (bool IsEnabled, Func<IEnumerable<string>> GetPaths)[] 1005 { 1006 (settings.EnablePathEnvironmentVariableSource, () => PathEnvironmentProgramPaths(settings.RunCommandSuffixes)), 1007 }; 1008 1009 var disabledProgramsList = settings.DisabledProgramSources; 1010 1011 // Get all paths but exclude all normal .Executables 1012 paths.UnionWith(sources 1013 .AsParallel() 1014 .SelectMany(source => source.IsEnabled ? source.GetPaths() : Enumerable.Empty<string>()) 1015 .Where(programPath => disabledProgramsList.All(x => x.UniqueIdentifier != programPath)) 1016 .Where(path => !ExecutableApplicationExtensions.Contains(Extension(path)))); 1017 runCommandPaths.UnionWith(runCommandSources 1018 .AsParallel() 1019 .SelectMany(source => source.IsEnabled ? source.GetPaths() : Enumerable.Empty<string>()) 1020 .Where(programPath => disabledProgramsList.All(x => x.UniqueIdentifier != programPath))); 1021 1022 var programs = paths.AsParallel().Select(source => GetProgramFromPath(source)); 1023 var runCommandPrograms = runCommandPaths.AsParallel().Select(source => GetRunCommandProgramFromPath(source)); 1024 1025 return DeduplicatePrograms(programs.Concat(runCommandPrograms).Where(program => program?.Valid == true)); 1026 } 1027 catch (Exception e) 1028 { 1029 ProgramLogger.Exception("An unexpected error occurred", e, MethodBase.GetCurrentMethod().DeclaringType, "Not available"); 1030 1031 return Array.Empty<Win32Program>(); 1032 } 1033 } 1034 } 1035 }