From 22555fa0c65a46d296c74c5ea8004f174653a959 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Tue, 25 Aug 2026 19:06:53 -0700 Subject: [PATCH] Rank Start Menu shortcuts so the primary launcher wins Resolving an app took the first Start Menu shortcut that matched, in directory enumeration order. That order is alphabetical, and an app's uninstaller, documentation link, safe-mode variant or reduced edition frequently sorts ahead of the launcher itself, so those got pinned instead: Uninstall beat (U before the app's initial) Apprentice beat Education in Safe Mode beat Documentation beat Firefox Private Browsing beat Firefox The last three are one comparison: the unwanted name continues with a space (0x20) where the launcher continues with the dot (0x2E) of its extension, and space sorts first. Matching was also 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. ShortcutRanker scores a candidate on how well it matches -- exact, at the start, or on a word boundary -- then demotes shortcuts whose name carries a marker word (uninstall, documentation, safe mode, apprentice, trial and similar) or whose target opens a document rather than a program. Ties go to the shortest name, which is the launcher whenever a variant is a suffix. Penalties are sized so a demoted shortcut can never outrank a primary one. Matching now requires a word boundary at the leading edge, so an alias no longer matches inside a longer word. A trailing digit is still allowed so a version number running straight on ("Photoshop9") keeps matching. Where this finds nothing it now resolves nothing rather than falling back to a loose substring hit -- pinning no app is better than pinning the wrong one. An exact name match is returned as-is, so asking for a variant by its full name still works and no app becomes unpinnable. AppResolver applies the ranking in both paths: the known-app lookup, which returned the first file matching any alias, and the broader search, whose confidence score was flat across all non-exact matches. Reading a shortcut's target is left behind a pre-filter so the COM call only happens for candidates that could plausibly win. --- src/Core/AppResolver.cs | 67 ++++++++------ src/Core/ShortcutRanker.cs | 171 +++++++++++++++++++++++++++++++++++ tests/ShortcutRankerTests.cs | 170 ++++++++++++++++++++++++++++++++++ 3 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 src/Core/ShortcutRanker.cs create mode 100644 tests/ShortcutRankerTests.cs 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)); + } +}