diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index cea59cacad..7a0b02cac9 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -70,6 +70,7 @@ APPMODEL APPNAME appref appsettings +appsfeatures appwindow appwiz appxpackage diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationCommand.cs new file mode 100644 index 0000000000..aea17631c6 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationCommand.cs @@ -0,0 +1,130 @@ +// 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.Diagnostics; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Management.Deployment; + +namespace Microsoft.CmdPal.Ext.Apps.Commands; + +internal sealed partial class UninstallApplicationCommand : InvokableCommand +{ + // This is a ms-settings URI that opens the Apps & Features page in Windows Settings. + // It's correct and follows the Microsoft documentation: + // https://learn.microsoft.com/en-us/windows/apps/develop/launch/launch-settings-app#apps + private const string AppsFeaturesUri = "ms-settings:appsfeatures"; + + private readonly UWPApplication? _uwpTarget; + private readonly Win32Program? _win32Target; + + public UninstallApplicationCommand(UWPApplication target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _uwpTarget = target ?? throw new ArgumentNullException(nameof(target)); + } + + public UninstallApplicationCommand(Win32Program target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _win32Target = target ?? throw new ArgumentNullException(nameof(target)); + } + + private async Task UninstallUwpAppAsync(UWPApplication app) + { + if (string.IsNullOrWhiteSpace(app.Package.FullName)) + { + Logger.LogError($"Critical error while uninstalling: packageFullName cannot be null or empty."); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + + try + { + // Which timeout to use for the uninstallation operation? + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60))) + { + var packageManager = new PackageManager(); + var result = await packageManager.RemovePackageAsync(app.Package.FullName).AsTask(cts.Token); + + if (result.ErrorText is not null && result.ErrorText.Length > 0) + { + Logger.LogError($"Failed to uninstall {app.Package.FullName}: {result.ErrorText}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + } + + // TODO: Update the Search results after uninstalling the app - unsure how to do this yet. + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_successful), app.DisplayName), + Result = CommandResult.GoHome(), + }); + } + catch (OperationCanceledException) + { + Logger.LogError($"Timeout exceeded while uninstalling {app.Package.FullName}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + catch (UnauthorizedAccessException ex) + { + Logger.LogError($"Permission denied to uninstall {app.Package.FullName}. Elevated privileges may be required. Error: {ex.Message}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + catch (Exception ex) + { + Logger.LogError($"An unexpected error occurred during uninstallation of {app.Package.FullName}: {ex.Message}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + } + + public override CommandResult Invoke() + { + if (_uwpTarget is not null) + { + return UninstallUwpAppAsync(_uwpTarget).ConfigureAwait(false).GetAwaiter().GetResult(); + } + + if (_win32Target is not null) + { + Process.Start(new ProcessStartInfo + { + FileName = AppsFeaturesUri, + UseShellExecute = true, + }); + return CommandResult.Dismiss(); + } + + Logger.LogError("UninstallApplicationCommand invoked with no target."); + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationConfirmation.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationConfirmation.cs new file mode 100644 index 0000000000..b0444178e0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationConfirmation.cs @@ -0,0 +1,66 @@ +// 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.Globalization; +using System.Text; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.Commands; + +internal sealed partial class UninstallApplicationConfirmation : InvokableCommand +{ + private readonly UWPApplication? _uwpTarget; + private readonly Win32Program? _win32Target; + + public UninstallApplicationConfirmation(UWPApplication target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _uwpTarget = target ?? throw new ArgumentNullException(nameof(target)); + } + + public UninstallApplicationConfirmation(Win32Program target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _win32Target = target ?? throw new ArgumentNullException(nameof(target)); + } + + public override CommandResult Invoke() + { + UninstallApplicationCommand uninstallCommand; + + var applicationTitle = Resources.uninstall_application; + + if (_uwpTarget is not null) + { + uninstallCommand = new UninstallApplicationCommand(_uwpTarget); + applicationTitle = _uwpTarget.DisplayName; + } + else if (_win32Target is not null) + { + uninstallCommand = new UninstallApplicationCommand(_win32Target); + applicationTitle = _win32Target.Name; + } + else + { + Logger.LogError("UninstallApplicationCommand invoked with no target."); + return CommandResult.Dismiss(); + } + + var confirmArgs = new ConfirmationArgs() + { + Title = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_confirm_title), applicationTitle), + Description = Resources.uninstall_application_confirm_description, + PrimaryCommand = uninstallCommand, + IsPrimaryCommandCritical = true, + }; + + return CommandResult.Confirm(confirmArgs); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs index 68f46bcee1..5f4a3e7a92 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs @@ -21,4 +21,6 @@ internal sealed class Icons public static IconInfo UnpinIcon { get; } = new("\uE77A"); // Unpin icon public static IconInfo PinIcon { get; } = new("\uE840"); // Pin icon + + public static IconInfo UninstallApplicationIcon { get; } = new("\uE74D"); // Uninstall icon } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs index 9b5c663082..541de2eee1 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs @@ -117,6 +117,14 @@ public class UWPApplication : IUWPApplication RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R), }); + commands.Add( + new CommandContextItem( + new UninstallApplicationConfirmation(this)) + { + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete), + IsCritical = true, + }); + return commands; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs index 32455b5ea4..dea7fb1e20 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs @@ -219,6 +219,16 @@ public class Win32Program : IProgram RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R), }); + if (AppType == ApplicationType.ShortcutApplication || AppType == ApplicationType.ApprefApplication || AppType == ApplicationType.Win32Application) + { + commands.Add(new CommandContextItem( + new UninstallApplicationConfirmation(this)) + { + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete), + IsCritical = true, + }); + } + return commands; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs index df36044dd1..9c4c288904 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs @@ -330,6 +330,51 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } } + /// + /// Looks up a localized string similar to Uninstall. + /// + internal static string uninstall_application { + get { + return ResourceManager.GetString("uninstall_application", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This app and its related information will be removed.. + /// + internal static string uninstall_application_confirm_description { + get { + return ResourceManager.GetString("uninstall_application_confirm_description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Uninstall "{0}"?. + /// + internal static string uninstall_application_confirm_title { + get { + return ResourceManager.GetString("uninstall_application_confirm_title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error while uninstalling '{0}'. + /// + internal static string uninstall_application_failed { + get { + return ResourceManager.GetString("uninstall_application_failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to '{0}' has been successfully uninstalled.. + /// + internal static string uninstall_application_successful { + get { + return ResourceManager.GetString("uninstall_application_successful", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unpin. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx index 9a2ce1cc76..4191efddd5 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx @@ -198,6 +198,21 @@ Unpin + + Uninstall + + + '{0}' has been successfully uninstalled. + + + Error while uninstalling '{0}' + + + This app and its related information will be removed. + + + Uninstall "{0}"? + 1