diff --git a/src/Core/AppResolver.cs b/src/Core/AppResolver.cs index 7eb961b..5b84635 100644 --- a/src/Core/AppResolver.cs +++ b/src/Core/AppResolver.cs @@ -71,58 +71,67 @@ public List Search(string query) ResolvedApp? FindStartMenuShortcut(string displayName, string[]? aliases) { - var dirs = GetStartMenuDirs(); var searchNames = new List { displayName }; if (aliases != null) searchNames.AddRange(aliases); - foreach (var dir in dirs) - { - if (!Directory.Exists(dir)) continue; + // Score every shortcut against every name we know the app by and take + // the best, rather than returning the first file that matched anything. + // Returning first is what pinned Adobe Media Encoder for Visual Studio + // Code: "Encoder" contained the "Code" alias and sorted earlier. + var best = RankStartMenu(name => searchNames.Max(s => ShortcutRanker.Score(name.Name, s, name.Target))) + .FirstOrDefault(); - foreach (var lnk in Directory.EnumerateFiles(dir, "*.lnk", SearchOption.AllDirectories)) - { - var name = Path.GetFileNameWithoutExtension(lnk); - foreach (var search in searchNames) - { - if (name.Equals(search, StringComparison.OrdinalIgnoreCase) || - name.Contains(search, StringComparison.OrdinalIgnoreCase)) - { - var linkPath = ToPortableLinkPath(lnk); - var target = ResolveShortcutTarget(lnk); - return new ResolvedApp(displayName, PinType.DesktopApp, linkPath, null, null, target, 95); - } - } - } - } + if (best == null) + return null; - return null; + // Keep the caller's display name -- it asked for "Mozilla Firefox", not + // whatever the shortcut on this particular machine happens to be called. + return new ResolvedApp(displayName, PinType.DesktopApp, best.LinkPath, null, null, best.Target, best.Score); } List SearchStartMenu(string query) { - var results = new List(); - var dirs = GetStartMenuDirs(); + return RankStartMenu(c => ShortcutRanker.Score(c.Name, query, c.Target)) + .Select(c => new ResolvedApp(c.Name, PinType.DesktopApp, c.LinkPath, null, null, c.Target, c.Score)) + .ToList(); + } + + record Candidate(string Name, string LinkPath, string? Target, int Score); + + /// + /// Enumerates every Start Menu shortcut, scores it with the supplied + /// function, and returns the matches best-first. Enumeration order does not + /// influence the result -- the score and ShortcutRanker's tie-breaks + /// decide it. + /// + List RankStartMenu(Func score) + { + var candidates = new List(); - foreach (var dir in dirs) + foreach (var dir in GetStartMenuDirs()) { if (!Directory.Exists(dir)) continue; foreach (var lnk in Directory.EnumerateFiles(dir, "*.lnk", SearchOption.AllDirectories)) { var name = Path.GetFileNameWithoutExtension(lnk); - if (!name.Contains(query, StringComparison.OrdinalIgnoreCase)) + + // Cheap pre-filter, so the COM call that reads a shortcut's + // target only happens for shortcuts that could plausibly win. + // Scoring runs twice for survivors, which is far cheaper than + // opening every .lnk on the machine. + var withoutTarget = new Candidate(name, ToPortableLinkPath(lnk), null, 0); + if (score(withoutTarget) == ShortcutRanker.NoMatch) continue; var target = ResolveShortcutTarget(lnk); - var linkPath = ToPortableLinkPath(lnk); - int confidence = name.Equals(query, StringComparison.OrdinalIgnoreCase) ? 95 : 70; - - results.Add(new ResolvedApp(name, PinType.DesktopApp, linkPath, null, null, target, confidence)); + var candidate = withoutTarget with { Target = target }; + candidates.Add(candidate with { Score = score(candidate) }); } } - return results; + return ShortcutRanker.Rank(candidates, c => c.Score, c => c.Name).ToList(); } List SearchAppxPackages(string query) diff --git a/src/Core/ShortcutRanker.cs b/src/Core/ShortcutRanker.cs new file mode 100644 index 0000000..1fb6849 --- /dev/null +++ b/src/Core/ShortcutRanker.cs @@ -0,0 +1,171 @@ +namespace TaskbarUtil.Core; + +/// +/// Scores a Start Menu shortcut against a search term so that the app's primary +/// launcher wins over its uninstaller, documentation and alternate editions. +/// +/// The resolver used to take the first match in directory enumeration order, +/// which is alphabetical, and that is wrong surprisingly often: +/// +/// Uninstall <app>.lnk beat <app>.lnk (U < the app's own initial) +/// <app> Apprentice.lnk beat <app> Education.lnk (A < E) +/// <app> in Safe Mode.lnk beat <app>.lnk +/// <app> Documentation.lnk beat <app>.lnk +/// Firefox Private Browsing.lnk beat Firefox.lnk +/// +/// The last three are the same comparison: the unwanted name continues with a +/// space (0x20) where the real launcher continues with the dot (0x2E) of its +/// extension, and space sorts first. +/// +/// Separately, matching was a bare case-insensitive substring test, so the +/// "Code" alias of Visual Studio Code matched Adobe Media En-code-r and +/// pinned an unrelated application. +/// +public static class ShortcutRanker +{ + /// No match at all -- the caller must discard this candidate. + public const int NoMatch = int.MinValue; + + // Kept on the same scale the resolver already used, so a Start Menu hit + // still ranks below a KnownApps entry that resolves straight to an AUMID. + const int ExactScore = 95; + const int PrefixScore = 80; + const int WordScore = 70; + + // Sized so a demoted shortcut can never outrank a primary one (lowest + // primary score is 70, highest demoted score is 80 - 40 = 40) while every + // score a caller sees stays positive. + const int SecondaryPenalty = 40; + const int NonExecutablePenalty = 25; + + /// + /// Words that mark a shortcut as something other than the app's main + /// launcher. Matched as whole words, so "Assist" does not fire on + /// "Assistant" and "Demo" does not fire on "Democracy". + /// + /// Deliberately conservative: every entry here was observed on a lab + /// machine shadowing a real launcher. A word that merely sounds secondary + /// does not belong here -- the cost of a wrong entry is an app that can + /// never be pinned. + /// + static readonly string[] SecondaryMarkers = + { + "uninstall", "uninstaller", "remove", "repair", "modify", "setup", "installer", + "documentation", "docs", "help", "manual", "readme", "release notes", + "user guide", "getting started", "tutorial", "samples", "examples", + "website", "web site", "changelog", "license", "licensing", + "safe mode", "troubleshoot", "private browsing", + "apprentice", "non-commercial", "noncommercial", "indie", "assist", "demo", "trial", + }; + + /// + /// Shortcut targets that open a document or a web page rather than running + /// the program. This catches a documentation shortcut whose name gives + /// nothing away, and needs no vocabulary to do it. + /// + static readonly string[] NonExecutableTargets = + { + ".html", ".htm", ".chm", ".url", ".pdf", ".txt", ".md", ".rtf", ".doc", ".docx", + }; + + /// + /// Score (no extension) against + /// . Higher is better; + /// means it does not match at all. + /// + public static int Score(string shortcutName, string searchTerm, string? targetPath = null) + { + if (string.IsNullOrWhiteSpace(shortcutName) || string.IsNullOrWhiteSpace(searchTerm)) + return NoMatch; + + var name = shortcutName.Trim(); + var term = searchTerm.Trim(); + + // An exact request is honoured as-is. If someone asks for "Houdini + // Apprentice" by that name they get it, markers and all. + if (name.Equals(term, StringComparison.OrdinalIgnoreCase)) + return ExactScore; + + var at = IndexOfWord(name, term); + if (at < 0) + return NoMatch; + + var score = at == 0 ? PrefixScore : WordScore; + + if (HasSecondaryMarker(name)) + score -= SecondaryPenalty; + + if (IsNonExecutableTarget(targetPath)) + score -= NonExecutablePenalty; + + return score; + } + + /// + /// Orders candidates best-first: score, then the shortest name, then + /// ordinally by name so the result never depends on enumeration order. + /// Shortest-name is what separates "<app> 9.0" from + /// "<app> 9.0 Documentation" when no marker word applies. + /// + public static IEnumerable Rank(IEnumerable candidates, Func score, Func name) + { + return candidates + .Where(c => score(c) != NoMatch) + .OrderByDescending(score) + .ThenBy(c => name(c).Length) + .ThenBy(name, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Finds in at a word + /// boundary, or -1. + /// + /// The leading edge must not be preceded by a letter or digit, which is + /// what stops "Code" matching inside "Encoder". The trailing edge only has + /// to not be followed by a *letter*, so "Photoshop" still matches + /// "Photoshop9" where a version number runs straight on. + /// + static int IndexOfWord(string name, string term) + { + var from = 0; + while (from <= name.Length - term.Length) + { + var at = name.IndexOf(term, from, StringComparison.OrdinalIgnoreCase); + if (at < 0) + return -1; + + var startsClean = at == 0 || !char.IsLetterOrDigit(name[at - 1]); + var end = at + term.Length; + var endsClean = end == name.Length || !char.IsLetter(name[end]); + + if (startsClean && endsClean) + return at; + + from = at + 1; + } + + return -1; + } + + static bool HasSecondaryMarker(string name) + { + foreach (var marker in SecondaryMarkers) + { + if (IndexOfWord(name, marker) >= 0) + return true; + } + return false; + } + + static bool IsNonExecutableTarget(string? targetPath) + { + if (string.IsNullOrWhiteSpace(targetPath)) + return false; + + var ext = Path.GetExtension(targetPath); + if (string.IsNullOrEmpty(ext)) + return false; + + return NonExecutableTargets.Contains(ext, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/tests/ShortcutRankerTests.cs b/tests/ShortcutRankerTests.cs new file mode 100644 index 0000000..2d5970c --- /dev/null +++ b/tests/ShortcutRankerTests.cs @@ -0,0 +1,170 @@ +using Xunit; +using TaskbarUtil.Core; + +namespace TaskbarUtil.Tests; + +/// +/// The shortcut names here are the shapes that broke the old first-match +/// resolver: an uninstaller, a documentation link, a safe-mode variant, a +/// reduced edition, and an alias that appeared inside an unrelated word. +/// +public class ShortcutRankerTests +{ + /// The winner among a real set of competing shortcut names. + static string Best(string term, params (string Name, string? Target)[] shortcuts) + { + var scored = shortcuts + .Select(s => (s.Name, Score: ShortcutRanker.Score(s.Name, term, s.Target))) + .ToList(); + + var ranked = ShortcutRanker.Rank(scored, s => s.Score, s => s.Name).ToList(); + Assert.NotEmpty(ranked); + return ranked[0].Name; + } + + // --- The five reported mis-resolutions --- + + [Fact] + public void ZBrush_PrefersLauncherOverUninstaller() + { + // "Uninstall ZBrush..." used to win because U sorts before Z. + Assert.Equal("ZBrush 9", Best("ZBrush", + ("Uninstall ZBrush 9 ZBrush 9", @"C:\Program Files\Maxon ZBrush 9\Uninstall Maxon ZBrush.exe"), + ("ZBrush 9", @"C:\Program Files\Maxon ZBrush 9\ZBrush.exe"))); + } + + [Fact] + public void Rhino_PrefersLauncherOverSafeMode() + { + // "Rhino 9 in Safe Mode" used to win: it continues with a space (0x20) + // where "Rhino 9.lnk" continues with the dot (0x2E) of its extension. + Assert.Equal("Rhino 9", Best("Rhino", + ("Rhino 9 in Safe Mode", @"C:\Program Files\Rhino 9\System\Rhino.exe"), + ("Rhino 9", @"C:\Program Files\Rhino 9\System\Rhino.exe"))); + } + + [Fact] + public void Nuke_PrefersLauncherOverDocumentation() + { + Assert.Equal("Nuke 9.0v1", Best("Nuke", + ("Nuke 9.0v1 Documentation", @"C:\Program Files\Nuke9.0v1\Documentation\index.html"), + ("Nuke 9.0v1", @"C:\Program Files\Nuke9.0v1\Nuke17.1.exe"))); + } + + [Fact] + public void Houdini_PrefersFullEditionOverApprentice() + { + // Apprentice used to win on A sorting before E and F. + var winner = Best("Houdini", + ("Houdini Apprentice 9.0.1", @"C:\Program Files\Side Effects Software\Houdini 9.0.1\bin\happrentice.exe"), + ("Houdini FX 9.0.1", @"C:\Program Files\Side Effects Software\Houdini 9.0.1\bin\houdinifx.exe"), + ("Houdini Education 9.0.1", @"C:\Program Files\Side Effects Software\Houdini 9.0.1\bin\houdinied.exe")); + + Assert.NotEqual("Houdini Apprentice 9.0.1", winner); + } + + [Fact] + public void Firefox_PrefersBrowserOverPrivateBrowsingShortcut() + { + Assert.Equal("Firefox", Best("Firefox", + ("Firefox Private Browsing", @"C:\Program Files\Mozilla Firefox\private_browsing.exe"), + ("Firefox", @"C:\Program Files\Mozilla Firefox\firefox.exe"))); + } + + // --- The substring-alias defect --- + + [Fact] + public void CodeAlias_DoesNotMatchInsideEncoder() + { + // "Adobe Media Encoder 9" contains "code" but not as a word, and + // this is the whole reason Visual Studio Code pinned an Adobe app. + Assert.Equal(ShortcutRanker.NoMatch, ShortcutRanker.Score("Adobe Media Encoder 9", "Code")); + } + + [Fact] + public void CodeAlias_StillMatchesVisualStudioCode() + { + Assert.NotEqual(ShortcutRanker.NoMatch, ShortcutRanker.Score("Visual Studio Code", "Code")); + } + + [Fact] + public void VisualStudioCode_WinsAgainstEncoder() + { + Assert.Equal("Visual Studio Code", Best("Code", + ("Adobe Media Encoder 9", @"C:\Program Files\Adobe\Adobe Media Encoder 9\Adobe Media Encoder.exe"), + ("Visual Studio Code", @"C:\Program Files\Microsoft VS Code\Code.exe"))); + } + + // --- Boundary rules --- + + [Fact] + public void MatchesWhenAVersionNumberRunsStraightOn() + { + // Trailing edge only rejects a following letter, not a digit. + Assert.NotEqual(ShortcutRanker.NoMatch, ShortcutRanker.Score("Photoshop9", "Photoshop")); + } + + [Fact] + public void DoesNotMatchInsideALongerWord() + { + Assert.Equal(ShortcutRanker.NoMatch, ShortcutRanker.Score("Blenderella", "Blender")); + } + + [Fact] + public void PunctuatedNamesStillMatch() + { + Assert.NotEqual(ShortcutRanker.NoMatch, ShortcutRanker.Score("7-Zip File Manager", "7-Zip")); + } + + [Fact] + public void ExactRequestIsHonouredEvenWhenItLooksSecondary() + { + // Asking for a demoted shortcut by its full name should still get it, + // otherwise a marker word makes an app permanently unpinnable. + Assert.Equal(95, ShortcutRanker.Score("Houdini Apprentice 9.0.1", "Houdini Apprentice 9.0.1")); + } + + [Fact] + public void PrimaryAlwaysOutranksDemoted() + { + // The property the penalty sizes depend on: the worst primary score + // beats the best demoted score, so no combination of penalties can + // ever let an uninstaller through. + var worstPrimary = ShortcutRanker.Score("Some App Nuke Edition", "Nuke", @"C:\app.exe"); + var bestDemoted = ShortcutRanker.Score("Nuke Uninstall", "Nuke", @"C:\uninstall.exe"); + + Assert.True(worstPrimary > bestDemoted, $"primary {worstPrimary} should beat demoted {bestDemoted}"); + } + + // --- Ranking behaviour --- + + [Fact] + public void ShorterNameWinsWhenNothingElseSeparatesThem() + { + Assert.Equal("Maya 9", Best("Maya", + ("Maya 9 Command Line", @"C:\Program Files\Autodesk\Maya9\bin\mayabatch.exe"), + ("Maya 9", @"C:\Program Files\Autodesk\Maya9\bin\maya.exe"))); + } + + [Fact] + public void NonExecutableTargetIsDemotedWithoutAMarkerWord() + { + // A documentation shortcut whose name gives nothing away is still + // caught, because its target opens a web page rather than a program. + var doc = ShortcutRanker.Score("Blender Guide", "Blender", @"C:\Program Files\Blender\guide.html"); + var app = ShortcutRanker.Score("Blender Studio", "Blender", @"C:\Program Files\Blender\blender.exe"); + + Assert.True(app > doc, $"exe {app} should beat html {doc}"); + } + + [Fact] + public void NoMatchIsDiscardedByRank() + { + var scored = new[] { "Adobe Media Encoder 9" } + .Select(n => (Name: n, Score: ShortcutRanker.Score(n, "Code"))) + .ToList(); + + // Better to pin nothing than to pin the wrong application. + Assert.Empty(ShortcutRanker.Rank(scored, s => s.Score, s => s.Name)); + } +}