diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/BasicTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/BasicTests.cs index 43662a2145..d6a5e8ca32 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/BasicTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/BasicTests.cs @@ -4,6 +4,8 @@ using System; using Microsoft.CmdPal.Ext.System.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.CmdPal.Ext.System.UnitTests; @@ -38,6 +40,72 @@ public class BasicTests Assert.IsTrue(Enum.IsDefined(typeof(FirmwareType), firmwareType)); } + [TestMethod] + public void UpdateShutdownFlagsTest() + { + // SHUTDOWN_INSTALL_UPDATES | SHUTDOWN_RESTART + Assert.AreEqual(0x44u, WindowsUpdateHelper.GetUpdateShutdownFlags(restart: true)); + + // SHUTDOWN_INSTALL_UPDATES | SHUTDOWN_POWEROFF + Assert.AreEqual(0x48u, WindowsUpdateHelper.GetUpdateShutdownFlags(restart: false)); + } + + [TestMethod] + public void UpdatePendingDetectionDoesNotThrowTest() + { + // The WUAPI query must never throw; on any failure it reports false. The actual + // value depends on the machine's update state, but back-to-back calls within the + // cache interval must agree (the second call takes the cached path). + var first = WindowsUpdateHelper.IsUpdatePending(); + var second = WindowsUpdateHelper.IsUpdatePending(); + + Assert.AreEqual(first, second, "Cached query should return the same value within the cache interval."); + } + + [TestMethod] + public void ExecuteCommandSurfacesFuncResult() + { + // A command built from a Func must return that result rather than + // always dismissing, so a failed action can keep the palette open. + var command = new ExecuteCommand(() => CommandResult.ShowToast("failure")); + + var result = command.Invoke(); + + Assert.AreEqual(CommandResultKind.ShowToast, result.Kind); + } + + [TestMethod] + public void ConfirmationCommandDirectPathSurfacesFailureResult() + { + // Confirmation off: invoking runs the action directly and must return its result + // (here a failure toast) instead of silently dismissing. + var command = new ExecuteCommandConfirmation("name", confirm: false, "message", () => CommandResult.ShowToast("failure")); + + var result = command.Invoke(); + + Assert.AreEqual(CommandResultKind.ShowToast, result.Kind); + } + + [TestMethod] + public void ConfirmationCommandConfirmedPathSurfacesFailureResult() + { + // Confirmation on: Invoke returns a Confirm result, and the primary command it + // carries must surface the same failure result when the user confirms. + var command = new ExecuteCommandConfirmation("name", confirm: true, "message", () => CommandResult.ShowToast("failure")); + + var confirmResult = command.Invoke(); + Assert.AreEqual(CommandResultKind.Confirm, confirmResult.Kind); + + var args = confirmResult.Args as ConfirmationArgs; + Assert.IsNotNull(args, "Confirm result should carry ConfirmationArgs."); + + var primary = args.PrimaryCommand as InvokableCommand; + Assert.IsNotNull(primary, "Confirmation should carry an invokable primary command."); + + var innerResult = primary.Invoke(); + Assert.AreEqual(CommandResultKind.ShowToast, innerResult.Kind); + } + [TestMethod] public void NetworkConnectionPropertiesTest() { diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs index 38cb8a4aa6..976179379d 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs @@ -145,4 +145,54 @@ public class QueryTests : CommandPaletteUnitTestBase var firstItemIsUefiCommand = firstItem?.Title.Contains("UEFI", StringComparison.OrdinalIgnoreCase) ?? false; Assert.AreEqual(hasCommand, firstItemIsUefiCommand, $"Expected to match (or not match) 'UEFI firmware settings' but got '{firstItem?.Title}'"); } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void UpdateCommandsAvailabilityTest(bool updatePending) + { + var settings = new Settings(updatePending: updatePending); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + + var hasUpdateRestart = allCommands.Any(i => i.Title.Equals("Update and restart", StringComparison.Ordinal)); + var hasUpdateShutdown = allCommands.Any(i => i.Title.Equals("Update and shut down", StringComparison.Ordinal)); + + Assert.AreEqual(updatePending, hasUpdateRestart, "'Update and restart' should only be listed while updates are pending."); + Assert.AreEqual(updatePending, hasUpdateShutdown, "'Update and shut down' should only be listed while updates are pending."); + } + + [TestMethod] + public void UpdateCommandsMatchQueryTest() + { + var settings = new Settings(updatePending: true); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + + var result = Query("update", allCommands); + + Assert.IsNotNull(result); + Assert.IsTrue( + result.Any(i => i.Title.Equals("Update and restart", StringComparison.Ordinal)), + "'update' query should match the 'Update and restart' command."); + Assert.IsTrue( + result.Any(i => i.Title.Equals("Update and shut down", StringComparison.Ordinal)), + "'update' query should match the 'Update and shut down' command."); + } + + [TestMethod] + public void UpdateCommandsHaveStableIdsTest() + { + var settings = new Settings(updatePending: true); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + + var updateRestart = allCommands.FirstOrDefault(i => i.Title.Equals("Update and restart", StringComparison.Ordinal)); + var updateShutdown = allCommands.FirstOrDefault(i => i.Title.Equals("Update and shut down", StringComparison.Ordinal)); + + Assert.IsNotNull(updateRestart); + Assert.IsNotNull(updateShutdown); + Assert.AreEqual("com.microsoft.cmdpal.builtin.system.update_restart", updateRestart.Command?.Id); + Assert.AreEqual("com.microsoft.cmdpal.builtin.system.update_shutdown", updateShutdown.Command?.Id); + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Settings.cs index 6e2a0dc221..9ec7999bb5 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Settings.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Settings.cs @@ -18,14 +18,16 @@ public class Settings : ISettingsInterface private bool showDialogToConfirmCommand; private bool showSuccessMessageAfterEmptyingRecycleBin; private FirmwareType firmwareType; + private bool updatePending; - public Settings(bool hideDisconnectedNetworkInfo = false, bool hideEmptyRecycleBin = false, bool showDialogToConfirmCommand = false, bool showSuccessMessageAfterEmptyingRecycleBin = false, FirmwareType firmwareType = FirmwareType.Uefi) + public Settings(bool hideDisconnectedNetworkInfo = false, bool hideEmptyRecycleBin = false, bool showDialogToConfirmCommand = false, bool showSuccessMessageAfterEmptyingRecycleBin = false, FirmwareType firmwareType = FirmwareType.Uefi, bool updatePending = false) { this.hideDisconnectedNetworkInfo = hideDisconnectedNetworkInfo; this.hideEmptyRecycleBin = hideEmptyRecycleBin; this.showDialogToConfirmCommand = showDialogToConfirmCommand; this.showSuccessMessageAfterEmptyingRecycleBin = showSuccessMessageAfterEmptyingRecycleBin; this.firmwareType = firmwareType; + this.updatePending = updatePending; } public bool HideDisconnectedNetworkInfo() => hideDisconnectedNetworkInfo; @@ -37,4 +39,6 @@ public class Settings : ISettingsInterface public bool ShowSuccessMessageAfterEmptyingRecycleBin() => showSuccessMessageAfterEmptyingRecycleBin; public FirmwareType GetSystemFirmwareType() => firmwareType; + + public bool IsUpdatePending() => updatePending; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs index d890ed39b6..e217fa6fb7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs @@ -10,15 +10,20 @@ namespace Microsoft.CmdPal.Ext.System; public sealed partial class ExecuteCommand : InvokableCommand { public ExecuteCommand(Action command) + : this(() => + { + command(); + return CommandResult.Dismiss(); + }) + { + } + + public ExecuteCommand(Func command) { _command = command; } - public override CommandResult Invoke() - { - _command(); - return CommandResult.Dismiss(); - } + public override CommandResult Invoke() => _command(); - private Action _command; + private Func _command; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs index a82935fa82..b9ffe145fd 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs @@ -10,6 +10,15 @@ namespace Microsoft.CmdPal.Ext.System; public sealed partial class ExecuteCommandConfirmation : InvokableCommand { public ExecuteCommandConfirmation(string name, bool confirm, string confirmationMessage, Action command) + : this(name, confirm, confirmationMessage, () => + { + command(); + return CommandResult.Dismiss(); + }) + { + } + + public ExecuteCommandConfirmation(string name, bool confirm, string confirmationMessage, Func command) { Name = name; _command = command; @@ -32,11 +41,10 @@ public sealed partial class ExecuteCommandConfirmation : InvokableCommand return CommandResult.Confirm(confirmationArgs); } - _command(); - return CommandResult.Dismiss(); + return _command(); } private bool _confirm; private string _confirmationMessage; - private Action _command; + private Func _command; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs index 8624953891..6f9ee7c2ab 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs @@ -14,6 +14,15 @@ internal sealed partial class FallbackSystemCommandItem : FallbackCommandItem { private const string _id = "com.microsoft.cmdpal.builtin.system.fallback"; + private readonly ISettingsInterface _settings; + + // Whether Windows Update was waiting for a restart the last time the command list + // was built. Unlike the other inputs this can change during a session, so we track + // it and rebuild the list when it flips instead of capturing it once. + private bool _isUpdatePending; + + private List systemCommands; + public FallbackSystemCommandItem(ISettingsInterface settings) : base(new NoOpCommand(), Resources.Microsoft_plugin_ext_fallback_display_title, _id) { @@ -21,15 +30,20 @@ internal sealed partial class FallbackSystemCommandItem : FallbackCommandItem Subtitle = string.Empty; Icon = Icons.LockIcon; - var isBootedInUefiMode = settings.GetSystemFirmwareType() == FirmwareType.Uefi; - var hideEmptyRB = settings.HideEmptyRecycleBin(); - var confirmSystemCommands = settings.ShowDialogToConfirmCommand(); - var showSuccessOnEmptyRB = settings.ShowSuccessMessageAfterEmptyingRecycleBin(); - - systemCommands = Commands.GetSystemCommands(isBootedInUefiMode, hideEmptyRB, confirmSystemCommands, showSuccessOnEmptyRB); + _settings = settings; + _isUpdatePending = settings.IsUpdatePending(); + systemCommands = BuildSystemCommands(_isUpdatePending); } - private readonly List systemCommands; + private List BuildSystemCommands(bool isUpdatePending) + { + var isBootedInUefiMode = _settings.GetSystemFirmwareType() == FirmwareType.Uefi; + var hideEmptyRB = _settings.HideEmptyRecycleBin(); + var confirmSystemCommands = _settings.ShowDialogToConfirmCommand(); + var showSuccessOnEmptyRB = _settings.ShowSuccessMessageAfterEmptyingRecycleBin(); + + return Commands.GetSystemCommands(isBootedInUefiMode, isUpdatePending, hideEmptyRB, confirmSystemCommands, showSuccessOnEmptyRB); + } public override void UpdateQuery(string query) { @@ -41,6 +55,17 @@ internal sealed partial class FallbackSystemCommandItem : FallbackCommandItem return; } + // The update-pending state can change while CmdPal is running (an update gets + // staged, or a restart clears it). Re-check it and rebuild the list only when it + // changes so the "Update and restart"/"shut down" items don't go stale. The + // underlying WUAPI query is cached, so this stays cheap per keystroke. + var isUpdatePending = _settings.IsUpdatePending(); + if (isUpdatePending != _isUpdatePending) + { + _isUpdatePending = isUpdatePending; + systemCommands = BuildSystemCommands(isUpdatePending); + } + IListItem? result = null; var resultScore = 0; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs index 8bc05a1522..2c5f19f6bf 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs @@ -29,15 +29,30 @@ internal static class Commands private static List networkPropertiesCache = new List(); private static DateTime timeOfLastNetworkQuery; + /// + /// Runs the "update and restart"/"update and shut down" action and turns the outcome + /// into a . When the shutdown can't be started we keep the + /// palette open and show a localized error instead of silently dismissing. + /// + private static CommandResult UpdateShutdownResult(bool restart) + => WindowsUpdateHelper.InitiateUpdateShutdown(restart) + ? CommandResult.Dismiss() + : CommandResult.ShowToast(new ToastArgs + { + Message = Resources.Microsoft_plugin_sys_update_failed, + Result = CommandResult.KeepOpen(), + }); + /// /// Returns a list with all system command results /// /// Value indicating if the system is booted in uefi mode + /// Value indicating if Windows Update is waiting for a restart to install updates. /// Value indicating if we should hide the Empty Recycle Bin command. /// A value indicating if the user should confirm the system commands /// Show a success message after empty Recycle Bin. /// A list of all results - public static List GetSystemCommands(bool isUefi, bool hideEmptyRecycleBin, bool confirmCommands, bool emptyRBSuccessMessage) + public static List GetSystemCommands(bool isUefi, bool isUpdatePending, bool hideEmptyRecycleBin, bool confirmCommands, bool emptyRBSuccessMessage) { var results = new List(); results.AddRange(new[] @@ -60,6 +75,39 @@ internal static class Commands Title = Resources.Microsoft_plugin_sys_restart_computer, Icon = Icons.RestartIcon, }, + }); + + // Update and restart/shut down commands. Only available while Windows Update + // is waiting for a restart to finish installing updates (mirrors the Start menu). + if (isUpdatePending) + { + results.AddRange(new[] + { + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_update_and_shutdown_confirmation, () => UpdateShutdownResult(restart: false)) + { + Id = "com.microsoft.cmdpal.builtin.system.update_shutdown", + }) + { + Title = Resources.Microsoft_plugin_sys_update_and_shutdown, + Subtitle = Resources.Microsoft_plugin_sys_update_and_shutdown_description, + Icon = Icons.ShutdownIcon, + }, + new ListItem( + new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_update_and_restart_confirmation, () => UpdateShutdownResult(restart: true)) + { + Id = "com.microsoft.cmdpal.builtin.system.update_restart", + }) + { + Title = Resources.Microsoft_plugin_sys_update_and_restart, + Subtitle = Resources.Microsoft_plugin_sys_update_and_restart_description, + Icon = Icons.RestartIcon, + }, + }); + } + + results.AddRange(new[] + { new ListItem( new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_signout, confirmCommands, Resources.Microsoft_plugin_sys_sign_out_confirmation, () => NativeMethods.ExitWindowsEx(EWXLOGOFF, 0)) { @@ -242,13 +290,14 @@ internal static class Commands var networkConnectionResults = Commands.GetNetworkConnectionResults(manager); var isBootedInUefiMode = manager.GetSystemFirmwareType() == FirmwareType.Uefi; + var isUpdatePending = manager.IsUpdatePending(); var hideEmptyRB = manager.HideEmptyRecycleBin(); var confirmSystemCommands = manager.ShowDialogToConfirmCommand(); var showSuccessOnEmptyRB = manager.ShowSuccessMessageAfterEmptyingRecycleBin(); // normal system commands are fast and can be returned immediately - var systemCommands = Commands.GetSystemCommands(isBootedInUefiMode, hideEmptyRB, confirmSystemCommands, showSuccessOnEmptyRB); + var systemCommands = Commands.GetSystemCommands(isBootedInUefiMode, isUpdatePending, hideEmptyRB, confirmSystemCommands, showSuccessOnEmptyRB); list.AddRange(systemCommands); list.AddRange(networkConnectionResults); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ISettingsInterface.cs index 1690007126..b814139f00 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ISettingsInterface.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ISettingsInterface.cs @@ -21,4 +21,6 @@ public interface ISettingsInterface public bool HideDisconnectedNetworkInfo(); public FirmwareType GetSystemFirmwareType(); + + public bool IsUpdatePending(); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs index ff077089ac..2efcd5649f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs @@ -55,6 +55,8 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface public FirmwareType GetSystemFirmwareType() => Win32Helpers.GetSystemFirmwareType(); + public bool IsUpdatePending() => WindowsUpdateHelper.IsUpdatePending(); + public SettingsManager() { FilePath = SettingsJsonPath(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/WindowsUpdateHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/WindowsUpdateHelper.cs new file mode 100644 index 0000000000..bb91e10535 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/WindowsUpdateHelper.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.Shutdown; + +namespace Microsoft.CmdPal.Ext.System.Helpers; + +/// +/// Detects whether Windows Update is waiting for a restart to finish installing updates +/// and initiates the "update and restart" / "update and shut down" actions. +/// +internal static partial class WindowsUpdateHelper +{ + // SHTDN_REASON_FLAG_PLANNED | SHTDN_REASON_MAJOR_OPERATINGSYSTEM | SHTDN_REASON_MINOR_UPGRADE + private const SHUTDOWN_REASON ShutdownReasonPlannedOsUpgrade = + SHUTDOWN_REASON.SHTDN_REASON_FLAG_PLANNED | SHUTDOWN_REASON.SHTDN_REASON_MAJOR_OPERATINGSYSTEM | SHUTDOWN_REASON.SHTDN_REASON_MINOR_UPGRADE; + + private const uint ClsCtxInprocServer = 0x1; + + // WUAPI SystemInformation coclass (wuapi.idl) + private static readonly Guid SystemInformationClsid = new("C01B9BA0-BEA7-41BA-B604-D0A36F469133"); + private static readonly Guid SystemInformationIid = new("ADE87BF7-7B56-4275-8FAB-B9B0E591844B"); + + private static readonly StrategyBasedComWrappers ComWrappers = new(); + + // Cache the WUAPI answer for a few seconds so that per-keystroke queries don't + // repeatedly instantiate the COM object (same approach as the network info cache). + private const long UpdateCacheIntervalMs = 5000; + private static readonly object CacheLock = new(); + private static bool cachedRebootRequired; + private static bool hasCachedValue; + + // Monotonic timestamp (Environment.TickCount64) of the last successful query, so a + // wall-clock change can't make the cache look newer or older than it really is. + private static long lastQueryTicks; + + /// + /// Gets a value indicating whether Windows Update requires a restart to finish + /// installing updates, i.e. whether the Start menu would show "Update and restart". + /// Returns false if the state cannot be determined. + /// + public static bool IsUpdatePending() + { + // The whole check-and-refresh runs under the lock so the timestamp and the value + // are always published together; otherwise a concurrent caller could read a stale + // value while the timestamp already claims it is fresh. + lock (CacheLock) + { + var now = Environment.TickCount64; + if (hasCachedValue && (now - lastQueryTicks) < UpdateCacheIntervalMs) + { + return cachedRebootRequired; + } + + cachedRebootRequired = QueryRebootRequired(); + lastQueryTicks = Environment.TickCount64; + hasCachedValue = true; + return cachedRebootRequired; + } + } + + private static bool QueryRebootRequired() + { + try + { + var hr = CoCreateInstance(in SystemInformationClsid, IntPtr.Zero, ClsCtxInprocServer, in SystemInformationIid, out var instance); + if (hr < 0) + { + return false; + } + + try + { + var systemInformation = (ISystemInformation)ComWrappers.GetOrCreateObjectForComInstance(instance, CreateObjectFlags.None); + return systemInformation.GetRebootRequired(); + } + finally + { + Marshal.Release(instance); + } + } + catch (Exception ex) + { + ExtensionHost.LogMessage(new LogMessage() { Message = $"Failed to query Windows Update reboot state: {ex.Message}" }); + return false; + } + } + + /// + /// Returns the InitiateShutdown flags for an "update and restart" (true) or + /// "update and shut down" (false) request. + /// + public static uint GetUpdateShutdownFlags(bool restart) + => (uint)(SHUTDOWN_FLAGS.SHUTDOWN_INSTALL_UPDATES | (restart ? SHUTDOWN_FLAGS.SHUTDOWN_RESTART : SHUTDOWN_FLAGS.SHUTDOWN_POWEROFF)); + + /// + /// Installs pending updates and restarts (true) or shuts down (false) the computer. + /// + /// True if the system accepted the shutdown request. + public static unsafe bool InitiateUpdateShutdown(bool restart) + { + HANDLE token; + if (!PInvoke.OpenProcessToken(PInvoke.GetCurrentProcess(), TOKEN_ACCESS_MASK.TOKEN_ADJUST_PRIVILEGES | TOKEN_ACCESS_MASK.TOKEN_QUERY, &token)) + { + ExtensionHost.LogMessage(new LogMessage() { Message = $"OpenProcessToken failed with Win32 error {Marshal.GetLastPInvokeError()}" }); + return false; + } + + try + { + // InitiateShutdown requires the (normally disabled) shutdown privilege. Enable it, + // remembering the previous state so we can put it back afterwards instead of + // leaving it enabled for the rest of the process lifetime. + if (!TryEnableShutdownPrivilege(token, out var previousState, out var hasPreviousState)) + { + return false; + } + + try + { + var result = PInvoke.InitiateShutdown(null, null, 0, (SHUTDOWN_FLAGS)GetUpdateShutdownFlags(restart), ShutdownReasonPlannedOsUpgrade); + if (result != 0) + { + ExtensionHost.LogMessage(new LogMessage() { Message = $"InitiateShutdown failed with Win32 error {result}" }); + return false; + } + + return true; + } + finally + { + if (hasPreviousState) + { + _ = PInvoke.AdjustTokenPrivileges(token, false, &previousState, (uint)sizeof(TOKEN_PRIVILEGES), null, null); + } + } + } + finally + { + _ = NativeMethods.CloseHandle(token); + } + } + + /// + /// Enables SeShutdownPrivilege on the given token, returning the prior state so + /// the caller can restore it. Returns false (and logs) if the privilege could not be + /// assigned — AdjustTokenPrivileges reports success even then, so the last error + /// must be checked for ERROR_NOT_ALL_ASSIGNED. + /// + private static unsafe bool TryEnableShutdownPrivilege(HANDLE token, out TOKEN_PRIVILEGES previousState, out bool hasPreviousState) + { + previousState = default; + hasPreviousState = false; + + if (!PInvoke.LookupPrivilegeValue(null, PInvoke.SE_SHUTDOWN_NAME, out var luid)) + { + ExtensionHost.LogMessage(new LogMessage() { Message = $"LookupPrivilegeValue failed with Win32 error {Marshal.GetLastPInvokeError()}" }); + return false; + } + + var privileges = new TOKEN_PRIVILEGES + { + PrivilegeCount = 1, + }; + privileges.Privileges[0] = new LUID_AND_ATTRIBUTES + { + Luid = luid, + Attributes = TOKEN_PRIVILEGES_ATTRIBUTES.SE_PRIVILEGE_ENABLED, + }; + + TOKEN_PRIVILEGES prior; + uint returnLength; + var adjusted = PInvoke.AdjustTokenPrivileges(token, false, &privileges, (uint)sizeof(TOKEN_PRIVILEGES), &prior, &returnLength); + var lastError = Marshal.GetLastPInvokeError(); + + if (!adjusted || lastError == (int)WIN32_ERROR.ERROR_NOT_ALL_ASSIGNED) + { + ExtensionHost.LogMessage(new LogMessage() { Message = $"Failed to enable SeShutdownPrivilege (Win32 error {lastError})" }); + return false; + } + + previousState = prior; + hasPreviousState = returnLength > 0; + return true; + } + + [LibraryImport("ole32.dll")] + private static partial int CoCreateInstance(in Guid rclsid, IntPtr pUnkOuter, uint dwClsContext, in Guid riid, out IntPtr ppv); +} + +/// +/// WUAPI ISystemInformation (wuapi.idl). This is a dual interface; the first four +/// methods are placeholders occupying the IDispatch vtable slots and must never be +/// called. The remaining members are called through the vtable, which dual interfaces +/// populate alongside IDispatch. +/// +[GeneratedComInterface] +[Guid("ADE87BF7-7B56-4275-8FAB-B9B0E591844B")] +internal partial interface ISystemInformation +{ + // IDispatch slots — placeholders, do not call. + void GetTypeInfoCountPlaceholder(nint pctinfo); + + void GetTypeInfoPlaceholder(uint iTInfo, uint lcid, nint ppTInfo); + + void GetIDsOfNamesPlaceholder(nint riid, nint rgszNames, uint cNames, uint lcid, nint rgDispId); + + void InvokePlaceholder(int dispIdMember, nint riid, uint lcid, ushort wFlags, nint pDispParams, nint pVarResult, nint pExcepInfo, nint puArgErr); + + // ISystemInformation members, in vtable order. + [return: MarshalAs(UnmanagedType.BStr)] + string GetOemHardwareSupportLink(); + + [return: MarshalAs(UnmanagedType.VariantBool)] + bool GetRebootRequired(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj index 14ee20b07a..728f782f9a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj @@ -11,8 +11,19 @@ false + + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + Resources.resx diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/NativeMethods.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/NativeMethods.json new file mode 100644 index 0000000000..a2e14c3baf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "useSafeHandles": false +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/NativeMethods.txt new file mode 100644 index 0000000000..7ecc9629a4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/NativeMethods.txt @@ -0,0 +1,17 @@ +InitiateShutdown +OpenProcessToken +GetCurrentProcess +LookupPrivilegeValue +AdjustTokenPrivileges + +SHUTDOWN_RESTART +SHUTDOWN_POWEROFF +SHUTDOWN_INSTALL_UPDATES +SHTDN_REASON_FLAG_PLANNED +SHTDN_REASON_MAJOR_OPERATINGSYSTEM +SHTDN_REASON_MINOR_UPGRADE +SE_PRIVILEGE_ENABLED +TOKEN_ADJUST_PRIVILEGES +TOKEN_QUERY +SE_SHUTDOWN_NAME +ERROR_NOT_ALL_ASSIGNED diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs index 5d219f477f..62f1c8453f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs @@ -779,7 +779,70 @@ namespace Microsoft.CmdPal.Ext.System { return ResourceManager.GetString("Microsoft_plugin_sys_Unknown", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Update and restart. + /// + public static string Microsoft_plugin_sys_update_and_restart { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_update_and_restart", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are about to install pending Windows updates and restart this computer, are you sure?. + /// + public static string Microsoft_plugin_sys_update_and_restart_confirmation { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_update_and_restart_confirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Install pending Windows updates and restart the computer. + /// + public static string Microsoft_plugin_sys_update_and_restart_description { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_update_and_restart_description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Update and shut down. + /// + public static string Microsoft_plugin_sys_update_and_shutdown { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_update_and_shutdown", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are about to install pending Windows updates and shut down this computer, are you sure?. + /// + public static string Microsoft_plugin_sys_update_and_shutdown_confirmation { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_update_and_shutdown_confirmation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Install pending Windows updates and shut down the computer. + /// + public static string Microsoft_plugin_sys_update_and_shutdown_description { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_update_and_shutdown_description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Couldn't start the Windows update. Please try again.. + /// + public static string Microsoft_plugin_sys_update_failed { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_update_failed", resourceCulture); + } + } + /// /// Looks up a localized string similar to WINS servers. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx index c500935517..26a2978e5e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx @@ -374,6 +374,34 @@ Unknown + + Update and restart + This should align to the "Update and restart" entry in the Windows Start menu power flyout. + + + You are about to install pending Windows updates and restart this computer, are you sure? + Confirmation message for the "Update and restart" command. + + + Install pending Windows updates and restart the computer + This should align to the "Update and restart" entry in the Windows Start menu power flyout. + + + Update and shut down + This should align to the "Update and shut down" entry in the Windows Start menu power flyout. + + + You are about to install pending Windows updates and shut down this computer, are you sure? + Confirmation message for the "Update and shut down" command. + + + Install pending Windows updates and shut down the computer + This should align to the "Update and shut down" entry in the Windows Start menu power flyout. + + + Couldn't start the Windows update. Please try again. + Error shown when the "Update and restart"/"Update and shut down" command fails to start. + WINS servers