daverayment's Description of Work
Summary of the Pull Request
This PR refactors the index generation and startup pipeline of Shortcut Guide, improving launch speed, removing redundancy and fixing concurrency issues. Several related bug fixes - including for a crash bug and for startup window flicker - are also included. It builds upon the prior PR #50497, which centred on fixing the lack of error handling and file checking in the index generation project. The commits from this PR are the latest 3. (I couldn't create a stacked PR because I don't have the necessary permissions, sorry.)
This PR is focused on the manifest file index creation and initial application startup and includes a further 12 unit tests on top of the 5 from the earlier PR.
PR Checklist
- [x] Closes: #50570
- [x] Closes: #50571
- [x] Closes: #50586
- [x] Closes: #50588
- [x] Closes: #50590
- [ ] Communication: I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected
- [x] Tests: Added/updated and all pass
- [ ] Localization: All end-user-facing strings can be localized
- [ ] Dev docs: Added/updated
- [ ] New binaries: Added on the required places
- [ ] JSON for signing for new binaries
- [ ] WXS for installer for new binaries and localization folder
- [ ] YML for CI pipeline for new test projects
- [ ] YML for signed pipeline
- [ ] Documentation updated: If checked, please file a pull request on our docs repo and link it here: #xxx
Detailed Description of the Pull Request / Additional comments
Fixes
- Fixed crash bug caused by the
_getAppIdsTaskTask inMainPaneControlbeingDisposed while it was still active. If the application was closed and then reopened immediately, this would cause the crash because two threads were attempting to update the nav items list concurrently. Tasks typically don't need to be disposed of manually. To fix the underlying issue, an invocation generation counter was added to theMainPaneControl, so an old Task can never update a newer instance of the control. A log entry is added when this is detected and mitigated. - Hardened
MainPaneControl.InitializeNavItemsAsyncagainst a latent null-reference: the background app-id task is now captured to a local variable before use, so it can no longer be cleared concurrently (byHide()/OnUnloaded()) between the null-check and the subsequentawait. - Fixed the UI flashing immediately upon first activation before the full overlay window is shown. This was a combination of an event ordering issue, the
Visibilityproperty ofMainPanebeing set toVisibletoo soon (in theLoadedhandler), and the module runner passingSW_SHOWinstead ofSW_HIDE. - The PowerToys hotkeys manifest file is now generated before the index file is created. Previously, this order was reversed, meaning Shortcut Guide had to be restarted twice to pick up any PowerToys shortcut changes by the user.
-
PowerToysShortcutsPopulator.Populate()now diffs its generated content against what's on disk and only writes if it actually changed. - Fixed the foreground window only being logged at application start instead of when the shortcut window is actually requested. The HWND/module/exe information is now logged each time Shortcut Guide is triggered.
- The index file writing is now atomic. Previously, it was possible for the UI project to read a part-written index file, leading to an exception. Now, the index file is created as a temporary file before being
Move()d to its destination.
Performance
- Manifest files are not copied if the bundled file has the same modified date or older.
- The index file is not recreated if all manifest files in the destination folder are older than it.
- In the UI project, the YamlDotNet
Deserializeris no longer created for each file's parsing - a shared class-levelDeserializeris used instead, and set toLazy<>so we don't pay for the setup cost if it isn't used. - In
PowerToysShortcutsPopulator.HotkeySettingsToYaml, we now use aStringBuilderinstead of concatenating string instances when creating shortcut entries for YAML, saving allocations and time, especially as there are already hundreds of shortcuts to process. - The index generator is called in-process instead of shelling out to an exe via
Process.Start(). Shelling out can cost more than 150 ms, and the UI cannot be displayed until this step is complete. The index generator exe is still available for external callers, and is now just a thin wrapper around calling the sharedCreateIndexYmlFile(). - Manifest index generation now processes the headers only, and uses a small custom deserializer which is
Span-based and allocation-free. Previously, YamlDotNet deserialized each manifest file completely, including the shortcuts, which were not needed. Manifest file opening and processing is done in parallel, which helps to mitigate the cost of anti-virus scanning of any newly-copied manifest files. - Index file serialization is now a few lines of
StringBuildercode and removes the need for YamlDotNet's serializer, saving startup memory and time. - In the UI project, logging the foreground window is done via a background Task, as it calls relatively expensive process-interrogation APIs.
-
GetCachedIndexYamlFile()is called once inGetAllCurrentApplicationIds()andIndexFileis cached, instead of every time a lookup happens. The file does not change after generating, so loading and deserializing the data each time is not required. -
Tasks used instead ofThreads (see below).
Manifest Index Generator project
- Now free of dependencies. The project is now AOT compilation compliant and publishing an AOT compiled version has been tested.
-
ManifestIndexGeneratoris shared between the console utility (PowerToys.ShortcutGuide.IndexYmlGenerator.exe) and the desktop application. - The manifest file path is owned by this project now, under
IndexYmlGenerator.ManifestIndexGenerator.DefaultManifestsPath. This means the UI project dependency can be removed, as it was only in place for this single path. - The new manifest header parser and index file writer allowed for the removal of the YamlDotNet dependency and a significant increase in speed.
- A
Managed.Common.Logger-compliant mini-logger was added, so CLI-callers can get logs output by file and/or via the console. - The AOT-compiled exe runs in 4 ms instead of 900 ms or more, including file reading, parsing and generating the index for 43 files.
Asynchrony
Changed new Thread() calls with TAP throughout:
1. Manifest and Index Generation (Program.cs)
- Old: Used a raw new Thread() for file copying/generation, which was later synchronously blocked on the UI thread using .Join() during the window's initialization.
- New: Converted to a fire-and-forget Task.Run(). The UI now safely awaits the new ManifestInitializationTask during startup, guaranteeing the WinUI message pump is never frozen while files are being copied or parsed.
2. PowerToys Runner Watcher (Program.cs)
- Old: Created a dedicated OS thread that blocked on a synchronous runnerProcess.WaitForExit() call, paired with a redundant global WaitHandle registration in App.xaml.cs.
- New: Replaced with Task.Run(async () => ...) and await runnerProcess.WaitForExitAsync(). This frees the underlying thread while waiting for the runner to close. The legacy ThreadPool.RegisterWaitForSingleObject infrastructure was removed.
3. Activation Event Listener (App.xaml.cs)
- Old: Used a raw Thread with a while(true) Win32 handle listener, which was stopped using .Join(timeout) during shutdown.
- New: Migrated to Task.Factory.StartNew(..., TaskCreationOptions.LongRunning). This preserves the dedicated background thread required for the blocking WaitAny loop, but standardises the lifecycle. It now shuts down cleanly using Task.Wait(), automatically catching any crashes inside a safe AggregateException rather than allowing unhandled thread exceptions to silently tear down the process.
Miscellaneous
- Added
AnonymizePath()for logging paths, which replaces a user's username in a path with verbatim<username>for privacy. - Index generation is now deterministic and independent of the order in which manifest files are retrieved. The same index.yml file will be produced for the same set of manifest files.
Performance
Release builds on Core i5-1135G7 laptop @ 2.4 GHz:
Old
1075 ms (cold start, no files exist in destination folder)
965 ms (warm start, no files exist in destination folder)
902 ms (all manifest files exist, only index needs recreating)
892 ms (30 manifest files and index missing)
954 ms (index and all manifest files already present)
New
117 ms (cold start no files exist in destination folder, copying files took 65 ms)
83 ms (warm start no files exist in destination folder, copying files took 60 ms)
37 ms (30 manifest files and index missing, 30 files copied in 35 ms)
27 ms (all manifest files exist, only index needs recreating)
6 ms (index and all manifest files already present)
Validation Steps Performed
-
Unit Tests:- Added / updated
ShortcutGuide.UnitTests.IndexGeneratorTestscovering: - Standard manifest extraction
- Quoted strings and unquoted values
- Empty files and missing required properties (
PackageName,WindowFilter) - Manifests with comments and trailing hashes
- Reserved words, executable names with and without
.exe - Case sensitivity and deterministic sorting
- Index regeneration rules
- All unit tests pass:
- Added / updated
-
Manual Verification:- Verified overlay displays correct application shortcuts and dynamic PowerToys hotkeys.
- Stress-tested rapid hotkey toggling to confirm the fix for the
_getAppIdsTaskDisposebug. - Verified no visual flash / empty overlay box on first activation.
- Verified user-added manifests in
%LocalAppData%\Microsoft\WinGet\KeyboardShortcutstrigger re-indexing on the next launch. - Verified console output and file logging when invoking
PowerToys.ShortcutGuide.IndexYmlGenerator.exestandalone. - Confirmed click-away behaviour and hotkey activation/deactivation work as before, and the overlay window appears in the foreground reliably.