diff --git a/API/Scoop/Converters/LicenseJsonConverter.cs b/API/Scoop/Converters/LicenseJsonConverter.cs new file mode 100644 index 0000000..6ef77a6 --- /dev/null +++ b/API/Scoop/Converters/LicenseJsonConverter.cs @@ -0,0 +1,80 @@ +using System; +using System.Text.Json.Serialization; +using System.Text.Json; +using Scoop.Responses; +using System.Linq; +using System.Collections.Generic; + +namespace Scoop.Converters; + +internal class LicenseJsonConverter : JsonConverter +{ + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(string) || base.CanConvert(typeToConvert); + + public override LicenseInfo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + return null; + + if (reader.TokenType is JsonTokenType.String) + { + var singleString = reader.GetString(); + if (Uri.TryCreate(singleString, UriKind.Absolute, out var licenseUri)) + { + return new() + { + Url = licenseUri + }; + } + + return new() + { + MultiLicenses = ParseIdentifiers(singleString!) + }; + } + + if (reader.TokenType is not JsonTokenType.StartObject) + throw new JsonException($"Expected the start of an object."); + + Dictionary properties = []; + + while (reader.TokenType is not JsonTokenType.EndObject) + { + if (!reader.Read() || reader.TokenType is not JsonTokenType.PropertyName) + break; + var propertyName = reader.GetString(); + + if (!reader.Read() || reader.TokenType is not JsonTokenType.String) + throw new JsonException("Expected property value"); + var propertyValue = reader.GetString(); + + properties[propertyName!] = propertyValue!; + } + + LicenseInfo license = new(); + + if (properties.TryGetValue("identifier", out var identifierStr)) + license.MultiLicenses = ParseIdentifiers(identifierStr); + + if (properties.TryGetValue("url", out var urlStr) && Uri.TryCreate(urlStr, UriKind.Absolute, out var url)) + license.Url = url; + + return license; + } + + public override void Write(Utf8JsonWriter writer, LicenseInfo value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + private static List> ParseIdentifiers(string value) + { + // Apps can be dual-licensed, and their different files within them can have different licenses. + // Dual-licenses are split by '|', and internal licenses by ','. + + return value + .Split('|') + .Select(d => d.Split(',').ToList()) + .ToList(); + } +} diff --git a/API/Scoop/Converters/StringOrArrayJsonConverter.cs b/API/Scoop/Converters/StringOrArrayJsonConverter.cs new file mode 100644 index 0000000..4b7d921 --- /dev/null +++ b/API/Scoop/Converters/StringOrArrayJsonConverter.cs @@ -0,0 +1,57 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Scoop.Responses; + +namespace Scoop.Converters; + +internal class StringOrArrayJsonConverter : JsonConverter +{ + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(string) || base.CanConvert(typeToConvert); + + public override StringOrArray? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + return []; + + if (reader.TokenType is JsonTokenType.String) + return [reader.GetString()]; + + if (reader.TokenType is not JsonTokenType.StartArray) + throw new JsonException($"Expected the start of a string array."); + + StringOrArray list = []; + + while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) + { + var str = JsonSerializer.Deserialize(ref reader, options) + ?? throw new JsonException($"Unexpected null value could not be converted to List."); + + list.Add(str); + } + + return list; + } + + public override void Write(Utf8JsonWriter writer, StringOrArray value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value.Count == 1) + { + writer.WriteStringValue(value[0]); + return; + } + + writer.WriteStartArray(); + + foreach (var str in value) + writer.WriteStringValue(str); + + writer.WriteEndArray(); + } +} diff --git a/API/Scoop/IScoopAppManager.cs b/API/Scoop/IScoopAppManager.cs new file mode 100644 index 0000000..68664b1 --- /dev/null +++ b/API/Scoop/IScoopAppManager.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Scoop; + +public interface IScoopAppManager +{ + Task InstallAsync(string name, CancellationToken token = default); + + Task DownloadAsync(string name, CancellationToken token = default); + + Task UpdateAsync(string name, CancellationToken token = default); + + Task UninstallAsync(string name, CancellationToken token = default); + + IAsyncEnumerable GetInstalledAppsAsync(string name, CancellationToken token = default); + + Task GetAppInfoAsync(string name, CancellationToken token = default); +} diff --git a/API/Scoop/IScoopBucketManager.cs b/API/Scoop/IScoopBucketManager.cs new file mode 100644 index 0000000..ca7782c --- /dev/null +++ b/API/Scoop/IScoopBucketManager.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Scoop.Responses; + +namespace Scoop; + +public interface IScoopBucketManager +{ + Task AddBucketAsync(string name, string? repo = null, CancellationToken token = default); + + Task RemoveBucketAsync(string name, CancellationToken token = default); + + IAsyncEnumerable GetKnownBucketsAsync(CancellationToken token = default); + + IAsyncEnumerable GetBucketsAsync(CancellationToken token = default); +} diff --git a/API/Scoop/IScoopMetadataService.cs b/API/Scoop/IScoopMetadataService.cs new file mode 100644 index 0000000..269fb93 --- /dev/null +++ b/API/Scoop/IScoopMetadataService.cs @@ -0,0 +1,13 @@ +using Scoop.Responses; +using System.Threading.Tasks; +using System.Threading; +using Flurl; + +namespace Scoop; + +public interface IScoopMetadataService +{ + Task GetManifestAsync(SearchResultMetadata metadata, CancellationToken token = default); + + Task GetManifestAsync(Url metadataUrl, CancellationToken token = default); +} diff --git a/API/Scoop/IScoopSearchService.cs b/API/Scoop/IScoopSearchService.cs new file mode 100644 index 0000000..02bd70e --- /dev/null +++ b/API/Scoop/IScoopSearchService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using System.Threading; +using Scoop.Responses; + +namespace Scoop; + +public interface IScoopSearchService +{ + Task SearchAsync(string query, int count = 20, int skip = 0, CancellationToken token = default); +} diff --git a/API/Scoop/Requests/SearchRequest.cs b/API/Scoop/Requests/SearchRequest.cs new file mode 100644 index 0000000..3c04f61 --- /dev/null +++ b/API/Scoop/Requests/SearchRequest.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Scoop.Requests; + +public class SearchRequest +{ + [JsonPropertyName("count")] + public bool Count { get; set; } + + [JsonPropertyName("search")] + public string Search { get; set; } + + [JsonPropertyName("searchMode")] + public string SearchMode { get; set; } + + [JsonPropertyName("filter")] + public string Filter { get; set; } + + [JsonPropertyName("orderby")] + public string OrderBy { get; set; } + + [JsonPropertyName("skip")] + public int Skip { get; set; } + + [JsonPropertyName("top")] + public int Top { get; set; } = 20; + + [JsonPropertyName("select")] + public string Select { get; set; } + + [JsonPropertyName("facets")] + public List Facets { get; set; } = []; + + [JsonPropertyName("highlight")] + public string Highlight { get; set; } = string.Empty; + + [JsonPropertyName("highlightPreTag")] + public string HighlightPreTag { get; set; } = string.Empty; + + [JsonPropertyName("highlightPostTag")] + public string HighlightPostTag { get; set; } = string.Empty; +} + diff --git a/API/Scoop/Responses/Bucket.cs b/API/Scoop/Responses/Bucket.cs new file mode 100644 index 0000000..c47474e --- /dev/null +++ b/API/Scoop/Responses/Bucket.cs @@ -0,0 +1,14 @@ +using System; + +namespace Scoop.Responses; + +public class Bucket +{ + public string Name { get; set; } + + public string Source { get; set; } + + public DateTime Updated { get; set; } + + public int Manifests { get; set; } +} diff --git a/API/Scoop/Responses/Manifest.cs b/API/Scoop/Responses/Manifest.cs new file mode 100644 index 0000000..5e1ca77 --- /dev/null +++ b/API/Scoop/Responses/Manifest.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Scoop.Converters; + +namespace Scoop.Responses; + +// https://github.com/ScoopInstaller/Scoop/wiki/App-Manifests + +public class Manifest +{ + /// + /// The version of the app that this manifest installs. + /// + [JsonPropertyName("version")] + public string Version { get; set; } + + /// + /// A one line string containing a short description of the program. + /// Don’t include the name of the program, if it’s the same + /// as the app’s filename. + /// + [JsonPropertyName("description")] + public string Description { get; set; } + + /// + /// The home page for the program. + /// + [JsonPropertyName("homepage")] + public string Homepage { get; set; } + + /// + /// A string or hash of the software license for the program. + /// + /// + /// For well-known licenses, please use the identifier found + /// at For other licenses, use the + /// URL of the license, if available. Otherwise, use + /// "Freeware", "Proprietary", "Public Domain", "Shareware", + /// or "Unknown", as appropriate. If different files have + /// different licenses, separate licenses with a comma (,). + /// If the entire application is dual licensed, separate + /// licenses with a pipe symbol (|). + /// + [JsonPropertyName("license")] + public LicenseInfo? License { get; set; } + + /// + /// If the app has 32- and 64-bit versions, architecture can + /// be used to wrap the differences. + /// + [JsonPropertyName("architecture")] + public Architecture? Architecture { get; set; } + + /// + /// Definition of how the manifest can be updated automatically. + /// + /// + /// + /// + [JsonPropertyName("autoupdate")] + public Architecture Autoupdate { get; set; } + + /// + /// A string or array of strings of programs (executables or scripts) + /// to make available on the user's path. + /// + // TODO: Deserialize complex bin + public JsonElement? Bin { get; set; } + + /// + /// App maintainers and developers can use the bin/checkver tool + /// to check for updated versions of apps. The property + /// in a manifest is a regular expression that can be used to + /// match the current stable version of an app from the app's + /// homepage. For an example, see the go manifest. If the homepage + /// doesn't have a reliable indication of the current version, you + /// can also specify a different URL to check—for an example see + /// the ruby manifest. + /// + // TODO: Deserialize complex checkver + [JsonPropertyName("checkver")] + public JsonElement? CheckVer { get; set; } + + /// + /// Runtime dependencies for the app which will be installed automatically. + /// + /// + /// See also for an alternative to . + /// + [JsonPropertyName("depends")] + public List? Depends { get; set; } + + /// + /// Add this directory to the user's path (or system path if --global is used). + /// The directory is relative to the install directory and must be inside the + /// install directory. + /// + [JsonPropertyName("env_add_path")] + public string? EnvAddPath { get; set; } + + /// + /// Sets one or more environment variables for the user + /// (or system if --global is used). + /// + [JsonPropertyName("env_set")] + public Dictionary? EnvSet { get; set; } + + /// + /// If points to a compressed file (.zip, .7z, .tar, .gz, + /// .lzma, and .lzh are supported), Scoop will extract just the directory + /// specified from it. + /// + [JsonPropertyName("extract_dir")] + public string? ExtractDir { get; set; } + + /// + /// If points to a compressed file (.zip, .7z, .tar, .gz, + /// .lzma, and .lzh are supported), Scoop will extract all content to the + /// directory specified. + /// + [JsonPropertyName("extract_to")] + public string? ExtractTo { get; set; } + + /// + /// A string or array of strings with a file hash for each URL in url. + /// Hashes are SHA256 by default, but you can use SHA512, SHA1 or MD5 by + /// prefixing the hash string with 'sha512:', 'sha1:' or 'md5:'. + /// + public StringOrArray Hash { get; set; } + + /// + /// Set to if the installer is InnoSetup based. + /// + [JsonPropertyName("innosetup")] + public bool IsInnoSetup { get; set; } + + /// + /// Instructions for running a non-MSI installer. + /// + public Installer? Installer { get; set; } + + /// + /// A one-line string, or array of strings, with a message to be displayed after installing the app. + /// + public StringOrArray Notes { get; set; } + + /// + /// A string or array of strings of directories and files to persist inside the data directory for the app. + /// + public StringOrArray Persist { get; set; } + + /// + /// A one-line string, or array of strings, of the commands to be executed after an application is installed. + /// These can use variables like $dir, $persist_dir, and $version. + /// + public StringOrArray PostInstall { get; set; } + + /// + /// Same options as , but executed before an application is installed. + /// + public StringOrArray PreInstall { get; set; } + + /// + /// Same options as , but executed before an application is installed. + /// + public StringOrArray PreUninstall { get; set; } + + /// + /// Same options as , but executed before an application is installed. + /// + public StringOrArray PostUninstall { get; set; } + + /// + /// Install as a PowerShell module in ~/scoop/modules. + /// + [JsonPropertyName("psmodule")] + public PowerShellModuleDeclaration? PowerShellModule { get; set; } + + /// + /// Specifies the shortcut values to make available in the startmenu. + /// The array has to contain a executable/label pair. The third and fourth element are optional. + /// + public List>? Shortcuts { get; set; } + + /// + /// Display a message suggesting optional apps that provide complementary features. + /// + public Dictionary? Suggest { get; set; } + + /// + /// Same options as , but the file/script is run to uninstall the application. + /// + public Installer? Uninstaller { get; set; } + + /// + /// The URL or URLs of files to download. If there's more than one URL, you can use a JSON - array, e.g. + /// "url": [ "http://example.org/program.zip", "http://example.org/dependencies.zip" ]. + /// URLs can be HTTP, HTTPS or FTP. + /// + public StringOrArray Url { get; set; } +} + +public class Architecture +{ + [JsonPropertyName("64bit")] + public ManifestDifference X64 { get; set; } + + [JsonPropertyName("32bit")] + public ManifestDifference X86 { get; set; } + + [JsonPropertyName("arm64")] + public ManifestDifference Arm64 { get; set; } +} + +public class ManifestDifference +{ + +} + +public class Installer +{ + /// + /// The installer executable file. For + /// this defaults to the last URL downloaded. Must be specified for + /// . + /// + [JsonPropertyName("file")] + public string File { get; set; } + + /// + /// A one-line string, or array of strings, of commands to be executed as + /// an installer/uninstaller instead of . + /// + [JsonPropertyName("script")] + public StringOrArray Script { get; set; } + + /// + /// An array of arguments to pass to the installer. Optional. + /// + public List? Args { get; set; } + + /// + /// if the installer should be kept after running (for future uninstallation, as an example). + /// If omitted or set to any other value, the installer will be deleted after running. + /// See java/oraclejdk for an example. + /// This option will be ignored when used in an directive. + /// + public bool Keep { get; set; } +} + +public class PowerShellModuleDeclaration +{ + /// + /// The name of the module, which should match at least one file in the extracted directory for PowerShell to recognize this as a module. + /// + public string Name { get; set; } +} + +[JsonConverter(typeof(LicenseJsonConverter))] +public class LicenseInfo +{ + /// + /// The SPDX identifier, or "Freeware" (free to use forever), "Proprietary" (must pay to use), + /// "Public Domain", "Shareware" (free to try, must pay eventually), or "Unknown" + /// (unable to determine the license), as appropriate. + /// + public List> MultiLicenses { get; set; } = [["Unknown"]]; + + /// + /// For non-SPDX licenses, include a link to the license. It is acceptable to include links + /// to SPDX licenses, as well. + /// + public Uri? Url { get; set; } +} + diff --git a/API/Scoop/Responses/SearchResponse.cs b/API/Scoop/Responses/SearchResponse.cs new file mode 100644 index 0000000..48d71e7 --- /dev/null +++ b/API/Scoop/Responses/SearchResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Scoop.Responses; + +public class SearchResponse +{ + [JsonPropertyName("@odata.context")] + public string ODataContext { get; set; } + + [JsonPropertyName("@odata.count")] + public int ODataCount { get; set; } + + [JsonPropertyName("value")] + public List Results { get; set; } +} diff --git a/API/Scoop/Responses/SearchResult.cs b/API/Scoop/Responses/SearchResult.cs new file mode 100644 index 0000000..433201a --- /dev/null +++ b/API/Scoop/Responses/SearchResult.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Scoop.Responses; + +public class SearchResult +{ + [JsonPropertyName("@search.score")] + public double SearchScore { get; set; } + + public string Id { get; set; } + public string Name { get; set; } + public string NamePartial { get; set; } + public string NameSuffix { get; set; } + public string Description { get; set; } + public string Homepage { get; set; } + public string License { get; set; } + public string Version { get; set; } + public SearchResultMetadata Metadata { get; set; } +} diff --git a/API/Scoop/Responses/SearchResultMetadata.cs b/API/Scoop/Responses/SearchResultMetadata.cs new file mode 100644 index 0000000..7863d95 --- /dev/null +++ b/API/Scoop/Responses/SearchResultMetadata.cs @@ -0,0 +1,17 @@ +using Flurl; +using System; + +namespace Scoop.Responses; + +public class SearchResultMetadata +{ + public string Repository { get; set; } + public bool OfficialRepository { get; set; } + public int RepositoryStars { get; set; } + public string FilePath { get; set; } + public string AuthorName { get; set; } + public DateTimeOffset Committed { get; set; } + public string Sha { get; set; } + + public Url GetManifestUrl() => $"{Repository}/raw/master/{FilePath}"; +} diff --git a/API/Scoop/Responses/StringOrArray.cs b/API/Scoop/Responses/StringOrArray.cs new file mode 100644 index 0000000..6ad3e72 --- /dev/null +++ b/API/Scoop/Responses/StringOrArray.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Scoop.Converters; + +namespace Scoop.Responses; + +[JsonConverter(typeof(StringOrArrayJsonConverter))] +public class StringOrArray : List +{ + public StringOrArray() : base() { } + + public StringOrArray(IEnumerable values) : base(values) { } + + public StringOrArray(string value) : base([value]) { } +} diff --git a/API/Scoop/Scoop.csproj b/API/Scoop/Scoop.csproj new file mode 100644 index 0000000..583d120 --- /dev/null +++ b/API/Scoop/Scoop.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0 + latest + enable + AnyCPU;x64;x86;ARM;ARM64 + + + + + + + diff --git a/API/Scoop/ScoopApi.cs b/API/Scoop/ScoopApi.cs new file mode 100644 index 0000000..fc2231d --- /dev/null +++ b/API/Scoop/ScoopApi.cs @@ -0,0 +1,8 @@ +using System; + +namespace Scoop; + +public class ScoopApi +{ + +} diff --git a/API/Scoop/ScoopSearch.cs b/API/Scoop/ScoopSearch.cs new file mode 100644 index 0000000..a200d1d --- /dev/null +++ b/API/Scoop/ScoopSearch.cs @@ -0,0 +1,60 @@ +using Flurl.Http; +using Flurl; +using System.Threading.Tasks; +using System.Threading; +using Scoop.Requests; +using Scoop.Responses; + +namespace Scoop; + +public class ScoopSearch : IScoopSearchService, IScoopMetadataService +{ + private const string SEARCH_URL = "https://scoopsearch.search.windows.net/indexes/apps/docs/search"; + private const string API_VERSION = "2020-06-30"; + private const string PUBLIC_API_KEY = "DC6D2BBE65FC7313F2C52BBD2B0286ED"; + + public string UserAgent { get; set; } = "Scoop/.NET"; + + public string ApiKey { get; set; } = PUBLIC_API_KEY; + + public async Task SearchAsync(string query, int count = 20, int skip = 0, CancellationToken token = default) + { + SearchRequest request = new() + { + Count = true, + Top = count, + Skip = skip, + Filter = "Metadata/OfficialRepositoryNumber eq 1 and Metadata/DuplicateOf eq null", + OrderBy = "search.score() desc, Metadata/OfficialRepositoryNumber desc, NameSortable asc", + Search = query, + SearchMode = "all", + Select = "Id,Name,NamePartial,NameSuffix,Description,Notes,Homepage,License,Version,Metadata/Repository,Metadata/FilePath,Metadata/OfficialRepository,Metadata/RepositoryStars,Metadata/Committed,Metadata/Sha" + }; + + return await SearchAsync(request, token); + } + + public async Task SearchAsync(SearchRequest request, CancellationToken token = default) + { + var http = await SEARCH_URL + .WithHeader("User-Agent", UserAgent) + .WithHeader("api-key", ApiKey) + .SetQueryParam("api-version", API_VERSION) + .PostJsonAsync(request, cancellationToken: token); + + return await http.GetJsonAsync(); + } + + public Task GetManifestAsync(SearchResultMetadata metadata, CancellationToken token = default) + { + return GetManifestAsync(metadata.GetManifestUrl(), token); + } + + public async Task GetManifestAsync(Url metadataUrl, CancellationToken token = default) + { + var manifest = await metadataUrl + .WithHeader("User-Agent", UserAgent) + .GetJsonAsync(cancellationToken: token); + return manifest; + } +} diff --git a/FluentStore.sln b/FluentStore.sln index 0e5c9b4..af80656 100644 --- a/FluentStore.sln +++ b/FluentStore.sln @@ -68,6 +68,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnifiedUpdatePlatform.Servi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentStore.Sources.Microsoft", "Sources\FluentStore.Sources.Microsoft\FluentStore.Sources.Microsoft.csproj", "{54E80C11-BF74-4A71-8620-1E110C9529C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scoop", "API\Scoop\Scoop.csproj", "{7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScoopTests", "Tests\ScoopTests\ScoopTests.csproj", "{7180E2A8-0441-4F74-AB91-D1B962A033A4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentStore.Sources.Scoop", "Sources\FluentStore.Sources.Scoop\FluentStore.Sources.Scoop.csproj", "{A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer", "Installer\Installer.csproj", "{1ED552DC-C81F-B5C2-8CA8-BF8BDC334B01}" EndProject Global @@ -566,6 +571,66 @@ Global {54E80C11-BF74-4A71-8620-1E110C9529C2}.Release|x64.Build.0 = Release|x64 {54E80C11-BF74-4A71-8620-1E110C9529C2}.Release|x86.ActiveCfg = Release|x86 {54E80C11-BF74-4A71-8620-1E110C9529C2}.Release|x86.Build.0 = Release|x86 + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|ARM.ActiveCfg = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|ARM.Build.0 = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|ARM64.Build.0 = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|x64.Build.0 = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|x86.ActiveCfg = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Debug|x86.Build.0 = Debug|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|Any CPU.Build.0 = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|ARM.ActiveCfg = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|ARM.Build.0 = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|ARM64.ActiveCfg = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|ARM64.Build.0 = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|x64.ActiveCfg = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|x64.Build.0 = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|x86.ActiveCfg = Release|Any CPU + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248}.Release|x86.Build.0 = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|ARM.ActiveCfg = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|ARM.Build.0 = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|ARM64.Build.0 = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|x64.Build.0 = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Debug|x86.Build.0 = Debug|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|Any CPU.Build.0 = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|ARM.ActiveCfg = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|ARM.Build.0 = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|ARM64.ActiveCfg = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|ARM64.Build.0 = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|x64.ActiveCfg = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|x64.Build.0 = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|x86.ActiveCfg = Release|Any CPU + {7180E2A8-0441-4F74-AB91-D1B962A033A4}.Release|x86.Build.0 = Release|Any CPU + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|Any CPU.ActiveCfg = Debug|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|Any CPU.Build.0 = Debug|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|ARM.ActiveCfg = Debug|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|ARM.Build.0 = Debug|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|ARM64.Build.0 = Debug|ARM64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|x64.ActiveCfg = Debug|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|x64.Build.0 = Debug|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|x86.ActiveCfg = Debug|x86 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Debug|x86.Build.0 = Debug|x86 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|Any CPU.ActiveCfg = Release|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|Any CPU.Build.0 = Release|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|ARM.ActiveCfg = Release|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|ARM.Build.0 = Release|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|ARM64.ActiveCfg = Release|ARM64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|ARM64.Build.0 = Release|ARM64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|x64.ActiveCfg = Release|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|x64.Build.0 = Release|x64 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|x86.ActiveCfg = Release|x86 + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1}.Release|x86.Build.0 = Release|x86 {1ED552DC-C81F-B5C2-8CA8-BF8BDC334B01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1ED552DC-C81F-B5C2-8CA8-BF8BDC334B01}.Debug|Any CPU.Build.0 = Debug|Any CPU {1ED552DC-C81F-B5C2-8CA8-BF8BDC334B01}.Debug|ARM.ActiveCfg = Debug|ARM @@ -609,6 +674,9 @@ Global {0611BAD5-C405-45EF-BAEC-C52E50E9791D} = {042B0595-6134-4558-9725-157181F8D185} {3BC41048-0234-4996-82BB-3C98163241D2} = {042B0595-6134-4558-9725-157181F8D185} {54E80C11-BF74-4A71-8620-1E110C9529C2} = {5AEB24F0-A15D-4E57-8498-AE6F8C5A04D0} + {7F2DEB6A-C57F-4A2C-A5C5-F0DF96F2C248} = {A6FEC513-6706-44AF-B200-D40836E32C19} + {7180E2A8-0441-4F74-AB91-D1B962A033A4} = {56C24CCE-3389-42E0-95F0-30B154BF421B} + {A999C2F9-8C07-4B2C-A1AC-6FCB504DF6D1} = {5AEB24F0-A15D-4E57-8498-AE6F8C5A04D0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DAB9771E-34BB-4E5E-95EA-D7538346B7C4} diff --git a/Sources/FluentStore.Sources.Scoop/FluentStore.Sources.Scoop.csproj b/Sources/FluentStore.Sources.Scoop/FluentStore.Sources.Scoop.csproj new file mode 100644 index 0000000..63c1353 --- /dev/null +++ b/Sources/FluentStore.Sources.Scoop/FluentStore.Sources.Scoop.csproj @@ -0,0 +1,15 @@ + + + + + + + + + Scoop + $(FluentStoreMajorMinorVersion).0.1 + $(Version)-alpha + Provides support for Scoop. Requires the Scoop CLI to be intalled. + + + diff --git a/Tests/ScoopTests/PwshHost.cs b/Tests/ScoopTests/PwshHost.cs new file mode 100644 index 0000000..69e8cfd --- /dev/null +++ b/Tests/ScoopTests/PwshHost.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentStore.Sources.Scoop; +using Xunit.Abstractions; + +namespace ScoopTests; + +public class PwshHost(ITestOutputHelper output) +{ + private readonly ScoopPwsh _scoop = new(); + + [Fact] + public async Task GetBucketsAsync() + { + await foreach (var bucket in _scoop.GetBucketsAsync()) + { + output.WriteLine($"{bucket.Name} {bucket.Updated} {bucket.Manifests} {bucket.Source}"); + } + } +} diff --git a/Tests/ScoopTests/ScoopPwsh.cs b/Tests/ScoopTests/ScoopPwsh.cs new file mode 100644 index 0000000..b547c48 --- /dev/null +++ b/Tests/ScoopTests/ScoopPwsh.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.PowerShell; +using Scoop; +using Scoop.Responses; + +namespace FluentStore.Sources.Scoop; + +public class ScoopPwsh : IScoopAppManager, IScoopBucketManager +{ + private const string CMDLET_SCOOP = "scoop"; + + public async Task AddBucketAsync(string name, string? repo = null, CancellationToken token = default) + { + var ps = PowerShell.Create(); + ps.AddCommand(CMDLET_SCOOP).AddArgument("bucket").AddArgument("add").AddArgument(name); + + if (repo is not null) + ps.AddCommand(repo); + + await ps.InvokeAsync(); + } + + public Task DownloadAsync(string name, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public Task GetAppInfoAsync(string name, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public async IAsyncEnumerable GetBucketsAsync([EnumeratorCancellation] CancellationToken token = default) + { + using var ps = CreatePwsh(); + ps.AddCommand(CMDLET_SCOOP).AddArgument("bucket").AddArgument("list"); + + token.ThrowIfCancellationRequested(); + + var psResults = await ps.InvokeAsync(); + + token.ThrowIfCancellationRequested(); + + foreach (var psResult in psResults) + { + token.ThrowIfCancellationRequested(); + + Bucket bucket = new() + { + Name = GetValue(psResult, "Name"), + Source = GetValue(psResult, "Source"), + Updated = GetValue(psResult, "Updated"), + Manifests = GetValue(psResult, "Manifests"), + }; + + yield return bucket; + } + } + + public IAsyncEnumerable GetInstalledAppsAsync(string name, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public IAsyncEnumerable GetKnownBucketsAsync(CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public Task InstallAsync(string name, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public Task RemoveBucketAsync(string name, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public Task UninstallAsync(string name, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + public Task UpdateAsync(string name, CancellationToken token = default) + { + throw new NotImplementedException(); + } + + /// + /// Creates a PowerShell console with an unrestricted execution policy + /// + /// + private PowerShell CreatePwsh() + { + // Create a default initial session state and set the execution policy. + InitialSessionState initialSessionState = InitialSessionState.CreateDefault(); + initialSessionState.ExecutionPolicy = ExecutionPolicy.Unrestricted; + + // Create a runspace and open it. This example uses C#8 simplified using statements + Runspace runspace = RunspaceFactory.CreateRunspace(initialSessionState); + runspace.Open(); + + // Create a PowerShell object + return PowerShell.Create(runspace); + } + + private static T GetValue(PSObject psObj, string propertyName) + { + var psProp = psObj.Properties[propertyName]; + return (T)((PSObject)psProp.Value).BaseObject; + } +} diff --git a/Tests/ScoopTests/ScoopTests.csproj b/Tests/ScoopTests/ScoopTests.csproj new file mode 100644 index 0000000..15ef7d0 --- /dev/null +++ b/Tests/ScoopTests/ScoopTests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/Tests/ScoopTests/Search.cs b/Tests/ScoopTests/Search.cs new file mode 100644 index 0000000..905c23d --- /dev/null +++ b/Tests/ScoopTests/Search.cs @@ -0,0 +1,87 @@ +using Scoop; +using Xunit.Abstractions; + +namespace ScoopTests; + +public class Search(ITestOutputHelper output) +{ + public readonly ScoopSearch _search = new(); + + [Fact] + public async Task SearchAsync_Minimum1() + { + var response = await _search.SearchAsync("vscode"); + var actualResults = response.Results; + Assert.Equal(3, actualResults.Count); + + var firstResult = actualResults[0]; + Assert.Equal("8706b3f90529133e6d1450c4e363645a1b24d4cf", firstResult.Id); + Assert.Equal("vscode", firstResult.Name); + Assert.Equal("https://code.visualstudio.com/", firstResult.Homepage); + + Assert.NotNull(firstResult.Metadata); + Assert.Equal("https://github.com/ScoopInstaller/Extras", firstResult.Metadata.Repository); + Assert.Equal("bucket/vscode.json", firstResult.Metadata.FilePath); + } + + [Theory] + [InlineData("vscode")] + [InlineData("notepadplusplus")] + [InlineData("notepadplusplus-np")] + [InlineData("jetbrains-mono")] + [InlineData("7zip")] + [InlineData("7zip-beta")] + public async Task GetManifestAsync_FromSearchResult(string name) + { + var response = await _search.SearchAsync(name); + var searchResult = response.Results.FirstOrDefault(); + + Assert.NotNull(searchResult); + Assert.Equal(name, searchResult.Name, ignoreCase: true); + Assert.NotNull(searchResult.Metadata); + + var manifest = await _search.GetManifestAsync(searchResult.Metadata); + + Assert.NotNull(manifest); + + output.WriteLine(manifest.Description ?? "{null}"); + } + + [Theory] + [InlineData("stack", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/stack.json", "Add-Path -Path \"$env:APPDATA\\local\\bin\" -Global:$global")] + [InlineData("cuda", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/cuda.json", "$names = @('bin', 'extras', 'include', 'lib', 'libnvvp', 'nvml', 'nvvm', 'compute-sanitizer')\r\nforeach ($name in $names) {\r\n Copy-Item \"$dir\\cuda_*\\*\\$name\" \"$dir\" -Recurse -Force\r\n Copy-Item \"$dir\\lib*\\*\\$name\" \"$dir\" -Recurse -Force\r\n}\r\nGet-ChildItem \"$dir\" -Exclude $names | Remove-Item -Recurse -Force")] + [InlineData("go", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/go.json", "$envgopath = \"$env:USERPROFILE\\go\"\r\nif ($env:GOPATH) { $envgopath = $env:GOPATH }\r\ninfo \"Adding '$envgopath\\bin' to PATH...\"\r\nAdd-Path -Path \"$envgopath\\bin\" -Global:$global -Force")] + public async Task DeserializeInstallerScript(string name, string manifestUrl, string fullScriptEx) + { + var manifest = await _search.GetManifestAsync(manifestUrl); + + Assert.NotNull(manifest); + Assert.NotNull(manifest.Installer); + Assert.NotNull(manifest.Installer.Script); + + var fullScriptAc = string.Join("\r\n", manifest.Installer.Script); + output.WriteLine(fullScriptAc); + + Assert.Equal(fullScriptEx, fullScriptAc); + } + + [Theory] + [InlineData("gpg", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/gpg.json", "GPL-3.0-or-later", null)] + [InlineData("cuda", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/cuda.json", "Freeware", "https://docs.nvidia.com/cuda/eula/index.html")] + [InlineData("xz", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/xz.json", "LGPL-2.1-or-later,GPL-2.0-or-later,GPL-3.0-or-later,Public Domain", "https://git.tukaani.org/?p=xz.git;a=blob;f=COPYING")] + [InlineData("scc", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/scc.json", "MIT|Unlicense", null)] + [InlineData("mongodb", "https://github.com/ScoopInstaller/Main/raw/489bc5445ae47a5504cb0fb1c32a43fa34d9a8f8/bucket/mongodb.json", "SSPL-1.0", "https://www.mongodb.com/licensing/server-side-public-license")] + public async Task DeserializeAppLicense(string name, string manifestUrl, string identifierEx, string? urlEx) + { + var manifest = await _search.GetManifestAsync(manifestUrl); + + Assert.NotNull(manifest); + Assert.NotNull(manifest.License); + + var identifierAc = string.Join("|", manifest.License.MultiLicenses.Select(d => string.Join(",", d))); + Assert.Equal(identifierEx, identifierAc); + + var urlAc = manifest.License.Url?.ToString(); + Assert.Equal(urlEx, urlAc); + } +} \ No newline at end of file