diff --git a/installer/BcuSetup.iss b/installer/BcuSetup.iss index 1eaff35c..76e1d66b 100644 --- a/installer/BcuSetup.iss +++ b/installer/BcuSetup.iss @@ -13,6 +13,7 @@ #include "Scripts\PortablePage.iss" #include "Scripts\PortableIcons.iss" #include "Scripts\Ngen.iss" +#include "Scripts\ShellExtension.iss" [Setup] AppId={{f4fef76c-1aa9-441c-af7e-d27f58d898d1} diff --git a/installer/scripts/ShellExtension.iss b/installer/scripts/ShellExtension.iss new file mode 100644 index 00000000..75264dd6 --- /dev/null +++ b/installer/scripts/ShellExtension.iss @@ -0,0 +1,7 @@ +[Run] +Filename: "{win}\Microsoft.NET\Framework64\v4.0.30319\RegAsm.exe"; Parameters: "/nologo /codebase ""{app}\win-x64\ContextMenuExtension.dll""" + +[UninstallRun] +Filename: "{win}\Microsoft.NET\Framework64\v4.0.30319\RegAsm.exe"; Parameters: "/nologo /unregister ""{app}\win-x64\ContextMenuExtension.dll""" +Filename: "{sys}\taskkill"; Parameters: "/F /IM explorer.exe" +Filename: "{win}\explorer.exe"; diff --git a/source/BCU-console/Program.cs b/source/BCU-console/Program.cs index 53e4958c..3de45996 100644 --- a/source/BCU-console/Program.cs +++ b/source/BCU-console/Program.cs @@ -5,6 +5,7 @@ Apache License Version 2.0 using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -29,6 +30,13 @@ private static void ShowHelp() filename – Specifies filename of the .bcul uninstall list that contains information about what applications to uninstall. +BCU-console uninstall [drive:][path]filename /P [/Q] [/U] [/V] - Uninstall single application by executable path. Accepts multiple applications. + [drive:][path] – Specifies drive and directory of the executable file. + filename – Specifies filename of executable file of the application to uninstall. + +BCU-console uninstall appName /N [/Q] [/U] [/V] - Uninstall single application by name. + appName – Specifies the display name of the application to remove. + BCU-console export [drive:][path]filename [/Q] [/U] [/V] - Export installed application data to xml file. [drive:][path] – Specifies drive and directory to where the export should be saved. filename – Specifies filename of the .xml file to save the exported application information to. @@ -161,30 +169,43 @@ private static int ProcessUninstallCommand(string[] args) if (!File.Exists(args[0])) return ShowInvalidSyntaxError("Invalid path or missing list file"); - UninstallList list; - try - { - list = UninstallList.ReadFromFile(args[0]); - if (list == null || list.Filters.Count == 0) - throw new IOException("List is empty"); - } - catch (SystemException ex) - { - return ShowInvalidSyntaxError( - $"Invalid or damaged uninstall list file - \"{args[0]}\"\nError: {ex.Message}\n"); - } - var isVerbose = args.Any(x => x.Equals("/V", StringComparison.OrdinalIgnoreCase)); var isQuiet = args.Any(x => x.Equals("/Q", StringComparison.OrdinalIgnoreCase)); var isUnattended = args.Any(x => x.Equals("/U", StringComparison.OrdinalIgnoreCase)); + var uninstallByPath = args.Any(x => x.Equals("/P", StringComparison.OrdinalIgnoreCase)); + var uninstallByName = args.Any(x => x.Equals("/N", StringComparison.OrdinalIgnoreCase)); if (isUnattended) Console.WriteLine(@"WARNING: Running in unattended mode. To abort press Ctrl+C or close the window."); - return RunUninstall(list, isQuiet, isUnattended, isVerbose); + UninstallList list; + + if (uninstallByPath) + { + return RunUninstallByPathCommand(args.Where(x => !x.StartsWith('/') && File.Exists(x)), isQuiet, isUnattended, isVerbose); + } + else if (uninstallByName) + { + return RunUninstallByNameCommand(args[0], isQuiet, isUnattended, isVerbose); + } + else // Uninstall with list + { + try + { + list = UninstallList.ReadFromFile(args[0]); + if (list == null || list.Filters.Count == 0) + throw new IOException("List is empty"); + } + catch (SystemException ex) + { + return ShowInvalidSyntaxError( + $"Invalid or damaged uninstall list file - \"{args[0]}\"\nError: {ex.Message}\n"); + } + return RunUninstallListCommand(list, isQuiet, isUnattended, isVerbose); + } } - private static int RunUninstall(UninstallList list, bool isQuiet, bool isUnattended, bool isVerbose) + private static int RunUninstallListCommand(UninstallList list, bool isQuiet, bool isUnattended, bool isVerbose) { Console.WriteLine(@"Starting bulk uninstall..."); var apps = QueryApps(isQuiet, isUnattended, isVerbose); @@ -206,7 +227,7 @@ private static int RunUninstall(UninstallList list, bool isQuiet, bool isUnatten { Console.WriteLine(@"Do you want to continue? [Y]es/[N]o"); if (Console.ReadKey(true).Key != ConsoleKey.Y) - return CancelledByUser(); + return CanceledByUser(); } Console.WriteLine(@"Setting-up for the uninstall task..."); @@ -246,6 +267,133 @@ private static int RunUninstall(UninstallList list, bool isQuiet, bool isUnatten return 0; } + private static int RunUninstallByPathCommand(IEnumerable paths, bool isQuiet, bool isUnattended, bool isVerbose) + { + Console.WriteLine(@"Starting uninstall..."); + + var queryResult = QueryApps(isQuiet, isUnattended, isVerbose); + var apps = new List(10); + apps.AddRange(from executablePath in paths + let target = queryResult.FirstOrDefault(a => NameMatches(a.DisplayName, FileVersionInfo.GetVersionInfo(executablePath).ProductName)) + where target != default + select target); + + if (apps.Count == 0) + { + Console.WriteLine(@"No uninstallers found with the given applications."); + return 0; + } + + Console.WriteLine("{0} application(s) were matched: {1}", apps.Count, + string.Join("; ", apps.Select(x => x.DisplayName))); + + Console.WriteLine(@"These applications will now be uninstalled PERMANENTLY."); + + // TODO: Below lines are (almost) the same with RunUninstallListCommand method. + // It is hard to extract method as is. It needs improvement. + if (!isUnattended) + { + Console.WriteLine(@"Do you want to continue? [Y]es/[N]o"); + if (Console.ReadKey(true).Key != ConsoleKey.Y) + return CanceledByUser(); + } + + Console.WriteLine(@"Setting-up for the uninstall task..."); + var targets = apps.Select(a => new BulkUninstallEntry(a, a.QuietUninstallPossible, UninstallStatus.Waiting)).ToList(); + var task = UninstallManager.CreateBulkUninstallTask(targets, + new BulkUninstallConfiguration(false, isQuiet, false, true, true)); + var isDone = false; + task.OnStatusChanged += (sender, args) => + { + ClearCurrentConsoleLine(); + + var running = task.AllUninstallersList.Count(x => x.IsRunning); + var waiting = task.AllUninstallersList.Count(x => x.CurrentStatus == UninstallStatus.Waiting); + var finished = task.AllUninstallersList.Count(x => x.Finished); + var errors = task.AllUninstallersList.Count(x => x.CurrentStatus == UninstallStatus.Failed || + x.CurrentStatus == UninstallStatus.Invalid); + Console.Write("Running: {0}, Waiting: {1}, Finished: {2}, Failed: {3}", + running, waiting, finished, errors); + + if (task.Finished) + { + isDone = true; + Console.WriteLine(); + Console.WriteLine(@"Uninstall task Finished."); + + foreach (var error in task.AllUninstallersList.Where(x => + x.CurrentStatus != UninstallStatus.Completed && x.CurrentError != null)) + Console.WriteLine($@"Error: {error.UninstallerEntry.DisplayName} - {error.CurrentError.Message}"); + } + }; + task.Start(); + + while (!isDone) + Thread.Sleep(250); + + return 0; + } + + private static int RunUninstallByNameCommand(string appName, bool isQuiet, bool isUnattended, bool isVerbose) + { + Console.WriteLine(@"Starting uninstall..."); + var queryResult = QueryApps(isQuiet, isUnattended, isVerbose); + var target = queryResult.FirstOrDefault(a => NameMatches(a.DisplayName, appName)); + + if (target == default) + { + Console.WriteLine(@"No uninstallers found with the given name."); + return 0; + } + + Console.WriteLine(@$"The application will now be uninstalled PERMANENTLY: {target.DisplayName}"); + + // TODO: Below lines are (almost) the same with RunUninstallListCommand method. + // It is hard to extract method as is. It needs improvement. + if (!isUnattended) + { + Console.WriteLine(@"Do you want to continue? [Y]es/[N]o"); + if (Console.ReadKey(true).Key != ConsoleKey.Y) + return CanceledByUser(); + } + + Console.WriteLine(@"Setting-up for the uninstall task..."); + var apps = new List(1) { target }; + var targets = apps.Select(a => new BulkUninstallEntry(a, a.QuietUninstallPossible, UninstallStatus.Waiting)).ToList(); + var task = UninstallManager.CreateBulkUninstallTask(targets, + new BulkUninstallConfiguration(false, isQuiet, false, true, true)); + var isDone = false; + task.OnStatusChanged += (sender, args) => + { + ClearCurrentConsoleLine(); + + var running = task.AllUninstallersList.Count(x => x.IsRunning); + var waiting = task.AllUninstallersList.Count(x => x.CurrentStatus == UninstallStatus.Waiting); + var finished = task.AllUninstallersList.Count(x => x.Finished); + var errors = task.AllUninstallersList.Count(x => x.CurrentStatus == UninstallStatus.Failed || + x.CurrentStatus == UninstallStatus.Invalid); + Console.Write("Running: {0}, Waiting: {1}, Finished: {2}, Failed: {3}", + running, waiting, finished, errors); + + if (task.Finished) + { + isDone = true; + Console.WriteLine(); + Console.WriteLine(@"Uninstall task Finished."); + + foreach (var error in task.AllUninstallersList.Where(x => + x.CurrentStatus != UninstallStatus.Completed && x.CurrentError != null)) + Console.WriteLine($@"Error: {error.UninstallerEntry.DisplayName} - {error.CurrentError.Message}"); + } + }; + task.Start(); + + while (!isDone) + Thread.Sleep(250); + + return 0; + } + public static void ClearCurrentConsoleLine() { var currentLineCursor = Console.CursorTop; @@ -254,9 +402,9 @@ public static void ClearCurrentConsoleLine() Console.SetCursorPosition(0, currentLineCursor); } - private static int CancelledByUser() + private static int CanceledByUser() { - Console.WriteLine(@"Operation cancelled by the user."); + Console.WriteLine(@"Operation canceled by the user."); return 1223; } @@ -275,27 +423,32 @@ private static IList QueryApps(bool isQuiet, bool i else { result = ApplicationUninstallerFactory.GetUninstallerEntries(report => - { - if (previousMain != report.Message) - { - previousMain = report.Message; - Console.WriteLine(report.Message); - } - if (isVerbose) - { - if (!string.IsNullOrEmpty(report.Inner?.Message)) - { - Console.Write("-> "); - Console.WriteLine(report.Inner.Message); - } - } - }); + { + if (previousMain != report.Message) + { + previousMain = report.Message; + Console.WriteLine(report.Message); + } + if (isVerbose) + { + if (!string.IsNullOrEmpty(report.Inner?.Message)) + { + Console.Write("-> "); + Console.WriteLine(report.Inner.Message); + } + } + }); } Console.WriteLine("Found {0} applications.", result.Count); return result; } + private static bool NameMatches(string left, string right) + { + var distance = Klocman.Tools.Sift4.SimplestDistance(left, right, 3); + return distance == 0 || distance < left.Length / 6; + } private static void ConfigureUninstallTools() { UninstallToolsGlobalConfig.ScanWinUpdates = false; diff --git a/source/ContextMenuExtension/CodeSigningKey.snk b/source/ContextMenuExtension/CodeSigningKey.snk new file mode 100644 index 00000000..97ff206d Binary files /dev/null and b/source/ContextMenuExtension/CodeSigningKey.snk differ diff --git a/source/ContextMenuExtension/ContextMenuExtension.csproj b/source/ContextMenuExtension/ContextMenuExtension.csproj new file mode 100644 index 00000000..fee4fea6 --- /dev/null +++ b/source/ContextMenuExtension/ContextMenuExtension.csproj @@ -0,0 +1,49 @@ + + + net48 + win-x64 + Library + false + true + true + AnyCPU;x64;x86 + 8.0 + + + ..\..\bin\Debug\ + + + win-x86 + ..\..\bin\Debug\ + + + win-x64 + ..\..\bin\Debug\ + + + ..\..\bin\Release\ + + + win-x86 + ..\..\bin\Release\ + + + win-x64 + ..\..\bin\Release\ + + + true + + + false + + + CodeSigningKey.snk + + + + + + + + \ No newline at end of file diff --git a/source/ContextMenuExtension/UninstallExtension.cs b/source/ContextMenuExtension/UninstallExtension.cs new file mode 100644 index 00000000..5c6d7b3b --- /dev/null +++ b/source/ContextMenuExtension/UninstallExtension.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Windows.Forms; +using SharpShell.Attributes; +using SharpShell.SharpContextMenu; + +namespace ContextMenuExtension +{ + [ComVisible(true)] + [COMServerAssociation(AssociationType.ClassOfExtension, ".lnk")] + public class UninstallExtension : SharpContextMenu + { + private readonly string _consolePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "BCUninstaller\\win-x64\\BCU-Console.exe"); + private readonly List _executables = new List(); + + protected override bool CanShowMenu() + { + _executables.Clear(); + _executables.AddRange(from item in SelectedItemPaths + where IsExecutable(item) + select GetShortcutTarget(item)); + return _executables.Count > 0; + } + + protected override ContextMenuStrip CreateMenu() + { + var menu = new ContextMenuStrip(); + + var itemUninstall = new ToolStripMenuItem + { + Text = "Uninstall with BCUninstaller" + }; + + itemUninstall.Click += (sender, args) => RunBcUninstaller(); + + menu.Items.Add(itemUninstall); + + return menu; + } + + private void RunBcUninstaller() + { + var result = MessageBox.Show("Uninstallation will start in the background.", "BCUninstaller", MessageBoxButtons.OKCancel, MessageBoxIcon.Information); + if (result != DialogResult.OK) + { + return; + } + + var argument = $"uninstall {string.Join(" ", _executables.Select(exe => "\"" + exe + "\"").ToArray())} /P /U"; +#if DEBUG + MessageBox.Show($"Arguments: {argument}", "BCUninstaller", MessageBoxButtons.OK, MessageBoxIcon.Information); +#endif + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _consolePath, + Arguments = argument, + RedirectStandardError = false, + RedirectStandardOutput = false, + UseShellExecute = true, + ErrorDialog = true, +#if DEBUG + WindowStyle = ProcessWindowStyle.Hidden +#endif + }, + EnableRaisingEvents = true + }; + process.Exited += (sender, obj) => { + _ = process.ExitCode != 0 + ? MessageBox.Show("An error occurred during installation. Please use BCUninstaller GUI.", "BCUninstaller", MessageBoxButtons.OK, MessageBoxIcon.Error) + : MessageBox.Show("Operation completed successfully.", "BCUninstaller", MessageBoxButtons.OK, MessageBoxIcon.Information); + }; + process.Start(); + + } + + private static bool IsExecutable(string item) + { + var target = GetShortcutTarget(item); + return !string.IsNullOrEmpty(target) && Path.GetExtension(target).Equals(".exe", StringComparison.Ordinal); + } + + /// + /// The method reads the binary file to find target path ignoring the arguments. + /// Please check the reference: + /// + /// LNK File full path + /// Target path or empty string. + private static string GetShortcutTarget(string filePath) + { + try + { + if (!StringComparer.OrdinalIgnoreCase.Equals(Path.GetExtension(filePath), ".lnk")) + { + return string.Empty; + } + + var fileStream = File.Open(filePath, FileMode.Open, FileAccess.Read); + using (var fileReader = new BinaryReader(fileStream)) + { + fileStream.Seek(0x0, SeekOrigin.Begin); + // The first 4 bytes of the file form a long integer that is always set to 4Ch this it the ASCII value for the uppercase letter L. + // This is used to identify a valid shell link file. + + var l = fileReader.ReadInt32(); + if (l != 76) + { + return string.Empty; + } + + fileStream.Seek(0x14, SeekOrigin.Begin); // Seek to flags + + //var flags = fileReader.ReadBytes(4); // Read flags + //var flag = new DataFlag(flags); + + var flags = fileReader.ReadUInt32(); // Read flags + if ((flags & 1) == 1) + { // Bit 1 set means we have to + // skip the shell item ID list + fileStream.Seek(0x4c, SeekOrigin.Begin); // Seek to the end of the header + uint offset = fileReader.ReadUInt16(); // Read the length of the Shell item ID list + fileStream.Seek(offset, SeekOrigin.Current); // Seek past it (to the file locator info) + } + + var fileInfoStartsAt = fileStream.Position; // Store the offset where the file info + // structure begins + var totalStructLength = fileReader.ReadUInt32(); // read the length of the whole struct + fileStream.Seek(0xc, SeekOrigin.Current); // seek to offset to base pathname + var fileOffset = fileReader.ReadUInt32(); // read offset to base pathname + // the offset is from the beginning of the file info struct (fileInfoStartsAt) + fileStream.Seek((fileInfoStartsAt + fileOffset), SeekOrigin.Begin); // Seek to beginning of + // base pathname (target) + var pathLength = (totalStructLength + fileInfoStartsAt) - fileStream.Position - 2; // read + // the base pathname. I don't need the 2 terminating nulls. + + + var link = Encoding.Default.GetString(fileReader.ReadBytes((int)pathLength)); + + var begin = link.IndexOf("\0\0"); + if (begin > -1) + { + var end = link.IndexOf("\\\\", begin + 2) + 2; + end = link.IndexOf('\0', end) + 1; + + var firstPart = link.Substring(0, begin); + var secondPart = link.Substring(end); + + return firstPart + secondPart; + } + else + { + return link; + } + } + } + catch + { + return string.Empty; + } + } + } +} \ No newline at end of file