/ src / modules / awake / Awake / Program.cs
Program.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using System;
  6  using System.CommandLine;
  7  using System.CommandLine.Parsing;
  8  using System.Diagnostics;
  9  using System.Globalization;
 10  using System.IO;
 11  using System.Linq;
 12  using System.Reactive.Concurrency;
 13  using System.Reactive.Linq;
 14  using System.Reflection;
 15  using System.Text.Json;
 16  using System.Threading;
 17  using System.Threading.Tasks;
 18  using Awake.Core;
 19  using Awake.Core.Models;
 20  using Awake.Core.Native;
 21  using Awake.Properties;
 22  using ManagedCommon;
 23  using Microsoft.PowerToys.Settings.UI.Library;
 24  using Microsoft.PowerToys.Telemetry;
 25  
 26  namespace Awake
 27  {
 28      internal sealed class Program
 29      {
 30          private static readonly string[] _aliasesConfigOption = ["--use-pt-config", "-c"];
 31          private static readonly string[] _aliasesDisplayOption = ["--display-on", "-d"];
 32          private static readonly string[] _aliasesTimeOption = ["--time-limit", "-t"];
 33          private static readonly string[] _aliasesPidOption = ["--pid", "-p"];
 34          private static readonly string[] _aliasesExpireAtOption = ["--expire-at", "-e"];
 35          private static readonly string[] _aliasesParentPidOption = ["--use-parent-pid", "-u"];
 36  
 37          private static readonly JsonSerializerOptions _serializerOptions = new() { IncludeFields = true };
 38          private static readonly ETWTrace _etwTrace = new();
 39  
 40          private static FileSystemWatcher? _watcher;
 41          private static SettingsUtils? _settingsUtils;
 42          private static EventWaitHandle? _exitEventHandle;
 43          private static RegisteredWaitHandle? _registeredWaitHandle;
 44  
 45          private static bool _startedFromPowerToys;
 46  
 47          public static Mutex? LockMutex { get; set; }
 48  
 49          private static ConsoleEventHandler? _handler;
 50          private static SystemPowerCapabilities _powerCapabilities;
 51  
 52          private static async Task<int> Main(string[] args)
 53          {
 54              Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs"));
 55  
 56              var rootCommand = BuildRootCommand();
 57  
 58              Bridge.AttachConsole(Core.Native.Constants.ATTACH_PARENT_PROCESS);
 59  
 60              var parseResult = rootCommand.Parse(args);
 61  
 62              if (parseResult.Tokens.Any(t => t.Value.ToLowerInvariant() is "--help" or "-h" or "-?"))
 63              {
 64                  // Print help and exit.
 65                  return rootCommand.Invoke(args);
 66              }
 67  
 68              if (parseResult.Errors.Count > 0)
 69              {
 70                  // Shows errors and returns non-zero.
 71                  return rootCommand.Invoke(args);
 72              }
 73  
 74              _settingsUtils = SettingsUtils.Default;
 75  
 76              LockMutex = new Mutex(true, Core.Constants.AppName, out bool instantiated);
 77  
 78              try
 79              {
 80                  string appLanguage = LanguageHelper.LoadLanguage();
 81                  if (!string.IsNullOrEmpty(appLanguage))
 82                  {
 83                      Thread.CurrentThread.CurrentUICulture = new CultureInfo(appLanguage);
 84                  }
 85              }
 86              catch (CultureNotFoundException ex)
 87              {
 88                  Logger.LogError("CultureNotFoundException: " + ex.Message);
 89              }
 90  
 91              await TrayHelper.InitializeTray(TrayHelper.DefaultAwakeIcon, Core.Constants.FullAppName);
 92              AppDomain.CurrentDomain.ProcessExit += (_, _) => TrayHelper.RunOnMainThread(() => LockMutex?.ReleaseMutex());
 93              AppDomain.CurrentDomain.UnhandledException += AwakeUnhandledExceptionCatcher;
 94  
 95              if (!instantiated)
 96              {
 97                  // Awake is already running - there is no need for us to process
 98                  // anything further
 99                  Exit(Core.Constants.AppName + " is already running! Exiting the application.", 1);
100                  return 1;
101              }
102              else
103              {
104                  if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredAwakeEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
105                  {
106                      Exit("PowerToys.Awake tried to start with a group policy setting that disables the tool. Please contact your system administrator.", 1);
107                      return 1;
108                  }
109                  else
110                  {
111                      Logger.LogInfo($"Launching {Core.Constants.AppName}...");
112                      Logger.LogInfo(FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion);
113                      Logger.LogInfo($"Build: {Core.Constants.BuildId}");
114                      Logger.LogInfo($"OS: {Environment.OSVersion}");
115                      Logger.LogInfo($"OS Build: {Manager.GetOperatingSystemBuild()}");
116  
117                      TaskScheduler.UnobservedTaskException += (sender, args) =>
118                      {
119                          Trace.WriteLine($"Task scheduler error: {args.Exception.Message}"); // somebody forgot to check!
120                          args.SetObserved();
121                      };
122  
123                      // To make it easier to diagnose future issues, let's get the
124                      // system power capabilities and aggregate them in the log.
125                      Bridge.GetPwrCapabilities(out _powerCapabilities);
126                      Logger.LogInfo(JsonSerializer.Serialize(_powerCapabilities, _serializerOptions));
127  
128                      return await rootCommand.InvokeAsync(args);
129                  }
130              }
131          }
132  
133          private static RootCommand BuildRootCommand()
134          {
135              Logger.LogInfo("Parsing parameters...");
136  
137              Option<bool> configOption = new(_aliasesConfigOption, () => false, Resources.AWAKE_CMD_HELP_CONFIG_OPTION)
138              {
139                  Arity = ArgumentArity.ZeroOrOne,
140                  IsRequired = false,
141              };
142  
143              Option<bool> displayOption = new(_aliasesDisplayOption, () => false, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION)
144              {
145                  Arity = ArgumentArity.ZeroOrOne,
146                  IsRequired = false,
147              };
148  
149              Option<uint> timeOption = new(_aliasesTimeOption, () => 0, Resources.AWAKE_CMD_HELP_TIME_OPTION)
150              {
151                  Arity = ArgumentArity.ExactlyOne,
152                  IsRequired = false,
153              };
154  
155              Option<int> pidOption = new(_aliasesPidOption, () => 0, Resources.AWAKE_CMD_HELP_PID_OPTION)
156              {
157                  Arity = ArgumentArity.ZeroOrOne,
158                  IsRequired = false,
159              };
160  
161              Option<string> expireAtOption = new(_aliasesExpireAtOption, () => string.Empty, Resources.AWAKE_CMD_HELP_EXPIRE_AT_OPTION)
162              {
163                  Arity = ArgumentArity.ZeroOrOne,
164                  IsRequired = false,
165              };
166  
167              Option<bool> parentPidOption = new(_aliasesParentPidOption, () => false, Resources.AWAKE_CMD_PARENT_PID_OPTION)
168              {
169                  Arity = ArgumentArity.ZeroOrOne,
170                  IsRequired = false,
171              };
172  
173              timeOption.AddValidator(result =>
174              {
175                  if (result.Tokens.Count != 0 && !uint.TryParse(result.Tokens[0].Value, out _))
176                  {
177                      string errorMessage = $"Interval in --time-limit could not be parsed correctly. Check that the value is valid and doesn't exceed 4,294,967,295. Value used: {result.Tokens[0].Value}.";
178                      Logger.LogError(errorMessage);
179                      result.ErrorMessage = errorMessage;
180                  }
181              });
182  
183              pidOption.AddValidator(result =>
184              {
185                  if (result.Tokens.Count != 0 && !int.TryParse(result.Tokens[0].Value, out _))
186                  {
187                      string errorMessage = $"PID value in --pid could not be parsed correctly. Check that the value is valid and falls within the boundaries of Windows PID process limits. Value used: {result.Tokens[0].Value}.";
188                      Logger.LogError(errorMessage);
189                      result.ErrorMessage = errorMessage;
190                  }
191              });
192  
193              expireAtOption.AddValidator(result =>
194              {
195                  if (result.Tokens.Count != 0 && !DateTimeOffset.TryParse(result.Tokens[0].Value, out _))
196                  {
197                      string errorMessage = $"Date and time value in --expire-at could not be parsed correctly. Check that the value is valid date and time. Refer to https://aka.ms/powertoys/awake for format examples. Value used: {result.Tokens[0].Value}.";
198                      Logger.LogError(errorMessage);
199                      result.ErrorMessage = errorMessage;
200                  }
201              });
202  
203              RootCommand? rootCommand =
204              [
205                  configOption,
206                  displayOption,
207                  timeOption,
208                  pidOption,
209                  expireAtOption,
210                  parentPidOption,
211              ];
212  
213              rootCommand.Description = Core.Constants.AppName;
214              rootCommand.SetHandler(HandleCommandLineArguments, configOption, displayOption, timeOption, pidOption, expireAtOption, parentPidOption);
215  
216              return rootCommand;
217          }
218  
219          private static void AwakeUnhandledExceptionCatcher(object sender, UnhandledExceptionEventArgs e)
220          {
221              if (e.ExceptionObject is Exception exception)
222              {
223                  Logger.LogError(exception.ToString());
224                  Logger.LogError(exception.StackTrace);
225              }
226          }
227  
228          private static bool ExitHandler(ControlType ctrlType)
229          {
230              Logger.LogInfo($"Exited through handler with control type: {ctrlType}");
231              Exit(Resources.AWAKE_EXIT_MESSAGE, Environment.ExitCode);
232              return false;
233          }
234  
235          private static void Exit(string message, int exitCode)
236          {
237              _etwTrace?.Dispose();
238              DisposeFileSystemWatcher();
239              _registeredWaitHandle?.Unregister(null);
240              _exitEventHandle?.Dispose();
241              Logger.LogInfo(message);
242              Manager.CompleteExit(exitCode);
243          }
244  
245          private static void DisposeFileSystemWatcher()
246          {
247              if (_watcher != null)
248              {
249                  _watcher.EnableRaisingEvents = false;
250                  _watcher.Dispose();
251                  _watcher = null;
252              }
253          }
254  
255          private static bool ProcessExists(int processId)
256          {
257              if (processId <= 0)
258              {
259                  return false;
260              }
261  
262              try
263              {
264                  // Throws if the Process ID is not found.
265                  using var p = Process.GetProcessById(processId);
266                  return !p.HasExited;
267              }
268              catch (ArgumentException)
269              {
270                  // Process with the specified ID is not running
271                  return false;
272              }
273              catch (InvalidOperationException ex)
274              {
275                  // Process has exited or cannot be accessed
276                  Logger.LogInfo($"Process {processId} cannot be accessed: {ex.Message}");
277                  return false;
278              }
279          }
280  
281          private static void HandleCommandLineArguments(bool usePtConfig, bool displayOn, uint timeLimit, int pid, string expireAt, bool useParentPid)
282          {
283              if (pid == 0 && !useParentPid)
284              {
285                  Logger.LogInfo("No PID specified. Allocating console...");
286                  Bridge.FreeConsole();
287                  AllocateLocalConsole();
288              }
289              else
290              {
291                  Logger.LogInfo("Starting with PID binding.");
292                  _startedFromPowerToys = true;
293              }
294  
295              Logger.LogInfo($"The value for --use-pt-config is: {usePtConfig}");
296              Logger.LogInfo($"The value for --display-on is: {displayOn}");
297              Logger.LogInfo($"The value for --time-limit is: {timeLimit}");
298              Logger.LogInfo($"The value for --pid is: {pid}");
299              Logger.LogInfo($"The value for --expire-at is: {expireAt}");
300              Logger.LogInfo($"The value for --use-parent-pid is: {useParentPid}");
301  
302              // Start the monitor thread that will be used to track the current state.
303              Manager.StartMonitor();
304  
305              _exitEventHandle = new EventWaitHandle(false, EventResetMode.ManualReset, PowerToys.Interop.Constants.AwakeExitEvent());
306              _registeredWaitHandle = ThreadPool.RegisterWaitForSingleObject(
307                  _exitEventHandle,
308                  (state, timedOut) => Exit(Resources.AWAKE_EXIT_SIGNAL_MESSAGE, 0),
309                  null,
310                  Timeout.Infinite,
311                  executeOnlyOnce: true);
312  
313              if (usePtConfig)
314              {
315                  // Configuration file is used, therefore we disregard any other command-line parameter
316                  // and instead watch for changes in the file. This is used as a priority against all other arguments,
317                  // so if --use-pt-config is applied the rest of the arguments are irrelevant.
318                  Manager.IsUsingPowerToysConfig = true;
319  
320                  try
321                  {
322                      string? settingsPath = _settingsUtils!.GetSettingsFilePath(Core.Constants.AppName);
323  
324                      Logger.LogInfo($"Reading configuration file: {settingsPath}");
325  
326                      if (!File.Exists(settingsPath))
327                      {
328                          Logger.LogError("The settings file does not exist. Scaffolding default configuration...");
329  
330                          AwakeSettings scaffoldSettings = new();
331                          _settingsUtils.SaveSettings(JsonSerializer.Serialize(scaffoldSettings), Core.Constants.AppName);
332                      }
333  
334                      ScaffoldConfiguration(settingsPath);
335  
336                      if (pid != 0)
337                      {
338                          if (!ProcessExists(pid))
339                          {
340                              Logger.LogError($"PID {pid} does not exist or is not accessible. Exiting.");
341                              Exit(Resources.AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE, 1);
342                          }
343  
344                          Logger.LogInfo($"Bound to target process while also using PowerToys settings: {pid}");
345  
346                          RunnerHelper.WaitForPowerToysRunner(pid, () =>
347                          {
348                              Logger.LogInfo($"Triggered PID-based exit handler for PID {pid}.");
349                              Exit(Resources.AWAKE_EXIT_BINDING_HOOK_MESSAGE, 0);
350                          });
351                      }
352                  }
353                  catch (Exception ex)
354                  {
355                      Logger.LogError($"There was a problem with the configuration file. Make sure it exists. {ex.Message}");
356                  }
357              }
358              else if (pid != 0 || useParentPid)
359              {
360                  HandleProcessScopedKeepAwake(pid, useParentPid, displayOn);
361              }
362              else
363              {
364                  // Date-based binding takes precedence over timed configuration, so we want to
365                  // check for that first.
366                  if (!string.IsNullOrWhiteSpace(expireAt))
367                  {
368                      try
369                      {
370                          DateTimeOffset expirationDateTime = DateTimeOffset.Parse(expireAt, CultureInfo.CurrentCulture);
371                          Logger.LogInfo($"Operating in thread ID {Environment.CurrentManagedThreadId}.");
372                          Manager.SetExpirableKeepAwake(expirationDateTime, displayOn);
373                      }
374                      catch (Exception ex)
375                      {
376                          Logger.LogError($"Could not parse date string {expireAt} into a DateTimeOffset object.");
377                          Logger.LogError(ex.Message);
378                      }
379                  }
380                  else
381                  {
382                      AwakeMode mode = timeLimit <= 0 ? AwakeMode.INDEFINITE : AwakeMode.TIMED;
383  
384                      if (mode == AwakeMode.INDEFINITE)
385                      {
386                          Manager.SetIndefiniteKeepAwake(displayOn);
387                      }
388                      else
389                      {
390                          Manager.SetTimedKeepAwake(timeLimit, displayOn);
391                      }
392                  }
393              }
394          }
395  
396          /// <summary>
397          /// Start a process-scoped keep-awake session. The application will keep the system awake
398          /// indefinitely until the target process terminates.
399          /// </summary>
400          /// <param name="pid">The explicit process ID to monitor.</param>
401          /// <param name="useParentPid">A flag indicating whether the application should monitor its
402          /// parent process.</param>
403          /// <param name="displayOn">Whether to keep the display on during the session.</param>
404          private static void HandleProcessScopedKeepAwake(int pid, bool useParentPid, bool displayOn)
405          {
406              int targetPid = 0;
407  
408              // We prioritize a user-provided PID over the parent PID. If both are given on the
409              // command line, the --pid value will be used.
410              if (pid != 0)
411              {
412                  if (pid == Environment.ProcessId)
413                  {
414                      Logger.LogError("Awake cannot bind to itself, as this would lead to an indefinite keep-awake state.");
415                      Exit(Resources.AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE, 1);
416                  }
417  
418                  if (!ProcessExists(pid))
419                  {
420                      Logger.LogError($"PID {pid} does not exist or is not accessible. Exiting.");
421                      Exit(Resources.AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE, 1);
422                  }
423  
424                  targetPid = pid;
425              }
426              else if (useParentPid)
427              {
428                  targetPid = Manager.GetParentProcess()?.Id ?? 0;
429  
430                  if (targetPid == 0)
431                  {
432                      // The parent process could not be identified.
433                      Logger.LogError("Failed to identify a parent process for binding.");
434                      Exit(Resources.AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE, 1);
435                  }
436              }
437  
438              // We have a valid non-zero PID to monitor.
439              Logger.LogInfo($"Bound to target process: {targetPid}");
440  
441              // Sets the keep-awake plan and updates the tray icon.
442              Manager.SetIndefiniteKeepAwake(displayOn, targetPid);
443  
444              // Synchronize with the target process, and trigger Exit() when it finishes.
445              RunnerHelper.WaitForPowerToysRunner(targetPid, () =>
446              {
447                  Logger.LogInfo($"Triggered PID-based exit handler for PID {targetPid}.");
448                  Exit(Resources.AWAKE_EXIT_BINDING_HOOK_MESSAGE, 0);
449              });
450          }
451  
452          private static void AllocateLocalConsole()
453          {
454              Manager.AllocateConsole();
455  
456              _handler = new ConsoleEventHandler(ExitHandler);
457              Manager.SetConsoleControlHandler(_handler, true);
458  
459              Trace.Listeners.Add(new ConsoleTraceListener());
460          }
461  
462          private static void ScaffoldConfiguration(string settingsPath)
463          {
464              try
465              {
466                  SetupFileSystemWatcher(settingsPath);
467                  InitializeSettings();
468                  ProcessSettings();
469              }
470              catch (Exception ex)
471              {
472                  Logger.LogError($"An error occurred scaffolding the configuration. Error details: {ex.Message}");
473              }
474          }
475  
476          private static void SetupFileSystemWatcher(string settingsPath)
477          {
478              string directory = Path.GetDirectoryName(settingsPath)!;
479              string fileName = Path.GetFileName(settingsPath);
480  
481              _watcher = new FileSystemWatcher
482              {
483                  Path = directory,
484                  EnableRaisingEvents = true,
485                  NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime,
486                  Filter = fileName,
487              };
488  
489              Observable.Merge(
490                  Observable.FromEventPattern<FileSystemEventHandler, FileSystemEventArgs>(
491                      h => _watcher.Changed += h,
492                      h => _watcher.Changed -= h),
493                  Observable.FromEventPattern<FileSystemEventHandler, FileSystemEventArgs>(
494                      h => _watcher.Created += h,
495                      h => _watcher.Created -= h))
496              .Throttle(TimeSpan.FromMilliseconds(25))
497              .SubscribeOn(TaskPoolScheduler.Default)
498              .Select(e => e.EventArgs)
499              .Subscribe(HandleAwakeConfigChange);
500          }
501  
502          private static void InitializeSettings()
503          {
504              AwakeSettings settings = Manager.ModuleSettings?.GetSettings<AwakeSettings>(Core.Constants.AppName) ?? new AwakeSettings();
505              TrayHelper.SetTray(settings, _startedFromPowerToys);
506          }
507  
508          private static void HandleAwakeConfigChange(FileSystemEventArgs fileEvent)
509          {
510              try
511              {
512                  Logger.LogInfo("Detected a settings file change. Updating configuration...");
513                  ProcessSettings();
514              }
515              catch (Exception e)
516              {
517                  Logger.LogError($"Could not handle Awake configuration change. Error: {e.Message}");
518              }
519          }
520  
521          private static void ProcessSettings()
522          {
523              try
524              {
525                  AwakeSettings settings = _settingsUtils!.GetSettings<AwakeSettings>(Core.Constants.AppName)
526                      ?? throw new InvalidOperationException("Settings are null.");
527  
528                  Logger.LogInfo($"Identified custom time shortcuts for the tray: {settings.Properties.CustomTrayTimes.Count}");
529  
530                  switch (settings.Properties.Mode)
531                  {
532                      case AwakeMode.PASSIVE:
533                          Manager.SetPassiveKeepAwake();
534                          break;
535  
536                      case AwakeMode.INDEFINITE:
537                          Manager.SetIndefiniteKeepAwake(settings.Properties.KeepDisplayOn);
538                          break;
539  
540                      case AwakeMode.TIMED:
541                          uint computedTime = (settings.Properties.IntervalHours * 3600) + (settings.Properties.IntervalMinutes * 60);
542                          Manager.SetTimedKeepAwake(computedTime, settings.Properties.KeepDisplayOn);
543                          break;
544  
545                      case AwakeMode.EXPIRABLE:
546                          // When we are loading from the settings file, let's make sure that we never
547                          // get users in a state where the expirable keep-awake is in the past.
548                          if (settings.Properties.ExpirationDateTime <= DateTimeOffset.Now)
549                          {
550                              settings.Properties.ExpirationDateTime = DateTimeOffset.Now.AddMinutes(5);
551                              _settingsUtils.SaveSettings(JsonSerializer.Serialize(settings), Core.Constants.AppName);
552  
553                              // Return here - the FileSystemWatcher will re-trigger ProcessSettings
554                              // with the corrected expiration time, which will then call SetExpirableKeepAwake.
555                              // This matches the pattern used by mode setters (e.g., SetExpirableKeepAwake line 292).
556                              return;
557                          }
558  
559                          Manager.SetExpirableKeepAwake(settings.Properties.ExpirationDateTime, settings.Properties.KeepDisplayOn);
560                          break;
561  
562                      default:
563                          Logger.LogError("Unknown mode of operation. Check config file.");
564                          break;
565                  }
566  
567                  TrayHelper.SetTray(settings, _startedFromPowerToys);
568              }
569              catch (Exception ex)
570              {
571                  Logger.LogError($"There was a problem reading the configuration file. Error: {ex.GetType()} {ex.Message}");
572              }
573          }
574      }
575  }