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
2 changes: 1 addition & 1 deletion dotnet/src/Devolutions.Pinget.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using Devolutions.Pinget.Core;
using YamlDotNet.Serialization;

const string Version = "0.4.1";
const string Version = "0.4.2";
const string UpgradeUnsupportedWarning = "Upgrading packages is not supported on this platform; no changes were made.";

if (args.Length == 1 && (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(args[0], "-v", StringComparison.OrdinalIgnoreCase)))
Expand Down Expand Up @@ -437,7 +437,7 @@
Explicit = s.Explicit,
Priority = s.Priority,
});
Console.WriteLine(JsonSerializer.Serialize(new { Sources = sources }, JsonOpts));

Check warning on line 440 in dotnet/src/Devolutions.Pinget.Cli/Program.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-x64)

Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.

Check warning on line 440 in dotnet/src/Devolutions.Pinget.Cli/Program.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-arm64)

Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.
});

sourceAddCmd.SetHandler((ctx) =>
Expand Down
137 changes: 137 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,143 @@ public void RegistryEntryMatchesInstalledPackage_UsesLocalIdentityInsteadOfCorre
Assert.False(InstallerDispatch.RegistryEntryMatchesInstalledPackage("ShareX.ShareX", "ShareX.ShareX", null, installed));
}

[Fact]
public void CorrelateInstalledPackage_TiebreaksOnUnnormalizedNamePrefix()
{
// `Notepad++` and `Notepad--` both normalize to `notepad` (alphanumeric filter
// strips the trailing punctuation). Without a tiebreaker, iteration order
// decided the winner. The installed display name's prefix picks the correct one.
var installed = new InstalledPackage
{
Name = "Notepad++ (ARM 64-bit)",
LocalId = @"ARP\Machine\X64\Notepad++",
InstalledVersion = "8.8.9",
Scope = "Machine",
InstallerCategory = "exe",
PackageFamilyNames = [],
ProductCodes = [],
UpgradeCodes = [],
};
var candidates = new List<SearchMatch>
{
new()
{
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Id = "ndd.Notepad--",
Name = "Notepad--",
Version = "1.0.0",
},
new()
{
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Id = "Notepad++.Notepad++",
Name = "Notepad++",
Version = "8.9.5",
},
};

var correlated = Repository.CorrelateInstalledPackage(installed, candidates, loose: true);
Assert.NotNull(correlated);
Assert.Equal("Notepad++.Notepad++", correlated!.Id);
}

[Fact]
public void CorrelateInstalledPackage_PrefersAnchoredCandidateOverWordFragment()
{
// Loose substring match used to pick up `Studio` anywhere in the installed name
// — so `ZeroBrane.Studio` could outrank `Microsoft.DotNet.SDK.10` because both
// scored 700 and iteration order favored the alphabetically-later candidate.
var installed = new InstalledPackage
{
Name = "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio",
LocalId = @"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}",
InstalledVersion = "40.10.18029",
Scope = "Machine",
InstallerCategory = "msi",
PackageFamilyNames = [],
ProductCodes = [],
UpgradeCodes = [],
};
var candidates = new List<SearchMatch>
{
new() { SourceName = "winget", SourceKind = SourceKind.PreIndexed, Id = "ZeroBrane.Studio", Name = "Studio", Version = "1.0" },
new() { SourceName = "winget", SourceKind = SourceKind.PreIndexed, Id = "BrickLink.Studio", Name = "Studio", Version = "1.0" },
new() { SourceName = "winget", SourceKind = SourceKind.PreIndexed, Id = "Microsoft.DotNet.SDK.10", Name = "Microsoft .NET SDK 10.0", Version = "10.0.204" },
};

var correlated = Repository.CorrelateInstalledPackage(installed, candidates, loose: true);
Assert.NotNull(correlated);
Assert.Equal("Microsoft.DotNet.SDK.10", correlated!.Id);
}

[Fact]
public void CorrelateInstalledPackage_MsixCorrelatesByName()
{
// Previously hard-skipped via `LocalId.StartsWith("MSIX\\")` → null, which
// prevented obvious MSIX updates (Microsoft.Teams etc.) from ever surfacing.
// Name-based correlation now runs uniformly.
var installed = new InstalledPackage
{
Name = "Microsoft Teams",
LocalId = @"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe",
InstalledVersion = "25290.205.4069.4894",
Scope = "User",
InstallerCategory = "msix",
PackageFamilyNames = ["MSTeams_8wekyb3d8bbwe"],
ProductCodes = [],
UpgradeCodes = [],
};
var candidates = new List<SearchMatch>
{
new()
{
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Id = "Microsoft.Teams",
Name = "Microsoft Teams",
Version = "26106.1906.4665.7308",
},
};

var correlated = Repository.CorrelateInstalledPackage(installed, candidates, loose: true);
Assert.NotNull(correlated);
Assert.Equal("Microsoft.Teams", correlated!.Id);
}

[Fact]
public void CorrelateInstalledPackage_MsixWithResourceStringNameDoesNotCorrelate()
{
// Some MSIX entries have unresolved resource-string display names
// (e.g. `ms-resource:appDisplayName`). They shouldn't latch onto an
// unrelated catalog package via the loose substring rule.
var installed = new InstalledPackage
{
Name = "ms-resource:appDisplayName",
LocalId = @"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe",
InstalledVersion = "1.28.239.0",
Scope = "User",
InstallerCategory = "msix",
PackageFamilyNames = ["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"],
ProductCodes = [],
UpgradeCodes = [],
};
var candidates = new List<SearchMatch>
{
new()
{
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Id = "Microsoft.AppInstaller",
Name = "App Installer",
Version = "1.28.240.0",
},
};

Assert.Null(Repository.CorrelateInstalledPackage(installed, candidates, loose: true));
}

[Fact]
public void BuildUninstallCommand_AppendsSilentSwitchOnlyWhenNeeded()
{
Expand Down
44 changes: 36 additions & 8 deletions dotnet/src/Devolutions.Pinget.Core/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
if (Interlocked.Exchange(ref s_sqliteNativeLibraryInitialized, 1) != 0)
return;

var assemblyDirectory = Path.GetDirectoryName(typeof(Repository).Assembly.Location);

Check warning on line 84 in dotnet/src/Devolutions.Pinget.Core/Repository.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-x64)

'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

Check warning on line 84 in dotnet/src/Devolutions.Pinget.Core/Repository.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-arm64)

'System.Reflection.Assembly.Location.get' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.
if (string.IsNullOrWhiteSpace(assemblyDirectory))
return;

Expand Down Expand Up @@ -1395,7 +1395,7 @@
internal static object ParseYamlManifestDocuments(byte[] bytes)
{
var yaml = System.Text.Encoding.UTF8.GetString(bytes);
var deserializer = new DeserializerBuilder()

Check warning on line 1398 in dotnet/src/Devolutions.Pinget.Core/Repository.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-x64)

Using member 'YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This builder configures the deserializer to use reflection which is not compatible with ahead-of-time compilation or assembly trimming. You need to use the code generator/analyzer to generate static code and use the 'StaticDeserializerBuilder' object instead of this one.

Check warning on line 1398 in dotnet/src/Devolutions.Pinget.Core/Repository.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-arm64)

Using member 'YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This builder configures the deserializer to use reflection which is not compatible with ahead-of-time compilation or assembly trimming. You need to use the code generator/analyzer to generate static code and use the 'StaticDeserializerBuilder' object instead of this one.
.IgnoreUnmatchedProperties()
.Build();

Expand Down Expand Up @@ -1425,7 +1425,7 @@
internal static Manifest ParseYamlManifest(byte[] bytes)
{
var yaml = System.Text.Encoding.UTF8.GetString(bytes);
var deserializer = new DeserializerBuilder()

Check warning on line 1428 in dotnet/src/Devolutions.Pinget.Core/Repository.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-x64)

Using member 'YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This builder configures the deserializer to use reflection which is not compatible with ahead-of-time compilation or assembly trimming. You need to use the code generator/analyzer to generate static code and use the 'StaticDeserializerBuilder' object instead of this one.

Check warning on line 1428 in dotnet/src/Devolutions.Pinget.Core/Repository.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-arm64)

Using member 'YamlDotNet.Serialization.DeserializerBuilder.DeserializerBuilder()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This builder configures the deserializer to use reflection which is not compatible with ahead-of-time compilation or assembly trimming. You need to use the code generator/analyzer to generate static code and use the 'StaticDeserializerBuilder' object instead of this one.
.IgnoreUnmatchedProperties()
.Build();

Expand Down Expand Up @@ -1778,29 +1778,57 @@
return warnings;
}

private static SearchMatch? CorrelateInstalledPackage(InstalledPackage pkg, List<SearchMatch> candidates, bool loose)
internal static SearchMatch? CorrelateInstalledPackage(InstalledPackage pkg, List<SearchMatch> candidates, bool loose)
{
if (pkg.LocalId.StartsWith(@"MSIX\", StringComparison.OrdinalIgnoreCase))
return null;
// Note: MSIX packages used to be hard-skipped here, but that prevented obvious
// correlations like `Microsoft Teams` (MSIX) → `Microsoft.Teams` (catalog).
// Name-based correlation now applies uniformly; MSIX entries whose installed
// name is an unresolved resource string (e.g. `ms-resource:appDisplayName`)
// simply fail to match and return null, same as before.

var installedName = NormalizeCorrelationName(pkg.Name);
var installedNameLower = pkg.Name.ToLowerInvariant();
var candidateNames = CorrelationNameCandidates(pkg.Name);

SearchMatch? best = null;
int bestScore = 0;
foreach (var candidate in candidates)
{
var candidateNorm = NormalizeCorrelationName(candidate.Name);
int score;
int baseScore;
if (candidate.Id.Equals(pkg.LocalId, StringComparison.OrdinalIgnoreCase))
score = 1000;
baseScore = 1000;
else if (candidateNames.Any(n => NormalizeCorrelationName(n).Equals(candidateNorm, StringComparison.OrdinalIgnoreCase)))
score = 900;
baseScore = 900;
else if (loose && candidateNorm.Length >= 6 && installedName.Contains(candidateNorm, StringComparison.OrdinalIgnoreCase))
score = 700;
baseScore = 700;
else
baseScore = 0;

if (baseScore == 0) continue;

// Tiebreaker so we pick the *right* candidate when multiple catalog
// entries collapse to the same normalized name or all loose-match a
// long installed name. Two failure modes this addresses:
// • `Notepad++` and `Notepad--` both normalize to `notepad` (the
// alphanumeric filter strips `+`/`-`), so both score 900 and
// iteration order picked an arbitrary one.
// • Loose substring match picks up `Studio` inside
// `...from Visual Studio` and lets `ZeroBrane.Studio` outrank
// `Microsoft.DotNet.SDK.10` when both score 700.
// The bonus rewards catalog names that appear verbatim in the
// installed display name — strongly when they anchor the start,
// weaker when they're embedded.
var candidateLower = candidate.Name.ToLowerInvariant();
int prefixBonus;
if (installedNameLower.StartsWith(candidateLower, StringComparison.Ordinal))
prefixBonus = 100;
else if (installedNameLower.Contains(candidateLower, StringComparison.Ordinal))
prefixBonus = 50;
else
score = 0;
prefixBonus = 0;

var score = baseScore + prefixBonus;
if (score > bestScore) { bestScore = score; best = candidate; }
}
return best;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'Devolutions.Pinget.Client.psm1'
ModuleVersion = '0.4.1'
ModuleVersion = '0.4.2'
CompatiblePSEditions = @('Desktop', 'Core')
GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd'
Author = 'Devolutions'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine;

public static class PowerShellEngineVersion
{
public const string Current = "0.4.1";
public const string Current = "0.4.2";
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk" DefaultTargets="Pack">

<PropertyGroup>
<Version>0.4.1</Version>
<Version>0.4.2</Version>
<Company>Devolutions Inc.</Company>
<Authors>Devolutions</Authors>
<PackageId>Devolutions.Pinget.Cli.DotNet</PackageId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk" DefaultTargets="Pack">

<PropertyGroup>
<Version>0.4.1</Version>
<Version>0.4.2</Version>
<Company>Devolutions Inc.</Company>
<Authors>Devolutions</Authors>
<PackageId>Devolutions.Pinget.Cli.Rust</PackageId>
Expand Down
4 changes: 2 additions & 2 deletions rust/crates/pinget-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pinget-cli"
version = "0.4.1"
version = "0.4.2"
edition = "2024"

[lints]
Expand All @@ -13,7 +13,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
pinget-core = { version = "0.4.1", path = "../pinget-core" }
pinget-core = { version = "0.4.2", path = "../pinget-core" }
chrono = "0.4.44"
dirs = "6.0"
jsonschema = "0.30"
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/pinget-com/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pinget-com"
version = "0.4.1"
version = "0.4.2"
edition = "2024"
description = "Windows-only native COM bridge for Pinget backed by pinget-core."
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/pinget-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pinget-core"
version = "0.4.1"
version = "0.4.2"
edition = "2024"
description = "Pure Rust Pinget core library that works directly with source caches, REST endpoints, and installed package state without COM."
license = "MIT"
Expand Down
Loading
Loading