Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix focus transition issues related to dialogs and UAC in Virtualization #3640

Merged
merged 3 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 120 additions & 67 deletions common/Scripts/ModifyWindowsOptionalFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,105 +13,121 @@

nieubank marked this conversation as resolved.
Show resolved Hide resolved
namespace DevHome.Common.Scripts;

public static class ModifyWindowsOptionalFeatures
{
public static async Task<ExitCode> 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<WindowsOptionalFeatureState> 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";
nieubank marked this conversation as resolved.
Show resolved Hide resolved
}
}

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<ExitCode> 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<ExitCode> 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
Expand All @@ -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;
}
nieubank marked this conversation as resolved.
Show resolved Hide resolved
}
else
{
log?.Error(ex, "Script failed");
}

return ExitCode.Failure;
}

public static string GetExitCodeDescription(ExitCode exitCode)
{
return exitCode switch
Expand All @@ -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);
}

/// <summary>
Expand Down Expand Up @@ -216,5 +269,5 @@ function ModifyFeatures($featuresString)
}

ModifyFeatures FEATURE_STRING_INPUT;
";
";
}
Original file line number Diff line number Diff line change
Expand Up @@ -317,31 +317,43 @@
<value>Apply</value>
<comment>Apply changes to feature state.</comment>
</data>
<data name="CommittingChangesTitle" xml:space="preserve">
<data name="ModifyFeaturesDialog.Title" xml:space="preserve">
<value>Committing changes</value>
<comment>Title displayed during the process of applying changes</comment>
</data>
<data name="CommittingChangesMessage" xml:space="preserve">
<data name="ModifyFeaturesDialogMessage.Text" xml:space="preserve">
<value>Please wait for the changes to take effect.</value>
<comment>Message indicating the user should wait for the changes to be applied</comment>
</data>
<data name="RestartNowButtonText" xml:space="preserve">
<value>Restart now</value>
<comment>Button text to restart the system immediately</comment>
</data>
<data name="CancelButtonText" xml:space="preserve">
<data name="ModifyFeaturesDialog.PrimaryButtonText" xml:space="preserve">
<value>Restart now</value>
<comment>Button text to restart the system immediately</comment>
</data>
<data name="ModifyFeaturesDialog.SecondaryButtonText" xml:space="preserve">
<value>Cancel</value>
<comment>Button text to cancel the current operation</comment>
</data>
<data name="RestartRequiredTitle" xml:space="preserve">
<data name="RestartDialog.Title" xml:space="preserve">
<value>Restart required</value>
<comment>Title displayed when a restart is required to apply changes</comment>
</data>
<data name="RestartRequiredMessage" xml:space="preserve">
<value>Please restart your machine for your applied changes to take effect.</value>
<comment>Message instructing the user to restart their machine to apply changes</comment>
</data>
<data name="DontRestartNowButtonText" xml:space="preserve">
<data name="RestartRequiredDialogMessage.Text" xml:space="preserve">
<value>Please restart your machine for your applied changes to take effect.</value>
<comment>Message instructing the user to restart their machine to apply changes</comment>
</data>
<data name="RestartDialog.PrimaryButtonText" xml:space="preserve">
<value>Restart now</value>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a random thought. Is it clear to the user that this is to restart the machine and not just DevHome?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a good question. I'll follow up with our content writer to see if more clarity is needed there.

<comment>Button text to restart the system immediately</comment>
</data>
<data name="RestartDialog.SecondaryButtonText" xml:space="preserve">
<value>Don't restart now</value>
<comment>Button text to postpone system restart</comment>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Loading
Loading