Awake and DevEx improvements (#44795)

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<AwakeSettings>()` 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.
This commit is contained in:
Den Delimarsky
2026-01-20 21:27:45 -08:00
committed by GitHub
parent 2a0d0a1210
commit 27dcd1e5bc
22 changed files with 1506 additions and 231 deletions

View File

@@ -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>(AwakeSettings.ModuleName);
}
catch
catch (Exception ex)
{
Logger.LogError($"Failed to read Awake settings: {ex.Message}");
return null;
}
}

View File

@@ -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";
}
}

View File

@@ -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}";
}
}
}

View File

@@ -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<ExecutionState> _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;
}
/// <summary>
/// Re-applies the current awake state after a power event.
/// Called when WM_POWERBROADCAST indicates system wake or power source change.
/// </summary>
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"));
}
/// <summary>
@@ -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<AwakeSettings>(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);

View File

@@ -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;

View File

@@ -11,7 +11,7 @@ namespace Awake.Core.Threading
{
internal sealed class SingleThreadSynchronizationContext : SynchronizationContext
{
private readonly Queue<Tuple<SendOrPostCallback, object?>?> 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<SendOrPostCallback, object?>? 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)
{

View File

@@ -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;
}
/// <summary>
/// Disposes of all icon resources to prevent GDI handle leaks.
/// </summary>
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<NotifyIconData>(),
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<NotifyIconData>(),
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<TrayCommands>().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<AwakeSettings>(Constants.AppName);
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(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<AwakeSettings>(Constants.AppName);
AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(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<string, uint> 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);

View File

@@ -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<int> 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<bool> displayOption = new(_aliasesDisplayOption, () => true, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION)
Option<bool> 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);

View File

@@ -60,15 +60,6 @@ namespace Awake.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Checked.
/// </summary>
internal static string AWAKE_CHECKED {
get {
return ResourceManager.GetString("AWAKE_CHECKED", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Specifies whether Awake will be using the PowerToys configuration file for managing the state..
/// </summary>
@@ -240,42 +231,6 @@ namespace Awake.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to d.
/// </summary>
internal static string AWAKE_LABEL_DAYS {
get {
return ResourceManager.GetString("AWAKE_LABEL_DAYS", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to h.
/// </summary>
internal static string AWAKE_LABEL_HOURS {
get {
return ResourceManager.GetString("AWAKE_LABEL_HOURS", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to m.
/// </summary>
internal static string AWAKE_LABEL_MINUTES {
get {
return ResourceManager.GetString("AWAKE_LABEL_MINUTES", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to s.
/// </summary>
internal static string AWAKE_LABEL_SECONDS {
get {
return ResourceManager.GetString("AWAKE_LABEL_SECONDS", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} minute.
/// </summary>
@@ -320,7 +275,16 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_SCREEN_ON", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Screen.
/// </summary>
internal static string AWAKE_TRAY_DISPLAY {
get {
return ResourceManager.GetString("AWAKE_TRAY_DISPLAY", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Expiring.
/// </summary>
@@ -329,7 +293,7 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_EXPIRATION", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Indefinite.
/// </summary>
@@ -338,7 +302,7 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_INDEFINITE", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Passive.
/// </summary>
@@ -347,31 +311,31 @@ namespace Awake.Properties {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_OFF", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Bound to.
/// </summary>
internal static string AWAKE_TRAY_TEXT_PID_BINDING {
get {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_PID_BINDING", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Interval.
/// Looks up a localized string similar to Timed.
/// </summary>
internal static string AWAKE_TRAY_TEXT_TIMED {
get {
return ResourceManager.GetString("AWAKE_TRAY_TEXT_TIMED", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unchecked.
/// Looks up a localized string similar to Until.
/// </summary>
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);
}
}
/// <summary>
/// Looks up a localized string similar to remaining.
/// </summary>
internal static string AWAKE_TRAY_REMAINING {
get {
return ResourceManager.GetString("AWAKE_TRAY_REMAINING", resourceCulture);
}
}
}

View File

@@ -117,9 +117,6 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AWAKE_CHECKED" xml:space="preserve">
<value>Checked</value>
</data>
<data name="AWAKE_EXIT" xml:space="preserve">
<value>Exit</value>
</data>
@@ -158,9 +155,6 @@
<value>Off (keep using the selected power plan)</value>
<comment>Don't keep the system awake, use the selected system power plan</comment>
</data>
<data name="AWAKE_UNCHECKED" xml:space="preserve">
<value>Unchecked</value>
</data>
<data name="AWAKE_CMD_HELP_CONFIG_OPTION" xml:space="preserve">
<value>Specifies whether Awake will be using the PowerToys configuration file for managing the state.</value>
</data>
@@ -195,31 +189,11 @@
<value>Passive</value>
</data>
<data name="AWAKE_TRAY_TEXT_TIMED" xml:space="preserve">
<value>Interval</value>
</data>
<data name="AWAKE_LABEL_DAYS" xml:space="preserve">
<value>d</value>
<comment>Used to display number of days in the system tray tooltip.</comment>
</data>
<data name="AWAKE_LABEL_HOURS" xml:space="preserve">
<value>h</value>
<comment>Used to display number of hours in the system tray tooltip.</comment>
</data>
<data name="AWAKE_LABEL_MINUTES" xml:space="preserve">
<value>m</value>
<comment>Used to display number of minutes in the system tray tooltip.</comment>
</data>
<data name="AWAKE_LABEL_SECONDS" xml:space="preserve">
<value>s</value>
<comment>Used to display number of seconds in the system tray tooltip.</comment>
<value>Timed</value>
</data>
<data name="AWAKE_CMD_PARENT_PID_OPTION" xml:space="preserve">
<value>Uses the parent process as the bound target - once the process terminates, Awake stops.</value>
</data>
<data name="AWAKE_TRAY_TEXT_PID_BINDING" xml:space="preserve">
<value>Bound to</value>
<comment>Describes the process ID Awake is bound to when running.</comment>
</data>
<data name="AWAKE_SCREEN_ON" xml:space="preserve">
<value>On</value>
</data>
@@ -235,4 +209,16 @@
<data name="AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE" xml:space="preserve">
<value>Exiting because the provided process ID is Awake's own.</value>
</data>
<data name="AWAKE_TRAY_DISPLAY" xml:space="preserve">
<value>Screen</value>
<comment>Label for the screen/display line in tray tooltip.</comment>
</data>
<data name="AWAKE_TRAY_UNTIL" xml:space="preserve">
<value>Until</value>
<comment>Label for expiration mode showing end date/time.</comment>
</data>
<data name="AWAKE_TRAY_REMAINING" xml:space="preserve">
<value>remaining</value>
<comment>Suffix for timed mode showing time remaining, e.g. "1:30:00 remaining".</comment>
</data>
</root>

168
src/modules/awake/README.md Normal file
View File

@@ -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`

View File

@@ -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"
}