From 27dcd1e5bc1ab0ae1bc84512e8396f9e78a99c33 Mon Sep 17 00:00:00 2001 From: Den Delimarsky Date: Tue, 20 Jan 2026 21:27:45 -0800 Subject: [PATCH] Awake and DevEx improvements (#44795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains a set of bug fixes and general improvements to [Awake](https://awake.den.dev/) and developer experience tooling for building the module. ### Awake Fixes - **#32544** - Fixed an issue where Awake settings became non-functional after the PC wakes from sleep. Added `WM_POWERBROADCAST` handling to detect system resume events (`PBT_APMRESUMEAUTOMATIC`, `PBT_APMRESUMESUSPEND`) and re-apply `SetThreadExecutionState` to restore the awake state. - **#36150** - Fixed an issue where Awake would not prevent sleep when AC power is connected. Added `PBT_APMPOWERSTATUSCHANGE` handling to re-apply `SetThreadExecutionState` when the power source changes (AC/battery transitions). - **#41674** - Fixed silent failure when `SetThreadExecutionState` fails. The monitor thread now handles the return value, logs an error, and reverts to passive mode with updated tray icon. - **#41738** - Fixed `--display-on` CLI flag default from `true` to `false` to align with documentation and PowerToys settings behavior. This is a breaking change for scripts relying on the undocumented default. - **#41918** - Fixed `WM_COMMAND` message processing flaw in `TrayHelper.WndProc` that incorrectly compared enum values against enum count. Added proper bounds checking for custom tray time entries. - **#44134** - Documented that `ES_DISPLAY_REQUIRED` (used when "Keep display on" is enabled) blocks Task Scheduler idle detection, preventing scheduled maintenance tasks like SSD TRIM. Workaround: disable "Keep display on" or manually run `Optimize-Volume -DriveLetter C -ReTrim`. - **#38770** - Fixed tray icon failing to appear after Windows updates. Increased retry attempts and delays for icon Add operations (10 attempts, up to ~15.5 seconds total) while keeping existing fast retry behavior for Update/Delete operations. - **#40501** - Fixed tray icon not disappearing when Awake is disabled. The `SetShellIcon` function was incorrectly requiring an icon for Delete operations, causing the `NIM_DELETE` message to never be sent. - Fixed an issue where toggling "Keep screen on" during an active timed session would disrupt the countdown timer. The display setting now updates directly without restarting the timer, preserving the exact remaining time. ### Performance Optimizations - Fixed O(n²) loop in `TrayHelper.CreateAwakeTimeSubMenu` by replacing `ElementAt(i)` with `foreach` iteration. - Fixed Observable subscription leak in `Manager.cs` by storing `IDisposable` and disposing in `CancelExistingThread()`. Also removed dead `_tokenSource` code that was no longer used. - Reduced allocations in `SingleThreadSynchronizationContext` by changing `Tuple<>` to `ValueTuple`. - Replaced dedicated exit event thread with `ThreadPool.RegisterWaitForSingleObject()` to reduce resource usage. ### Code Quality - Replaced `Console.WriteLine` with `Logger.LogError` in `TrayHelper.cs` for consistent logging. - Added proper error logging to silent exception catches in `AwakeService.cs`. - Removed dead `Math.Min(minutes, int.MaxValue)` code where `minutes` is already an `int`. - Extracted hardcoded tray icon ID to named constant `TrayIconId`. - Standardized null coalescing for `GetSettings()` calls across all files. ### Debugging Experience Fixes - Fixed first-chance exceptions in `settings_window.cpp` during debugging. Added `HasKey()` check before accessing `hotkey_changed` property to prevent `hresult_error` exceptions when the property doesn't exist in module settings. - Fixed first-chance exceptions in FindMyMouse `parse_settings` during debugging. Refactored to extract the properties object once and added `HasKey()` checks before all `GetNamedObject()` calls. This prevents `winrt::hresult_error` exceptions when optional settings keys (like legacy `overlay_opacity`) don't exist, improving the debugging experience by eliminating spurious exception breaks. - Fixed LightSwitch.UITests build failures when building from a clean state. Added missing project references (`ManagedCommon`, `LightSwitchModuleInterface`) with `ReferenceOutputAssembly=false` to ensure proper build ordering, and added existence check for the native DLL copy operation. ### Developer Experience - Added `setup-dev-environment.ps1` script to automate development environment setup. - Added `clean-artifacts.ps1` script to resolve build errors from corrupted build state or missing image files. - Added build script that allows standalone command line build of the Awake module. - Added troubleshooting section to `doc/devdocs/development/debugging.md` with guidance on resolving common build errors. --- .github/actions/spell-check/expect.txt | 4 + .gitignore | 1 + doc/devdocs/development/debugging.md | 37 ++ doc/devdocs/readme.md | 52 +- doc/planning/awake.md | 19 +- .../LightSwitch.UITests.csproj | 8 +- .../MouseUtils/FindMyMouse/dllmain.cpp | 154 ++++-- .../Awake.ModuleServices/AwakeService.cs | 12 +- src/modules/awake/Awake/Core/Constants.cs | 2 +- .../awake/Awake/Core/ExtensionMethods.cs | 13 +- src/modules/awake/Awake/Core/Manager.cs | 126 +++-- .../awake/Awake/Core/Native/Constants.cs | 6 + .../SingleThreadSynchronizationContext.cs | 8 +- src/modules/awake/Awake/Core/TrayHelper.cs | 107 +++- src/modules/awake/Awake/Program.cs | 54 ++- .../Awake/Properties/Resources.Designer.cs | 90 ++-- .../awake/Awake/Properties/Resources.resx | 40 +- src/modules/awake/README.md | 168 +++++++ src/modules/awake/scripts/Build-Awake.ps1 | 456 ++++++++++++++++++ src/runner/settings_window.cpp | 5 +- tools/build/clean-artifacts.ps1 | 84 ++++ tools/build/setup-dev-environment.ps1 | 291 +++++++++++ 22 files changed, 1506 insertions(+), 231 deletions(-) create mode 100644 src/modules/awake/README.md create mode 100644 src/modules/awake/scripts/Build-Awake.ps1 create mode 100644 tools/build/clean-artifacts.ps1 create mode 100644 tools/build/setup-dev-environment.ps1 diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 32da7e7842..084bf5cf39 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -64,6 +64,9 @@ apidl APIENTRY APIIs Apm +APMPOWERSTATUSCHANGE +APMRESUMEAUTOMATIC +APMRESUMESUSPEND APPBARDATA APPEXECLINK appext @@ -1345,6 +1348,7 @@ Pomodoro Popups POPUPWINDOW POSITIONITEM +POWERBROADCAST POWERRENAMECONTEXTMENU powerrenameinput POWERRENAMETEST diff --git a/.gitignore b/.gitignore index 1ed1bbcbc1..001a59ebae 100644 --- a/.gitignore +++ b/.gitignore @@ -359,3 +359,4 @@ src/common/Telemetry/*.etl # PowerToysInstaller Build Temp Files installer/*/*.wxs.bk +/src/modules/awake/.claude diff --git a/doc/devdocs/development/debugging.md b/doc/devdocs/development/debugging.md index 3756dd9396..37242cf11a 100644 --- a/doc/devdocs/development/debugging.md +++ b/doc/devdocs/development/debugging.md @@ -96,3 +96,40 @@ The Shell Process Debugging Tool is a Visual Studio extension that helps debug m - Logs are stored in the local app directory: `%LOCALAPPDATA%\Microsoft\PowerToys` - Check Event Viewer for application crashes related to `PowerToys.Settings.exe` - Crash dumps can be obtained from Event Viewer + +## Troubleshooting Build Errors + +### Missing Image Files or Corrupted Build State + +If you encounter build errors about missing image files (e.g., `.png`, `.ico`, or other assets), this typically indicates a corrupted build state. To resolve: + +1. **Clean the solution in Visual Studio**: Build > Clean Solution + + Or from the command line (Developer Command Prompt for VS 2022): + ```pwsh + msbuild PowerToys.slnx /t:Clean /p:Platform=x64 /p:Configuration=Debug + ``` + +2. **Delete build output and package folders** from the repository root: + - `x64/` + - `ARM64/` + - `Debug/` + - `Release/` + - `packages/` + +3. **Rebuild the solution** + +#### Helper Script + +A PowerShell script is available to automate this cleanup: + +```pwsh +.\tools\build\clean-artifacts.ps1 +``` + +This script will run MSBuild Clean and remove the build folders listed above. Use `-SkipMSBuildClean` if you only want to delete the folders without running MSBuild Clean. + +After cleaning, rebuild with: +```pwsh +msbuild -restore -p:RestorePackagesConfig=true -p:Platform=x64 -m PowerToys.slnx +``` diff --git a/doc/devdocs/readme.md b/doc/devdocs/readme.md index a6ac800be9..f70299f8d7 100644 --- a/doc/devdocs/readme.md +++ b/doc/devdocs/readme.md @@ -83,14 +83,40 @@ Once you've discussed your proposed feature/fix/etc. with a team member, and an 1. A local clone of the PowerToys repository 1. Enable long paths in Windows (see [Enable Long Paths](https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation#enabling-long-paths-in-windows-10-version-1607-and-later) for details) -### Install Visual Studio dependencies +### Automated Setup (Recommended) + +Run the setup script to automatically configure your development environment: + +```powershell +.\tools\build\setup-dev-environment.ps1 +``` + +This script will: +- Enable Windows long path support (requires administrator privileges) +- Enable Windows Developer Mode (requires administrator privileges) +- Guide you through installing required Visual Studio components from `.vsconfig` +- Initialize git submodules + +Run with `-Help` to see all available options: + +```powershell +.\tools\build\setup-dev-environment.ps1 -Help +``` + +### Manual Setup + +If you prefer to set up manually, follow these steps: + +#### Install Visual Studio dependencies 1. Open the `PowerToys.slnx` file. 1. If you see a dialog that says `install extra components` in the solution explorer pane, click `install` -### Get Submodules to compile +Alternatively, import the `.vsconfig` file from the repository root using Visual Studio Installer to install all required workloads. -We have submodules that need to be initialized before you can compile most parts of PowerToys. This should be a one-time step. +#### Get Submodules to compile + +We have submodules that need to be initialized before you can compile most parts of PowerToys. This should be a one-time step. 1. Open a terminal 1. Navigate to the folder you cloned PowerToys to. @@ -98,12 +124,32 @@ We have submodules that need to be initialized before you can compile most parts ### Compiling Source Code +#### Using Visual Studio + - Open `PowerToys.slnx` in Visual Studio. - In the `Solutions Configuration` drop-down menu select `Release` or `Debug`. - From the `Build` menu choose `Build Solution`, or press Control+Shift+b on your keyboard. - The build process may take several minutes depending on your computer's performance. Once it completes, the PowerToys binaries will be in your repo under `x64\Release\`. - You can run `x64\Release\PowerToys.exe` directly without installing PowerToys, but some modules (i.e. PowerRename, ImageResizer, File Explorer extension etc.) will not be available unless you also build the installer and install PowerToys. +#### Using Command Line + +You can also build from the command line using the provided scripts in `tools\build\`: + +```powershell +# Build the full solution (auto-detects platform) +.\tools\build\build.ps1 + +# Build with specific configuration +.\tools\build\build.ps1 -Platform x64 -Configuration Release + +# Build only essential projects (runner + settings) for faster iteration +.\tools\build\build-essentials.ps1 + +# Build everything including the installer (Release only) +.\tools\build\build-installer.ps1 +``` + ## Compile the installer Our installer is two parts, an EXE and an MSI. The EXE (Bootstrapper) contains the MSI and handles more complex installation logic. diff --git a/doc/planning/awake.md b/doc/planning/awake.md index d6ccd6808f..c2c9c7b2fa 100644 --- a/doc/planning/awake.md +++ b/doc/planning/awake.md @@ -1,5 +1,5 @@ --- -last-update: 7-16-2024 +last-update: 1-18-2026 --- # PowerToys Awake Changelog @@ -12,6 +12,7 @@ The build ID moniker is made up of two components - a reference to a [Halo](http | Build ID | Build Date | |:-------------------------------------------------------------------|:------------------| +| [`DIDACT_01182026`](#DIDACT_01182026-january-18-2026) | January 18, 2026 | | [`TILLSON_11272024`](#TILLSON_11272024-november-27-2024) | November 27, 2024 | | [`PROMETHEAN_09082024`](#PROMETHEAN_09082024-september-8-2024) | September 8, 2024 | | [`VISEGRADRELAY_08152024`](#VISEGRADRELAY_08152024-august-15-2024) | August 15, 2024 | @@ -20,6 +21,22 @@ The build ID moniker is made up of two components - a reference to a [Halo](http | [`LIBRARIAN_03202022`](#librarian_03202022-march-20-2022) | March 20, 2022 | | `ARBITER_01312022` | January 31, 2022 | +### `DIDACT_01182026` (January 18, 2026) + +>[!NOTE] +>See pull request: [Awake - `DIDACT_01182026`](https://github.com/microsoft/PowerToys/pull/44795) + +- [#32544](https://github.com/microsoft/PowerToys/issues/32544) Fixed an issue where Awake settings became non-functional after the PC wakes from sleep. Added `WM_POWERBROADCAST` handling to detect system resume events (`PBT_APMRESUMEAUTOMATIC`, `PBT_APMRESUMESUSPEND`) and re-apply `SetThreadExecutionState` to restore the awake state. +- [#36150](https://github.com/microsoft/PowerToys/issues/36150) Fixed an issue where Awake would not prevent sleep when AC power is connected. Added `PBT_APMPOWERSTATUSCHANGE` handling to re-apply `SetThreadExecutionState` when the power source changes (AC/battery transitions). +- Fixed an issue where toggling "Keep screen on" during an active timed session would disrupt the countdown timer. The display setting now updates directly without restarting the timer, preserving the exact remaining time. +- [#41918](https://github.com/microsoft/PowerToys/issues/41918) Fixed `WM_COMMAND` message processing flaw in `TrayHelper.WndProc` that incorrectly compared enum values against enum count. Added proper bounds checking for custom tray time entries. +- Investigated [#44134](https://github.com/microsoft/PowerToys/issues/44134) - documented that `ES_DISPLAY_REQUIRED` (used when "Keep display on" is enabled) blocks Task Scheduler idle detection, preventing scheduled maintenance tasks like SSD TRIM. Workaround: disable "Keep display on" or manually run `Optimize-Volume -DriveLetter C -ReTrim`. Additional investigation needed for potential "idle window" feature. +- [#41738](https://github.com/microsoft/PowerToys/issues/41738) Fixed `--display-on` CLI flag default from `true` to `false` to align with documentation and PowerToys settings behavior. This is a breaking change for scripts relying on the undocumented default. +- [#41674](https://github.com/microsoft/PowerToys/issues/41674) Fixed silent failure when `SetThreadExecutionState` fails. The monitor thread now handles the return value, logs an error, and reverts to passive mode with updated tray icon. +- [#38770](https://github.com/microsoft/PowerToys/issues/38770) Fixed tray icon failing to appear after Windows updates. Increased retry attempts and delays for icon Add operations (10 attempts, up to ~15.5 seconds total) while keeping existing fast retry behavior for Update/Delete operations. +- [#40501](https://github.com/microsoft/PowerToys/issues/40501) Fixed tray icon not disappearing when Awake is disabled. The `SetShellIcon` function was incorrectly requiring an icon for Delete operations, causing the `NIM_DELETE` message to never be sent. +- [#40659](https://github.com/microsoft/PowerToys/issues/40659) Fixed potential stack overflow crash in EXPIRABLE mode. Added early return after SaveSettings when correcting past expiration times, matching the pattern used by other mode handlers to prevent reentrant execution. + ### `TILLSON_11272024` (November 27, 2024) >[!NOTE] diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj index f457025c0e..a1ec81d30c 100644 --- a/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj @@ -18,10 +18,16 @@ + + false + + + false + - + \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/dllmain.cpp b/src/modules/MouseUtils/FindMyMouse/dllmain.cpp index af99f45136..52b79074c8 100644 --- a/src/modules/MouseUtils/FindMyMouse/dllmain.cpp +++ b/src/modules/MouseUtils/FindMyMouse/dllmain.cpp @@ -238,12 +238,30 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { auto settingsObject = settings.get_raw_json(); FindMyMouseSettings findMyMouseSettings; - if (settingsObject.GetView().Size()) + + if (!settingsObject.GetView().Size()) + { + Logger::info("Find My Mouse settings are empty"); + m_findMyMouseSettings = findMyMouseSettings; + return; + } + + // Early exit if no properties object exists + if (!settingsObject.HasKey(JSON_KEY_PROPERTIES)) + { + Logger::info("Find My Mouse settings have no properties"); + m_findMyMouseSettings = findMyMouseSettings; + return; + } + + auto properties = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + + // Parse Activation Method + if (properties.HasKey(JSON_KEY_ACTIVATION_METHOD)) { try { - // Parse Activation Method - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_METHOD); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ACTIVATION_METHOD); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value < static_cast(FindMyMouseActivationMethod::EnumElements) && value >= 0) { @@ -266,34 +284,50 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Activation Method from settings. Will use default value"); } + } + + // Parse Include Win Key + if (properties.HasKey(JSON_KEY_INCLUDE_WIN_KEY)) + { try { - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_INCLUDE_WIN_KEY); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_INCLUDE_WIN_KEY); findMyMouseSettings.includeWinKey = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); } catch (...) { Logger::warn("Failed to get 'include windows key with ctrl' setting"); } + } + + // Parse Do Not Activate On Game Mode + if (properties.HasKey(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE)) + { try { - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE); findMyMouseSettings.doNotActivateOnGameMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); } catch (...) { Logger::warn("Failed to get 'do not activate on game mode' setting"); } - // Colors + legacy overlay opacity migration - // Desired behavior: - // - Old schema: colors stored as RGB (no alpha) + separate overlay_opacity (0-100). We should migrate by applying that opacity as alpha. - // - New schema: colors stored as ARGB (alpha embedded). Ignore overlay_opacity even if still present. - int legacyOverlayOpacity = -1; - bool backgroundColorHadExplicitAlpha = false; - bool spotlightColorHadExplicitAlpha = false; + } + + // Colors + legacy overlay opacity migration + // Desired behavior: + // - Old schema: colors stored as RGB (no alpha) + separate overlay_opacity (0-100). We should migrate by applying that opacity as alpha. + // - New schema: colors stored as ARGB (alpha embedded). Ignore overlay_opacity even if still present. + int legacyOverlayOpacity = -1; + bool backgroundColorHadExplicitAlpha = false; + bool spotlightColorHadExplicitAlpha = false; + + // Parse Legacy Overlay Opacity (may not exist in newer settings) + if (properties.HasKey(JSON_KEY_OVERLAY_OPACITY)) + { try { - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_OVERLAY_OPACITY); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_OVERLAY_OPACITY); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0 && value <= 100) { @@ -302,11 +336,16 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) } catch (...) { - // overlay_opacity may not exist anymore + // overlay_opacity may have invalid data } + } + + // Parse Background Color + if (properties.HasKey(JSON_KEY_BACKGROUND_COLOR)) + { try { - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_BACKGROUND_COLOR); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_BACKGROUND_COLOR); auto backgroundColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); uint8_t a = 255, r, g, b; bool parsed = false; @@ -333,9 +372,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize background color from settings. Will use default value"); } + } + + // Parse Spotlight Color + if (properties.HasKey(JSON_KEY_SPOTLIGHT_COLOR)) + { try { - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_COLOR); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_COLOR); auto spotlightColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); uint8_t a = 255, r, g, b; bool parsed = false; @@ -362,10 +406,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize spotlight color from settings. Will use default value"); } + } + + // Parse Spotlight Radius + if (properties.HasKey(JSON_KEY_SPOTLIGHT_RADIUS)) + { try { - // Parse Spotlight Radius - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_RADIUS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_RADIUS); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -380,10 +428,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Spotlight Radius from settings. Will use default value"); } + } + + // Parse Animation Duration + if (properties.HasKey(JSON_KEY_ANIMATION_DURATION_MS)) + { try { - // Parse Animation Duration - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ANIMATION_DURATION_MS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ANIMATION_DURATION_MS); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -398,10 +450,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Animation Duration from settings. Will use default value"); } + } + + // Parse Spotlight Initial Zoom + if (properties.HasKey(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM)) + { try { - // Parse Spotlight Initial Zoom - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -416,10 +472,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Spotlight Initial Zoom from settings. Will use default value"); } + } + + // Parse Excluded Apps + if (properties.HasKey(JSON_KEY_EXCLUDED_APPS)) + { try { - // Parse Excluded Apps - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_EXCLUDED_APPS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_EXCLUDED_APPS); std::wstring apps = jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE).c_str(); std::vector excludedApps; auto excludedUppercase = apps; @@ -441,10 +501,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Excluded Apps from settings. Will use default value"); } + } + + // Parse Shaking Minimum Distance + if (properties.HasKey(JSON_KEY_SHAKING_MINIMUM_DISTANCE)) + { try { - // Parse Shaking Minimum Distance - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_MINIMUM_DISTANCE); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_MINIMUM_DISTANCE); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -459,10 +523,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Shaking Minimum Distance from settings. Will use default value"); } + } + + // Parse Shaking Interval Milliseconds + if (properties.HasKey(JSON_KEY_SHAKING_INTERVAL_MS)) + { try { - // Parse Shaking Interval Milliseconds - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_INTERVAL_MS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_INTERVAL_MS); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -477,10 +545,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Shaking Interval Milliseconds from settings. Will use default value"); } + } + + // Parse Shaking Factor + if (properties.HasKey(JSON_KEY_SHAKING_FACTOR)) + { try { - // Parse Shaking Factor - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_FACTOR); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_FACTOR); int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -495,11 +567,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Shaking Factor from settings. Will use default value"); } + } + // Parse HotKey + if (properties.HasKey(JSON_KEY_ACTIVATION_SHORTCUT)) + { try { - // Parse HotKey - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); m_hotkey = HotkeyEx(); if (hotkey.win_pressed()) @@ -528,18 +603,15 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Activation Shortcut from settings. Will use default value"); } + } - if (!m_hotkey.modifiersMask) - { - Logger::info("Using default Activation Shortcut"); - m_hotkey.modifiersMask = MOD_SHIFT | MOD_WIN; - m_hotkey.vkCode = 0x46; // F key - } - } - else + if (!m_hotkey.modifiersMask) { - Logger::info("Find My Mouse settings are empty"); + Logger::info("Using default Activation Shortcut"); + m_hotkey.modifiersMask = MOD_SHIFT | MOD_WIN; + m_hotkey.vkCode = 0x46; // F key } + m_findMyMouseSettings = findMyMouseSettings; } diff --git a/src/modules/awake/Awake.ModuleServices/AwakeService.cs b/src/modules/awake/Awake.ModuleServices/AwakeService.cs index f783cdc3db..0ce08fa0c0 100644 --- a/src/modules/awake/Awake.ModuleServices/AwakeService.cs +++ b/src/modules/awake/Awake.ModuleServices/AwakeService.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Text.Json; using Common.UI; +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using PowerToys.ModuleContracts; @@ -82,10 +83,9 @@ public sealed class AwakeService : ModuleServiceBase, IAwakeService return UpdateSettingsAsync( settings => { - var totalMinutes = Math.Min(minutes, int.MaxValue); settings.Properties.Mode = AwakeMode.TIMED; - settings.Properties.IntervalHours = (uint)(totalMinutes / 60); - settings.Properties.IntervalMinutes = (uint)(totalMinutes % 60); + settings.Properties.IntervalHours = (uint)(minutes / 60); + settings.Properties.IntervalMinutes = (uint)(minutes % 60); }, cancellationToken); } @@ -130,8 +130,9 @@ public sealed class AwakeService : ModuleServiceBase, IAwakeService { return Process.GetProcessesByName("PowerToys.Awake").Length > 0; } - catch + catch (Exception ex) { + Logger.LogError($"Failed to check Awake process status: {ex.Message}"); return false; } } @@ -143,8 +144,9 @@ public sealed class AwakeService : ModuleServiceBase, IAwakeService var settingsUtils = SettingsUtils.Default; return settingsUtils.GetSettingsOrDefault(AwakeSettings.ModuleName); } - catch + catch (Exception ex) { + Logger.LogError($"Failed to read Awake settings: {ex.Message}"); return null; } } diff --git a/src/modules/awake/Awake/Core/Constants.cs b/src/modules/awake/Awake/Core/Constants.cs index d6864712ee..7e9981e45d 100644 --- a/src/modules/awake/Awake/Core/Constants.cs +++ b/src/modules/awake/Awake/Core/Constants.cs @@ -17,6 +17,6 @@ namespace Awake.Core // Format of the build ID is: CODENAME_MMDDYYYY, where MMDDYYYY // is representative of the date when the last change was made before // the pull request is issued. - internal const string BuildId = "TILLSON_11272024"; + internal const string BuildId = "DIDACT_01182026"; } } diff --git a/src/modules/awake/Awake/Core/ExtensionMethods.cs b/src/modules/awake/Awake/Core/ExtensionMethods.cs index 626b9c6443..b07d632420 100644 --- a/src/modules/awake/Awake/Core/ExtensionMethods.cs +++ b/src/modules/awake/Awake/Core/ExtensionMethods.cs @@ -22,14 +22,13 @@ namespace Awake.Core public static string ToHumanReadableString(this TimeSpan timeSpan) { - // Get days, hours, minutes, and seconds from the TimeSpan - int days = timeSpan.Days; - int hours = timeSpan.Hours; - int minutes = timeSpan.Minutes; - int seconds = timeSpan.Seconds; + // Format as H:MM:SS or M:SS depending on total hours + if (timeSpan.TotalHours >= 1) + { + return $"{(int)timeSpan.TotalHours}:{timeSpan.Minutes:D2}:{timeSpan.Seconds:D2}"; + } - // Format the string based on the presence of days, hours, minutes, and seconds - return $"{days:D2}{Properties.Resources.AWAKE_LABEL_DAYS} {hours:D2}{Properties.Resources.AWAKE_LABEL_HOURS} {minutes:D2}{Properties.Resources.AWAKE_LABEL_MINUTES} {seconds:D2}{Properties.Resources.AWAKE_LABEL_SECONDS}"; + return $"{timeSpan.Minutes}:{timeSpan.Seconds:D2}"; } } } diff --git a/src/modules/awake/Awake/Core/Manager.cs b/src/modules/awake/Awake/Core/Manager.cs index cc3e461b20..3849d43268 100644 --- a/src/modules/awake/Awake/Core/Manager.cs +++ b/src/modules/awake/Awake/Core/Manager.cs @@ -37,7 +37,7 @@ namespace Awake.Core internal static SettingsUtils? ModuleSettings { get; set; } - private static AwakeMode CurrentOperatingMode { get; set; } + internal static AwakeMode CurrentOperatingMode { get; private set; } private static bool IsDisplayOn { get; set; } @@ -54,11 +54,12 @@ namespace Awake.Core private static readonly CompositeFormat AwakeHour = CompositeFormat.Parse(Resources.AWAKE_HOUR); private static readonly CompositeFormat AwakeHours = CompositeFormat.Parse(Resources.AWAKE_HOURS); private static readonly BlockingCollection _stateQueue; - private static CancellationTokenSource _tokenSource; + private static CancellationTokenSource _monitorTokenSource; + private static IDisposable? _timerSubscription; static Manager() { - _tokenSource = new CancellationTokenSource(); + _monitorTokenSource = new CancellationTokenSource(); _stateQueue = []; ModuleSettings = SettingsUtils.Default; } @@ -68,18 +69,36 @@ namespace Awake.Core Thread monitorThread = new(() => { Thread.CurrentThread.IsBackground = false; - while (true) + try { - ExecutionState state = _stateQueue.Take(); + while (!_monitorTokenSource.Token.IsCancellationRequested) + { + ExecutionState state = _stateQueue.Take(_monitorTokenSource.Token); - Logger.LogInfo($"Setting state to {state}"); + Logger.LogInfo($"Setting state to {state}"); - SetAwakeState(state); + if (!SetAwakeState(state)) + { + Logger.LogError($"Failed to set execution state to {state}. Reverting to passive mode."); + CurrentOperatingMode = AwakeMode.PASSIVE; + SetModeShellIcon(); + } + } + } + catch (OperationCanceledException) + { + Logger.LogInfo("Monitor thread received cancellation signal. Exiting gracefully."); } }); monitorThread.Start(); } + internal static void StopMonitor() + { + _monitorTokenSource.Cancel(); + _monitorTokenSource.Dispose(); + } + internal static void SetConsoleControlHandler(ConsoleEventHandler handler, bool addHandler) { Bridge.SetConsoleCtrlHandler(handler, addHandler); @@ -110,8 +129,9 @@ namespace Awake.Core ExecutionState stateResult = Bridge.SetThreadExecutionState(state); return stateResult != 0; } - catch + catch (Exception ex) { + Logger.LogError($"Failed to set awake state to {state}: {ex.Message}"); return false; } } @@ -123,26 +143,34 @@ namespace Awake.Core : ExecutionState.ES_SYSTEM_REQUIRED | ExecutionState.ES_CONTINUOUS; } + /// + /// Re-applies the current awake state after a power event. + /// Called when WM_POWERBROADCAST indicates system wake or power source change. + /// + internal static void ReapplyAwakeState() + { + if (CurrentOperatingMode == AwakeMode.PASSIVE) + { + // No need to reapply in passive mode + return; + } + + Logger.LogInfo($"Power event received. Reapplying awake state for mode: {CurrentOperatingMode}"); + _stateQueue.Add(ComputeAwakeState(IsDisplayOn)); + } + internal static void CancelExistingThread() { - Logger.LogInfo("Ensuring the thread is properly cleaned up..."); + Logger.LogInfo("Canceling existing timer and resetting state..."); - // Reset the thread state and handle cancellation. + // Reset the thread state. _stateQueue.Add(ExecutionState.ES_CONTINUOUS); - if (_tokenSource != null) - { - _tokenSource.Cancel(); - _tokenSource.Dispose(); - } - else - { - Logger.LogWarning("Token source is null."); - } + // Dispose the timer subscription to stop any running timer. + _timerSubscription?.Dispose(); + _timerSubscription = null; - _tokenSource = new CancellationTokenSource(); - - Logger.LogInfo("New token source and thread token instantiated."); + Logger.LogInfo("Timer subscription disposed."); } internal static void SetModeShellIcon(bool forceAdd = false) @@ -153,25 +181,25 @@ namespace Awake.Core switch (CurrentOperatingMode) { case AwakeMode.INDEFINITE: - string processText = ProcessId == 0 + string pidLine = ProcessId == 0 ? string.Empty - : $" - {Resources.AWAKE_TRAY_TEXT_PID_BINDING}: {ProcessId}"; - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_INDEFINITE}{processText}][{ScreenStateString}]"; + : $"\nPID: {ProcessId}"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_INDEFINITE}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}{pidLine}"; icon = TrayHelper.IndefiniteIcon; break; case AwakeMode.PASSIVE: - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_OFF}]"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_SCREEN_OFF}"; icon = TrayHelper.DisabledIcon; break; case AwakeMode.EXPIRABLE: - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_EXPIRATION}][{ScreenStateString}][{ExpireAt:yyyy-MM-dd HH:mm:ss}]"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_UNTIL} {ExpireAt:MMM d, h:mm tt}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}"; icon = TrayHelper.ExpirableIcon; break; case AwakeMode.TIMED: - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}]"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_TIMED}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}"; icon = TrayHelper.TimedIcon; break; } @@ -280,9 +308,8 @@ namespace Awake.Core TimeSpan remainingTime = expireAt - DateTimeOffset.Now; - Observable.Timer(remainingTime).Subscribe( - _ => HandleTimerCompletion("expirable"), - _tokenSource.Token); + _timerSubscription = Observable.Timer(remainingTime).Subscribe( + _ => HandleTimerCompletion("expirable")); } internal static void SetTimedKeepAwake(uint seconds, bool keepDisplayOn = true, [CallerMemberName] string callerName = "") @@ -300,6 +327,8 @@ namespace Awake.Core TimeSpan timeSpan = TimeSpan.FromSeconds(seconds); uint totalHours = (uint)timeSpan.TotalHours; + + // Round up partial minutes to prevent timer from expiring before intended duration uint remainingMinutes = (uint)Math.Ceiling(timeSpan.TotalMinutes % 60); bool settingsChanged = currentSettings.Properties.Mode != AwakeMode.TIMED || @@ -336,7 +365,7 @@ namespace Awake.Core var targetExpiryTime = DateTimeOffset.Now.AddSeconds(seconds); - Observable.Interval(TimeSpan.FromSeconds(1)) + _timerSubscription = Observable.Interval(TimeSpan.FromSeconds(1)) .Select(_ => targetExpiryTime - DateTimeOffset.Now) .TakeWhile(remaining => remaining.TotalSeconds > 0) .Subscribe( @@ -346,12 +375,11 @@ namespace Awake.Core TrayHelper.SetShellIcon( TrayHelper.WindowHandle, - $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}][{remainingTimeSpan.ToHumanReadableString()}]", + $"{Constants.FullAppName}\n{remainingTimeSpan.ToHumanReadableString()} {Resources.AWAKE_TRAY_REMAINING}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}", TrayHelper.TimedIcon, TrayIconAction.Update); }, - () => HandleTimerCompletion("timed"), - _tokenSource.Token); + () => HandleTimerCompletion("timed")); } /// @@ -384,6 +412,16 @@ namespace Awake.Core { SetPassiveKeepAwake(updateSettings: false); + // Stop the monitor thread gracefully + StopMonitor(); + + // Dispose the timer subscription + _timerSubscription?.Dispose(); + _timerSubscription = null; + + // Dispose tray icons + TrayHelper.DisposeIcons(); + if (TrayHelper.WindowHandle != IntPtr.Zero) { // Delete the icon. @@ -496,15 +534,21 @@ namespace Awake.Core AwakeSettings currentSettings = ModuleSettings!.GetSettings(Constants.AppName) ?? new AwakeSettings(); currentSettings.Properties.KeepDisplayOn = !currentSettings.Properties.KeepDisplayOn; - // We want to make sure that if the display setting changes (e.g., through the tray) - // then we do not reset the counter from zero. Because the settings are only storing - // hours and minutes, we round up the minutes value up when changes occur. + // For TIMED mode: update state directly without restarting timer + // This preserves the existing timer Observable subscription and targetExpiryTime if (CurrentOperatingMode == AwakeMode.TIMED && TimeRemaining > 0) { - TimeSpan timeSpan = TimeSpan.FromSeconds(TimeRemaining); + // Update internal state + IsDisplayOn = currentSettings.Properties.KeepDisplayOn; - currentSettings.Properties.IntervalHours = (uint)timeSpan.TotalHours; - currentSettings.Properties.IntervalMinutes = (uint)Math.Ceiling(timeSpan.TotalMinutes % 60); + // Update execution state without canceling timer + _stateQueue.Add(ComputeAwakeState(IsDisplayOn)); + + // Save settings - ProcessSettings will skip reinitialization + // since we're already in TIMED mode + ModuleSettings!.SaveSettings(JsonSerializer.Serialize(currentSettings), Constants.AppName); + + return; } ModuleSettings!.SaveSettings(JsonSerializer.Serialize(currentSettings), Constants.AppName); diff --git a/src/modules/awake/Awake/Core/Native/Constants.cs b/src/modules/awake/Awake/Core/Native/Constants.cs index 156c3a9da2..781a2413a1 100644 --- a/src/modules/awake/Awake/Core/Native/Constants.cs +++ b/src/modules/awake/Awake/Core/Native/Constants.cs @@ -15,6 +15,12 @@ namespace Awake.Core.Native internal const int WM_DESTROY = 0x0002; internal const int WM_LBUTTONDOWN = 0x0201; internal const int WM_RBUTTONDOWN = 0x0204; + internal const uint WM_POWERBROADCAST = 0x0218; + + // Power Broadcast Event Types + internal const int PBT_APMRESUMEAUTOMATIC = 0x0012; + internal const int PBT_APMRESUMESUSPEND = 0x0007; + internal const int PBT_APMPOWERSTATUSCHANGE = 0x000A; // Menu Flags internal const uint MF_BYPOSITION = 1024; diff --git a/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs b/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs index 04c28dfd34..63aa9cbc12 100644 --- a/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs +++ b/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs @@ -11,7 +11,7 @@ namespace Awake.Core.Threading { internal sealed class SingleThreadSynchronizationContext : SynchronizationContext { - private readonly Queue?> queue = new(); + private readonly Queue<(SendOrPostCallback Callback, object? State)?> queue = new(); public override void Post(SendOrPostCallback d, object? state) { @@ -19,7 +19,7 @@ namespace Awake.Core.Threading lock (queue) { - queue.Enqueue(Tuple.Create(d, state)); + queue.Enqueue((d, state)); Monitor.Pulse(queue); } } @@ -28,7 +28,7 @@ namespace Awake.Core.Threading { while (true) { - Tuple? work; + (SendOrPostCallback Callback, object? State)? work; lock (queue) { while (queue.Count == 0) @@ -46,7 +46,7 @@ namespace Awake.Core.Threading try { - work.Item1(work.Item2); + work.Value.Callback(work.Value.State); } catch (Exception e) { diff --git a/src/modules/awake/Awake/Core/TrayHelper.cs b/src/modules/awake/Awake/Core/TrayHelper.cs index 16cd5f5795..142bb78720 100644 --- a/src/modules/awake/Awake/Core/TrayHelper.cs +++ b/src/modules/awake/Awake/Core/TrayHelper.cs @@ -45,12 +45,26 @@ namespace Awake.Core internal static readonly Icon IndefiniteIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/indefinite.ico")); internal static readonly Icon DisabledIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/disabled.ico")); + private const int TrayIconId = 1000; + static TrayHelper() { TrayMenu = IntPtr.Zero; WindowHandle = IntPtr.Zero; } + /// + /// Disposes of all icon resources to prevent GDI handle leaks. + /// + internal static void DisposeIcons() + { + DefaultAwakeIcon?.Dispose(); + TimedIcon?.Dispose(); + ExpirableIcon?.Dispose(); + IndefiniteIcon?.Dispose(); + DisabledIcon?.Dispose(); + } + private static void ShowContextMenu(IntPtr hWnd) { if (TrayMenu == IntPtr.Zero) @@ -172,7 +186,11 @@ namespace Awake.Core internal static void SetShellIcon(IntPtr hWnd, string text, Icon? icon, TrayIconAction action = TrayIconAction.Add, [CallerMemberName] string callerName = "") { - if (hWnd != IntPtr.Zero && icon != null) + // For Delete operations, we don't need an icon - only hWnd is required + // For Add/Update operations, we need both hWnd and icon + bool canProceed = hWnd != IntPtr.Zero && (action == TrayIconAction.Delete || icon != null); + + if (canProceed) { int message = Native.Constants.NIM_ADD; @@ -195,7 +213,7 @@ namespace Awake.Core { CbSize = Marshal.SizeOf(), HWnd = hWnd, - UId = 1000, + UId = TrayIconId, UFlags = Native.Constants.NIF_ICON | Native.Constants.NIF_TIP | Native.Constants.NIF_MESSAGE, UCallbackMessage = (int)Native.Constants.WM_USER, HIcon = icon?.Handle ?? IntPtr.Zero, @@ -208,29 +226,54 @@ namespace Awake.Core { CbSize = Marshal.SizeOf(), HWnd = hWnd, - UId = 1000, + UId = TrayIconId, UFlags = 0, }; } - for (int attempt = 1; attempt <= 3; attempt++) + // Retry configuration based on action type + // Add operations need longer delays as Explorer may still be initializing after Windows updates + int maxRetryAttempts; + int baseDelayMs; + + if (action == TrayIconAction.Add) + { + maxRetryAttempts = 10; + baseDelayMs = 500; // 500, 1000, 2000, 2000, 2000... (capped) + } + else + { + maxRetryAttempts = 3; + baseDelayMs = 100; // 100, 200, 400 (existing behavior) + } + + const int maxDelayMs = 2000; // Cap delay at 2 seconds + + for (int attempt = 1; attempt <= maxRetryAttempts; attempt++) { if (Bridge.Shell_NotifyIcon(message, ref _notifyIconData)) { + if (attempt > 1) + { + Logger.LogInfo($"Successfully set shell icon on attempt {attempt}. Action: {action}"); + } + break; } else { int errorCode = Marshal.GetLastWin32Error(); - Logger.LogInfo($"Could not set the shell icon. Action: {action}, error code: {errorCode}. HIcon handle is {icon?.Handle} and HWnd is {hWnd}. Invoked by {callerName}."); + Logger.LogInfo($"Could not set the shell icon. Action: {action}, error code: {errorCode}, attempt: {attempt}/{maxRetryAttempts}. HIcon handle is {icon?.Handle} and HWnd is {hWnd}. Invoked by {callerName}."); - if (attempt == 3) + if (attempt == maxRetryAttempts) { - Logger.LogError($"Failed to change tray icon after 3 attempts. Action: {action} and error code: {errorCode}. Invoked by {callerName}."); + Logger.LogError($"Failed to change tray icon after {maxRetryAttempts} attempts. Action: {action} and error code: {errorCode}. Invoked by {callerName}."); break; } - Thread.Sleep(100); + // Exponential backoff with cap + int delayMs = Math.Min(baseDelayMs * (1 << (attempt - 1)), maxDelayMs); + Thread.Sleep(delayMs); } } @@ -241,7 +284,7 @@ namespace Awake.Core } else { - Logger.LogInfo($"Cannot set the shell icon - parent window handle is zero or icon is not available. Text: {text} Action: {action}"); + Logger.LogInfo($"Cannot set the shell icon - parent window handle is zero{(action != TrayIconAction.Delete && icon == null ? " or icon is not available" : string.Empty)}. Text: {text} Action: {action}"); } } @@ -280,11 +323,9 @@ namespace Awake.Core Bridge.PostQuitMessage(0); break; case Native.Constants.WM_COMMAND: - int trayCommandsSize = Enum.GetNames().Length; + long targetCommandValue = wParam.ToInt64() & 0xFFFF; - long targetCommandIndex = wParam.ToInt64() & 0xFFFF; - - switch (targetCommandIndex) + switch (targetCommandValue) { case (uint)TrayCommands.TC_EXIT: { @@ -300,7 +341,7 @@ namespace Awake.Core case (uint)TrayCommands.TC_MODE_INDEFINITE: { - AwakeSettings settings = Manager.ModuleSettings!.GetSettings(Constants.AppName); + AwakeSettings settings = Manager.ModuleSettings!.GetSettings(Constants.AppName) ?? new AwakeSettings(); Manager.SetIndefiniteKeepAwake(keepDisplayOn: settings.Properties.KeepDisplayOn); break; } @@ -313,23 +354,43 @@ namespace Awake.Core default: { - if (targetCommandIndex >= trayCommandsSize) + // Custom tray time commands start at TC_TIME and increment by 1 for each entry. + // Check if this command falls within the custom time range. + if (targetCommandValue >= (uint)TrayCommands.TC_TIME) { - AwakeSettings settings = Manager.ModuleSettings!.GetSettings(Constants.AppName); + AwakeSettings settings = Manager.ModuleSettings!.GetSettings(Constants.AppName) ?? new AwakeSettings(); if (settings.Properties.CustomTrayTimes.Count == 0) { settings.Properties.CustomTrayTimes.AddRange(Manager.GetDefaultTrayOptions()); } - int index = (int)targetCommandIndex - (int)TrayCommands.TC_TIME; - uint targetTime = settings.Properties.CustomTrayTimes.ElementAt(index).Value; - Manager.SetTimedKeepAwake(targetTime, keepDisplayOn: settings.Properties.KeepDisplayOn); + int index = (int)targetCommandValue - (int)TrayCommands.TC_TIME; + + if (index >= 0 && index < settings.Properties.CustomTrayTimes.Count) + { + uint targetTime = settings.Properties.CustomTrayTimes.Values.Skip(index).First(); + Manager.SetTimedKeepAwake(targetTime, keepDisplayOn: settings.Properties.KeepDisplayOn); + } + else + { + Logger.LogError($"Custom tray time index {index} is out of range. Available entries: {settings.Properties.CustomTrayTimes.Count}"); + } } break; } } + break; + case Native.Constants.WM_POWERBROADCAST: + int eventType = wParam.ToInt32(); + if (eventType == Native.Constants.PBT_APMRESUMEAUTOMATIC || + eventType == Native.Constants.PBT_APMRESUMESUSPEND || + eventType == Native.Constants.PBT_APMPOWERSTATUSCHANGE) + { + Manager.ReapplyAwakeState(); + } + break; default: if (message == _taskbarCreatedMessage) @@ -357,7 +418,7 @@ namespace Awake.Core } catch (Exception e) { - Console.WriteLine("Error: " + e.Message); + Logger.LogError($"Error in tray thread execution: {e.Message}"); } }, null); @@ -439,9 +500,11 @@ namespace Awake.Core private static void CreateAwakeTimeSubMenu(Dictionary trayTimeShortcuts, bool isChecked = false) { nint awakeTimeMenu = Bridge.CreatePopupMenu(); - for (int i = 0; i < trayTimeShortcuts.Count; i++) + int i = 0; + foreach (var shortcut in trayTimeShortcuts) { - Bridge.InsertMenu(awakeTimeMenu, (uint)i, Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING, (uint)TrayCommands.TC_TIME + (uint)i, trayTimeShortcuts.ElementAt(i).Key); + Bridge.InsertMenu(awakeTimeMenu, (uint)i, Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING, (uint)TrayCommands.TC_TIME + (uint)i, shortcut.Key); + i++; } Bridge.InsertMenu(TrayMenu, 0, Native.Constants.MF_BYPOSITION | Native.Constants.MF_POPUP | (isChecked ? Native.Constants.MF_CHECKED : Native.Constants.MF_UNCHECKED), (uint)awakeTimeMenu, Resources.AWAKE_KEEP_ON_INTERVAL); diff --git a/src/modules/awake/Awake/Program.cs b/src/modules/awake/Awake/Program.cs index 452487f2bf..6b65e9eea3 100644 --- a/src/modules/awake/Awake/Program.cs +++ b/src/modules/awake/Awake/Program.cs @@ -39,18 +39,20 @@ namespace Awake private static FileSystemWatcher? _watcher; private static SettingsUtils? _settingsUtils; + private static EventWaitHandle? _exitEventHandle; + private static RegisteredWaitHandle? _registeredWaitHandle; private static bool _startedFromPowerToys; public static Mutex? LockMutex { get; set; } -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - private static ConsoleEventHandler _handler; + private static ConsoleEventHandler? _handler; private static SystemPowerCapabilities _powerCapabilities; -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. private static async Task Main(string[] args) { + Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs")); + var rootCommand = BuildRootCommand(); Bridge.AttachConsole(Core.Native.Constants.ATTACH_PARENT_PROCESS); @@ -73,8 +75,6 @@ namespace Awake LockMutex = new Mutex(true, Core.Constants.AppName, out bool instantiated); - Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs")); - try { string appLanguage = LanguageHelper.LoadLanguage(); @@ -140,7 +140,7 @@ namespace Awake IsRequired = false, }; - Option displayOption = new(_aliasesDisplayOption, () => true, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION) + Option displayOption = new(_aliasesDisplayOption, () => false, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION) { Arity = ArgumentArity.ZeroOrOne, IsRequired = false, @@ -235,10 +235,23 @@ namespace Awake private static void Exit(string message, int exitCode) { _etwTrace?.Dispose(); + DisposeFileSystemWatcher(); + _registeredWaitHandle?.Unregister(null); + _exitEventHandle?.Dispose(); Logger.LogInfo(message); Manager.CompleteExit(exitCode); } + private static void DisposeFileSystemWatcher() + { + if (_watcher != null) + { + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + _watcher = null; + } + } + private static bool ProcessExists(int processId) { if (processId <= 0) @@ -252,8 +265,15 @@ namespace Awake using var p = Process.GetProcessById(processId); return !p.HasExited; } - catch + catch (ArgumentException) { + // Process with the specified ID is not running + return false; + } + catch (InvalidOperationException ex) + { + // Process has exited or cannot be accessed + Logger.LogInfo($"Process {processId} cannot be accessed: {ex.Message}"); return false; } } @@ -282,12 +302,13 @@ namespace Awake // Start the monitor thread that will be used to track the current state. Manager.StartMonitor(); - EventWaitHandle eventHandle = new(false, EventResetMode.ManualReset, PowerToys.Interop.Constants.AwakeExitEvent()); - new Thread(() => - { - WaitHandle.WaitAny([eventHandle]); - Exit(Resources.AWAKE_EXIT_SIGNAL_MESSAGE, 0); - }).Start(); + _exitEventHandle = new EventWaitHandle(false, EventResetMode.ManualReset, PowerToys.Interop.Constants.AwakeExitEvent()); + _registeredWaitHandle = ThreadPool.RegisterWaitForSingleObject( + _exitEventHandle, + (state, timedOut) => Exit(Resources.AWAKE_EXIT_SIGNAL_MESSAGE, 0), + null, + Timeout.Infinite, + executeOnlyOnce: true); if (usePtConfig) { @@ -432,7 +453,7 @@ namespace Awake { Manager.AllocateConsole(); - _handler += new ConsoleEventHandler(ExitHandler); + _handler = new ConsoleEventHandler(ExitHandler); Manager.SetConsoleControlHandler(_handler, true); Trace.Listeners.Add(new ConsoleTraceListener()); @@ -528,6 +549,11 @@ namespace Awake { settings.Properties.ExpirationDateTime = DateTimeOffset.Now.AddMinutes(5); _settingsUtils.SaveSettings(JsonSerializer.Serialize(settings), Core.Constants.AppName); + + // Return here - the FileSystemWatcher will re-trigger ProcessSettings + // with the corrected expiration time, which will then call SetExpirableKeepAwake. + // This matches the pattern used by mode setters (e.g., SetExpirableKeepAwake line 292). + return; } Manager.SetExpirableKeepAwake(settings.Properties.ExpirationDateTime, settings.Properties.KeepDisplayOn); diff --git a/src/modules/awake/Awake/Properties/Resources.Designer.cs b/src/modules/awake/Awake/Properties/Resources.Designer.cs index a3aecd2627..27d77ddc2c 100644 --- a/src/modules/awake/Awake/Properties/Resources.Designer.cs +++ b/src/modules/awake/Awake/Properties/Resources.Designer.cs @@ -60,15 +60,6 @@ namespace Awake.Properties { } } - /// - /// Looks up a localized string similar to Checked. - /// - internal static string AWAKE_CHECKED { - get { - return ResourceManager.GetString("AWAKE_CHECKED", resourceCulture); - } - } - /// /// Looks up a localized string similar to Specifies whether Awake will be using the PowerToys configuration file for managing the state.. /// @@ -240,42 +231,6 @@ namespace Awake.Properties { } } - /// - /// Looks up a localized string similar to d. - /// - internal static string AWAKE_LABEL_DAYS { - get { - return ResourceManager.GetString("AWAKE_LABEL_DAYS", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to h. - /// - internal static string AWAKE_LABEL_HOURS { - get { - return ResourceManager.GetString("AWAKE_LABEL_HOURS", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to m. - /// - internal static string AWAKE_LABEL_MINUTES { - get { - return ResourceManager.GetString("AWAKE_LABEL_MINUTES", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to s. - /// - internal static string AWAKE_LABEL_SECONDS { - get { - return ResourceManager.GetString("AWAKE_LABEL_SECONDS", resourceCulture); - } - } - /// /// Looks up a localized string similar to {0} minute. /// @@ -320,7 +275,16 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_SCREEN_ON", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Screen. + /// + internal static string AWAKE_TRAY_DISPLAY { + get { + return ResourceManager.GetString("AWAKE_TRAY_DISPLAY", resourceCulture); + } + } + /// /// Looks up a localized string similar to Expiring. /// @@ -329,7 +293,7 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_TRAY_TEXT_EXPIRATION", resourceCulture); } } - + /// /// Looks up a localized string similar to Indefinite. /// @@ -338,7 +302,7 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_TRAY_TEXT_INDEFINITE", resourceCulture); } } - + /// /// Looks up a localized string similar to Passive. /// @@ -347,31 +311,31 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_TRAY_TEXT_OFF", resourceCulture); } } - + /// - /// Looks up a localized string similar to Bound to. - /// - internal static string AWAKE_TRAY_TEXT_PID_BINDING { - get { - return ResourceManager.GetString("AWAKE_TRAY_TEXT_PID_BINDING", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Interval. + /// Looks up a localized string similar to Timed. /// internal static string AWAKE_TRAY_TEXT_TIMED { get { return ResourceManager.GetString("AWAKE_TRAY_TEXT_TIMED", resourceCulture); } } - + /// - /// Looks up a localized string similar to Unchecked. + /// Looks up a localized string similar to Until. /// - internal static string AWAKE_UNCHECKED { + internal static string AWAKE_TRAY_UNTIL { get { - return ResourceManager.GetString("AWAKE_UNCHECKED", resourceCulture); + return ResourceManager.GetString("AWAKE_TRAY_UNTIL", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to remaining. + /// + internal static string AWAKE_TRAY_REMAINING { + get { + return ResourceManager.GetString("AWAKE_TRAY_REMAINING", resourceCulture); } } } diff --git a/src/modules/awake/Awake/Properties/Resources.resx b/src/modules/awake/Awake/Properties/Resources.resx index 388ca62580..5820744cb2 100644 --- a/src/modules/awake/Awake/Properties/Resources.resx +++ b/src/modules/awake/Awake/Properties/Resources.resx @@ -117,9 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Checked - Exit @@ -158,9 +155,6 @@ Off (keep using the selected power plan) Don't keep the system awake, use the selected system power plan - - Unchecked - Specifies whether Awake will be using the PowerToys configuration file for managing the state. @@ -195,31 +189,11 @@ Passive - Interval - - - d - Used to display number of days in the system tray tooltip. - - - h - Used to display number of hours in the system tray tooltip. - - - m - Used to display number of minutes in the system tray tooltip. - - - s - Used to display number of seconds in the system tray tooltip. + Timed Uses the parent process as the bound target - once the process terminates, Awake stops. - - Bound to - Describes the process ID Awake is bound to when running. - On @@ -235,4 +209,16 @@ Exiting because the provided process ID is Awake's own. + + Screen + Label for the screen/display line in tray tooltip. + + + Until + Label for expiration mode showing end date/time. + + + remaining + Suffix for timed mode showing time remaining, e.g. "1:30:00 remaining". + \ No newline at end of file diff --git a/src/modules/awake/README.md b/src/modules/awake/README.md new file mode 100644 index 0000000000..1ca2f9a7dd --- /dev/null +++ b/src/modules/awake/README.md @@ -0,0 +1,168 @@ +# PowerToys Awake Module + +A PowerToys utility that prevents Windows from sleeping and/or turning off the display. + +**Author:** [Den Delimarsky](https://den.dev) + +## Resources + +- [Awake Website](https://awake.den.dev) - Official documentation and guides +- [Microsoft Learn Documentation](https://learn.microsoft.com/windows/powertoys/awake) - Usage instructions and feature overview +- [GitHub Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+label%3AProduct-Awake) - Report bugs or request features + +## Overview + +The Awake module consists of three projects: + +| Project | Purpose | +|---------|---------| +| `Awake/` | Main WinExe application with CLI support | +| `Awake.ModuleServices/` | Service layer for PowerToys integration | +| `AwakeModuleInterface/` | C++ native module bridge | + +## How It Works + +The module uses the Win32 `SetThreadExecutionState()` API to signal Windows that the system should remain awake: + +- `ES_SYSTEM_REQUIRED` - Prevents system sleep +- `ES_DISPLAY_REQUIRED` - Prevents display sleep +- `ES_CONTINUOUS` - Maintains state until explicitly changed + +## Operating Modes + +| Mode | Description | +|------|-------------| +| **PASSIVE** | Normal power behavior (off) | +| **INDEFINITE** | Keep awake until manually stopped | +| **TIMED** | Keep awake for a specified duration | +| **EXPIRABLE** | Keep awake until a specific date/time | + +## Command-Line Usage + +Awake can be run standalone with the following options: + +``` +PowerToys.Awake.exe [options] + +Options: + -c, --use-pt-config Use PowerToys configuration file + -d, --display-on Keep display on (default: false) + -t, --time-limit Time limit in seconds + -p, --pid Process ID to bind to + -e, --expire-at Expiration date/time + -u, --use-parent-pid Bind to parent process +``` + +### Examples + +Keep system awake indefinitely: +```powershell +PowerToys.Awake.exe +``` + +Keep awake for 1 hour with display on: +```powershell +PowerToys.Awake.exe --time-limit 3600 --display-on +``` + +Keep awake until a specific time: +```powershell +PowerToys.Awake.exe --expire-at "2024-12-31 23:59:59" +``` + +Keep awake while another process is running: +```powershell +PowerToys.Awake.exe --pid 1234 +``` + +## Architecture + +### Design Highlights + +1. **Pure Win32 API for Tray UI** - No WPF/WinForms dependencies, keeping the binary small. Uses direct `Shell_NotifyIcon` API for tray icon management. + +2. **Reactive Extensions (Rx.NET)** - Used for timed operations via `Observable.Interval()` and `Observable.Timer()`. File system watching uses 25ms throttle to debounce rapid config changes. + +3. **Custom SynchronizationContext** - Queue-based message dispatch ensures tray operations run on a dedicated thread for thread-safe UI updates. + +4. **Dual-Mode Operation** + - Standalone: Command-line arguments only + - Integrated: PowerToys settings file + process binding + +5. **Process Binding** - The `--pid` parameter keeps the system awake only while a target process runs, with auto-exit when the parent PowerToys runner terminates. + +## Key Files + +| File | Purpose | +|------|---------| +| `Program.cs` | Entry point & CLI parsing | +| `Core/Manager.cs` | State orchestration & power management | +| `Core/TrayHelper.cs` | System tray UI management | +| `Core/Native/Bridge.cs` | Win32 P/Invoke declarations | +| `Core/Threading/SingleThreadSynchronizationContext.cs` | Threading utilities | + +## Building + +### Prerequisites + +- Visual Studio 2022 with C++ and .NET workloads +- Windows SDK 10.0.26100.0 or later + +### Build Commands + +From the `src/modules/awake` directory: + +```powershell +# Using the build script +.\scripts\Build-Awake.ps1 + +# Or with specific configuration +.\scripts\Build-Awake.ps1 -Configuration Debug -Platform x64 +``` + +Or using MSBuild directly: + +```powershell +msbuild Awake\Awake.csproj /p:Configuration=Release /p:Platform=x64 +``` + +## Dependencies + +- **System.CommandLine** - Command-line parsing +- **System.Reactive** - Rx.NET for timer management +- **PowerToys.ManagedCommon** - Shared PowerToys utilities +- **PowerToys.Settings.UI.Lib** - Settings integration +- **PowerToys.Interop** - Native interop layer + +## Configuration + +When running with PowerToys (`--use-pt-config`), settings are stored in: +``` +%LOCALAPPDATA%\Microsoft\PowerToys\Awake\settings.json +``` + +## Known Limitations + +### Task Scheduler Idle Detection ([#44134](https://github.com/microsoft/PowerToys/issues/44134)) + +When "Keep display on" is enabled, Awake uses the `ES_DISPLAY_REQUIRED` flag which blocks Windows Task Scheduler from detecting the system as idle. This prevents scheduled maintenance tasks (like SSD TRIM, disk defragmentation, and other idle-triggered tasks) from running. + +Per [Microsoft's documentation](https://learn.microsoft.com/en-us/windows/win32/taskschd/task-idle-conditions): + +> "An exception would be for any presentation type application that sets the ES_DISPLAY_REQUIRED flag. This flag forces Task Scheduler to not consider the system as being idle, regardless of user activity or resource consumption." + +**Workarounds:** + +1. **Disable "Keep display on"** - With this setting off, Awake only uses `ES_SYSTEM_REQUIRED` which still prevents sleep but allows Task Scheduler to detect idle state. + +2. **Manually run maintenance tasks** - For example, to run TRIM manually: + ```powershell + # Run as Administrator + Optimize-Volume -DriveLetter C -ReTrim -Verbose + ``` + +## Telemetry + +The module emits telemetry events for: +- Keep-awake mode changes (indefinite, timed, expirable, passive) +- Privacy-compliant event tagging via `Microsoft.PowerToys.Telemetry` diff --git a/src/modules/awake/scripts/Build-Awake.ps1 b/src/modules/awake/scripts/Build-Awake.ps1 new file mode 100644 index 0000000000..3b4389917d --- /dev/null +++ b/src/modules/awake/scripts/Build-Awake.ps1 @@ -0,0 +1,456 @@ +<# +.SYNOPSIS + Builds the PowerToys Awake module. + +.DESCRIPTION + This script builds the Awake module and its dependencies using MSBuild. + It automatically locates the Visual Studio installation and uses the + appropriate MSBuild version. + +.PARAMETER Configuration + The build configuration. Valid values are 'Debug' or 'Release'. + Default: Release + +.PARAMETER Platform + The target platform. Valid values are 'x64' or 'ARM64'. + Default: x64 + +.PARAMETER Clean + If specified, cleans the build output before building. + +.PARAMETER Restore + If specified, restores NuGet packages before building. + +.EXAMPLE + .\Build-Awake.ps1 + Builds Awake in Release configuration for x64. + +.EXAMPLE + .\Build-Awake.ps1 -Configuration Debug + Builds Awake in Debug configuration for x64. + +.EXAMPLE + .\Build-Awake.ps1 -Clean -Restore + Cleans, restores packages, and builds Awake. + +.EXAMPLE + .\Build-Awake.ps1 -Platform ARM64 + Builds Awake for ARM64 architecture. +#> + +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + [ValidateSet('x64', 'ARM64')] + [string]$Platform = 'x64', + + [switch]$Clean, + + [switch]$Restore +) + +# Force UTF-8 output for Unicode characters +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$ErrorActionPreference = 'Stop' +$script:StartTime = Get-Date + +# Get script directory and project paths +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ModuleDir = Split-Path -Parent $ScriptDir +$RepoRoot = Resolve-Path (Join-Path $ModuleDir "..\..\..") | Select-Object -ExpandProperty Path +$AwakeProject = Join-Path $ModuleDir "Awake\Awake.csproj" +$ModuleServicesProject = Join-Path $ModuleDir "Awake.ModuleServices\Awake.ModuleServices.csproj" + +# ============================================================================ +# Modern UI Components +# ============================================================================ + +$script:Colors = @{ + Primary = "Cyan" + Success = "Green" + Error = "Red" + Warning = "Yellow" + Muted = "DarkGray" + Accent = "Magenta" + White = "White" +} + +# Box drawing characters (not emojis) +$script:UI = @{ + BoxH = [char]0x2500 # Horizontal line + BoxV = [char]0x2502 # Vertical line + BoxTL = [char]0x256D # Top-left corner (rounded) + BoxTR = [char]0x256E # Top-right corner (rounded) + BoxBL = [char]0x2570 # Bottom-left corner (rounded) + BoxBR = [char]0x256F # Bottom-right corner (rounded) + TreeL = [char]0x2514 # Tree last item + TreeT = [char]0x251C # Tree item +} + +# Braille spinner frames (the npm-style spinner) +$script:SpinnerFrames = @( + [char]0x280B, # ⠋ + [char]0x2819, # ⠙ + [char]0x2839, # ⠹ + [char]0x2838, # ⠸ + [char]0x283C, # ⠼ + [char]0x2834, # ⠴ + [char]0x2826, # ⠦ + [char]0x2827, # ⠧ + [char]0x2807, # ⠇ + [char]0x280F # ⠏ +) + +function Get-ElapsedTime { + $elapsed = (Get-Date) - $script:StartTime + if ($elapsed.TotalSeconds -lt 60) { + return "$([math]::Round($elapsed.TotalSeconds, 1))s" + } else { + return "$([math]::Floor($elapsed.TotalMinutes))m $($elapsed.Seconds)s" + } +} + +function Write-Header { + Write-Host "" + Write-Host " Awake Build" -ForegroundColor $Colors.White + Write-Host " $Platform / $Configuration" -ForegroundColor $Colors.Muted + Write-Host "" +} + +function Write-Phase { + param([string]$Name) + Write-Host "" + Write-Host " $Name" -ForegroundColor $Colors.Accent + Write-Host "" +} + +function Write-Task { + param([string]$Name, [switch]$Last) + $tree = if ($Last) { $UI.TreeL } else { $UI.TreeT } + Write-Host " $tree$($UI.BoxH)$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted + Write-Host $Name -NoNewline -ForegroundColor $Colors.White +} + +function Write-TaskStatus { + param([string]$Status, [string]$Time, [switch]$Failed) + if ($Failed) { + Write-Host " FAIL" -ForegroundColor $Colors.Error + } else { + Write-Host " " -NoNewline + Write-Host $Status -NoNewline -ForegroundColor $Colors.Success + if ($Time) { + Write-Host " ($Time)" -ForegroundColor $Colors.Muted + } else { + Write-Host "" + } + } +} + +function Write-BuildTree { + param([string[]]$Items) + $count = $Items.Count + for ($i = 0; $i -lt $count; $i++) { + $isLast = ($i -eq $count - 1) + $tree = if ($isLast) { $UI.TreeL } else { $UI.TreeT } + Write-Host " $tree$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted + Write-Host $Items[$i] -ForegroundColor $Colors.Muted + } +} + +function Write-SuccessBox { + param([string]$Time, [string]$Output, [string]$Size) + + $width = 44 + $lineChar = [string]$UI.BoxH + $line = $lineChar * ($width - 2) + + Write-Host "" + Write-Host " $($UI.BoxTL)$line$($UI.BoxTR)" -ForegroundColor $Colors.Success + + # Title row + $title = " BUILD SUCCESSFUL" + $titlePadding = $width - 2 - $title.Length + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host $title -NoNewline -ForegroundColor $Colors.White + Write-Host (" " * $titlePadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + # Empty row + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host (" " * ($width - 2)) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + # Time row + $timeText = " Completed in $Time" + $timePadding = $width - 2 - $timeText.Length + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host $timeText -NoNewline -ForegroundColor $Colors.Muted + Write-Host (" " * $timePadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + # Output row + $outText = " Output: $Output ($Size)" + if ($outText.Length -gt ($width - 2)) { + $outText = $outText.Substring(0, $width - 5) + "..." + } + $outPadding = $width - 2 - $outText.Length + if ($outPadding -lt 0) { $outPadding = 0 } + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host $outText -NoNewline -ForegroundColor $Colors.Muted + Write-Host (" " * $outPadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + Write-Host " $($UI.BoxBL)$line$($UI.BoxBR)" -ForegroundColor $Colors.Success + Write-Host "" +} + +function Write-ErrorBox { + param([string]$Message) + + $width = 44 + $lineChar = [string]$UI.BoxH + $line = $lineChar * ($width - 2) + + Write-Host "" + Write-Host " $($UI.BoxTL)$line$($UI.BoxTR)" -ForegroundColor $Colors.Error + $title = " BUILD FAILED" + $titlePadding = $width - 2 - $title.Length + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Error + Write-Host $title -NoNewline -ForegroundColor $Colors.White + Write-Host (" " * $titlePadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Error + Write-Host " $($UI.BoxBL)$line$($UI.BoxBR)" -ForegroundColor $Colors.Error + Write-Host "" +} + +# ============================================================================ +# Build Functions +# ============================================================================ + +function Find-MSBuild { + $vsWherePath = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + + if (Test-Path $vsWherePath) { + $vsPath = & $vsWherePath -latest -requires Microsoft.Component.MSBuild -property installationPath + if ($vsPath) { + $msbuildPath = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe" + if (Test-Path $msbuildPath) { + return $msbuildPath + } + } + } + + $commonPaths = @( + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" + ) + + foreach ($path in $commonPaths) { + if (Test-Path $path) { + return $path + } + } + + throw "MSBuild not found. Please install Visual Studio 2022." +} + +function Invoke-BuildWithSpinner { + param( + [string]$TaskName, + [string]$MSBuildPath, + [string[]]$Arguments, + [switch]$ShowProjects, + [switch]$IsLast + ) + + $taskStart = Get-Date + $isInteractive = [Environment]::UserInteractive -and -not [Console]::IsOutputRedirected + + # Only write initial task line in interactive mode (will be overwritten by spinner) + if ($isInteractive) { + Write-Task $TaskName -Last:$IsLast + Write-Host " " -NoNewline + } + + # Start MSBuild process + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $MSBuildPath + $psi.Arguments = $Arguments -join " " + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $psi.WorkingDirectory = $RepoRoot + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + + # Collect output asynchronously + $outputBuilder = [System.Text.StringBuilder]::new() + $errorBuilder = [System.Text.StringBuilder]::new() + + $outputHandler = { + if (-not [String]::IsNullOrEmpty($EventArgs.Data)) { + $Event.MessageData.AppendLine($EventArgs.Data) + } + } + + $outputEvent = Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action $outputHandler -MessageData $outputBuilder + $errorEvent = Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action $outputHandler -MessageData $errorBuilder + + $process.Start() | Out-Null + $process.BeginOutputReadLine() + $process.BeginErrorReadLine() + + # Animate spinner while process is running + $frameIndex = 0 + + while (-not $process.HasExited) { + if ($isInteractive) { + $frame = $script:SpinnerFrames[$frameIndex] + Write-Host "`r $($UI.TreeL)$($UI.BoxH)$($UI.BoxH) $TaskName $frame " -NoNewline + $frameIndex = ($frameIndex + 1) % $script:SpinnerFrames.Count + } + Start-Sleep -Milliseconds 80 + } + + $process.WaitForExit() + + Unregister-Event -SourceIdentifier $outputEvent.Name + Unregister-Event -SourceIdentifier $errorEvent.Name + Remove-Job -Name $outputEvent.Name -Force -ErrorAction SilentlyContinue + Remove-Job -Name $errorEvent.Name -Force -ErrorAction SilentlyContinue + + $exitCode = $process.ExitCode + $output = $outputBuilder.ToString() -split "`n" + $errors = $errorBuilder.ToString() + + $taskElapsed = (Get-Date) - $taskStart + $elapsed = "$([math]::Round($taskElapsed.TotalSeconds, 1))s" + + # Write final status line + $tree = if ($IsLast) { $UI.TreeL } else { $UI.TreeT } + if ($isInteractive) { + Write-Host "`r" -NoNewline + } + Write-Host " $tree$($UI.BoxH)$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted + Write-Host $TaskName -NoNewline -ForegroundColor $Colors.White + + if ($exitCode -ne 0) { + Write-TaskStatus "FAIL" -Failed + Write-Host "" + foreach ($line in $output) { + if ($line -match "error\s+\w+\d*:") { + Write-Host " x $line" -ForegroundColor $Colors.Error + } + } + return @{ Success = $false; Output = $output; ExitCode = $exitCode } + } + + Write-TaskStatus "done" $elapsed + + # Show built projects + if ($ShowProjects) { + $projects = @() + foreach ($line in $output) { + if ($line -match "^\s*(\S+)\s+->\s+(.+)$") { + $project = $Matches[1] + $fileName = Split-Path $Matches[2] -Leaf + $projects += "$project -> $fileName" + } + } + if ($projects.Count -gt 0) { + Write-BuildTree $projects + } + } + + return @{ Success = $true; Output = $output; ExitCode = 0 } +} + +# ============================================================================ +# Main +# ============================================================================ + +# Verify project exists +if (-not (Test-Path $AwakeProject)) { + Write-Host "" + Write-Host " x Project not found: $AwakeProject" -ForegroundColor $Colors.Error + exit 1 +} + +$MSBuild = Find-MSBuild + +# Display header +Write-Header + +# Build arguments base +$BaseArgs = @( + "/p:Configuration=$Configuration", + "/p:Platform=$Platform", + "/v:minimal", + "/nologo", + "/m" +) + +# Clean phase +if ($Clean) { + Write-Phase "Cleaning" + $cleanArgs = @($AwakeProject) + $BaseArgs + @("/t:Clean") + $result = Invoke-BuildWithSpinner -TaskName "Build artifacts" -MSBuildPath $MSBuild -Arguments $cleanArgs -IsLast + if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode + } +} + +# Restore phase +if ($Restore) { + Write-Phase "Restoring" + $restoreArgs = @($AwakeProject) + $BaseArgs + @("/t:Restore") + $result = Invoke-BuildWithSpinner -TaskName "NuGet packages" -MSBuildPath $MSBuild -Arguments $restoreArgs -IsLast + if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode + } +} + +# Build phase +Write-Phase "Building" + +$hasModuleServices = Test-Path $ModuleServicesProject + +# Build Awake +$awakeArgs = @($AwakeProject) + $BaseArgs + @("/t:Build") +$result = Invoke-BuildWithSpinner -TaskName "Awake" -MSBuildPath $MSBuild -Arguments $awakeArgs -ShowProjects -IsLast:(-not $hasModuleServices) +if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode +} + +# Build ModuleServices +if ($hasModuleServices) { + $servicesArgs = @($ModuleServicesProject) + $BaseArgs + @("/t:Build") + $result = Invoke-BuildWithSpinner -TaskName "Awake.ModuleServices" -MSBuildPath $MSBuild -Arguments $servicesArgs -ShowProjects -IsLast + if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode + } +} + +# Summary +$OutputDir = Join-Path $RepoRoot "$Platform\$Configuration" +$AwakeDll = Join-Path $OutputDir "PowerToys.Awake.dll" +$elapsed = Get-ElapsedTime + +if (Test-Path $AwakeDll) { + $size = "$([math]::Round((Get-Item $AwakeDll).Length / 1KB, 1)) KB" + Write-SuccessBox -Time $elapsed -Output "PowerToys.Awake.dll" -Size $size +} else { + Write-SuccessBox -Time $elapsed -Output $OutputDir -Size "N/A" +} diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index e46572f579..1cf8bac330 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -181,7 +181,10 @@ void dispatch_json_config_to_modules(const json::JsonObject& powertoys_configs) const auto properties = settings.GetNamedObject(L"properties"); // Currently, only PowerToys Run settings use the 'hotkey_changed' property. - json::get(properties, L"hotkey_changed", hotkeyUpdated, true); + if (properties.HasKey(L"hotkey_changed")) + { + json::get(properties, L"hotkey_changed", hotkeyUpdated, true); + } } send_json_config_to_module(powertoy_element.Key().c_str(), element.c_str(), hotkeyUpdated); diff --git a/tools/build/clean-artifacts.ps1 b/tools/build/clean-artifacts.ps1 new file mode 100644 index 0000000000..22b252f74e --- /dev/null +++ b/tools/build/clean-artifacts.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS +Cleans PowerToys build artifacts to resolve build errors. + +.DESCRIPTION +Use this script when you encounter build errors about missing image files or corrupted +build state. It removes build output folders and optionally runs MSBuild Clean. + +.PARAMETER SkipMSBuildClean +Skip running MSBuild Clean target, only delete folders. + +.EXAMPLE +.\tools\build\clean-artifacts.ps1 + +.EXAMPLE +.\tools\build\clean-artifacts.ps1 -SkipMSBuildClean +#> + +param ( + [switch]$SkipMSBuildClean +) + +$ErrorActionPreference = 'Continue' + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path "$scriptDir\..\..").Path + +Write-Host "Cleaning build artifacts..." +Write-Host "" + +# Run MSBuild Clean +if (-not $SkipMSBuildClean) { + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsWhere) { + $vsPath = & $vsWhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath + if ($vsPath) { + $msbuildPath = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe" + if (Test-Path $msbuildPath) { + $solutionFile = Join-Path $repoRoot "PowerToys.sln" + if (-not (Test-Path $solutionFile)) { + $solutionFile = Join-Path $repoRoot "PowerToys.slnx" + } + + if (Test-Path $solutionFile) { + Write-Host " Running MSBuild Clean..." + foreach ($plat in @('x64', 'ARM64')) { + foreach ($config in @('Debug', 'Release')) { + & $msbuildPath $solutionFile /t:Clean /p:Platform=$plat /p:Configuration=$config /verbosity:quiet 2>&1 | Out-Null + } + } + Write-Host " Done." + } + } + } + } +} + +# Delete build folders +$folders = @('x64', 'ARM64', 'Debug', 'Release', 'packages') +$deleted = @() + +foreach ($folder in $folders) { + $fullPath = Join-Path $repoRoot $folder + if (Test-Path $fullPath) { + Write-Host " Removing $folder/" + try { + Remove-Item -Path $fullPath -Recurse -Force -ErrorAction Stop + $deleted += $folder + } catch { + Write-Host " Failed to remove $folder/: $_" + } + } +} + +Write-Host "" +if ($deleted.Count -gt 0) { + Write-Host "Removed: $($deleted -join ', ')" +} else { + Write-Host "No build folders found to remove." +} + +Write-Host "" +Write-Host "To rebuild, run:" +Write-Host " msbuild -restore -p:RestorePackagesConfig=true -p:Platform=x64 -m PowerToys.slnx" diff --git a/tools/build/setup-dev-environment.ps1 b/tools/build/setup-dev-environment.ps1 new file mode 100644 index 0000000000..916dfffaf4 --- /dev/null +++ b/tools/build/setup-dev-environment.ps1 @@ -0,0 +1,291 @@ +<# +.SYNOPSIS +Sets up the development environment for building PowerToys. + +.DESCRIPTION +This script automates the setup of prerequisites needed to build PowerToys locally: +- Enables Windows long path support (requires elevation) +- Enables Windows Developer Mode (requires elevation) +- Installs required Visual Studio workloads from .vsconfig +- Initializes git submodules + +Run this script once after cloning the repository to prepare your development environment. + +.PARAMETER SkipLongPaths +Skip enabling long path support in Windows. + +.PARAMETER SkipDevMode +Skip enabling Windows Developer Mode. + +.PARAMETER SkipVSComponents +Skip installing Visual Studio components from .vsconfig. + +.PARAMETER SkipSubmodules +Skip initializing git submodules. + +.PARAMETER VSInstallPath +Path to Visual Studio installation. Default: auto-detected. + +.PARAMETER Help +Show this help message. + +.EXAMPLE +.\tools\build\setup-dev-environment.ps1 +Runs the full setup process. + +.EXAMPLE +.\tools\build\setup-dev-environment.ps1 -SkipVSComponents +Runs setup but skips Visual Studio component installation. + +.EXAMPLE +.\tools\build\setup-dev-environment.ps1 -VSInstallPath "C:\Program Files\Microsoft Visual Studio\2022\Enterprise" +Runs setup with a custom Visual Studio installation path. + +.NOTES +- Some operations require administrator privileges (long paths, VS component installation). +- If not running as administrator, the script will prompt for elevation for those steps. +- The script is idempotent and safe to run multiple times. +#> + +param ( + [switch]$SkipLongPaths, + [switch]$SkipDevMode, + [switch]$SkipVSComponents, + [switch]$SkipSubmodules, + [string]$VSInstallPath = '', + [switch]$Help +) + +if ($Help) { + Get-Help $MyInvocation.MyCommand.Path -Detailed + exit 0 +} + +$ErrorActionPreference = 'Stop' + +# Find repository root +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = $scriptDir +while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot "PowerToys.slnx"))) { + $parent = Split-Path -Parent $repoRoot + if ($parent -eq $repoRoot) { + Write-Error "Could not find PowerToys repository root. Ensure this script is in the PowerToys repository." + exit 1 + } + $repoRoot = $parent +} + +Write-Host "Repository: $repoRoot" +Write-Host "" + +function Test-Administrator { + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($currentUser) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +$isAdmin = Test-Administrator + +# Step 1: Enable Long Paths +if (-not $SkipLongPaths) { + Write-Host "[1/4] Checking Windows long path support" + + $longPathsEnabled = $false + try { + $regValue = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -ErrorAction SilentlyContinue + $longPathsEnabled = ($regValue.LongPathsEnabled -eq 1) + } catch { + $longPathsEnabled = $false + } + + if ($longPathsEnabled) { + Write-Host " Long paths already enabled" -ForegroundColor Green + } elseif ($isAdmin) { + Write-Host " Enabling long paths..." + try { + Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -Type DWord + Write-Host " Long paths enabled" -ForegroundColor Green + } catch { + Write-Warning " Failed to enable long paths: $_" + } + } else { + Write-Warning " Long paths not enabled. Run as Administrator to enable, or run manually:" + Write-Host " Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1" -ForegroundColor DarkGray + } +} else { + Write-Host "[1/4] Skipping long path check" -ForegroundColor DarkGray +} + +Write-Host "" + +# Step 2: Enable Developer Mode +if (-not $SkipDevMode) { + Write-Host "[2/4] Checking Windows Developer Mode" + + $devModeEnabled = $false + try { + $regValue = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" -Name "AllowDevelopmentWithoutDevLicense" -ErrorAction SilentlyContinue + $devModeEnabled = ($regValue.AllowDevelopmentWithoutDevLicense -eq 1) + } catch { + $devModeEnabled = $false + } + + if ($devModeEnabled) { + Write-Host " Developer Mode already enabled" -ForegroundColor Green + } elseif ($isAdmin) { + Write-Host " Enabling Developer Mode..." + try { + $regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + if (-not (Test-Path $regPath)) { + New-Item -Path $regPath -Force | Out-Null + } + Set-ItemProperty -Path $regPath -Name "AllowDevelopmentWithoutDevLicense" -Value 1 -Type DWord + Write-Host " Developer Mode enabled" -ForegroundColor Green + } catch { + Write-Warning " Failed to enable Developer Mode: $_" + } + } else { + Write-Warning " Developer Mode not enabled. Run as Administrator to enable, or enable manually:" + Write-Host " Settings > System > For developers > Developer Mode" -ForegroundColor DarkGray + } +} else { + Write-Host "[2/4] Skipping Developer Mode check" -ForegroundColor DarkGray +} + +Write-Host "" + +# Step 3: Install Visual Studio Components +if (-not $SkipVSComponents) { + Write-Host "[3/4] Checking Visual Studio components" + + $vsConfigPath = Join-Path $repoRoot ".vsconfig" + if (-not (Test-Path $vsConfigPath)) { + Write-Warning " .vsconfig not found at $vsConfigPath" + } else { + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + + if (-not $VSInstallPath -and (Test-Path $vsWhere)) { + $VSInstallPath = & $vsWhere -latest -property installationPath 2>$null + } + + if (-not $VSInstallPath) { + $commonPaths = @( + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community" + ) + foreach ($path in $commonPaths) { + if (Test-Path $path) { + $VSInstallPath = $path + break + } + } + } + + if (-not $VSInstallPath -or -not (Test-Path $VSInstallPath)) { + Write-Warning " Could not find Visual Studio 2022 installation" + Write-Warning " Please install Visual Studio 2022 and try again, or import .vsconfig manually" + } else { + Write-Host " Found: $VSInstallPath" -ForegroundColor DarkGray + + $vsInstaller = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vs_installer.exe" + + if (Test-Path $vsInstaller) { + Write-Host "" + Write-Host " To install required components:" + Write-Host "" + Write-Host " Option A - Visual Studio Installer GUI:" + Write-Host " 1. Open Visual Studio Installer" + Write-Host " 2. Click 'More' > 'Import configuration'" + Write-Host " 3. Select: $vsConfigPath" + Write-Host "" + Write-Host " Option B - Command line (close VS first):" + Write-Host " & `"$vsInstaller`" modify --installPath `"$VSInstallPath`" --config `"$vsConfigPath`"" -ForegroundColor DarkGray + Write-Host "" + + $choices = @( + [System.Management.Automation.Host.ChoiceDescription]::new("&Install", "Run VS Installer now"), + [System.Management.Automation.Host.ChoiceDescription]::new("&Skip", "Continue without installing") + ) + + try { + $decision = $Host.UI.PromptForChoice("", "Install VS components now?", $choices, 1) + + if ($decision -eq 0) { + # Check if VS Installer is already running (it runs as setup.exe from the Installer folder) + $vsInstallerDir = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" + $vsInstallerRunning = Get-Process -Name "setup" -ErrorAction SilentlyContinue | + Where-Object { $_.Path -and $_.Path.StartsWith($vsInstallerDir, [System.StringComparison]::OrdinalIgnoreCase) } + if ($vsInstallerRunning) { + Write-Warning " Visual Studio Installer is already running" + Write-Host " Close it and run this script again, or import .vsconfig manually" -ForegroundColor DarkGray + } else { + Write-Host " Launching Visual Studio Installer..." + Write-Host " Close Visual Studio if it's running." -ForegroundColor DarkGray + $process = Start-Process -FilePath $vsInstaller -ArgumentList "modify", "--installPath", "`"$VSInstallPath`"", "--config", "`"$vsConfigPath`"" -Wait -PassThru + if ($process.ExitCode -eq 0) { + Write-Host " VS component installation completed" -ForegroundColor Green + } elseif ($process.ExitCode -eq 3010) { + Write-Host " VS component installation completed (restart may be required)" -ForegroundColor Green + } else { + Write-Warning " VS Installer exited with code $($process.ExitCode)" + Write-Host " You may need to run the installer manually" -ForegroundColor DarkGray + } + } + } else { + Write-Host " Skipped VS component installation" + } + } catch { + Write-Host " Non-interactive mode. Run the command above manually if needed." -ForegroundColor DarkGray + } + } else { + Write-Warning " Visual Studio Installer not found" + } + } + } +} else { + Write-Host "[3/4] Skipping VS component check" -ForegroundColor DarkGray +} + +Write-Host "" + +# Step 4: Initialize Git Submodules +if (-not $SkipSubmodules) { + Write-Host "[4/4] Initializing git submodules" + + Push-Location $repoRoot + try { + $submoduleStatus = git submodule status 2>&1 + $uninitializedCount = ($submoduleStatus | Where-Object { $_ -match '^\-' }).Count + + if ($uninitializedCount -eq 0 -and $submoduleStatus) { + Write-Host " Submodules already initialized" -ForegroundColor Green + } else { + Write-Host " Running: git submodule update --init --recursive" -ForegroundColor DarkGray + git submodule update --init --recursive + if ($LASTEXITCODE -eq 0) { + Write-Host " Submodules initialized" -ForegroundColor Green + } else { + Write-Warning " Submodule initialization may have encountered issues (exit code: $LASTEXITCODE)" + } + } + } catch { + Write-Warning " Failed to initialize submodules: $_" + } finally { + Pop-Location + } +} else { + Write-Host "[4/4] Skipping submodule initialization" -ForegroundColor DarkGray +} + +Write-Host "" +Write-Host "Setup complete" -ForegroundColor Green +Write-Host "" +Write-Host "Next steps:" +Write-Host " 1. Open PowerToys.slnx in Visual Studio 2022" +Write-Host " 2. If prompted to install additional components, click Install" +Write-Host " 3. Build the solution (Ctrl+Shift+B)" +Write-Host "" +Write-Host "Or build from command line:" +Write-Host " .\tools\build\build.ps1" -ForegroundColor DarkGray +Write-Host ""