diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 11b4b8d1a7..1f0282e24c 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -610,6 +610,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureError); WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureErrorListMatches); WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureErrorNoMatches); + WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureSingleResultPrompt); WINGET_DEFINE_RESOURCE_STRINGID(SearchFailureWarning); WINGET_DEFINE_RESOURCE_STRINGID(SearchId); WINGET_DEFINE_RESOURCE_STRINGID(SearchMatch); diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 5e298f5c92..82c3b41f38 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -11,35 +11,36 @@ using namespace AppInstaller::Utility::literals; namespace AppInstaller::CLI::Workflow { - namespace + bool IsInteractivityAllowed(Execution::Context& context) { - 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)) { - // 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 + AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled due to non-interactive context."); + return false; + } - 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 (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; + } - if (Settings::User().Get()) - { - AICLI_LOG(CLI, Verbose, << "Skipping prompt. Interactivity is disabled in settings."); - return false; - } + return true; + } - return true; - } + namespace + { bool HandleSourceAgreementsForOneSource(Execution::Context& context, const Repository::Source& source) { diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 7219cb1fff..692e6998df 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -5,6 +5,10 @@ namespace AppInstaller::CLI::Workflow { + // Returns true if interactivity is currently allowed in the given context. + bool IsInteractivityAllowed(Execution::Context& context); + + // Handles all opened source(s) agreements if needed. // Required Args: The source to be checked for agreements // Inputs: None diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 9ee72f47db..5c739a4b4a 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -981,6 +981,38 @@ namespace AppInstaller::CLI::Workflow warn << Resource::String::SearchFailureWarning(Utility::LocIndView{ failure.SourceName }) << std::endl; } } + // When ShowSearchResultsOnPartialFailure is set (e.g. install) and exactly one match + // was found, prompt the user interactively rather than failing outright. + // Falls back to the hard error path if interactivity is disabled. + else if (WI_IsFlagSet(context.GetFlags(), Execution::ContextFlag::ShowSearchResultsOnPartialFailure) && + searchResult.Matches.size() == 1 && + IsInteractivityAllowed(context)) + { + { + auto warn = context.Reporter.Warn(); + for (const auto& failure : searchResult.Failures) + { + warn << Resource::String::SearchFailureWarning(Utility::LocIndView{ failure.SourceName }) << std::endl; + } + } + + if (context.Reporter.PromptForBoolResponse(Resource::String::SearchFailureSingleResultPrompt, Reporter::Level::Warning, false)) + { + return; + } + + // User declined; terminate with the source failure HRESULT without re-showing errors + HRESULT overallHR = S_OK; + for (const auto& failure : searchResult.Failures) + { + HRESULT failureHR = HandleException(nullptr, failure.Exception); + if (overallHR == S_OK) + { + overallHR = failureHR; + } + } + context.SetTerminationHR(overallHR); + } else { HRESULT overallHR = S_OK; diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 7293ac52a6..60f498e76e 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -1332,6 +1332,10 @@ Do you agree to the terms? Please specify one of them using the --source option to proceed. {Locked="--source"} "working sources" as in "sources that are working correctly" + + A package was found among the working sources. Would you like to proceed? + "working sources" as in "sources that are working correctly" + No packages were found among the working sources. "working sources" as in "sources that are working correctly" diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp index ce69a4d12d..39ede15f82 100644 --- a/src/AppInstallerCLITests/WorkFlow.cpp +++ b/src/AppInstallerCLITests/WorkFlow.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -187,3 +188,64 @@ TEST_CASE("Export_Settings", "[Settings][workflow]") REQUIRE(userSettingsFileValue.find("settings.json") != std::string::npos); } } + +TEST_CASE("HandleSearchResultFailures_SingleMatchWithPartialFailure", "[HandleSearchResultFailures][workflow]") +{ + auto makeSearchResult = []() + { + SearchResult result; + result.Matches.push_back(ResultMatch{ + TestCompositePackage::Make(std::vector{ Manifest{} }), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact } + }); + result.Failures.push_back({ "FailedSource", std::make_exception_ptr(std::runtime_error("source error")) }); + return result; + }; + + SECTION("Interactive_Accept") + { + std::istringstream input{ "y\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.SetFlags(ContextFlag::ShowSearchResultsOnPartialFailure); + context.Add(makeSearchResult()); + + context << HandleSearchResultFailures; + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(output.str().find("FailedSource") != std::string::npos); + } + + SECTION("Interactive_Decline") + { + std::istringstream input{ "n\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.SetFlags(ContextFlag::ShowSearchResultsOnPartialFailure); + context.Add(makeSearchResult()); + + context << HandleSearchResultFailures; + + INFO(output.str()); + REQUIRE(context.IsTerminated()); + } + + SECTION("NonInteractive_HardError") + { + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.SetFlags(ContextFlag::ShowSearchResultsOnPartialFailure); + context.Args.AddArg(Args::Type::DisableInteractivity); + context.Add(makeSearchResult()); + + context << HandleSearchResultFailures; + + INFO(output.str()); + REQUIRE(context.IsTerminated()); + REQUIRE(output.str().find("FailedSource") != std::string::npos); + } +}