diff --git a/src/AppInstallerCLICore/Commands/RepairCommand.cpp b/src/AppInstallerCLICore/Commands/RepairCommand.cpp index 210dabb584..39543f527d 100644 --- a/src/AppInstallerCLICore/Commands/RepairCommand.cpp +++ b/src/AppInstallerCLICore/Commands/RepairCommand.cpp @@ -104,11 +104,7 @@ namespace AppInstaller::CLI Workflow::GetManifestFromArg << Workflow::ReportManifestIdentity << Workflow::SearchSourceUsingManifest << - Workflow::EnsureOneMatchFromSearchResult(OperationType::Repair) << - Workflow::GetInstalledPackageVersion << - Workflow::SelectInstaller << - Workflow::EnsureApplicableInstaller << - Workflow::RepairSinglePackage; + Workflow::EnsureOneMatchFromSearchResult(OperationType::Repair); } else { @@ -116,10 +112,12 @@ namespace AppInstaller::CLI Workflow::SearchSourceForSingle << Workflow::HandleSearchResultFailures << Workflow::EnsureOneMatchFromSearchResult(OperationType::Repair) << - Workflow::ReportPackageIdentity << - Workflow::GetInstalledPackageVersion << - Workflow::SelectApplicablePackageVersion << - Workflow::RepairSinglePackage; + Workflow::ReportPackageIdentity; } + + context << + Workflow::GetInstalledPackageVersion << + Workflow::SelectApplicableInstallerIfNecessary << + Workflow::RepairSinglePackage; } } diff --git a/src/AppInstallerCLICore/Workflows/RepairFlow.cpp b/src/AppInstallerCLICore/Workflows/RepairFlow.cpp index 44e05419d5..9f113de15b 100644 --- a/src/AppInstallerCLICore/Workflows/RepairFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/RepairFlow.cpp @@ -19,16 +19,14 @@ using namespace AppInstaller::Manifest; using namespace AppInstaller::Msix; using namespace AppInstaller::Repository; +using namespace AppInstaller::CLI::Execution; namespace AppInstaller::CLI::Workflow { // Internal implementation details namespace { - // Sets the uninstall string in the context. - // RequiredArgs: - // Inputs:InstalledPackageVersion - // Outputs:SilentUninstallString, UninstallString + void SetUninstallStringInContext(Execution::Context& context) { const auto& installedPackageVersion = context.Get(); @@ -56,10 +54,6 @@ namespace AppInstaller::CLI::Workflow context.Add(uninstallCommandItr->second); } - // Sets the modify path in the context. - // RequiredArgs:None - // Inputs:InstalledPackageVersion - // Outputs:ModifyPath void SetModifyPathInContext(Execution::Context& context) { const auto& installedPackageVersion = context.Get(); @@ -76,10 +70,6 @@ namespace AppInstaller::CLI::Workflow context.Add(modifyPathItr->second); } - // Sets the product codes in the context. - // RequiredArgs:None - // Inputs:InstalledPackageVersion - // Outputs:ProductCodes void SetProductCodesInContext(Execution::Context& context) { const auto& installedPackageVersion = context.Get(); @@ -94,10 +84,6 @@ namespace AppInstaller::CLI::Workflow context.Add(productCodes); } - // Sets the package family names in the context. - // RequiredArgs:None - // Inputs:InstalledPackageVersion - // Outputs:PackageFamilyNames void SetPackageFamilyNamesInContext(Execution::Context& context) { const auto& installedPackageVersion = context.Get(); @@ -112,10 +98,13 @@ namespace AppInstaller::CLI::Workflow context.Add(packageFamilyNames); } - // The function performs a preliminary check on the installed package by reading its ARP registry flags for NoModify and NoRepair to confirm if the repair operation is applicable. - // RequiredArgs:None - // Inputs:InstalledPackageVersion, NoModify ?, NoRepair ? - // Outputs:None + InstallerTypeEnum GetInstalledType(Execution::Context& context) + { + const auto& installedPackage = context.Get(); + std::string installedType = installedPackage->GetMetadata()[PackageVersionMetadata::InstalledType]; + return ConvertToInstallerTypeEnum(installedType); + } + void ApplicabilityCheckForInstalledPackage(Execution::Context& context) { // Installed Package repair applicability check @@ -145,12 +134,14 @@ namespace AppInstaller::CLI::Workflow } } - // This function performs a preliminary check on the available matching package by reading its manifest entries for repair behavior to determine the type of repair operation and repair switch are applicable - // RequiredArgs:None - // Inputs:InstallerType, RepairBehavior - // Outputs:None void ApplicabilityCheckForAvailablePackage(Execution::Context& context) { + // Skip the Available Package applicability check for MSI and MSIX repair as they aren't needed. + if (!context.Contains(Execution::Data::Installer)) + { + return; + } + // Selected Installer repair applicability check auto installerType = context.Get()->EffectiveInstallerType(); auto repairBehavior = context.Get()->RepairBehavior; @@ -170,10 +161,34 @@ namespace AppInstaller::CLI::Workflow } } - // Generate the repair string based on the repair behavior and installer type. - // RequiredArgs:None - // Inputs:BaseInstallerType, RepairBehavior, ModifyPath?, UninstallString?, InstallerArgs - // Outputs:RepairString + void HandleModifyRepairBehavior(Execution::Context& context, std::string& repairCommand) + { + SetModifyPathInContext(context); + repairCommand += context.Get(); + } + + void HandleInstallerRepairBehavior(Execution::Context& context, InstallerTypeEnum installerType) + { + context << + ShowInstallationDisclaimer << + ShowPromptsForSinglePackage(/* ensureAcceptance */ true) << + DownloadInstaller; + + if (installerType == InstallerTypeEnum::Zip) + { + context << + ScanArchiveFromLocalManifest << + ExtractFilesFromArchive << + VerifyAndSetNestedInstaller; + } + } + + void HandleUninstallerRepairBehavior(Execution::Context& context, std::string& repairCommand) + { + SetUninstallStringInContext(context); + repairCommand += context.Get(); + } + void GenerateRepairString(Execution::Context& context) { const auto& installer = context.Get(); @@ -185,35 +200,14 @@ namespace AppInstaller::CLI::Workflow switch (repairBehavior) { case RepairBehaviorEnum::Modify: - { - SetModifyPathInContext(context); - repairCommand.append(context.Get()); - } - break; + HandleModifyRepairBehavior(context, repairCommand); + break; case RepairBehaviorEnum::Installer: - { - // [NOTE:] We will ShellExecuteInstall for this scenario which uses installer path directly.so no need for repair command generation. - // We prepare installer download and archive extraction here. - context << - ShowInstallationDisclaimer << - ShowPromptsForSinglePackage(/* ensureAcceptance */ true) << - DownloadInstaller; - - if (installerType == InstallerTypeEnum::Zip) - { - context << - ScanArchiveFromLocalManifest << - ExtractFilesFromArchive << - VerifyAndSetNestedInstaller; - } - } - break; + HandleInstallerRepairBehavior(context, installerType); + break; case RepairBehaviorEnum::Uninstaller: - { - SetUninstallStringInContext(context); - repairCommand.append(context.Get()); - } - break; + HandleUninstallerRepairBehavior(context, repairCommand); + break; case RepairBehaviorEnum::Unknown: default: context.Reporter.Error() << Resource::String::NoRepairInfoFound << std::endl; @@ -223,41 +217,81 @@ namespace AppInstaller::CLI::Workflow context << GetInstallerArgs; - // Following is not applicable for RepairBehaviorEnum::Installer, as we can call ShellExecuteInstall directly with repair argument. - if (repairBehavior != RepairBehaviorEnum::Installer) + // If the repair behavior is set to 'Installer', we can proceed with the repair command as is. + // For repair behaviors other than 'Installer', subsequent steps will be necessary to prepare the repair command. + if (repairBehavior == RepairBehaviorEnum::Installer) + { + return; + } + + if (repairCommand.empty()) { - if (repairCommand.empty()) + context.Reporter.Error() << Resource::String::NoRepairInfoFound << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_REPAIR_INFO_FOUND); + } + + repairCommand += " "; + repairCommand += context.Get(); + context.Add(repairCommand); + } + + bool IsInstallerMappingRequired(Execution::Context& context) + { + InstallerTypeEnum installerTypeEnum = GetInstalledType(context); + + switch (installerTypeEnum) + { + case InstallerTypeEnum::Msi: + return false; + case InstallerTypeEnum::Msix: + // For MSIX packages that are from the Microsoft Store, selecting an installer is required. + if (context.Contains(Execution::Data::Package)) { - context.Reporter.Error() << Resource::String::NoRepairInfoFound << std::endl; - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_REPAIR_INFO_FOUND); + auto availablePackages = context.Get()->GetAvailable(); + + if (availablePackages.size() == 1 && availablePackages[0]->GetSource() == WellKnownSource::MicrosoftStore) + { + return true; + } } - repairCommand.append(" "); - repairCommand.append(context.Get()); - context.Add(repairCommand); + // For MSIX packages that are not from the Microsoft Store, selecting an installer is not required. + return false; + default: + return true; } } - } - void RunRepairForRepairBehaviorBasedInstaller(Execution::Context& context) - { - const auto& installer = context.Get(); - auto repairBehavior = installer->RepairBehavior; - - if (repairBehavior == RepairBehaviorEnum::Modify || repairBehavior == RepairBehaviorEnum::Uninstaller) + void HandleModifyOrUninstallerRepair(Execution::Context& context, RepairBehaviorEnum repairBehavior) { context << ShellExecuteRepairImpl << ReportRepairResult(RepairBehaviorToString(repairBehavior), APPINSTALLER_CLI_ERROR_EXEC_REPAIR_FAILED); } - else if (repairBehavior == RepairBehaviorEnum::Installer) + + void HandleInstallerRepair(Execution::Context& context, RepairBehaviorEnum repairBehavior) { context << ShellExecuteInstallImpl << ReportInstallerResult(RepairBehaviorToString(repairBehavior), APPINSTALLER_CLI_ERROR_EXEC_REPAIR_FAILED); } - else + } + + void RunRepairForRepairBehaviorBasedInstaller(Execution::Context& context) + { + const auto& installer = context.Get(); + auto repairBehavior = installer->RepairBehavior; + + switch (repairBehavior) { + case RepairBehaviorEnum::Modify: + case RepairBehaviorEnum::Uninstaller: + HandleModifyOrUninstallerRepair(context, repairBehavior); + break; + case RepairBehaviorEnum::Installer: + HandleInstallerRepair(context, repairBehavior); + break; + default: context.Reporter.Error() << Resource::String::NoRepairInfoFound << std::endl; AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_REPAIR_INFO_FOUND); } @@ -279,11 +313,7 @@ namespace AppInstaller::CLI::Workflow void ExecuteRepair(Execution::Context& context) { - // [TODO:] At present, the repair flow necessitates a mapped available installer. - // However, future refactoring should allow for msix/msi repair without the need for one. - - const auto& installer = context.Get(); - InstallerTypeEnum installerTypeEnum = installer->EffectiveInstallerType(); + InstallerTypeEnum installerTypeEnum = context.Contains(Execution::Data::Installer) ? context.Get()->EffectiveInstallerType() : GetInstalledType(context); Synchronization::CrossProcessInstallLock lock; @@ -303,8 +333,8 @@ namespace AppInstaller::CLI::Workflow switch (installerTypeEnum) { - case InstallerTypeEnum::Burn: case InstallerTypeEnum::Exe: + case InstallerTypeEnum::Burn: case InstallerTypeEnum::Inno: case InstallerTypeEnum::Nullsoft: { @@ -340,11 +370,11 @@ namespace AppInstaller::CLI::Workflow void GetRepairInfo(Execution::Context& context) { - const auto& installer = context.Get(); - InstallerTypeEnum installerTypeEnum = installer->EffectiveInstallerType(); + InstallerTypeEnum installerTypeEnum = context.Contains(Execution::Data::Installer) ? context.Get()->BaseInstallerType : GetInstalledType(context); switch (installerTypeEnum) { + // Exe based installers, for installed package all gets mapped to exe extension. case InstallerTypeEnum::Burn: case InstallerTypeEnum::Exe: case InstallerTypeEnum::Inno: @@ -354,6 +384,7 @@ namespace AppInstaller::CLI::Workflow GenerateRepairString; } break; + // MSI based installers, for installed package all gets mapped to msi extension. case InstallerTypeEnum::Msi: case InstallerTypeEnum::Wix: { @@ -361,12 +392,12 @@ namespace AppInstaller::CLI::Workflow SetProductCodesInContext; } break; + // MSIX based installers, msix. case InstallerTypeEnum::Msix: { context << SetPackageFamilyNamesInContext; } - break; case InstallerTypeEnum::MSStore: break; case InstallerTypeEnum::Portable: @@ -432,6 +463,12 @@ namespace AppInstaller::CLI::Workflow void SelectApplicablePackageVersion(Execution::Context& context) { + // If the repair flow is initiated with manifest, then we don't need to select the applicable package version. + if (context.Args.Contains(Args::Type::Manifest)) + { + return; + } + const auto& installedPackage = context.Get(); Utility::Version installedVersion = Utility::Version(installedPackage->GetProperty(PackageVersionProperty::Version)); @@ -459,9 +496,20 @@ namespace AppInstaller::CLI::Workflow context << GetManifestWithVersionFromPackage( requestedVersion, - context.Args.GetArg(Execution::Args::Type::Channel), false) << - SelectInstaller << - EnsureApplicableInstaller; + context.Args.GetArg(Execution::Args::Type::Channel), false); + } + + void SelectApplicableInstallerIfNecessary(Execution::Context& context) + { + // For MSI installers, the platform provides built-in support for repair via msiexec, hence no need to select an installer. + // Similarly, for MSIX packages that are not from the Microsoft Store, selecting an installer is not required. + if (IsInstallerMappingRequired(context)) + { + context << + SelectApplicablePackageVersion << + SelectInstaller << + EnsureApplicableInstaller; + } } void ReportRepairResult::operator()(Execution::Context& context) const @@ -470,7 +518,9 @@ namespace AppInstaller::CLI::Workflow if (repairResult != 0) { - const auto& repairPackage = context.Get(); + auto& repairPackage = context.Contains(Execution::Data::PackageVersion) ? + context.Get() : + context.Get(); Logging::Telemetry().LogRepairFailure( repairPackage->GetProperty(PackageVersionProperty::Id), @@ -505,4 +555,4 @@ namespace AppInstaller::CLI::Workflow context.Reporter.Info() << Resource::String::RepairFlowRepairSuccess << std::endl; } } -} \ No newline at end of file +} diff --git a/src/AppInstallerCLICore/Workflows/RepairFlow.h b/src/AppInstallerCLICore/Workflows/RepairFlow.h index 6af77249df..5c15d9f666 100644 --- a/src/AppInstallerCLICore/Workflows/RepairFlow.h +++ b/src/AppInstallerCLICore/Workflows/RepairFlow.h @@ -35,7 +35,7 @@ namespace AppInstaller::CLI::Workflow // Outputs:RepairString?, ProductCodes?, PackageFamilyNames? void GetRepairInfo(Execution::Context& context); - // Perform the repair operation for the MSIX package. + // Perform the repair operation for the MSIX NonStore package. // RequiredArgs:None // Inputs:PackageFamilyNames , InstallScope? // Outputs:None @@ -47,6 +47,13 @@ namespace AppInstaller::CLI::Workflow // Outputs:Manifest, PackageVersion, Installer void SelectApplicablePackageVersion(Execution::Context& context); + /// + /// Select the applicable installer for the installed package if necessary. + // RequiredArgs:None + // Inputs: Package,InstalledPackageVersion, AvailablePackageVersions + // Outputs:Manifest, PackageVersion, Installer + void SelectApplicableInstallerIfNecessary(Execution::Context& context); + // Perform the repair operation for the single package. // RequiredArgs:None // Inputs: SearchResult, InstalledPackage, ApplicableInstaller diff --git a/src/AppInstallerCLIE2ETests/Constants.cs b/src/AppInstallerCLIE2ETests/Constants.cs index 61cf60eb95..fdc0a3e111 100644 --- a/src/AppInstallerCLIE2ETests/Constants.cs +++ b/src/AppInstallerCLIE2ETests/Constants.cs @@ -25,6 +25,7 @@ public class Constants public const string LocalServerCertPathParameter = "LocalServerCertPath"; public const string ExeInstallerPathParameter = "ExeTestInstallerPath"; public const string MsiInstallerPathParameter = "MsiTestInstallerPath"; + public const string MsiInstallerV2PathParameter = "MsiTestInstallerV2Path"; public const string MsixInstallerPathParameter = "MsixTestInstallerPath"; public const string PackageCertificatePathParameter = "PackageCertificatePath"; public const string PowerShellModulePathParameter = "PowerShellModulePath"; @@ -58,6 +59,7 @@ public class Constants public const string ZipInstaller = "AppInstallerTestZipInstaller"; public const string ExeInstallerFileName = "AppInstallerTestExeInstaller.exe"; public const string MsiInstallerFileName = "AppInstallerTestMsiInstaller.msi"; + public const string MsiInstallerV2FileName = "AppInstallerTestMsiInstallerV2.msi"; public const string MsixInstallerFileName = "AppInstallerTestMsixInstaller.msix"; public const string ZipInstallerFileName = "AppInstallerTestZipInstaller.zip"; public const string IndexPackage = "source.msix"; @@ -91,6 +93,7 @@ public class Constants public const string TestExeInstalledFileName = "TestExeInstalled.txt"; public const string TestExeUninstallerFileName = "UninstallTestExe.bat"; public const string TestExeUninstalledFileName = "TestExeUninstalled.txt"; + public const string TestExeRepairCompletedFileName = "TestExeRepairCompleted.txt"; // PowerShell Cmdlets public const string FindCmdlet = "Find-WinGetPackage"; @@ -263,6 +266,10 @@ public class ErrorCode public const int ERROR_INVALID_RESUME_STATE = unchecked((int)0x8A150070); public const int ERROR_CANNOT_OPEN_CHECKPOINT_INDEX = unchecked((int)0x8A150071); + public const int ERROR_NO_REPAIR_INFO_FOUND = unchecked((int)0x8A150079); + public const int ERROR_REPAIR_NOT_SUPPORTED = unchecked((int)0x8A15007C); + public const int ERROR_ADMIN_CONTEXT_REPAIR_PROHIBITED = unchecked((int)0x8A15007D); + public const int ERROR_INSTALL_PACKAGE_IN_USE = unchecked((int)0x8A150101); public const int ERROR_INSTALL_INSTALL_IN_PROGRESS = unchecked((int)0x8A150102); public const int ERROR_INSTALL_FILE_IN_USE = unchecked((int)0x8A150103); diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs b/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs index ddad1a1d2a..9d60109edc 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/TestCommon.cs @@ -16,6 +16,7 @@ namespace AppInstallerCLIE2ETests.Helpers using System.Security.Principal; using System.Text; using System.Threading; + using System.Web; using AppInstallerCLIE2ETests; using AppInstallerCLIE2ETests.PowerShell; using Microsoft.Management.Deployment; @@ -470,6 +471,32 @@ public static bool VerifyTestExeInstalled(string installDir, string expectedCont return verifyInstallSuccess; } + /// + /// Verifies if the repair of the test executable was successful. + /// + /// The directory where the test executable is installed. + /// The expected content in the test executable file. This is optional. + /// Returns true if the repair was successful, false otherwise. + public static bool VerifyTestExeRepairSuccessful(string installDir, string expectedContent = null) + { + bool verifyRepairSuccess = true; + + if (!File.Exists(Path.Combine(installDir, Constants.TestExeRepairCompletedFileName))) + { + TestContext.Out.WriteLine($"{Constants.TestExeRepairCompletedFileName} not found at {installDir}"); + verifyRepairSuccess = false; + } + + if (verifyRepairSuccess && !string.IsNullOrEmpty(expectedContent)) + { + string content = File.ReadAllText(Path.Combine(installDir, Constants.TestExeRepairCompletedFileName)); + TestContext.Out.WriteLine($"TestExeRepairCompleted.txt content: {content}"); + verifyRepairSuccess = content.Contains(expectedContent); + } + + return verifyRepairSuccess; + } + /// /// Verify installer and manifest downloaded correctly and cleanup. /// @@ -543,6 +570,35 @@ public static bool VerifyInstallerDownload( return downloadResult; } + /// + /// Best effort test exe cleanup. + /// + /// Install directory. + public static void BestEffortTestExeCleanup(string installDir) + { + var uninstallerPath = Path.Combine(installDir, Constants.TestExeUninstallerFileName); + if (File.Exists(uninstallerPath)) + { + RunCommand(Path.Combine(installDir, Constants.TestExeUninstallerFileName)); + } + } + + /// + /// Best effort test exe cleanup and install directory cleanup. + /// + /// Install directory. + public static void CleanupTestExeAndDirectory(string installDir) + { + // Always try clean up and ignore clean up failure + BestEffortTestExeCleanup(installDir); + + // Delete the install directory to reclaim disk space + if (Directory.Exists(installDir)) + { + Directory.Delete(installDir, true); + } + } + /// /// Verify exe installer correctly and then uninstall it. /// @@ -554,15 +610,25 @@ public static bool VerifyTestExeInstalledAndCleanup(string installDir, string ex bool verifyInstallSuccess = VerifyTestExeInstalled(installDir, expectedContent); // Always try clean up and ignore clean up failure - var uninstallerPath = Path.Combine(installDir, Constants.TestExeUninstallerFileName); - if (File.Exists(uninstallerPath)) - { - RunCommand(Path.Combine(installDir, Constants.TestExeUninstallerFileName)); - } + BestEffortTestExeCleanup(installDir); return verifyInstallSuccess; } + /// + /// Verify exe repair completed and cleanup. + /// + /// Install directory. + /// Optional expected context. + /// True if success. + public static bool VerifyTestExeRepairCompletedAndCleanup(string installDir, string expectedContent = null) + { + bool verifyRepairSuccess = VerifyTestExeRepairSuccessful(installDir, expectedContent); + CleanupTestExeAndDirectory(installDir); + + return verifyRepairSuccess; + } + /// /// Verify msi installed correctly. /// @@ -912,6 +978,61 @@ public static string GetExpectedModulePath(TestModuleLocation location) } } + /// + /// Copy the installer file to the ARP InstallSource directory. + /// + /// Test installer to be copied. + /// Installer Product. + /// is WoW6432Node to use. + /// Returns the installer source directory if the file operation is successful, otherwise returns an empty string. + public static string CopyInstallerFileToARPInstallSourceDirectory(string installerFilePath, string productCode, bool useWoW6432Node = false) + { + if (string.IsNullOrEmpty(installerFilePath)) + { + new ArgumentNullException(nameof(installerFilePath)); + } + + if (!File.Exists(installerFilePath)) + { + new FileNotFoundException(installerFilePath); + } + + string outputDirectory = string.Empty; + + // Define the registry paths for both x64 and x86 + string registryPath = useWoW6432Node + ? $@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{productCode}" + : $@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{productCode}"; + + // Open the registry key where the uninstall information is stored + using (RegistryKey key = Registry.LocalMachine.OpenSubKey(registryPath)) + { + if (key != null) + { + // Read the InstallSource value + string arpInstallSourceDirectory = key.GetValue("InstallSource") as string; + + if (!string.IsNullOrEmpty(arpInstallSourceDirectory)) + { + // Copy the MSI installer to the InstallSource directory + string installerFileName = Path.GetFileName(installerFilePath); + string installerDestinationPath = Path.Combine(arpInstallSourceDirectory, installerFileName); + + if (!Directory.Exists(arpInstallSourceDirectory)) + { + Directory.CreateDirectory(arpInstallSourceDirectory); + } + + File.Copy(installerFilePath, installerDestinationPath, true); + + outputDirectory = arpInstallSourceDirectory; + } + } + } + + return outputDirectory; + } + /// /// Run winget command via direct process. /// diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs b/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs index 91931403f8..2728661e86 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/TestIndex.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- // // Copyright (c) Microsoft Corporation. Licensed under the MIT License. // @@ -22,6 +22,7 @@ static TestIndex() // Expected path for the installers. TestIndex.ExeInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.ExeInstaller, Constants.ExeInstallerFileName); TestIndex.MsiInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.MsiInstaller, Constants.MsiInstallerFileName); + TestIndex.MsiInstallerV2 = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.MsiInstaller, Constants.MsiInstallerV2FileName); TestIndex.MsixInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.MsixInstaller, Constants.MsixInstallerFileName); TestIndex.ZipInstaller = Path.Combine(TestSetup.Parameters.StaticFileRootPath, Constants.ZipInstaller, Constants.ZipInstallerFileName); } @@ -36,6 +37,11 @@ static TestIndex() /// public static string MsiInstaller { get; private set; } + /// + /// Gets the signed msi installerV2 path used by the manifests in the E2E test. + /// + public static string MsiInstallerV2 { get; private set; } + /// /// Gets the signed msix installer path used by the manifests in the E2E test. /// @@ -73,6 +79,16 @@ public static void GenerateE2ESource() throw new FileNotFoundException(testParams.MsiInstallerPath); } + if (string.IsNullOrEmpty(testParams.MsiInstallerV2Path)) + { + throw new ArgumentNullException($"{Constants.MsiInstallerV2PathParameter} is required"); + } + + if (!File.Exists(testParams.MsiInstallerV2Path)) + { + throw new FileNotFoundException(testParams.MsiInstallerV2Path); + } + if (string.IsNullOrEmpty(testParams.MsixInstallerPath)) { throw new ArgumentNullException($"{Constants.MsixInstallerPathParameter} is required"); @@ -118,6 +134,13 @@ public static void GenerateE2ESource() HashToken = "", }, new LocalInstaller + { + Type = InstallerType.Msi, + Name = Path.Combine(Constants.MsiInstaller, Constants.MsiInstallerV2FileName), + Input = testParams.MsiInstallerPath, + HashToken = "", + }, + new LocalInstaller { Type = InstallerType.Msix, Name = Path.Combine(Constants.MsixInstaller, Constants.MsixInstallerFileName), diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs b/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs index 2e81098f8e..b681fc710f 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs @@ -43,6 +43,7 @@ private TestSetup() this.ExeInstallerPath = this.InitializeFileParam(Constants.ExeInstallerPathParameter); this.MsiInstallerPath = this.InitializeFileParam(Constants.MsiInstallerPathParameter); this.MsixInstallerPath = this.InitializeFileParam(Constants.MsixInstallerPathParameter); + this.MsiInstallerV2Path = this.InitializeFileParam(Constants.MsiInstallerV2PathParameter); this.ForcedExperimentalFeatures = this.InitializeStringArrayParam(Constants.ForcedExperimentalFeaturesParameter); } @@ -103,6 +104,11 @@ public static TestSetup Parameters /// public string MsiInstallerPath { get; } + /// + /// Gets the msi installer V2 path. + /// + public string MsiInstallerV2Path { get; } + /// /// Gets the msix installer path. /// diff --git a/src/AppInstallerCLIE2ETests/RepairCommand.cs b/src/AppInstallerCLIE2ETests/RepairCommand.cs new file mode 100644 index 0000000000..53c361ad72 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/RepairCommand.cs @@ -0,0 +1,280 @@ +// ----------------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// +// ----------------------------------------------------------------------------- + +namespace AppInstallerCLIE2ETests +{ + using System.IO; + using AppInstallerCLIE2ETests.Helpers; + using NUnit.Framework; + + /// + /// Test Repair command. + /// + public class RepairCommand : BaseCommand + { + /// + /// One time setup. + /// + [OneTimeSetUp] + public void OneTimeSetup() + { + // Try clean up AppInstallerTest.TestMsiInstaller for failure cases where cleanup is not successful + TestCommon.RunAICLICommand("uninstall", "AppInstallerTest.TestMsiInstaller"); + } + + /// + /// Test MSI installer repair. + /// + [Test] + public void RepairMSIInstaller() + { + if (string.IsNullOrEmpty(TestIndex.MsiInstallerV2)) + { + Assert.Ignore("MSI installer not available"); + } + + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestMsiRepair --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + // Note: The 'msiexec repair' command requires the original installer file to be present at the location registered in the ARP (Add/Remove Programs). + // In our test scenario, the MSI installer file is initially placed in a temporary location and then deleted, which can cause the repair operation to fail. + // To work around this, we copy the installer file to the ARP source directory before running the repair command. + // A more permanent solution would be to modify the MSI installer to cache the installer file in a known location and register that location as the installer source. + // This would allow the 'msiexec repair' command to function as expected. + string installerSourceDir = TestCommon.CopyInstallerFileToARPInstallSourceDirectory(TestCommon.GetTestDataFile("AppInstallerTestMsiInstallerV2.msi"), Constants.MsiInstallerProductCode, true); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestMsiRepair"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Repair operation completed successfully")); + Assert.True(TestCommon.VerifyTestMsiInstalledAndCleanup(installDir)); + + if (installerSourceDir != null && Directory.Exists(installerSourceDir)) + { + Directory.Delete(installerSourceDir, true); + } + } + + /// + /// Test MSIX non-store package repair. + /// + [Test] + public void RepairNonStoreMSIXPackage() + { + // install a test msix package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestMsixInstaller --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestMsixInstaller"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Repair operation completed successfully")); + Assert.True(TestCommon.VerifyTestMsixInstalledAndCleanup()); + } + + /// + /// Test MSIX non-store package repair with machine scope. + /// + [Test] + public void RepairNonStoreMsixPackageWithMachineScope() + { + // Selecting Microsoft.Paint_8wekyb3d8bbwe because it's a system package suitable for this scenario. + // First, we need to ensure this package is installed, otherwise, we skip the test. + var result = TestCommon.RunAICLICommand("list", "Microsoft.Paint_8wekyb3d8bbwe"); + + if (result.ExitCode != Constants.ErrorCode.S_OK) + { + Assert.Ignore("Test skipped as Microsoft.Paint_8wekyb3d8bbwe is not installed."); + } + + Assert.True(result.StdOut.Contains("Microsoft.Paint_8wekyb3d8bbwe")); + + result = TestCommon.RunAICLICommand("repair", "Microsoft.Paint_8wekyb3d8bbwe --scope machine"); + Assert.AreEqual(Constants.ErrorCode.ERROR_INSTALL_SYSTEM_NOT_SUPPORTED, result.ExitCode); + Assert.True(result.StdOut.Contains("The current system configuration does not support the repair of this package.")); + } + + /// + /// Test repair of a Burn installer that has a "modify" repair behavior specified in the manifest. + /// + [Test] + public void RepairBurnInstallerWithModifyBehavior() + { + // install a test burn package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestModifyRepair -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestModifyRepair"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Repair operation completed successfully")); + Assert.True(TestCommon.VerifyTestExeRepairCompletedAndCleanup(installDir, "Modify Repair operation")); + } + + /// + /// Tests the repair operation of a Burn installer that was installed in user scope but is being repaired in an admin context. + /// + [Test] + public void RepairBurnInstallerInAdminContextWithUserScopeInstall() + { + // install a test burn package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestUserScopeInstallRepairInAdminContext -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestUserScopeInstallRepairInAdminContext"); + Assert.AreEqual(Constants.ErrorCode.ERROR_ADMIN_CONTEXT_REPAIR_PROHIBITED, result.ExitCode); + Assert.True(result.StdOut.Contains("The package installed for user scope cannot be repaired when running with administrator privileges.")); + TestCommon.CleanupTestExeAndDirectory(installDir); + } + + /// + /// Tests the repair operation of a Burn installer that lacks a repair behavior. + /// + [Test] + public void RepairBurnInstallerMissingRepairBehavior() + { + // install a test burn package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestMissingRepairBehavior -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestMissingRepairBehavior"); + Assert.AreEqual(Constants.ErrorCode.ERROR_NO_REPAIR_INFO_FOUND, result.ExitCode); + Assert.True(result.StdOut.Contains("The repair command for this package cannot be found. Please reach out to the package publisher for support.")); + TestCommon.CleanupTestExeAndDirectory(installDir); + } + + /// + /// Test repair of a Exe installer that has a "uninstaller" repair behavior specified in the manifest and NoModify ARP flag set. + /// + [Test] + public void RepairBurnInstallerWithWithModifyBehaviorAndNoModifyFlag() + { + // install a test Exe package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestModifyRepairWithNoModify -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestModifyRepairWithNoModify"); + Assert.AreEqual(Constants.ErrorCode.ERROR_REPAIR_NOT_SUPPORTED, result.ExitCode); + Assert.True(result.StdOut.Contains("The installer technology in use does not support repair.")); + TestCommon.CleanupTestExeAndDirectory(installDir); + } + + /// + /// Tests the scenario where the repair operation is not supported for Portable Installer type. + /// + [Test] + public void RepairOperationNotSupportedForPortableInstaller() + { + string installDir = TestCommon.GetPortablePackagesDirectory(); + string packageId, commandAlias, fileName, packageDirName, productCode; + packageId = "AppInstallerTest.TestPortableExe"; + packageDirName = productCode = packageId + "_" + Constants.TestSourceIdentifier; + commandAlias = fileName = "AppInstallerTestExeInstaller.exe"; + + var result = TestCommon.RunAICLICommand("install", $"{packageId}"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestPortableExe"); + Assert.AreEqual(Constants.ErrorCode.ERROR_REPAIR_NOT_SUPPORTED, result.ExitCode); + Assert.True(result.StdOut.Contains("The installer technology in use does not support repair.")); + + // If no location specified, default behavior is to create a package directory with the name "{packageId}_{sourceId}" + TestCommon.VerifyPortablePackage(Path.Combine(installDir, packageDirName), commandAlias, fileName, productCode, true); + } + + /// + /// Test repair of a Exe installer that has a "uninstaller" repair behavior specified in the manifest. + /// + [Test] + public void RepairExeInstallerWithUninstallerBehavior() + { + // install a test Exe package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.UninstallerRepair -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.UninstallerRepair"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Repair operation completed successfully")); + Assert.True(TestCommon.VerifyTestExeRepairCompletedAndCleanup(installDir, "Uninstaller Repair operation")); + } + + /// + /// Test repair of a Exe installer that has a "uninstaller" repair behavior specified in the manifest and NoRepair ARP flag set. + /// + [Test] + public void RepairExeInstallerWithUninstallerBehaviorAndNoRepairFlag() + { + // install a test Exe package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.UninstallerRepairWithNoRepair -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.UninstallerRepairWithNoRepair"); + Assert.AreEqual(Constants.ErrorCode.ERROR_REPAIR_NOT_SUPPORTED, result.ExitCode); + Assert.True(result.StdOut.Contains("The installer technology in use does not support repair.")); + TestCommon.CleanupTestExeAndDirectory(installDir); + } + + /// + /// Test repair of a Nullsoft installer that has a "uninstaller" repair behavior specified in the manifest. + /// + [Test] + public void RepairNullsoftInstallerWithUninstallerBehavior() + { + // install a test Nullsoft package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.UninstallerRepair -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.UninstallerRepair"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Repair operation completed successfully")); + Assert.True(TestCommon.VerifyTestExeRepairCompletedAndCleanup(installDir, "Uninstaller Repair operation")); + } + + /// + /// Test repair of a Inno installer that has a "installer" repair behavior specified in the manifest. + /// + [Test] + public void RepairInnoInstallerWithInstallerBehavior() + { + // install a test Inno package from TestSource and then repair it. + var installDir = TestCommon.GetRandomTestDir(); + var result = TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestInstallerRepair -v 2.0.0.0 --silent -l {installDir}"); + + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Successfully installed")); + + result = TestCommon.RunAICLICommand("repair", "AppInstallerTest.TestInstallerRepair"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Repair operation completed successfully")); + Assert.True(TestCommon.VerifyTestExeRepairCompletedAndCleanup(installDir, "Installer Repair operation")); + } + } +} diff --git a/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstallerV2.msi b/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstallerV2.msi new file mode 100644 index 0000000000..0024a35649 Binary files /dev/null and b/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstallerV2.msi differ diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.MissingRepairBehavior.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.MissingRepairBehavior.yaml new file mode 100644 index 0000000000..d490a7b4d1 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.MissingRepairBehavior.yaml @@ -0,0 +1,18 @@ +PackageIdentifier: AppInstallerTest.TestMissingRepairBehavior +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: TestMissingRepairBehavior +Publisher: AppInstallerTest +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: burn + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName TestMissingRepairBehavior /UseHKLM + Repair: /repair +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.ModifyRepair.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.ModifyRepair.yaml new file mode 100644 index 0000000000..70b44ec797 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.ModifyRepair.yaml @@ -0,0 +1,19 @@ +PackageIdentifier: AppInstallerTest.TestModifyRepair +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: TestModifyRepair +Publisher: AppInstallerTest +RepairBehavior: modify +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: burn + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName TestModifyRepair /UseHKLM + Repair: /repair +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.ModifyRepairWithNoModify.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.ModifyRepairWithNoModify.yaml new file mode 100644 index 0000000000..65a93af82f --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.ModifyRepairWithNoModify.yaml @@ -0,0 +1,19 @@ +PackageIdentifier: AppInstallerTest.TestModifyRepairWithNoModify +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: TestModifyRepairWithNoModify +Publisher: AppInstallerTest +RepairBehavior: modify +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: burn + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName TestModifyRepairWithNoModify /UseHKLM /NoModify + Repair: /repair +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.UserScopeInstallRepairInAdminContext.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.UserScopeInstallRepairInAdminContext.yaml new file mode 100644 index 0000000000..9255693e78 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestBurnInstaller.UserScopeInstallRepairInAdminContext.yaml @@ -0,0 +1,19 @@ +PackageIdentifier: AppInstallerTest.TestUserScopeInstallRepairInAdminContext +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: TestUserScopeInstallRepairInAdminContext +Publisher: AppInstallerTest +RepairBehavior: modify +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: burn + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName TestUserScopeInstallRepairInAdminContext + Repair: /repair +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.UninstallerRepair.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.UninstallerRepair.yaml new file mode 100644 index 0000000000..6bd501d5c1 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.UninstallerRepair.yaml @@ -0,0 +1,19 @@ +PackageIdentifier: AppInstallerTest.UninstallerRepair +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: UninstallerRepair +Publisher: AppInstallerTest +RepairBehavior: uninstaller +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: exe + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName UninstallerRepair /UseHKLM + Repair: /repair +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.UninstallerRepairWithNoRepair.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.UninstallerRepairWithNoRepair.yaml new file mode 100644 index 0000000000..e816c82e04 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.UninstallerRepairWithNoRepair.yaml @@ -0,0 +1,19 @@ +PackageIdentifier: AppInstallerTest.UninstallerRepairWithNoRepair +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: UninstallerRepairWithNoRepair +Publisher: AppInstallerTest +RepairBehavior: uninstaller +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: exe + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName UninstallerRepairWithNoRepair /UseHKLM /NoRepair + Repair: /repair +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestInnoInstaller.InstallerRepair.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestInnoInstaller.InstallerRepair.yaml new file mode 100644 index 0000000000..a43a34722b --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestInnoInstaller.InstallerRepair.yaml @@ -0,0 +1,19 @@ +PackageIdentifier: AppInstallerTest.TestInstallerRepair +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: TestInstallerRepair +Publisher: AppInstallerTest +RepairBehavior: installer +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: inno + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName TestInstallerRepair /UseHKLM + Repair: /repair /UseHKLM +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestMsiInstaller.Repair.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestMsiInstaller.Repair.yaml new file mode 100644 index 0000000000..1ae08edcea --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestMsiInstaller.Repair.yaml @@ -0,0 +1,14 @@ +# Uses the MSI installer; +PackageIdentifier: AppInstallerTest.TestMsiRepair +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: TestMsiInstallerV2 +Publisher: AppInstallerTest +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestMsiInstaller/AppInstallerTestMsiInstallerV2.msi + InstallerType: msi + InstallerSha256: + ProductCode: '{A5D36CF1-1993-4F63-BFB4-3ACD910D36A1}' +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestNullsoftInstaller.UninstallerRepair.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestNullsoftInstaller.UninstallerRepair.yaml new file mode 100644 index 0000000000..c1444e6626 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestNullsoftInstaller.UninstallerRepair.yaml @@ -0,0 +1,19 @@ +PackageIdentifier: AppInstallerTest.NullsoftUninstallerRepair +PackageVersion: 2.0.0.0 +PackageLocale: en-US +PackageName: NullsoftUninstallerRepair +Publisher: AppInstallerTest +RepairBehavior: uninstaller +Installers: + - Architecture: x86 + InstallerUrl: https://localhost:5001/TestKit/AppInstallerTestExeInstaller/AppInstallerTestExeInstaller.exe + InstallerType: exe + InstallerSha256: + ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' + ElevationRequirement: elevationRequired + InstallerSwitches: + InstallLocation: /InstallDir + Custom: /Version 2.0.0.0 /DisplayName NullsoftUninstallerRepair /UseHKLM + Repair: /repair +ManifestType: singleton +ManifestVersion: 1.7.0 diff --git a/src/AppInstallerCLIE2ETests/TestData/localsource.json b/src/AppInstallerCLIE2ETests/TestData/localsource.json index adb672f0fc..45bae359be 100644 --- a/src/AppInstallerCLIE2ETests/TestData/localsource.json +++ b/src/AppInstallerCLIE2ETests/TestData/localsource.json @@ -17,6 +17,12 @@ "Input": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstaller.msi", "HashToken": "" }, + { + "Type": "msi", + "Name": "AppInstallerTestMsiInstaller/AppInstallerTestMsiInstallerV2.msi", + "Input": "%BUILD_SOURCESDIRECTORY%/src/AppInstallerCLIE2ETests/TestData/AppInstallerTestMsiInstallerV2.msi", + "HashToken": "" + }, { "Type": "msix", "Name": "AppInstallerTestMsixInstaller/AppInstallerTestMsixInstaller.msix", diff --git a/src/AppInstallerTestExeInstaller/main.cpp b/src/AppInstallerTestExeInstaller/main.cpp index aed19942bc..7ead74755d 100644 --- a/src/AppInstallerTestExeInstaller/main.cpp +++ b/src/AppInstallerTestExeInstaller/main.cpp @@ -16,6 +16,42 @@ std::wstring_view DefaultProductID = L"{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}"; std::wstring_view DefaultDisplayName = L"AppInstallerTestExeInstaller"; std::wstring_view DefaultDisplayVersion = L"1.0.0.0"; +void WriteModifyRepairScript(std::wofstream& script, const path& repairCompletedTextFilePath, bool isModifyScript) { + std::wstring scriptName = isModifyScript ? L"Modify" : L"Uninstaller"; + script << L" if /I \"%%A\"==\"/repair\" (\n" + << L" ECHO " << scriptName << L" Repair operation for AppInstallerTestExeInstaller.exe completed successfully > \"" << repairCompletedTextFilePath.wstring() << "\"\n" + << L" ECHO " << scriptName << L" Repair operation for AppInstallerTestExeInstaller.exe completed successfully\n" + << L" EXIT /B 0\n" + << L" ) else if /I \"%%A\"==\"/r\" (\n" + << L" ECHO " << scriptName << L" Repair operation for AppInstallerTestExeInstaller.exe completed successfully > \"" << repairCompletedTextFilePath.wstring() << "\"\n" + << L" ECHO " << scriptName << L" Repair operation for AppInstallerTestExeInstaller.exe completed successfully\n" + << L" EXIT /B 0\n" + << L" )"; +} + +void WriteModifyUninstallScript(std::wofstream& script) { + script << L" else if /I \"%%A\"==\"/uninstall\" (\n" + << L" call UninstallTestExe.bat\n" + << L" EXIT /B 0\n" + << L" ) else if /I \"%%A\"==\"/X\" (\n" + << L" call UninstallTestExe.bat\n" + << L" EXIT /B 0\n" + << L" )\n"; +} + +void WriteModifyInvalidOperationScript(std::wofstream& script) { + script << L"echo Invalid operation\n" + << L"EXIT /B 1\n"; +} + +void WriteUninstallerScript(std::wofstream& uninstallerScript, const path& uninstallerOutputTextFilePath, const std::wstring& registryKey, const path& modifyScriptPath, const path& repairCompletedTextFilePath) { + uninstallerScript << "ECHO. >" << uninstallerOutputTextFilePath << "\n"; + uninstallerScript << "ECHO AppInstallerTestExeInstaller.exe uninstalled successfully.\n"; + uninstallerScript << "REG DELETE " << registryKey << " /f\n"; + uninstallerScript << "if exist \"" << modifyScriptPath.wstring() << "\" del \"" << modifyScriptPath.wstring() << "\"\n"; + uninstallerScript << "if exist \"" << repairCompletedTextFilePath.wstring() << "\" del \"" << repairCompletedTextFilePath.wstring() << "\"\n"; +} + path GenerateUninstaller(std::wostream& out, const path& installDirectory, const std::wstring& productID, bool useHKLM) { path uninstallerPath = installDirectory; @@ -26,6 +62,12 @@ path GenerateUninstaller(std::wostream& out, const path& installDirectory, const path uninstallerOutputTextFilePath = installDirectory; uninstallerOutputTextFilePath /= "TestExeUninstalled.txt"; + path repairCompletedTextFilePath = installDirectory; + repairCompletedTextFilePath /= "TestExeRepairCompleted.txt"; + + path modifyScriptPath = installDirectory; + modifyScriptPath /= "ModifyTestExe.bat"; + std::wstring registryKey{ useHKLM ? L"HKEY_LOCAL_MACHINE\\" : L"HKEY_CURRENT_USER\\" }; registryKey += RegistrySubkey; if (!productID.empty()) @@ -39,22 +81,49 @@ path GenerateUninstaller(std::wostream& out, const path& installDirectory, const std::wofstream uninstallerScript(uninstallerPath); uninstallerScript << "@echo off\n"; - uninstallerScript << "ECHO. >" << uninstallerOutputTextFilePath << "\n"; - uninstallerScript << "ECHO AppInstallerTestExeInstaller.exe uninstalled successfully.\n"; - uninstallerScript << "REG DELETE " << registryKey << " /f\n"; + uninstallerScript << L"for %%A in (%*) do (\n"; + WriteModifyRepairScript(uninstallerScript, repairCompletedTextFilePath, false /*isModifyScript*/); + uninstallerScript << ")\n"; + WriteUninstallerScript(uninstallerScript, uninstallerOutputTextFilePath, registryKey, modifyScriptPath, repairCompletedTextFilePath); + uninstallerScript.close(); return uninstallerPath; } +path GenerateModifyPath(const path& installDirectory) +{ + path modifyScriptPath = installDirectory; + modifyScriptPath /= "ModifyTestExe.bat"; + + path repairCompletedTextFilePath = installDirectory; + repairCompletedTextFilePath /= "TestExeRepairCompleted.txt"; + + std::wofstream modifyScript(modifyScriptPath); + + modifyScript << L"@echo off\n"; + modifyScript << L"for %%A in (%*) do (\n"; + WriteModifyRepairScript(modifyScript, repairCompletedTextFilePath, true /*isModifyScript*/); + WriteModifyUninstallScript(modifyScript); + modifyScript << L")\n"; + WriteModifyInvalidOperationScript(modifyScript); + + modifyScript.close(); + + return modifyScriptPath; +} + void WriteToUninstallRegistry( std::wostream& out, const std::wstring& productID, const path& uninstallerPath, + const path& modifyPath, const std::wstring& displayName, const std::wstring& displayVersion, const std::wstring& installLocation, - bool useHKLM) + bool useHKLM, + bool noRepair, + bool noModify) { HKEY hkey; LONG lReg; @@ -62,16 +131,18 @@ void WriteToUninstallRegistry( // String inputs to registry must be of wide char type const wchar_t* publisher = L"Microsoft Corporation"; std::wstring uninstallString = uninstallerPath.wstring(); + std::wstring modifyPathString = modifyPath.wstring(); + DWORD version = 1; std::wstring registryKey{ RegistrySubkey }; - if (!productID.empty()) + if (!productID.empty()) { registryKey += productID; out << "Product Code overridden to: " << registryKey << std::endl; } - else + else { registryKey += DefaultProductID; out << "Default Product Code used: " << registryKey << std::endl; @@ -128,6 +199,32 @@ void WriteToUninstallRegistry( out << "Failed to write InstallLocation value. Error Code: " << res << std::endl; } + // Set ModifyPath Property Value + if (LONG res = RegSetValueEx(hkey, L"ModifyPath", NULL, REG_EXPAND_SZ, (LPBYTE)modifyPath.c_str(), (DWORD)(modifyPath.wstring().length() + 1) * sizeof(wchar_t)) != ERROR_SUCCESS) + { + out << "Failed to write ModifyPath value. Error Code: " << res << std::endl; + } + + if(noRepair) + { + // Set NoRepair Property Value + DWORD noRepairValue = 1; + if (LONG res = RegSetValueEx(hkey, L"NoRepair", NULL, REG_DWORD, (LPBYTE)&noRepairValue, sizeof(noRepairValue)) != ERROR_SUCCESS) + { + out << "Failed to write NoRepair value. Error Code: " << res << std::endl; + } + } + + if(noModify) + { + // Set NoModify Property Value + DWORD noModifyValue = 1; + if (LONG res = RegSetValueEx(hkey, L"NoModify", NULL, REG_DWORD, (LPBYTE)&noModifyValue, sizeof(noModifyValue)) != ERROR_SUCCESS) + { + out << "Failed to write NoModify value. Error Code: " << res << std::endl; + } + } + out << "Write to registry key completed" << std::endl; } else { @@ -137,6 +234,80 @@ void WriteToUninstallRegistry( RegCloseKey(hkey); } +void WriteToFile(const path& filePath, const std::wstringstream& content) +{ + std::wofstream file(filePath, std::ofstream::out); + file << content.str(); + file.close(); +} + +void HandleRepairOperation(const std::wstring& productID, const std::wstringstream& outContent, bool useHKLM) +{ + path installDirectory; + + // Open the registry key + HKEY hKey; + std::wstring registryPath = std::wstring(RegistrySubkey); + + if (!productID.empty()) + { + registryPath += productID; + } + else + { + registryPath += DefaultProductID; + } + + LONG lReg = RegOpenKeyEx(useHKLM ? HKEY_LOCAL_MACHINE : HKEY_CURRENT_USER, registryPath.c_str(), 0, KEY_READ, &hKey); + + if (lReg == ERROR_SUCCESS) + { + // Query the value of the InstallLocation + wchar_t regInstallLocation[MAX_PATH]; + DWORD bufferSize = sizeof(regInstallLocation); + lReg = RegQueryValueEx(hKey, L"InstallLocation", NULL, NULL, (LPBYTE)regInstallLocation, &bufferSize); + + if (lReg == ERROR_SUCCESS) + { + // Convert the InstallLocation to a path + installDirectory = std::wstring(regInstallLocation); + } + + // Close the registry key + RegCloseKey(hKey); + + if(installDirectory.empty()) + { + // We could not find the install location, so we cannot repair + return; + } + } + else + { + // We could not find the uninstall APR registry key, so we cannot repair + return; + } + + path outFilePath = installDirectory; + outFilePath /= "TestExeRepairCompleted.txt"; + WriteToFile(outFilePath, outContent); +} + +void HandleInstallationOperation(std::wostream& out, const path& installDirectory, const std::wstringstream& outContent, const std::wstring& productCode, bool useHKLM, const std::wstring& displayName, const std::wstring& displayVersion, bool noRepair, bool noModify) +{ + path outFilePath = installDirectory; + outFilePath /= "TestExeInstalled.txt"; + + std::wofstream file(outFilePath, std::ofstream::out); + file << outContent.str(); + file.close(); + + path uninstallerPath = GenerateUninstaller(out, installDirectory, productCode, useHKLM); + path modifyPath = GenerateModifyPath(installDirectory); + + WriteToUninstallRegistry(out, productCode, uninstallerPath, modifyPath, displayName, displayVersion, installDirectory.wstring(), useHKLM, noRepair, noModify); +} + // The installer prints all args to an output file and writes to the Uninstall registry key int wmain(int argc, const wchar_t** argv) { @@ -150,6 +321,9 @@ int wmain(int argc, const wchar_t** argv) bool useHKLM = false; bool noOperation = false; int exitCode = 0; + bool isRepair = false; + bool noRepair = false; + bool noModify = false; // Output to cout by default, but swap to a file if requested std::wostream* out = &std::wcout; @@ -246,6 +420,23 @@ int wmain(int argc, const wchar_t** argv) } } + // Supports /repair and /r to emulate repair operation using installer. + else if (_wcsicmp(argv[i], L"/repair") == 0 + || _wcsicmp(argv[i], L"/r") == 0) + { + isRepair = true; + } + + else if (_wcsicmp(argv[i], L"/NoRepair") == 0) + { + noRepair = true; + } + + else if (_wcsicmp(argv[i], L"/NoModify") == 0) + { + noModify = true; + } + // Returns the success exit code to emulate being invoked by another caller. else if (_wcsicmp(argv[i], L"/NoOperation") == 0) { @@ -264,7 +455,7 @@ int wmain(int argc, const wchar_t** argv) execInfo.cbSize = sizeof(execInfo); execInfo.fMask = SEE_MASK_NOCLOSEPROCESS; execInfo.lpFile = aliasToExecute.c_str(); - + if (!aliasArguments.empty()) { execInfo.lpParameters = aliasArguments.c_str(); @@ -288,16 +479,16 @@ int wmain(int argc, const wchar_t** argv) } path outFilePath = installDirectory; - outFilePath /= "TestExeInstalled.txt"; - std::wofstream file(outFilePath, std::ofstream::out); - - file << outContent.str(); - - file.close(); - path uninstallerPath = GenerateUninstaller(*out, installDirectory, productCode, useHKLM); - - WriteToUninstallRegistry(*out, productCode, uninstallerPath, displayName, displayVersion, installDirectory.wstring(), useHKLM); + if (isRepair) + { + outContent << L"\nInstaller Repair operation for AppInstallerTestExeInstaller.exe completed successfully."; + HandleRepairOperation(productCode, outContent, useHKLM); + } + else + { + HandleInstallationOperation(*out, installDirectory, outContent, productCode, useHKLM, displayName, displayVersion, noRepair, noModify); + } return exitCode; }