diff --git a/doc/Settings.md b/doc/Settings.md index 7285c9e1a0..bb2e078d85 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -135,6 +135,16 @@ The `purgePortablePackage` behavior affects the default behavior for uninstallin }, ``` +### Default install root + +The `defaultInstallRoot` affects the install location when a package requires one. This can be overridden by the `--location` parameter. This setting is only used when a package manifest includes `InstallLocationRequired`, and the actual location is obtained by appending the package ID to the root. + +```json + "installBehavior": { + "defaultInstallRoot": "C:\installRoot" + }, +``` + ## Telemetry The `telemetry` settings control whether winget writes ETW events that may be sent to Microsoft on a default installation of Windows. @@ -183,6 +193,20 @@ The `doProgressTimeoutInSeconds` setting updates the number of seconds to wait w } ``` +## Interactivity + +The `interactivity` settings control whether winget may show interactive prompts during execution. Note that this refers only to prompts shown by winget itself and not to those shown by package installers. + +### disable + +```json + "interactivity": { + "disable": true + }, +``` + +If set to true, the `interactivity.disable` setting will prevent any interactive prompt from being shown. + ## Experimental Features To allow work to be done and distributed to early adopters for feedback, settings can be used to enable "experimental" features. diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index cb5b67f196..768158ccff 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -116,6 +116,11 @@ "description": "The default root directory where packages are installed to under Machine scope. Applies to the portable installer type.", "type": "string", "default": "%PROGRAMFILES%/WinGet/Packages/" + }, + "defaultInstallRoot": { + "description": "Default install location to use for packages that require it when not specified", + "type": "string", + "maxLength": "32767" } } }, @@ -164,6 +169,17 @@ } } }, + "Interactivity": { + "description": "Interactivity settings", + "type": "object", + "properties": { + "disable": { + "description": "Controls whether interactive prompts are shown by the Windows Package Manager client", + "type": "boolean", + "default": false + } + } + }, "Experimental": { "description": "Experimental Features", "type": "object", @@ -233,6 +249,12 @@ }, "additionalItems": true }, + { + "properties": { + "interactivity": { "$ref": "#/definitions/Interactivity" } + }, + "additionalItems": true + }, { "properties": { "experimentalFeatures": { "$ref": "#/definitions/Experimental" } diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj index f09cd3c3ab..4cc8f5d9da 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -260,7 +260,7 @@ - + @@ -283,6 +283,7 @@ + @@ -336,6 +337,7 @@ + diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters index f0925e10b9..4904eab23d 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters @@ -173,7 +173,7 @@ Workflows - + Public @@ -181,7 +181,10 @@ Workflows - + + + Workflows + @@ -328,9 +331,12 @@ Workflows + + Workflows + Workflows - + diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp index a871a3b387..6af4585099 100644 --- a/src/AppInstallerCLICore/Argument.cpp +++ b/src/AppInstallerCLICore/Argument.cpp @@ -110,6 +110,7 @@ namespace AppInstaller::CLI args.push_back(ForType(Args::Type::RainbowStyle)); args.push_back(ForType(Args::Type::RetroStyle)); args.push_back(ForType(Args::Type::VerboseLogs)); + args.emplace_back("disable-interactivity", NoAlias, Args::Type::DisableInteractivity, Resource::String::DisableInteractivityArgumentDescription, ArgumentType::Flag, false); } std::string Argument::GetUsageString() const diff --git a/src/AppInstallerCLICore/COMContext.h b/src/AppInstallerCLICore/COMContext.h index 5cd9cde1e8..fa282661cc 100644 --- a/src/AppInstallerCLICore/COMContext.h +++ b/src/AppInstallerCLICore/COMContext.h @@ -41,12 +41,14 @@ namespace AppInstaller::CLI::Execution Reporter.SetChannel(Reporter::Channel::Disabled); Reporter.SetProgressSink(this); SetFlags(CLI::Execution::ContextFlag::AgreementsAcceptedByCaller); + SetFlags(CLI::Execution::ContextFlag::DisableInteractivity); } COMContext(std::ostream& out, std::istream& in) : CLI::Execution::Context(out, in) { Reporter.SetProgressSink(this); SetFlags(CLI::Execution::ContextFlag::AgreementsAcceptedByCaller); + SetFlags(CLI::Execution::ContextFlag::DisableInteractivity); } ~COMContext() = default; diff --git a/src/AppInstallerCLICore/Command.cpp b/src/AppInstallerCLICore/Command.cpp index 5f52898c81..7a820a2817 100644 --- a/src/AppInstallerCLICore/Command.cpp +++ b/src/AppInstallerCLICore/Command.cpp @@ -835,6 +835,7 @@ namespace AppInstaller::CLI { ExecuteInternal(context); } + if (context.Args.Contains(Execution::Args::Type::Wait)) { context.Reporter.PromptForEnter(); diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h index 80568e1b89..bafda0b0cf 100644 --- a/src/AppInstallerCLICore/ExecutionArgs.h +++ b/src/AppInstallerCLICore/ExecutionArgs.h @@ -79,22 +79,26 @@ namespace AppInstaller::CLI::Execution AdminSettingEnable, AdminSettingDisable, - // Upgrade Command - All, // Update all installed packages to latest - IncludeUnknown, // Allow upgrades of packages with unknown versions + // Upgrade command + All, // Used in Update command to update all installed packages to latest + IncludeUnknown, // Used in Upgrade command to allow upgrades of packages with unknown versions - // Other + // Show command ListVersions, // Used in Show command to list all available versions of an app + + // Common arguments NoVT, // Disable VirtualTerminal outputs RetroStyle, // Makes progress display as retro RainbowStyle, // Makes progress display as a rainbow Help, // Show command usage Info, // Show general info about WinGet VerboseLogs, // Increases winget logging level to verbose + DisableInteractivity, // Disable interactive prompts + Wait, // Prompts the user to press any key before exiting + DependencySource, // Index source to be queried against for finding dependencies CustomHeader, // Optional Rest source header AcceptSourceAgreements, // Accept all source agreements - Wait, // Prompts the user to press any key before exiting // Used for demonstration purposes ExperimentalArg, diff --git a/src/AppInstallerCLICore/ExecutionContext.h b/src/AppInstallerCLICore/ExecutionContext.h index d2f0deb301..6fe4d8c444 100644 --- a/src/AppInstallerCLICore/ExecutionContext.h +++ b/src/AppInstallerCLICore/ExecutionContext.h @@ -64,6 +64,7 @@ namespace AppInstaller::CLI::Execution // TODO: Remove when the source interface is refactored. TreatSourceFailuresAsWarning = 0x10, ShowSearchResultsOnPartialFailure = 0x20, + DisableInteractivity = 0x40, }; DEFINE_ENUM_FLAG_OPERATORS(ContextFlag); diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index c70e78a858..88808bc50a 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -163,6 +163,32 @@ namespace AppInstaller::CLI::Execution m_in.get(); } + std::filesystem::path Reporter::PromptForPath(Resource::LocString message, Level level) + { + auto out = GetOutputStream(level); + + // Try prompting until we get a valid answer + for (;;) + { + out << message << ' '; + + // Read the response + std::string response; + if (!std::getline(m_in, response)) + { + THROW_HR(APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); + } + + // Validate the path + std::filesystem::path path{ response }; + if (path.is_absolute()) + { + return path; + } + } + + } + void Reporter::ShowIndefiniteProgress(bool running) { if (m_spinner) @@ -186,7 +212,7 @@ namespace AppInstaller::CLI::Execution m_progressBar->ShowProgress(current, maximum, type); } } - + void Reporter::BeginProgress() { GetBasicOutputStream() << VirtualTerminal::Cursor::Visibility::DisableShow; diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h index e262f44a6a..f31c1469b0 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.h +++ b/src/AppInstallerCLICore/ExecutionReporter.h @@ -102,6 +102,9 @@ namespace AppInstaller::CLI::Execution // Prompts the user, continues when Enter is pressed void PromptForEnter(Level level = Level::Info); + // Prompts the user for a path. + std::filesystem::path PromptForPath(Resource::LocString message, Level level = Level::Info); + // Used to show indefinite progress. Currently an indefinite spinner is the form of // showing indefinite progress. // running: shows indefinite progress if set to true, stops indefinite progress if set to false diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 2367f53d76..157fc4bd9f 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -45,6 +45,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(CompleteCommandLongDescription); WINGET_DEFINE_RESOURCE_STRINGID(CompleteCommandShortDescription); WINGET_DEFINE_RESOURCE_STRINGID(CountArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(CountOutOfBoundsError); WINGET_DEFINE_RESOURCE_STRINGID(DependenciesFlowInstall); WINGET_DEFINE_RESOURCE_STRINGID(DependenciesFlowSourceNotFound); WINGET_DEFINE_RESOURCE_STRINGID(DependenciesFlowSourceTooManyMatches); @@ -56,8 +57,8 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(DependenciesFlowContainsLoop); WINGET_DEFINE_RESOURCE_STRINGID(DependenciesManagementError); WINGET_DEFINE_RESOURCE_STRINGID(DependenciesManagementExitMessage); - WINGET_DEFINE_RESOURCE_STRINGID(CountOutOfBoundsError); WINGET_DEFINE_RESOURCE_STRINGID(DisabledByGroupPolicy); + WINGET_DEFINE_RESOURCE_STRINGID(DisableInteractivityArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(Done); WINGET_DEFINE_RESOURCE_STRINGID(ExactArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(ExperimentalArgumentDescription); @@ -121,6 +122,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(InstallCommandShortDescription); WINGET_DEFINE_RESOURCE_STRINGID(InstalledPackageNotAvailable); WINGET_DEFINE_RESOURCE_STRINGID(InstalledPackageVersionNotAvailable); + WINGET_DEFINE_RESOURCE_STRINGID(InstallerAbortsTerminal); WINGET_DEFINE_RESOURCE_STRINGID(InstallerElevationExpected); WINGET_DEFINE_RESOURCE_STRINGID(InstallerBlockedByPolicy); WINGET_DEFINE_RESOURCE_STRINGID(InstallerFailedSecurityCheck); @@ -133,6 +135,9 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(InstallerHashVerified); WINGET_DEFINE_RESOURCE_STRINGID(InstallerLogAvailable); WINGET_DEFINE_RESOURCE_STRINGID(InstallerProhibitsElevation); + WINGET_DEFINE_RESOURCE_STRINGID(InstallerRequiresInstallLocation); + WINGET_DEFINE_RESOURCE_STRINGID(InstallersAbortTerminal); + WINGET_DEFINE_RESOURCE_STRINGID(InstallersRequireInstallLocation); WINGET_DEFINE_RESOURCE_STRINGID(InstallFlowInstallSuccess); WINGET_DEFINE_RESOURCE_STRINGID(InstallFlowRegistrationDeferred); WINGET_DEFINE_RESOURCE_STRINGID(InstallFlowReturnCodeAlreadyInstalled); @@ -152,6 +157,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(InstallFlowReturnCodeRebootRequiredToFinish); WINGET_DEFINE_RESOURCE_STRINGID(InstallFlowStartingPackageInstall); WINGET_DEFINE_RESOURCE_STRINGID(InstallForceArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(InstallLocationNotProvided); WINGET_DEFINE_RESOURCE_STRINGID(InstallScopeDescription); WINGET_DEFINE_RESOURCE_STRINGID(InteractiveArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(InvalidAliasError); @@ -231,8 +237,10 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(PressEnterToContinue); WINGET_DEFINE_RESOURCE_STRINGID(PrivacyStatement); WINGET_DEFINE_RESOURCE_STRINGID(ProductCodeArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(PromptForInstallRoot); WINGET_DEFINE_RESOURCE_STRINGID(PromptOptionNo); WINGET_DEFINE_RESOURCE_STRINGID(PromptOptionYes); + WINGET_DEFINE_RESOURCE_STRINGID(PromptToProceed); WINGET_DEFINE_RESOURCE_STRINGID(PurgeArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(PurgeInstallDirectory); WINGET_DEFINE_RESOURCE_STRINGID(QueryArgumentDescription); @@ -240,6 +248,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(RelatedLink); WINGET_DEFINE_RESOURCE_STRINGID(RenameArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(ReparsePointsNotSupportedError); + WINGET_DEFINE_RESOURCE_STRINGID(ReportIdentityForAgreements); WINGET_DEFINE_RESOURCE_STRINGID(ReportIdentityFound); WINGET_DEFINE_RESOURCE_STRINGID(RequiredArgError); WINGET_DEFINE_RESOURCE_STRINGID(ReservedFilenameError); diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp index 4c91903834..b24920b684 100644 --- a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp @@ -12,7 +12,8 @@ #include "ArchiveFlow.h" #include "PortableFlow.h" #include "WorkflowBase.h" -#include "Workflows/DependenciesFlow.h" +#include "DependenciesFlow.h" +#include "PromptFlow.h" #include #include #include @@ -28,6 +29,7 @@ using namespace AppInstaller::Manifest; using namespace AppInstaller::Repository; using namespace AppInstaller::Settings; using namespace AppInstaller::Utility; +using namespace AppInstaller::Utility::literals; namespace AppInstaller::CLI::Workflow { @@ -245,91 +247,6 @@ namespace AppInstaller::CLI::Workflow } } - void ShowPackageAgreements::operator()(Execution::Context& context) const - { - const auto& manifest = context.Get(); - auto agreements = manifest.CurrentLocalization.Get(); - - if (agreements.empty()) - { - // Nothing to do - return; - } - - context << Workflow::ShowPackageInfo; - context.Reporter.Info() << std::endl; - - if (m_ensureAcceptance) - { - context << Workflow::EnsurePackageAgreementsAcceptance(/* showPrompt */ true); - } - } - - void EnsurePackageAgreementsAcceptance::operator()(Execution::Context& context) const - { - if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::AgreementsAcceptedByCaller)) - { - AICLI_LOG(CLI, Info, << "Skipping package agreements acceptance check because AgreementsAcceptedByCaller flag is set."); - return; - } - - if (context.Args.Contains(Execution::Args::Type::AcceptPackageAgreements)) - { - AICLI_LOG(CLI, Info, << "Package agreements accepted by CLI flag"); - return; - } - - if (m_showPrompt) - { - bool accepted = context.Reporter.PromptForBoolResponse(Resource::String::PackageAgreementsPrompt); - if (accepted) - { - AICLI_LOG(CLI, Info, << "Package agreements accepted in prompt"); - return; - } - else - { - AICLI_LOG(CLI, Info, << "Package agreements not accepted in prompt"); - } - } - - AICLI_LOG(CLI, Error, << "Package agreements were not agreed to."); - context.Reporter.Error() << Resource::String::PackageAgreementsNotAgreedTo << std::endl; - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_PACKAGE_AGREEMENTS_NOT_ACCEPTED); - } - - void EnsurePackageAgreementsAcceptanceForMultipleInstallers(Execution::Context& context) - { - bool hasPackageAgreements = false; - for (auto& packageContext : context.Get()) - { - // Show agreements for each package that has one - auto agreements = packageContext->Get().CurrentLocalization.Get(); - if (agreements.empty()) - { - continue; - } - Execution::Context& showContext = *packageContext; - auto previousThreadGlobals = showContext.SetForCurrentThread(); - - showContext << - Workflow::ReportManifestIdentityWithVersion << - Workflow::ShowPackageAgreements(/* ensureAcceptance */ false); - if (showContext.IsTerminated()) - { - AICLI_TERMINATE_CONTEXT(showContext.GetTerminationHR()); - } - - hasPackageAgreements |= true; - } - - // If any package has agreements, ensure they are accepted - if (hasPackageAgreements) - { - context << Workflow::EnsurePackageAgreementsAcceptance(/* showPrompt */ false); - } - } - void ExecuteInstallerForType::operator()(Execution::Context& context) const { bool isUpdate = WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::InstallerExecutionUseUpdate); @@ -513,7 +430,7 @@ namespace AppInstaller::CLI::Workflow void ReportIdentityAndInstallationDisclaimer(Execution::Context& context) { context << - Workflow::ReportManifestIdentityWithVersion << + Workflow::ReportManifestIdentityWithVersion() << Workflow::ShowInstallationDisclaimer; } @@ -535,7 +452,7 @@ namespace AppInstaller::CLI::Workflow { context << Workflow::ReportIdentityAndInstallationDisclaimer << - Workflow::ShowPackageAgreements(/* ensureAcceptance */ true) << + Workflow::ShowPromptsForSinglePackage(/* ensureAcceptance */ true) << Workflow::GetDependenciesFromInstaller << Workflow::ReportDependencies(Resource::String::InstallAndUpgradeCommandsReportDependencies) << Workflow::ManagePackageDependencies(Resource::String::InstallAndUpgradeCommandsReportDependencies) << @@ -561,11 +478,8 @@ namespace AppInstaller::CLI::Workflow void InstallMultiplePackages::operator()(Execution::Context& context) const { - if (m_ensurePackageAgreements) - { - // Show all license agreements before installing anything - context << Workflow::EnsurePackageAgreementsAcceptanceForMultipleInstallers; - } + // Show all prompts needed for every package before installing anything + context << Workflow::ShowPromptsForMultiplePackages(m_ensurePackageAgreements); if (context.IsTerminated()) { @@ -592,7 +506,7 @@ namespace AppInstaller::CLI::Workflow for (auto& packageContext : context.Get()) { packagesProgress++; - context.Reporter.Info() << "(" << packagesProgress << "/" << packagesCount << ") "; + context.Reporter.Info() << '(' << packagesProgress << '/' << packagesCount << ") "_liv; // We want to do best effort to install all packages regardless of previous failures Execution::Context& installContext = *packageContext; diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.h b/src/AppInstallerCLICore/Workflows/InstallFlow.h index 62136565de..7e62becb94 100644 --- a/src/AppInstallerCLICore/Workflows/InstallFlow.h +++ b/src/AppInstallerCLICore/Workflows/InstallFlow.h @@ -35,44 +35,7 @@ namespace AppInstaller::CLI::Workflow // Outputs: None void CheckForUnsupportedArgs(Execution::Context& context); - // Shows the license agreements if the application has them. - // Required Args: None - // Inputs: Manifest - // Outputs: None - struct ShowPackageAgreements : public WorkflowTask - { - ShowPackageAgreements(bool ensureAcceptance) : WorkflowTask("ShowPackageAgreements"), m_ensureAcceptance(ensureAcceptance) {} - - void operator()(Execution::Context& context) const override; - - private: - // Whether we need to ensure that the agreements are accepted, or only show them. - bool m_ensureAcceptance; - }; - - // Ensure the user accepted the license agreements. - // Required Args: None - // Inputs: None - // Outputs: None - struct EnsurePackageAgreementsAcceptance : public WorkflowTask - { - EnsurePackageAgreementsAcceptance(bool showPrompt) : WorkflowTask("EnsurePackageAgreementsAcceptance"), m_showPrompt(showPrompt) {} - - void operator()(Execution::Context& context) const override; - - private: - // Whether to show an interactive prompt - bool m_showPrompt; - }; - - // Ensure that the user accepted all the license agreements when there are - // multiple installers. - // Required Args: None - // Inputs: PackagesToInstall - // Outputs: None - void EnsurePackageAgreementsAcceptanceForMultipleInstallers(Execution::Context& context); - - // Starts execution of the installer. + // Composite flow that chooses what to do based on the installer type. // Required Args: None // Inputs: Installer, InstallerPath // Outputs: None diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp new file mode 100644 index 0000000000..1df6f3ea17 --- /dev/null +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -0,0 +1,461 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "PromptFlow.h" +#include "ShowFlow.h" +#include + +using namespace AppInstaller::Settings; +using namespace AppInstaller::Utility::literals; + +namespace AppInstaller::CLI::Workflow +{ + namespace + { + bool IsInteractivityAllowed(Execution::Context& context) + { + // Interactivity can be disabled for several reasons: + // * We are running in a non-interactive context (e.g., COM call) + // * It is disabled in the settings + // * It was disabled from the command line + + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::DisableInteractivity)) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); + return false; + } + + if (context.Args.Contains(Execution::Args::Type::DisableInteractivity)) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled by command line argument."); + return false; + } + + if (Settings::User().Get()) + { + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); + return false; + } + + return true; + } + + bool HandleSourceAgreementsForOneSource(Execution::Context& context, const Repository::Source& source) + { + auto details = source.GetDetails(); + AICLI_LOG(CLI, Verbose, << "Checking Source agreements for source: " << details.Name); + + if (source.CheckSourceAgreements()) + { + AICLI_LOG(CLI, Verbose, << "Source agreements satisfied. Source: " << details.Name); + return true; + } + + // Show source agreements + std::string agreementsTitleMessage = Resource::LocString{ Resource::String::SourceAgreementsTitle }; + context.Reporter.Info() << Execution::SourceInfoEmphasis << + Utility::LocIndString{ Utility::FindAndReplaceMessageToken(agreementsTitleMessage, details.Name) } << std::endl; + + const auto& agreements = source.GetInformation().SourceAgreements; + + for (const auto& agreement : agreements) + { + if (!agreement.Label.empty()) + { + context.Reporter.Info() << Execution::SourceInfoEmphasis << Utility::LocIndString{ agreement.Label } << ": "_liv; + } + + if (!agreement.Text.empty()) + { + context.Reporter.Info() << Utility::LocIndString{ agreement.Text } << std::endl; + } + + if (!agreement.Url.empty()) + { + context.Reporter.Info() << Utility::LocIndString{ agreement.Url } << std::endl; + } + } + + // Show message for each individual implicit agreement field + auto fields = source.GetAgreementFieldsFromSourceInformation(); + if (WI_IsFlagSet(fields, Repository::ImplicitAgreementFieldEnum::Market)) + { + context.Reporter.Info() << Resource::String::SourceAgreementsMarketMessage << std::endl; + } + + context.Reporter.Info() << std::endl; + + bool accepted = context.Args.Contains(Execution::Args::Type::AcceptSourceAgreements); + + if (!accepted && IsInteractivityAllowed(context)) + { + accepted = context.Reporter.PromptForBoolResponse(Resource::String::SourceAgreementsPrompt); + } + + if (accepted) + { + AICLI_LOG(CLI, Verbose, << "Source agreements accepted. Source: " << details.Name); + source.SaveAcceptedSourceAgreements(); + } + else + { + AICLI_LOG(CLI, Verbose, << "Source agreements not accepted. Source: " << details.Name); + } + + return accepted; + } + + // An interface for defining prompts to the user regarding a package. + // Note that each prompt may behave differently when running non-interactively + // (e.g. failing if it is needed vs. continuing silently), and they may + // do some work while checking if the prompt is needed even if no prompt is shown, + // so they need to always run. + struct PackagePrompt + { + virtual ~PackagePrompt() = default; + + // Determines whether a package needs this prompt. + // Inputs: Manifest, Installer + // Outputs: None + virtual bool PackageNeedsPrompt(Execution::Context& context) = 0; + + // Prompts for the information needed for a single package. + // Inputs: Manifest, Installer + // Outputs: None + virtual void PromptForSinglePackage(Execution::Context& context) = 0; + + // Prompts for the information needed for multiple packages. + // Inputs: Manifest, Installer (for each sub context) + // Outputs: None + virtual void PromptForMultiplePackages(Execution::Context& context, std::vector& packagesToPrompt) = 0; + }; + + // Prompt for accepting package agreements. + struct PackageAgreementsPrompt : public PackagePrompt + { + PackageAgreementsPrompt(bool ensureAgreementsAcceptance) : m_ensureAgreementsAcceptance(ensureAgreementsAcceptance) {} + + bool PackageNeedsPrompt(Execution::Context& context) override + { + const auto& agreements = context.Get().CurrentLocalization.Get(); + return !agreements.empty(); + } + + void PromptForSinglePackage(Execution::Context& context) override + { + ShowPackageAgreements(context); + EnsurePackageAgreementsAcceptance(context, /* showPrompt */ true); + } + + void PromptForMultiplePackages(Execution::Context& context, std::vector& packagesToPrompt) override + { + for (auto packageContext : packagesToPrompt) + { + // Show agreements for each package + Execution::Context& showContext = *packageContext; + auto previousThreadGlobals = showContext.SetForCurrentThread(); + + ShowPackageAgreements(showContext); + if (showContext.IsTerminated()) + { + AICLI_TERMINATE_CONTEXT(showContext.GetTerminationHR()); + } + } + + EnsurePackageAgreementsAcceptance(context, /* showPrompt */ false); + } + + private: + void ShowPackageAgreements(Execution::Context& context) + { + const auto& manifest = context.Get(); + auto agreements = manifest.CurrentLocalization.Get(); + + if (agreements.empty()) + { + // Nothing to do + return; + } + + context << Workflow::ReportManifestIdentityWithVersion(Resource::String::ReportIdentityForAgreements) << Workflow::ShowPackageInfo; + context.Reporter.EmptyLine(); + } + + void EnsurePackageAgreementsAcceptance(Execution::Context& context, bool showPrompt) const + { + if (!m_ensureAgreementsAcceptance) + { + return; + } + + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::AgreementsAcceptedByCaller)) + { + AICLI_LOG(CLI, Info, << "Skipping package agreements acceptance check because AgreementsAcceptedByCaller flag is set."); + return; + } + + if (context.Args.Contains(Execution::Args::Type::AcceptPackageAgreements)) + { + AICLI_LOG(CLI, Info, << "Package agreements accepted by CLI flag"); + return; + } + + if (showPrompt) + { + AICLI_LOG(CLI, Verbose, << "Prompting to accept package agreements"); + if (IsInteractivityAllowed(context)) + { + bool accepted = context.Reporter.PromptForBoolResponse(Resource::String::PackageAgreementsPrompt); + if (accepted) + { + AICLI_LOG(CLI, Info, << "Package agreements accepted in prompt"); + return; + } + else + { + AICLI_LOG(CLI, Info, << "Package agreements not accepted in prompt"); + } + } + } + + AICLI_LOG(CLI, Error, << "Package agreements were not agreed to."); + context.Reporter.Error() << Resource::String::PackageAgreementsNotAgreedTo << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_PACKAGE_AGREEMENTS_NOT_ACCEPTED); + } + + bool m_ensureAgreementsAcceptance; + }; + + // Prompt for getting the install root when a package requires it and it is not + // specified by the settings. + struct InstallRootPrompt : public PackagePrompt + { + InstallRootPrompt() : m_installLocation(User().Get()) {} + + bool PackageNeedsPrompt(Execution::Context& context) override + { + if (context.Get()->InstallLocationRequired && + !context.Args.Contains(Execution::Args::Type::InstallLocation)) + { + AICLI_LOG(CLI, Info, << "Package [" << context.Get().Id << "] requires an install location."); + + // An install location is required but one wasn't provided. + // Check if there is a default one from settings. + if (m_installLocation.empty()) + { + // We need to prompt + return true; + } + else + { + // Use the default + SetInstallLocation(context); + } + } + + return false; + } + + void PromptForSinglePackage(Execution::Context& context) override + { + context.Reporter.Info() << Resource::String::InstallerRequiresInstallLocation << std::endl; + PromptForInstallRoot(context); + + // When prompting for a single package, we use the provided location directly. + // This is different from when we prompt for multiple packages or use the root in the settings. + context.Args.AddArg(Execution::Args::Type::InstallLocation, m_installLocation.string()); + } + + void PromptForMultiplePackages(Execution::Context& context, std::vector& packagesToPrompt) override + { + // Report packages that will be affected. + context.Reporter.Info() << Resource::String::InstallersRequireInstallLocation << std::endl; + for (auto packageContext : packagesToPrompt) + { + *packageContext << ReportManifestIdentityWithVersion(" - "_liv, Execution::Reporter::Level::Warning); + if (packageContext->IsTerminated()) + { + AICLI_TERMINATE_CONTEXT(packageContext->GetTerminationHR()); + } + } + + PromptForInstallRoot(context); + + // Set the install location for each package. + for (auto packageContext : packagesToPrompt) + { + SetInstallLocation(*packageContext); + } + } + + private: + void PromptForInstallRoot(Execution::Context& context) + { + if (!IsInteractivityAllowed(context)) + { + AICLI_LOG(CLI, Error, << "Install location is required but was not provided."); + context.Reporter.Error() << Resource::String::InstallLocationNotProvided << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_INSTALL_LOCATION_REQUIRED); + } + + AICLI_LOG(CLI, Info, << "Prompting for install root."); + m_installLocation = context.Reporter.PromptForPath(Resource::String::PromptForInstallRoot); + AICLI_LOG(CLI, Info, << "Proceeding with installation using install root: " << m_installLocation); + } + + // Sets the install location for an execution context. + // The install location is obtained by appending the package ID to the install root. + // This function assumes that m_installLocation is set, either from settings or from the prompt, + // and that the context does not already have an install location. + void SetInstallLocation(Execution::Context& context) + { + auto packageId = context.Get().Id; + auto installLocation = m_installLocation; + installLocation += "\\" + packageId; + AICLI_LOG(CLI, Info, << "Setting install location for package [" << packageId << "] to: " << installLocation); + context.Args.AddArg(Execution::Args::Type::InstallLocation, installLocation.string()); + } + + std::filesystem::path m_installLocation; + }; + + // Prompt asking whether to continue when an installer will abort the terminal. + struct InstallerAbortsTerminalPrompt : public PackagePrompt + { + bool PackageNeedsPrompt(Execution::Context& context) override + { + return context.Get()->InstallerAbortsTerminal; + } + + void PromptForSinglePackage(Execution::Context& context) override + { + AICLI_LOG(CLI, Info, << "This installer may abort the terminal"); + context.Reporter.Warn() << Resource::String::InstallerAbortsTerminal << std::endl; + PromptToProceed(context); + } + + void PromptForMultiplePackages(Execution::Context& context, std::vector& packagesToPrompt) override + { + AICLI_LOG(CLI, Info, << "One or more installers may abort the terminal"); + context.Reporter.Warn() << Resource::String::InstallersAbortTerminal << std::endl; + for (auto packageContext : packagesToPrompt) + { + *packageContext << ReportManifestIdentityWithVersion(" - "_liv, Execution::Reporter::Level::Warning); + if (packageContext->IsTerminated()) + { + AICLI_TERMINATE_CONTEXT(packageContext->GetTerminationHR()); + } + } + + PromptToProceed(context); + } + + private: + void PromptToProceed(Execution::Context& context) + { + AICLI_LOG(CLI, Info, << "Prompting before proceeding with installer that aborts terminal."); + if (!IsInteractivityAllowed(context)) + { + return; + } + + bool accepted = context.Reporter.PromptForBoolResponse(Resource::String::PromptToProceed, Execution::Reporter::Level::Warning); + if (accepted) + { + AICLI_LOG(CLI, Info, << "Proceeding with installation"); + } + else + { + AICLI_LOG(CLI, Error, << "Aborting installation"); + context.Reporter.Error() << Resource::String::Cancelled << std::endl; + AICLI_TERMINATE_CONTEXT(E_ABORT); + } + } + }; + + // Gets all the prompts that may be displayed, in order of appearance + std::vector> GetPackagePrompts(bool ensureAgreementsAcceptance = true) + { + std::vector> result; + result.push_back(std::make_unique(ensureAgreementsAcceptance)); + result.push_back(std::make_unique()); + result.push_back(std::make_unique()); + return result; + } + } + + void HandleSourceAgreements::operator()(Execution::Context& context) const + { + if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::AgreementsAcceptedByCaller)) + { + AICLI_LOG(CLI, Info, << "Skipping source agreements acceptance check because AgreementsAcceptedByCaller flag is set."); + return; + } + + bool allAccepted = true; + + if (m_source.IsComposite()) + { + for (auto const& source : m_source.GetAvailableSources()) + { + if (!HandleSourceAgreementsForOneSource(context, source)) + { + allAccepted = false; + } + } + } + else + { + allAccepted = HandleSourceAgreementsForOneSource(context, m_source); + } + + if (!allAccepted) + { + context.Reporter.Error() << Resource::String::SourceAgreementsNotAgreedTo << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_SOURCE_AGREEMENTS_NOT_ACCEPTED); + } + } + + void ShowPromptsForSinglePackage::operator()(Execution::Context& context) const + { + for (auto& prompt : GetPackagePrompts()) + { + // Show the prompt if needed + if (prompt->PackageNeedsPrompt(context)) + { + prompt->PromptForSinglePackage(context); + } + + if (context.IsTerminated()) + { + return; + } + } + } + + void ShowPromptsForMultiplePackages::operator()(Execution::Context& context) const + { + for (auto& prompt : GetPackagePrompts(m_ensureAgreementsAcceptance)) + { + // Find which packages need this prompt + std::vector packagesToPrompt; + for (auto& packageContext : context.Get()) + { + if (prompt->PackageNeedsPrompt(*packageContext)) + { + packagesToPrompt.push_back(packageContext.get()); + } + } + + // Prompt only if needed + if (!packagesToPrompt.empty()) + { + prompt->PromptForMultiplePackages(context, packagesToPrompt); + if (context.IsTerminated()) + { + return; + } + } + } + } +} diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h new file mode 100644 index 0000000000..0c79df84b6 --- /dev/null +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "ExecutionContext.h" + +namespace AppInstaller::CLI::Workflow +{ + // Handles all opened source(s) agreements if needed. + // Required Args: The source to be checked for agreements + // Inputs: None + // Outputs: None + struct HandleSourceAgreements : public WorkflowTask + { + HandleSourceAgreements(Repository::Source source) : WorkflowTask("HandleSourceAgreements"), m_source(std::move(source)) {} + + void operator()(Execution::Context& context) const override; + + private: + Repository::Source m_source; + }; + + // Shows all the prompts required for a single package, e.g. for package agreements + // Required Args: None + // Inputs: Manifest, Installer + // Outputs: None + struct ShowPromptsForSinglePackage : public WorkflowTask + { + ShowPromptsForSinglePackage(bool ensureAgreementsAcceptance) : + WorkflowTask("ShowPromptsForSinglePackage"), m_ensureAgreementsAcceptance(ensureAgreementsAcceptance) {} + + void operator()(Execution::Context& context) const override; + + private: + bool m_ensureAgreementsAcceptance; + }; + + // Shows all the prompts required for multiple package, e.g. for package agreements + // Required Args: None + // Inputs: PackagesToInstall + // Outputs: None + struct ShowPromptsForMultiplePackages : public WorkflowTask + { + ShowPromptsForMultiplePackages(bool ensureAgreementsAcceptance) : + WorkflowTask("ShowPromptsForMultiplePackages"), m_ensureAgreementsAcceptance(ensureAgreementsAcceptance) {} + + void operator()(Execution::Context& context) const override; + + private: + bool m_ensureAgreementsAcceptance; + }; +} \ No newline at end of file diff --git a/src/AppInstallerCLICore/Workflows/SourceFlow.cpp b/src/AppInstallerCLICore/Workflows/SourceFlow.cpp index 9dbb3c2e91..d6c45556df 100644 --- a/src/AppInstallerCLICore/Workflows/SourceFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/SourceFlow.cpp @@ -3,6 +3,7 @@ #include "pch.h" #include "Resources.h" #include "SourceFlow.h" +#include "PromptFlow.h" #include "TableOutput.h" #include "WorkflowBase.h" diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index aaf5a52f9e..d18c690894 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -4,6 +4,7 @@ #include "WorkflowBase.h" #include "ExecutionContext.h" #include "ManifestComparator.h" +#include "PromptFlow.h" #include "TableOutput.h" #include @@ -31,14 +32,29 @@ namespace AppInstaller::CLI::Workflow } } - void ReportIdentity(Execution::Context& context, std::string_view name, std::string_view id) + void ReportIdentity( + Execution::Context& context, + Utility::LocIndView prefix, + std::optional label, + std::string_view name, + std::string_view id, + std::string_view version = {}, + Execution::Reporter::Level level = Execution::Reporter::Level::Info) { - context.Reporter.Info() << Resource::String::ReportIdentityFound << ' ' << Execution::NameEmphasis << name << " [" << Execution::IdEmphasis << id << ']' << std::endl; - } + auto out = context.Reporter.GetOutputStream(level); + out << prefix; + if (label) + { + out << *label << ' '; + } + out << Execution::NameEmphasis << name << " ["_liv << Execution::IdEmphasis << id << ']'; - void ReportIdentity(Execution::Context& context, std::string_view name, std::string_view id, std::string_view version) - { - context.Reporter.Info() << Resource::String::ReportIdentityFound << ' ' << Execution::NameEmphasis << name << " [" << Execution::IdEmphasis << id << "] " << Resource::String::ShowVersion << ' ' << version << std::endl; + if (!version.empty()) + { + out << ' ' << Resource::String::ShowVersion << ' ' << version; + } + + out << std::endl; } Repository::Source OpenNamedSource(Execution::Context& context, std::string_view sourceName) @@ -154,71 +170,6 @@ namespace AppInstaller::CLI::Workflow } } - bool HandleSourceAgreementsForOneSource(Execution::Context& context, const Source& source) - { - auto details = source.GetDetails(); - AICLI_LOG(CLI, Verbose, << "Checking Source agreements for source: " << details.Name); - - if (source.CheckSourceAgreements()) - { - AICLI_LOG(CLI, Verbose, << "Source agreements satisfied. Source: " << details.Name); - return true; - } - - // Show source agreements - std::string agreementsTitleMessage = Resource::LocString{ Resource::String::SourceAgreementsTitle }; - context.Reporter.Info() << Execution::SourceInfoEmphasis << - Utility::LocIndString{ Utility::FindAndReplaceMessageToken(agreementsTitleMessage, details.Name) } << std::endl; - - const auto& agreements = source.GetInformation().SourceAgreements; - - for (const auto& agreement : agreements) - { - if (!agreement.Label.empty()) - { - context.Reporter.Info() << Execution::SourceInfoEmphasis << Utility::LocIndString{ agreement.Label } << ": "_liv; - } - - if (!agreement.Text.empty()) - { - context.Reporter.Info() << Utility::LocIndString{ agreement.Text } << std::endl; - } - - if (!agreement.Url.empty()) - { - context.Reporter.Info() << Utility::LocIndString{ agreement.Url } << std::endl; - } - } - - // Show message for each individual implicit agreement field - auto fields = source.GetAgreementFieldsFromSourceInformation(); - if (WI_IsFlagSet(fields, ImplicitAgreementFieldEnum::Market)) - { - context.Reporter.Info() << Resource::String::SourceAgreementsMarketMessage << std::endl; - } - - context.Reporter.Info() << std::endl; - - bool accepted = context.Args.Contains(Execution::Args::Type::AcceptSourceAgreements); - - if (!accepted) - { - accepted = context.Reporter.PromptForBoolResponse(Resource::String::SourceAgreementsPrompt); - } - - if (accepted) - { - AICLI_LOG(CLI, Verbose, << "Source agreements accepted. Source: " << details.Name); - source.SaveAcceptedSourceAgreements(); - } - else - { - AICLI_LOG(CLI, Verbose, << "Source agreements rejected. Source: " << details.Name); - } - - return accepted; - } - // Data shown on a line of a table displaying installed packages struct InstalledPackagesTableLine { @@ -996,19 +947,19 @@ namespace AppInstaller::CLI::Workflow void ReportPackageIdentity(Execution::Context& context) { auto package = context.Get(); - ReportIdentity(context, package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id)); + ReportIdentity(context, {}, Resource::String::ReportIdentityFound, package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id)); } void ReportManifestIdentity(Execution::Context& context) { const auto& manifest = context.Get(); - ReportIdentity(context, manifest.CurrentLocalization.Get(), manifest.Id); + ReportIdentity(context, {}, Resource::String::ReportIdentityFound, manifest.CurrentLocalization.Get(), manifest.Id); } - void ReportManifestIdentityWithVersion(Execution::Context& context) + void ReportManifestIdentityWithVersion::operator()(Execution::Context& context) const { const auto& manifest = context.Get(); - ReportIdentity(context, manifest.CurrentLocalization.Get(), manifest.Id, manifest.Version); + ReportIdentity(context, m_prefix, m_label, manifest.CurrentLocalization.Get(), manifest.Id, manifest.Version, m_level); } void GetManifest(Execution::Context& context) @@ -1169,38 +1120,6 @@ namespace AppInstaller::CLI::Workflow { context.SetExecutionStage(m_stage); } - - void HandleSourceAgreements::operator()(Execution::Context& context) const - { - if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::AgreementsAcceptedByCaller)) - { - AICLI_LOG(CLI, Info, << "Skipping source agreements acceptance check because AgreementsAcceptedByCaller flag is set."); - return; - } - - bool allAccepted = true; - - if (m_source.IsComposite()) - { - for (auto const& source : m_source.GetAvailableSources()) - { - if (!HandleSourceAgreementsForOneSource(context, source)) - { - allAccepted = false; - } - } - } - else - { - allAccepted = HandleSourceAgreementsForOneSource(context, m_source); - } - - if (!allAccepted) - { - context.Reporter.Error() << Resource::String::SourceAgreementsNotAgreedTo << std::endl; - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_SOURCE_AGREEMENTS_NOT_ACCEPTED); - } - } } AppInstaller::CLI::Execution::Context& operator<<(AppInstaller::CLI::Execution::Context& context, AppInstaller::CLI::Workflow::WorkflowTask::Func f) diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index ab526554db..03f81d0d47 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -2,6 +2,7 @@ // Licensed under the MIT License. #pragma once #include "ExecutionArgs.h" +#include "ExecutionReporter.h" #include #include @@ -300,7 +301,20 @@ namespace AppInstaller::CLI::Workflow // Required Args: None // Inputs: Manifest // Outputs: None - void ReportManifestIdentityWithVersion(Execution::Context& context); + struct ReportManifestIdentityWithVersion : public WorkflowTask + { + ReportManifestIdentityWithVersion(Utility::LocIndView prefix, Execution::Reporter::Level level = Execution::Reporter::Level::Info) : + WorkflowTask("ReportManifestIdentityWithVersion"), m_prefix(prefix), m_level(level) {} + ReportManifestIdentityWithVersion(Resource::StringId label = Resource::String::ReportIdentityFound, Execution::Reporter::Level level = Execution::Reporter::Level::Info) : + WorkflowTask("ReportManifestIdentityWithVersion"), m_label(label), m_level(level) {} + + void operator()(Execution::Context& context) const override; + + private: + Utility::LocIndView m_prefix; + std::optional m_label; + Execution::Reporter::Level m_level; + }; // Composite flow that produces a manifest; either from one given on the command line or by searching. // Required Args: None @@ -359,20 +373,6 @@ namespace AppInstaller::CLI::Workflow private: ExecutionStage m_stage; }; - - // Handles all opened source(s) agreements if needed. - // Required Args: The source to be checked for agreements - // Inputs: None - // Outputs: None - struct HandleSourceAgreements : public WorkflowTask - { - HandleSourceAgreements(Repository::Source source) : WorkflowTask("HandleSourceAgreements"), m_source(std::move(source)) {} - - void operator()(Execution::Context& context) const override; - - private: - Repository::Source m_source; - }; } // Passes the context to the function if it has not been terminated; returns the context. diff --git a/src/AppInstallerCLIE2ETests/Constants.cs b/src/AppInstallerCLIE2ETests/Constants.cs index c26fe05e7d..b2d034d42f 100644 --- a/src/AppInstallerCLIE2ETests/Constants.cs +++ b/src/AppInstallerCLIE2ETests/Constants.cs @@ -197,6 +197,7 @@ public class ErrorCode public const int ERROR_EXTRACT_ARCHIVE_FAILED = unchecked((int)0x8A15005C); public const int ERROR_NESTEDINSTALLER_INVALID_PATH = unchecked((int)0x8A15005D); public const int ERROR_PINNED_CERTIFICATE_MISMATCH = unchecked((int)0x8A15005E); + public const int ERROR_INSTALL_LOCATION_REQUIRED = unchecked((int)0x8A15005F); public const int ERROR_INSTALL_PACKAGE_IN_USE = unchecked((int)0x8A150101); public const int ERROR_INSTALL_INSTALL_IN_PROGRESS = unchecked((int)0x8A150102); diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index f5705c9943..4058653011 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -1400,4 +1400,33 @@ Please specify one of them using the `--source` option to proceed. The nested installer type is not supported + + This installer is known to restart the terminal or shell + + + This package requires an install location + + + The following installers are known to restart the terminal or shell: + + + The following installers require an install location: + + + Specify the install root: + + + Do you want to proceed? + + + Agreements for + This will be followed by a package name, and then a list of license agreements + + + Install location is required by the package but it was not provided + + + Disable interactive prompts + Description for a command line argument, shown next to it in the help + \ No newline at end of file diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj index ede8a06710..93e345fd0c 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -265,6 +265,12 @@ true + + true + + + true + true diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters index 39c1c9e126..04bd47fbb5 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -507,12 +507,18 @@ TestData + + TestData + TestData TestData + + TestData + TestData diff --git a/src/AppInstallerCLITests/TestData/InstallFlowTest_AbortsTerminal.yaml b/src/AppInstallerCLITests/TestData/InstallFlowTest_AbortsTerminal.yaml new file mode 100644 index 0000000000..4d7980b285 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/InstallFlowTest_AbortsTerminal.yaml @@ -0,0 +1,16 @@ +PackageIdentifier: AppInstallerCliTest.TestInstaller +PackageVersion: 1.0.0.0 +PackageLocale: en-US +PackageName: AppInstaller Test Installer +ShortDescription: AppInstaller Test Installer +Publisher: Microsoft Corporation +Moniker: AICLITestExe +License: Test +InstallerAbortsTerminal: true +Installers: + - Architecture: x86 + InstallerUrl: https://ThisIsNotUsed + InstallerType: exe + InstallerSha256: 65DB2F2AC2686C7F2FD69D4A4C6683B888DC55BFA20A0E32CA9F838B51689A3B +ManifestType: singleton +ManifestVersion: 1.1.0 diff --git a/src/AppInstallerCLITests/TestData/InstallFlowTest_InstallLocationRequired.yaml b/src/AppInstallerCLITests/TestData/InstallFlowTest_InstallLocationRequired.yaml new file mode 100644 index 0000000000..4c0e987253 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/InstallFlowTest_InstallLocationRequired.yaml @@ -0,0 +1,18 @@ +PackageIdentifier: AppInstallerCliTest.TestInstaller +PackageVersion: 1.0.0.0 +PackageLocale: en-US +PackageName: AppInstaller Test Installer +ShortDescription: AppInstaller Test Installer +Publisher: Microsoft Corporation +Moniker: AICLITestExe +License: Test +InstallLocationRequired: true +InstallerSwitches: + InstallLocation: /InstallDir +Installers: + - Architecture: x86 + InstallerUrl: https://ThisIsNotUsed + InstallerType: exe + InstallerSha256: 65DB2F2AC2686C7F2FD69D4A4C6683B888DC55BFA20A0E32CA9F838B51689A3B +ManifestType: singleton +ManifestVersion: 1.1.0 diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp index cefc35414d..9dd0b07047 100644 --- a/src/AppInstallerCLITests/WorkFlow.cpp +++ b/src/AppInstallerCLITests/WorkFlow.cpp @@ -10,19 +10,20 @@ #include #include #include -#include +#include +#include #include +#include #include #include -#include -#include -#include -#include -#include #include +#include +#include +#include #include #include -#include +#include +#include #include #include #include @@ -3345,3 +3346,156 @@ TEST_CASE("AdminSetting_LocalManifestFiles", "[LocalManifests][workflow]") REQUIRE_THROWS(installCommand3.ValidateArguments(args3)); } } + +TEST_CASE("PromptFlow_InteractivityDisabled", "[PromptFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + TestCommon::TestUserSettings testSettings; + + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("InstallFlowTest_LicenseAgreement.yaml").GetPath().u8string()); + + SECTION("Disabled by setting") + { + testSettings.Set(true); + } + SECTION("Disabled by arg") + { + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + } + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify prompt is not shown + REQUIRE(installOutput.str().find(Resource::LocString(Resource::String::PackageAgreementsPrompt).get()) == std::string::npos); + + // Verify installation failed + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_PACKAGE_AGREEMENTS_NOT_ACCEPTED); + REQUIRE_FALSE(std::filesystem::exists(installResultPath.GetPath())); + REQUIRE(installOutput.str().find(Resource::LocString(Resource::String::PackageAgreementsNotAgreedTo).get()) != std::string::npos); +} + +TEST_CASE("PromptFlow_InstallerAbortsTerminal_Proceed", "[PromptFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + + // Accept that the installer may abort the terminal by saying "Yes" at the prompt + std::istringstream installInput{ "y" }; + + std::ostringstream installOutput; + TestContext context{ installOutput, installInput }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("InstallFlowTest_AbortsTerminal.yaml").GetPath().u8string()); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify prompt is shown + REQUIRE(installOutput.str().find(Resource::LocString(Resource::String::InstallerAbortsTerminal).get()) != std::string::npos); + + // Verify Installer is called. + REQUIRE(std::filesystem::exists(installResultPath.GetPath())); +} + +TEST_CASE("PromptFlow_InstallerAbortsTerminal_Cancel", "[PromptFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + + // Cancel the installation by saying "No" at the prompt that the installer may abort the terminal + std::istringstream installInput{ "n" }; + + std::ostringstream installOutput; + TestContext context{ installOutput, installInput }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("InstallFlowTest_AbortsTerminal.yaml").GetPath().u8string()); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify prompt is shown + REQUIRE(installOutput.str().find(Resource::LocString(Resource::String::InstallerAbortsTerminal).get()) != std::string::npos); + + // Verify installation failed + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE_FALSE(std::filesystem::exists(installResultPath.GetPath())); +} + +TEST_CASE("PromptFlow_InstallLocationRequired", "[PromptFlow][workflow]") +{ + TestCommon::TempDirectory installLocation("TempDirectory"); + TestCommon::TestUserSettings testSettings; + + std::istringstream installInput; + std::ostringstream installOutput; + TestContext context{ installOutput, installInput }; + auto previousThreadGlobals = context.SetForCurrentThread(); + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("InstallFlowTest_InstallLocationRequired.yaml").GetPath().u8string()); + + bool shouldShowPrompt = false; + std::filesystem::path installResultPath = installLocation.GetPath() / "TestExeInstalled.txt"; + SECTION("From argument") + { + context.Args.AddArg(Execution::Args::Type::InstallLocation, installLocation.GetPath().string()); + } + SECTION("From settings") + { + testSettings.Set(installLocation.GetPath().string()); + + // When using the default location from settings, the Package ID is appended to the root + auto installLocationWithPackageId = installLocation.GetPath() / "AppInstallerCliTest.TestInstaller"; + std::filesystem::create_directory(installLocationWithPackageId); + installResultPath = installLocationWithPackageId / "TestExeInstalled.txt"; + } + SECTION("From prompt") + { + installInput.str(installLocation.GetPath().string()); + shouldShowPrompt = true; + } + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + bool promptShown = installOutput.str().find(Resource::LocString(Resource::String::InstallerRequiresInstallLocation).get()) != std::string::npos; + REQUIRE(shouldShowPrompt == promptShown); + + // Verify Installer is called with the right parameters + REQUIRE(std::filesystem::exists(installResultPath)); + std::ifstream installResultFile(installResultPath); + REQUIRE(installResultFile.is_open()); + std::string installResultStr; + std::getline(installResultFile, installResultStr); + const auto installDirArgument = "/InstallDir " + installLocation.GetPath().string(); + REQUIRE(installResultStr.find(installDirArgument) != std::string::npos); +} + +TEST_CASE("PromptFlow_InstallLocationRequired_Missing", "[PromptFlow][workflow]") +{ + TestCommon::TempFile installResultPath("TestExeInstalled.txt"); + + std::ostringstream installOutput; + TestContext context{ installOutput, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::Manifest, TestDataFile("InstallFlowTest_InstallLocationRequired.yaml").GetPath().u8string()); + // Disable interactivity so that there is not prompt and we cannot get the required location + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + + InstallCommand install({}); + install.Execute(context); + INFO(installOutput.str()); + + // Verify prompt is shown + REQUIRE(installOutput.str().find(Resource::LocString(Resource::String::InstallerRequiresInstallLocation).get()) != std::string::npos); + + // Verify installation failed + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_INSTALL_LOCATION_REQUIRED); + REQUIRE_FALSE(std::filesystem::exists(installResultPath.GetPath())); +} \ No newline at end of file diff --git a/src/AppInstallerCommonCore/Errors.cpp b/src/AppInstallerCommonCore/Errors.cpp index 642bb5e44a..346d5936a4 100644 --- a/src/AppInstallerCommonCore/Errors.cpp +++ b/src/AppInstallerCommonCore/Errors.cpp @@ -156,6 +156,8 @@ namespace AppInstaller return "Source agreements were not agreed to"; case APPINSTALLER_CLI_ERROR_CUSTOMHEADER_EXCEEDS_MAXLENGTH: return "Header size exceeds the allowable limit of 1024 characters. Please reduce the size and try again."; + case APPINSTALLER_CLI_ERROR_MISSING_RESOURCE_FILE: + return "Missing resource file"; case APPINSTALLER_CLI_ERROR_MSI_INSTALL_FAILED: return "Running MSI install failed"; case APPINSTALLER_CLI_ERROR_INVALID_MSIEXEC_ARGUMENT: @@ -188,6 +190,8 @@ namespace AppInstaller return "Failed to uninstall portable package"; case APPINSTALLER_CLI_ERROR_ARP_VERSION_VALIDATION_FAILED: return "Failed to validate DisplayVersion values against index."; + case APPINSTALLER_CLI_ERROR_INSTALL_LOCATION_REQUIRED: + return "Install location required but not provided"; case APPINSTALLER_CLI_ERROR_INSTALL_PACKAGE_IN_USE: return "Application is currently running.Exit the application then try again."; case APPINSTALLER_CLI_ERROR_INSTALL_INSTALL_IN_PROGRESS: diff --git a/src/AppInstallerCommonCore/Public/AppInstallerErrors.h b/src/AppInstallerCommonCore/Public/AppInstallerErrors.h index 11d36b9550..1a5014f00d 100644 --- a/src/AppInstallerCommonCore/Public/AppInstallerErrors.h +++ b/src/AppInstallerCommonCore/Public/AppInstallerErrors.h @@ -107,6 +107,7 @@ #define APPINSTALLER_CLI_ERROR_EXTRACT_ARCHIVE_FAILED ((HRESULT)0x8A15005C) #define APPINSTALLER_CLI_ERROR_NESTEDINSTALLER_INVALID_PATH ((HRESULT)0x8A15005D) #define APPINSTALLER_CLI_ERROR_PINNED_CERTIFICATE_MISMATCH ((HRESULT)0x8A15005E) +#define APPINSTALLER_CLI_ERROR_INSTALL_LOCATION_REQUIRED ((HRESULT)0x8A15005F) // Install errors. #define APPINSTALLER_CLI_ERROR_INSTALL_PACKAGE_IN_USE ((HRESULT)0x8A150101) diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index ef66182813..7f9130fb05 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -68,29 +68,41 @@ namespace AppInstaller::Settings // Validate will be called by ValidateAll without any more changes. enum class Setting : size_t { + // Visual ProgressBarVisualStyle, + // Source AutoUpdateTimeInMinutes, + // Experimental EFExperimentalCmd, EFExperimentalArg, EFDependencies, + EFDirectMSI, EFZipInstall, + // Telemetry TelemetryDisable, + // Install behavior InstallScopePreference, InstallScopeRequirement, - NetworkDownloader, - NetworkDOProgressTimeoutInSeconds, InstallArchitecturePreference, InstallArchitectureRequirement, InstallLocalePreference, InstallLocaleRequirement, - EFDirectMSI, - EnableSelfInitiatedMinidump, - LoggingLevelPreference, + InstallDefaultRoot, InstallIgnoreWarnings, DisableInstallNotes, PortablePackageUserRoot, PortablePackageMachineRoot, + // Network + NetworkDownloader, + NetworkDOProgressTimeoutInSeconds, + // Logging + LoggingLevelPreference, + // Uninstall behavior UninstallPurgePortablePackage, + // Interactivity + InteractivityDisable, + // Debug + EnableSelfInitiatedMinidump, Max }; @@ -124,30 +136,42 @@ namespace AppInstaller::Settings #define SETTINGMAPPING_SPECIALIZATION(_setting_, _json_, _value_, _default_, _path_) \ SETTINGMAPPING_SPECIALIZATION_POLICY(_setting_, _json_, _value_, _default_, _path_, ValuePolicy::None) + // Visual SETTINGMAPPING_SPECIALIZATION(Setting::ProgressBarVisualStyle, std::string, VisualStyle, VisualStyle::Accent, ".visual.progressBar"sv); + // Source SETTINGMAPPING_SPECIALIZATION_POLICY(Setting::AutoUpdateTimeInMinutes, uint32_t, std::chrono::minutes, 5min, ".source.autoUpdateIntervalInMinutes"sv, ValuePolicy::SourceAutoUpdateIntervalInMinutes); + // Experimental SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalCmd, bool, bool, false, ".experimentalFeatures.experimentalCmd"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalArg, bool, bool, false, ".experimentalFeatures.experimentalArg"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFDependencies, bool, bool, false, ".experimentalFeatures.dependencies"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFZipInstall, bool, bool, false, ".experimentalFeatures.zipInstall"sv); + // Telemetry SETTINGMAPPING_SPECIALIZATION(Setting::TelemetryDisable, bool, bool, false, ".telemetry.disable"sv); + // Install behavior SETTINGMAPPING_SPECIALIZATION(Setting::InstallArchitecturePreference, std::vector, std::vector, {}, ".installBehavior.preferences.architectures"sv); SETTINGMAPPING_SPECIALIZATION(Setting::InstallArchitectureRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.architectures"sv); SETTINGMAPPING_SPECIALIZATION(Setting::InstallScopePreference, std::string, ScopePreference, ScopePreference::User, ".installBehavior.preferences.scope"sv); SETTINGMAPPING_SPECIALIZATION(Setting::InstallScopeRequirement, std::string, ScopePreference, ScopePreference::None, ".installBehavior.requirements.scope"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDownloader, std::string, InstallerDownloader, InstallerDownloader::Default, ".network.downloader"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDOProgressTimeoutInSeconds, uint32_t, std::chrono::seconds, 60s, ".network.doProgressTimeoutInSeconds"sv); SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocalePreference, std::vector, std::vector, {}, ".installBehavior.preferences.locale"sv); SETTINGMAPPING_SPECIALIZATION(Setting::InstallLocaleRequirement, std::vector, std::vector, {}, ".installBehavior.requirements.locale"sv); SETTINGMAPPING_SPECIALIZATION(Setting::InstallIgnoreWarnings, bool, bool, false, ".installBehavior.ignoreWarnings"sv); SETTINGMAPPING_SPECIALIZATION(Setting::DisableInstallNotes, bool, bool, false, ".installBehavior.disableInstallNotes"sv); SETTINGMAPPING_SPECIALIZATION(Setting::PortablePackageUserRoot, std::string, std::filesystem::path, {}, ".installBehavior.portablePackageUserRoot"sv); SETTINGMAPPING_SPECIALIZATION(Setting::PortablePackageMachineRoot, std::string, std::filesystem::path, {}, ".installBehavior.portablePackageMachineRoot"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::InstallDefaultRoot, std::string, std::filesystem::path, {}, ".installBehavior.defaultInstallRoot"sv); + // Uninstall behavior SETTINGMAPPING_SPECIALIZATION(Setting::UninstallPurgePortablePackage, bool, bool, false, ".uninstallBehavior.purgePortablePackage"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv); + // Network + SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDownloader, std::string, InstallerDownloader, InstallerDownloader::Default, ".network.downloader"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::NetworkDOProgressTimeoutInSeconds, uint32_t, std::chrono::seconds, 60s, ".network.doProgressTimeoutInSeconds"sv); + // Debug SETTINGMAPPING_SPECIALIZATION(Setting::EnableSelfInitiatedMinidump, bool, bool, false, ".debugging.enableSelfInitiatedMinidump"sv); + // Logging SETTINGMAPPING_SPECIALIZATION(Setting::LoggingLevelPreference, std::string, Logging::Level, Logging::Level::Info, ".logging.level"sv); - + // Interactivity + SETTINGMAPPING_SPECIALIZATION(Setting::InteractivityDisable, bool, bool, false, ".interactivity.disable"sv); + // Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types. template inline auto Deduce(std::index_sequence) { return std::variant(I)>::value_t...>{}; } diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index b80b19ce3e..56e62e0851 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -189,6 +189,17 @@ namespace AppInstaller::Settings // Use folding to call each setting validate function. (FoldHelper{}, ..., Validate(S)>(root, settings, warnings)); } + + std::optional ValidatePathValue(std::string_view value) + { + std::filesystem::path path = ConvertToUTF16(value); + if (!path.is_absolute()) + { + return {}; + } + + return path; + } } namespace details @@ -235,9 +246,10 @@ namespace AppInstaller::Settings WINGET_VALIDATE_PASS_THROUGH(EFExperimentalCmd) WINGET_VALIDATE_PASS_THROUGH(EFExperimentalArg) WINGET_VALIDATE_PASS_THROUGH(EFDependencies) + WINGET_VALIDATE_PASS_THROUGH(EFDirectMSI) WINGET_VALIDATE_PASS_THROUGH(EFZipInstall) WINGET_VALIDATE_PASS_THROUGH(TelemetryDisable) - WINGET_VALIDATE_PASS_THROUGH(EFDirectMSI) + WINGET_VALIDATE_PASS_THROUGH(InteractivityDisable) WINGET_VALIDATE_PASS_THROUGH(EnableSelfInitiatedMinidump) WINGET_VALIDATE_PASS_THROUGH(InstallIgnoreWarnings) WINGET_VALIDATE_PASS_THROUGH(DisableInstallNotes) @@ -245,18 +257,12 @@ namespace AppInstaller::Settings WINGET_VALIDATE_SIGNATURE(PortablePackageUserRoot) { - std::filesystem::path root = ConvertToUTF16(value); - if (!root.is_absolute()) - { - return {}; - } - - return root; + return ValidatePathValue(value); } WINGET_VALIDATE_SIGNATURE(PortablePackageMachineRoot) { - return SettingMapping::Validate(value); + return ValidatePathValue(value); } WINGET_VALIDATE_SIGNATURE(InstallArchitecturePreference) @@ -318,6 +324,11 @@ namespace AppInstaller::Settings return SettingMapping::Validate(value); } + WINGET_VALIDATE_SIGNATURE(InstallDefaultRoot) + { + return ValidatePathValue(value); + } + WINGET_VALIDATE_SIGNATURE(NetworkDownloader) { static constexpr std::string_view s_downloader_default = "default";