diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index c419d1b588..83289fa102 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -291,6 +291,7 @@
"Mono.Cecil.Rocks.dll",
"Newtonsoft.Json.dll",
"CommunityToolkit.WinUI.Controls.TitleBar.dll",
+ "CommunityToolkit.WinUI.Controls.OpacityMaskView.dll",
"NLog.dll",
"HtmlAgilityPack.dll",
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0c5f282973..c4412d2db9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,6 +7,7 @@
+
diff --git a/NOTICE.md b/NOTICE.md
index a0d87d429c..6ca3cbfceb 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -1498,6 +1498,7 @@ SOFTWARE.
- CoenM.ImageSharp.ImageHash
- CommunityToolkit.Common
- CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock
+- CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView
- CommunityToolkit.Mvvm
- CommunityToolkit.WinUI.Animations
- CommunityToolkit.WinUI.Collections
diff --git a/README.md b/README.md
index ab53e53a46..624d95501b 100644
--- a/README.md
+++ b/README.md
@@ -51,19 +51,20 @@ But to get started quickly, choose one of the installation methods below:
Go to the [PowerToys GitHub releases][github-release-link], click Assets to reveal the downloads, and choose the installer that matches your architecture and install scope. For most devices, that's the x64 per-user installer.
-[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.96%22
-[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.95%22
-[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysUserSetup-0.95.1-x64.exe
-[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysUserSetup-0.95.1-arm64.exe
-[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysSetup-0.95.1-x64.exe
-[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.95.1/PowerToysSetup-0.95.1-arm64.exe
+[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22
+[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.96%22
+[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysUserSetup-0.96.0-x64.exe
+[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysUserSetup-0.96.0-arm64.exe
+[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysSetup-0.96.0-x64.exe
+[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysSetup-0.96.0-arm64.exe
| Description | Filename |
|----------------|----------|
-| Per user - x64 | [PowerToysUserSetup-0.95.1-x64.exe][ptUserX64] |
-| Per user - ARM64 | [PowerToysUserSetup-0.95.1-arm64.exe][ptUserArm64] |
-| Machine wide - x64 | [PowerToysSetup-0.95.1-x64.exe][ptMachineX64] |
-| Machine wide - ARM64 | [PowerToysSetup-0.95.1-arm64.exe][ptMachineArm64] |
+| Per user - x64 | [PowerToysUserSetup-0.96.0-x64.exe][ptUserX64] |
+| Per user - ARM64 | [PowerToysUserSetup-0.96.0-arm64.exe][ptUserArm64] |
+| Machine wide - x64 | [PowerToysSetup-0.96.0-x64.exe][ptMachineX64] |
+| Machine wide - ARM64 | [PowerToysSetup-0.96.0-arm64.exe][ptMachineArm64] |
+
@@ -102,156 +103,131 @@ There are [community driven install methods](./doc/unofficialInstallMethods.md)
## ✨ What's new
-**Version 0.95 (October 2025)**
+**Version 0.96 (November 2025)**
For an in-depth look at the latest changes, visit the [Windows Command Line blog](https://aka.ms/powertoys-releaseblog).
**✨ Highlights**
- - **NEW:** The **Light Switch** utility in PowerToys allows you to automatically switch between light and dark themes in Windows based on the time of day.
- - Command Palette delivered major search performance gains (new fuzzy matcher and smarter fallbacks) improving relevance and speed.
- - Peek can now be activated using just the Spacebar!
- - Find My Mouse added transparent spotlight with independent backdrop opacity, boosting focus and accessibility.
- - Settings now lets you delete shortcuts entirely and ignore conflicts.
- - Mouse Pointer Crosshairs gained orientation options (vertical / horizontal / both) for customizable accessibility. Thanks [@mikehall-ms](https://github.com/mikehall-ms)!
- - PowerRename fixed enumeration counter skipping ensuring reliable batch renames. Thanks [@daverayment](https://github.com/daverayment)!
- - ZoomIt restored legacy draw and snipping behaviors, and fixed recording issues, improving reliability. Thanks [@chakrik73](https://github.com/chakrik73)!
+ - Advanced Paste now supports multiple online and on-device AI model providers: Azure OpenAI, OpenAI, Google Gemini, Mistral, Foundry Local and Ollama.
+ - Command Palette received extensive improvements including file search filters, better clipboard history metadata, context-menu styling, and dozens of bug fixes and enhancements.
+ - PowerRename can now extract and use photo metadata (EXIF, XMP) in renaming patterns like `%Camera`, `%Lens`, and `%ExposureTime`.
+
+### Advanced Paste
+ - Advanced Paste now lets you connect to multiple AI providers instead of being limited to a single OpenAI provider. See [Advanced Paste documentation](https://learn.microsoft.com/windows/powertoys/advanced-paste) for usage.
+
+### Awake
+ - The Awake countdown timer now stays accurate over long periods. Thanks [@daverayment](https://github.com/daverayment)!
+ - Fixed Awake context menu positioning. The fix removed the conversion of the mouse cursor from screen to client-window coordinates, instead using the raw screen coordinates returned by GetCursorPos; the context menu now appears at the correct screen position. Thanks [@lzandman](https://github.com/lzandman)!
### Command Palette
- - Applied conditional margin for icon-only tags to tighten layout. Thanks [@samrueby](https://github.com/samrueby)
- - Improved the reliability of accessing Command Palette settings through PowerToys Settings and executing other x-cmdpal:// protocol commands. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Enabled AOT by default for improved performance while simplifying publish configs.
- - Replaced service state color dots with play/pause/stop icons for enhanced accessibility. Thanks [@samrueby](https://github.com/samrueby)
- - Fixed filter dropdown sync and crash by binding SelectedValue and raising UI-thread notifications. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Ensured long links wrap correctly in details view.
- - Removed animation and enforced minimum width on filter dropdown for clarity. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Restored focus to More button after ESC closes context menu, improving keyboard flow. Thanks [@chatasweetie](https://github.com/chatasweetie)
- - Marked main and toast windows as tool windows to keep them out of Alt+Tab while preserving style. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Fixed AOT template and theming issues for filter separators. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Introduced grid layouts (small, medium, gallery) for richer page presentation.
- - Materialized result lists to avoid rescoring overhead.
- - Disabled problematic selection TextToSuggest behind environment flag.
- - Major search performance improvements (new fuzzy matcher, smarter fallbacks, fewer exceptions).
- - Added context menu "Show Details" command when details pane is hidden.
- - Reduced window flicker by avoiding unnecessary cloaking. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Restored EmptyContent rendering for blank states. Thanks [@DevLGuilherme](https://github.com/DevLGuilherme)
- - Saved new state even if prior app state file was corrupt (better resilience). Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Migrated settings window to WinUI TitleBar control. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Prevented crash on duplicate keybindings and simplified matching. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Hotkeys now always respect the “Ignore shortcut in fullscreen” setting. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Hid search box on content pages, improving focus and accessibility, and added Home title. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Blocked Ctrl+I from inserting stray tabs in search box.
- - Logged HRESULT codes in error logs for deeper diagnostics. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Advanced font and emoji icon classification and alignment improvements. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Ensured that fallback command icons are visible on the extension settings page. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Fixed breadcrumb margin misalignment (visual polish). Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Truncated overly long command labels with ellipsis to prevent overflow.
- - Added a setting to configure the page transition animation.
- - Collection of small improvements and nits for Run Commands.
- - Improved bookmarks performance and experience. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Added Ctrl+O shortcut in Clipboard History to open links directly.
- - Resolved conflict with external software that blocked Command Palette from hiding.
- - Updated context menu items to reflect name and icon changes, and ensured application icons are displayed correctly. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Added Alt+Home shortcut to return immediately to the Command Palette home page. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Fixed a crash when displaying code blocks in markdown on detail or content pages. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Fixed an issue where the search bar icon and title were not updated when rapidly switching pages. Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Improved the appearance of the search box in the context menu.
-
+ - The search field in context menus now matches the look of the Command Palette, with a smoke backdrop and improved padding.
+ - Fallback items such as math calculations or the Run command now appear in results more quickly. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Ensured the command bar updates correctly after navigating to another page and commands are displayed correctly. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - The Command Palette settings page has been reorganized. Activation-key options are grouped under an expander and extension settings are framed for improved readability.
+ - When you modify a command, its alias, hotkey, and tags now update in the top-level list, keeping the displayed information in sync. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Press `Ctrl + ,` to open Command Palette settings from anywhere. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - You can use `Page Up` and `Page Down` to navigate the list while focus is in the search box. Thanks [@samrueby](https://github.com/samrueby)!
+ - Fixed an issue where the search box could disappear when navigating pages. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Ensured search text is selected when *Go home when activated* and *Highlight search on activate* are both enabled. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Fixed an issue where Command Palette window occasionally appeared on the taskbar under certain Windows settings. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Ensured that labels and icons of list items and menu items update when they change. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Fixed visibility of list filters when navigating to a content page. Thanks [@DevLGuilherme](https://github.com/DevLGuilherme)!
+ - Added search to the extension list and a link to extensions on the Microsoft Store. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Added options to open the Command Palette window at its last position or re-center it.
+ - The Command Palette now remembers its window size after restarting.
+ - Added a global error handler that logs fatal errors and provides feedback when unexpected failures force Command Palette to close. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Fixed forms and extension settings not showing on some machines due to a missing VC++ runtime.
+ - Restored ranking of fallback commands for built-in extensions (Sleep, Shutdown, Windows settings, Web search, etc.). Thanks [@jiripolasek](https://github.com/jiripolasek).
+ - Improved and unified labels and texts across the application!
+ - Maintainance: Resolved numerous build warnings in Command Palette projects; no user-visible impact. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Maintainance: Fixed a logging issue so exception messages are properly recorded instead of placeholder text, improving troubleshooting. Thanks [@jiripolasek](https://github.com/jiripolasek)!
### Command Palette Extensions
- - Replaced localized WebSearch setting keys with stable literals and numeric history count. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Enabled advanced markdown tables and emphasis extensions. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Added setting to choose Clipboard History primary action (Paste vs Copy). Thanks [@jiripolasek](https://github.com/jiripolasek)
- - Added actionable empty-state hints for File Search (search PC / open indexing settings). Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Ensured all WinGet extension assets copy reliably to output. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Improved Run command line parsing for paths with spaces; sped up related tests.
- - Updated WebSearch extension icon set for enhanced clarity and contrast. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Added Terminal profile sort order setting including MRU tracking. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Added Uninstall Application command (UWP direct, Win32 via Settings). Thanks [@mKpwnz](https://github.com/mKpwnz)!
- - Deferred WinGet details loading and added timing logs.
- - Removed LINQ from All Apps extension for performance.
- - Added standardized key chord system + shortcuts to File Search commands. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Added Terminal channel filter & remembered selection option. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Enabled loading local/data/app images in markdown with sizing hints. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Added external extension reload via x-cmdpal://reload (configurable). Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Instant WebSearch history updates with in-memory store & events. Thanks [@jiripolasek](https://github.com/jiripolasek)!
- - Added keep-after-paste option and safe delete with confirmation for Clipboard History. Thanks [@jiripolasek](https://github.com/jiripolasek)!
-
-### Environment Variables
- - Replaced custom window chrome with WinUI TitleBar for cleaner, maintainable Environment Variables UI.
-
-### File Locksmith
- - Adopted WinUI TitleBar to simplify window chrome while preserving appearance.
+ - Bookmarks: Added hints about bookmark placeholders to the Add/Edit Bookmark form. — Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Bookmarks: Improved migration of bookmarks from older versions and fixed an issue where aliases or keyboard shortcuts could be lost after restart. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Clipboard history: Items shown in Command Palette’s clipboard history now include helpful metadata. For example, image items show dimensions, text files show names and sizes, web links include page titles, and text entries display word counts. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - File search: Added filter buttons to show *all items*, *files only*, or *folders only*. Selecting a filter adds `kind:folders` or `kind:not folders` to narrow results.
+ - System commands: Replaced the `:red_circle:` placeholder with an actual red-circle emoji so the correct icon appears in the UI. Thanks [@samrueby](https://github.com/samrueby)!
+ - WinGet: Search performance feels more responsive because typed input is now processed via a task queue rather than complex cancellation tokens!
+ - Window Walker: UWP apps no longer show a "not responding" label when suspended. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Window Walker: Now displays the actual icon of each window rather than using the process icon, improving recognition of PWAs and Python GUIs. Thanks [@Lee-WonJun](https://github.com/Lee-WonJun)!
+- Windows Terminal profiles: Fixed a rare crash in the Windows Terminal extension when the `LOCALAPPDATA` environment variable was missing. The path is now retrieved via a reliable API. Thanks [@jiripolasek](https://github.com/jiripolasek)!
### Find My Mouse
- - Added transparent spotlight support with separate backdrop opacity; migrated to Windows App SDK composition APIs.
+ - Activating Find My Mouse no longer makes the cursor change to the busy (hourglass) icon or steals focus from your active application.
### Hosts File Editor
- - Migrated to native WinUI TitleBar for cleaner, maintainable window chrome.
+ - Added customizable backup settings allowing users to configure backup frequency, location, and auto-deletion policies. Thanks [@davidegiacometti](https://github.com/davidegiacometti)!
+
+### Image Resizer
+ - Fixed settings consistency during batch resize operations by capturing settings once before processing. Thanks [@daverayment](https://github.com/daverayment)!
### Light Switch
- - Introduced as a brand-new PowerToy module.
- - Automatically switches between light and dark themes.
- - Supports time-based scheduling or location-based sunrise/sunset switching.
- - Supports using a keyboard shortcut to force a change.
- - Supports filtering changes for Apps and/or System Theme.
+- Introduced new UI to allow users to manually enter their latitude and longitude in Sunrise to Sunset mode.
+- Refactored service with cleaner state management for stability.
+- Removed logs from every tick, only logging key events to largely reduce log size.
### Mouse Pointer Crosshairs
- - Added Esc key to cancel active gliding cursor sequence. Thanks [@mikehall-ms](https://github.com/mikehall-ms)!
- - Added orientation option (vertical / horizontal / both) for crosshairs customization. Thanks [@mikehall-ms](https://github.com/mikehall-ms)!
+ - Enabled switching between Mouse Pointer Crosshairs and Gliding Cursor modes. Thanks [@mikehall-ms](https://github.com/mikehall-ms)!
### Mouse Without Borders
- - Continued Common class refactor (part 5/7) by extracting clipboard and init/cleanup logic into focused classes. Thanks [@mikeclayton](https://github.com/mikeclayton)!
-
- - Fix connection failures caused by conflicting MachineId across machines. Thanks [@noraa-junker](https://github.com/noraa-junker) for troubleshooting!
+ - Added horizontal scrolling support. Thanks [@MasonBergstrom](https://github.com/MasonBergstrom)!
### Peek
- - Added the option to activate Peek with just the Spacebar.
+- Fixed media files remaining locked after preview window closes. Thanks [@daverayment](https://github.com/daverayment)!
+- Added a command-line interface for file previewing. See the [Peek documentation](https://learn.microsoft.com/windows/powertoys/peek) for usage. Thanks [@prochan2](https://github.com/prochan2)!
### PowerRename
- - Fixed enumeration counter skipping when regex replacement equals original filename (counters now advance reliably). Thanks [@daverayment](https://github.com/daverayment)!
+- PowerRename no longer crashes due to a missing resources file.
+- Added photo metadata extraction support using EXIF and XMP for pattern-based renaming with camera info, GPS coordinates, and date taken. See [PowerRename Documentation](https://learn.microsoft.com/en-us/windows/powertoys/powerrename).
-### Quick Accent
- - Expanded Welsh layout with acute, grave, and dieresis variants for vowels (consistent ordering). Thanks [@PesBandi](https://github.com/PesBandi)!
+### PowerToys Run
+ - Added retry logic with exponential backoff to handle DWM composition errors during theme changes. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+ - Updated OneNote icons to reflect new Microsoft 365 design. Thanks [@trevorNgo](https://github.com/trevorNgo)!
-### Registry Preview
- - Migrated to native TitleBar and AppWindow APIs for cleaner window chrome.
+ ### Quick Accent
+ - Added diameter symbol (⌀) for Shift+O in Special Characters mode, thanks to [@anselumjuju](https://github.com/anselumjuju)!
-### Screen Ruler
- - Fixed ARM64 crash by aligning cursor position structure to 8-byte boundary.
+### Zoomit
+ - Smoothed out zoom-animation in ZoomIt by coalescing mouse-move and timer events, thanks to [@foxmsft](https://github.com/foxmsft)!
+ - Enabled GIF support for ZoomIt, thanks to [@MarioHewardt](https://github.com/MarioHewardt)!
+ - Fixed spelling mistakes, and refactored some literal strings to string constants, thanks to [@lzandman](https://github.com/lzandman)!
+ - Fixed inaccurate "actual size" screenshots in ZoomIt and resolves a GDI handle leak, improving capture fidelity and long-session stability. thanks to [@daverayment](https://github.com/daverayment)!
### Settings
- - Added ability to ignore specific hotkey conflicts to reduce noise.
- - Stopped creating backup directory during dry-run status checks (cleaner first-run).
- - Standardized casing and localization for ZoomIt and modules header.
- - Improved search results page accessibility and conditional module grouping.
+- Fixed title bar overlapping issue at smaller window sizes.
+- Refined shortcut control visual design with improved consistency and spacing.
+- Added dashboard utilities sorting by name or status.
+- Made update notification InfoBar in flyout clickable for direct navigation to update page.
+- Expanded installation instructions by default in README.
+- Improved accessibility for shortcut conflict button with static resource-based automation properties.
+- Added ScrollViewer to Command Palette page in PowerToys Settings. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+- Fixed module list glitches and Sort Status checkmark issue. Thanks [@daverayment](https://github.com/daverayment)!
-### ZoomIt
- - Updated resource file to reflect standalone v9.01 and current copyright year. Thanks [@foxmsft](https://github.com/foxmsft)!
- - Restored legacy draw/snipping behaviors and fixed recording race conditions. Thanks [@chakrik73](https://github.com/chakrik73)!
- - Added smooth image option for improved zoom quality using GDI+ for static zoom and Magnifier API for live zoom. Thanks [@markrussinovich](https://github.com/markrussinovich)!
-
- ### Documentation
- - New Microsoft Learn documentation for the Light Switch module.
- - New dev docs for the Light Switch module.
-
-### Development (Area-Build & Area-Tests)
-- Allowed debug launches to continue when modules fail to load, speeding developer iteration.
-- Fixed spell checker dictionary entry (advapi) to eliminate false error.
-- Added VS Code development guide and launch configs to streamline cross-editor workflows.
-- Upgraded Windows App SDK and related dependencies to 1.8 for newer platform features.
-- Rewrote YAML comment to resolve new spell checker forbidden pattern. Thanks [@jiripolasek](https://github.com/jiripolasek)!
-- Corrected solution structure by returning misplaced Common project, reducing build confusion.
-- Modernized build scripts with shared helpers and VS environment autodetection for simpler CLI builds.
-- Standardized build scripts and platform detection to improve reliability and reuse.
-- Added missing Command Palette version bump to align module release cadence.
-- Added EXECUTEDEFAULT term to dictionary to prevent regression build failures. Thanks [@jiripolasek](https://github.com/jiripolasek)!
-- Introduced nightly pre-warm pipeline and configurable MSBuild cache mode to improve CI performance.
-- Resolved CI forbidden pattern spelling complaint to keep pipelines green.
-- Added AI contributor instruction set to clarify code area expectations.
-- Added accessibility IDs to settings and FancyZones toggles, stabilizing UI tests.
-- Added automatic log collection on UI test failures to speed root cause analysis.
-- Stabilized Mouse Utils tests by switching to AccessibilityId selectors.
-- Added Screen Ruler UI test coverage to validate core measurement workflows.
+### Development
+- Fixed accessibility by associating controls with labels for screen readers.
+- Added accessible name to Shortcut Conflicts button for screen readers.
+- Excluded TitleBars from tab navigation across multiple utilities. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+- Migrated build infrastructure from Windows Server 2019 to Server 2022 with improved failure logging and predictable NuGet package paths.
+- Configured build agents to use larger P: drive for release builds to address disk space constraints.
+- Enhanced DSC v3 support by organizing resource manifests in a dedicated subfolder with PATH configuration.
+- Reduced installer bundle size by 6-7MB through centralized Hybrid CRT configuration across all C++ projects.
+- Updated .NET packages to version 9.0.10 for security fixes. Thanks [@snickler](https://github.com/snickler)!
+- Fixed spell check dictionary entries for consistency.
+- Restored accidentally deleted NuGet configuration file for Command Palette extensions.
+- Fixed package identity build by updating AppxManifest entry points to use PowerShell Core.
+- Optimized CI pipeline by replacing file copy operations with hard links and moves, reducing build time and disk usage by 10-15GB.
+- Updated Copilot guidance and PR prompt workflow.
+- Included high-volume bugs in issue template header. Thanks [@daverayment](https://github.com/daverayment)!
+- Fixed incorrect HRESULT logging for inner exceptions. Thanks [@jiripolasek](https://github.com/jiripolasek)!
+- Introduced shared sparse package identity for PowerToys Win32 components to enable access to Windows platform APIs.
+- Consolidated installer builds to produce both machine and user installers simultaneously, reducing build time and complexity.
+- Migrated exclusively to WiX v5 installer infrastructure, removing legacy WiX v3 support.
+- Temporarily removed PowerToys installer path from PATH environment variable to prevent application crashes.
+- Added complete OCR UI test coverage with automated tests for activation, settings, language selection, and text extraction.
+- Fixed test input for drive path normalization in bookmark resolver unit tests.
+- Fixed Peek UI tests by restoring Ctrl+Space activation shortcut for test scenarios.
+- Hided apps in PowerToys.SpareApps package from Start Menu. Thanks [@jiripolasek](https://github.com/jiripolasek)!
## 🛣️ Roadmap
We are planning some nice new features and improvements for the next releases – a revamped Keyboard Manager UI, custom endpoint and local model support for Advanced Paste, Command Palette improvements and a brand-new Shortcut Guide experience! Stay tuned for [v0.96][github-next-release-work]!
diff --git a/doc/devdocs/modules/lightswitch.md b/doc/devdocs/modules/lightswitch.md
index 1e251dfff1..18192f7f23 100644
--- a/doc/devdocs/modules/lightswitch.md
+++ b/doc/devdocs/modules/lightswitch.md
@@ -33,9 +33,12 @@ The **Light Switch** module lets users automatically transition between light an
> **Note:** Using the shortcut overrides the current schedule until the next transition event.
-* **LightSwitchService**
- Reads settings and applies theming. Runs a check every minute to ensure the state is correct.
-
+* **LightSwitchService.cpp**
+ is the heart beat of the module. Controls ticking every minute and depending on user actions (manual override, settings changing, etc) triggers the state manager to perform the corresponding operation.
+
+* **LightSwitchStateManager.cpp**
+ handles updating the state based on the signals sent by LightSwitchService.
+
* **SettingsXAML/LightSwitch**
Provides the settings UI for configuring schedules, syncing location, and customizing shortcuts.
diff --git a/src/PackageIdentity/AppxManifest.xml b/src/PackageIdentity/AppxManifest.xml
index 2e9d52a2fa..822daae8bc 100644
--- a/src/PackageIdentity/AppxManifest.xml
+++ b/src/PackageIdentity/AppxManifest.xml
@@ -42,7 +42,8 @@
Description="PowerToys OCR Module"
BackgroundColor="transparent"
Square150x150Logo="Images\Square150x150Logo.png"
- Square44x44Logo="Images\Square44x44Logo.png">
+ Square44x44Logo="Images\Square44x44Logo.png"
+ AppListEntry="none">
@@ -51,7 +52,8 @@
Description="PowerToys Settings UI"
BackgroundColor="transparent"
Square150x150Logo="Images\Square150x150Logo.png"
- Square44x44Logo="Images\Square44x44Logo.png">
+ Square44x44Logo="Images\Square44x44Logo.png"
+ AppListEntry="none">
@@ -60,7 +62,8 @@
Description="PowerToys Image Resizer UI"
BackgroundColor="transparent"
Square150x150Logo="Images\Square150x150Logo.png"
- Square44x44Logo="Images\Square44x44Logo.png">
+ Square44x44Logo="Images\Square44x44Logo.png"
+ AppListEntry="none">
diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs
index c2b0f4ab36..a279f7389a 100644
--- a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs
+++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs
@@ -10,6 +10,23 @@ namespace LanguageModelProvider.FoundryLocal;
internal sealed class FoundryClient
{
public static async Task CreateAsync()
+ {
+ // First attempt with current environment
+ var client = await TryCreateClientAsync().ConfigureAwait(false);
+ if (client != null)
+ {
+ return client;
+ }
+
+ // If failed, refresh PATH from registry and retry once
+ // This handles cases where PowerToys was launched by MSI installer.
+ Logger.LogInfo("[FoundryClient] First attempt failed, refreshing PATH and retrying");
+ RefreshEnvironmentPath();
+
+ return await TryCreateClientAsync().ConfigureAwait(false);
+ }
+
+ private static async Task TryCreateClientAsync()
{
try
{
@@ -169,41 +186,23 @@ internal sealed class FoundryClient
public async Task EnsureModelLoaded(string modelId)
{
- try
+ Logger.LogInfo($"[FoundryClient] EnsureModelLoaded called with: {modelId}");
+
+ // Check if already loaded
+ if (await IsModelLoaded(modelId).ConfigureAwait(false))
{
- Logger.LogInfo($"[FoundryClient] EnsureModelLoaded called with: {modelId}");
-
- // Check if already loaded
- if (await IsModelLoaded(modelId).ConfigureAwait(false))
- {
- Logger.LogInfo($"[FoundryClient] Model already loaded: {modelId}");
- return true;
- }
-
- // Check if model exists in cache
- var cachedModels = await ListCachedModels().ConfigureAwait(false);
- Logger.LogInfo($"[FoundryClient] Cached models: {string.Join(", ", cachedModels.Select(m => m.Name))}");
-
- if (!cachedModels.Any(m => m.Name == modelId))
- {
- Logger.LogWarning($"[FoundryClient] Model not found in cache: {modelId}");
- return false;
- }
-
- // Load the model
- Logger.LogInfo($"[FoundryClient] Loading model: {modelId}");
- await _foundryManager.LoadModelAsync(modelId).ConfigureAwait(false);
-
- // Verify it's loaded
- var loaded = await IsModelLoaded(modelId).ConfigureAwait(false);
- Logger.LogInfo($"[FoundryClient] Model load result: {loaded}");
- return loaded;
- }
- catch (Exception ex)
- {
- Logger.LogError($"[FoundryClient] EnsureModelLoaded exception: {ex.Message}");
- return false;
+ Logger.LogInfo($"[FoundryClient] Model already loaded: {modelId}");
+ return true;
}
+
+ // Load the model
+ Logger.LogInfo($"[FoundryClient] Loading model: {modelId}");
+ await _foundryManager.LoadModelAsync(modelId).ConfigureAwait(false);
+
+ // Verify it's loaded
+ var loaded = await IsModelLoaded(modelId).ConfigureAwait(false);
+ Logger.LogInfo($"[FoundryClient] Model load result: {loaded}");
+ return loaded;
}
public async Task EnsureRunning()
@@ -213,4 +212,68 @@ internal sealed class FoundryClient
await _foundryManager.StartServiceAsync();
}
}
+
+ ///
+ /// Refreshes the PATH environment variable from the system registry.
+ /// This is necessary when tools are installed while PowerToys is running,
+ /// as the installer updates the system PATH but running processes don't see the change.
+ ///
+ private static void RefreshEnvironmentPath()
+ {
+ try
+ {
+ Logger.LogInfo("[FoundryClient] Refreshing PATH environment variable from system");
+
+ var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process) ?? string.Empty;
+ var machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? string.Empty;
+ var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty;
+
+ var pathsToAdd = new List();
+
+ if (!string.IsNullOrWhiteSpace(currentPath))
+ {
+ pathsToAdd.AddRange(currentPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries));
+ }
+
+ if (!string.IsNullOrWhiteSpace(userPath))
+ {
+ var userPaths = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var path in userPaths)
+ {
+ if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase))
+ {
+ pathsToAdd.Add(path);
+ }
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(machinePath))
+ {
+ var machinePaths = machinePath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var path in machinePaths)
+ {
+ if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase))
+ {
+ pathsToAdd.Add(path);
+ }
+ }
+ }
+
+ var newPath = string.Join(Path.PathSeparator.ToString(), pathsToAdd);
+
+ if (currentPath != newPath)
+ {
+ Logger.LogInfo("[FoundryClient] Updating process PATH with latest system values");
+ Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process);
+ }
+ else
+ {
+ Logger.LogInfo("[FoundryClient] PATH is already up to date");
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError($"[FoundryClient] Failed to refresh PATH: {ex.Message}");
+ }
+ }
}
diff --git a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs
index 3c9c618f7f..5158e4334e 100644
--- a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs
+++ b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs
@@ -12,8 +12,8 @@ namespace LanguageModelProvider;
public sealed class FoundryLocalModelProvider : ILanguageModelProvider
{
- private IEnumerable? _downloadedModels;
private FoundryClient? _foundryClient;
+ private IEnumerable? _catalogModels;
private string? _serviceUrl;
public static FoundryLocalModelProvider Instance { get; } = new();
@@ -24,22 +24,8 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider
public IChatClient? GetIChatClient(string modelId)
{
- try
- {
- Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {modelId}");
- InitializeAsync().GetAwaiter().GetResult();
- }
- catch (Exception ex)
- {
- Logger.LogError($"[FoundryLocal] Failed to initialize: {ex.Message}");
- return null;
- }
-
- if (string.IsNullOrWhiteSpace(_serviceUrl) || _foundryClient == null)
- {
- Logger.LogError("[FoundryLocal] Service URL or manager is null");
- return null;
- }
+ Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {modelId}");
+ InitializeAsync().GetAwaiter().GetResult();
if (string.IsNullOrWhiteSpace(modelId))
{
@@ -47,39 +33,38 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider
return null;
}
- // Ensure the model is loaded before returning chat client
- try
+ // Check if model is in catalog
+ var isInCatalog = _catalogModels?.Any(m => m.Name == modelId) ?? false;
+ if (!isInCatalog)
{
- var isLoaded = _foundryClient.EnsureModelLoaded(modelId).GetAwaiter().GetResult();
- if (!isLoaded)
- {
- Logger.LogError($"[FoundryLocal] Failed to load model: {modelId}");
- return null;
- }
-
- Logger.LogInfo($"[FoundryLocal] Model is loaded: {modelId}");
+ var errorMessage = $"{modelId} is not supported in Foundry Local. Please configure supported models in Settings.";
+ Logger.LogError($"[FoundryLocal] {errorMessage}");
+ throw new InvalidOperationException(errorMessage);
}
- catch (Exception ex)
+
+ // Ensure the model is loaded before returning chat client
+ var isLoaded = _foundryClient!.EnsureModelLoaded(modelId).GetAwaiter().GetResult();
+ if (!isLoaded)
{
- Logger.LogError($"[FoundryLocal] Exception ensuring model loaded: {ex.Message}");
- return null;
+ Logger.LogError($"[FoundryLocal] Failed to load model: {modelId}");
+ throw new InvalidOperationException($"Failed to load the model '{modelId}'.");
}
// Use ServiceUri instead of Endpoint since Endpoint already includes /v1
var baseUri = _foundryClient.GetServiceUri();
if (baseUri == null)
{
- Logger.LogError("[FoundryLocal] Service URI is null");
- return null;
+ const string message = "Foundry Local service URL is not available. Please make sure Foundry Local is installed and running.";
+ Logger.LogError($"[FoundryLocal] {message}");
+ throw new InvalidOperationException(message);
}
var endpointUri = new Uri($"{baseUri.ToString().TrimEnd('/')}/v1");
Logger.LogInfo($"[FoundryLocal] Creating OpenAI client with endpoint: {endpointUri}");
- Logger.LogInfo($"[FoundryLocal] Model ID for chat client: {modelId}");
return new OpenAIClient(
new ApiKeyCredential("none"),
- new OpenAIClientOptions { Endpoint = endpointUri })
+ new OpenAIClientOptions { Endpoint = endpointUri, NetworkTimeout = TimeSpan.FromMinutes(5) })
.GetChatClient(modelId)
.AsIChatClient();
}
@@ -105,49 +90,16 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider
return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{_serviceUrl}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()";
}
- public async Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default)
+ public async Task> GetModelsAsync(CancellationToken cancelationToken = default)
{
- if (ignoreCached)
- {
- Logger.LogInfo("[FoundryLocal] Ignoring cached models, resetting");
- Reset();
- }
-
await InitializeAsync(cancelationToken);
- Logger.LogInfo($"[FoundryLocal] Returning {_downloadedModels?.Count() ?? 0} downloaded models");
- return _downloadedModels ?? [];
- }
-
- private void Reset()
- {
- _downloadedModels = null;
- _ = InitializeAsync();
- }
-
- private async Task InitializeAsync(CancellationToken cancelationToken = default)
- {
- if (_foundryClient != null && _downloadedModels != null && _downloadedModels.Any())
- {
- await _foundryClient.EnsureRunning().ConfigureAwait(false);
- return;
- }
-
- Logger.LogInfo("[FoundryLocal] Initializing provider");
- _foundryClient ??= await FoundryClient.CreateAsync();
-
if (_foundryClient == null)
{
- Logger.LogError("[FoundryLocal] Failed to create Foundry client");
- return;
+ return Array.Empty();
}
- _serviceUrl ??= await _foundryClient.GetServiceUrl();
- Logger.LogInfo($"[FoundryLocal] Service URL: {_serviceUrl}");
-
var cachedModels = await _foundryClient.ListCachedModels();
- Logger.LogInfo($"[FoundryLocal] Found {cachedModels.Count} cached models");
-
List downloadedModels = [];
foreach (var model in cachedModels)
@@ -160,13 +112,37 @@ public sealed class FoundryLocalModelProvider : ILanguageModelProvider
Url = $"fl://{model.Name}",
Description = $"{model.Name} running locally with Foundry Local",
HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL],
- SupportedOnQualcomm = true,
ProviderModelDetails = model,
});
}
- _downloadedModels = downloadedModels;
- Logger.LogInfo($"[FoundryLocal] Initialization complete. Total downloaded models: {downloadedModels.Count}");
+ return downloadedModels;
+ }
+
+ private async Task InitializeAsync(CancellationToken cancelationToken = default)
+ {
+ if (_foundryClient != null && _catalogModels != null && _catalogModels.Any())
+ {
+ await _foundryClient.EnsureRunning().ConfigureAwait(false);
+ return;
+ }
+
+ Logger.LogInfo("[FoundryLocal] Initializing provider");
+ _foundryClient ??= await FoundryClient.CreateAsync();
+
+ if (_foundryClient == null)
+ {
+ const string message = "Foundry Local client could not be created. Please make sure Foundry Local is installed and running.";
+ Logger.LogError($"[FoundryLocal] {message}");
+ throw new InvalidOperationException(message);
+ }
+
+ _serviceUrl ??= await _foundryClient.GetServiceUrl();
+ Logger.LogInfo($"[FoundryLocal] Service URL: {_serviceUrl}");
+
+ var catalogModels = await _foundryClient.ListCatalogModels();
+ Logger.LogInfo($"[FoundryLocal] Found {catalogModels.Count} catalog models");
+ _catalogModels = catalogModels;
}
public async Task IsAvailable()
diff --git a/src/common/LanguageModelProvider/ILanguageModelProvider.cs b/src/common/LanguageModelProvider/ILanguageModelProvider.cs
index 2bef3fb7f1..9d203adaf6 100644
--- a/src/common/LanguageModelProvider/ILanguageModelProvider.cs
+++ b/src/common/LanguageModelProvider/ILanguageModelProvider.cs
@@ -12,7 +12,7 @@ public interface ILanguageModelProvider
string ProviderDescription { get; }
- Task> GetModelsAsync(bool ignoreCached = false, CancellationToken cancelationToken = default);
+ Task> GetModelsAsync(CancellationToken cancelationToken = default);
IChatClient? GetIChatClient(string modelId);
diff --git a/src/common/LanguageModelProvider/ModelDetails.cs b/src/common/LanguageModelProvider/ModelDetails.cs
index 2e68ca6feb..e383aa7d27 100644
--- a/src/common/LanguageModelProvider/ModelDetails.cs
+++ b/src/common/LanguageModelProvider/ModelDetails.cs
@@ -24,8 +24,6 @@ public class ModelDetails
public List HardwareAccelerators { get; set; } = [];
- public bool SupportedOnQualcomm { get; set; }
-
public string License { get; set; } = string.Empty;
public object? ProviderModelDetails { get; set; }
diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml
index b948a8190f..6303564d9b 100644
--- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml
+++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml
@@ -558,7 +558,7 @@
+ Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs
index 759d6ec57d..c886bcef43 100644
--- a/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs
+++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs
@@ -215,7 +215,6 @@ public sealed class AdvancedAIKernelService : KernelServiceBase
return new OpenAIPromptExecutionSettings
{
FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
- Temperature = 0.01,
};
}
}
diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs
index a24032ff31..8b57baae74 100644
--- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs
+++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs
@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using LanguageModelProvider;
using Microsoft.Extensions.AI;
@@ -33,10 +34,6 @@ public sealed class FoundryLocalPasteProvider : IPasteAIProvider
_config = config;
}
- public string ProviderName => AIServiceType.FoundryLocal.ToNormalizedKey();
-
- public string DisplayName => string.IsNullOrWhiteSpace(_config?.Model) ? "Foundry Local" : _config.Model;
-
public async Task IsAvailableAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -76,13 +73,20 @@ public sealed class FoundryLocalPasteProvider : IPasteAIProvider
}
cancellationToken.ThrowIfCancellationRequested();
- var chatClient = _modelProvider.GetIChatClient(modelReference);
- if (chatClient is null)
+
+ IChatClient chatClient;
+ try
{
+ chatClient = _modelProvider.GetIChatClient(modelReference);
+ }
+ catch (InvalidOperationException ex)
+ {
+ // GetIChatClient throws InvalidOperationException for user-facing errors
+ var errorMessage = string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("FoundryLocal_UnableToLoadModel"), modelReference);
throw new PasteActionException(
- $"Unable to load Foundry Local model: {modelReference}",
- new InvalidOperationException("Chat client resolution failed"),
- aiServiceMessage: "The model may not be downloaded or the Foundry Local service may not be running. Please check the model status in settings.");
+ errorMessage,
+ ex,
+ aiServiceMessage: ex.Message);
}
var userMessageContent = $"""
@@ -142,6 +146,7 @@ public sealed class FoundryLocalPasteProvider : IPasteAIProvider
var options = new ChatOptions
{
ModelId = modelReference,
+ MaxOutputTokens = 2048,
};
if (!string.IsNullOrWhiteSpace(systemPrompt))
diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs
index 819549b466..eb2f56e01f 100644
--- a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs
+++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs
@@ -157,8 +157,6 @@ namespace AdvancedPaste.Services.CustomActions
{
AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings
{
- Temperature = 0.01,
- MaxTokens = 2000,
FunctionChoiceBehavior = null,
},
_ => new PromptExecutionSettings(),
diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw
index 521c1d60ba..f365778321 100644
--- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw
+++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw
@@ -160,10 +160,10 @@
Active provider: {0}
- AI providers
+ Configured models
- No AI providers configured
+ No models configured
Configure models in Settings
@@ -364,8 +364,12 @@
You are using a custom endpoint. Verify all answers.
-
+
Local
Badge label displayed next to local AI model providers (e.g., Ollama, Foundry Local) to indicate the model runs locally
+
+ Unable to load Foundry Local model: {0}
+ {0} is the model identifier. Do not translate {0}.
+
\ No newline at end of file
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
index 74efeda933..845e24fa93 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp
@@ -271,7 +271,6 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam)
if (wait == WAIT_OBJECT_0 + (hParent ? (hManualOverride ? 3 : 2) : 2))
{
- Logger::info(L"[LightSwitchService] Settings file changed event detected.");
ResetEvent(hSettingsChanged);
LightSwitchSettings::instance().LoadSettings();
stateManager.OnSettingsChanged();
diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
index 836d511159..4fba4ae9a6 100644
--- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
+++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp
@@ -17,12 +17,10 @@ LightSwitchStateManager::LightSwitchStateManager()
void LightSwitchStateManager::OnSettingsChanged()
{
std::lock_guard lock(_stateMutex);
- Logger::info(L"[LightSwitchStateManager] Settings changed event received");
// If manual override was active, clear it so new settings take effect
if (_state.isManualOverride)
{
- Logger::info(L"[LightSwitchStateManager] Clearing manual override due to settings update.");
_state.isManualOverride = false;
}
@@ -33,7 +31,6 @@ void LightSwitchStateManager::OnSettingsChanged()
void LightSwitchStateManager::OnTick(int currentMinutes)
{
std::lock_guard lock(_stateMutex);
- Logger::debug(L"[LightSwitchStateManager] Tick received: {}", currentMinutes);
EvaluateAndApplyIfNeeded();
}
@@ -51,7 +48,7 @@ void LightSwitchStateManager::OnManualOverride()
_state.isAppsLightActive = GetCurrentAppsTheme();
- Logger::info(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).",
+ Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).",
(_state.isSystemLightActive ? L"light" : L"dark"),
(_state.isAppsLightActive ? L"light" : L"dark"));
}
@@ -79,9 +76,9 @@ void LightSwitchStateManager::SyncInitialThemeState()
std::lock_guard lock(_stateMutex);
_state.isSystemLightActive = GetCurrentSystemTheme();
_state.isAppsLightActive = GetCurrentAppsTheme();
- Logger::info(L"[LightSwitchStateManager] Synced initial state to current system theme ({})",
+ Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})",
_state.isSystemLightActive ? L"light" : L"dark");
- Logger::info(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})",
+ Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})",
_state.isAppsLightActive ? L"light" : L"dark");
}
@@ -127,7 +124,6 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
// Early exit: OFF mode just pauses activity
if (_currentSettings.scheduleMode == ScheduleMode::Off)
{
- Logger::debug(L"[LightSwitchStateManager] Mode is OFF — pausing service logic.");
_state.lastTickMinutes = now;
return;
}
@@ -145,7 +141,6 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
if (newDay || modeChangedToSun)
{
- Logger::info(L"[LightSwitchStateManager] Recalculating sun times (mode/day change).");
auto [newLightTime, newDarkTime] = update_sun_times(_currentSettings);
_state.lastEvaluatedDay = st.wDay;
_state.effectiveLightMinutes = newLightTime + _currentSettings.sunrise_offset;
@@ -188,12 +183,10 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
if (crossedBoundary)
{
- Logger::info(L"[LightSwitchStateManager] Manual override cleared after crossing boundary.");
_state.isManualOverride = false;
}
else
{
- Logger::debug(L"[LightSwitchStateManager] Manual override active — skipping auto apply.");
_state.lastTickMinutes = now;
return;
}
@@ -206,7 +199,7 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight);
bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight);
- Logger::debug(
+ /* Logger::debug(
L"[LightSwitchStateManager] now = {:02d}:{:02d}, light boundary = {:02d}:{:02d} ({}), dark boundary = {:02d}:{:02d} ({})",
now / 60,
now % 60,
@@ -215,12 +208,12 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
_state.effectiveLightMinutes,
_state.effectiveDarkMinutes / 60,
_state.effectiveDarkMinutes % 60,
- _state.effectiveDarkMinutes);
+ _state.effectiveDarkMinutes); */
- Logger::debug("should be light = {}, apps needs change = {}, system needs change = {}",
+ /* Logger::debug("should be light = {}, apps needs change = {}, system needs change = {}",
shouldBeLight ? "true" : "false",
appsNeedsToChange ? "true" : "false",
- systemNeedsToChange ? "true" : "false");
+ systemNeedsToChange ? "true" : "false"); */
// Only apply theme if there's a change or no override active
if (!_state.isManualOverride && (appsNeedsToChange || systemNeedsToChange))
@@ -230,10 +223,6 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded()
_state.isSystemLightActive = GetCurrentSystemTheme();
_state.isAppsLightActive = GetCurrentAppsTheme();
-
- Logger::debug(L"[LightSwitchStateManager] Synced post-apply theme state — System: {}, Apps: {}",
- _state.isSystemLightActive ? L"light" : L"dark",
- _state.isAppsLightActive ? L"light" : L"dark");
}
_state.lastTickMinutes = now;
diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.h b/src/modules/ZoomIt/ZoomIt/ZoomIt.h
index 2687ba2b65..552af677ce 100644
--- a/src/modules/ZoomIt/ZoomIt/ZoomIt.h
+++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.h
@@ -39,6 +39,10 @@ type_pEnableThemeDialogTexture pEnableThemeDialogTexture;
#define WIN7_VERSION 0x106
#define WIN10_VERSION 0x206
+// Default recording format frame rates
+#define RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE 15
+#define RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE 30
+
// Time that we'll cache live zoom window to avoid flicker
// of live zooming on Vista/ws2k8
#define LIVEZOOM_WINDOW_TIMEOUT 2*3600*1000
diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc
index 99bdb66b58..5f5e9d16cf 100644
--- a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc
+++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc
@@ -121,7 +121,7 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0
BEGIN
DEFPUSHBUTTON "OK",IDOK,166,306,50,14
PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14
- LTEXT "ZoomIt v9.20",IDC_VERSION,42,7,73,10
+ LTEXT "ZoomIt v9.21",IDC_VERSION,42,7,73,10
LTEXT "Copyright 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8
CONTROL "Sysinternals - www.sysinternals.com",IDC_LINK,
"SysLink",WS_TABSTOP,42,26,150,9
diff --git a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h
index 486d5a61e7..efd731cdce 100644
--- a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h
+++ b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h
@@ -44,11 +44,11 @@ LOGFONT g_LogFont;
BOOLEAN g_DemoTypeUserDriven = false;
TCHAR g_DemoTypeFile[MAX_PATH] = {0};
DWORD g_DemoTypeSpeedSlider = static_cast(((MIN_TYPING_SPEED - MAX_TYPING_SPEED) / 2) + MAX_TYPING_SPEED);
-DWORD g_RecordFrameRate = 30;
+DWORD g_RecordFrameRate = 30; // We default to 30 here, but g_RecordFrameRate can be different depending on recording format and gets set accordingly
DWORD g_RecordScaling = 100;
DWORD g_RecordScalingGIF = 50;
DWORD g_RecordScalingMP4 = 100;
-RecordingFormat g_RecordingFormat = RecordingFormat::GIF;
+RecordingFormat g_RecordingFormat = RecordingFormat::MP4;
BOOLEAN g_CaptureAudio = FALSE;
TCHAR g_MicrophoneDeviceId[MAX_PATH] = {0};
@@ -87,8 +87,7 @@ REG_SETTING RegSettings[] = {
{ L"SnapToGrid", SETTING_TYPE_BOOLEAN, 0, &g_SnapToGrid, static_cast(g_SnapToGrid) },
{ L"ZoominSliderLevel", SETTING_TYPE_DWORD, 0, &g_SliderZoomLevel, static_cast(g_SliderZoomLevel) },
{ L"Font", SETTING_TYPE_BINARY, sizeof g_LogFont, &g_LogFont, static_cast(0) },
- { L"RecordFrameRate", SETTING_TYPE_DWORD, 0, &g_RecordFrameRate, static_cast(g_RecordFrameRate) },
- { L"RecordingFormat", SETTING_TYPE_DWORD, 0, &g_RecordingFormat, static_cast(0) },
+ { L"RecordingFormat", SETTING_TYPE_DWORD, 0, &g_RecordingFormat, static_cast(g_RecordingFormat) },
{ L"RecordScalingGIF", SETTING_TYPE_DWORD, 0, &g_RecordScalingGIF, static_cast(g_RecordScalingGIF) },
{ L"RecordScalingMP4", SETTING_TYPE_DWORD, 0, &g_RecordScalingMP4, static_cast(g_RecordScalingMP4) },
{ L"CaptureAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureAudio, static_cast(g_CaptureAudio) },
diff --git a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp
index bc72ac01fc..137c234616 100644
--- a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp
+++ b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp
@@ -168,6 +168,7 @@ BOOL g_RecordToggle = FALSE;
BOOL g_RecordCropping = FALSE;
SelectRectangle g_SelectRectangle;
std::wstring g_RecordingSaveLocation;
+std::wstring g_RecordingSaveLocationGIF;
winrt::IDirect3DDevice g_RecordDevice{ nullptr };
std::shared_ptr g_RecordingSession = nullptr;
std::shared_ptr g_GifRecordingSession = nullptr;
@@ -2173,7 +2174,10 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO,
g_CaptureAudio ? BST_CHECKED: BST_UNCHECKED );
- for (int i = 0; i < _countof(g_FramerateOptions); i++) {
+ //
+ // The framerate drop down list is not used in the current version (might be added in the future)
+ //
+ /*for (int i = 0; i < _countof(g_FramerateOptions); i++) {
_stprintf(text, L"%d", g_FramerateOptions[i]);
SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), static_cast(CB_ADDSTRING),
@@ -2182,7 +2186,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), CB_SETCURSEL, static_cast(i), static_cast(0));
}
- }
+ }*/
// Add the recording format to the combo box and set the current selection
size_t selection = 0;
@@ -2345,17 +2349,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
text[2] = 0;
newTimeout = _tstoi( text );
- if( g_RecordingFormat == RecordingFormat::GIF )
- {
- // Hardcode lower frame rate for GIFs
- g_RecordFrameRate = 15;
- }
- else
- {
- g_RecordFrameRate = g_FramerateOptions[SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), static_cast(CB_GETCURSEL), static_cast(0), static_cast(0))];
- }
-
g_RecordingFormat = static_cast(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FORMAT), static_cast(CB_GETCURSEL), static_cast(0), static_cast(0)));
+ g_RecordFrameRate = (g_RecordingFormat == RecordingFormat::GIF) ? RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE : RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE;
g_RecordScaling = static_cast(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), static_cast(CB_GETCURSEL), static_cast(0), static_cast(0)) * 10 + 10);
// Get the selected microphone
@@ -3536,7 +3531,16 @@ void StopRecording()
//----------------------------------------------------------------------------
auto GetUniqueRecordingFilename()
{
- std::filesystem::path path{ g_RecordingSaveLocation };
+ std::filesystem::path path;
+
+ if (g_RecordingFormat == RecordingFormat::GIF)
+ {
+ path = g_RecordingSaveLocationGIF;
+ }
+ else
+ {
+ path = g_RecordingSaveLocation;
+ }
// Chop off index if it's there
auto base = std::regex_replace( path.stem().wstring(), std::wregex( L" [(][0-9]+[)]$" ), L"" );
@@ -3591,6 +3595,7 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
auto stream = co_await file.OpenAsync( winrt::FileAccessMode::ReadWrite );
// Create the appropriate recording session based on format
+ OutputDebugStringW((L"Starting recording session. Framerate: " + std::to_wstring(g_RecordFrameRate) + L" scaling: " + std::to_wstring(g_RecordScaling) + L" Format: " + (g_RecordingFormat == RecordingFormat::GIF ? L"GIF" : L"MP4") + L"\n").c_str());
if (g_RecordingFormat == RecordingFormat::GIF)
{
g_GifRecordingSession = GifRecordingSession::Create(
@@ -3657,18 +3662,44 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes );
}
- if( g_RecordingSaveLocation.size() == 0) {
+ // Peek the folder Windows has chosen to display
+ static std::filesystem::path lastSaveFolder;
+ wil::unique_cotaskmem_string chosenFolderPath;
+ wil::com_ptr currentSelectedFolder;
+ bool bFolderChanged = false;
+ if (SUCCEEDED(saveDialog->GetFolder(currentSelectedFolder.put())))
+ {
+ if (SUCCEEDED(currentSelectedFolder->GetDisplayName(SIGDN_FILESYSPATH, chosenFolderPath.put())))
+ {
+ if (lastSaveFolder != chosenFolderPath.get())
+ {
+ lastSaveFolder = chosenFolderPath.get() ? chosenFolderPath.get() : std::filesystem::path{};
+ bFolderChanged = true;
+ }
+ }
+ }
+
+ if( (g_RecordingFormat == RecordingFormat::GIF && g_RecordingSaveLocationGIF.size() == 0) || (g_RecordingFormat == RecordingFormat::MP4 && g_RecordingSaveLocation.size() == 0) || (bFolderChanged)) {
wil::com_ptr shellItem;
wil::unique_cotaskmem_string folderPath;
- if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put())))
- g_RecordingSaveLocation = folderPath.get();
+ if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) {
+ if (g_RecordingFormat == RecordingFormat::GIF) {
+ g_RecordingSaveLocationGIF = folderPath.get();
+ std::filesystem::path currentPath{ g_RecordingSaveLocationGIF };
+ g_RecordingSaveLocationGIF = currentPath / DEFAULT_GIF_RECORDING_FILE;
+ }
+ else {
+ g_RecordingSaveLocation = folderPath.get();
+ if (g_RecordingFormat == RecordingFormat::MP4) {
+ std::filesystem::path currentPath{ g_RecordingSaveLocation };
+ g_RecordingSaveLocation = currentPath / DEFAULT_RECORDING_FILE;
+ }
+ }
+ }
}
// Always use appropriate default filename based on current format
- std::filesystem::path currentPath{ g_RecordingSaveLocation };
- const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF) ? DEFAULT_GIF_RECORDING_FILE : DEFAULT_RECORDING_FILE;
- g_RecordingSaveLocation = currentPath.parent_path() / defaultFile;
auto suggestedName = GetUniqueRecordingFilename();
saveDialog->SetFileName( suggestedName.c_str() );
@@ -3696,9 +3727,15 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
}
else {
- co_await file.MoveAndReplaceAsync( destFile );
- g_RecordingSaveLocation = file.Path();
- SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd);
+ co_await file.MoveAndReplaceAsync(destFile);
+ if (g_RecordingFormat == RecordingFormat::GIF) {
+ g_RecordingSaveLocationGIF = file.Path();
+ SaveToClipboard(g_RecordingSaveLocationGIF.c_str(), hWnd);
+ }
+ else {
+ g_RecordingSaveLocation = file.Path();
+ SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd);
+ }
}
g_bSaveInProgress = false;
@@ -4039,8 +4076,10 @@ LRESULT APIENTRY MainWndProc(
// Set g_RecordScaling based on the current recording format
if (g_RecordingFormat == RecordingFormat::GIF) {
g_RecordScaling = g_RecordScalingGIF;
+ g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE;
} else {
g_RecordScaling = g_RecordScalingMP4;
+ g_RecordFrameRate = RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE;
}
// to support migrating from
@@ -6332,6 +6371,17 @@ LRESULT APIENTRY MainWndProc(
{
// Reload the settings. This message is called from PowerToys after a setting is changed by the user.
reg.ReadRegSettings(RegSettings);
+
+ if (g_RecordingFormat == RecordingFormat::GIF)
+ {
+ g_RecordScaling = g_RecordScalingGIF;
+ g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE;
+ }
+ else
+ {
+ g_RecordScaling = g_RecordScalingMP4;
+ g_RecordFrameRate = RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE;
+ }
// Apply tray icon setting
EnableDisableTrayIcon(hWnd, g_ShowTrayIcon);
diff --git a/src/modules/awake/Awake/Core/Manager.cs b/src/modules/awake/Awake/Core/Manager.cs
index ad4c417b31..c6aa1c2efb 100644
--- a/src/modules/awake/Awake/Core/Manager.cs
+++ b/src/modules/awake/Awake/Core/Manager.cs
@@ -350,7 +350,7 @@ namespace Awake.Core
TrayHelper.TimedIcon,
TrayIconAction.Update);
},
- _ => HandleTimerCompletion("timed"),
+ () => HandleTimerCompletion("timed"),
_tokenSource.Token);
}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs
index 62434a632a..2a82f80a02 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs
@@ -64,6 +64,8 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
public string Title { get => string.IsNullOrEmpty(field) ? Name : field; protected set; } = string.Empty;
+ public string Id { get; protected set; } = string.Empty;
+
// This property maps to `IPage.IsLoading`, but we want to expose our own
// `IsLoading` property as a combo of this value and `IsInitialized`
public bool ModelIsLoading { get; protected set; } = true;
@@ -142,6 +144,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
return; // throw?
}
+ Id = page.Id;
Name = page.Name;
ModelIsLoading = page.IsLoading;
Title = page.Title;
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs
index db413491db..7963aec154 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs
@@ -2,21 +2,52 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
+using Windows.Graphics;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed class WindowPosition
{
+ ///
+ /// Gets or sets left position in device pixels.
+ ///
public int X { get; set; }
+ ///
+ /// Gets or sets top position in device pixels.
+ ///
public int Y { get; set; }
+ ///
+ /// Gets or sets width in device pixels.
+ ///
public int Width { get; set; }
+ ///
+ /// Gets or sets height in device pixels.
+ ///
public int Height { get; set; }
+
+ ///
+ /// Gets or sets width of the screen in device pixels where the window is located.
+ ///
+ public int ScreenWidth { get; set; }
+
+ ///
+ /// Gets or sets height of the screen in device pixels where the window is located.
+ ///
+ public int ScreenHeight { get; set; }
+
+ ///
+ /// Gets or sets DPI (dots per inch) of the display where the window is located.
+ ///
+ public int Dpi { get; set; }
+
+ ///
+ /// Converts the window position properties to a structure representing the physical window rectangle.
+ ///
+ public RectInt32 ToPhysicalWindowRectangle()
+ {
+ return new RectInt32(X, Y, Width, Height);
+ }
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs
index 040dd146d0..ac941b7724 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs
@@ -15,9 +15,12 @@ public class OpenPage : EventBase, IEvent
{
public int PageDepth { get; set; }
- public OpenPage(int pageDepth)
+ public string Id { get; set; }
+
+ public OpenPage(int pageDepth, string id)
{
PageDepth = pageDepth;
+ Id = id;
EventName = "CmdPal_OpenPage";
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
index 32f542fc3b..b80ea69b86 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs
@@ -18,6 +18,7 @@ using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.PowerToys.Telemetry;
+using Microsoft.UI;
using Microsoft.UI.Composition;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Input;
@@ -33,6 +34,8 @@ using Windows.UI.WindowManagement;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Dwm;
+using Windows.Win32.Graphics.Gdi;
+using Windows.Win32.UI.HiDpi;
using Windows.Win32.UI.Input.KeyboardAndMouse;
using Windows.Win32.UI.WindowsAndMessaging;
using WinRT;
@@ -48,6 +51,9 @@ public sealed partial class MainWindow : WindowEx,
IRecipient,
IDisposable
{
+ private const int DefaultWidth = 800;
+ private const int DefaultHeight = 480;
+
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")]
private readonly uint WM_TASKBAR_RESTART;
@@ -173,22 +179,8 @@ public sealed partial class MainWindow : WindowEx,
return;
}
- AppWindow.Resize(new SizeInt32 { Width = savedPosition.Width, Height = savedPosition.Height });
-
- var savedRect = new RectInt32(savedPosition.X, savedPosition.Y, savedPosition.Width, savedPosition.Height);
- var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest);
- var workArea = displayArea.WorkArea;
-
- var maxX = workArea.X + Math.Max(0, workArea.Width - savedPosition.Width);
- var maxY = workArea.Y + Math.Max(0, workArea.Height - savedPosition.Height);
-
- var targetPoint = new PointInt32
- {
- X = Math.Clamp(savedPosition.X, workArea.X, maxX),
- Y = Math.Clamp(savedPosition.Y, workArea.Y, maxY),
- };
-
- AppWindow.Move(targetPoint);
+ var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi);
+ AppWindow.MoveAndResize(newRect);
}
private void PositionCentered(DisplayArea displayArea)
@@ -207,12 +199,16 @@ public sealed partial class MainWindow : WindowEx,
private void UpdateWindowPositionInMemory()
{
+ var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary;
_currentWindowPosition = new WindowPosition
{
X = AppWindow.Position.X,
Y = AppWindow.Position.Y,
Width = AppWindow.Size.Width,
Height = AppWindow.Size.Height,
+ Dpi = (int)this.GetDpiForWindow(),
+ ScreenWidth = displayArea.WorkArea.Width,
+ ScreenHeight = displayArea.WorkArea.Height,
};
}
@@ -300,8 +296,8 @@ public sealed partial class MainWindow : WindowEx,
if (target == MonitorBehavior.ToLast)
{
- AppWindow.Resize(new SizeInt32 { Width = _currentWindowPosition.Width, Height = _currentWindowPosition.Height });
- AppWindow.Move(new PointInt32 { X = _currentWindowPosition.X, Y = _currentWindowPosition.Y });
+ var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi);
+ AppWindow.MoveAndResize(newRect);
}
else
{
@@ -330,6 +326,114 @@ public sealed partial class MainWindow : WindowEx,
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
}
+ ///
+ /// Ensures that the window rectangle is visible on-screen.
+ ///
+ /// The window rectangle in physical pixels.
+ /// The desktop area the window was positioned on.
+ /// The window's original DPI.
+ ///
+ /// A window rectangle in physical pixels, moved to the nearest display and resized
+ /// if the DPI has changed.
+ ///
+ private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi)
+ {
+ var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest);
+ if (displayArea is null)
+ {
+ return windowRect;
+ }
+
+ var workArea = displayArea.WorkArea;
+ if (workArea.Width <= 0 || workArea.Height <= 0)
+ {
+ // Fallback, nothing reasonable to do
+ return windowRect;
+ }
+
+ var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea);
+ if (originalDpi <= 0)
+ {
+ originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed)
+ }
+
+ var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0;
+ if (hasInvalidSize)
+ {
+ windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight);
+ }
+
+ // If we have a DPI change, scale the window rectangle accordingly
+ if (effectiveDpi != originalDpi)
+ {
+ var scalingFactor = effectiveDpi / (double)originalDpi;
+ windowRect = new RectInt32(
+ (int)Math.Round(windowRect.X * scalingFactor),
+ (int)Math.Round(windowRect.Y * scalingFactor),
+ (int)Math.Round(windowRect.Width * scalingFactor),
+ (int)Math.Round(windowRect.Height * scalingFactor));
+ }
+
+ var targetWidth = Math.Min(windowRect.Width, workArea.Width);
+ var targetHeight = Math.Min(windowRect.Height, workArea.Height);
+
+ // Ensure at least some minimum visible area (e.g., 100 pixels)
+ // This helps prevent the window from being entirely offscreen, regardless of display scaling.
+ const int minimumVisibleSize = 100;
+ var isOffscreen =
+ windowRect.X + minimumVisibleSize > workArea.X + workArea.Width ||
+ windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X ||
+ windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height ||
+ windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y;
+
+ // if the work area size has changed, re-center the window
+ var workAreaSizeChanged =
+ originalScreen.Width != workArea.Width ||
+ originalScreen.Height != workArea.Height;
+
+ int targetX;
+ int targetY;
+ var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize;
+ if (recenter)
+ {
+ targetX = workArea.X + ((workArea.Width - targetWidth) / 2);
+ targetY = workArea.Y + ((workArea.Height - targetHeight) / 2);
+ }
+ else
+ {
+ targetX = windowRect.X;
+ targetY = windowRect.Y;
+ }
+
+ return new RectInt32(targetX, targetY, targetWidth, targetHeight);
+ }
+
+ private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea)
+ {
+ var effectiveDpi = 96;
+
+ var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
+ if (!hMonitor.IsNull)
+ {
+ var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _);
+ if (hr == 0)
+ {
+ effectiveDpi = (int)dpiX;
+ }
+ else
+ {
+ Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}");
+ }
+ }
+
+ if (effectiveDpi <= 0)
+ {
+ effectiveDpi = 96;
+ }
+
+ return effectiveDpi;
+ }
+
private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
{
// Leaving a note here, in case we ever need it:
@@ -479,6 +583,9 @@ public sealed partial class MainWindow : WindowEx,
Y = _currentWindowPosition.Y,
Width = _currentWindowPosition.Width,
Height = _currentWindowPosition.Height,
+ Dpi = _currentWindowPosition.Dpi,
+ ScreenWidth = _currentWindowPosition.ScreenWidth,
+ ScreenHeight = _currentWindowPosition.ScreenHeight,
};
SettingsModel.SaveSettings(settings);
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs
index d009c626e0..e51597d268 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs
@@ -21,7 +21,6 @@ using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Automation.Peers;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Animation;
@@ -160,7 +159,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
new AsyncNavigationRequest(message.Page, message.CancellationToken),
message.WithAnimation ? DefaultPageAnimation : _noAnimation);
- PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
+ PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth, message.Page.Id));
if (!ViewModel.IsNested)
{
@@ -655,15 +654,15 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
e.Handled = true;
break;
default:
- {
- // The CommandBar is responsible for handling all the item keybindings,
- // since the bound context item may need to then show another
- // context menu
- TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
- WeakReferenceMessenger.Default.Send(msg);
- e.Handled = msg.Handled;
- break;
- }
+ {
+ // The CommandBar is responsible for handling all the item keybindings,
+ // since the bound context item may need to then show another
+ // context menu
+ TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key);
+ WeakReferenceMessenger.Default.Send(msg);
+ e.Handled = msg.Handled;
+ break;
+ }
}
}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json
index febacfc92e..4631e9aeaf 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json
@@ -5,6 +5,11 @@
"nativeDebugging": false,
"doNotLaunchApp": false
},
+ "Microsoft.CmdPal.UI (Package) + Native debugging": {
+ "commandName": "MsixPackage",
+ "nativeDebugging": true,
+ "doNotLaunchApp": false
+ },
"Microsoft.CmdPal.UI (Unpackaged)": {
"commandName": "Project"
}
diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj
index b146db8435..3ce98d8386 100644
--- a/src/modules/imageresizer/ui/ImageResizerUI.csproj
+++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj
@@ -24,13 +24,13 @@
Resources\ImageResizer.ico
-
+
diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs
index c967f5d840..653b85553e 100644
--- a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs
+++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs
@@ -19,7 +19,7 @@ public static class AIServiceTypeRegistry
{
ServiceType = AIServiceType.AzureAIInference,
DisplayName = "Azure AI Inference",
- IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", // No icon for Azure AI Inference, use Foundry Local temporarily
+ IconPath = "ms-appx:///Assets/Settings/Icons/Models/Azure.svg",
IsOnlineService = true,
LegalDescription = "AdvancedPaste_AzureAIInference_LegalDescription",
TermsLabel = "AdvancedPaste_AzureAIInference_TermsLabel",
diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg
index 7066f294f9..53747d557d 100644
--- a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg
+++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg
@@ -1,59 +1,34 @@
-
+
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml
index 292113dd42..adc802eba9 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml
@@ -30,6 +30,24 @@
+
+
+
+
+
+
+
+
+
@@ -152,7 +170,7 @@
Spacing="8">
DownloadableModels?.Cast
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs
index 145fc9b592..ea318100f0 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs
@@ -63,12 +63,20 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
}
- private void CmdPalSettingsDeeplink_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
+ private void SettingsCard_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
{
// Launch CmdPal settings window as normal user using explorer
string launchPath = "explorer.exe";
string launchArgs = "x-cmdpal://settings";
LaunchApp(launchPath, launchArgs);
}
+
+ private void LaunchCard_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
+ {
+ // Launch CmdPal window as normal user using explorer
+ string launchPath = "explorer.exe";
+ string launchArgs = "x-cmdpal:";
+ LaunchApp(launchPath, launchArgs);
+ }
}
}
diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
index ae0e2b7fb8..a9c13d8128 100644
--- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
+++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw
@@ -2703,7 +2703,7 @@ From there, simply click on one of the supported files in the File Explorer and
Mouse Pointer Crosshairs
Mouse as in the hardware peripheral.
-
+
Draw crosshairs centered around the mouse pointer.
Mouse as in the hardware peripheral.
@@ -2827,7 +2827,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Refers to the utility name
- Find My Mouse highlights the position of the cursor when pressing the Ctrl key twice, using a custom shortcut or when shaking the mouse.
+ Highlight the position of the cursor when pressing the Ctrl key twice, using a custom shortcut or when shaking the mouse.
"Ctrl" is a keyboard key. "Find My Mouse" is the name of the utility
@@ -2916,7 +2916,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Refers to the utility name
- Mouse Highlighter mode will highlight mouse clicks.
+ Highlight mouse clicks.
"Mouse Highlighter" is the name of the utility. Mouse is the hardware mouse.
@@ -2961,7 +2961,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.Refers to the utility name
- Mouse Pointer Crosshairs draws crosshairs centered on the mouse pointer.
+ Draw crosshairs centered on the mouse pointer.
"Mouse Pointer Crosshairs" is the name of the utility. Mouse is the hardware mouse.
@@ -3410,7 +3410,7 @@ Activate by holding the key for the character you want to add an accent to, then
An AI powered tool to put your clipboard content into any format you need, focused towards developer workflows.
- Transform your clipboard content with the power of AI. An cloud or local endpoint is required.
+ Transform your clipboard content with the power of AI. A cloud or local endpoint is required.
Learn more
@@ -5159,25 +5159,12 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
System Tools
-
- Command Palette
-
-
- A better quick launcher
-
-
- Open Command Palette
-
Enable Command Palette
- "Command Palette" is the name of the utility.
-
-
- A fully extensible quick launcher with a richer display and additional capabilities without sacrificing performance.
+ Command Palette is a product name, do not loc
- Learn more about Command Palette
- Command Palette is a product name, do not loc
+ Learn more
Command Palette
@@ -5185,11 +5172,11 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
A fully extensible quick launcher with a richer display and additional capabilities without sacrificing performance.
- "Command Palette" is a product name
+ Command Palette is a product name, do not loc
Command Palette
- "Command Palette" is a product name
+ Command Palette is a product name, do not loc
and start typing!
@@ -5222,14 +5209,8 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
Retry
-
- Activation
-
-
- Activation shortcut
-
-
- Open Command Palette settings to customize the activation shortcut
+
+ Settings
chroma (CIE LCh)
@@ -5693,14 +5674,14 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
Foundry Local model
Do not localize "Foundry Local", it's a product name
-
+
Use the Foundry Local CLI to download models that run locally on-device. They'll appear here.
Do not localize "Foundry Local", it's a product name
Refresh model list
-
+
Foundry Local is not available on this device yet.
Do not localize "Foundry Local", it's a product name
@@ -5753,4 +5734,51 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
Display a preview of the current clipboard content
+
+ Learn more
+
+
+ Foundry Local is still in public preview
+ Do not loc "Foundry Local"
+
+
+ Configure the activation shortcut, extensions, behavior and much more
+
+
+ Open Command Palette
+ Command Palette is a product name, do not loc
+
+
+ A better quick launcher
+
+
+ Find files, launch apps, and do so much more with the most extensible quick launcher.
+
+
+ Command Palette
+ Command Palette is a product name, do not loc
+
+
+ Open Command Palette
+ Command Palette is a product name, do not loc
+
+
+ Powerful extensions help you do more
+
+
+ Extensible
+
+
+ Find files and launch apps in an instant
+
+
+ Fast
+
+
+ Beautiful
+
+
+ A modern UI built with Fluent Design
+ Fluent Design is a product name, do not loc
+
\ No newline at end of file
diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
index 56437cd9f3..b0520dd38d 100644
--- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
+++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
@@ -40,6 +40,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public ObservableCollection ActionModules { get; set; } = new ObservableCollection();
+ // Master list of module items that is sorted and projected into AllModules.
+ private List _moduleItems = new List();
+
+ // Flag to prevent circular updates when a UI toggle triggers settings changes.
+ private bool _isUpdatingFromUI;
+
private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData();
public AllHotkeyConflictsData AllHotkeyConflictsData
@@ -74,7 +80,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
generalSettingsConfig.DashboardSortOrder = value;
OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(generalSettingsConfig);
SendConfigMSG(outgoing.ToString());
- RefreshModuleList();
+ SortModuleList();
}
}
}
@@ -96,8 +102,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// set the callback functions value to handle outgoing IPC message.
SendConfigMSG = ipcMSGCallBackFunc;
- RefreshModuleList();
- GetShortcutModules();
+ BuildModuleList();
+ SortModuleList();
+ RefreshShortcutModules();
}
protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e)
@@ -129,11 +136,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
}
- private void RefreshModuleList()
+ ///
+ /// Builds the master list of module items. Called once during initialization.
+ /// Each module item contains its configuration, enabled state, and GPO lock status.
+ ///
+ private void BuildModuleList()
{
- AllModules.Clear();
-
- var moduleItems = new List();
+ _moduleItems.Clear();
foreach (ModuleType moduleType in Enum.GetValues())
{
@@ -149,47 +158,143 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
DashboardModuleItems = GetModuleItems(moduleType),
};
newItem.EnabledChangedCallback = EnabledChangedOnUI;
- moduleItems.Add(newItem);
- }
-
- // Sort based on current sort order
- var sortedItems = DashboardSortOrder switch
- {
- DashboardSortOrder.ByStatus => moduleItems.OrderByDescending(x => x.IsEnabled).ThenBy(x => x.Label),
- _ => moduleItems.OrderBy(x => x.Label), // Default alphabetical
- };
-
- foreach (var item in sortedItems)
- {
- AllModules.Add(item);
+ _moduleItems.Add(newItem);
}
}
+ ///
+ /// Sorts the module list according to the current sort order and updates the AllModules collection.
+ /// On first call, populates AllModules. On subsequent calls, uses Move() to reorder items in-place
+ /// to avoid destroying and recreating UI elements.
+ ///
+ private void SortModuleList()
+ {
+ var sortedItems = (DashboardSortOrder switch
+ {
+ DashboardSortOrder.ByStatus => _moduleItems.OrderByDescending(x => x.IsEnabled).ThenBy(x => x.Label),
+ _ => _moduleItems.OrderBy(x => x.Label), // Default alphabetical
+ }).ToList();
+
+ // If AllModules is empty (first load), just populate it.
+ if (AllModules.Count == 0)
+ {
+ foreach (var item in sortedItems)
+ {
+ AllModules.Add(item);
+ }
+
+ return;
+ }
+
+ // Otherwise, update the collection in place using Move to avoid UI glitches.
+ for (int i = 0; i < sortedItems.Count; i++)
+ {
+ var currentItem = sortedItems[i];
+ var currentIndex = AllModules.IndexOf(currentItem);
+
+ if (currentIndex != -1 && currentIndex != i)
+ {
+ AllModules.Move(currentIndex, i);
+ }
+ }
+
+ // Notify that DashboardSortOrder changed so the menu updates its checked state.
+ OnPropertyChanged(nameof(DashboardSortOrder));
+ }
+
+ ///
+ /// Refreshes module enabled/locked states by re-reading GPO configuration. Only
+ /// updates properties that have actually changed to minimize UI notifications
+ /// then re-sorts the list according to the current sort order.
+ ///
+ private void RefreshModuleList()
+ {
+ foreach (var item in _moduleItems)
+ {
+ GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(item.Tag);
+
+ // GPO can force-enable (Enabled) or force-disable (Disabled) a module.
+ // If Enabled: module is on and the user cannot disable it.
+ // If Disabled: module is off and the user cannot enable it.
+ // Otherwise, the setting is unlocked and the user can enable/disable it.
+ bool newEnabledState = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, item.Tag));
+
+ // Lock the toggle when GPO is controlling the module.
+ bool newLockedState = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled;
+
+ // Only update if there's an actual change to minimize UI notifications.
+ if (item.IsEnabled != newEnabledState)
+ {
+ item.IsEnabled = newEnabledState;
+ }
+
+ if (item.IsLocked != newLockedState)
+ {
+ item.IsLocked = newLockedState;
+ }
+ }
+
+ SortModuleList();
+ }
+
+ ///
+ /// Callback invoked when a user toggles a module's enabled state in the UI.
+ /// Sets the _isUpdatingFromUI flag to prevent circular updates, then updates
+ /// settings, re-sorts if needed, and refreshes dependent collections.
+ ///
private void EnabledChangedOnUI(DashboardListItem dashboardListItem)
{
- Views.ShellPage.UpdateGeneralSettingsCallback(dashboardListItem.Tag, dashboardListItem.IsEnabled);
-
- if (dashboardListItem.Tag == ModuleType.NewPlus && dashboardListItem.IsEnabled == true)
+ _isUpdatingFromUI = true;
+ try
{
- var settingsUtils = new SettingsUtils();
- var settings = NewPlusViewModel.LoadSettings(settingsUtils);
- NewPlusViewModel.CopyTemplateExamples(settings.Properties.TemplateLocation.Value);
- }
+ Views.ShellPage.UpdateGeneralSettingsCallback(dashboardListItem.Tag, dashboardListItem.IsEnabled);
- // Request updated conflicts after module state change
- RequestConflictData();
+ if (dashboardListItem.Tag == ModuleType.NewPlus && dashboardListItem.IsEnabled == true)
+ {
+ var settingsUtils = new SettingsUtils();
+ var settings = NewPlusViewModel.LoadSettings(settingsUtils);
+ NewPlusViewModel.CopyTemplateExamples(settings.Properties.TemplateLocation.Value);
+ }
+
+ // Re-sort only required if sorting by enabled status.
+ if (DashboardSortOrder == DashboardSortOrder.ByStatus)
+ {
+ SortModuleList();
+ }
+
+ // Always refresh shortcuts/actions to reflect enabled state changes.
+ RefreshShortcutModules();
+
+ // Request updated conflicts after module state change.
+ RequestConflictData();
+ }
+ finally
+ {
+ _isUpdatingFromUI = false;
+ }
}
+ ///
+ /// Callback invoked when module enabled state changes from other parts of the
+ /// settings UI. Ignores the notification if it was triggered by a UI toggle
+ /// we're already handling, to prevent circular updates.
+ ///
public void ModuleEnabledChangedOnSettingsPage()
{
+ // Ignore if this was triggered by a UI change that we're already handling.
+ if (_isUpdatingFromUI)
+ {
+ return;
+ }
+
try
{
RefreshModuleList();
- GetShortcutModules();
+ RefreshShortcutModules();
OnPropertyChanged(nameof(ShortcutModules));
- // Request updated conflicts after module state change
+ // Request updated conflicts after module state change.
RequestConflictData();
}
catch (Exception ex)
@@ -198,7 +303,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
- private void GetShortcutModules()
+ ///
+ /// Rebuilds ShortcutModules and ActionModules collections by filtering AllModules
+ /// to only include enabled modules and their respective shortcut/action items.
+ ///
+ private void RefreshShortcutModules()
{
ShortcutModules.Clear();
ActionModules.Clear();
diff --git a/tools/module_loader/ModuleLoader.manifest b/tools/module_loader/ModuleLoader.manifest
new file mode 100644
index 0000000000..2607358482
--- /dev/null
+++ b/tools/module_loader/ModuleLoader.manifest
@@ -0,0 +1,37 @@
+
+
+
+ PowerToys Module Loader - Standalone module testing utility
+
+
+
+
+ true/PM
+ PerMonitorV2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/module_loader/ModuleLoader.vcxproj b/tools/module_loader/ModuleLoader.vcxproj
new file mode 100644
index 0000000000..dd9c01c584
--- /dev/null
+++ b/tools/module_loader/ModuleLoader.vcxproj
@@ -0,0 +1,205 @@
+
+
+
+
+ Debug
+ x64
+
+
+ Debug
+ ARM64
+
+
+ Release
+ x64
+
+
+ Release
+ ARM64
+
+
+
+ 17.0
+ Win32Proj
+ {8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}
+ ModuleLoader
+ 10.0
+ ModuleLoader
+
+
+
+ Application
+ true
+ v143
+ Unicode
+
+
+ Application
+ true
+ v143
+ Unicode
+
+
+ Application
+ false
+ v143
+ true
+ Unicode
+
+
+ Application
+ false
+ v143
+ true
+ Unicode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(SolutionDir)$(Platform)\$(Configuration)\
+ $(Platform)\$(Configuration)\
+ ModuleLoader
+
+
+ $(SolutionDir)$(Platform)\$(Configuration)\
+ $(Platform)\$(Configuration)\
+ ModuleLoader
+
+
+ $(SolutionDir)$(Platform)\$(Configuration)\
+ $(Platform)\$(Configuration)\
+ ModuleLoader
+
+
+ $(SolutionDir)$(Platform)\$(Configuration)\
+ $(Platform)\$(Configuration)\
+ ModuleLoader
+
+
+
+ Level4
+ true
+ _DEBUG;_CONSOLE;%(PreprocessorDefinitions)
+ true
+ stdcpp20
+ $(ProjectDir)src;$(SolutionDir)src\modules\interface;$(SolutionDir)src;%(AdditionalIncludeDirectories)
+ NotUsing
+ false
+
+
+ Console
+ true
+ kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)
+ type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'
+
+
+ $(ProjectDir)ModuleLoader.manifest
+
+
+
+
+ Level4
+ true
+ _DEBUG;_CONSOLE;%(PreprocessorDefinitions)
+ true
+ stdcpp20
+ $(ProjectDir)src;$(SolutionDir)src\modules\interface;$(SolutionDir)src;%(AdditionalIncludeDirectories)
+ NotUsing
+ false
+
+
+ Console
+ true
+ kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)
+ type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'
+
+
+ $(ProjectDir)ModuleLoader.manifest
+
+
+
+
+ Level4
+ true
+ true
+ true
+ NDEBUG;_CONSOLE;%(PreprocessorDefinitions)
+ true
+ stdcpp20
+ $(ProjectDir)src;$(SolutionDir)src\modules\interface;$(SolutionDir)src;%(AdditionalIncludeDirectories)
+ NotUsing
+ false
+
+
+ Console
+ true
+ true
+ true
+ kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)
+ type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'
+
+
+ $(ProjectDir)ModuleLoader.manifest
+
+
+
+
+ Level4
+ true
+ true
+ true
+ NDEBUG;_CONSOLE;%(PreprocessorDefinitions)
+ true
+ stdcpp20
+ $(ProjectDir)src;$(SolutionDir)src\modules\interface;$(SolutionDir)src;%(AdditionalIncludeDirectories)
+ NotUsing
+ false
+
+
+ Console
+ true
+ true
+ true
+ kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)
+ type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'
+
+
+ $(ProjectDir)ModuleLoader.manifest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/module_loader/ModuleLoader.vcxproj.filters b/tools/module_loader/ModuleLoader.vcxproj.filters
new file mode 100644
index 0000000000..823f1c4e60
--- /dev/null
+++ b/tools/module_loader/ModuleLoader.vcxproj.filters
@@ -0,0 +1,51 @@
+
+
+
+
+ {4FC737F1-C7A5-4376-A066-2A32D752A2FF}
+ cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx
+
+
+ {93995380-89BD-4b04-88EB-625FBE52EBFB}
+ h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd
+
+
+ {67DA6AB6-F800-4c08-8B7A-83BB121AAD01}
+ rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms
+
+
+
+
+ Source Files
+
+
+ Source Files
+
+
+ Source Files
+
+
+ Source Files
+
+
+ Source Files
+
+
+
+
+ Header Files
+
+
+ Header Files
+
+
+ Header Files
+
+
+ Header Files
+
+
+
+
+
+
diff --git a/tools/module_loader/SHARING.md b/tools/module_loader/SHARING.md
new file mode 100644
index 0000000000..1923d31adf
--- /dev/null
+++ b/tools/module_loader/SHARING.md
@@ -0,0 +1,483 @@
+# Sharing ModuleLoader and Modules
+
+This guide explains how to share the ModuleLoader tool and PowerToy modules with others for testing purposes.
+
+## Overview
+
+The ModuleLoader is designed to be a **portable, standalone testing tool** that can be shared with module developers and testers. It has minimal dependencies and can work with any compatible PowerToy module DLL.
+
+---
+
+## What You Need to Share
+
+### For Testing a Module (e.g., CursorWrap)
+
+#### **Minimum Package** (Recommended for Quick Testing)
+
+1. **ModuleLoader.exe** - The standalone loader application
+ - Location: `x64\Debug\ModuleLoader.exe` or `x64\Release\ModuleLoader.exe`
+ - No additional DLLs required (uses only Windows system libraries)
+
+2. **The Module DLL** - The PowerToy module to test
+ - Example: `CursorWrap.dll` from `x64\Debug\` or `x64\Release\`
+ - Location varies by module (see module-specific locations below)
+
+3. **settings.json** - Module configuration (place in same folder as the DLL)
+ - **NEW**: Settings can be placed alongside the module DLL for portable testing
+ - Location: Same directory as the module DLL (e.g., `settings.json` next to `CursorWrap.dll`)
+ - Falls back to: `%LOCALAPPDATA%\Microsoft\PowerToys\\settings.json` if not found locally
+
+#### **Complete Standalone Package** (For Users Without PowerToys Installed)
+
+1. **ModuleLoader.exe**
+2. **Module DLL**
+3. **Sample settings.json** - Pre-configured settings file
+4. **Installation instructions** - See "Standalone Package Setup" section below
+
+---
+
+### Debug Builds
+If you build the module in Debug configuration:
+- The module will output debug messages via `OutputDebugString()`
+- View these with [DebugView](https://learn.microsoft.com/sysinternals/downloads/debugview) or Visual Studio Output window
+- Example: CursorWrap outputs detailed topology and cursor wrapping debug info
+
+---
+
+
+## Module-Specific File Locations
+
+### CursorWrap
+```
+Files to share:
+ - x64\Debug\CursorWrap.dll (or Release)
+ - %LOCALAPPDATA%\Microsoft\PowerToys\CursorWrap\settings.json
+
+Size: ~100KB
+```
+
+### MouseHighlighter
+```
+Files to share:
+ - x64\Debug\MouseHighlighter.dll (or Release)
+ - %LOCALAPPDATA%\Microsoft\PowerToys\MouseHighlighter\settings.json
+
+Size: ~150KB
+```
+
+### FindMyMouse
+```
+Files to share:
+ - x64\Debug\FindMyMouse.dll (or Release)
+ - %LOCALAPPDATA%\Microsoft\PowerToys\FindMyMouse\settings.json
+
+Size: ~120KB
+```
+
+### MousePointerCrosshairs
+```
+Files to share:
+ - x64\Debug\MousePointerCrosshairs.dll (or Release)
+ - %LOCALAPPDATA%\Microsoft\PowerToys\MousePointerCrosshairs\settings.json
+
+Size: ~140KB
+```
+
+### MouseJump
+```
+Files to share:
+ - x64\Debug\MouseJump.dll (or Release)
+ - %LOCALAPPDATA%\Microsoft\PowerToys\MouseJump\settings.json
+
+Note: MouseJump is a UI-based module and may not work fully with ModuleLoader
+Size: ~200KB
+```
+
+### AlwaysOnTop
+```
+Files to share:
+ - x64\Debug\AlwaysOnTop.dll (or Release)
+ - %LOCALAPPDATA%\Microsoft\PowerToys\AlwaysOnTop\settings.json
+
+Size: ~100KB
+```
+
+---
+
+## Dependency Analysis
+
+### ModuleLoader.exe Dependencies
+**Windows System Libraries Only** (automatically available on all Windows systems):
+- `KERNEL32.dll` - Core Windows API
+- `USER32.dll` - User interface functions
+- `SHELL32.dll` - Shell functions
+- `ole32.dll` - COM library
+
+**No PowerToys dependencies required!** The ModuleLoader is completely standalone.
+
+### Module DLL Dependencies (Typical)
+Most PowerToy modules depend on:
+- Windows system DLLs (automatically available)
+- PowerToys common libraries (if any, they're typically statically linked)
+- **Module settings** - Must be present in `%LOCALAPPDATA%\Microsoft\PowerToys\\`
+
+**Important**: Modules are generally **self-contained** and statically link most dependencies. You typically only need the module DLL itself.
+
+---
+
+## Creating a Standalone Package
+
+### Step 1: Prepare the Files
+
+Create a folder structure like this:
+```
+ModuleLoaderPackage\
+??? ModuleLoader.exe
+??? CursorWrap.dll (or other module)
+??? settings.json (module settings - placed locally!)
+```
+
+**NEW Simplified Structure**: You can now place `settings.json` directly alongside the module DLL! The ModuleLoader will check this location first before looking in the standard PowerToys settings directories.
+
+### Step 2: Extract Settings from Your Machine
+
+```powershell
+# Copy settings from your development machine
+$moduleName = "CursorWrap" # Change as needed
+$settingsPath = "$env:LOCALAPPDATA\Microsoft\PowerToys\$moduleName\settings.json"
+Copy-Item $settingsPath ".\settings\$moduleName\settings.json"
+```
+
+### Step 3: Create Installation Instructions (README.txt)
+
+```text
+PowerToys Module Testing Package
+=================================
+
+This package contains the ModuleLoader tool for testing PowerToy modules.
+
+Contents:
+ - ModuleLoader.exe : Standalone module loader
+ - modules\*.dll : PowerToy module(s) to test
+ - settings\*\*.json : Module configuration files
+
+Setup (First Time):
+-------------------
+1. Create settings directory:
+ %LOCALAPPDATA%\Microsoft\PowerToys\
+
+2. Copy settings:
+ Copy the entire "settings\" folder to:
+ %LOCALAPPDATA%\Microsoft\PowerToys\
+
+ Example for CursorWrap:
+ Copy "settings\CursorWrap" to:
+ %LOCALAPPDATA%\Microsoft\PowerToys\CursorWrap\
+
+Usage:
+------
+ModuleLoader.exe modules\CursorWrap.dll
+
+The tool will:
+ - Load the module DLL
+ - Read settings from %LOCALAPPDATA%\Microsoft\PowerToys\\
+ - Register hotkeys
+ - Enable the module
+
+Press Ctrl+C to exit.
+Press the module's hotkey to toggle functionality.
+
+Requirements:
+-------------
+- Windows 10 1803 or later
+- No PowerToys installation required!
+
+Troubleshooting:
+----------------
+If you see "Settings file not found":
+ 1. Make sure you copied the settings folder correctly
+ 2. Check that the path is:
+ %LOCALAPPDATA%\Microsoft\PowerToys\\settings.json
+ 3. You can also run PowerToys once to generate default settings
+
+Debug Logs:
+-----------
+Module logs are written to:
+ %LOCALAPPDATA%\Microsoft\PowerToys\\Logs\
+
+For debug builds, use DebugView to see real-time output.
+```
+
+---
+
+## Quick Distribution Methods
+
+### Method 1: ZIP Archive
+```powershell
+# Create a complete package
+$moduleName = "CursorWrap"
+$packageName = "ModuleLoader-$moduleName-Package"
+
+# Collect files
+New-Item $packageName -ItemType Directory
+Copy-Item "x64\Debug\ModuleLoader.exe" "$packageName\"
+New-Item "$packageName\modules" -ItemType Directory
+Copy-Item "x64\Debug\$moduleName.dll" "$packageName\modules\"
+New-Item "$packageName\settings\$moduleName" -ItemType Directory -Force
+Copy-Item "$env:LOCALAPPDATA\Microsoft\PowerToys\$moduleName\settings.json" "$packageName\settings\$moduleName\"
+
+# Create README
+@"
+See README in the tools\module_loader folder for instructions
+"@ | Out-File "$packageName\README.txt"
+
+# Zip it
+Compress-Archive -Path $packageName -DestinationPath "$packageName.zip"
+```
+
+### Method 2: Direct Share (Advanced Users)
+For developers who already have PowerToys installed:
+```powershell
+# Just share the executables
+Copy-Item "x64\Debug\ModuleLoader.exe" "\\ShareLocation\"
+Copy-Item "x64\Debug\CursorWrap.dll" "\\ShareLocation\"
+```
+
+They can run: `ModuleLoader.exe CursorWrap.dll`
+(Settings will be loaded from their existing PowerToys installation)
+
+---
+
+## Platform-Specific Notes
+
+### x64 vs ARM64
+
+**Important**: Match architectures!
+- `x64\Debug\ModuleLoader.exe` ? Only works with `x64` module DLLs
+- `ARM64\Debug\ModuleLoader.exe` ? Only works with `ARM64` module DLLs
+
+**Distribution Tip**: Provide both architectures if targeting multiple platforms:
+```
+ModuleLoaderPackage\
+??? x64\
+? ??? ModuleLoader.exe
+? ??? modules\CursorWrap.dll
+??? ARM64\
+? ??? ModuleLoader.exe
+? ??? modules\CursorWrap.dll
+??? settings\...
+```
+
+### Debug vs Release
+
+**Debug builds**:
+- Larger file size
+- Include debug symbols
+- Verbose logging via `OutputDebugString()`
+- Recommended for testing/development
+
+**Release builds**:
+- Smaller file size
+- Optimized performance
+- Minimal logging
+- Recommended for end-user testing
+
+---
+
+## Testing Checklist
+
+Before sharing a module package:
+
+- [ ] ModuleLoader.exe is included
+- [ ] Module DLL is included (matching architecture)
+- [ ] Sample settings.json is included
+- [ ] README/instructions are included
+- [ ] Tested on a clean machine (no PowerToys installed)
+- [ ] Verified hotkeys work
+- [ ] Verified Ctrl+C exits cleanly
+- [ ] Confirmed settings path in documentation
+
+---
+
+## Advanced: Portable Package Script
+
+Here's a complete PowerShell script to create a fully portable package:
+
+```powershell
+param(
+ [Parameter(Mandatory=$true)]
+ [string]$ModuleName,
+
+ [ValidateSet("Debug", "Release")]
+ [string]$Configuration = "Debug",
+
+ [ValidateSet("x64", "ARM64")]
+ [string]$Platform = "x64"
+)
+
+$packageName = "ModuleLoader-$ModuleName-$Platform-$Configuration"
+$packagePath = ".\$packageName"
+
+Write-Host "Creating portable package: $packageName" -ForegroundColor Green
+
+# Create structure
+New-Item $packagePath -ItemType Directory -Force | Out-Null
+New-Item "$packagePath\modules" -ItemType Directory -Force | Out-Null
+New-Item "$packagePath\settings\$ModuleName" -ItemType Directory -Force | Out-Null
+
+# Copy ModuleLoader
+$loaderPath = "$Platform\$Configuration\ModuleLoader.exe"
+if (Test-Path $loaderPath) {
+ Copy-Item $loaderPath "$packagePath\"
+ Write-Host "? Copied ModuleLoader.exe" -ForegroundColor Green
+} else {
+ Write-Host "? ModuleLoader.exe not found at $loaderPath" -ForegroundColor Red
+ exit 1
+}
+
+# Copy Module DLL
+$modulePath = "$Platform\$Configuration\$ModuleName.dll"
+if (Test-Path $modulePath) {
+ Copy-Item $modulePath "$packagePath\modules\"
+ Write-Host "? Copied $ModuleName.dll" -ForegroundColor Green
+} else {
+ Write-Host "? $ModuleName.dll not found at $modulePath" -ForegroundColor Red
+ exit 1
+}
+
+# Copy Settings
+$settingsPath = "$env:LOCALAPPDATA\Microsoft\PowerToys\$ModuleName\settings.json"
+if (Test-Path $settingsPath) {
+ Copy-Item $settingsPath "$packagePath\settings\$ModuleName\"
+ Write-Host "? Copied settings.json" -ForegroundColor Green
+} else {
+ Write-Host "? Settings not found at $settingsPath - creating placeholder" -ForegroundColor Yellow
+ @"
+{
+ "name": "$ModuleName",
+ "version": "1.0"
+}
+"@ | Out-File "$packagePath\settings\$ModuleName\settings.json"
+}
+
+# Create README
+@"
+PowerToys $ModuleName Testing Package
+======================================
+
+Configuration: $Configuration
+Platform: $Platform
+
+Setup Instructions:
+-------------------
+1. Copy the 'settings\$ModuleName' folder to:
+ %LOCALAPPDATA%\Microsoft\PowerToys\
+
+2. Run:
+ ModuleLoader.exe modules\$ModuleName.dll
+
+3. Press Ctrl+C to exit
+
+Logs are written to:
+ %LOCALAPPDATA%\Microsoft\PowerToys\$ModuleName\Logs\
+
+For more information, see:
+ https://github.com/microsoft/PowerToys/tree/main/tools/module_loader
+"@ | Out-File "$packagePath\README.txt"
+
+# Create ZIP
+$zipPath = "$packageName.zip"
+Compress-Archive -Path $packagePath -DestinationPath $zipPath -Force
+Write-Host "? Created $zipPath" -ForegroundColor Green
+
+# Show summary
+Write-Host "`nPackage Contents:" -ForegroundColor Cyan
+Get-ChildItem $packagePath -Recurse | ForEach-Object {
+ Write-Host " $($_.FullName.Replace($packagePath, ''))"
+}
+
+Write-Host "`nPackage ready: $zipPath" -ForegroundColor Green
+Write-Host "Size: $([math]::Round((Get-Item $zipPath).Length / 1KB, 2)) KB"
+```
+
+**Usage**:
+```powershell
+.\CreateModulePackage.ps1 -ModuleName "CursorWrap" -Configuration Release -Platform x64
+```
+
+---
+
+## FAQ
+
+### Q: Can I share just ModuleLoader.exe and the module DLL?
+**A**: Yes, but the recipient must have PowerToys installed (or manually create the settings file).
+
+### Q: Does the tester need PowerToys installed?
+**A**: No, if you provide the complete package with settings. ModuleLoader is fully standalone.
+
+### Q: What if settings.json doesn't exist?
+**A**: ModuleLoader will show an error. Either:
+1. Run PowerToys once with the module enabled to generate settings
+2. Manually create a minimal settings.json file
+3. Include a sample settings.json in your package
+
+### Q: Can I test modules on a virtual machine?
+**A**: Yes! This is a great use case. Just copy the package to the VM - no PowerToys installation needed.
+
+### Q: Do I need to include PDB files?
+**A**: Only for debugging. For normal testing, just the EXE and DLL are sufficient.
+
+### Q: Can I distribute this to end users?
+**A**: ModuleLoader is a **development/testing tool**, not intended for end-user distribution. For production use, direct users to install PowerToys.
+
+---
+
+## Security Considerations
+
+When sharing module DLLs:
+
+1. **Verify Source**: Only share modules you built from trusted source code
+2. **Scan for Malware**: Run antivirus scans on the package before sharing
+3. **HTTPS Only**: Use secure channels (HTTPS, OneDrive, SharePoint) for distribution
+4. **Hash Verification**: Consider providing SHA256 hashes for file integrity:
+ ```powershell
+ Get-FileHash ModuleLoader.exe -Algorithm SHA256
+ Get-FileHash modules\CursorWrap.dll -Algorithm SHA256
+ ```
+
+---
+
+## Example Package (CursorWrap)
+
+Here's what a complete CursorWrap testing package looks like:
+
+```
+ModuleLoader-CursorWrap-x64-Debug.zip (220 KB)
+?
+??? ModuleLoader-CursorWrap-x64-Debug\
+ ??? ModuleLoader.exe (160 KB)
+ ??? README.txt (2 KB)
+ ??? modules\
+ ? ??? CursorWrap.dll (55 KB)
+ ??? settings\
+ ??? CursorWrap\
+ ??? settings.json (3 KB)
+```
+
+**Total package size**: ~220 KB (compressed)
+
+---
+
+## Support
+
+For issues with ModuleLoader, see:
+- [ModuleLoader README](./README.md)
+- [PowerToys Documentation](https://aka.ms/PowerToysOverview)
+- [PowerToys GitHub Issues](https://github.com/microsoft/PowerToys/issues)
+
+---
+
+## License
+
+ModuleLoader is part of PowerToys and is licensed under the MIT License.
+See the LICENSE file in the PowerToys repository root for details.
diff --git a/tools/module_loader/src/ConsoleHost.cpp b/tools/module_loader/src/ConsoleHost.cpp
new file mode 100644
index 0000000000..1ab2cdefa2
--- /dev/null
+++ b/tools/module_loader/src/ConsoleHost.cpp
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+#include "ConsoleHost.h"
+#include
+
+bool ConsoleHost::s_exitRequested = false;
+
+ConsoleHost::ConsoleHost(ModuleLoader& moduleLoader, HotkeyManager& hotkeyManager)
+ : m_moduleLoader(moduleLoader)
+ , m_hotkeyManager(hotkeyManager)
+{
+}
+
+ConsoleHost::~ConsoleHost()
+{
+}
+
+BOOL WINAPI ConsoleHost::ConsoleCtrlHandler(DWORD ctrlType)
+{
+ switch (ctrlType)
+ {
+ case CTRL_C_EVENT:
+ case CTRL_BREAK_EVENT:
+ case CTRL_CLOSE_EVENT:
+ std::wcout << L"\nCtrl+C received, shutting down...\n";
+ s_exitRequested = true;
+
+ // Post a quit message to break the message loop
+ PostQuitMessage(0);
+ return TRUE;
+
+ default:
+ return FALSE;
+ }
+}
+
+void ConsoleHost::Run()
+{
+ // Install console control handler
+ if (!SetConsoleCtrlHandler(ConsoleCtrlHandler, TRUE))
+ {
+ std::wcerr << L"Warning: Failed to set console control handler\n";
+ }
+
+ s_exitRequested = false;
+
+ // Message loop
+ MSG msg;
+ while (!s_exitRequested)
+ {
+ // Wait for a message with a timeout so we can check s_exitRequested
+ DWORD result = MsgWaitForMultipleObjects(0, nullptr, FALSE, 100, QS_ALLINPUT);
+
+ if (result == WAIT_OBJECT_0)
+ {
+ // Process all pending messages
+ while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
+ {
+ if (msg.message == WM_QUIT)
+ {
+ s_exitRequested = true;
+ break;
+ }
+
+ if (msg.message == WM_HOTKEY)
+ {
+ m_hotkeyManager.HandleHotkey(static_cast(msg.wParam), m_moduleLoader);
+ }
+
+ TranslateMessage(&msg);
+ DispatchMessage(&msg);
+ }
+ }
+ }
+
+ // Remove console control handler
+ SetConsoleCtrlHandler(ConsoleCtrlHandler, FALSE);
+}
diff --git a/tools/module_loader/src/ConsoleHost.h b/tools/module_loader/src/ConsoleHost.h
new file mode 100644
index 0000000000..153fdaa0f0
--- /dev/null
+++ b/tools/module_loader/src/ConsoleHost.h
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+#pragma once
+
+#include
+#include "ModuleLoader.h"
+#include "HotkeyManager.h"
+
+///
+/// Console host that runs the message loop and handles Ctrl+C
+///
+class ConsoleHost
+{
+public:
+ ConsoleHost(ModuleLoader& moduleLoader, HotkeyManager& hotkeyManager);
+ ~ConsoleHost();
+
+ // Prevent copying
+ ConsoleHost(const ConsoleHost&) = delete;
+ ConsoleHost& operator=(const ConsoleHost&) = delete;
+
+ ///
+ /// Run the message loop until Ctrl+C is pressed
+ ///
+ void Run();
+
+private:
+ ModuleLoader& m_moduleLoader;
+ HotkeyManager& m_hotkeyManager;
+ static bool s_exitRequested;
+
+ ///
+ /// Console control handler (for Ctrl+C)
+ ///
+ static BOOL WINAPI ConsoleCtrlHandler(DWORD ctrlType);
+};
diff --git a/tools/module_loader/src/HotkeyManager.cpp b/tools/module_loader/src/HotkeyManager.cpp
new file mode 100644
index 0000000000..ce0ced5a03
--- /dev/null
+++ b/tools/module_loader/src/HotkeyManager.cpp
@@ -0,0 +1,279 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+#include "HotkeyManager.h"
+#include
+#include
+
+HotkeyManager::HotkeyManager()
+ : m_nextHotkeyId(1) // Start from 1
+ , m_hotkeyExRegistered(false)
+ , m_hotkeyExId(0)
+{
+}
+
+HotkeyManager::~HotkeyManager()
+{
+ UnregisterAll();
+}
+
+UINT HotkeyManager::ConvertModifiers(bool win, bool ctrl, bool alt, bool shift) const
+{
+ UINT modifiers = MOD_NOREPEAT; // Prevent repeat events
+ if (win) modifiers |= MOD_WIN;
+ if (ctrl) modifiers |= MOD_CONTROL;
+ if (alt) modifiers |= MOD_ALT;
+ if (shift) modifiers |= MOD_SHIFT;
+ return modifiers;
+}
+
+bool HotkeyManager::RegisterModuleHotkeys(ModuleLoader& moduleLoader)
+{
+ if (!moduleLoader.IsLoaded())
+ {
+ std::wcerr << L"Error: Module not loaded\n";
+ return false;
+ }
+
+ bool anyRegistered = false;
+
+ // First, try the newer GetHotkeyEx() API
+ auto hotkeyEx = moduleLoader.GetHotkeyEx();
+ if (hotkeyEx.has_value())
+ {
+ std::wcout << L"Module has HotkeyEx activation hotkey\n";
+
+ UINT modifiers = hotkeyEx->modifiersMask | MOD_NOREPEAT;
+ UINT vkCode = hotkeyEx->vkCode;
+
+ if (vkCode != 0)
+ {
+ int hotkeyId = m_nextHotkeyId++;
+
+ std::wcout << L" Registering HotkeyEx: ";
+ std::wcout << ModifiersToString(modifiers) << L"+" << VKeyToString(vkCode);
+
+ if (RegisterHotKey(nullptr, hotkeyId, modifiers, vkCode))
+ {
+ m_hotkeyExRegistered = true;
+ m_hotkeyExId = hotkeyId;
+
+ std::wcout << L" - OK (Activation/Toggle)\n";
+ anyRegistered = true;
+ }
+ else
+ {
+ DWORD error = GetLastError();
+ std::wcout << L" - FAILED (Error: " << error << L")\n";
+
+ if (error == ERROR_HOTKEY_ALREADY_REGISTERED)
+ {
+ std::wcout << L" (Hotkey is already registered by another application)\n";
+ }
+ }
+ }
+ }
+
+ // Also check the legacy get_hotkeys() API
+ size_t hotkeyCount = moduleLoader.GetHotkeys(nullptr, 0);
+ if (hotkeyCount > 0)
+ {
+ std::wcout << L"Module reports " << hotkeyCount << L" legacy hotkey(s)\n";
+
+ // Allocate buffer and get the hotkeys
+ std::vector hotkeys(hotkeyCount);
+ size_t actualCount = moduleLoader.GetHotkeys(hotkeys.data(), hotkeyCount);
+
+ // Register each hotkey
+ for (size_t i = 0; i < actualCount; i++)
+ {
+ const auto& hotkey = hotkeys[i];
+
+ UINT modifiers = ConvertModifiers(hotkey.win, hotkey.ctrl, hotkey.alt, hotkey.shift);
+ UINT vkCode = hotkey.key;
+
+ if (vkCode == 0)
+ {
+ std::wcout << L" Skipping hotkey " << i << L" (no key code)\n";
+ continue;
+ }
+
+ int hotkeyId = m_nextHotkeyId++;
+
+ std::wcout << L" Registering hotkey " << i << L": ";
+ std::wcout << ModifiersToString(modifiers) << L"+" << VKeyToString(vkCode);
+
+ if (RegisterHotKey(nullptr, hotkeyId, modifiers, vkCode))
+ {
+ HotkeyInfo info;
+ info.id = hotkeyId;
+ info.moduleHotkeyId = i;
+ info.modifiers = modifiers;
+ info.vkCode = vkCode;
+ info.description = ModifiersToString(modifiers) + L"+" + VKeyToString(vkCode);
+
+ m_registeredHotkeys.push_back(info);
+ std::wcout << L" - OK\n";
+ anyRegistered = true;
+ }
+ else
+ {
+ DWORD error = GetLastError();
+ std::wcout << L" - FAILED (Error: " << error << L")\n";
+
+ if (error == ERROR_HOTKEY_ALREADY_REGISTERED)
+ {
+ std::wcout << L" (Hotkey is already registered by another application)\n";
+ }
+ }
+ }
+ }
+
+ if (!anyRegistered && hotkeyCount == 0 && !hotkeyEx.has_value())
+ {
+ std::wcout << L"Module has no hotkeys\n";
+ }
+
+ return anyRegistered;
+}
+
+void HotkeyManager::UnregisterAll()
+{
+ for (const auto& hotkey : m_registeredHotkeys)
+ {
+ UnregisterHotKey(nullptr, hotkey.id);
+ }
+ m_registeredHotkeys.clear();
+
+ if (m_hotkeyExRegistered)
+ {
+ UnregisterHotKey(nullptr, m_hotkeyExId);
+ m_hotkeyExRegistered = false;
+ m_hotkeyExId = 0;
+ }
+}
+
+bool HotkeyManager::HandleHotkey(int hotkeyId, ModuleLoader& moduleLoader)
+{
+ // Check if it's the HotkeyEx activation hotkey
+ if (m_hotkeyExRegistered && hotkeyId == m_hotkeyExId)
+ {
+ std::wcout << L"\nActivation hotkey triggered (HotkeyEx)\n";
+
+ moduleLoader.OnHotkeyEx();
+
+ std::wcout << L"Module toggled via activation hotkey\n";
+ std::wcout << L"Module enabled: " << (moduleLoader.IsEnabled() ? L"Yes" : L"No") << L"\n\n";
+
+ return true;
+ }
+
+ // Check legacy hotkeys
+ for (const auto& hotkey : m_registeredHotkeys)
+ {
+ if (hotkey.id == hotkeyId)
+ {
+ std::wcout << L"\nHotkey triggered: " << hotkey.description << L"\n";
+
+ bool result = moduleLoader.OnHotkey(hotkey.moduleHotkeyId);
+
+ std::wcout << L"Module handled hotkey: " << (result ? L"Swallowed" : L"Not swallowed") << L"\n";
+ std::wcout << L"Module enabled: " << (moduleLoader.IsEnabled() ? L"Yes" : L"No") << L"\n\n";
+
+ return true;
+ }
+ }
+
+ return false;
+}
+
+void HotkeyManager::PrintHotkeys() const
+{
+ for (const auto& hotkey : m_registeredHotkeys)
+ {
+ std::wcout << L" " << hotkey.description << L"\n";
+ }
+}
+
+std::wstring HotkeyManager::ModifiersToString(UINT modifiers) const
+{
+ std::wstringstream ss;
+ bool first = true;
+
+ if (modifiers & MOD_WIN)
+ {
+ if (!first) ss << L"+";
+ ss << L"Win";
+ first = false;
+ }
+ if (modifiers & MOD_CONTROL)
+ {
+ if (!first) ss << L"+";
+ ss << L"Ctrl";
+ first = false;
+ }
+ if (modifiers & MOD_ALT)
+ {
+ if (!first) ss << L"+";
+ ss << L"Alt";
+ first = false;
+ }
+ if (modifiers & MOD_SHIFT)
+ {
+ if (!first) ss << L"+";
+ ss << L"Shift";
+ first = false;
+ }
+
+ return ss.str();
+}
+
+std::wstring HotkeyManager::VKeyToString(UINT vkCode) const
+{
+ // Handle special keys
+ switch (vkCode)
+ {
+ case VK_SPACE: return L"Space";
+ case VK_RETURN: return L"Enter";
+ case VK_ESCAPE: return L"Esc";
+ case VK_TAB: return L"Tab";
+ case VK_BACK: return L"Backspace";
+ case VK_DELETE: return L"Del";
+ case VK_INSERT: return L"Ins";
+ case VK_HOME: return L"Home";
+ case VK_END: return L"End";
+ case VK_PRIOR: return L"PgUp";
+ case VK_NEXT: return L"PgDn";
+ case VK_LEFT: return L"Left";
+ case VK_RIGHT: return L"Right";
+ case VK_UP: return L"Up";
+ case VK_DOWN: return L"Down";
+ case VK_F1: return L"F1";
+ case VK_F2: return L"F2";
+ case VK_F3: return L"F3";
+ case VK_F4: return L"F4";
+ case VK_F5: return L"F5";
+ case VK_F6: return L"F6";
+ case VK_F7: return L"F7";
+ case VK_F8: return L"F8";
+ case VK_F9: return L"F9";
+ case VK_F10: return L"F10";
+ case VK_F11: return L"F11";
+ case VK_F12: return L"F12";
+ }
+
+ // For alphanumeric keys, use MapVirtualKey
+ wchar_t keyName[256];
+ UINT scanCode = MapVirtualKeyW(vkCode, MAPVK_VK_TO_VSC);
+
+ if (GetKeyNameTextW(scanCode << 16, keyName, 256) > 0)
+ {
+ return keyName;
+ }
+
+ // Fallback to hex code
+ std::wstringstream ss;
+ ss << L"0x" << std::hex << vkCode;
+ return ss.str();
+}
diff --git a/tools/module_loader/src/HotkeyManager.h b/tools/module_loader/src/HotkeyManager.h
new file mode 100644
index 0000000000..714e5a0962
--- /dev/null
+++ b/tools/module_loader/src/HotkeyManager.h
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+#pragma once
+
+#include
+#include
+#include
+#include