Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
Expand Down
89 changes: 88 additions & 1 deletion dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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);
Expand All @@ -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());
}
Expand Down Expand Up @@ -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": [
{
Expand Down
61 changes: 61 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,67 @@ public record Manifest
public List<PackageAgreement> Agreements { get; init; } = [];
public List<string> PackageDependencies { get; init; } = [];
public List<Documentation> Documentation { get; init; } = [];
public List<PackageIcon> Icons { get; init; } = [];
public PackageIcon? Icon => SelectBestIcon(Icons);
public string? IconUrl => Icon?.IconUrl;
public List<Installer> 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<PackageIcon> 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -456,6 +514,9 @@ public record SerializableShowManifest
public List<string> PackageDependencies { get; init; } = [];
public List<Documentation> Documentation { get; init; } = [];
public List<PackageAgreement> Agreements { get; init; } = [];
public string? IconUrl { get; init; }
public PackageIcon? Icon { get; init; }
public List<PackageIcon> Icons { get; init; } = [];
public List<SerializableInstaller> Installers { get; init; } = [];
public SerializableInstaller? SelectedInstaller { get; init; }
public List<string> CachedFiles { get; init; } = [];
Expand Down
29 changes: 29 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,34 @@ static List<string> ReadStringList(object? value)

var agreements = ReadAgreements(dict);

var icons = new List<PackageIcon>();
if (dict.TryGetValue("Icons", out var iconsObj) && iconsObj is IList<object> iconList)
{
foreach (var iconObj in iconList)
{
if (iconObj is not IDictionary<object, object> 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");
Expand Down Expand Up @@ -2048,6 +2076,7 @@ List<string> InstArr(string key)
Tags = tags,
Agreements = agreements,
Documentation = docs,
Icons = icons,
Installers = installers,
PackageDependencies = dependencies,
RequireExplicitUpgrade = topLevelRequireExplicit || anyInstallerRequireExplicit,
Expand Down
37 changes: 37 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core/RestSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,42 @@ InstallerSwitches GetSwitches(JsonElement el)
}
}

List<PackageIcon> GetIcons(JsonElement locale)
{
var result = new List<PackageIcon>();
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,
Expand All @@ -432,6 +468,7 @@ InstallerSwitches GetSwitches(JsonElement el)
Agreements = agreements,
PackageDependencies = GetPackageDependencies(installersSource),
Documentation = docs,
Icons = icons,
Installers = installers,
};

Expand Down
Loading
Loading