From b294d77a35829d529ea7907e4be76c4dc9012d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Tue, 2 Jun 2026 02:35:18 -0400 Subject: [PATCH] Expose package icon URLs Add manifest icon models, parsing, selection, and structured output fields for both .NET and Rust implementations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CliJsonCompatibilityTests.cs | 24 ++ .../CoreTests.cs | 89 ++++- dotnet/src/Devolutions.Pinget.Core/Models.cs | 61 ++++ .../src/Devolutions.Pinget.Core/Repository.cs | 29 ++ .../src/Devolutions.Pinget.Core/RestSource.cs | 37 ++ rust/crates/pinget-core/src/lib.rs | 327 +++++++++++++++++- 6 files changed, 565 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs index f5b5e54..f368e5f 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs @@ -91,6 +91,26 @@ public void StructuredJsonSerializer_PreservesExistingShowManifestPropertyNames( LicenseUrl = "https://example.test/license", ReleaseNotesUrl = "https://example.test/release-notes", Tags = ["utilities", "powertoys"], + IconUrl = "https://example.test/icon.ico", + Icon = new PackageIcon + { + IconUrl = "https://example.test/icon.ico", + IconFileType = "ico", + IconResolution = "256x256", + IconTheme = "default", + IconSha256 = "DEF456", + }, + Icons = + [ + new PackageIcon + { + IconUrl = "https://example.test/icon.ico", + IconFileType = "ico", + IconResolution = "256x256", + IconTheme = "default", + IconSha256 = "DEF456", + }, + ], Installers = [ new SerializableInstaller @@ -117,6 +137,10 @@ public void StructuredJsonSerializer_PreservesExistingShowManifestPropertyNames( Assert.Equal("https://example.test/license", root.GetProperty(nameof(SerializableShowManifest.LicenseUrl)).GetString()); Assert.Equal("https://example.test/release-notes", root.GetProperty(nameof(SerializableShowManifest.ReleaseNotesUrl)).GetString()); Assert.Equal(["utilities", "powertoys"], root.GetProperty(nameof(SerializableShowManifest.Tags)).EnumerateArray().Select(item => item.GetString() ?? string.Empty).ToArray()); + Assert.Equal("https://example.test/icon.ico", root.GetProperty(nameof(SerializableShowManifest.IconUrl)).GetString()); + Assert.Equal("https://example.test/icon.ico", root.GetProperty(nameof(SerializableShowManifest.Icon)).GetProperty(nameof(PackageIcon.IconUrl)).GetString()); + Assert.Equal("ico", root.GetProperty(nameof(SerializableShowManifest.Icon)).GetProperty(nameof(PackageIcon.IconFileType)).GetString()); + Assert.Equal("https://example.test/icon.ico", root.GetProperty(nameof(SerializableShowManifest.Icons))[0].GetProperty(nameof(PackageIcon.IconUrl)).GetString()); var installer = root.GetProperty(nameof(SerializableShowManifest.Installers))[0]; Assert.Equal("https://example.test/installer.exe", installer.GetProperty(nameof(SerializableInstaller.InstallerUrl)).GetString()); Assert.Equal("ABC123", installer.GetProperty(nameof(SerializableInstaller.InstallerSha256)).GetString()); diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 57ef07c..158dfcd 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -778,6 +778,67 @@ public void ParseYamlManifest_ReadsInstallerSwitches() Assert.Equal("/HELP", installer.Switches.Interactive); } + [Fact] + public void ParseYamlManifest_ReadsIconsAndSelectsBestIcon() + { + var yaml = """ + PackageIdentifier: Test.Package + PackageVersion: 1.2.3 + PackageName: Test Package + Icons: + - IconUrl: https://example.test/dark-512.png + IconFileType: png + IconResolution: 512x512 + IconTheme: dark + IconSha256: DARK + - IconUrl: https://example.test/default-64.png + IconFileType: png + IconResolution: 64x64 + IconTheme: default + - IconUrl: https://example.test/default-256.ico + IconFileType: ico + IconResolution: 256x256 + IconTheme: default + - IconFileType: png + IconResolution: 1024x1024 + Installers: + - Architecture: x64 + InstallerType: exe + InstallerUrl: https://example.test/Test.Package.exe + InstallerSha256: ABC123 + """; + + var manifest = Repository.ParseYamlManifest(System.Text.Encoding.UTF8.GetBytes(yaml)); + + Assert.Equal(4, manifest.Icons.Count); + Assert.Equal("https://example.test/dark-512.png", manifest.Icons[0].IconUrl); + Assert.Equal("https://example.test/default-256.ico", manifest.IconUrl); + Assert.NotNull(manifest.Icon); + Assert.Equal("ico", manifest.Icon!.IconFileType); + Assert.Equal("256x256", manifest.Icon.IconResolution); + } + + [Fact] + public void ParseYamlManifest_WithoutIconsReturnsEmptyIconListAndNoSelection() + { + var yaml = """ + PackageIdentifier: Test.Package + PackageVersion: 1.2.3 + PackageName: Test Package + Installers: + - Architecture: x64 + InstallerType: exe + InstallerUrl: https://example.test/Test.Package.exe + InstallerSha256: ABC123 + """; + + var manifest = Repository.ParseYamlManifest(System.Text.Encoding.UTF8.GetBytes(yaml)); + + Assert.Empty(manifest.Icons); + Assert.Null(manifest.Icon); + Assert.Null(manifest.IconUrl); + } + [Fact] public void ParseYamlManifest_PreservesPlatformAndMinimumOsVersion() { @@ -2606,6 +2667,12 @@ public void ShowManifest_RestExactId_ReturnsSerializableManifestAndSelectedInsta Assert.Equal("Release notes", result.ReleaseNotes); Assert.Equal("https://example.test/release-notes", result.ReleaseNotesUrl); Assert.Equal(["ai", "cli"], result.Tags); + Assert.Equal("https://example.test/tessl-256.ico", result.IconUrl); + Assert.NotNull(result.Icon); + Assert.Equal("ico", result.Icon!.IconFileType); + Assert.Equal("256x256", result.Icon.IconResolution); + Assert.Equal(2, result.Icons.Count); + Assert.Equal("https://example.test/tessl-128.png", result.Icons[0].IconUrl); Assert.Equal(["Contoso.Dependency"], result.PackageDependencies); var installer = Assert.Single(result.Installers); Assert.Equal("https://example.test/tessl.exe", installer.InstallerUrl); @@ -2627,10 +2694,17 @@ public void ShowManifest_RestExactId_ReturnsSerializableManifestAndSelectedInsta }); Assert.Equal("Tessl short description", typedResult.Manifest.ShortDescription); + Assert.Equal("https://example.test/tessl-256.ico", typedResult.Manifest.IconUrl); var json = JsonSerializer.Serialize(result, PingetJsonContext.Default.SerializableShowManifest); using var document = JsonDocument.Parse(json); Assert.Equal(TesslPackageId, document.RootElement.GetProperty(nameof(SerializableShowManifest.PackageIdentifier)).GetString()); + Assert.Equal("https://example.test/tessl-256.ico", + document.RootElement.GetProperty(nameof(SerializableShowManifest.IconUrl)).GetString()); + Assert.Equal("https://example.test/tessl-256.ico", + document.RootElement.GetProperty(nameof(SerializableShowManifest.Icon)).GetProperty(nameof(PackageIcon.IconUrl)).GetString()); + Assert.Equal("https://example.test/tessl-128.png", + document.RootElement.GetProperty(nameof(SerializableShowManifest.Icons))[0].GetProperty(nameof(PackageIcon.IconUrl)).GetString()); Assert.Equal("https://example.test/tessl.exe", document.RootElement.GetProperty(nameof(SerializableShowManifest.SelectedInstaller)).GetProperty(nameof(SerializableInstaller.InstallerUrl)).GetString()); } @@ -3569,7 +3643,20 @@ public static TestRestResponse DefaultResponse(HttpListenerRequest request) "LicenseUrl": "https://example.test/license", "ReleaseNotes": "Release notes", "ReleaseNotesUrl": "https://example.test/release-notes", - "Tags": [ "ai", "cli" ] + "Tags": [ "ai", "cli" ], + "Icons": [ + { + "IconUrl": "https://example.test/tessl-128.png", + "IconFileType": "png", + "IconResolution": "128x128", + "IconTheme": "default" + }, + { + "IconUrl": "https://example.test/tessl-256.ico", + "IconFileType": "ico", + "IconResolution": "256x256" + } + ] }, "Versions": [ { diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 1ab3b5b..29ee8a2 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -320,12 +320,67 @@ public record Manifest public List Agreements { get; init; } = []; public List PackageDependencies { get; init; } = []; public List Documentation { get; init; } = []; + public List Icons { get; init; } = []; + public PackageIcon? Icon => SelectBestIcon(Icons); + public string? IconUrl => Icon?.IconUrl; public List Installers { get; init; } = []; // `RequireExplicitUpgrade: true` opts a package out of bulk // `pinget upgrade` output (winget parity). Users can still upgrade by // explicit id. Set at top-level or per-installer; treated as true // when any installer asserts it. public bool RequireExplicitUpgrade { get; init; } + + internal static PackageIcon? SelectBestIcon(IReadOnlyList icons) => + icons + .Select((icon, index) => (Icon: icon, Index: index)) + .Where(item => !string.IsNullOrWhiteSpace(item.Icon.IconUrl)) + .OrderByDescending(item => IconThemeScore(item.Icon.IconTheme)) + .ThenByDescending(item => IconResolutionScore(item.Icon.IconResolution)) + .ThenByDescending(item => IconFileTypeScore(item.Icon.IconFileType)) + .ThenBy(item => item.Index) + .Select(item => item.Icon) + .FirstOrDefault(); + + private static int IconThemeScore(string? theme) => + string.IsNullOrWhiteSpace(theme) || string.Equals(theme, "default", StringComparison.OrdinalIgnoreCase) ? 1 : 0; + + private static int IconFileTypeScore(string? fileType) + { + if (string.Equals(fileType, "ico", StringComparison.OrdinalIgnoreCase)) + return 2; + if (string.Equals(fileType, "png", StringComparison.OrdinalIgnoreCase)) + return 1; + return 0; + } + + private static int IconResolutionScore(string? resolution) + { + if (string.IsNullOrWhiteSpace(resolution)) + return 0; + + var normalized = resolution.Trim(); + var separator = normalized.IndexOf('x', StringComparison.OrdinalIgnoreCase); + if (separator <= 0 || separator == normalized.Length - 1) + return 0; + + if (!int.TryParse(normalized[..separator], out var width) || + !int.TryParse(normalized[(separator + 1)..], out var height) || + width <= 0 || + height <= 0 || + width != height) + return 0; + + return width; + } +} + +public record PackageIcon +{ + public string? IconUrl { get; init; } + public string? IconFileType { get; init; } + public string? IconResolution { get; init; } + public string? IconTheme { get; init; } + public string? IconSha256 { get; init; } } public record InstallRequest @@ -420,6 +475,9 @@ public SerializableShowManifest ToSerializableManifest() PackageDependencies = Manifest.PackageDependencies, Documentation = Manifest.Documentation, Agreements = Manifest.Agreements, + IconUrl = Manifest.IconUrl, + Icon = Manifest.Icon, + Icons = Manifest.Icons, Installers = Manifest.Installers.Select(SerializableInstaller.FromInstaller).ToList(), SelectedInstaller = SelectedInstaller is null ? null : SerializableInstaller.FromInstaller(SelectedInstaller), CachedFiles = CachedFiles, @@ -456,6 +514,9 @@ public record SerializableShowManifest public List PackageDependencies { get; init; } = []; public List Documentation { get; init; } = []; public List Agreements { get; init; } = []; + public string? IconUrl { get; init; } + public PackageIcon? Icon { get; init; } + public List Icons { get; init; } = []; public List Installers { get; init; } = []; public SerializableInstaller? SelectedInstaller { get; init; } public List CachedFiles { get; init; } = []; diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 73552d7..c160d28 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -1942,6 +1942,34 @@ static List ReadStringList(object? value) var agreements = ReadAgreements(dict); + var icons = new List(); + if (dict.TryGetValue("Icons", out var iconsObj) && iconsObj is IList iconList) + { + foreach (var iconObj in iconList) + { + if (iconObj is not IDictionary iconDict) + continue; + + string? IconStr(string key) => iconDict.TryGetValue(key, out var value) ? value?.ToString() : null; + var icon = new PackageIcon + { + IconUrl = IconStr("IconUrl"), + IconFileType = IconStr("IconFileType"), + IconResolution = IconStr("IconResolution"), + IconTheme = IconStr("IconTheme"), + IconSha256 = IconStr("IconSha256"), + }; + if (!string.IsNullOrWhiteSpace(icon.IconUrl) || + !string.IsNullOrWhiteSpace(icon.IconFileType) || + !string.IsNullOrWhiteSpace(icon.IconResolution) || + !string.IsNullOrWhiteSpace(icon.IconTheme) || + !string.IsNullOrWhiteSpace(icon.IconSha256)) + { + icons.Add(icon); + } + } + } + // Top-level installer defaults (merged manifest format) string? topInstallerType = GetOptStr("InstallerType"); string? topNestedInstallerType = GetOptStr("NestedInstallerType"); @@ -2048,6 +2076,7 @@ List InstArr(string key) Tags = tags, Agreements = agreements, Documentation = docs, + Icons = icons, Installers = installers, PackageDependencies = dependencies, RequireExplicitUpgrade = topLevelRequireExplicit || anyInstallerRequireExplicit, diff --git a/dotnet/src/Devolutions.Pinget.Core/RestSource.cs b/dotnet/src/Devolutions.Pinget.Core/RestSource.cs index 3c7cbd3..6f4092d 100644 --- a/dotnet/src/Devolutions.Pinget.Core/RestSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/RestSource.cs @@ -407,6 +407,42 @@ InstallerSwitches GetSwitches(JsonElement el) } } + List GetIcons(JsonElement locale) + { + var result = new List(); + if (!locale.TryGetProperty("Icons", out var iconsArr) || iconsArr.ValueKind != JsonValueKind.Array) + return result; + + foreach (var iconElement in iconsArr.EnumerateArray()) + { + if (iconElement.ValueKind != JsonValueKind.Object) + continue; + + var icon = new PackageIcon + { + IconUrl = GetOptStr(iconElement, "IconUrl"), + IconFileType = GetOptStr(iconElement, "IconFileType"), + IconResolution = GetOptStr(iconElement, "IconResolution"), + IconTheme = GetOptStr(iconElement, "IconTheme"), + IconSha256 = GetOptStr(iconElement, "IconSha256"), + }; + if (!string.IsNullOrWhiteSpace(icon.IconUrl) || + !string.IsNullOrWhiteSpace(icon.IconFileType) || + !string.IsNullOrWhiteSpace(icon.IconResolution) || + !string.IsNullOrWhiteSpace(icon.IconTheme) || + !string.IsNullOrWhiteSpace(icon.IconSha256)) + { + result.Add(icon); + } + } + + return result; + } + + var icons = GetIcons(defaultLocale); + if (icons.Count == 0 && data.TryGetProperty("DefaultLocale", out var packageDefaultLocale)) + icons = GetIcons(packageDefaultLocale); + var manifest = new Manifest { Id = GetStr(data, "PackageIdentifier").Length > 0 ? GetStr(data, "PackageIdentifier") : packageId, @@ -432,6 +468,7 @@ InstallerSwitches GetSwitches(JsonElement el) Agreements = agreements, PackageDependencies = GetPackageDependencies(installersSource), Documentation = docs, + Icons = icons, Installers = installers, }; diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index 3bfe36c..b3f4880 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -296,6 +296,7 @@ pub struct Manifest { pub agreements: Vec, pub package_dependencies: Vec, pub documentation: Vec, + pub icons: Vec, pub installers: Vec, // `RequireExplicitUpgrade: true` opts a package out of bulk // `pinget upgrade` output (winget parity). Users can still upgrade by @@ -304,6 +305,95 @@ pub struct Manifest { pub require_explicit_upgrade: bool, } +impl Manifest { + pub fn icon(&self) -> Option<&PackageIcon> { + select_best_icon(&self.icons) + } + + pub fn icon_url(&self) -> Option<&str> { + self.icon().and_then(|icon| icon.icon_url.as_deref()) + } +} + +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub struct PackageIcon { + pub icon_url: Option, + pub icon_file_type: Option, + pub icon_resolution: Option, + pub icon_theme: Option, + pub icon_sha256: Option, +} + +impl PackageIcon { + fn is_empty(&self) -> bool { + self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty()) + && self + .icon_file_type + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self + .icon_resolution + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty()) + && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty()) + } +} + +fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> { + icons + .iter() + .enumerate() + .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty())) + .max_by(|(left_index, left), (right_index, right)| { + icon_theme_score(left.icon_theme.as_deref()) + .cmp(&icon_theme_score(right.icon_theme.as_deref())) + .then_with(|| { + icon_resolution_score(left.icon_resolution.as_deref()) + .cmp(&icon_resolution_score(right.icon_resolution.as_deref())) + }) + .then_with(|| { + icon_file_type_score(left.icon_file_type.as_deref()) + .cmp(&icon_file_type_score(right.icon_file_type.as_deref())) + }) + .then_with(|| right_index.cmp(left_index)) + }) + .map(|(_, icon)| icon) +} + +fn icon_theme_score(theme: Option<&str>) -> usize { + match theme { + None => 1, + Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1, + Some(_) => 0, + } +} + +fn icon_file_type_score(file_type: Option<&str>) -> usize { + match file_type { + Some(value) if value.eq_ignore_ascii_case("ico") => 2, + Some(value) if value.eq_ignore_ascii_case("png") => 1, + _ => 0, + } +} + +fn icon_resolution_score(resolution: Option<&str>) -> usize { + let Some(resolution) = resolution else { + return 0; + }; + let resolution = resolution.trim(); + let Some(separator) = resolution.find(['x', 'X']) else { + return 0; + }; + let (width, height) = resolution.split_at(separator); + let height = &height[1..]; + let (Ok(width), Ok(height)) = (width.parse::(), height.parse::()) else { + return 0; + }; + if width == height { width } else { 0 } +} + #[derive(Debug, Clone, serde::Serialize)] pub struct Documentation { pub label: Option, @@ -478,7 +568,7 @@ pub struct ShowResult { impl ShowResult { pub fn structured_document(&self) -> JsonValue { - collapse_structured_document(&self.manifest_documents) + add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest) } } @@ -6345,6 +6435,7 @@ fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> { agreements: yaml_agreement_list(&merged), package_dependencies: yaml_package_dependencies(&merged), documentation: yaml_documentation_list(&merged), + icons: yaml_icon_list(&merged), installers, require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit, }, @@ -6377,6 +6468,7 @@ fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: & let default_locale = selected .get("DefaultLocale") .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?; + let package_default_locale = data.get("DefaultLocale"); let name = json_string(default_locale, "PackageName") .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?; let installer_switch_defaults = json_installer_switches(selected); @@ -6433,6 +6525,10 @@ fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: & .unwrap_or_default(); let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade"); let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade); + let mut icons = json_icon_list(default_locale); + if icons.is_empty() { + icons = package_default_locale.map(json_icon_list).unwrap_or_default(); + } let manifest = Manifest { id: package_id.to_owned(), @@ -6458,6 +6554,7 @@ fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: & agreements: json_agreement_list(default_locale), package_dependencies: json_package_dependencies(selected), documentation: json_documentation_list(default_locale), + icons, installers, require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit, }; @@ -6485,6 +6582,32 @@ fn collapse_structured_documents(documents: &[JsonValue]) -> Vec { documents.iter().map(collapse_structured_document).collect() } +fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue { + let Some(root) = document.as_object_mut() else { + return document; + }; + + root.insert( + "IconUrl".to_owned(), + manifest + .icon_url() + .map(|url| JsonValue::String(url.to_owned())) + .unwrap_or(JsonValue::Null), + ); + root.insert( + "Icon".to_owned(), + manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null), + ); + root.entry("Icons".to_owned()) + .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail")); + + document +} + +fn icon_to_json_value(icon: &PackageIcon) -> JsonValue { + serde_json::to_value(icon).expect("package icon serialization cannot fail") +} + fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue { let version = documents.iter().find(|document| { document @@ -6937,6 +7060,28 @@ fn yaml_documentation_list(root: &YamlMapping) -> Vec { .unwrap_or_default() } +fn yaml_icon_list(root: &YamlMapping) -> Vec { + root.get(YamlValue::from("Icons")) + .and_then(YamlValue::as_sequence) + .map(|items| { + items + .iter() + .filter_map(YamlValue::as_mapping) + .filter_map(|item| { + let icon = PackageIcon { + icon_url: yaml_string(item, "IconUrl"), + icon_file_type: yaml_string(item, "IconFileType"), + icon_resolution: yaml_string(item, "IconResolution"), + icon_theme: yaml_string(item, "IconTheme"), + icon_sha256: yaml_string(item, "IconSha256"), + }; + (!icon.is_empty()).then_some(icon) + }) + .collect() + }) + .unwrap_or_default() +} + fn yaml_agreement_list(root: &YamlMapping) -> Vec { root.get(YamlValue::from("Agreements")) .and_then(YamlValue::as_sequence) @@ -7019,6 +7164,28 @@ fn json_documentation_list(value: &JsonValue) -> Vec { .unwrap_or_default() } +fn json_icon_list(value: &JsonValue) -> Vec { + value + .get("Icons") + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let icon = PackageIcon { + icon_url: json_string(item, "IconUrl"), + icon_file_type: json_string(item, "IconFileType"), + icon_resolution: json_string(item, "IconResolution"), + icon_theme: json_string(item, "IconTheme"), + icon_sha256: json_string(item, "IconSha256"), + }; + (!icon.is_empty()).then_some(icon) + }) + .collect() + }) + .unwrap_or_default() +} + fn json_agreement_list(value: &JsonValue) -> Vec { value .get("Agreements") @@ -8995,6 +9162,7 @@ mod tests { agreements: Vec::new(), package_dependencies: Vec::new(), documentation: Vec::new(), + icons: Vec::new(), installers: Vec::new(), require_explicit_upgrade: false, } @@ -9782,6 +9950,13 @@ mod tests { label: Some("Docs".to_owned()), url: "https://example.test/docs".to_owned(), }], + icons: vec![PackageIcon { + icon_url: Some("https://example.test/default-256.ico".to_owned()), + icon_file_type: Some("ico".to_owned()), + icon_resolution: Some("256x256".to_owned()), + icon_theme: Some("default".to_owned()), + icon_sha256: Some("DEF456".to_owned()), + }], installers: vec![Installer { architecture: Some("x64".to_owned()), installer_type: Some("msix".to_owned()), @@ -9848,6 +10023,15 @@ mod tests { "Publisher": "Example", "License": "MIT", "ShortDescription": "Structured output", + "Icons": [ + { + "IconUrl": "https://example.test/default-256.ico", + "IconFileType": "ico", + "IconResolution": "256x256", + "IconTheme": "default", + "IconSha256": "DEF456" + } + ], "ManifestType": "defaultLocale", "ManifestVersion": "1.10.0" }), @@ -9889,6 +10073,15 @@ mod tests { document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(), Some("/quiet") ); + assert_eq!( + document["IconUrl"].as_str(), + Some("https://example.test/default-256.ico") + ); + assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico")); + assert_eq!( + document["Icons"][0]["IconUrl"].as_str(), + Some("https://example.test/default-256.ico") + ); } #[test] @@ -9975,6 +10168,132 @@ Installers: assert!(!manifest.require_explicit_upgrade); } + #[test] + fn manifest_parses_icons_and_selects_best_icon_from_yaml() { + let yaml = r#" +PackageIdentifier: Test.Package +PackageVersion: 1.2.3 +DefaultLocale: en-US +ManifestType: singleton +ManifestVersion: 1.10.0 +PackageLocale: en-US +PackageName: Test Package +Publisher: Example +License: MIT +ShortDescription: icon fixture +Icons: + - IconUrl: https://example.test/dark-512.png + IconFileType: png + IconResolution: 512x512 + IconTheme: dark + IconSha256: DARK + - IconUrl: https://example.test/default-64.png + IconFileType: png + IconResolution: 64x64 + IconTheme: default + - IconUrl: https://example.test/default-256.ico + IconFileType: ico + IconResolution: 256x256 + IconTheme: default + - IconFileType: png + IconResolution: 1024x1024 +Installers: + - Architecture: x64 + InstallerType: exe + InstallerUrl: https://example.test/Test.Package.exe + InstallerSha256: ABC123 +"#; + let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse"); + + assert_eq!(manifest.icons.len(), 4); + assert_eq!( + manifest.icons[0].icon_url.as_deref(), + Some("https://example.test/dark-512.png") + ); + assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico")); + let selected = manifest.icon().expect("selected icon"); + assert_eq!(selected.icon_file_type.as_deref(), Some("ico")); + assert_eq!(selected.icon_resolution.as_deref(), Some("256x256")); + } + + #[test] + fn manifest_without_icons_returns_empty_icon_list_and_no_selection() { + let yaml = r#" +PackageIdentifier: Test.Package +PackageVersion: 1.2.3 +DefaultLocale: en-US +ManifestType: singleton +ManifestVersion: 1.10.0 +PackageLocale: en-US +PackageName: Test Package +Publisher: Example +License: MIT +ShortDescription: no-icon fixture +Installers: + - Architecture: x64 + InstallerType: exe + InstallerUrl: https://example.test/Test.Package.exe + InstallerSha256: ABC123 +"#; + let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse"); + + assert!(manifest.icons.is_empty()); + assert!(manifest.icon().is_none()); + assert!(manifest.icon_url().is_none()); + } + + #[test] + fn rest_manifest_parses_icons_from_default_locale() { + let response = serde_json::json!({ + "Data": { + "PackageIdentifier": "Test.Package", + "Versions": [ + { + "PackageVersion": "1.2.3", + "DefaultLocale": { + "PackageName": "Test Package", + "Publisher": "Example", + "ShortDescription": "REST icon fixture", + "Icons": [ + { + "IconUrl": "https://example.test/default-128.png", + "IconFileType": "png", + "IconResolution": "128x128", + "IconTheme": "default" + }, + { + "IconUrl": "https://example.test/default-256.ico", + "IconFileType": "ico", + "IconResolution": "256x256" + } + ] + }, + "Installers": [ + { + "Architecture": "x64", + "InstallerType": "exe", + "InstallerUrl": "https://example.test/Test.Package.exe", + "InstallerSha256": "ABC123" + } + ], + "ManifestVersion": "1.10.0" + } + ], + "ManifestVersion": "1.10.0" + } + }); + let bytes = serde_json::to_vec(&response).expect("json"); + + let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse"); + + assert_eq!(manifest.icons.len(), 2); + assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico")); + assert_eq!( + document["Icons"][0]["IconUrl"].as_str(), + Some("https://example.test/default-128.png") + ); + } + #[test] fn upgrade_filter_hides_require_explicit_upgrade_by_default() { // winget hides `RequireExplicitUpgrade` rows from bulk `upgrade` @@ -10124,6 +10443,7 @@ Installers: agreements: Vec::new(), package_dependencies: Vec::new(), documentation: Vec::new(), + icons: Vec::new(), installers, require_explicit_upgrade: false, } @@ -10371,6 +10691,7 @@ Installers: agreements: Vec::new(), package_dependencies: Vec::new(), documentation: Vec::new(), + icons: Vec::new(), installers: Vec::new(), require_explicit_upgrade: false, }; @@ -10432,6 +10753,7 @@ Installers: agreements: Vec::new(), package_dependencies: Vec::new(), documentation: Vec::new(), + icons: Vec::new(), installers: Vec::new(), require_explicit_upgrade: false, }; @@ -10617,6 +10939,7 @@ Installers: agreements: Vec::new(), package_dependencies: Vec::new(), documentation: Vec::new(), + icons: Vec::new(), installers: Vec::new(), require_explicit_upgrade: false, }; @@ -10704,6 +11027,7 @@ Installers: agreements: Vec::new(), package_dependencies: Vec::new(), documentation: Vec::new(), + icons: Vec::new(), installers: Vec::new(), require_explicit_upgrade: false, }; @@ -10794,6 +11118,7 @@ Installers: agreements: Vec::new(), package_dependencies: Vec::new(), documentation: Vec::new(), + icons: Vec::new(), installers: Vec::new(), require_explicit_upgrade: false, };