diff --git a/common/Scripts/ModifyWindowsOptionalFeatures.cs b/common/Scripts/ModifyWindowsOptionalFeatures.cs index dd0c340476..09f9c0160a 100644 --- a/common/Scripts/ModifyWindowsOptionalFeatures.cs +++ b/common/Scripts/ModifyWindowsOptionalFeatures.cs @@ -13,105 +13,121 @@ namespace DevHome.Common.Scripts; -public static class ModifyWindowsOptionalFeatures -{ - public static async Task ModifyFeaturesAsync( +public class ModifyWindowsOptionalFeatures : IDisposable +{ + private readonly Process _process; + private readonly ILogger? _log; + private readonly CancellationToken _cancellationToken; + private readonly string _featuresString; + private Stopwatch _stopwatch = new(); + private bool _disposed; + + public ModifyWindowsOptionalFeatures( IEnumerable features, - ILogger? log = null, - CancellationToken cancellationToken = default) - { - if (!features.Any(f => f.HasChanged)) - { - return ExitCode.Success; - } + ILogger? log, + CancellationToken cancellationToken) + { + _log = log; + _cancellationToken = cancellationToken; // Format the argument for the PowerShell script using `n as a newline character since the list // will be parsed with ConvertFrom-StringData. // The format is FeatureName1=True|False`nFeatureName2=True|False`n... - var featuresString = string.Empty; + _featuresString = string.Empty; foreach (var featureState in features) { if (featureState.HasChanged) { - featuresString += $"{featureState.Feature.FeatureName}={featureState.IsEnabled}`n"; + _featuresString += $"{featureState.Feature.FeatureName}={featureState.IsEnabled}`n"; } - } - - var scriptString = Script.Replace("FEATURE_STRING_INPUT", featuresString); - var process = new Process - { - StartInfo = new ProcessStartInfo - { - WindowStyle = ProcessWindowStyle.Hidden, - FileName = "powershell.exe", - Arguments = $"-ExecutionPolicy Bypass -Command {scriptString}", - UseShellExecute = true, - Verb = "runas", - }, - }; - - var exitCode = ExitCode.Failure; - - Stopwatch stopwatch = Stopwatch.StartNew(); - + } + + var scriptString = Script.Replace("FEATURE_STRING_INPUT", _featuresString); + _process = new Process + { + StartInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + FileName = "powershell.exe", + Arguments = $"-ExecutionPolicy Bypass -Command {scriptString}", + UseShellExecute = true, + Verb = "runas", + }, + }; + } + + public async Task Execute() + { + var exitCode = ExitCode.Success; + await Task.Run( + () => + { + // Since a UAC prompt will be shown, we need to wait for the process to exit + // This can also be cancelled by the user which will result in an exception, + // which is handled as a failure. + try + { + if (_cancellationToken.IsCancellationRequested) + { + _log?.Information("Operation was cancelled."); + exitCode = ExitCode.Cancelled; + } + + _process.Start(); + } + catch (Exception ex) + { + // This is most likely a case where the user cancelled the UAC prompt. + exitCode = HandleProcessExecutionException(ex, _log); + } + }, + _cancellationToken); + + if (exitCode == ExitCode.Success) + { + _stopwatch = Stopwatch.StartNew(); + } + + return exitCode; + } + + public async Task WaitForCompleted() + { + var exitCode = ExitCode.Success; await Task.Run( () => { - // Since a UAC prompt will be shown, we need to wait for the process to exit - // This can also be cancelled by the user which will result in an exception, - // which is handled as a failure. try { - if (cancellationToken.IsCancellationRequested) - { - log?.Information("Operation was cancelled."); - exitCode = ExitCode.Cancelled; - return; - } - - process.Start(); - while (!process.WaitForExit(1000)) + while (!_process.WaitForExit(1000)) { - if (cancellationToken.IsCancellationRequested) + if (_cancellationToken.IsCancellationRequested) { // Attempt to kill the process if cancellation is requested exitCode = ExitCode.Cancelled; - process.Kill(); - log?.Information("Operation was cancelled."); + _process.Kill(); + _log?.Information("Operation was cancelled."); return; } } - exitCode = FromExitCode(process.ExitCode); + exitCode = FromExitCode(_process.ExitCode); } catch (Exception ex) { - // This is most likely a case where the user cancelled the UAC prompt. - if (ex is System.ComponentModel.Win32Exception win32Exception) - { - if (win32Exception.NativeErrorCode == 1223) - { - log?.Information(ex, "UAC was cancelled by the user."); - exitCode = ExitCode.Cancelled; - } - } - else - { - log?.Error(ex, "Script failed"); - exitCode = ExitCode.Failure; - } + exitCode = HandleProcessExecutionException(ex, _log); } }, - cancellationToken); + _cancellationToken); - stopwatch.Stop(); + _stopwatch.Stop(); ModifyWindowsOptionalFeaturesEvent.Log( - featuresString, + _featuresString, exitCode, - stopwatch.ElapsedMilliseconds); + _stopwatch.ElapsedMilliseconds); - return exitCode; + return ExitCode.Success; } public enum ExitCode @@ -131,6 +147,24 @@ private static ExitCode FromExitCode(int exitCode) }; } + private static ExitCode HandleProcessExecutionException(Exception ex, ILogger? log = null) + { + if (ex is System.ComponentModel.Win32Exception win32Exception) + { + if (win32Exception.NativeErrorCode == 1223) + { + log?.Information(ex, "UAC was cancelled by the user."); + return ExitCode.Cancelled; + } + } + else + { + log?.Error(ex, "Script failed"); + } + + return ExitCode.Failure; + } + public static string GetExitCodeDescription(ExitCode exitCode) { return exitCode switch @@ -139,6 +173,25 @@ public static string GetExitCodeDescription(ExitCode exitCode) ExitCode.Failure => "Failure", _ => "Cancelled", }; + } + + protected void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _process.Dispose(); + } + + _disposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); } /// @@ -216,5 +269,5 @@ function ModifyFeatures($featuresString) } ModifyFeatures FEATURE_STRING_INPUT; -"; +"; } diff --git a/tools/Customization/DevHome.Customization/Strings/en-us/Resources.resw b/tools/Customization/DevHome.Customization/Strings/en-us/Resources.resw index 53354d6cc6..5860f7124e 100644 --- a/tools/Customization/DevHome.Customization/Strings/en-us/Resources.resw +++ b/tools/Customization/DevHome.Customization/Strings/en-us/Resources.resw @@ -317,11 +317,11 @@ Apply Apply changes to feature state. - + Committing changes Title displayed during the process of applying changes - + Please wait for the changes to take effect. Message indicating the user should wait for the changes to be applied @@ -329,11 +329,15 @@ Restart now Button text to restart the system immediately - + + Restart now + Button text to restart the system immediately + + Cancel Button text to cancel the current operation - + Restart required Title displayed when a restart is required to apply changes @@ -341,7 +345,15 @@ Please restart your machine for your applied changes to take effect. Message instructing the user to restart their machine to apply changes - + + Please restart your machine for your applied changes to take effect. + Message instructing the user to restart their machine to apply changes + + + Restart now + Button text to restart the system immediately + + Don't restart now Button text to postpone system restart diff --git a/tools/Customization/DevHome.Customization/ViewModels/ModifyFeaturesDialogViewModel.cs b/tools/Customization/DevHome.Customization/ViewModels/ModifyFeaturesDialogViewModel.cs index ce3622b50b..2a45564937 100644 --- a/tools/Customization/DevHome.Customization/ViewModels/ModifyFeaturesDialogViewModel.cs +++ b/tools/Customization/DevHome.Customization/ViewModels/ModifyFeaturesDialogViewModel.cs @@ -3,102 +3,20 @@ using System.Threading; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using DevHome.Common.Helpers; -using DevHome.Common.Services; namespace DevHome.Customization.ViewModels; public partial class ModifyFeaturesDialogViewModel : ObservableObject { - private readonly StringResource _stringResource; - - private readonly IAsyncRelayCommand _applyChangesCommand; - private CancellationTokenSource? _cancellationTokenSource; - public enum State - { - Initial, - CommittingChanges, - Complete, - } - - [ObservableProperty] - private State _currentState = State.Initial; - - public ModifyFeaturesDialogViewModel(IAsyncRelayCommand applyChangedCommand) - { - _stringResource = new StringResource("DevHome.Customization.pri", "DevHome.Customization/Resources"); - _applyChangesCommand = applyChangedCommand; - } - - [ObservableProperty] - private string _title = string.Empty; - - [ObservableProperty] - private string _message = string.Empty; - - [ObservableProperty] - private string _primaryButtonText = string.Empty; - - [ObservableProperty] - private string _secondaryButtonText = string.Empty; - - [ObservableProperty] - private bool _isPrimaryButtonEnabled; - - [ObservableProperty] - private bool _isSecondaryButtonEnabled; - - [ObservableProperty] - private bool _showProgress; - public void SetCommittingChanges(CancellationTokenSource cancellationTokenSource) { - CurrentState = State.CommittingChanges; - _cancellationTokenSource = cancellationTokenSource; - IsPrimaryButtonEnabled = false; - IsSecondaryButtonEnabled = true; - ShowProgress = true; - Title = _stringResource.GetLocalized("CommittingChangesTitle"); - Message = _stringResource.GetLocalized("CommittingChangesMessage"); - PrimaryButtonText = _stringResource.GetLocalized("RestartNowButtonText"); - SecondaryButtonText = _stringResource.GetLocalized("CancelButtonText"); - } - - public void SetCompleteRestartRequired() - { - CurrentState = State.Complete; - - _cancellationTokenSource = null; - IsPrimaryButtonEnabled = true; - IsSecondaryButtonEnabled = true; - ShowProgress = false; - Title = _stringResource.GetLocalized("RestartRequiredTitle"); - Message = _stringResource.GetLocalized("RestartRequiredMessage"); - PrimaryButtonText = _stringResource.GetLocalized("RestartNowButtonText"); - SecondaryButtonText = _stringResource.GetLocalized("DontRestartNowButtonText"); - } - - internal void HandlePrimaryButton() - { - switch (CurrentState) - { - case State.Complete: - RestartHelper.RestartComputer(); - break; - } } - internal void HandleSecondaryButton() + internal void HandleCancel() { - switch (CurrentState) - { - case State.CommittingChanges: - _cancellationTokenSource?.Cancel(); - break; - } + _cancellationTokenSource?.Cancel(); } } diff --git a/tools/Customization/DevHome.Customization/ViewModels/VirtualizationFeatureManagementViewModel.cs b/tools/Customization/DevHome.Customization/ViewModels/VirtualizationFeatureManagementViewModel.cs index efd3a9aa24..60de434dce 100644 --- a/tools/Customization/DevHome.Customization/ViewModels/VirtualizationFeatureManagementViewModel.cs +++ b/tools/Customization/DevHome.Customization/ViewModels/VirtualizationFeatureManagementViewModel.cs @@ -21,6 +21,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Serilog; +using Windows.Foundation; namespace DevHome.Customization.ViewModels; @@ -38,6 +39,8 @@ public partial class VirtualizationFeatureManagementViewModel : ObservableObject private readonly ModifyFeaturesDialog _modifyFeaturesDialog; + private readonly RestartDialog _restartDialog; + private StackedNotificationsBehavior? _notificationQueue; public IAsyncRelayCommand LoadFeaturesCommand { get; } @@ -90,7 +93,12 @@ public VirtualizationFeatureManagementViewModel(Window window) } }; - _modifyFeaturesDialog = new ModifyFeaturesDialog(ApplyChangesCommand) + _modifyFeaturesDialog = new ModifyFeaturesDialog() + { + XamlRoot = _window.Content.XamlRoot, + }; + + _restartDialog = new RestartDialog() { XamlRoot = _window.Content.XamlRoot, }; @@ -203,17 +211,26 @@ private async Task ApplyChangesAsync() var cancellationTokenSource = new CancellationTokenSource(); _modifyFeaturesDialog.ViewModel.SetCommittingChanges(cancellationTokenSource); - var showDialogTask = _modifyFeaturesDialog.ShowAsync(); - await _window.DispatcherQueue.EnqueueAsync(async () => { - var exitCode = await ModifyWindowsOptionalFeatures.ModifyFeaturesAsync(Features, _log, cancellationTokenSource.Token); + if (!Features.Any(f => f.HasChanged)) + { + return; + } + + var modifyFeatures = new ModifyWindowsOptionalFeatures(Features, _log, cancellationTokenSource.Token); + var exitCode = await modifyFeatures.Execute(); - await LoadFeaturesCommand.ExecuteAsync(null); - _restartNeeded = HasFeatureStatusChanged(); - if (_restartNeeded) + if (exitCode == ModifyWindowsOptionalFeatures.ExitCode.Success) { - ShowRestartNotification(); + var showOperation = _modifyFeaturesDialog.ShowAsync(); + exitCode = await modifyFeatures.WaitForCompleted(); + + await LoadFeaturesCommand.ExecuteAsync(null); + _restartNeeded = HasFeatureStatusChanged(); + + _modifyFeaturesDialog.Hide(); + await showOperation; } switch (exitCode) @@ -223,7 +240,7 @@ await _window.DispatcherQueue.EnqueueAsync(async () => // to be displayed when the user navigates away from the page and returns. if (_restartNeeded) { - _modifyFeaturesDialog.ViewModel.SetCompleteRestartRequired(); + await _restartDialog.ShowAsync(); } else { @@ -243,7 +260,10 @@ await _window.DispatcherQueue.EnqueueAsync(async () => } }); - await showDialogTask; + if (_restartNeeded) + { + ShowRestartNotification(); + } } private async Task OnFeaturesChanged() diff --git a/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml b/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml index 780a05af9e..a864608eb5 100644 --- a/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml +++ b/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml @@ -2,16 +2,12 @@ x:Class="DevHome.Customization.Views.ModifyFeaturesDialog" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + x:Uid="ms-resource:///DevHome.Customization/Resources/ModifyFeaturesDialog" Style="{ThemeResource DefaultContentDialogStyle}" DefaultButton="Primary" PrimaryButtonStyle="{StaticResource AccentButtonStyle}" - Title="{x:Bind ViewModel.Title, Mode=OneWay}" - PrimaryButtonText="{x:Bind ViewModel.PrimaryButtonText, Mode=OneWay}" - SecondaryButtonText="{x:Bind ViewModel.SecondaryButtonText, Mode=OneWay}" - IsPrimaryButtonEnabled="{x:Bind ViewModel.IsPrimaryButtonEnabled, Mode=OneWay}" - IsSecondaryButtonEnabled="{x:Bind ViewModel.IsSecondaryButtonEnabled, Mode=OneWay}" - PrimaryButtonClick="OnPrimaryButtonClick" - SecondaryButtonClick="OnSecondaryButtonClick"> + IsPrimaryButtonEnabled="False" + SecondaryButtonClick="OnCancelClick"> diff --git a/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml.cs b/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml.cs index a66770dd27..1da956ec41 100644 --- a/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml.cs +++ b/tools/Customization/DevHome.Customization/Views/ModifyFeaturesDialog.xaml.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using CommunityToolkit.Mvvm.Input; using DevHome.Customization.ViewModels; using Microsoft.UI.Xaml.Controls; @@ -11,20 +10,15 @@ public sealed partial class ModifyFeaturesDialog : ContentDialog { public ModifyFeaturesDialogViewModel ViewModel { get; } - public ModifyFeaturesDialog(IAsyncRelayCommand applyChangedCommand) + public ModifyFeaturesDialog() { - ViewModel = new ModifyFeaturesDialogViewModel(applyChangedCommand); + ViewModel = new ModifyFeaturesDialogViewModel(); this.InitializeComponent(); this.DataContext = ViewModel; } - private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + private void OnCancelClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) { - ViewModel.HandlePrimaryButton(); - } - - private void OnSecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) - { - ViewModel.HandleSecondaryButton(); + ViewModel.HandleCancel(); } } diff --git a/tools/Customization/DevHome.Customization/Views/RestartDialog.xaml b/tools/Customization/DevHome.Customization/Views/RestartDialog.xaml new file mode 100644 index 0000000000..d669e70099 --- /dev/null +++ b/tools/Customization/DevHome.Customization/Views/RestartDialog.xaml @@ -0,0 +1,23 @@ + + + + + + diff --git a/tools/Customization/DevHome.Customization/Views/RestartDialog.xaml.cs b/tools/Customization/DevHome.Customization/Views/RestartDialog.xaml.cs new file mode 100644 index 0000000000..73bc429407 --- /dev/null +++ b/tools/Customization/DevHome.Customization/Views/RestartDialog.xaml.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using DevHome.Common.Helpers; +using Microsoft.UI.Xaml.Controls; + +namespace DevHome.Customization.Views; + +public sealed partial class RestartDialog : ContentDialog +{ + public RestartDialog() + { + this.InitializeComponent(); + } + + private void OnRestartClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + RestartHelper.RestartComputer(); + } +}