commit 9a15df45d0b2deae578aee957bdab52ab69fe11e parent e50438ea2d5b82fd8fbfa5b383a634e60dff8725 Author: Chacón <lechacon@users.noreply.github.com> Date: Fri, 27 Aug 2021 13:34:08 -0700 Use MSI API to allow UAC prompts on MSI silent installs (#1398) Diffstat:
45 files changed, 1092 insertions(+), 30 deletions(-)
diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt @@ -1,5 +1,7 @@ abi ACCESSDENIED +ACTIONDATA +ACTIONSTART addmanifest addstore admins @@ -53,6 +55,7 @@ cnt codepage COMMANDBARFLYOUT Commandline +COMMONDATA comparand conemu config @@ -108,6 +111,7 @@ dword DWORDLONG elseif emoji +ENDDIALOG endif endl ensureandinsert @@ -131,19 +135,24 @@ exesilent exeswp exitcode expr +EXTRADEBUG EXTRAFLAGS FAILIFTHERE fakeswitch +FATALEXIT FIELDTAG FILEFLAGS FILEFLAGSMASK FILELOGGER FILEOS filepath +FILESINUSE FILESUBTYPE filesystem FILETYPE FILEVERSION +FLUSHEACHLINE +forcerestart foreach fstream func @@ -154,6 +163,7 @@ github githubusercontent hfile HGLOBAL +HIDECANCEL hinternet HKEY hmac @@ -194,7 +204,10 @@ Inq installertype Installeruniqueness installlocation +INSTALLLOGATTRIBUTES +INSTALLLOGMODE INSTALLPATH +INSTALLUILEVEL interop INVALIDARG iomanip @@ -221,7 +234,9 @@ LLVM llvmorg LOCALAPPDATA localtime +LOGONLYONERROR LOGPATH +LOGPERFORMANCE logsql logto LONGLONG @@ -275,6 +290,7 @@ NOMINMAX NONAME nonexistentsetting NONINFRINGEMENT +norestart NOTHROW NOTIMPL NOTNULL @@ -295,6 +311,7 @@ ostream ostringstream OSVERSIONINFOEXW outfile +OUTOFDISKSPACE OUTOFMEMORY OWC PACKAGESSCHEMA @@ -326,6 +343,9 @@ PRIMARYKEY prioritization PRODUCTNAME PRODUCTVERSION +PROGRESSONLY +promptrestart +PROPERTYDUMP psz ptr publiccontainer @@ -341,6 +361,7 @@ READONLY READWRITE realloc REALTIME +REBOOTPROMPT Redistributable REFCLSID regex @@ -350,6 +371,7 @@ repolibtest rescap resheader resmimetype +RESOLVESOURCE RESTSOURCE resw resx @@ -459,6 +481,7 @@ TEXTINCLUDE there're Timeline todo +tokenizer tolower toupper TOutput @@ -472,6 +495,7 @@ ttl typedef typename UAC +UACONLY uap UBool UBreak diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt @@ -180,6 +180,7 @@ libsolv libyaml Linq liv +liwpx llvm localhost localizationpriority @@ -187,6 +188,8 @@ LPBYTE LPWSTR LSTATUS LTDA +lw +lz malware MBH megamorf @@ -218,6 +221,8 @@ netlify Newtonsoft NOEXPAND normer +NOSEPARATOR +NOTAPROPERTY npp nsis nuffing diff --git a/src/AppInstallerCLI/AppInstallerCLI.vcxproj b/src/AppInstallerCLI/AppInstallerCLI.vcxproj @@ -184,7 +184,7 @@ <Link> <SubSystem>Console</SubSystem> <GenerateWindowsMetadata>false</GenerateWindowsMetadata> - <AdditionalDependencies Condition="'$(Configuration)'=='Debug'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies Condition="'$(Configuration)'=='Debug'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;msi.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> <Manifest> <AdditionalManifestFiles Condition="'$(Configuration)|$(Platform)'=='Debug|ARM'">$(ProjectDir)..\manifest\shared.manifest %(AdditionalManifestFiles)</AdditionalManifestFiles> @@ -246,7 +246,7 @@ <EnableCOMDATFolding>true</EnableCOMDATFolding> <OptimizeReferences>true</OptimizeReferences> <GenerateWindowsMetadata>false</GenerateWindowsMetadata> - <AdditionalDependencies Condition="'$(Configuration)'=='Release'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies Condition="'$(Configuration)'=='Release'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;msi.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> <Manifest> <AdditionalManifestFiles Condition="'$(Configuration)|$(Platform)'=='Release|ARM'">$(ProjectDir)..\manifest\shared.manifest %(AdditionalManifestFiles)</AdditionalManifestFiles> diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -266,6 +266,7 @@ <ClInclude Include="PackageCollection.h" /> <ClInclude Include="Workflows\CompletionFlow.h" /> <ClInclude Include="Workflows\ImportExportFlow.h" /> + <ClInclude Include="Workflows\MsiInstallFlow.h" /> <ClInclude Include="Workflows\MSStoreInstallerHandler.h" /> <ClInclude Include="Workflows\ShellExecuteInstallerHandler.h" /> <ClInclude Include="Workflows\InstallFlow.h" /> @@ -313,6 +314,7 @@ <ClCompile Include="VTSupport.cpp" /> <ClCompile Include="Workflows\CompletionFlow.cpp" /> <ClCompile Include="Workflows\ImportExportFlow.cpp" /> + <ClCompile Include="Workflows\MsiInstallFlow.cpp" /> <ClCompile Include="Workflows\MSStoreInstallerHandler.cpp" /> <ClCompile Include="Workflows\ShellExecuteInstallerHandler.cpp" /> <ClCompile Include="Workflows\InstallFlow.cpp" /> diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters @@ -164,6 +164,9 @@ <ClInclude Include="Commands\COMInstallCommand.h"> <Filter>Commands</Filter> </ClInclude> + <ClInclude Include="Workflows\MsiInstallFlow.h"> + <Filter>Workflows</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <ClCompile Include="pch.cpp"> @@ -295,6 +298,9 @@ <ClCompile Include="Commands\COMInstallCommand.cpp"> <Filter>Commands</Filter> </ClCompile> + <ClCompile Include="Workflows\MsiInstallFlow.cpp"> + <Filter>Workflows</Filter> + </ClCompile> </ItemGroup> <ItemGroup> <None Include="PropertySheet.props" /> diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Argument.h" #include "Resources.h" diff --git a/src/AppInstallerCLICore/Resources.cpp b/src/AppInstallerCLICore/Resources.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Resources.h" diff --git a/src/AppInstallerCLICore/Workflows/CompletionFlow.cpp b/src/AppInstallerCLICore/Workflows/CompletionFlow.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "CompletionFlow.h" diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp @@ -7,6 +7,7 @@ #include "Resources.h" #include "ShellExecuteInstallerHandler.h" #include "MSStoreInstallerHandler.h" +#include "MsiInstallFlow.h" #include "WorkflowBase.h" #include "Workflows/DependenciesFlow.h" @@ -22,6 +23,7 @@ namespace AppInstaller::CLI::Workflow using namespace AppInstaller::Utility; using namespace AppInstaller::Manifest; using namespace AppInstaller::Repository; + using namespace AppInstaller::Settings; namespace { @@ -40,6 +42,18 @@ namespace AppInstaller::CLI::Workflow return false; } } + + bool ShouldUseDirectMSIInstall(InstallerTypeEnum type, bool isSilentInstall) + { + switch (type) + { + case InstallerTypeEnum::Msi: + case InstallerTypeEnum::Wix: + return isSilentInstall || ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::DirectMSI); + default: + return false; + } + } } void EnsureApplicableInstaller(Execution::Context& context) @@ -385,7 +399,14 @@ namespace AppInstaller::CLI::Workflow ExecuteUninstaller; context.ClearFlags(Execution::ContextFlag::InstallerExecutionUseUpdate); } - context << ShellExecuteInstall; + if (ShouldUseDirectMSIInstall(installer.InstallerType, context.Args.Contains(Execution::Args::Type::Silent))) + { + context << DirectMSIInstall; + } + else + { + context << ShellExecuteInstall; + } break; case InstallerTypeEnum::Msix: context << MsixInstall; @@ -409,6 +430,14 @@ namespace AppInstaller::CLI::Workflow ShellExecuteInstallImpl; } + void DirectMSIInstall(Execution::Context& context) + { + context << + GetInstallerArgs << + RenameDownloadedInstaller << + DirectMSIInstallImpl; + } + void MsixInstall(Execution::Context& context) { std::string uri; diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.h b/src/AppInstallerCLICore/Workflows/InstallFlow.h @@ -102,6 +102,12 @@ namespace AppInstaller::CLI::Workflow // Outputs: None void ShellExecuteInstall(Execution::Context& context); + // Runs an MSI installer directly via MSI APIs. + // Required Args: None + // Inputs: Installer, InstallerPath + // Outputs: None + void DirectMSIInstall(Execution::Context& context); + // Deploys the MSIX. // Required Args: None // Inputs: Manifest?, Installer || InstallerPath diff --git a/src/AppInstallerCLICore/Workflows/MsiInstallFlow.cpp b/src/AppInstallerCLICore/Workflows/MsiInstallFlow.cpp @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "MsiInstallFlow.h" +#include "winget/MsiExecArguments.h" + +namespace AppInstaller::CLI::Workflow +{ + namespace + { + std::optional<UINT> InvokeMsiInstallProduct(const std::filesystem::path& installerPath, const Msi::MsiParsedArguments& msiArgs, IProgressCallback&) + { + if (msiArgs.LogFile) + { + THROW_IF_WIN32_ERROR(MsiEnableLogW(msiArgs.LogMode, msiArgs.LogFile->c_str(), msiArgs.LogAttributes)); + } + else + { + // Disable logging + THROW_IF_WIN32_ERROR(MsiEnableLogW(0, nullptr, 0)); + } + + // Returns old UI level. We don't need to reset it so we ignore it. + MsiSetInternalUI(msiArgs.UILevel, nullptr); + + // TODO: Use progress callback + return MsiInstallProductW(installerPath.c_str(), msiArgs.Properties.c_str()); + } + } + + void DirectMSIInstallImpl(Execution::Context& context) + { + context.Reporter.Info() << Resource::String::InstallFlowStartingPackageInstall << std::endl; + + const std::filesystem::path& installerPath = context.Get<Execution::Data::InstallerPath>(); + const auto& additionalSuccessCodes = context.Get<Execution::Data::Installer>()->InstallerSuccessCodes; + + Msi::MsiParsedArguments parsedArgs = Msi::ParseMSIArguments(context.Get<Execution::Data::InstallerArgs>()); + + auto installResult = context.Reporter.ExecuteWithProgress( + std::bind(InvokeMsiInstallProduct, + installerPath, + parsedArgs, + std::placeholders::_1)); + + if (!installResult) + { + context.Reporter.Warn() << "Installation abandoned" << std::endl; + AICLI_TERMINATE_CONTEXT(E_ABORT); + } + else if (installResult.value() != 0 && (std::find(additionalSuccessCodes.begin(), additionalSuccessCodes.end(), installResult.value()) == additionalSuccessCodes.end())) + { + const auto& manifest = context.Get<Execution::Data::Manifest>(); + Logging::Telemetry().LogInstallerFailure(manifest.Id, manifest.Version, manifest.Channel, "ShellExecute", installResult.value()); + + context.Reporter.Error() << "Installer failed with exit code: " << installResult.value() << std::endl; + // Show installer log path if exists + if (context.Contains(Execution::Data::LogPath) && std::filesystem::exists(context.Get<Execution::Data::LogPath>())) + { + context.Reporter.Info() << "Installer log is available at: " << context.Get<Execution::Data::LogPath>().u8string() << std::endl; + } + + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_SHELLEXEC_INSTALL_FAILED); + } + else + { + context.Reporter.Info() << Resource::String::InstallFlowInstallSuccess << std::endl; + } + } +} diff --git a/src/AppInstallerCLICore/Workflows/MsiInstallFlow.h b/src/AppInstallerCLICore/Workflows/MsiInstallFlow.h @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "ExecutionContext.h" + +namespace AppInstaller::CLI::Workflow +{ + // Ensures that there is an applicable installer. + // Required Args: None + // Inputs: InstallerArgs, Installer, InstallerPath, Manifest + // Outputs: None + void DirectMSIInstallImpl(Execution::Context& context); +} diff --git a/src/AppInstallerCLICore/Workflows/SourceFlow.cpp b/src/AppInstallerCLICore/Workflows/SourceFlow.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Resources.h" #include "SourceFlow.h" diff --git a/src/AppInstallerCLICore/pch.h b/src/AppInstallerCLICore/pch.h @@ -6,6 +6,7 @@ #include <windows.h> #include <shellapi.h> #include <WinInet.h> +#include <msi.h> #pragma warning( push ) #pragma warning ( disable : 4458 4100 6031 4702 ) diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -116,7 +116,7 @@ <Link> <SubSystem>Console</SubSystem> <GenerateWindowsMetadata>false</GenerateWindowsMetadata> - <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;msi.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> <Manifest> <AdditionalManifestFiles Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">$(ProjectDir)..\manifest\shared.manifest</AdditionalManifestFiles> @@ -132,7 +132,7 @@ <TreatWarningAsError Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</TreatWarningAsError> </ClCompile> <Link> - <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;msi.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> <Manifest> <AdditionalManifestFiles Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">$(ProjectDir)..\manifest\shared.manifest</AdditionalManifestFiles> @@ -157,8 +157,8 @@ <EnableCOMDATFolding>true</EnableCOMDATFolding> <OptimizeReferences>true</OptimizeReferences> <GenerateWindowsMetadata>false</GenerateWindowsMetadata> - <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;%(AdditionalDependencies)</AdditionalDependencies> - <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Release|x64'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;msi.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies Condition="'$(Configuration)|$(Platform)'=='Release|x64'">wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;msi.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> <Manifest> <AdditionalManifestFiles Condition="'$(Configuration)|$(Platform)'=='Release|x64'">$(ProjectDir)..\manifest\shared.manifest</AdditionalManifestFiles> @@ -193,6 +193,7 @@ <ClCompile Include="HttpClientHelper.cpp" /> <ClCompile Include="ManifestComparator.cpp" /> <ClCompile Include="JsonHelper.cpp" /> + <ClCompile Include="MsiExecArguments.cpp" /> <ClCompile Include="MsixInfo.cpp" /> <ClCompile Include="NameNormalization.cpp" /> <ClCompile Include="PackageCollection.cpp" /> @@ -535,7 +536,7 @@ <CopyFileToFolders Include="TestData\Installer_Exe_Dependencies.yaml"> <DeploymentContent>true</DeploymentContent> </CopyFileToFolders> - <CopyFileToFolders Include="TestData\Installer_Msi_WFDependency.yaml"> + <CopyFileToFolders Include="TestData\Installer_Msix_WFDependency.yaml"> <DeploymentContent>true</DeploymentContent> </CopyFileToFolders> <CopyFileToFolders Include="TestData\Installer_Exe_DependenciesOnRoot.yaml"> diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -164,6 +164,9 @@ <ClCompile Include="SearchRequestSerializer.cpp"> <Filter>Source Files</Filter> </ClCompile> + <ClCompile Include="MsiExecArguments.cpp"> + <Filter>Source Files</Filter> + </ClCompile> </ItemGroup> <ItemGroup> <None Include="PropertySheet.props" /> @@ -474,7 +477,7 @@ <CopyFileToFolders Include="TestData\UpdateFlowTest_ExeDependencies.yaml"> <Filter>TestData</Filter> </CopyFileToFolders> - <CopyFileToFolders Include="TestData\Installer_Msi_WFDependency.yaml"> + <CopyFileToFolders Include="TestData\Installer_Msix_WFDependency.yaml"> <Filter>TestData</Filter> </CopyFileToFolders> <CopyFileToFolders Include="TestData\Installer_Exe_DependenciesOnRoot.yaml"> diff --git a/src/AppInstallerCLITests/MsiExecArguments.cpp b/src/AppInstallerCLITests/MsiExecArguments.cpp @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TestCommon.h" +#include <winget/MsiExecArguments.h> +#include <AppInstallerErrors.h> + +using namespace std::string_view_literals; +using namespace AppInstaller; + +TEST_CASE("MsiExecArgs_ParseEmpty", "[msiexec]") +{ + std::vector<std::string_view> emptyArguments = { ""sv, " ", "\t" }; + + for (const auto argString : emptyArguments) + { + auto args = Msi::ParseMSIArguments(argString); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } +} + +TEST_CASE("MsiExecArgs_ParseUILevel", "[msiexec]") +{ + { + auto args = Msi::ParseMSIArguments("/qn"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY)); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/qb+"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == (INSTALLUILEVEL_BASIC | INSTALLUILEVEL_ENDDIALOG)); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/q"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY)); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/qr"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == (INSTALLUILEVEL_REDUCED)); + REQUIRE(args.Properties.empty()); + } + + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("/qr-"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("/q arg"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); +} + +TEST_CASE("MsiExecArgs_ParseLogMode", "[msiexec]") +{ + { + auto args = Msi::ParseMSIArguments("/l file.txt"sv); + REQUIRE(args.LogMode == Msi::DefaultLogMode); + REQUIRE(args.LogAttributes == 0); + REQUIRE(args.LogFile == L"file.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/le errors.txt"sv); + REQUIRE(args.LogMode == INSTALLLOGMODE_ERROR); + REQUIRE(args.LogAttributes == 0); + REQUIRE(args.LogFile == L"errors.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/l! flush.txt"sv); + REQUIRE(args.LogMode == Msi::DefaultLogMode); + REQUIRE(args.LogAttributes == INSTALLLOGATTRIBUTES_FLUSHEACHLINE); + REQUIRE(args.LogFile == L"flush.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/l\"i\" \"quoted path.txt\""sv); + REQUIRE(args.LogMode == INSTALLLOGMODE_INFO); + REQUIRE(args.LogAttributes == 0); + REQUIRE(args.LogFile == L"quoted path.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/liwpx+! log.txt"sv); + REQUIRE(args.LogMode == (INSTALLLOGMODE_INFO | INSTALLLOGMODE_WARNING | INSTALLLOGMODE_PROPERTYDUMP | INSTALLLOGMODE_EXTRADEBUG)); + REQUIRE(args.LogAttributes == (INSTALLLOGATTRIBUTES_FLUSHEACHLINE | INSTALLLOGATTRIBUTES_APPEND)); + REQUIRE(args.LogFile == L"log.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/l* all.txt"sv); + REQUIRE(args.LogMode == Msi::AllLogMode); + REQUIRE(args.LogAttributes == 0); + REQUIRE(args.LogFile == L"all.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/l* \"without closing quote.txt"sv); + REQUIRE(args.LogMode == Msi::AllLogMode); + REQUIRE(args.LogAttributes == 0); + REQUIRE(args.LogFile == L"without closing quote.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("/l"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("/lz log.txt"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); +} + +TEST_CASE("MsiExecArgs_ParseProperties", "[msiexec]") +{ + { + auto args = Msi::ParseMSIArguments("PROPERTY=value"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties == L" PROPERTY=value"sv); + } + + { + auto args = Msi::ParseMSIArguments("EMPTY="sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties == L" EMPTY="sv); + } + + { + auto args = Msi::ParseMSIArguments("PROPERTY=\"quoted value\""sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties == L" PROPERTY=\"quoted value\""sv); + } + + { + auto args = Msi::ParseMSIArguments("PROPERTY=\"escaped \"\" quotes\""sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties == L" PROPERTY=\"escaped \"\" quotes\""sv); + } + + { + auto args = Msi::ParseMSIArguments("PROPERTY1=value1 PROPERTY2=value2"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties == L" PROPERTY1=value1 PROPERTY2=value2"sv); + } + + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("NOSEPARATOR"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("$NOTAPROPERTY=value"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("PROPERTY=not quoted"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("PROPERTY=\"bad \"internal\" quotes\""sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + REQUIRE_THROWS_HR(Msi::ParseMSIArguments("PROPERTY=\"mismatched quote"sv), APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); +} + +TEST_CASE("MsiExecArgs_ParseMultipleOptions", "[msiexec]") +{ + { + auto args = Msi::ParseMSIArguments("/li first.txt /le second.txt"sv); + REQUIRE(args.LogMode == INSTALLLOGMODE_ERROR); + REQUIRE(args.LogAttributes == 0); + REQUIRE(args.LogFile == L"second.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("PROPERTY1=value1 /qb PROPERTY2= /lw file.txt"sv); + REQUIRE(args.LogMode == INSTALLLOGMODE_WARNING); + REQUIRE(args.LogAttributes == 0); + REQUIRE(args.LogFile == L"file.txt"sv); + REQUIRE(args.UILevel == INSTALLUILEVEL_BASIC); + REQUIRE(args.Properties == L" PROPERTY1=value1 PROPERTY2="); + } +} + +TEST_CASE("MsiExecArgs_ParseLongOptions", "[msiexec]") +{ + { + auto args = Msi::ParseMSIArguments("/quiet"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY)); + REQUIRE(args.Properties.empty()); + } + + { + auto args = Msi::ParseMSIArguments("/passive"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == (INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL)); + REQUIRE(args.Properties == L" REBOOTPROMPT=S"sv); + } + + { + auto args = Msi::ParseMSIArguments("/NoRestart"sv); + REQUIRE(!args.LogFile.has_value()); + REQUIRE(args.UILevel == INSTALLUILEVEL_DEFAULT); + REQUIRE(args.Properties == L" REBOOT=ReallySuppress"sv); + } +}+ \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/Installer_Msi_WFDependency.yaml b/src/AppInstallerCLITests/TestData/Installer_Msix_WFDependency.yaml diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp @@ -9,6 +9,7 @@ #include <AppInstallerStrings.h> #include <Workflows/ImportExportFlow.h> #include <Workflows/InstallFlow.h> +#include <Workflows/MsiInstallFlow.h> #include <Workflows/UninstallFlow.h> #include <Workflows/UpdateFlow.h> #include <Workflows/MSStoreInstallerHandler.h> @@ -38,6 +39,7 @@ using namespace AppInstaller::CLI::Workflow; using namespace AppInstaller::Logging; using namespace AppInstaller::Manifest; using namespace AppInstaller::Repository; +using namespace AppInstaller::Settings; using namespace AppInstaller::Utility; @@ -225,7 +227,7 @@ namespace if (input == "AppInstallerCliTest.TestMsixInstaller.WFDep") { - auto manifest = YamlParser::CreateFromPath(TestDataFile("Installer_Msi_WFDependency.yaml")); + auto manifest = YamlParser::CreateFromPath(TestDataFile("Installer_Msix_WFDependency.yaml")); result.Matches.emplace_back( ResultMatch( TestPackage::Make( @@ -403,6 +405,32 @@ void OverrideForShellExecute(TestContext& context) OverrideForUpdateInstallerMotw(context); } +void OverrideForDirectMsi(TestContext& context) +{ + context.Override({ DownloadInstallerFile, [](TestContext& context) + { + context.Add<Data::HashPair>({ {}, {} }); + // We don't have an msi installer for tests, but we won't execute it anyway + context.Add<Data::InstallerPath>(TestDataFile("AppInstallerTestExeInstaller.exe")); + } }); + + context.Override({ RenameDownloadedInstaller, [](TestContext&) + { + } }); + + OverrideForUpdateInstallerMotw(context); + + context.Override({ DirectMSIInstallImpl, [](TestContext& context) + { + // Write out the install command + std::filesystem::path temp = std::filesystem::temp_directory_path(); + temp /= "TestMsiInstalled.txt"; + std::ofstream file(temp, std::ofstream::out); + file << context.Get<Execution::Data::InstallerArgs>(); + file.close(); + } }); +} + void OverrideForExeUninstall(TestContext& context) { context.Override({ ShellExecuteUninstallImpl, [](TestContext& context) @@ -624,6 +652,32 @@ TEST_CASE("MsixInstallFlow_StreamingFlow", "[InstallFlow][workflow]") REQUIRE(uri.SchemeName() == L"https"); } +TEST_CASE("MsiInstallFlow_DirectMsi", "[InstallFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestMsiInstalled.txt"); + + TestCommon::TestUserSettings testSettings; + testSettings.Set<Setting::EFDirectMSI>(true); + + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + OverrideForDirectMsi(context); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("InstallerArgTest_Msi_NoSwitches.yaml").GetPath().u8string()); + context.Args.AddArg(Execution::Args::Type::Silent); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify Installer is called and parameters are passed in. + REQUIRE(std::filesystem::exists(installResultPath.GetPath())); + std::ifstream installResultFile(installResultPath.GetPath()); + REQUIRE(installResultFile.is_open()); + std::string installResultStr; + std::getline(installResultFile, installResultStr); + REQUIRE(installResultStr.find("/quiet") != std::string::npos); +} + TEST_CASE("ShellExecuteHandlerInstallerArgs", "[InstallFlow][workflow]") { { diff --git a/src/AppInstallerCLITests/pch.h b/src/AppInstallerCLITests/pch.h @@ -7,6 +7,7 @@ #include <shellapi.h> #include <objbase.h> #include <urlmon.h> +#include <Msi.h> #include <catch.hpp> diff --git a/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj b/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj @@ -295,6 +295,7 @@ <ClInclude Include="Public\winget\ManifestValidation.h" /> <ClInclude Include="Public\winget\ManifestYamlParser.h" /> <ClInclude Include="Public\winget\ManifestYamlPopulator.h" /> + <ClInclude Include="Public\winget\MsiExecArguments.h" /> <ClInclude Include="Public\winget\NameNormalization.h" /> <ClInclude Include="Public\winget\Regex.h" /> <ClInclude Include="Public\winget\Registry.h" /> @@ -345,6 +346,7 @@ <ClCompile Include="Manifest\ManifestSchemaValidation.cpp" /> <ClCompile Include="Manifest\ManifestYamlPopulator.cpp" /> <ClCompile Include="Manifest\YamlParser.cpp" /> + <ClCompile Include="MsiExecArguments.cpp" /> <ClCompile Include="MsixInfo.cpp"> <ExcludedFromBuild Condition="'$(Configuration)'=='Fuzzing'">true</ExcludedFromBuild> </ClCompile> diff --git a/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj.filters b/src/AppInstallerCommonCore/AppInstallerCommonCore.vcxproj.filters @@ -174,6 +174,9 @@ <ClInclude Include="Public\winget\TraceLogger.h"> <Filter>Public\winget</Filter> </ClInclude> + <ClInclude Include="Public\winget\Msi.h"> + <Filter>Public\winget</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <ClCompile Include="pch.cpp"> diff --git a/src/AppInstallerCommonCore/Deployment.cpp b/src/AppInstallerCommonCore/Deployment.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Public/AppInstallerDeployment.h" #include "Public/AppInstallerLogging.h" diff --git a/src/AppInstallerCommonCore/Errors.cpp b/src/AppInstallerCommonCore/Errors.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Public/AppInstallerErrors.h" #include "Public/AppInstallerLogging.h" diff --git a/src/AppInstallerCommonCore/ExperimentalFeature.cpp b/src/AppInstallerCommonCore/ExperimentalFeature.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "AppInstallerLogging.h" #include "winget/ExperimentalFeature.h" @@ -48,6 +47,8 @@ namespace AppInstaller::Settings return userSettings.Get<Setting::EFPackagedAPI>(); case ExperimentalFeature::Feature::Dependencies: return userSettings.Get<Setting::EFDependencies>(); + case ExperimentalFeature::Feature::DirectMSI: + return userSettings.Get<Setting::EFDirectMSI>(); default: THROW_HR(E_UNEXPECTED); } @@ -80,6 +81,8 @@ namespace AppInstaller::Settings return ExperimentalFeature{ "Packaged API Support", "packagedAPI", "https://aka.ms/winget-settings", Feature::PackagedAPI }; case Feature::Dependencies: return ExperimentalFeature{ "Show Dependencies Information", "dependencies", "https://aka.ms/winget-settings", Feature::Dependencies }; + case Feature::DirectMSI: + return ExperimentalFeature{ "Direct MSI Installation", "directMSI", "https://aka.ms/winget-settings", Feature::DirectMSI }; default: THROW_HR(E_UNEXPECTED); } diff --git a/src/AppInstallerCommonCore/ExtensionCatalog.cpp b/src/AppInstallerCommonCore/ExtensionCatalog.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "winget/ExtensionCatalog.h" #include "AppInstallerErrors.h" diff --git a/src/AppInstallerCommonCore/MsiExecArguments.cpp b/src/AppInstallerCommonCore/MsiExecArguments.cpp @@ -0,0 +1,570 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "Public/winget/MsiExecArguments.h" +#include "Public/AppInstallerErrors.h" +#include "Public/AppInstallerLogging.h" +#include "Public/AppInstallerStrings.h" + + +namespace AppInstaller::Msi +{ + using namespace std::string_view_literals; + + namespace + { + const char MsiExecQuietOption = 'q'; + const char MsiExecLogOption = 'l'; + + // Description of how a long option is replaced by a short option. + struct TokenReplacement + { + TokenReplacement(std::string_view longOption, std::string_view shortOption) : LongOption(longOption), ShortOption({ shortOption }) {} + TokenReplacement(std::string_view longOption, std::vector<std::string_view>&& shortOption) : LongOption(longOption), ShortOption(std::move(shortOption)) {} + std::string_view LongOption; + std::vector<std::string_view> ShortOption; + }; + + // Determines whether an argument token is a switch/option. + bool IsSwitch(std::string_view token) + { + THROW_HR_IF(APPINSTALLER_CLI_ERROR_INTERNAL_ERROR, token.empty()); + return token[0] == '-' || token[0] == '/'; + } + + // Parses the log mode and log file for the Log (/l) option. + // The option has a modifier specifying the log mode (what is logged) + // and a value specifying the log file. + // E.g. /l* log.txt, /lw warnings.txt + void ParseLogOption(std::string_view logModeString, std::string_view logFile, MsiParsedArguments& parsedArgs) + { + if (Utility::IsEmptyOrWhitespace(logFile)) + { + AICLI_LOG(Core, Error, << "MSI log file path cannot be empty"); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + + INSTALLLOGMODE logMode = {}; + INSTALLLOGATTRIBUTES logAttributes = {}; + + // Note: These flags are mostly consecutive bits in the order given, except where indicated. + // Skipped flags are not mapped to a command line option. + std::map<char, INSTALLLOGMODE> ValidLogModes + { + { 'm', INSTALLLOGMODE_FATALEXIT }, + { 'e', INSTALLLOGMODE_ERROR }, + { 'w', INSTALLLOGMODE_WARNING }, + { 'u', INSTALLLOGMODE_USER }, + { 'i', INSTALLLOGMODE_INFO }, + // FILESINUSE + // RESOLVESOURCE + { 'o', INSTALLLOGMODE_OUTOFDISKSPACE }, + { 'a', INSTALLLOGMODE_ACTIONSTART }, + { 'r', INSTALLLOGMODE_ACTIONDATA }, + { 'p', INSTALLLOGMODE_PROPERTYDUMP }, + { 'c', INSTALLLOGMODE_COMMONDATA }, + { 'v', INSTALLLOGMODE_VERBOSE }, + { 'x', INSTALLLOGMODE_EXTRADEBUG }, + // LOGONLYONERROR + // LOGPERFORMANCE + }; + + std::map<char, INSTALLLOGATTRIBUTES> ValidLogAttributes + { + { '+', INSTALLLOGATTRIBUTES_APPEND }, + { '!', INSTALLLOGATTRIBUTES_FLUSHEACHLINE }, + }; + + bool isLogModeSet = false; + for (char c : logModeString) + { + // Log-all option + if (c == '*') + { + logMode |= AllLogMode; + isLogModeSet = true; + continue; + } + + auto modeItr = ValidLogModes.find(c); + if (modeItr != ValidLogModes.end()) + { + logMode |= modeItr->second; + isLogModeSet = true; + continue; + } + + auto attributeItr = ValidLogAttributes.find(c); + if (attributeItr != ValidLogAttributes.end()) + { + logAttributes |= attributeItr->second; + continue; + } + + AICLI_LOG(Core, Error, << "Unknown msiexec log modifier: " << c); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + + if (!isLogModeSet) + { + logMode = DefaultLogMode; + } + + parsedArgs.LogMode = logMode; + parsedArgs.LogAttributes = logAttributes; + parsedArgs.LogFile = Utility::ConvertToUTF16(logFile); + } + + // Parses the modifier for the UI Level option (/q) + // The modifier starts with a base (b, f, n, r), followed by extra flags (+, -, !). + // E.g. /qn, /qb-! + void ParseQuietOption(std::string_view modifier, MsiParsedArguments& parsedArgs) + { + if (modifier.empty()) + { + // /q is treated as equivalent to /qn + modifier = "n"sv; + } + + // Lower values in INSTALLUILEVEL work like a base enum (e.g. None=2, Basic=3) + // with higher values being modifying flags (e.g. HideCancel=0x20, ProgressOnly=0x40). + // Some steps depend on the base enum, so we keep it separate for easier checking. + INSTALLUILEVEL uiLevelBase = {}; + INSTALLUILEVEL uiLevelModifiers = {}; + + // Parse the base level + switch (std::tolower(modifier[0])) + { + case 'f': + uiLevelBase = INSTALLUILEVEL_FULL; + break; + case 'r': + uiLevelBase = INSTALLUILEVEL_REDUCED; + break; + case 'b': + uiLevelBase = INSTALLUILEVEL_BASIC; + break; + case '+': + uiLevelBase = INSTALLUILEVEL_NONE; + uiLevelModifiers = INSTALLUILEVEL_ENDDIALOG; + break; + case 'n': + uiLevelBase = INSTALLUILEVEL_NONE; + break; + default: + AICLI_LOG(Core, Error, << "Invalid modifier for msiexec /q argument: " << modifier); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + }; + + // Parse the modifiers + for (size_t i = 1; i < modifier.size(); ++i) + { + const char c = modifier[i]; + + if (c == '+') + { + WI_SetFlag(uiLevelModifiers, INSTALLUILEVEL_ENDDIALOG); + } + else if (c == '-') + { + if (uiLevelBase == INSTALLUILEVEL_BASIC) + { + WI_SetFlag(uiLevelModifiers, INSTALLUILEVEL_PROGRESSONLY); + } + else + { + AICLI_LOG(Core, Error, << "msiexec UI option Progress Only (-) is only valid with UI level Basic (b)"); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + } + else if (c == '!') + { + if (uiLevelBase == INSTALLUILEVEL_BASIC) + { + WI_SetFlag(uiLevelModifiers, INSTALLUILEVEL_HIDECANCEL); + } + else + { + AICLI_LOG(Core, Error, << "msiexec UI option Hide Cancel (!) is only valid with UI level Basic (b)"); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + } + } + + // Only deviation from msiexec: + // When using UI Level None, allow showing the UAC prompt. + WI_SetFlagIf(uiLevelModifiers, INSTALLUILEVEL_UACONLY, uiLevelBase == INSTALLUILEVEL_NONE); + + parsedArgs.UILevel = uiLevelBase | uiLevelModifiers; + } + + bool IsWhiteSpace(char c) + { + return c == ' ' || c == '\t'; + } + + // Gets the next token found in the arguments string, starting the search on the given position. + // If there are no more tokens, return empty. + // After finding the token, updates `start` to point to the next place we need to start the next token search. + std::string_view GetNextToken(std::string_view arguments, size_t& start) + { + // Eat leading whitespace + while (start < arguments.size() && IsWhiteSpace(arguments[start])) + { + ++start; + } + + if (start >= arguments.size()) + { + // We reached the end + return {}; + } + + size_t pos = start; + bool seekingSpaceSeparator = ('"' != arguments[pos]); + bool withinQuotes = false; + + // Start looking from the next character + ++pos; + + // Advance until we hit the end or the next separator + while (pos < arguments.size()) + { + bool isSpace = IsWhiteSpace(arguments[pos]); + bool isQuote = ('"' == arguments[pos]); + + if (isSpace || isQuote) + { + // We've encountered one of the two separators we're interested in + if (seekingSpaceSeparator) + { + if (isQuote) + { + // We will ignore space characters enclosed between double quotes + withinQuotes = !withinQuotes; + } + else + { + // This is a space character. If it is between quotes we ignore it; + // otherwise it is a separator. + if (!withinQuotes) + { + break; + } + } + } + else + { + if (isQuote) + { + // we've got what we needed, it is OK to stop + break; + } + } + } + + ++pos; + } + + if (!seekingSpaceSeparator) + { + // We were looking for a terminating " character. + if (pos < arguments.size()) + { + // We move past the " character (it is OK for the end of the line + // to act as the matching " character in some cases) + ++pos; + } + } + + auto result = arguments.substr(start, pos - start); + start = pos; + return result; + } + + // Split the arguments string into tokens. Tokens are delimited by whitespace + // unless quoted. Each token represents an option (like /q), an argument + // for an option, or a property. + std::list<std::string> TokenizeMsiArguments(std::string_view arguments) + { + size_t start = 0; + std::list<std::string> result; + auto token = GetNextToken(arguments, start); + while (!token.empty()) + { + result.emplace_back(token); + token = GetNextToken(arguments, start); + } + + return result; + } + + // Parses a token that represents an argument to an option. + // If the value is unquoted, returns it as is. + // If the value is quoted, removes the quotes and replaces escaped characters. + std::string ParseValue(std::string_view valueToken) + { + if (valueToken.empty() || valueToken[0] != '"') + { + // Nothing to do for empty or unquoted tokens + return std::string{ valueToken }; + } + + // Copy the string ignoring the quotes and replacing escaped characters. + // In quoted tokens, the back quote represents double quotes (` means ") + // and can be escaped with back slash (\` means `). + // Note that we accept quoted values with a missing closing quote (the end + // of string signals the end). + std::string result; + for (size_t i = 1; i < valueToken.size(); ++i) + { + if (valueToken[i] == '"') + { + // The tokenizer can leave several pairs of quotes in the token + // but they are not accepted in this case. We only accept the final + // closing quotes. + if (i + 1 == valueToken.size()) + { + break; + } + else + { + AICLI_LOG(Core, Error, << "Invalid msiexec argument: " << valueToken); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + } + + if (i + 1 < valueToken.size() && valueToken[i] == '\\' && valueToken[i + 1] == '`') + { + result += '`'; + ++i; + } + else if (valueToken[i] == '`') + { + result += '"'; + } + else + { + result += valueToken[i]; + } + } + + return result; + } + + // Validates that a token represents a property. + // This checks that the property has the form PropertyName=Value, + // with the value optionally quoted. + bool IsValidPropertyToken(std::string_view token) + { + THROW_HR_IF(APPINSTALLER_CLI_ERROR_INTERNAL_ERROR, token.empty()); + + if (token[0] != '%' && !IsCharAlphaNumericA(token[0])) + { + AICLI_LOG(Core, Error, << "Bad property for msiexec: " << token); + return false; + } + + // Find the = separator at the end of the property name + size_t pos = 0; + while (pos < token.size() && !IsWhiteSpace(token[pos]) && token[pos] != '=') + { + ++pos; + } + + if (pos == token.size() || token[pos] != '=') + { + AICLI_LOG(Core, Error, << "Expected property for call to msiexec, but couldn't find separator: " << token); + return false; + } + + // Validate the property value. + // It should be completely enclosed in quotes, or not contain white space. + // If quoted, there can be pairs of consecutive quotes that work as escape sequences. + // We accept empty property values. + ++pos; + if (pos == token.size()) + { + // Empty value + return true; + } + + // If quoted, we will only inspect the values between the quotes. + bool quoted = false; + size_t end = token.size(); + if (token[pos] == '"') + { + ++pos; + + if (pos >= end || token.back() != '"') + { + AICLI_LOG(Core, Error, << "Badly quoted msiexec property: " << token); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + + --end; + quoted = true; + } + + while (pos < end) + { + if (quoted) + { + // For quoted values, any internal quote must be followed by another one. + if (token[pos] == '"') + { + if (pos + 1 < end && token[pos + 1] == '"') + { + // Skip the two quotes + ++pos; + } + else + { + AICLI_LOG(Core, Error, << "Unexpected quotes in msiexec property arg: " << token); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + } + } + else + { + // For unquoted values, we only check that there is no whitespace + if (IsWhiteSpace(token[pos])) + { + AICLI_LOG(Core, Error, << "Unexpected space in msiexec property arg: " << token); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + } + + ++pos; + } + + return true; + } + + // Replaces long options in the arguments (e.g. /quiet), by their short equivalents + // (e.g. /qn). The replacement is done in-place. + void ReplaceLongOptions(std::list<std::string>& tokens) + { + // We don't handle all possible options because we don't need to. + // Options not handled: + // /update + // /uninstall + // /package + // /help + const std::vector<TokenReplacement> Replacements + { + { "quiet"sv, "/qn"sv }, + { "passive"sv, { "/qb!-"sv, "REBOOTPROMPT=S"sv } }, + { "norestart"sv, "REBOOT=ReallySuppress"sv }, + { "forcerestart"sv, "REBOOT=Force"sv }, + { "promptrestart"sv, "REBOOTPROMPT=\"\""sv }, + { "log"sv, "/l*"sv }, + }; + + auto itr = tokens.begin(); + while (itr != tokens.end()) + { + if (!IsSwitch(*itr)) + { + // We only need to replace switches. + ++itr; + continue; + } + + // Find if there is a replacement for this option. + // We ignore the leading / or - when comparing. + auto option = std::string_view(*itr).substr(1); + auto replacementItr = std::find_if(Replacements.begin(), Replacements.end(), [&](const TokenReplacement& replacement) { return Utility::CaseInsensitiveEquals(replacement.LongOption, option); }); + if (replacementItr == Replacements.end()) + { + // There is no replacement for this switch; + ++itr; + continue; + } + + // Add all the replacements tokens needed before this one, then delete the existing token. + tokens.insert(itr, replacementItr->ShortOption.begin(), replacementItr->ShortOption.end()); + + // Delete the current token an move to the next one. + // We don't need to do anything more to the newly added tokens. + itr = tokens.erase(itr); + } + } + + // Consumes the next argument token in the list. If the token is an option + // that takes an argument, also consumes it. After consuming the token(s), + // removes it from the list and updates the parsed arguments accordingly. + void ConsumeNextToken(std::list<std::string>& tokens, MsiParsedArguments& parsedArgs) + { + THROW_HR_IF(APPINSTALLER_CLI_ERROR_INTERNAL_ERROR, tokens.empty()); + + auto token = std::move(tokens.front()); + tokens.pop_front(); + if (!IsSwitch(token)) + { + // Token is a property, i.e. NAME=value. Add it to the parsed args. + THROW_HR_IF(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT, !IsValidPropertyToken(token)); + parsedArgs.Properties += L" " + Utility::ConvertToUTF16(token); + return; + } + + // Token is an option. + if (token.size() <= 1) + { + AICLI_LOG(Core, Error, << "Invalid command line argument for msiexec: " << token); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + + char option = token[1]; + auto optionModifier = ParseValue(std::string_view(token).substr(2)); + + // Options are case-insensitive + switch (std::tolower(option)) + { + case MsiExecQuietOption: + { + ParseQuietOption(optionModifier, parsedArgs); + break; + } + case MsiExecLogOption: + { + if (tokens.empty()) + { + // Log option must be followed by an option argument + AICLI_LOG(Core, Error, << "msiexec option " << token << " must be followed by a value"); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + + const auto optionValue = ParseValue(tokens.front()); + tokens.pop_front(); + + ParseLogOption(optionModifier, optionValue, parsedArgs); + break; + } + default: + { + AICLI_LOG(Core, Error, << "Invalid option for msiexec: " << token); + THROW_HR(APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT); + } + } + } + } + + MsiParsedArguments ParseMSIArguments(std::string_view arguments) + { + // Split the arguments into tokens, which we will process one by one. + auto argumentTokens = TokenizeMsiArguments(arguments); + + // Replace long options so we can work only with short ones. + ReplaceLongOptions(argumentTokens); + + // Process the arguments. + MsiParsedArguments result; + while (!argumentTokens.empty()) + { + ConsumeNextToken(argumentTokens, result); + } + + return result; + } +}+ \ No newline at end of file diff --git a/src/AppInstallerCommonCore/Public/AppInstallerErrors.h b/src/AppInstallerCommonCore/Public/AppInstallerErrors.h @@ -79,6 +79,7 @@ #define APPINSTALLER_CLI_ERROR_STREAM_READ_FAILURE ((HRESULT)0x8a150040) #define APPINSTALLER_CLI_ERROR_LICENSE_NOT_ACCEPTED ((HRESULT)0x8a150041) #define APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR ((HRESULT)0x8a150042) +#define APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT ((HRESULT)0x8a150043) namespace AppInstaller { diff --git a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h @@ -23,6 +23,8 @@ namespace AppInstaller::Settings ExperimentalMSStore = 0x1, PackagedAPI = 0x2, Dependencies = 0x4, + // Before making DirectMSI non-experimental, it should be part of manifest validation. + DirectMSI = 0x8, Max, // This MUST always be after all experimental features // Features listed after Max will not be shown with the features command diff --git a/src/AppInstallerCommonCore/Public/winget/MsiExecArguments.h b/src/AppInstallerCommonCore/Public/winget/MsiExecArguments.h @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include <list> +#include <string_view> +#include <vector> + + +// This file defines parsing of the command line arguments passed to msiexec.exe. +// +// Some packages require the UAC prompt for installing even on silent installs. This +// can be done with the MSI API using the INSTALLUILEVEL_UACONLY flag, but msiexec.exe +// does not provide a way to use it. So, we use the MSI API directly instead of +// through msiexec.exe. Since msiexec.exe does some parsing of command line arguments +// before handing off to the API, we replicate that parsing here. +// +// Since we care only about installation, we simplify the parsing by assuming that +// the command line has the form +// msiexec.exe /i (MSI file) [Other args...] + +namespace AppInstaller::Msi +{ + DEFINE_ENUM_FLAG_OPERATORS(INSTALLUILEVEL); + DEFINE_ENUM_FLAG_OPERATORS(INSTALLLOGMODE); + DEFINE_ENUM_FLAG_OPERATORS(INSTALLLOGATTRIBUTES); + + constexpr INSTALLLOGMODE DefaultLogMode = + INSTALLLOGMODE_FATALEXIT | INSTALLLOGMODE_ERROR | INSTALLLOGMODE_WARNING | INSTALLLOGMODE_INFO | + INSTALLLOGMODE_OUTOFDISKSPACE | INSTALLLOGMODE_ACTIONSTART | INSTALLLOGMODE_ACTIONDATA; + + // All but the four flags that always have to be set explicitly (Verbose, ExtraDebug, LogOnlyOnError, LogPerformance) + constexpr INSTALLLOGMODE AllLogMode = + INSTALLLOGMODE_FATALEXIT | INSTALLLOGMODE_ERROR | INSTALLLOGMODE_WARNING | INSTALLLOGMODE_USER | INSTALLLOGMODE_INFO | + INSTALLLOGMODE_OUTOFDISKSPACE | INSTALLLOGMODE_ACTIONSTART | INSTALLLOGMODE_ACTIONDATA | + INSTALLLOGMODE_PROPERTYDUMP | INSTALLLOGMODE_COMMONDATA; + + // Arguments parsed from a command line string. + // Arguments currently supported are: + // - Logging options (/l) + // - Quiet options (/q) + // - Properties (PROPERTY=Value) + struct MsiParsedArguments + { + // Logging options. See: MsiEnableLog() + INSTALLLOGMODE LogMode = {}; + std::optional<std::wstring> LogFile; + INSTALLLOGATTRIBUTES LogAttributes = {}; + + // UI options. See: MsiSetInternalUI() + INSTALLUILEVEL UILevel = INSTALLUILEVEL_DEFAULT; + + // Properties string + std::wstring Properties; + }; + + // Parses a command line string for msiexec. + // This function assumes that the full command line will have the form + // msiexec.exe /i package.msi [arguments] + // and that it is only parsing the [arguments] part. + // + // Note: This does not match msiexec exactly. It does not support options + // unrelated to install, nor all options for install (e.g. /n). + MsiParsedArguments ParseMSIArguments(std::string_view arguments); +} diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -77,6 +77,7 @@ namespace AppInstaller::Settings InstallLocalePreference, InstallLocaleRequirement, EFPackagedAPI, + EFDirectMSI, Max }; @@ -124,6 +125,7 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocalePreference, std::vector<std::string>, std::vector<std::string>, {}, ".installBehavior.preferences.locale"sv); SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocaleRequirement, std::vector<std::string>, std::vector<std::string>, {}, ".installBehavior.requirements.locale"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFPackagedAPI, bool, bool, false, ".experimentalFeatures.packagedAPI"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv); // Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types. template <size_t... I> diff --git a/src/AppInstallerCommonCore/Synchronization.cpp b/src/AppInstallerCommonCore/Synchronization.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include <AppInstallerSynchronization.h> #include <AppInstallerStrings.h> diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp @@ -228,6 +228,7 @@ namespace AppInstaller::Settings WINGET_VALIDATE_PASS_THROUGH(EFDependencies) WINGET_VALIDATE_PASS_THROUGH(TelemetryDisable) WINGET_VALIDATE_PASS_THROUGH(EFPackagedAPI) + WINGET_VALIDATE_PASS_THROUGH(EFDirectMSI) WINGET_VALIDATE_SIGNATURE(InstallScopePreference) { diff --git a/src/AppInstallerCommonCore/Yaml.cpp b/src/AppInstallerCommonCore/Yaml.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include <pch.h> #include "winget/Yaml.h" #include "YamlWrapper.h" diff --git a/src/AppInstallerCommonCore/YamlWrapper.cpp b/src/AppInstallerCommonCore/YamlWrapper.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include <pch.h> #include "YamlWrapper.h" #include "AppInstallerErrors.h" diff --git a/src/AppInstallerCommonCore/pch.h b/src/AppInstallerCommonCore/pch.h @@ -11,6 +11,7 @@ #include <Shlwapi.h> #include <wow64apiset.h> #include <icu.h> +#include <msi.h> #include "TraceLogging.h" diff --git a/src/AppInstallerRepositoryCore/Microsoft/PreIndexedPackageSourceFactory.cpp b/src/AppInstallerRepositoryCore/Microsoft/PreIndexedPackageSourceFactory.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Microsoft/PreIndexedPackageSourceFactory.h" #include "Microsoft/SQLiteIndex.h" diff --git a/src/AppInstallerRepositoryCore/Microsoft/PredefinedInstalledSourceFactory.cpp b/src/AppInstallerRepositoryCore/Microsoft/PredefinedInstalledSourceFactory.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Microsoft/ARPHelper.h" #include "Microsoft/PredefinedInstalledSourceFactory.h" diff --git a/src/AppInstallerRepositoryCore/Microsoft/PredefinedWriteableSourceFactory.cpp b/src/AppInstallerRepositoryCore/Microsoft/PredefinedWriteableSourceFactory.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "Microsoft/ARPHelper.h" #include "Microsoft/PredefinedWriteableSourceFactory.h" diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/1_0/SearchResultsTable_1_0.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/1_0/SearchResultsTable_1_0.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "SearchResultsTable.h" #include "SQLiteStatementBuilder.h" diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/1_1/SearchResultsTable_1_1.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/1_1/SearchResultsTable_1_1.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "SearchResultsTable.h" diff --git a/src/AppInstallerRepositoryCore/Microsoft/Schema/1_2/SearchResultsTable_1_2.cpp b/src/AppInstallerRepositoryCore/Microsoft/Schema/1_2/SearchResultsTable_1_2.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "SearchResultsTable.h" diff --git a/src/AppInstallerRepositoryCore/SQLiteStatementBuilder.cpp b/src/AppInstallerRepositoryCore/SQLiteStatementBuilder.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "SQLiteStatementBuilder.h" diff --git a/src/AppInstallerRepositoryCore/SQLiteTempTable.cpp b/src/AppInstallerRepositoryCore/SQLiteTempTable.cpp @@ -1,6 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#pragma once #include "pch.h" #include "SQLiteTempTable.h" diff --git a/src/WinGetServer/WinGetServer.vcxproj b/src/WinGetServer/WinGetServer.vcxproj @@ -106,7 +106,7 @@ <SubSystem>Windows</SubSystem> <GenerateWindowsMetadata>false</GenerateWindowsMetadata> <AdditionalLibraryDirectories>$(OutDir)..\Microsoft.Management.Deployment;$(OutDir)..\AppInstallerCLICore;$(OutDir)..\JsonCppLib;$(OutDir)..\AppInstallerRepositoryCore;$(OutDir)..\YamlCppLib;$(OutDir)..\AppInstallerCommonCore;$(OutDir)..\cpprestsdk;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> - <AdditionalDependencies>Microsoft.Management.Deployment.lib;AppInstallerCLICore.lib;AppInstallerCommonCore.lib;AppInstallerRepositoryCore.lib;JsonCppLib.lib;YamlCppLib.lib;cpprestsdk.lib;wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>Microsoft.Management.Deployment.lib;AppInstallerCLICore.lib;AppInstallerCommonCore.lib;AppInstallerRepositoryCore.lib;JsonCppLib.lib;YamlCppLib.lib;cpprestsdk.lib;wininet.lib;shell32.lib;winsqlite3.lib;shlwapi.lib;icuuc.lib;icuin.lib;urlmon.lib;Advapi32.lib;winhttp.lib;onecoreuap.lib;msi.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">