diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 121b0fca54..5c3827e284 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -450,6 +450,7 @@ VOS vso wapproj wchar +wcout wcsicmp webpage wekyb @@ -465,11 +466,14 @@ winsqlite wix wmain woah +wofstream workflow +wostream wpfn wrl WStr wstring +wstringstream www xamarin xlang diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 537c02caf7..cdba6a4d0d 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -245,6 +245,9 @@ usersettingstest USHORT Utils UWP +validator +valijson +valueiterator vamus VERSI VERSIE diff --git a/doc/Settings.md b/doc/Settings.md index f9ba7858e6..231ce7315c 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -97,3 +97,13 @@ While work is in progress on uninstall, the command is hidden behind a feature t "uninstall": true }, ``` + +### importExport + +While work is in progress for import and export, the command is hidden behind a feature toggle. One can enable it as below: + +``` + "experimentalFeatures": { + "importExport": true + }, +``` diff --git a/schemas/JSON/packages/packages.schema.1.0.json b/schemas/JSON/packages/packages.schema.1.0.json new file mode 100644 index 0000000000..9efff5ea4e --- /dev/null +++ b/schemas/JSON/packages/packages.schema.1.0.json @@ -0,0 +1,97 @@ +{ + "$id": "https://aka.ms/winget-packages.schema.json", + "$schema": "https://json-schema.org/draft/2019-09/schema#", + + "title": "winget Packages List Schema", + "description": "Describes a list of packages for batch installs", + + "type": "object", + "required": [ "WinGetVersion", "Sources" ], + "additionalProperties": true, + + "properties": { + "WinGetVersion": { + "description": "Version of winget that generated this file", + "type": "string", + "pattern": "^[0-9]+\\.[0-9]\\.[0-9]$" + }, + + "CreationDate": { + "description": "Date when this list was generated", + "type": "string", + "format": "date-time" + }, + + "Sources": { + "description": "Sources from which each package comes from", + "type": "array", + + "items": { + "description": "A source and the list of packages to install from it", + "type": "object", + "required": [ "SourceDetails", "Packages" ], + "additionalProperties": true, + + "properties": { + "SourceDetails": { + "description": "Details about this source", + "type": "object", + "required": [ "Name", "Identifier", "Argument", "Type" ], + "additionalProperties": true, + + "properties": { + "Name": { + "description": "Name of the source", + "type": "string" + }, + + "Identifier": { + "description": "Identifier for the source", + "type": "string" + }, + + "Argument": { + "description": "Argument used to install the source", + "type": "string" + }, + + "Type": { + "description": "Type of the source", + "type": "string" + } + } + }, + + "Packages": { + "description": "Packages installed from this source", + "type": "array", + "required": [ "Id" ], + "minItems": 1, + + "items": { + "description": "A package to be installed from this source", + "type": "object", + "additionalProperties": true, + "properties": { + "Id": { + "description": "Package ID", + "type": "string" + }, + + "Version": { + "description": "Package version", + "type": "string" + }, + + "Channel": { + "description": "Package channel", + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/doc/settings.schema.json b/schemas/JSON/settings/settings.schema.0.2.json similarity index 94% rename from doc/settings.schema.json rename to schemas/JSON/settings/settings.schema.0.2.json index 45072807b1..16f43b6a02 100644 --- a/doc/settings.schema.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -62,6 +62,11 @@ "description": "Enable the uninstall command while it is in development", "type": "boolean", "default": false + }, + "importExport": { + "description": "Enable the import and export commands", + "type": "boolean", + "default": false } } } diff --git a/src/AppInstallerCLI.sln b/src/AppInstallerCLI.sln index ee39aea80c..950e04bf30 100644 --- a/src/AppInstallerCLI.sln +++ b/src/AppInstallerCLI.sln @@ -18,6 +18,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Project", "Project", "{8D53 ..\azure-pipelines.yml = ..\azure-pipelines.yml ..\cgmanifest.json = ..\cgmanifest.json ..\README.md = ..\README.md + ..\doc\packages.schema.json = ..\doc\packages.schema.json ..\doc\Settings.md = ..\doc\Settings.md ..\doc\settings.schema.json = ..\doc\settings.schema.json EndProjectSection diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj index 6eff93b8ee..ec96bde19d 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -177,6 +177,8 @@ + + @@ -201,7 +203,9 @@ + + @@ -213,11 +217,14 @@ + + + @@ -241,6 +248,7 @@ + diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters index 324ea67f93..9a17abfdbe 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters @@ -9,10 +9,6 @@ {93995380-89BD-4b04-88EB-625FBE52EBFB} h;hh;hpp;hxx;hm;inl;inc;xsd - - {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} - rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms - {4b0dcf8b-b4a1-47e5-9c28-e8a3440178e6} @@ -141,6 +137,21 @@ Workflows + + Commands + + + Header Files + + + Workflows + + + Commands + + + Header Files + @@ -248,6 +259,18 @@ Workflows + + Commands + + + Source Files + + + Workflows + + + Commands + diff --git a/src/AppInstallerCLICore/Commands/ExportCommand.cpp b/src/AppInstallerCLICore/Commands/ExportCommand.cpp new file mode 100644 index 0000000000..15d0923281 --- /dev/null +++ b/src/AppInstallerCLICore/Commands/ExportCommand.cpp @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "ExportCommand.h" +#include "Workflows/CompletionFlow.h" +#include "Workflows/ImportExportFlow.h" +#include "Workflows/WorkflowBase.h" +#include "Resources.h" + +namespace AppInstaller::CLI +{ + using namespace std::string_view_literals; + + std::vector ExportCommand::GetArguments() const + { + return { + Argument{ "output", 'o', Execution::Args::Type::OutputFile, Resource::String::OutputFileArgumentDescription, ArgumentType::Positional, true }, + Argument{ "source", 's', Execution::Args::Type::Source, Resource::String::ExportSourceArgumentDescription, ArgumentType::Standard }, + Argument{ "include-versions", Argument::NoAlias, Execution::Args::Type::IncludeVersions, Resource::String::ExportIncludeVersionsArgumentDescription, ArgumentType::Flag }, + }; + } + + Resource::LocString ExportCommand::ShortDescription() const + { + return { Resource::String::ExportCommandShortDescription }; + } + + Resource::LocString ExportCommand::LongDescription() const + { + return { Resource::String::ExportCommandLongDescription }; + } + + void ExportCommand::Complete(Execution::Context& context, Execution::Args::Type valueType) const + { + if (valueType == Execution::Args::Type::OutputFile) + { + // Intentionally output nothing to allow pass through to filesystem. + return; + } + + if (valueType == Execution::Args::Type::Source) + { + context << Workflow::CompleteSourceName; + return; + } + } + + std::string ExportCommand::HelpLink() const + { + // TODO: point to correct location + return "https://aka.ms/winget-command-export"; + } + + void ExportCommand::ExecuteInternal(Execution::Context& context) const + { + context << + Workflow::ReportExecutionStage(Workflow::ExecutionStage::Discovery) << + Workflow::OpenSource << + Workflow::OpenCompositeSource(Repository::PredefinedSource::Installed) << + Workflow::SearchSourceForMany << + Workflow::EnsureMatchesFromSearchResult(true) << + Workflow::SelectVersionsToExport << + Workflow::ReportExecutionStage(Workflow::ExecutionStage::Execution) << + Workflow::WriteImportFile; + } +} diff --git a/src/AppInstallerCLICore/Commands/ExportCommand.h b/src/AppInstallerCLICore/Commands/ExportCommand.h new file mode 100644 index 0000000000..0c11977b4a --- /dev/null +++ b/src/AppInstallerCLICore/Commands/ExportCommand.h @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "Command.h" + +namespace AppInstaller::CLI +{ + // Command to get the set of installed packages on the system. + struct ExportCommand final : public Command + { + ExportCommand(std::string_view parent) : Command("export", parent, Settings::ExperimentalFeature::Feature::ExperimentalImportExport) {} + + std::vector GetArguments() const override; + + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + void Complete(Execution::Context& context, Execution::Args::Type valueType) const override; + + std::string HelpLink() const override; + + protected: + void ExecuteInternal(Execution::Context& context) const override; + }; +} diff --git a/src/AppInstallerCLICore/Commands/ImportCommand.cpp b/src/AppInstallerCLICore/Commands/ImportCommand.cpp new file mode 100644 index 0000000000..7d7c9c2a19 --- /dev/null +++ b/src/AppInstallerCLICore/Commands/ImportCommand.cpp @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "ImportCommand.h" +#include "Workflows/CompletionFlow.h" +#include "Workflows/ImportExportFlow.h" +#include "Workflows/InstallFlow.h" +#include "Workflows/WorkflowBase.h" +#include "Resources.h" + +namespace AppInstaller::CLI +{ + using namespace std::string_view_literals; + + std::vector ImportCommand::GetArguments() const + { + return { + Argument{ "import-file", 'i', Execution::Args::Type::ImportFile, Resource::String::ImportFileArgumentDescription, ArgumentType::Positional, true }, + Argument{ "ignore-unavailable", Argument::NoAlias, Execution::Args::Type::IgnoreUnavailable, Resource::String::ImportIgnoreUnavailableArgumentDescription, ArgumentType::Flag }, + Argument{ "ignore-versions", Argument::NoAlias, Execution::Args::Type::IgnoreVersions, Resource::String::ImportIgnorePackageVersionsArgumentDescription, ArgumentType::Flag }, + }; + } + + Resource::LocString ImportCommand::ShortDescription() const + { + return { Resource::String::ImportCommandShortDescription }; + } + + Resource::LocString ImportCommand::LongDescription() const + { + return { Resource::String::ImportCommandLongDescription }; + } + + std::string ImportCommand::HelpLink() const + { + // TODO: point to correct location + return "https://aka.ms/winget-command-import"; + } + + void ImportCommand::ExecuteInternal(Execution::Context& context) const + { + context << + Workflow::ReportExecutionStage(Workflow::ExecutionStage::Discovery) << + Workflow::VerifyFile(Execution::Args::Type::ImportFile) << + Workflow::ReadImportFile << + Workflow::OpenSourcesForImport << + Workflow::OpenPredefinedSource(Repository::PredefinedSource::Installed) << + Workflow::SearchPackagesForImport << + Workflow::ReportExecutionStage(Workflow::ExecutionStage::Execution) << + Workflow::InstallMultiple; + } +} diff --git a/src/AppInstallerCLICore/Commands/ImportCommand.h b/src/AppInstallerCLICore/Commands/ImportCommand.h new file mode 100644 index 0000000000..d2a58db14c --- /dev/null +++ b/src/AppInstallerCLICore/Commands/ImportCommand.h @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "Command.h" + +namespace AppInstaller::CLI +{ + // Command to install a set of packages from a list. + struct ImportCommand final : public Command + { + ImportCommand(std::string_view parent) : Command("import", parent, Settings::ExperimentalFeature::Feature::ExperimentalImportExport) {} + + std::vector GetArguments() const override; + + Resource::LocString ShortDescription() const override; + Resource::LocString LongDescription() const override; + + std::string HelpLink() const override; + + protected: + void ExecuteInternal(Execution::Context& context) const override; + }; +} diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.cpp b/src/AppInstallerCLICore/Commands/InstallCommand.cpp index 157f9676bc..a900ac1ce7 100644 --- a/src/AppInstallerCLICore/Commands/InstallCommand.cpp +++ b/src/AppInstallerCLICore/Commands/InstallCommand.cpp @@ -99,15 +99,6 @@ namespace AppInstaller::CLI context << Workflow::ReportExecutionStage(ExecutionStage::Discovery) << Workflow::GetManifest << - Workflow::EnsureMinOSVersion << - Workflow::SelectInstaller << - Workflow::EnsureApplicableInstaller << - Workflow::ShowInstallationDisclaimer << - Workflow::ReportExecutionStage(ExecutionStage::Download) << - Workflow::DownloadInstaller << - Workflow::ReportExecutionStage(ExecutionStage::Execution) << - Workflow::ExecuteInstaller << - Workflow::ReportExecutionStage(ExecutionStage::PostExecution) << - Workflow::RemoveInstaller; + Workflow::InstallPackageVersion; } } diff --git a/src/AppInstallerCLICore/Commands/RootCommand.cpp b/src/AppInstallerCLICore/Commands/RootCommand.cpp index 83f2441bc2..aa34ba601e 100644 --- a/src/AppInstallerCLICore/Commands/RootCommand.cpp +++ b/src/AppInstallerCLICore/Commands/RootCommand.cpp @@ -16,6 +16,8 @@ #include "FeaturesCommand.h" #include "ExperimentalCommand.h" #include "CompleteCommand.h" +#include "ExportCommand.h" +#include "ImportCommand.h" #include "Resources.h" #include "TableOutput.h" @@ -40,6 +42,8 @@ namespace AppInstaller::CLI std::make_unique(FullName()), std::make_unique(FullName()), std::make_unique(FullName()), + std::make_unique(FullName()), + std::make_unique(FullName()), }); } diff --git a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp index d2fcbd58bd..10c5534863 100644 --- a/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp +++ b/src/AppInstallerCLICore/Commands/UpgradeCommand.cpp @@ -126,8 +126,8 @@ namespace AppInstaller::CLI context << Workflow::ReportExecutionStage(ExecutionStage::Discovery) << - OpenSource << - OpenCompositeSource(Repository::PredefinedSource::Installed); + Workflow::OpenSource << + Workflow::OpenCompositeSource(Repository::PredefinedSource::Installed); if (ShouldListUpgrade(context)) { diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h index 0d0191187f..eb0e67fcea 100644 --- a/src/AppInstallerCLICore/ExecutionArgs.h +++ b/src/AppInstallerCLICore/ExecutionArgs.h @@ -55,6 +55,15 @@ namespace AppInstaller::CLI::Execution CommandLine, Position, + // Export Command + OutputFile, + IncludeVersions, + + // Import Command + ImportFile, + IgnoreUnavailable, + IgnoreVersions, + // Other All, // Used in Update command to update all installed packages to latest Force, // Generic flag to enable a command to skip some check diff --git a/src/AppInstallerCLICore/ExecutionContext.h b/src/AppInstallerCLICore/ExecutionContext.h index 71ed0e141f..2ec8f7a927 100644 --- a/src/AppInstallerCLICore/ExecutionContext.h +++ b/src/AppInstallerCLICore/ExecutionContext.h @@ -8,6 +8,7 @@ #include "ExecutionReporter.h" #include "ExecutionArgs.h" #include "CompletionData.h" +#include "PackageCollection.h" #include #include @@ -19,16 +20,20 @@ // Terminates the Context with some logging to indicate the location. // Also returns from the current function. -#define AICLI_TERMINATE_CONTEXT_ARGS(_context_,_hr_) \ +#define AICLI_TERMINATE_CONTEXT_ARGS(_context_,_hr_,_ret_) \ do { \ HRESULT AICLI_TERMINATE_CONTEXT_ARGS_hr = _hr_; \ _context_.Terminate(AICLI_TERMINATE_CONTEXT_ARGS_hr, __FILE__, __LINE__); \ - return; \ + return _ret_; \ } while(0,0) // Terminates the Context named 'context' with some logging to indicate the location. // Also returns from the current function. -#define AICLI_TERMINATE_CONTEXT(_hr_) AICLI_TERMINATE_CONTEXT_ARGS(context,_hr_) +#define AICLI_TERMINATE_CONTEXT(_hr_) AICLI_TERMINATE_CONTEXT_ARGS(context,_hr_,) + +// Terminates the Context named 'context' with some logging to indicate the location. +// Also returns the specified value from the current function. +#define AICLI_TERMINATE_CONTEXT_RETURN(_hr_,_ret_) AICLI_TERMINATE_CONTEXT_ARGS(context,_hr_,_ret_) namespace AppInstaller::CLI::Workflow { @@ -60,6 +65,13 @@ namespace AppInstaller::CLI::Execution UninstallString, PackageFamilyNames, ProductCodes, + // On export: A collection of packages to be exported to a file + // On import: A collection of packages read from a file + PackageCollection, + // On import: A collection of specific package versions to install + PackagesToInstall, + // On import: Sources for the imported packages + Sources, Max }; @@ -184,6 +196,24 @@ namespace AppInstaller::CLI::Execution using value_t = std::vector; }; + template <> + struct DataMapping + { + using value_t = CLI::PackageCollection; + }; + + template <> + struct DataMapping + { + using value_t = std::vector>; + }; + + template <> + struct DataMapping + { + using value_t = std::vector>; + }; + // Used to deduce the DataVariant type; making a variant that includes std::monostate and all DataMapping types. template inline auto Deduce(std::index_sequence) { return std::variant(I)>::value_t...>{}; } diff --git a/src/AppInstallerCLICore/PackageCollection.cpp b/src/AppInstallerCLICore/PackageCollection.cpp new file mode 100644 index 0000000000..bfcba49b44 --- /dev/null +++ b/src/AppInstallerCLICore/PackageCollection.cpp @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" + +#include "PackageCollection.h" +#include "AppInstallerRuntime.h" + +#include +#include + +namespace AppInstaller::CLI +{ + using namespace AppInstaller::Repository; + + namespace + { + // Strings used in the Packages JSON file. + // Most will be used to access a JSON value, so they need to be std::string + const std::string s_PackagesJson_Schema = "$schema"; + const std::string s_PackagesJson_SchemaUri_v1_0 = "https://aka.ms/winget-packages.schema.1.0.json"; + const std::string s_PackagesJson_WinGetVersion = "WinGetVersion"; + const std::string s_PackagesJson_CreationDate = "CreationDate"; + + const std::string s_PackagesJson_Sources = "Sources"; + const std::string s_PackagesJson_Source_Details = "SourceDetails"; + const std::string s_PackagesJson_Source_Name = "Name"; + const std::string s_PackagesJson_Source_Identifier = "Identifier"; + const std::string s_PackagesJson_Source_Argument = "Argument"; + const std::string s_PackagesJson_Source_Type = "Type"; + + const std::string s_PackagesJson_Packages = "Packages"; + const std::string s_PackagesJson_Package_Id = "Id"; + const std::string s_PackagesJson_Package_Version = "Version"; + const std::string s_PackagesJson_Package_Channel = "Channel"; + + // Gets or creates a property of a JSON object by its name. + Json::Value& GetJsonProperty(Json::Value& node, const std::string& propertyName, Json::ValueType valueType) + { + if (!node.isMember(propertyName)) + { + node[propertyName] = Json::Value{ valueType }; + } + else + { + THROW_HR_IF(E_NOT_VALID_STATE, node[propertyName].type() != valueType); + } + + return node[propertyName]; + } + + // Reads the description of a package from a Package node in the JSON. + PackageCollection::Package ParsePackageNode(const Json::Value& packageNode) + { + std::string id = packageNode[s_PackagesJson_Package_Id].asString(); + std::string version = packageNode.isMember(s_PackagesJson_Package_Version) ? packageNode[s_PackagesJson_Package_Version].asString() : ""; + std::string channel = packageNode.isMember(s_PackagesJson_Package_Channel) ? packageNode[s_PackagesJson_Package_Channel].asString() : ""; + + PackageCollection::Package package{ Utility::LocIndString{ id }, Utility::Version{ version }, Utility::Channel{ channel } }; + + return package; + } + + // Reads the description of a Source and all the packages needed from it, from a Source node in the JSON. + PackageCollection::Source ParseSourceNode(const Json::Value& sourceNode) + { + SourceDetails sourceDetails; + auto& detailsNode = sourceNode[s_PackagesJson_Source_Details]; + sourceDetails.Identifier = Utility::LocIndString{ detailsNode[s_PackagesJson_Source_Identifier].asString() }; + sourceDetails.Name = detailsNode[s_PackagesJson_Source_Name].asString(); + sourceDetails.Arg = detailsNode[s_PackagesJson_Source_Argument].asString(); + sourceDetails.Type = detailsNode[s_PackagesJson_Source_Type].asString(); + + PackageCollection::Source source{ std::move(sourceDetails) }; + for (const auto& packageNode : sourceNode[s_PackagesJson_Packages]) + { + source.Packages.emplace_back(ParsePackageNode(packageNode)); + } + + return source; + } + + // Creates a minimal root object of a Packages JSON file. + Json::Value CreateRoot(const std::string& wingetVersion) + { + Json::Value root{ Json::ValueType::objectValue }; + root[s_PackagesJson_WinGetVersion] = wingetVersion; + root[s_PackagesJson_Schema] = s_PackagesJson_SchemaUri_v1_0; + + // TODO: This uses localtime. Do we want to use UTC or add time zone? + std::stringstream currentTimeStream; + Utility::OutputTimePoint(currentTimeStream, std::chrono::system_clock::now()); + root[s_PackagesJson_CreationDate] = currentTimeStream.str(); + + return root; + } + + // Adds a new Package node to a Source node in the Json file, and returns it. + Json::Value& AddPackageToSource(Json::Value& sourceNode, const PackageCollection::Package& package) + { + Json::Value packageNode{ Json::ValueType::objectValue }; + packageNode[s_PackagesJson_Package_Id] = package.Id.get(); + + // Only add version and channel if present. + // Packages may not have a channel, or versions may not have been requested. + const std::string& version = package.VersionAndChannel.GetVersion().ToString(); + if (!version.empty()) + { + packageNode[s_PackagesJson_Package_Version] = version; + } + + const std::string& channel = package.VersionAndChannel.GetChannel().ToString(); + if (!channel.empty()) + { + packageNode[s_PackagesJson_Package_Channel] = channel; + } + + return sourceNode[s_PackagesJson_Packages].append(std::move(packageNode)); + } + + // Adds a new Source node to the JSON, and returns it. + Json::Value& AddSourceNode(Json::Value& root, const PackageCollection::Source& source) + { + Json::Value sourceNode{ Json::ValueType::objectValue }; + + + Json::Value sourceDetailsNode{ Json::ValueType::objectValue }; + sourceDetailsNode[s_PackagesJson_Source_Name] = source.Details.Name; + sourceDetailsNode[s_PackagesJson_Source_Argument] = source.Details.Arg; + sourceDetailsNode[s_PackagesJson_Source_Identifier] = source.Details.Identifier; + sourceDetailsNode[s_PackagesJson_Source_Type] = source.Details.Type; + sourceNode[s_PackagesJson_Source_Details] = std::move(sourceDetailsNode); + + sourceNode[s_PackagesJson_Packages] = Json::Value{ Json::ValueType::arrayValue }; + + auto& sourcesNode = GetJsonProperty(root, s_PackagesJson_Sources, Json::ValueType::arrayValue); + for (const auto& package : source.Packages) + { + AddPackageToSource(sourceNode, package); + } + + return sourcesNode.append(std::move(sourceNode)); + } + } + + namespace PackagesJson + { + Json::Value CreateJson(const PackageCollection& packages) + { + Json::Value root = CreateRoot(packages.ClientVersion); + for (const auto& source : packages.Sources) + { + AddSourceNode(root, source); + } + + return root; + } + + std::optional TryParseJson(const Json::Value& root) + { + // TODO: Embed schema in binaries & validate file. This will return nullopt on failure. + + PackageCollection packages; + packages.ClientVersion = root[s_PackagesJson_WinGetVersion].asString(); + for (const auto& sourceNode : root[s_PackagesJson_Sources]) + { + auto newSource = ParseSourceNode(sourceNode); + auto existingSource = std::find_if(packages.Sources.begin(), packages.Sources.end(), [&](const PackageCollection::Source& s) { return s.Details.Identifier == newSource.Details.Identifier; }); + if (existingSource == packages.Sources.end()) + { + packages.Sources.push_back(std::move(newSource)); + } + else + { + existingSource->Packages.insert(existingSource->Packages.end(), newSource.Packages.begin(), newSource.Packages.end()); + } + } + + return packages; + } + } +} \ No newline at end of file diff --git a/src/AppInstallerCLICore/PackageCollection.h b/src/AppInstallerCLICore/PackageCollection.h new file mode 100644 index 0000000000..29b3d6d3e4 --- /dev/null +++ b/src/AppInstallerCLICore/PackageCollection.h @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once + +#include "AppInstallerDateTime.h" +#include "AppInstallerLanguageUtilities.h" +#include "AppInstallerRepositorySource.h" + +#include + +#include + +namespace AppInstaller::CLI +{ + using namespace AppInstaller::Repository; + + // Container for data to identify multiple packages to be installed from multiple sources. + struct PackageCollection + { + // Description of a package. + // Does not represent the actual package, just enough to find and install it. + struct Package + { + Package() = default; + Package(Utility::LocIndString&& id) : + Id(std::move(id)) {} + Package(Utility::LocIndString&& id, Utility::Version&& version, Utility::Channel&& channel) : + Id(std::move(id)), VersionAndChannel(std::move(version), std::move(channel)) {} + Package(Utility::LocIndString&& id, Utility::VersionAndChannel&& versionAndChannel) : + Id(std::move(id)), VersionAndChannel(std::move(versionAndChannel)) {} + + Utility::LocIndString Id; + Utility::VersionAndChannel VersionAndChannel; + }; + + // A source along with a set of packages available from it. + struct Source + { + Source() = default; + Source(const SourceDetails& sourceDetails) : Details(sourceDetails) {} + Source(SourceDetails&& sourceDetails) : Details(std::move(sourceDetails)) {} + + SourceDetails Details; + std::vector Packages; + }; + + // Version of the WinGet client that produced this collection. + std::string ClientVersion; + + // Requests from each individual source. + std::vector Sources; + }; + + namespace PackagesJson + { + // Converts a collection of packages to its JSON representation for exporting. + Json::Value CreateJson(const PackageCollection& packages); + + std::optional TryParseJson(const Json::Value& root); + } +} \ No newline at end of file diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 095c038566..20ddde8476 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -48,6 +48,10 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(ExperimentalArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(ExperimentalCommandLongDescription); WINGET_DEFINE_RESOURCE_STRINGID(ExperimentalCommandShortDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ExportCommandLongDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ExportCommandShortDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ExportIncludeVersionsArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ExportSourceArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(ExtraPositionalError); WINGET_DEFINE_RESOURCE_STRINGID(FeatureDisabledMessage); WINGET_DEFINE_RESOURCE_STRINGID(FeaturesCommandLongDescription); @@ -68,12 +72,23 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(HelpForDetails); WINGET_DEFINE_RESOURCE_STRINGID(HelpLinkPreamble); WINGET_DEFINE_RESOURCE_STRINGID(IdArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ImportCommandLongDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ImportCommandShortDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ImportFileArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ImportIgnorePackageVersionsArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ImportIgnoreUnavailableArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(ImportInstallFailed); + WINGET_DEFINE_RESOURCE_STRINGID(ImportPackageAlreadyInstalled); + WINGET_DEFINE_RESOURCE_STRINGID(ImportSearchFailed); + WINGET_DEFINE_RESOURCE_STRINGID(ImportSourceNotInstalled); WINGET_DEFINE_RESOURCE_STRINGID(InstallationDisclaimer1); WINGET_DEFINE_RESOURCE_STRINGID(InstallationDisclaimer2); WINGET_DEFINE_RESOURCE_STRINGID(InstallationDisclaimerMSStore); WINGET_DEFINE_RESOURCE_STRINGID(InstallationRequiresHigherWindows); WINGET_DEFINE_RESOURCE_STRINGID(InstallCommandLongDescription); WINGET_DEFINE_RESOURCE_STRINGID(InstallCommandShortDescription); + WINGET_DEFINE_RESOURCE_STRINGID(InstalledPackageNotAvailable); + WINGET_DEFINE_RESOURCE_STRINGID(InstalledPackageVersionNotAvailable); WINGET_DEFINE_RESOURCE_STRINGID(InstallerBlockedByPolicy); WINGET_DEFINE_RESOURCE_STRINGID(InstallerFailedSecurityCheck); WINGET_DEFINE_RESOURCE_STRINGID(InstallerFailedVirusScan); @@ -87,6 +102,7 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(InteractiveArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(InvalidAliasError); WINGET_DEFINE_RESOURCE_STRINGID(InvalidArgumentSpecifierError); + WINGET_DEFINE_RESOURCE_STRINGID(InvalidJsonFile); WINGET_DEFINE_RESOURCE_STRINGID(InvalidNameError); WINGET_DEFINE_RESOURCE_STRINGID(LanguageArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(LicenseAgreement); @@ -121,12 +137,14 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(NoExperimentalFeaturesMessage); WINGET_DEFINE_RESOURCE_STRINGID(NoInstalledPackageFound); WINGET_DEFINE_RESOURCE_STRINGID(NoPackageFound); + WINGET_DEFINE_RESOURCE_STRINGID(NoPackagesFoundInImportFile); WINGET_DEFINE_RESOURCE_STRINGID(NoUninstallInfoFound); WINGET_DEFINE_RESOURCE_STRINGID(NoVTArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoMatch); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoMatchHelp); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoSourceDefined); WINGET_DEFINE_RESOURCE_STRINGID(Options); + WINGET_DEFINE_RESOURCE_STRINGID(OutputFileArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OverrideArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(Package); WINGET_DEFINE_RESOURCE_STRINGID(PendingWorkError); @@ -210,8 +228,8 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(UninstallCommandLongDescription); WINGET_DEFINE_RESOURCE_STRINGID(UninstallCommandShortDescription); WINGET_DEFINE_RESOURCE_STRINGID(UninstallFailedWithCode); - WINGET_DEFINE_RESOURCE_STRINGID(UninstallFlowUninstallSuccess); WINGET_DEFINE_RESOURCE_STRINGID(UninstallFlowStartingPackageUninstall); + WINGET_DEFINE_RESOURCE_STRINGID(UninstallFlowUninstallSuccess); WINGET_DEFINE_RESOURCE_STRINGID(UnrecognizedCommand); WINGET_DEFINE_RESOURCE_STRINGID(UpdateAllArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(UpdateNotApplicable); diff --git a/src/AppInstallerCLICore/Workflows/ImportExportFlow.cpp b/src/AppInstallerCLICore/Workflows/ImportExportFlow.cpp new file mode 100644 index 0000000000..6aee4eb594 --- /dev/null +++ b/src/AppInstallerCLICore/Workflows/ImportExportFlow.cpp @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "InstallFlow.h" +#include "ImportExportFlow.h" +#include "UpdateFlow.h" +#include "PackageCollection.h" +#include "WorkflowBase.h" +#include "AppInstallerRepositorySearch.h" + +namespace AppInstaller::CLI::Workflow +{ + using namespace AppInstaller::Repository; + + namespace + { + SourceDetails GetSourceDetails(const SourceDetails& source) + { + return source; + } + + SourceDetails GetSourceDetails(const PackageCollection::Source& source) + { + return source.Details; + } + + SourceDetails GetSourceDetails(const std::shared_ptr& source) + { + return source->GetDetails(); + } + + // Creates a predicate that determines whether a source matches a description in a SourceDetails. + template + std::function GetSourceDetailsEquivalencePredicate(const SourceDetails& details) + { + return [&](const T& source) + { + SourceDetails sourceDetails = GetSourceDetails(source); + return sourceDetails.Type == details.Type && sourceDetails.Identifier == details.Identifier; + }; + } + + // Finds a source equivalent to the one specified. + template + typename std::vector::const_iterator FindSource(const std::vector& sources, const SourceDetails& details) + { + return std::find_if(sources.begin(), sources.end(), GetSourceDetailsEquivalencePredicate(details)); + } + + // Finds a source equivalent to the one specified. + template + typename std::vector::iterator FindSource(std::vector& sources, const SourceDetails& details) + { + return std::find_if(sources.begin(), sources.end(), GetSourceDetailsEquivalencePredicate(details)); + } + + // Gets the available version of an installed package. + // If requested, checks that the installed version is available and reports a warning if it is not. + std::shared_ptr GetAvailableVersionForInstalledPackage( + Execution::Context& context, + std::shared_ptr package, + std::string_view version, + std::string_view channel, + bool checkVersion) + { + if (!checkVersion) + { + return package->GetLatestAvailableVersion(); + } + + auto availablePackageVersion = package->GetAvailableVersion({ "", version, channel }); + if (!availablePackageVersion) + { + availablePackageVersion = package->GetLatestAvailableVersion(); + if (availablePackageVersion) + { + // Warn installed version is not available. + AICLI_LOG( + CLI, + Info, + << "Installed package version is not available." + << " Package Id [" << availablePackageVersion->GetProperty(PackageVersionProperty::Id) << "], Version [" << version << "], Channel [" << channel << "]" + << ". Found Version [" << availablePackageVersion->GetProperty(PackageVersionProperty::Version) << "], Channel [" << availablePackageVersion->GetProperty(PackageVersionProperty::Version) << "]"); + context.Reporter.Warn() + << Resource::String::InstalledPackageVersionNotAvailable + << ' ' << availablePackageVersion->GetProperty(PackageVersionProperty::Id) + << ' ' << version << ' ' << channel << std::endl; + } + } + + return availablePackageVersion; + } + } + + void SelectVersionsToExport(Execution::Context& context) + { + const auto& searchResult = context.Get(); + const bool includeVersions = context.Args.Contains(Execution::Args::Type::IncludeVersions); + PackageCollection exportedPackages; + exportedPackages.ClientVersion = Runtime::GetClientVersion().get(); + auto& exportedSources = exportedPackages.Sources; + for (const auto& packageMatch : searchResult.Matches) + { + auto installedPackageVersion = packageMatch.Package->GetInstalledVersion(); + auto version = installedPackageVersion->GetProperty(PackageVersionProperty::Version); + auto channel = installedPackageVersion->GetProperty(PackageVersionProperty::Channel); + + // Find an available version of this package to determine its source. + auto availablePackageVersion = GetAvailableVersionForInstalledPackage(context, packageMatch.Package, version, channel, includeVersions); + if (!availablePackageVersion) + { + // Report package not found and move to next package. + AICLI_LOG(CLI, Warning, << "No available version of package [" << installedPackageVersion->GetProperty(PackageVersionProperty::Name) << "] was found to export"); + context.Reporter.Warn() << Resource::String::InstalledPackageNotAvailable << ' ' << installedPackageVersion->GetProperty(PackageVersionProperty::Name) << std::endl; + continue; + } + + const auto& sourceDetails = availablePackageVersion->GetSource()->GetDetails(); + AICLI_LOG(CLI, Info, + << "Installed package is available. Package Id [" << availablePackageVersion->GetProperty(PackageVersionProperty::Id) << "], Source [" << sourceDetails.Identifier << "]"); + + // Find the exported source for this package + auto sourceItr = FindSource(exportedSources, sourceDetails); + if (sourceItr == exportedSources.end()) + { + exportedSources.emplace_back(sourceDetails); + sourceItr = std::prev(exportedSources.end()); + } + + // Take the Id from the available package because that is the one used in the source, + // but take the exported version from the installed package if needed. + if (includeVersions) + { + sourceItr->Packages.emplace_back( + availablePackageVersion->GetProperty(PackageVersionProperty::Id), + version.get(), + channel.get()); + } + else + { + sourceItr->Packages.emplace_back(availablePackageVersion->GetProperty(PackageVersionProperty::Id)); + } + } + + context.Add(std::move(exportedPackages)); + } + + void WriteImportFile(Execution::Context& context) + { + auto packages = PackagesJson::CreateJson(context.Get()); + + std::filesystem::path outputFilePath{ context.Args.GetArg(Execution::Args::Type::OutputFile) }; + std::ofstream outputFileStream{ outputFilePath }; + outputFileStream << packages; + } + + void ReadImportFile(Execution::Context& context) + { + std::ifstream importFile{ context.Args.GetArg(Execution::Args::Type::ImportFile) }; + THROW_LAST_ERROR_IF(importFile.fail()); + + Json::Value jsonRoot; + Json::CharReaderBuilder builder; + Json::String errors; + if (!Json::parseFromStream(builder, importFile, &jsonRoot, &errors)) + { + AICLI_LOG(CLI, Error, << "Failed to read JSON: " << errors); + context.Reporter.Error() << Resource::String::InvalidJsonFile << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_JSON_INVALID_FILE); + } + + auto packages = PackagesJson::TryParseJson(jsonRoot); + if (!packages.has_value()) + { + context.Reporter.Error() << Resource::String::InvalidJsonFile << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_JSON_INVALID_FILE); + } + + if (packages->Sources.empty()) + { + AICLI_LOG(CLI, Warning, << "No packages to install"); + context.Reporter.Info() << Resource::String::NoPackagesFoundInImportFile << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); + } + + if (context.Args.Contains(Execution::Args::Type::IgnoreVersions)) + { + // Strip out all the version information as we don't need it. + for (auto& source : packages->Sources) + { + for (auto& package : source.Packages) + { + package.VersionAndChannel = {}; + } + } + } + + context.Add(packages.value()); + } + + void OpenSourcesForImport(Execution::Context& context) + { + auto availableSources = Repository::GetSources(); + for (auto& requiredSource : context.Get().Sources) + { + // Find the installed source matching the one described in the collection. + AICLI_LOG(CLI, Info, << "Looking for source [" << requiredSource.Details.Identifier << "]"); + auto matchingSource = FindSource(availableSources, requiredSource.Details); + if (matchingSource != availableSources.end()) + { + requiredSource.Details.Name = matchingSource->Name; + } + else + { + AICLI_LOG(CLI, Error, << "Missing required source: " << requiredSource.Details.Name); + context.Reporter.Warn() << Resource::String::ImportSourceNotInstalled << ' ' << requiredSource.Details.Name << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST); + } + + context << Workflow::OpenNamedSourceForSources(requiredSource.Details.Name); + if (context.IsTerminated()) + { + return; + } + } + } + + void SearchPackagesForImport(Execution::Context& context) + { + const auto& sources = context.Get(); + std::vector> packagesToInstall = {}; + bool foundAll = true; + + // Look for the packages needed from each source independently. + // If a package is available from multiple sources, this ensures we will get it from the right one. + for (auto& requiredSource : context.Get().Sources) + { + // Find the required source among the open sources. This must exist as we already found them. + auto sourceItr = FindSource(sources, requiredSource.Details); + if (sourceItr == sources.end()) + { + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_INTERNAL_ERROR); + } + + // Search for all the packages in the source. + // Each search is done in a sub context to search everything regardless of previous failures. + auto source = Repository::CreateCompositeSource(context.Get(), *sourceItr, CompositeSearchBehavior::AllPackages); + AICLI_LOG(CLI, Info, << "Searching for packages requested from source [" << requiredSource.Details.Identifier << "]"); + for (const auto& packageRequest : requiredSource.Packages) + { + Logging::SubExecutionTelemetryScope subExecution; + AICLI_LOG(CLI, Info, << "Searching for package [" << packageRequest.Id << "]"); + + // Search for the current package + SearchRequest searchRequest; + searchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::Id, MatchType::CaseInsensitive, packageRequest.Id)); + + auto searchContextPtr = context.Clone(); + Execution::Context& searchContext = *searchContextPtr; + searchContext.Add(source); + searchContext.Add(source->Search(searchRequest)); + + // Find the single version we want is available + searchContext << + Workflow::EnsureOneMatchFromSearchResult(false) << + Workflow::GetManifestWithVersionFromPackage(packageRequest.VersionAndChannel) << + Workflow::GetInstalledPackageVersion; + + if (searchContext.Contains(Execution::Data::InstalledPackageVersion) && searchContext.Get()) + { + searchContext << Workflow::EnsureUpdateVersionApplicable; + } + + if (searchContext.IsTerminated()) + { + if (searchContext.GetTerminationHR() == APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE) + { + AICLI_LOG(CLI, Info, << "Package is already installed: [" << packageRequest.Id << "]"); + context.Reporter.Info() << Resource::String::ImportPackageAlreadyInstalled << ' ' << packageRequest.Id << std::endl; + continue; + } + else + { + AICLI_LOG(CLI, Info, << "Package not found for import: [" << packageRequest.Id << "], Version " << packageRequest.VersionAndChannel.ToString()); + context.Reporter.Info() << Resource::String::ImportSearchFailed << ' ' << packageRequest.Id << std::endl; + + // Keep searching for the remaining packages and only fail at the end. + foundAll = false; + continue; + } + } + + packagesToInstall.push_back(std::move(searchContext.Get())); + } + } + + if (!foundAll) + { + AICLI_LOG(CLI, Info, << "Could not find one or more packages for import"); + if (context.Args.Contains(Execution::Args::Type::IgnoreUnavailable)) + { + AICLI_LOG(CLI, Info, << "Ignoring unavailable packages due to command line argument"); + } + else + { + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND); + } + } + + context.Add(std::move(packagesToInstall)); + } +} diff --git a/src/AppInstallerCLICore/Workflows/ImportExportFlow.h b/src/AppInstallerCLICore/Workflows/ImportExportFlow.h new file mode 100644 index 0000000000..f89381a955 --- /dev/null +++ b/src/AppInstallerCLICore/Workflows/ImportExportFlow.h @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include "ExecutionContext.h" + +namespace AppInstaller::CLI::Workflow +{ + // Selects the package versions to list on the exported file + // Required Args: None + // Inputs: SearchResult + // Outputs: PackageCollection + void SelectVersionsToExport(Execution::Context& context); + + // Exports a collection of packages to a JSON import file + // Required Args: OutputFile + // Inputs: PackageCollection + // Outputs: None + void WriteImportFile(Execution::Context& context); + + // Reads the contents of an import file + // Required Args: ImportFile + // Inputs: None + // Outputs: PackageCollection + void ReadImportFile(Execution::Context& context); + + // Opens the sources specified in an import file + // Required Args: None + // Inputs: PackageCollection + // Outputs: Sources + void OpenSourcesForImport(Execution::Context& context); + + // Finds the package versions to install matching their descriptions + // Needs the sources for all packages and the installed source + // Required Args: None + // Inputs: PackageCollection, Sources, Source + // Outputs: PackagesToInstall + void SearchPackagesForImport(Execution::Context& context); +} diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp index 2091529c83..cb3de2a22b 100644 --- a/src/AppInstallerCLICore/Workflows/InstallFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/InstallFlow.cpp @@ -368,4 +368,48 @@ namespace AppInstaller::CLI::Workflow } } } + + void InstallPackageVersion(Execution::Context& context) + { + context << + Workflow::EnsureMinOSVersion << + Workflow::SelectInstaller << + Workflow::EnsureApplicableInstaller << + Workflow::ShowInstallationDisclaimer << + Workflow::ReportExecutionStage(ExecutionStage::Download) << + Workflow::DownloadInstaller << + Workflow::ReportExecutionStage(ExecutionStage::Execution) << + Workflow::ExecuteInstaller << + Workflow::ReportExecutionStage(ExecutionStage::PostExecution) << + Workflow::RemoveInstaller; + } + + void InstallMultiple(Execution::Context& context) + { + bool allSucceeded = true; + for (auto package : context.Get()) + { + Logging::SubExecutionTelemetryScope subExecution; + + // We want to do best effort to install all packages regardless of previous failures + auto installContextPtr = context.Clone(); + Execution::Context& installContext = *installContextPtr; + + // Extract the data needed for installing + installContext.Add(package); + installContext.Add(package->GetManifest()); + + installContext << InstallPackageVersion; + if (installContext.IsTerminated()) + { + allSucceeded = false; + } + } + + if (!allSucceeded) + { + context.Reporter.Error() << Resource::String::ImportInstallFailed << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_IMPORT_INSTALL_FAILED); + } + } } diff --git a/src/AppInstallerCLICore/Workflows/InstallFlow.h b/src/AppInstallerCLICore/Workflows/InstallFlow.h index 8e6351fecc..0be8045d10 100644 --- a/src/AppInstallerCLICore/Workflows/InstallFlow.h +++ b/src/AppInstallerCLICore/Workflows/InstallFlow.h @@ -82,4 +82,16 @@ namespace AppInstaller::CLI::Workflow // Inputs: InstallerPath // Outputs: None void RemoveInstaller(Execution::Context& context); + + // Installs a single package from its manifest + // Required Args: None + // Inputs: Manifest, PackageVersion, Source + // Outputs: Manifest + void InstallPackageVersion(Execution::Context& context); + + // Installs multiple packages. + // Required Args: None + // Inputs: Manifests + // Outputs: None + void InstallMultiple(Execution::Context& context); } diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 8018e1a044..ab34652c0f 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -36,6 +36,53 @@ namespace AppInstaller::CLI::Workflow context.Reporter.Info() << Resource::String::ReportIdentityFound << ' ' << Execution::NameEmphasis << name << " [" << Execution::IdEmphasis << id << ']' << std::endl; } + std::shared_ptr OpenNamedSource(Execution::Context& context, std::string_view sourceName) + { + std::shared_ptr source; + try + { + auto result = context.Reporter.ExecuteWithProgress(std::bind(Repository::OpenSource, sourceName, std::placeholders::_1), true); + source = result.Source; + + // We'll only report the source update failure as warning and continue + for (const auto& s : result.SourcesWithUpdateFailure) + { + context.Reporter.Warn() << Resource::String::SourceOpenWithFailedUpdate << ' ' << s.Name << std::endl; + } + } + catch (...) + { + context.Reporter.Error() << Resource::String::SourceOpenFailedSuggestion << std::endl; + throw; + } + + if (!source) + { + std::vector sources = GetSources(); + + if (!sourceName.empty() && !sources.empty()) + { + // A bad name was given, try to help. + context.Reporter.Error() << Resource::String::OpenSourceFailedNoMatch << ' ' << sourceName << std::endl; + context.Reporter.Info() << Resource::String::OpenSourceFailedNoMatchHelp << std::endl; + for (const auto& details : sources) + { + context.Reporter.Info() << " "_liv << details.Name << std::endl; + } + + AICLI_TERMINATE_CONTEXT_RETURN(APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST, {}); + } + else + { + // Even if a name was given, there are no sources + context.Reporter.Error() << Resource::String::OpenSourceFailedNoSourceDefined << std::endl; + AICLI_TERMINATE_CONTEXT_RETURN(APPINSTALLER_CLI_ERROR_NO_SOURCES_DEFINED, {}); + } + } + + return source; + } + void SearchSourceApplyFilters(Execution::Context& context, SearchRequest& searchRequest, MatchType matchType) { const auto& args = context.Args; @@ -102,50 +149,30 @@ namespace AppInstaller::CLI::Workflow sourceName = context.Args.GetArg(Execution::Args::Type::Source); } - std::shared_ptr source; - try + auto source = OpenNamedSource(context, sourceName); + if (context.IsTerminated()) { - auto result = context.Reporter.ExecuteWithProgress(std::bind(Repository::OpenSource, sourceName, std::placeholders::_1), true); - source = result.Source; - - // We'll only report the source update failure as warning and continue - for (const auto& s : result.SourcesWithUpdateFailure) - { - context.Reporter.Warn() << Resource::String::SourceOpenWithFailedUpdate << ' ' << s.Name << std::endl; - } + return; } - catch (...) + + context.Add(std::move(source)); + } + + void OpenNamedSourceForSources::operator()(Execution::Context& context) const + { + auto source = OpenNamedSource(context, m_sourceName); + if (context.IsTerminated()) { - context.Reporter.Error() << Resource::String::SourceOpenFailedSuggestion << std::endl; - throw; + return; } - if (!source) + if (!context.Contains(Execution::Data::Sources)) { - std::vector sources = GetSources(); - - if (context.Args.Contains(Execution::Args::Type::Source) && !sources.empty()) - { - // A bad name was given, try to help. - context.Reporter.Error() << Resource::String::OpenSourceFailedNoMatch << ' ' << context.Args.GetArg(Execution::Args::Type::Source) << std::endl; - context.Reporter.Info() << Resource::String::OpenSourceFailedNoMatchHelp << std::endl; - for (const auto& details : sources) - { - context.Reporter.Info() << " "_liv << details.Name << std::endl; - } - - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST); - } - else - { - // Even if a name was given, there are no sources - context.Reporter.Error() << Resource::String::OpenSourceFailedNoSourceDefined << std::endl; - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_SOURCES_DEFINED); - } + context.Add({ std::move(source) }); } else { - context.Add(std::move(source)); + context.Get().emplace_back(std::move(source)); } } @@ -472,12 +499,9 @@ namespace AppInstaller::CLI::Workflow }; } - void GetManifestFromPackage(Execution::Context& context) + void GetManifestWithVersionFromPackage::operator()(Execution::Context& context) const { - std::string_view version = context.Args.GetArg(Execution::Args::Type::Version); - std::string_view channel = context.Args.GetArg(Execution::Args::Type::Channel); - - PackageVersionKey key("", version, channel); + PackageVersionKey key("", m_version, m_channel); auto requestedVersion = context.Get()->GetAvailableVersion(key); std::optional manifest; @@ -488,17 +512,18 @@ namespace AppInstaller::CLI::Workflow if (!manifest) { - context.Reporter.Error() << Resource::String::GetManifestResultVersionNotFound << ' '; - if (!version.empty()) + auto errorStream = context.Reporter.Error(); + errorStream << Resource::String::GetManifestResultVersionNotFound << ' '; + if (!m_version.empty()) { - context.Reporter.Error() << version; + errorStream << m_version; } - if (!channel.empty()) + if (!m_channel.empty()) { - context.Reporter.Error() << '[' << channel << ']'; + errorStream << '[' << m_channel << ']'; } - context.Reporter.Error() << std::endl; + errorStream << std::endl; AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_NO_MANIFEST_FOUND); } @@ -507,6 +532,11 @@ namespace AppInstaller::CLI::Workflow context.Add(std::move(requestedVersion)); } + void GetManifestFromPackage(Execution::Context& context) + { + context << GetManifestWithVersionFromPackage(context.Args.GetArg(Execution::Args::Type::Version), context.Args.GetArg(Execution::Args::Type::Channel)); + } + void VerifyFile::operator()(Execution::Context& context) const { std::filesystem::path path = Utility::ConvertToUTF16(context.Args.GetArg(m_arg)); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index 8f457c2132..848ca9205b 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -60,6 +60,20 @@ namespace AppInstaller::CLI::Workflow // Outputs: Source void OpenSource(Execution::Context& context); + // Creates a source object for a source specified by name, and adds it to the list of open sources. + // Required Args: None + // Inputs: Sources? + // Outputs: Sources + struct OpenNamedSourceForSources : public WorkflowTask + { + OpenNamedSourceForSources(std::string_view sourceName) : WorkflowTask("OpenNamedSourceForSources"), m_sourceName(sourceName) {} + + void operator()(Execution::Context& context) const override; + + private: + std::string_view m_sourceName; + }; + // Creates a source object for a predefined source. // Required Args: None // Inputs: None @@ -184,6 +198,25 @@ namespace AppInstaller::CLI::Workflow bool m_isFromInstalledSource; }; + // Gets the manifest from package. + // Required Args: Version and channel; can be empty + // Inputs: Package + // Outputs: Manifest, PackageVersion + struct GetManifestWithVersionFromPackage : public WorkflowTask + { + GetManifestWithVersionFromPackage(const Utility::VersionAndChannel& versionAndChannel) : + WorkflowTask("GetManifestWithVersionFromPackage"), m_version(versionAndChannel.GetVersion().ToString()), m_channel(versionAndChannel.GetChannel().ToString()) {} + + GetManifestWithVersionFromPackage(std::string_view version, std::string_view channel) : + WorkflowTask("GetManifestWithVersionFromPackage"), m_version(version), m_channel(channel) {} + + void operator()(Execution::Context& context) const override; + + private: + std::string_view m_version; + std::string_view m_channel; + }; + // Gets the manifest from package. // Required Args: None // Inputs: Package diff --git a/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj b/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj index 40833adb02..c01d6d4bbc 100644 --- a/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj +++ b/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj @@ -22,6 +22,14 @@ + + + + + + + + diff --git a/src/AppInstallerCLIE2ETests/Constants.cs b/src/AppInstallerCLIE2ETests/Constants.cs index abce1b5929..50d9781fd9 100644 --- a/src/AppInstallerCLIE2ETests/Constants.cs +++ b/src/AppInstallerCLIE2ETests/Constants.cs @@ -53,6 +53,9 @@ public class Constants public const string MsiInstallerProductCode = "{A5D36CF1-1993-4F63-BFB4-3ACD910D36A1}"; public const string MsixInstallerPackageFamilyName = "6c6338fe-41b7-46ca-8ba6-b5ad5312bb0e_8wekyb3d8bbwe"; + public const string TestExeInstalledFileName = "TestExeInstalled.txt"; + public const string TestExeUninstallerFileName = "UninstallTestExe.bat"; + public class ErrorCode { public const int S_OK = 0; @@ -104,6 +107,19 @@ public class ErrorCode public const int ERROR_LIBYAML_ERROR = unchecked((int)0x8A150027); public const int ERROR_MANIFEST_VALIDATION_WARNING = unchecked((int)0x8A150028); public const int ERROR_MANIFEST_VALIDATION_FAILURE = unchecked((int)0x8A150029); + public const int APPINSTALLER_CLI_ERROR_INVALID_MANIFEST = unchecked((int)0x8A15002A); + public const int APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE = unchecked((int)0x8A15002B); + public const int APPINSTALLER_CLI_ERROR_UPDATE_ALL_HAS_FAILURE = unchecked((int)0x8A15002C); + public const int APPINSTALLER_CLI_ERROR_INSTALLER_SECURITY_CHECK_FAILED = unchecked((int)0x8A15002D); + public const int APPINSTALLER_CLI_ERROR_DOWNLOAD_SIZE_MISMATCH = unchecked((int)0x8A15002E); + public const int APPINSTALLER_CLI_ERROR_NO_UNINSTALL_INFO_FOUND = unchecked((int)0x8a15002F); + public const int APPINSTALLER_CLI_ERROR_EXEC_UNINSTALL_COMMAND_FAILED = unchecked((int)0x8a150030); + public const int APPINSTALLER_CLI_ERROR_ICU_BREAK_ITERATOR_ERROR = unchecked((int)0x8A150031); + public const int APPINSTALLER_CLI_ERROR_ICU_CASEMAP_ERROR = unchecked((int)0x8A150032); + public const int APPINSTALLER_CLI_ERROR_ICU_REGEX_ERROR = unchecked((int)0x8A150033); + public const int APPINSTALLER_CLI_ERROR_IMPORT_INSTALL_FAILED = unchecked((int)0x8a150034); + public const int APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND = unchecked((int)0x8a150035); + public const int APPINSTALLER_CLI_ERROR_JSON_INVALID_FILE = unchecked((int)0x8a150036); } } } diff --git a/src/AppInstallerCLIE2ETests/ImportCommand.cs b/src/AppInstallerCLIE2ETests/ImportCommand.cs new file mode 100644 index 0000000000..912c7cc083 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/ImportCommand.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace AppInstallerCLIE2ETests +{ + using System.IO; + using NUnit.Framework; + + public class ImportCommand : BaseCommand + { + [SetUp] + public void Setup() + { + InitializeAllFeatures(false); + ConfigureFeature("importExport", true); + CleanupTestExe(); + } + + [TearDown] + public void TearDown() + { + InitializeAllFeatures(false); + } + + [Test] + public void ImportSuccessful() + { + var result = TestCommon.RunAICLICommand("import", GetTestImportFile("ImportFile-Good.json")); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(VerifyTestExeInstalled()); + UninstallTestExe(); + } + + // Ignore while we don't have schema validation + // [Test] + public void ImportInvalidFile() + { + // Verify failure when trying to import with an invalid file + var result = TestCommon.RunAICLICommand("import", GetTestImportFile("ImportFile-Bad-Invalid.json")); + Assert.AreEqual(Constants.ErrorCode.APPINSTALLER_CLI_ERROR_JSON_INVALID_FILE, result.ExitCode); + Assert.True(result.StdOut.Contains("JSON file is not valid")); + } + + [Test] + public void ImportUnknownSource() + { + // Verify failure when trying to import from an unknown source + var result = TestCommon.RunAICLICommand("import", GetTestImportFile("ImportFile-Bad-UnknownSource.json")); + Assert.AreEqual(Constants.ErrorCode.ERROR_SOURCE_NAME_DOES_NOT_EXIST, result.ExitCode); + Assert.True(result.StdOut.Contains("Source required for import is not installed")); + } + + [Test] + public void ImportUnavailablePackage() + { + // Verify failure when trying to import an unavailable package + var result = TestCommon.RunAICLICommand("import", GetTestImportFile("ImportFile-Bad-UnknownPackage.json")); + Assert.AreEqual(Constants.ErrorCode.APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND, result.ExitCode); + Assert.True(result.StdOut.Contains("Package not found for import")); + } + + [Test] + public void ImportUnavailableVersion() + { + // Verify failure when trying to import an unavailable package + var result = TestCommon.RunAICLICommand("import", GetTestImportFile("ImportFile-Bad-UnknownPackageVersion.json")); + Assert.AreEqual(Constants.ErrorCode.APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND, result.ExitCode); + Assert.True(result.StdOut.Contains("Package not found for import")); + } + + [Test] + public void ImportAlreadyInstalled() + { + // Verify success with message when trying to import a package that is already installed + var installDir = TestCommon.GetRandomTestDir(); + TestCommon.RunAICLICommand("install", $"AppInstallerTest.TestExeInstaller -l {installDir}"); + var result = TestCommon.RunAICLICommand("import", $"{GetTestImportFile("ImportFile-Good.json")}"); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(result.StdOut.Contains("Package is already installed")); + Assert.False(VerifyTestExeInstalled()); + UninstallTestExe(); + } + + [Test] + public void ImportExportedFile() + { + // Verify success when importing an exported list of packages. + // First install the test package to ensure it is exported. + TestCommon.RunAICLICommand("install", Constants.ExeInstallerPackageId); + + var jsonFile = TestCommon.GetRandomTestFile(".json"); + TestCommon.RunAICLICommand("export", $"{jsonFile} -s TestSource"); + + // Uninstall the package to ensure we can install it again + UninstallTestExe(); + + // Import the file + var result = TestCommon.RunAICLICommand("import", jsonFile); + Assert.AreEqual(Constants.ErrorCode.S_OK, result.ExitCode); + Assert.True(VerifyTestExeInstalled()); + UninstallTestExe(); + } + + private string GetTestImportFile(string importFileName) + { + return TestCommon.GetTestDataFile(Path.Combine("ImportFiles", importFileName)); + } + + private bool VerifyTestExeInstalled(string installDir = null) + { + if (string.IsNullOrEmpty(installDir)) + { + // Default location used by installer + installDir = Path.GetTempPath(); + } + + return File.Exists(Path.Combine(installDir, Constants.TestExeInstalledFileName)); + } + + private void UninstallTestExe() + { + ConfigureFeature("uninstall", true); + TestCommon.RunAICLICommand("uninstall", Constants.ExeInstallerPackageId); + } + + private void CleanupTestExe() + { + UninstallTestExe(); + File.Delete(Path.Combine(Path.GetTempPath(), Constants.TestExeInstalledFileName)); + File.Delete(Path.Combine(Path.GetTempPath(), Constants.TestExeUninstallerFileName)); + } + } +} \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/InstallCommand.cs b/src/AppInstallerCLIE2ETests/InstallCommand.cs index 99630b2812..8917975ef5 100644 --- a/src/AppInstallerCLIE2ETests/InstallCommand.cs +++ b/src/AppInstallerCLIE2ETests/InstallCommand.cs @@ -8,7 +8,6 @@ namespace AppInstallerCLIE2ETests public class InstallCommand : BaseCommand { - private const string InstallTestExeInstalledFile = @"TestExeInstalled.txt"; private const string InstallTestMsiInstalledFile = @"AppInstallerTestExeInstaller.exe"; private const string InstallTestMsiProductId = @"{A5D36CF1-1993-4F63-BFB4-3ACD910D36A1}"; private const string InstallTestMsixName = @"6c6338fe-41b7-46ca-8ba6-b5ad5312bb0e"; @@ -137,17 +136,18 @@ public void InstallMSIXWithSignatureHashMismatch() private bool VerifyTestExeInstalled(string installDir, string expectedContent = null) { - if (!File.Exists(Path.Combine(installDir, InstallTestExeInstalledFile))) + if (!File.Exists(Path.Combine(installDir, Constants.TestExeInstalledFileName))) { return false; } if (!string.IsNullOrEmpty(expectedContent)) { - string content = File.ReadAllText(Path.Combine(installDir, InstallTestExeInstalledFile)); + string content = File.ReadAllText(Path.Combine(installDir, Constants.TestExeInstalledFileName)); return content.Contains(expectedContent); } + TestCommon.RunCommand(Path.Combine(installDir, Constants.TestExeUninstallerFileName)); return true; } diff --git a/src/AppInstallerCLIE2ETests/TestCommon.cs b/src/AppInstallerCLIE2ETests/TestCommon.cs index 6697735d0e..9b1d84f67b 100644 --- a/src/AppInstallerCLIE2ETests/TestCommon.cs +++ b/src/AppInstallerCLIE2ETests/TestCommon.cs @@ -190,7 +190,7 @@ public static RunCommandResult RunAICLICommandViaInvokeCommandInDesktopPackage(s return result; } - public static bool RunCommand(string fileName, string args, int timeOut = 60000) + public static bool RunCommand(string fileName, string args = "", int timeOut = 60000) { return RunCommandWithResult(fileName, args, timeOut).ExitCode == 0; } @@ -233,13 +233,25 @@ public static string GetTestDataFile(string fileName) return GetTestFile(Path.Combine("TestData", fileName)); } + public static string GetTestWorkDir() + { + string workDir = Path.Combine(TestContext.CurrentContext.TestDirectory, "WorkDirectory"); + Directory.CreateDirectory(workDir); + return workDir; + } + public static string GetRandomTestDir() { - string randDir = Path.Combine(TestContext.CurrentContext.TestDirectory, Path.Combine("WorkDirectory", Path.GetRandomFileName())); + string randDir = Path.Combine(GetTestWorkDir(), Path.GetRandomFileName()); Directory.CreateDirectory(randDir); return randDir; } + public static string GetRandomTestFile(string extension) + { + return Path.Combine(GetTestWorkDir(), Path.GetRandomFileName() + extension); + } + public static bool InstallMsix(string file) { return RunCommand("powershell", $"Add-AppxPackage \"{file}\""); diff --git a/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-Invalid.json b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-Invalid.json new file mode 100644 index 0000000000..6bfedac997 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-Invalid.json @@ -0,0 +1 @@ +"A valid JSON file that does not conform to the schema" \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownPackage.json b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownPackage.json new file mode 100644 index 0000000000..317c9f299b --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownPackage.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "MissingPackage", + "Version": "1.0.0.0" + } + ], + "SourceDetails": { + "Name": "TestSource", + "Argument": "https://localhost:5001/TestKit", + "Identifier": "WingetE2E.Tests_8wekyb3d8bbwe", + "Type": "Microsoft.PreIndexed.Package" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownPackageVersion.json b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownPackageVersion.json new file mode 100644 index 0000000000..5ba77b275b --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownPackageVersion.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerTest.TestExeInstaller", + "Version": "4.3.2.1" + } + ], + "SourceDetails": { + "Name": "TestSource", + "Argument": "https://localhost:5001/TestKit", + "Identifier": "WingetE2E.Tests_8wekyb3d8bbwe", + "Type": "Microsoft.PreIndexed.Package" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownSource.json b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownSource.json new file mode 100644 index 0000000000..13c63b2ce1 --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Bad-UnknownSource.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerTest.TestExeInstaller", + "Version": "1.0.0.0" + } + ], + "SourceDetails": { + "Name": "TestSource", + "Argument": "https://localhost", + "Identifier": "WingetE2E.UnknownTestSource_8wekyb3d8bbwe", + "Type": "Microsoft.PreIndexed.Package" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Good.json b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Good.json new file mode 100644 index 0000000000..f513feaa9a --- /dev/null +++ b/src/AppInstallerCLIE2ETests/TestData/ImportFiles/ImportFile-Good.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerTest.TestExeInstaller", + "Version": "1.0.0.0" + } + ], + "SourceDetails": { + "Name": "TestSource", + "Argument": "https://localhost:5001/TestKit", + "Identifier": "WingetE2E.Tests_8wekyb3d8bbwe", + "Type": "Microsoft.PreIndexed.Package" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.2.0.0.0.yaml b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.2.0.0.0.yaml index 1a6e9d673c..f2d6af8541 100644 --- a/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.2.0.0.0.yaml +++ b/src/AppInstallerCLIE2ETests/TestData/Manifests/TestExeInstaller.2.0.0.0.yaml @@ -10,7 +10,7 @@ Installers: InstallerType: exe ProductCode: '{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}' Switches: - Custom: /execustom + Custom: /execustom /Version 2.0.0.0 SilentWithProgress: /exeswp Silent: /exesilent Interactive: /exeinteractive diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 456fae16e0..e251ade0c8 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -756,4 +756,59 @@ They can be configured through the settings file 'winget settings'. Uninstall failed with exit code: + + Exports a list of the installed packages + + + Installs all the packages listed in a file. + + + Installs all the packages in a file + + + File where the result is to be written + + + File describing the packages to install + + + Export packages from the specified source + + + Writes a list of the installed packages to a file. The packages can then be installed with the import command. + {Locked="import"} + + + One or more imported packages failed to install + + + Package not found for import: + + + Source required for import is not installed: + + + Installed package is not available from any source: + + + Installed version of package is not available from any source: + + + No packages found in import file + + + JSON file is not valid + + + Package is already installed: + + + Ignore unavailable packages + + + Include package versions in produced file + + + Ignore package versions from import file + \ No newline at end of file diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj index 251385568a..22288f3f7a 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -186,6 +186,7 @@ + @@ -233,6 +234,27 @@ true + + true + + + true + + + true + + + true + + + true + + + true + + + true + diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters index 858f4f4700..74416c87d6 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -113,6 +113,9 @@ Source Files + + Source Files + Source Files @@ -339,6 +342,27 @@ TestData + + TestData + + + TestData + + + TestData + + + TestData + + + TestData + + + TestData + + + TestData + TestData diff --git a/src/AppInstallerCLITests/PackageCollection.cpp b/src/AppInstallerCLITests/PackageCollection.cpp new file mode 100644 index 0000000000..ce549d562e --- /dev/null +++ b/src/AppInstallerCLITests/PackageCollection.cpp @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TestCommon.h" + +#include + +// Duplicating here because a change to these values in the product *REALLY* needs to be thought through. +using namespace std::string_literals; +using namespace std::string_view_literals; + +using namespace AppInstaller::CLI; +using namespace AppInstaller::Repository; +using namespace AppInstaller::Utility; + +const std::string s_PackagesJson_Schema = "$schema"; +const std::string s_PackagesJson_SchemaUri_v1_0 = "https://aka.ms/winget-packages.schema.1.0.json"; +const std::string s_PackagesJson_WinGetVersion = "WinGetVersion"; +const std::string s_PackagesJson_CreationDate = "CreationDate"; + +const std::string s_PackagesJson_Sources = "Sources"; +const std::string s_PackagesJson_Source_Details = "SourceDetails"; +const std::string s_PackagesJson_Source_Name = "Name"; +const std::string s_PackagesJson_Source_Identifier = "Identifier"; +const std::string s_PackagesJson_Source_Argument = "Argument"; +const std::string s_PackagesJson_Source_Type = "Type"; + +const std::string s_PackagesJson_Packages = "Packages"; +const std::string s_PackagesJson_Package_Id = "Id"; +const std::string s_PackagesJson_Package_Version = "Version"; +const std::string s_PackagesJson_Package_Channel = "Channel"; + +namespace +{ + + Json::Value ParseJsonString(const std::string& jsonString) + { + Json::Value root; + std::stringstream{ jsonString } >> root; + return root; + } + + void ValidateJsonStringProperty(const Json::Value& node, const std::string& propertyName, std::string_view expectedValue, bool allowMissing = false) + { + if (allowMissing && expectedValue.empty() && !node.isMember(propertyName)) + { + return; + } + + REQUIRE(node.isMember(propertyName)); + REQUIRE(node[propertyName].isString()); + REQUIRE(node[propertyName].asString() == expectedValue); + } + + const Json::Value& GetAndValidateJsonProperty(const Json::Value& node, const std::string& propertyName, Json::ValueType valueType) + { + REQUIRE(node.isMember(propertyName)); + REQUIRE(node[propertyName].type() == valueType); + return node[propertyName]; + } + + void ValidateJsonWithCollection(const Json::Value& root, const PackageCollection& collection) + { + ValidateJsonStringProperty(root, s_PackagesJson_Schema, s_PackagesJson_SchemaUri_v1_0); + ValidateJsonStringProperty(root, s_PackagesJson_WinGetVersion, collection.ClientVersion); + REQUIRE(root.isMember(s_PackagesJson_CreationDate)); + + const auto& jsonSources = GetAndValidateJsonProperty(root, s_PackagesJson_Sources, Json::ValueType::arrayValue); + REQUIRE(jsonSources.size() == collection.Sources.size()); + + // Expect the order to be the same. Not really needed, but it makes things easier. + auto jsonSourceItr = jsonSources.begin(); + auto sourceItr = collection.Sources.begin(); + for (; jsonSourceItr != jsonSources.end(); ++jsonSourceItr, ++sourceItr) + { + REQUIRE(jsonSourceItr->isObject()); + const auto& jsonSourceDetails = GetAndValidateJsonProperty(*jsonSourceItr, s_PackagesJson_Source_Details, Json::ValueType::objectValue); + ValidateJsonStringProperty(jsonSourceDetails, s_PackagesJson_Source_Name, sourceItr->Details.Name); + ValidateJsonStringProperty(jsonSourceDetails, s_PackagesJson_Source_Argument, sourceItr->Details.Arg); + ValidateJsonStringProperty(jsonSourceDetails, s_PackagesJson_Source_Type, sourceItr->Details.Type); + ValidateJsonStringProperty(jsonSourceDetails, s_PackagesJson_Source_Identifier, sourceItr->Details.Identifier); + + const auto& jsonPackages = GetAndValidateJsonProperty(*jsonSourceItr, s_PackagesJson_Packages, Json::ValueType::arrayValue); + REQUIRE(jsonPackages.size() == sourceItr->Packages.size()); + + auto jsonPackageItr = jsonPackages.begin(); + auto packageItr = sourceItr->Packages.begin(); + for (; jsonPackageItr != jsonPackages.end(); ++jsonPackageItr, ++packageItr) + { + REQUIRE(jsonPackageItr->isObject()); + ValidateJsonStringProperty(*jsonPackageItr, s_PackagesJson_Package_Id, packageItr->Id); + ValidateJsonStringProperty(*jsonPackageItr, s_PackagesJson_Package_Version, packageItr->VersionAndChannel.GetVersion().ToString(), true); + ValidateJsonStringProperty(*jsonPackageItr, s_PackagesJson_Package_Channel, packageItr->VersionAndChannel.GetChannel().ToString(), true); + } + } + } + + void ValidateEqualCollections(const PackageCollection& first, const PackageCollection& second) + { + REQUIRE(first.ClientVersion == second.ClientVersion); + REQUIRE(first.Sources.size() == second.Sources.size()); + + auto firstSourceItr = first.Sources.begin(); + auto secondSourceItr = second.Sources.begin(); + for (; firstSourceItr != first.Sources.end(); ++firstSourceItr, ++secondSourceItr) + { + REQUIRE(firstSourceItr->Details.Name == secondSourceItr->Details.Name); + REQUIRE(firstSourceItr->Details.Arg == secondSourceItr->Details.Arg); + REQUIRE(firstSourceItr->Details.Type == secondSourceItr->Details.Type); + REQUIRE(firstSourceItr->Details.Identifier == secondSourceItr->Details.Identifier); + + REQUIRE(firstSourceItr->Packages.size() == secondSourceItr->Packages.size()); + auto firstPackageItr = firstSourceItr->Packages.begin(); + auto secondPackageItr = secondSourceItr->Packages.begin(); + for (; firstPackageItr != firstSourceItr->Packages.end(); ++firstPackageItr, ++secondPackageItr) + { + REQUIRE(firstPackageItr->Id == secondPackageItr->Id); + REQUIRE(firstPackageItr->VersionAndChannel.ToString() == secondPackageItr->VersionAndChannel.ToString()); + } + } + } +} + +TEST_CASE("PackageCollection_Write_SingleSource", "[PackageCollection]") +{ + PackageCollection::Source source; + source.Details.Name = "TestSource"; + source.Details.Arg = "https://aka.ms/winget"; + source.Details.Type = "Microsoft.PreIndexed.Package"; + source.Details.Identifier = "TestSourceId"; + + source.Packages.emplace_back(LocIndString{ "test.package1"sv }, Version{ "1.0.1" }, Channel{ "" }); + source.Packages.emplace_back(LocIndString{ "test.package2"sv }, Version{ "2" }, Channel{ "Public" }); + + PackageCollection pc + { + "0.1.2.3", + std::vector{ source } + }; + + ValidateJsonWithCollection(PackagesJson::CreateJson(pc), pc); +} + +TEST_CASE("PackageCollection_Write_MultipleSources", "[PackageCollection]") +{ + PackageCollection::Source source1; + source1.Details.Name = "TestSource"; + source1.Details.Arg = "https://aka.ms/winget"; + source1.Details.Type = "Microsoft.PreIndexed.Package"; + source1.Details.Identifier = "TestSourceId"; + source1.Packages.emplace_back(LocIndString{ "test.package1"sv }, Version{ "1.0.1" }, Channel{ "" }); + + PackageCollection::Source source2; + source2.Details.Name = "TestSource2"; + source2.Details.Arg = "https://aka.ms/winget"; + source2.Details.Type = "*Test"; + source2.Details.Identifier = "SecondId"; + source2.Packages.emplace_back(LocIndString{ "test.package2"sv }, Version{ "2.1.0" }, Channel{ "Beta" }); + + PackageCollection pc + { + "1.0.0.0", + std::vector{ source1, source2 } + }; + + ValidateJsonWithCollection(PackagesJson::CreateJson(pc), pc); +} + +TEST_CASE("PackageCollection_Read_SingleSource", "[PackageCollection]") +{ + auto json = ParseJsonString(R"( + { + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "test.WithVersion", + "Version": "0.1", + "Channel": "Preview" + }, + { + "Id": "test.NoVersion" + } + ], + "SourceDetails": { + "Argument": "https://aka.ms/winget", + "Identifier": "TestSourceId", + "Name": "TestSource", + "Type": "Microsoft.PreIndexed.Package" + } + } + ], + "WinGetVersion": "1.0.0" + })"); + + auto parsed = PackagesJson::TryParseJson(json); + REQUIRE(parsed.has_value()); + + PackageCollection::Source source; + source.Details.Name = "TestSource"; + source.Details.Arg = "https://aka.ms/winget"; + source.Details.Type = "Microsoft.PreIndexed.Package"; + source.Details.Identifier = "TestSourceId"; + + source.Packages.emplace_back(LocIndString{ "test.WithVersion"sv }, Version{ "0.1" }, Channel{ "Preview" }); + source.Packages.emplace_back(LocIndString{ "test.NoVersion"sv }, Version{ "" }, Channel{ "" }); + + PackageCollection expected + { + "1.0.0", + std::vector{ source } + }; + + ValidateEqualCollections(parsed.value(), expected); +} + +TEST_CASE("PackageCollection_Read_MultipleSources", "[PackageCollection]") +{ + auto json = ParseJsonString(R"( + { + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "WinGetVersion": "1.0.0", + "Sources": [ + { + "SourceDetails": { + "Argument": "//firstSource", + "Identifier": "Id1", + "Name": "First", + "Type": "Microsoft.PreIndexed.Package" + }, + "Packages": [ + { + "Id": "test" + } + ] + }, + { + "SourceDetails": { + "Argument": "//secondSource", + "Identifier": "Id2", + "Name": "Second", + "Type": "*TestSource" + }, + "Packages": [ + { + "Id": "test2", + "Version": "1.0" + } + ] + } + ] + })"); + + auto parsed = PackagesJson::TryParseJson(json); + REQUIRE(parsed.has_value()); + + PackageCollection::Source source1; + source1.Details.Name = "First"; + source1.Details.Arg = "//firstSource"; + source1.Details.Type = "Microsoft.PreIndexed.Package"; + source1.Details.Identifier = "Id1"; + source1.Packages.emplace_back(LocIndString{ "test"sv }, Version{ "" }, Channel{ "" }); + + PackageCollection::Source source2; + source2.Details.Name = "Second"; + source2.Details.Arg = "//secondSource"; + source2.Details.Type = "*TestSource"; + source2.Details.Identifier = "Id2"; + source2.Packages.emplace_back(LocIndString{ "test2"sv }, Version{ "1.0" }, Channel{ "" }); + + PackageCollection expected + { + "1.0.0", + std::vector{ source1, source2 } + }; + + ValidateEqualCollections(parsed.value(), expected); +} + +TEST_CASE("PackageCollection_Read_RepeatedSource", "[PackageCollection]") +{ + auto json = ParseJsonString(R"( + { + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "WinGetVersion": "1.0.0", + "Sources": [ + { + "SourceDetails": { + "Argument": "//firstSource", + "Identifier": "Id1", + "Name": "First", + "Type": "Microsoft.PreIndexed.Package" + }, + "Packages": [ + { + "Id": "test" + } + ] + }, + { + "SourceDetails": { + "Argument": "//secondSource", + "Identifier": "Id2", + "Name": "Second", + "Type": "*TestSource" + }, + "Packages": [ + { + "Id": "test2", + "Version": "1.0" + } + ] + }, + { + "SourceDetails": { + "Argument": "//secondSource", + "Identifier": "Id2", + "Name": "Second", + "Type": "*TestSource" + }, + "Packages": [ + { + "Id": "test3", + "Version": "1.2" + } + ] + } + ] + })"); + + auto parsed = PackagesJson::TryParseJson(json); + REQUIRE(parsed.has_value()); + + PackageCollection::Source source1; + source1.Details.Name = "First"; + source1.Details.Arg = "//firstSource"; + source1.Details.Type = "Microsoft.PreIndexed.Package"; + source1.Details.Identifier = "Id1"; + source1.Packages.emplace_back(LocIndString{ "test"sv }, Version{ "" }, Channel{ "" }); + + PackageCollection::Source source2; + source2.Details.Name = "Second"; + source2.Details.Arg = "//secondSource"; + source2.Details.Type = "*TestSource"; + source2.Details.Identifier = "Id2"; + source2.Packages.emplace_back(LocIndString{ "test2"sv }, Version{ "1.0" }, Channel{ "" }); + source2.Packages.emplace_back(LocIndString{ "test3"sv }, Version{ "1.2" }, Channel{ "" }); + + PackageCollection expected + { + "1.0.0", + std::vector{ source1, source2 } + }; + + ValidateEqualCollections(parsed.value(), expected); +} \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/ImportFile-Bad-Invalid.json b/src/AppInstallerCLITests/TestData/ImportFile-Bad-Invalid.json new file mode 100644 index 0000000000..6bfedac997 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/ImportFile-Bad-Invalid.json @@ -0,0 +1 @@ +"A valid JSON file that does not conform to the schema" \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/ImportFile-Bad-Malformed.json b/src/AppInstallerCLITests/TestData/ImportFile-Bad-Malformed.json new file mode 100644 index 0000000000..7e50697753 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/ImportFile-Bad-Malformed.json @@ -0,0 +1 @@ +This is not a valid JSON file. \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownPackage.json b/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownPackage.json new file mode 100644 index 0000000000..4ea305296a --- /dev/null +++ b/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownPackage.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerCliTest.TestExeInstaller", + "Version": "2.0.0.0" + }, + { + "Id": "MissingPackage", + "Version": "1.0.0.0" + } + ], + "SourceDetails": { + "Argument": "//arg", + "Identifier": "*TestSource", + "Name": "TestSource", + "Type": "Microsoft.TestSource" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownPackageVersion.json b/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownPackageVersion.json new file mode 100644 index 0000000000..4d2b076e5a --- /dev/null +++ b/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownPackageVersion.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerCliTest.TestExeInstaller", + "Version": "4.3.2.1" + } + ], + "SourceDetails": { + "Argument": "//arg", + "Identifier": "*TestSource", + "Name": "TestSource", + "Type": "Microsoft.TestSource" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownSource.json b/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownSource.json new file mode 100644 index 0000000000..213cc6eff2 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/ImportFile-Bad-UnknownSource.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerCliTest.TestExeInstaller", + "Version": "1.0.0.0" + } + ], + "SourceDetails": { + "Argument": "//bad", + "Identifier": "*BadSource", + "Name": "TestSource", + "Type": "Microsoft.TestSource" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/ImportFile-Good-AlreadyInstalled.json b/src/AppInstallerCLITests/TestData/ImportFile-Good-AlreadyInstalled.json new file mode 100644 index 0000000000..4463587609 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/ImportFile-Good-AlreadyInstalled.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerCliTest.TestExeInstaller", + "Version": "1.0.0.0" + } + ], + "SourceDetails": { + "Argument": "//arg", + "Identifier": "*TestSource", + "Name": "TestSource", + "Type": "Microsoft.TestSource" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestData/ImportFile-Good.json b/src/AppInstallerCLITests/TestData/ImportFile-Good.json new file mode 100644 index 0000000000..42c46904d6 --- /dev/null +++ b/src/AppInstallerCLITests/TestData/ImportFile-Good.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://aka.ms/winget-packages.schema.1.0.json", + "CreationDate": "2021-01-01T12:00:00.000", + "Sources": [ + { + "Packages": [ + { + "Id": "AppInstallerCliTest.TestExeInstaller", + "Version": "2.0.0.0" + }, + { + "Id": "AppInstallerCliTest.TestMsixInstaller", + "Version": "2.0.0.0" + } + ], + "SourceDetails": { + "Argument": "//arg", + "Identifier": "*TestSource", + "Name": "TestSource", + "Type": "Microsoft.TestSource" + } + } + ], + "WinGetVersion": "1.0.0" +} \ No newline at end of file diff --git a/src/AppInstallerCLITests/TestSource.cpp b/src/AppInstallerCLITests/TestSource.cpp index 5fc1ca3513..81c6279ab5 100644 --- a/src/AppInstallerCLITests/TestSource.cpp +++ b/src/AppInstallerCLITests/TestSource.cpp @@ -12,6 +12,9 @@ namespace TestCommon TestPackageVersion::TestPackageVersion(const Manifest& manifest, MetadataMap installationMetadata) : VersionManifest(manifest), Metadata(std::move(installationMetadata)) {} + TestPackageVersion::TestPackageVersion(const Manifest& manifest, std::weak_ptr source) : + VersionManifest(manifest), Source(source) {} + TestPackageVersion::LocIndString TestPackageVersion::GetProperty(PackageVersionProperty property) const { switch (property) @@ -59,7 +62,7 @@ namespace TestCommon std::shared_ptr TestPackageVersion::GetSource() const { - return Source; + return Source.lock(); } TestPackageVersion::MetadataMap TestPackageVersion::GetMetadata() const @@ -80,20 +83,20 @@ namespace TestCommon } } - TestPackage::TestPackage(const std::vector& available) + TestPackage::TestPackage(const std::vector& available, std::weak_ptr source) { for (const auto& manifest : available) { - AvailableVersions.emplace_back(TestPackageVersion::Make(manifest)); + AvailableVersions.emplace_back(TestPackageVersion::Make(manifest, source)); } } - TestPackage::TestPackage(const Manifest& installed, MetadataMap installationMetadata, const std::vector& available) : + TestPackage::TestPackage(const Manifest& installed, MetadataMap installationMetadata, const std::vector& available, std::weak_ptr source) : InstalledVersion(TestPackageVersion::Make(installed, std::move(installationMetadata))) { for (const auto& manifest : available) { - AvailableVersions.emplace_back(TestPackageVersion::Make(manifest)); + AvailableVersions.emplace_back(TestPackageVersion::Make(manifest, source)); } } @@ -185,7 +188,7 @@ namespace TestCommon const std::string& TestSource::GetIdentifier() const { - return Identifier; + return Details.Identifier; } SearchResult TestSource::Search(const SearchRequest& request) const diff --git a/src/AppInstallerCLITests/TestSource.h b/src/AppInstallerCLITests/TestSource.h index daee38ca28..59f05e6dbe 100644 --- a/src/AppInstallerCLITests/TestSource.h +++ b/src/AppInstallerCLITests/TestSource.h @@ -17,7 +17,8 @@ namespace TestCommon using LocIndString = AppInstaller::Utility::LocIndString; using MetadataMap = AppInstaller::Repository::IPackageVersion::Metadata; - TestPackageVersion(const Manifest& manifest, MetadataMap installationMetadata = {}); + TestPackageVersion(const Manifest& manifest, std::weak_ptr source = {}); + TestPackageVersion(const Manifest& manifest, MetadataMap installationMetadata); template static std::shared_ptr Make(Args&&... args) @@ -33,7 +34,7 @@ namespace TestCommon Manifest VersionManifest; MetadataMap Metadata; - std::shared_ptr Source; + std::weak_ptr Source; protected: static void AddFoldedIfHasValueAndNotPresent(const AppInstaller::Utility::NormalizedString& value, std::vector& target); @@ -43,14 +44,15 @@ namespace TestCommon struct TestPackage : public AppInstaller::Repository::IPackage { using Manifest = AppInstaller::Manifest::Manifest; + using ISource = AppInstaller::Repository::ISource; using LocIndString = AppInstaller::Utility::LocIndString; using MetadataMap = TestPackageVersion::MetadataMap; // Create a package with only available versions using these manifests. - TestPackage(const std::vector& available); + TestPackage(const std::vector& available, std::weak_ptr source = {}); // Create a package with an installed version, metadata, and optionally available versions. - TestPackage(const Manifest& installed, MetadataMap installationMetadata, const std::vector& available = {}); + TestPackage(const Manifest& installed, MetadataMap installationMetadata, const std::vector& available = {}, std::weak_ptr source = {}); template static std::shared_ptr Make(Args&&... args) @@ -70,15 +72,14 @@ namespace TestCommon }; // An ISource implementation for use across the test code. - struct TestSource : public AppInstaller::Repository::ISource + struct TestSource : public AppInstaller::Repository::ISource, public std::enable_shared_from_this { const AppInstaller::Repository::SourceDetails& GetDetails() const override; const std::string& GetIdentifier() const override; AppInstaller::Repository::SearchResult Search(const AppInstaller::Repository::SearchRequest& request) const override; bool IsComposite() const override; - AppInstaller::Repository::SourceDetails Details; - std::string Identifier = "*TestSource"; + AppInstaller::Repository::SourceDetails Details = { "TestSource", "Microsoft.TestSource", "//arg", "", "*TestSource" }; std::function SearchFunction; bool Composite = false; }; diff --git a/src/AppInstallerCLITests/WorkFlow.cpp b/src/AppInstallerCLITests/WorkFlow.cpp index e236c3eba8..b98d7084c7 100644 --- a/src/AppInstallerCLITests/WorkFlow.cpp +++ b/src/AppInstallerCLITests/WorkFlow.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -65,7 +68,7 @@ namespace auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); result.Matches.emplace_back( ResultMatch( - TestPackage::Make(std::vector{ manifest }), + TestPackage::Make(std::vector{ manifest }, this->shared_from_this()), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "TestQueryReturnOne"))); } else if (input == "TestQueryReturnTwo") @@ -73,13 +76,13 @@ namespace auto manifest = YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); result.Matches.emplace_back( ResultMatch( - TestPackage::Make(std::vector{ manifest }), + TestPackage::Make(std::vector{ manifest }, this->shared_from_this()), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "TestQueryReturnTwo"))); auto manifest2 = YamlParser::CreateFromPath(TestDataFile("Manifest-Good.yaml")); result.Matches.emplace_back( ResultMatch( - TestPackage::Make(std::vector{ manifest2 }), + TestPackage::Make(std::vector{ manifest2 }, this->shared_from_this()), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "TestQueryReturnTwo"))); } @@ -119,7 +122,8 @@ namespace { PackageVersionMetadata::StandardUninstallCommand, "C:\\uninstall.exe" }, { PackageVersionMetadata::SilentUninstallCommand, "C:\\uninstall.exe /silence" }, }, - std::vector{ manifest2, manifest } + std::vector{ manifest2, manifest }, + this->shared_from_this() ), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "AppInstallerCliTest.TestExeInstaller"))); } @@ -133,7 +137,8 @@ namespace TestPackage::Make( manifest, TestPackage::MetadataMap{ { PackageVersionMetadata::InstalledType, "Msix" } }, - std::vector{ manifest2, manifest } + std::vector{ manifest2, manifest }, + this->shared_from_this() ), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "AppInstallerCliTest.TestMsixInstaller"))); } @@ -146,7 +151,8 @@ namespace TestPackage::Make( manifest, TestPackage::MetadataMap{ { PackageVersionMetadata::InstalledType, "MSStore" } }, - std::vector{ manifest } + std::vector{ manifest }, + this->shared_from_this() ), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "AppInstallerCliTest.TestMSStoreInstaller"))); } @@ -160,7 +166,8 @@ namespace TestPackage::Make( manifest2, TestPackage::MetadataMap{ { PackageVersionMetadata::InstalledType, "Exe" } }, - std::vector{ manifest2, manifest } + std::vector{ manifest2, manifest }, + this->shared_from_this() ), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "AppInstallerCliTest.TestExeInstaller"))); } @@ -174,7 +181,8 @@ namespace TestPackage::Make( manifest, TestPackage::MetadataMap{ { PackageVersionMetadata::InstalledType, "Msix" } }, - std::vector{ manifest2, manifest } + std::vector{ manifest2, manifest }, + this->shared_from_this() ), PackageMatchFilter(PackageMatchField::Id, MatchType::Exact, "AppInstallerCliTest.TestExeInstaller"))); } @@ -294,6 +302,19 @@ void OverrideForCompositeInstalledSource(TestContext& context) } }); } +void OverrideForImportSource(TestContext& context) +{ + context.Override({ "OpenPredefinedSource", [](TestContext& context) + { + context.Add({}); + } }); + + context.Override({ Workflow::OpenSourcesForImport, [](TestContext& context) + { + context.Add(std::vector>{ std::make_shared() }); + } }); +} + void OverrideForUpdateInstallerMotw(TestContext& context) { context.Override({ UpdateInstallerFileMotwIfApplicable, [](TestContext&) @@ -1068,6 +1089,240 @@ TEST_CASE("UninstallFlow_UninstallExeNotFound", "[UninstallFlow][workflow]") REQUIRE(context.GetTerminationHR() == APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND); } +TEST_CASE("ExportFlow_ExportAll", "[ExportFlow][workflow]") +{ + TestCommon::TempFile exportResultPath("TestExport.json"); + + std::ostringstream exportOutput; + TestContext context{ exportOutput, std::cin }; + OverrideForCompositeInstalledSource(context); + context.Args.AddArg(Execution::Args::Type::OutputFile, exportResultPath); + + ExportCommand exportCommand({}); + exportCommand.Execute(context); + INFO(exportOutput.str()); + + // Verify contents of exported collection + const auto& exportedCollection = context.Get(); + REQUIRE(exportedCollection.Sources.size() == 1); + REQUIRE(exportedCollection.Sources[0].Details.Identifier == "*TestSource"); + + const auto& exportedPackages = exportedCollection.Sources[0].Packages; + REQUIRE(exportedPackages.size() == 3); + REQUIRE(exportedPackages.end() != std::find_if(exportedPackages.begin(), exportedPackages.end(), [](const auto& p) + { + return p.Id == "AppInstallerCliTest.TestExeInstaller" && p.VersionAndChannel.GetVersion().ToString().empty(); + })); + REQUIRE(exportedPackages.end() != std::find_if(exportedPackages.begin(), exportedPackages.end(), [](const auto& p) + { + return p.Id == "AppInstallerCliTest.TestMsixInstaller" && p.VersionAndChannel.GetVersion().ToString().empty(); + })); + REQUIRE(exportedPackages.end() != std::find_if(exportedPackages.begin(), exportedPackages.end(), [](const auto& p) + { + return p.Id == "AppInstallerCliTest.TestMSStoreInstaller" && p.VersionAndChannel.GetVersion().ToString().empty(); + })); +} + +TEST_CASE("ExportFlow_ExportAll_WithVersions", "[ExportFlow][workflow]") +{ + TestCommon::TempFile exportResultPath("TestExport.json"); + + std::ostringstream exportOutput; + TestContext context{ exportOutput, std::cin }; + OverrideForCompositeInstalledSource(context); + context.Args.AddArg(Execution::Args::Type::OutputFile, exportResultPath); + context.Args.AddArg(Execution::Args::Type::IncludeVersions); + + ExportCommand exportCommand({}); + exportCommand.Execute(context); + INFO(exportOutput.str()); + + // Verify contents of exported collection + const auto& exportedCollection = context.Get(); + REQUIRE(exportedCollection.Sources.size() == 1); + REQUIRE(exportedCollection.Sources[0].Details.Identifier == "*TestSource"); + + const auto& exportedPackages = exportedCollection.Sources[0].Packages; + REQUIRE(exportedPackages.size() == 3); + REQUIRE(exportedPackages.end() != std::find_if(exportedPackages.begin(), exportedPackages.end(), [](const auto& p) + { + return p.Id == "AppInstallerCliTest.TestExeInstaller" && p.VersionAndChannel.GetVersion().ToString() == "1.0.0.0"; + })); + REQUIRE(exportedPackages.end() != std::find_if(exportedPackages.begin(), exportedPackages.end(), [](const auto& p) + { + return p.Id == "AppInstallerCliTest.TestMsixInstaller" && p.VersionAndChannel.GetVersion().ToString() == "1.0.0.0"; + })); + REQUIRE(exportedPackages.end() != std::find_if(exportedPackages.begin(), exportedPackages.end(), [](const auto& p) + { + return p.Id == "AppInstallerCliTest.TestMSStoreInstaller" && p.VersionAndChannel.GetVersion().ToString() == "Latest"; + })); +} + +TEST_CASE("ImportFlow_Successful", "[ImportFlow][workflow]") +{ + TestCommon::TempFile exeInstallResultPath("TestExeInstalled.txt"); + TestCommon::TempFile msixInstallResultPath("TestMsixInstalled.txt"); + + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + OverrideForImportSource(context); + OverrideForMSIX(context); + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Good.json").GetPath().string()); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Verify all packages were installed + REQUIRE(std::filesystem::exists(exeInstallResultPath.GetPath())); + REQUIRE(std::filesystem::exists(msixInstallResultPath.GetPath())); +} + +TEST_CASE("ImportFlow_PackageAlreadyInstalled", "[ImportFlow][workflow]") +{ + TestCommon::TempFile exeInstallResultPath("TestExeInstalled.txt"); + + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + OverrideForImportSource(context); + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Good-AlreadyInstalled.json").GetPath().string()); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Exe should not have been installed again + REQUIRE(!std::filesystem::exists(exeInstallResultPath.GetPath())); + REQUIRE(importOutput.str().find(Resource::LocString(Resource::String::ImportPackageAlreadyInstalled).get()) != std::string::npos); +} + +TEST_CASE("ImportFlow_IgnoreVersions", "[ImportFlow][workflow]") +{ + TestCommon::TempFile exeInstallResultPath("TestExeInstalled.txt"); + + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + OverrideForImportSource(context); + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Good-AlreadyInstalled.json").GetPath().string()); + context.Args.AddArg(Execution::Args::Type::IgnoreVersions); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Specified version is already installed. It should have been updated since we ignored the version. + REQUIRE(std::filesystem::exists(exeInstallResultPath.GetPath())); +} + +TEST_CASE("ImportFlow_MissingSource", "[ImportFlow][workflow]") +{ + TestCommon::TempFile exeInstallResultPath("TestExeInstalled.txt"); + + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Bad-UnknownSource.json").GetPath().string()); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Installer should not be called + REQUIRE(!std::filesystem::exists(exeInstallResultPath.GetPath())); + REQUIRE(importOutput.str().find(Resource::LocString(Resource::String::ImportSourceNotInstalled).get()) != std::string::npos); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_SOURCE_NAME_DOES_NOT_EXIST); +} + +TEST_CASE("ImportFlow_MissingPackage", "[ImportFlow][workflow]") +{ + TestCommon::TempFile exeInstallResultPath("TestExeInstalled.txt"); + + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + OverrideForImportSource(context); + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Bad-UnknownPackage.json").GetPath().string()); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Installer should not be called + REQUIRE(!std::filesystem::exists(exeInstallResultPath.GetPath())); + REQUIRE(importOutput.str().find(Resource::LocString(Resource::String::ImportSearchFailed).get()) != std::string::npos); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND); +} + +TEST_CASE("ImportFlow_IgnoreMissingPackage", "[ImportFlow][workflow]") +{ + TestCommon::TempFile exeInstallResultPath("TestExeInstalled.txt"); + + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + OverrideForImportSource(context); + OverrideForShellExecute(context); + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Bad-UnknownPackage.json").GetPath().string()); + context.Args.AddArg(Execution::Args::Type::IgnoreUnavailable); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Verify installer was called for the package that was available. + REQUIRE(std::filesystem::exists(exeInstallResultPath.GetPath())); + REQUIRE(importOutput.str().find(Resource::LocString(Resource::String::ImportSearchFailed).get()) != std::string::npos); +} + +TEST_CASE("ImportFlow_MissingVersion", "[ImportFlow][workflow]") +{ + TestCommon::TempFile exeInstallResultPath("TestExeInstalled.txt"); + + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + OverrideForImportSource(context); + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Bad-UnknownPackageVersion.json").GetPath().string()); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Installer should not be called + REQUIRE(!std::filesystem::exists(exeInstallResultPath.GetPath())); + REQUIRE(importOutput.str().find(Resource::LocString(Resource::String::ImportSearchFailed).get()) != std::string::npos); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND); +} + +TEST_CASE("ImportFlow_MalformedJsonFile", "[ImportFlow][workflow]") +{ + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Bad-Malformed.json").GetPath().string()); + + ImportCommand importCommand({}); + importCommand.Execute(context); + INFO(importOutput.str()); + + // Command should have failed + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_JSON_INVALID_FILE); +} + +TEST_CASE("ImportFlow_InvalidJsonFile", "[ImportFlow][workflow]") +{ + std::ostringstream importOutput; + TestContext context{ importOutput, std::cin }; + context.Args.AddArg(Execution::Args::Type::ImportFile, TestDataFile("ImportFile-Bad-Invalid.json").GetPath().string()); + + ImportCommand importCommand({}); + // TODO: Enable when we have schema validation + /* + importCommand.Execute(context); + INFO(importOutput.str()); + + // Command should have failed + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_JSON_INVALID_FILE); + */ +} + void VerifyMotw(const std::filesystem::path& testFile, DWORD zone) { std::filesystem::path motwFile(testFile); diff --git a/src/AppInstallerCommonCore/DateTime.cpp b/src/AppInstallerCommonCore/DateTime.cpp index 238ffc462e..06ca51455b 100644 --- a/src/AppInstallerCommonCore/DateTime.cpp +++ b/src/AppInstallerCommonCore/DateTime.cpp @@ -6,7 +6,7 @@ namespace AppInstaller::Utility { // If moved to C++20, this can be replaced with standard library implementations. - void OutputTimePoint(std::ostream& stream, const std::chrono::system_clock::time_point& time) + void OutputTimePoint(std::ostream& stream, const std::chrono::system_clock::time_point& time, bool useRFC3339) { using namespace std::chrono; @@ -14,12 +14,14 @@ namespace AppInstaller::Utility auto tt = system_clock::to_time_t(time); _localtime64_s(&localTime, &tt); - // Don't bother with fill chars for dates, as most of the time this won't be an issue. - stream << (1900 + localTime.tm_year) << '-' << (1 + localTime.tm_mon) << '-' << localTime.tm_mday << ' ' + stream + << std::setw(4) << (1900 + localTime.tm_year) << '-' + << std::setw(2) << (1 + localTime.tm_mon) << '-' + << std::setw(2) << localTime.tm_mday << (useRFC3339 ? 'T' : ' ') << std::setw(2) << std::setfill('0') << localTime.tm_hour << ':' << std::setw(2) << std::setfill('0') << localTime.tm_min << ':' << std::setw(2) << std::setfill('0') << localTime.tm_sec << '.'; - + // Get partial seconds auto sinceEpoch = time.time_since_epoch(); auto leftoverMillis = duration_cast(sinceEpoch) - duration_cast(sinceEpoch); diff --git a/src/AppInstallerCommonCore/Errors.cpp b/src/AppInstallerCommonCore/Errors.cpp index ce849d984a..3052b82527 100644 --- a/src/AppInstallerCommonCore/Errors.cpp +++ b/src/AppInstallerCommonCore/Errors.cpp @@ -107,12 +107,20 @@ namespace AppInstaller return "Installer failed security check"; case APPINSTALLER_CLI_ERROR_DOWNLOAD_SIZE_MISMATCH: return "Download size does not match expected content length"; + case APPINSTALLER_CLI_ERROR_NO_UNINSTALL_INFO_FOUND: + return "Uninstall command not found"; + case APPINSTALLER_CLI_ERROR_EXEC_UNINSTALL_COMMAND_FAILED: + return "Running uninstall command failed"; case APPINSTALLER_CLI_ERROR_ICU_BREAK_ITERATOR_ERROR: return "ICU break iterator error"; case APPINSTALLER_CLI_ERROR_ICU_CASEMAP_ERROR: return "ICU casemap error"; case APPINSTALLER_CLI_ERROR_ICU_REGEX_ERROR: return "ICU regex error"; + case APPINSTALLER_CLI_ERROR_IMPORT_INSTALL_FAILED: + return "Failed to install one or more imported packages"; + case APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND: + return "Could not find one or more requested packages"; default: return "Unknown Error Code"; } diff --git a/src/AppInstallerCommonCore/ExperimentalFeature.cpp b/src/AppInstallerCommonCore/ExperimentalFeature.cpp index a5b9b437cb..55a8e93c41 100644 --- a/src/AppInstallerCommonCore/ExperimentalFeature.cpp +++ b/src/AppInstallerCommonCore/ExperimentalFeature.cpp @@ -27,6 +27,8 @@ namespace AppInstaller::Settings return User().Get(); case Feature::ExperimentalUninstall: return User().Get(); + case Feature::ExperimentalImportExport: + return User().Get(); default: THROW_HR(E_UNEXPECTED); } @@ -48,6 +50,8 @@ namespace AppInstaller::Settings return ExperimentalFeature{ "Upgrade Command", "upgrade", "https://aka.ms/winget-settings", Feature::ExperimentalUpgrade }; case Feature::ExperimentalUninstall: return ExperimentalFeature{ "Uninstall Command", "uninstall", "https://aka.ms/winget-settings", Feature::ExperimentalUninstall }; + case Feature::ExperimentalImportExport: + return ExperimentalFeature{ "Import & Export Commands", "importExport", "https://aka.ms/winget-settings", Feature::ExperimentalImportExport }; default: THROW_HR(E_UNEXPECTED); } diff --git a/src/AppInstallerCommonCore/Public/AppInstallerDateTime.h b/src/AppInstallerCommonCore/Public/AppInstallerDateTime.h index b4999801ce..f84be6d4dd 100644 --- a/src/AppInstallerCommonCore/Public/AppInstallerDateTime.h +++ b/src/AppInstallerCommonCore/Public/AppInstallerDateTime.h @@ -10,7 +10,7 @@ namespace AppInstaller::Utility // Writes the given time to the given stream. // Assumes that system_clock uses Linux epoch (as required by C++20 standard). // Time is also assumed to be after the epoch. - void OutputTimePoint(std::ostream& stream, const std::chrono::system_clock::time_point& time); + void OutputTimePoint(std::ostream& stream, const std::chrono::system_clock::time_point& time, bool useRFC3339 = false); // Gets the current time as a string. Can be used as a file name. std::string GetCurrentTimeForFilename(); diff --git a/src/AppInstallerCommonCore/Public/AppInstallerErrors.h b/src/AppInstallerCommonCore/Public/AppInstallerErrors.h index 8cb137bb97..33cc55e8a5 100644 --- a/src/AppInstallerCommonCore/Public/AppInstallerErrors.h +++ b/src/AppInstallerCommonCore/Public/AppInstallerErrors.h @@ -64,6 +64,9 @@ #define APPINSTALLER_CLI_ERROR_ICU_BREAK_ITERATOR_ERROR ((HRESULT)0x8A150031) #define APPINSTALLER_CLI_ERROR_ICU_CASEMAP_ERROR ((HRESULT)0x8A150032) #define APPINSTALLER_CLI_ERROR_ICU_REGEX_ERROR ((HRESULT)0x8A150033) +#define APPINSTALLER_CLI_ERROR_IMPORT_INSTALL_FAILED ((HRESULT)0x8a150034) +#define APPINSTALLER_CLI_ERROR_NOT_ALL_PACKAGES_FOUND ((HRESULT)0x8a150035) +#define APPINSTALLER_CLI_ERROR_JSON_INVALID_FILE ((HRESULT)0x8a150036) namespace AppInstaller { diff --git a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h index 23ebf50206..2d09eea653 100644 --- a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h +++ b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h @@ -25,6 +25,7 @@ namespace AppInstaller::Settings ExperimentalList = 0x8, ExperimentalUpgrade = 0x10, ExperimentalUninstall = 0x20, + ExperimentalImportExport = 0x40, Max, // This MUST always be last }; diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 5475012d2a..3b33c4d9fe 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -52,6 +52,7 @@ namespace AppInstaller::Settings EFList, EFExperimentalUpgrade, EFUninstall, + EFImportExport, Max }; @@ -86,7 +87,8 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalMSStore, bool, bool, false, ".experimentalFeatures.experimentalMSStore"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFList, bool, bool, false, ".experimentalFeatures.list"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFExperimentalUpgrade, bool, bool, false, ".experimentalFeatures.upgrade"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFUninstall, bool, bool, false, "experimentalFeatures.uninstall"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EFUninstall, bool, bool, false, ".experimentalFeatures.uninstall"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EFImportExport, bool, bool, false, ".experimentalFeatures.importExport"sv); // Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types. template diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index cf030387e1..d9d1308d0c 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -126,7 +126,7 @@ namespace AppInstaller::Settings } else { - AICLI_LOG(Core, Info, << "Setting " << path <<" not found. Using default"); + AICLI_LOG(Core, Info, << "Setting " << path << " not found. Using default"); } } @@ -216,6 +216,12 @@ namespace AppInstaller::Settings { return value; } + + std::optional::value_t> + SettingMapping::Validate(const SettingMapping::json_t& value) + { + return value; + } } UserSettings::UserSettings() : m_type(UserSettingsType::Default) diff --git a/src/AppInstallerRepositoryCore/CompositeSource.cpp b/src/AppInstallerRepositoryCore/CompositeSource.cpp index a48d4de937..cdf0a64c94 100644 --- a/src/AppInstallerRepositoryCore/CompositeSource.cpp +++ b/src/AppInstallerRepositoryCore/CompositeSource.cpp @@ -364,10 +364,10 @@ namespace AppInstaller::Repository }; } - CompositeSource::CompositeSource(std::string identifier) : - m_identifier(identifier) + CompositeSource::CompositeSource(std::string identifier) { m_details.Name = "CompositeSource"; + m_details.Identifier = std::move(identifier); } const SourceDetails& CompositeSource::GetDetails() const @@ -377,7 +377,7 @@ namespace AppInstaller::Repository const std::string& CompositeSource::GetIdentifier() const { - return m_identifier; + return m_details.Identifier; } // The composite search needs to take several steps to get results, and due to the @@ -404,9 +404,10 @@ namespace AppInstaller::Repository m_availableSources.emplace_back(std::move(source)); } - void CompositeSource::SetInstalledSource(std::shared_ptr source) + void CompositeSource::SetInstalledSource(std::shared_ptr source, CompositeSearchBehavior searchBehavior) { m_installedSource = std::move(source); + m_searchBehavior = searchBehavior; } // An installed search first finds all installed packages that match the request, then correlates with available sources. @@ -466,7 +467,7 @@ namespace AppInstaller::Repository { auto id = installedVersion->GetProperty(PackageVersionProperty::Id); - AICLI_LOG(Repo, Info, + AICLI_LOG(Repo, Info, << "Found multiple matches for installed package [" << id << "] in source [" << source->GetIdentifier() << "] when searching for [" << systemReferenceSearch.ToString() << "]"); // More than one match found for the system reference; run some heuristics to check for a match @@ -515,6 +516,7 @@ namespace AppInstaller::Repository // If no package was found that was already in the results, do a correlation lookup with the installed // source to create a new composite package entry if we find any packages there. + bool foundInstalledMatch = false; if (packageData && !packageData->SystemReferenceStrings.empty()) { // Create a search request to run against the installed source @@ -532,9 +534,16 @@ namespace AppInstaller::Repository auto installedVersion = crossRef.Package->GetInstalledVersion(); auto installedPackageData = result.ReserveInstalledPackageSlot(installedVersion.get()); + foundInstalledMatch = true; result.Matches.emplace_back(std::make_shared(std::move(crossRef.Package), std::move(match.Package)), match.MatchCriteria); } } + + // If there was no correlation for this package, add it without one. + if (m_searchBehavior == CompositeSearchBehavior::AllPackages && !foundInstalledMatch) + { + result.Matches.push_back(std::move(match)); + } } SortResultMatches(result.Matches); diff --git a/src/AppInstallerRepositoryCore/CompositeSource.h b/src/AppInstallerRepositoryCore/CompositeSource.h index 905c89a98a..1b5dc1e92e 100644 --- a/src/AppInstallerRepositoryCore/CompositeSource.h +++ b/src/AppInstallerRepositoryCore/CompositeSource.h @@ -41,7 +41,7 @@ namespace AppInstaller::Repository void AddAvailableSource(std::shared_ptr source); // Sets the installed source to be composited. - void SetInstalledSource(std::shared_ptr source); + void SetInstalledSource(std::shared_ptr source, CompositeSearchBehavior searchBehavior = CompositeSearchBehavior::Installed); private: // Performs a search when an installed source is present. @@ -57,7 +57,7 @@ namespace AppInstaller::Repository std::shared_ptr m_installedSource; std::vector> m_availableSources; SourceDetails m_details; - std::string m_identifier; + CompositeSearchBehavior m_searchBehavior; }; } diff --git a/src/AppInstallerRepositoryCore/Microsoft/PreIndexedPackageSourceFactory.cpp b/src/AppInstallerRepositoryCore/Microsoft/PreIndexedPackageSourceFactory.cpp index cd6646cc82..0948198217 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/PreIndexedPackageSourceFactory.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/PreIndexedPackageSourceFactory.cpp @@ -86,6 +86,7 @@ namespace AppInstaller::Repository::Microsoft AICLI_LOG(Repo, Info, << "Found package full name: " << details.Name << " => " << fullName); details.Data = Msix::GetPackageFamilyNameFromFullName(fullName); + details.Identifier = Msix::GetPackageFamilyNameFromFullName(fullName); auto lock = Synchronization::CrossProcessReaderWriteLock::LockForWrite(CreateNameForCPRWL(details)); @@ -146,6 +147,8 @@ namespace AppInstaller::Repository::Microsoft SQLiteIndex index = SQLiteIndex::Open(indexLocation.u8string(), SQLiteIndex::OpenDisposition::Immutable); + // We didn't use to store the source identifier, so we compute it here in case it's + // missing from the details. return std::make_shared(details, GetPackageFamilyNameFromDetails(details), std::move(index), std::move(lock)); } @@ -252,6 +255,8 @@ namespace AppInstaller::Repository::Microsoft SQLiteIndex index = SQLiteIndex::Open(packageLocation.u8string(), SQLiteIndex::OpenDisposition::Read); + // We didn't use to store the source identifier, so we compute it here in case it's + // missing from the details. return std::make_shared(details, GetPackageFamilyNameFromDetails(details), std::move(index), std::move(lock)); } diff --git a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.cpp b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.cpp index 4520ab48d9..93274ffb2c 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.cpp +++ b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.cpp @@ -293,8 +293,9 @@ namespace AppInstaller::Repository::Microsoft } SQLiteIndexSource::SQLiteIndexSource(const SourceDetails& details, std::string identifier, SQLiteIndex&& index, Synchronization::CrossProcessReaderWriteLock&& lock, bool isInstalledSource) : - m_details(details), m_identifier(std::move(identifier)), m_lock(std::move(lock)), m_isInstalled(isInstalledSource), m_index(std::move(index)) + m_details(details), m_lock(std::move(lock)), m_isInstalled(isInstalledSource), m_index(std::move(index)) { + m_details.Identifier = std::move(identifier); } const SourceDetails& SQLiteIndexSource::GetDetails() const @@ -304,7 +305,7 @@ namespace AppInstaller::Repository::Microsoft const std::string& SQLiteIndexSource::GetIdentifier() const { - return m_identifier; + return m_details.Identifier; } SearchResult SQLiteIndexSource::Search(const SearchRequest& request) const diff --git a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.h b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.h index 69f854d043..71b7821a2d 100644 --- a/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.h +++ b/src/AppInstallerRepositoryCore/Microsoft/SQLiteIndexSource.h @@ -39,7 +39,6 @@ namespace AppInstaller::Repository::Microsoft private: SourceDetails m_details; - std::string m_identifier; Synchronization::CrossProcessReaderWriteLock m_lock; bool m_isInstalled; SQLiteIndex m_index; diff --git a/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySource.h b/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySource.h index 86176a0220..c938c9faba 100644 --- a/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySource.h +++ b/src/AppInstallerRepositoryCore/Public/AppInstallerRepositorySource.h @@ -44,9 +44,12 @@ namespace AppInstaller::Repository // The argument used when adding the source. std::string Arg; - // The sources extra data string. + // The source's extra data string. std::string Data; + // The source's unique identifier. + std::string Identifier; + // The last time that this source was updated. std::chrono::system_clock::time_point LastUpdateTime = {}; @@ -124,8 +127,23 @@ namespace AppInstaller::Repository // These sources are not under the direct control of the user, such as packages installed on the system. std::shared_ptr OpenPredefinedSource(PredefinedSource source, IProgressCallback& progress); + // Search behavior for composite sources. + // Only relevant for composite sources with an installed source, not for aggregates of multiple available sources. + // Installed and available packages in the result are always correlated when possible. + enum class CompositeSearchBehavior + { + // Search only installed packages. + Installed, + // Search both installed and available packages. + AllPackages, + }; + // Creates a source that merges the installed packages with the given available packages. - std::shared_ptr CreateCompositeSource(const std::shared_ptr& installedSource, const std::shared_ptr& availableSource); + // The source can search for installed packages only, or also include non-installed available packages. + std::shared_ptr CreateCompositeSource( + const std::shared_ptr& installedSource, + const std::shared_ptr& availableSource, + CompositeSearchBehavior searchBehavior = CompositeSearchBehavior::Installed); // Updates an existing source. // Return value indicates whether the named source was found. diff --git a/src/AppInstallerRepositoryCore/RepositorySource.cpp b/src/AppInstallerRepositoryCore/RepositorySource.cpp index 5471d0653a..1aedf1e5ac 100644 --- a/src/AppInstallerRepositoryCore/RepositorySource.cpp +++ b/src/AppInstallerRepositoryCore/RepositorySource.cpp @@ -20,6 +20,7 @@ namespace AppInstaller::Repository constexpr std::string_view s_SourcesYaml_Source_Type = "Type"sv; constexpr std::string_view s_SourcesYaml_Source_Arg = "Arg"sv; constexpr std::string_view s_SourcesYaml_Source_Data = "Data"sv; + constexpr std::string_view s_SourcesYaml_Source_Identifier = "Identifier"sv; constexpr std::string_view s_SourcesYaml_Source_IsTombstone = "IsTombstone"sv; constexpr std::string_view s_MetadataYaml_Sources = "Sources"sv; @@ -29,10 +30,12 @@ namespace AppInstaller::Repository constexpr std::string_view s_Source_WingetCommunityDefault_Name = "winget"sv; constexpr std::string_view s_Source_WingetCommunityDefault_Arg = "https://winget.azureedge.net/cache"sv; constexpr std::string_view s_Source_WingetCommunityDefault_Data = "Microsoft.Winget.Source_8wekyb3d8bbwe"sv; + constexpr std::string_view s_Source_WingetCommunityDefault_Identifier = "Microsoft.Winget.Source_8wekyb3d8bbwe"sv; constexpr std::string_view s_Source_WingetMSStoreDefault_Name = "msstore"sv; constexpr std::string_view s_Source_WingetMSStoreDefault_Arg = "https://winget.azureedge.net/msstore"sv; constexpr std::string_view s_Source_WingetMSStoreDefault_Data = "Microsoft.Winget.MSStore.Source_8wekyb3d8bbwe"sv; + constexpr std::string_view s_Source_WingetMSStoreDefault_Identifier = "Microsoft.Winget.MSStore.Source_8wekyb3d8bbwe"sv; namespace { @@ -187,6 +190,7 @@ namespace AppInstaller::Repository details.Type = Microsoft::PreIndexedPackageSourceFactory::Type(); details.Arg = s_Source_WingetCommunityDefault_Arg; details.Data = s_Source_WingetCommunityDefault_Data; + details.Identifier = s_Source_WingetCommunityDefault_Identifier; details.TrustLevel = SourceTrustLevel::Trusted; result.emplace_back(std::move(details)); @@ -197,6 +201,7 @@ namespace AppInstaller::Repository storeDetails.Type = Microsoft::PreIndexedPackageSourceFactory::Type(); storeDetails.Arg = s_Source_WingetMSStoreDefault_Arg; storeDetails.Data = s_Source_WingetMSStoreDefault_Data; + storeDetails.Identifier = s_Source_WingetMSStoreDefault_Identifier; storeDetails.TrustLevel = SourceTrustLevel::Trusted; result.emplace_back(std::move(storeDetails)); } @@ -214,6 +219,7 @@ namespace AppInstaller::Repository if (!TryReadScalar(name, settingValue, source, s_SourcesYaml_Source_Arg, details.Arg)) { return false; } if (!TryReadScalar(name, settingValue, source, s_SourcesYaml_Source_Data, details.Data)) { return false; } if (!TryReadScalar(name, settingValue, source, s_SourcesYaml_Source_IsTombstone, details.IsTombstone)) { return false; } + TryReadScalar(name, settingValue, source, s_SourcesYaml_Source_Identifier, details.Identifier); return true; }); break; @@ -246,6 +252,7 @@ namespace AppInstaller::Repository out << YAML::Key << s_SourcesYaml_Source_Type << YAML::Value << details.Type; out << YAML::Key << s_SourcesYaml_Source_Arg << YAML::Value << details.Arg; out << YAML::Key << s_SourcesYaml_Source_Data << YAML::Value << details.Data; + out << YAML::Key << s_SourcesYaml_Source_Identifier << YAML::Value << details.Identifier; out << YAML::Key << s_SourcesYaml_Source_IsTombstone << YAML::Value << details.IsTombstone; out << YAML::EndMap; } @@ -374,7 +381,7 @@ namespace AppInstaller::Repository constexpr static auto s_ZeroMins = 0min; auto autoUpdateTime = User().Get(); - // A value of zero means no auto update, to get update the source run `winget update` + // A value of zero means no auto update, to get update the source run `winget update` if (autoUpdateTime != s_ZeroMins) { auto autoUpdateTimeMins = std::chrono::minutes(autoUpdateTime); @@ -603,6 +610,7 @@ namespace AppInstaller::Repository AddSourceFromDetails(details, progress); AICLI_LOG(Repo, Info, << "Source created with extra data: " << details.Data); + AICLI_LOG(Repo, Info, << "Source created with identifier: " << details.Identifier); sourceList.AddSource(details); } @@ -724,7 +732,7 @@ namespace AppInstaller::Repository THROW_HR(E_UNEXPECTED); } - std::shared_ptr CreateCompositeSource(const std::shared_ptr& installedSource, const std::shared_ptr& availableSource) + std::shared_ptr CreateCompositeSource(const std::shared_ptr& installedSource, const std::shared_ptr& availableSource, CompositeSearchBehavior searchBehavior) { std::shared_ptr result = std::dynamic_pointer_cast(availableSource); @@ -734,7 +742,7 @@ namespace AppInstaller::Repository result->AddAvailableSource(availableSource); } - result->SetInstalledSource(installedSource); + result->SetInstalledSource(installedSource, searchBehavior); return result; } diff --git a/src/AppInstallerTestExeInstaller/main.cpp b/src/AppInstallerTestExeInstaller/main.cpp index 8ba5116466..decb3c623f 100644 --- a/src/AppInstallerTestExeInstaller/main.cpp +++ b/src/AppInstallerTestExeInstaller/main.cpp @@ -11,10 +11,11 @@ using namespace std::filesystem; -std::string_view registrySubkey = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"; -std::string_view defaultProductID = "{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}"; +std::wstring_view registrySubkey = L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"; +std::wstring_view defaultProductID = L"{A499DD5E-8DC5-4AD2-911A-BCD0263295E9}"; +std::wstring_view defaultVersion = L"1.0.0.0"; -path GenerateUninstaller(std::ostream& out, const path& installDirectory, const std::string& productID) +path GenerateUninstaller(std::wostream& out, const path& installDirectory, const std::wstring& productID) { path uninstallerPath = installDirectory; uninstallerPath /= "UninstallTestExe.bat"; @@ -24,7 +25,7 @@ path GenerateUninstaller(std::ostream& out, const path& installDirectory, const path uninstallerOutputTextFilePath = installDirectory; uninstallerOutputTextFilePath /= "TestExeUninstalled.txt"; - std::string registryKey{ "HKEY_CURRENT_USER\\" }; + std::wstring registryKey{ L"HKEY_CURRENT_USER\\" }; registryKey += registrySubkey; if (!productID.empty()) { @@ -35,7 +36,7 @@ path GenerateUninstaller(std::ostream& out, const path& installDirectory, const registryKey += defaultProductID; } - std::ofstream uninstallerScript(uninstallerPath); + std::wofstream uninstallerScript(uninstallerPath); uninstallerScript << "@echo off\n"; uninstallerScript << "ECHO. >" << uninstallerOutputTextFilePath << "\n"; uninstallerScript << "ECHO AppInstallerTestExeInstaller.exe uninstalled successfully.\n"; @@ -45,19 +46,18 @@ path GenerateUninstaller(std::ostream& out, const path& installDirectory, const return uninstallerPath; } -void WriteToUninstallRegistry(std::ostream& out, const std::string& productID, const path& uninstallerPath) +void WriteToUninstallRegistry(std::wostream& out, const std::wstring& productID, const path& uninstallerPath, const std::wstring& displayVersion) { HKEY hkey; LONG lReg; // String inputs to registry must be of wide char type const wchar_t* displayName = L"AppInstallerTestExeInstaller"; - const wchar_t* displayVersion = L"1.0.0.0"; const wchar_t* publisher = L"Microsoft Corporation"; const wchar_t* uninstallString = uninstallerPath.c_str(); DWORD version = 1; - std::string registryKey{ registrySubkey }; + std::wstring registryKey{ registrySubkey }; if (!productID.empty()) { @@ -70,7 +70,7 @@ void WriteToUninstallRegistry(std::ostream& out, const std::string& productID, c out << "Default Product Code used: " << registryKey << "\n"; } - lReg = RegCreateKeyExA( + lReg = RegCreateKeyEx( HKEY_CURRENT_USER, registryKey.c_str(), 0, @@ -92,7 +92,7 @@ void WriteToUninstallRegistry(std::ostream& out, const std::string& productID, c } // Set Display Version Property Value - if (LONG res = RegSetValueEx(hkey, L"DisplayVersion", NULL, REG_SZ, (LPBYTE)displayVersion, (DWORD)(wcslen(displayVersion) + 1) * sizeof(wchar_t)) != ERROR_SUCCESS) + if (LONG res = RegSetValueEx(hkey, L"DisplayVersion", NULL, REG_SZ, (LPBYTE)displayVersion.c_str(), (DWORD)(displayVersion.length() + 1) * sizeof(wchar_t)) != ERROR_SUCCESS) { out << "Failed to write DisplayVersion value. Error Code: " << res << "\n"; } @@ -125,44 +125,56 @@ void WriteToUninstallRegistry(std::ostream& out, const std::string& productID, c } // The installer prints all args to an output file and writes to the Uninstall registry key -int main(int argc, const char** argv) +int wmain(int argc, const wchar_t** argv) { path installDirectory = temp_directory_path(); - std::stringstream outContent; - std::string productCode; + std::wstringstream outContent; + std::wstring productCode; + std::wstring version; // Output to cout by default, but swap to a file if requested - std::ostream* out = &std::cout; - std::ofstream logFile; + std::wostream* out = &std::wcout; + std::wofstream logFile; for (int i = 1; i < argc; i++) { outContent << argv[i] << ' '; // Supports custom install path. - if (_stricmp(argv[i], "/InstallDir") == 0 && ++i < argc) + if (_wcsicmp(argv[i], L"/InstallDir") == 0 && ++i < argc) { installDirectory = argv[i]; outContent << argv[i] << ' '; } // Supports custom product code ID - if (_stricmp(argv[i], "/ProductID") == 0 && ++i < argc) + if (_wcsicmp(argv[i], L"/ProductID") == 0 && ++i < argc) { productCode = argv[i]; } + // Supports custom version + if (_wcsicmp(argv[i], L"/Version") == 0 && ++i < argc) + { + version = argv[i]; + } + // Supports log file - if (_stricmp(argv[i], "/LogFile") == 0 && ++i < argc) + if (_wcsicmp(argv[i], L"/LogFile") == 0 && ++i < argc) { - logFile = std::ofstream(argv[i], std::ofstream::out | std::ofstream::trunc); + logFile = std::wofstream(argv[i], std::wofstream::out | std::wofstream::trunc); out = &logFile; } } + if (version.empty()) + { + version = defaultVersion; + } + path outFilePath = installDirectory; outFilePath /= "TestExeInstalled.txt"; - std::ofstream file(outFilePath, std::ofstream::out); + std::wofstream file(outFilePath, std::ofstream::out); file << outContent.str(); @@ -170,7 +182,7 @@ int main(int argc, const char** argv) path uninstallerPath = GenerateUninstaller(*out, installDirectory, productCode); - WriteToUninstallRegistry(*out, productCode, uninstallerPath); + WriteToUninstallRegistry(*out, productCode, uninstallerPath, version); return 0; }