diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 4ed292f..c32f093 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -130,9 +130,12 @@ public void PackagedLayout_PathsResolveFromPackagedAppRoot() var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var appRoot = Path.Combine(localAppData, "Packages", SourceStoreManager.PackagedFamilyName, "LocalState"); + var source = SourceStore.Default().Sources.First(s => s.Name == "winget"); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState"), appRoot, StringComparison.OrdinalIgnoreCase); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "settings.json"), SettingsStoreManager.UserSettingsPath(appRoot), StringComparison.OrdinalIgnoreCase); Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "Microsoft", "Windows Package Manager"), SourceStoreManager.GetPackagedFileCacheRoot(appRoot), StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "Microsoft.PreIndexed.Package", "Microsoft.Winget.Source_8wekyb3d8bbwe"), SourceStoreManager.SourceStateDir(source, appRoot), StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName, "LocalState", "Microsoft.Winget.Source_8wekyb3d8bbwe", "installed.db"), SourceStoreManager.SourceInstalledDbPath(source, appRoot), StringComparison.OrdinalIgnoreCase); } [Fact] @@ -149,6 +152,31 @@ public void DefaultAppRootOutsidePackage_AvoidsPackagedLayout() Assert.DoesNotContain(Path.Combine("Packages", SourceStoreManager.PackagedFamilyName), appRoot, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void ShowMetadata_RetriesWingetManagerAliasOnlyForCleanMisses() + { + Assert.True(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: true, + source: "winget", + matchCount: 0, + failureCount: 0)); + Assert.False(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: false, + source: "winget", + matchCount: 0, + failureCount: 0)); + Assert.False(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: true, + source: "winget.pro", + matchCount: 0, + failureCount: 0)); + Assert.False(Repository.ShouldRetryShowAcrossSystemWingetSources( + usesSystemWingetSources: true, + source: "winget", + matchCount: 0, + failureCount: 1)); + } + [Fact] public void PackagedSourceYaml_OverlaysDefaultsAndMetadata() { @@ -192,24 +220,26 @@ public void PackagedSourceYaml_OverlaysDefaultsAndMetadata() public void SystemWingetExport_ParsesJsonLines() { const string output = """ -{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} -{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} +{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://cdn.winget.microsoft.com/cache","Data":"Microsoft.Winget.Source_8wekyb3d8bbwe","Explicit":false,"Identifier":"Microsoft.Winget.Source_8wekyb3d8bbwe","Name":"winget","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} +{"Arg":"https://cdn.winget.microsoft.com/fonts","Data":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Name":"winget-font","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} """; var sources = SystemWingetSourceStore.ParseExport(output); - Assert.Equal(["contoso", "contoso-cache"], sources.Select(source => source.Name).ToArray()); + Assert.Equal(["winget.pro", "msstore", "winget", "winget-font"], sources.Select(source => source.Name).ToArray()); var rest = sources[0]; Assert.Equal(SourceKind.Rest, rest.Kind); - Assert.Equal("https://api.contoso.test/feed", rest.Arg); - Assert.Equal("api.contoso.test", rest.Identifier); + Assert.Equal("https://api.winget.pro/feed", rest.Arg); + Assert.Equal("api.winget.pro", rest.Identifier); Assert.Equal("Trusted", rest.TrustLevel); Assert.False(rest.Explicit); - var preIndexed = sources[1]; + var preIndexed = sources[2]; Assert.Equal(SourceKind.PreIndexed, preIndexed.Kind); - Assert.Equal("Contoso.Source_8wekyb3d8bbwe", preIndexed.Identifier); - Assert.True(preIndexed.Explicit); + Assert.Equal("Microsoft.Winget.Source_8wekyb3d8bbwe", preIndexed.Identifier); + Assert.False(preIndexed.Explicit); } [Fact] @@ -361,6 +391,49 @@ public void RepositoryOpen_UsesCustomAppRoot() } } + [Fact] + public void SystemWingetMirror_UsesPrivateCacheAndPreservesMetadata() + { + var appRoot = TestPaths.CreateTempAppRoot(); + var originalRunner = SystemWingetSourceStore.CommandRunner; + try + { + SystemWingetSourceStore.CommandRunner = _ => new WingetCommandResult( + 0, + """ +{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +""", + ""); + + var (mode, store) = SourceStoreManager.LoadEffective(appRoot, SourceMode.SystemWingetMirror); + + Assert.Equal(EffectiveSourceMode.SystemWingetMirror, mode); + Assert.Single(store.Sources); + Assert.True(File.Exists(Path.Combine(appRoot, "system-sources.json"))); + Assert.False(File.Exists(Path.Combine(appRoot, "sources.json"))); + + var source = store.Sources[0]; + source.LastUpdate = DateTime.UtcNow; + source.SourceVersion = "cached-contract"; + source.ETag = "\"etag\""; + source.LastModified = "Wed, 03 Jun 2026 12:00:00 GMT"; + SourceStoreManager.SaveSystemWingetMirrorStore(appRoot, store); + + var refreshed = SourceStoreManager.RefreshSystemWingetMirrorStore(appRoot); + var refreshedSource = Assert.Single(refreshed.Sources); + + Assert.Equal("cached-contract", refreshedSource.SourceVersion); + Assert.Equal("\"etag\"", refreshedSource.ETag); + Assert.Equal(Path.Combine(appRoot, "sources", "api.contoso.test"), SourceStoreManager.SourceStateDir(refreshedSource, appRoot, identifierKeyed: true)); + Assert.Equal(Path.Combine(appRoot, "sources", "contoso"), SourceStoreManager.SourceStateDir(refreshedSource, appRoot)); + } + finally + { + SystemWingetSourceStore.CommandRunner = originalRunner; + TestPaths.DeleteAppRoot(appRoot); + } + } + [Fact] public void EditSourceAndResetSource_PreserveCustomMetadata() { @@ -951,6 +1024,55 @@ out string? sourceName Assert.Null(sourceName); } + [Fact] + public void ListMatchFromInstalled_UsesWinGetArpIdentityMetadata() + { + var package = new InstalledPackage + { + Name = "tessl", + LocalId = @"ARP\User\X64\tessl.tessl_api.winget.pro", + InstalledVersion = "0.77.0", + Publisher = "tessl.io", + Scope = "User", + InstallerCategory = "portable", + WinGetPackageIdentifier = "tessl.tessl", + WinGetSourceIdentifier = "api.winget.pro", + }; + + var item = Repository.ListMatchFromInstalledForTesting(package, []); + + Assert.Equal("tessl.tessl", item.Id); + Assert.Equal("api.winget.pro", item.SourceName); + } + + [Fact] + public void ListMatchFromInstalled_MapsWinGetArpSourceIdentifierToConfiguredName() + { + var package = new InstalledPackage + { + Name = "tessl", + LocalId = @"ARP\User\X64\tessl.tessl_api.winget.pro", + InstalledVersion = "0.77.0", + Publisher = "tessl.io", + Scope = "User", + InstallerCategory = "portable", + WinGetPackageIdentifier = "tessl.tessl", + WinGetSourceIdentifier = "api.winget.pro", + }; + SourceRecord source = new() + { + Name = "winget.pro", + Kind = SourceKind.Rest, + Arg = "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5", + Identifier = "api.winget.pro", + }; + + var item = Repository.ListMatchFromInstalledForTesting(package, [source]); + + Assert.Equal("tessl.tessl", item.Id); + Assert.Equal("winget.pro", item.SourceName); + } + } public class PinStoreTests @@ -1757,6 +1879,30 @@ public void LessThanLatestArpAnchoredVersion_HandlesCoarseDisplayVersion() Assert.Null(Repository.LessThanLatestArpAnchoredVersion(entries, "25.4.0")); } + [Fact] + public void GreaterThanLatestArpAnchoredVersion_HandlesServicedMsixFrameworkVersion() + { + // WindowsAppRuntime packages can be serviced beyond the latest winget + // catalog versionData range. winget renders those as `> latest` and + // does not report an available downgrade. + var entries = new List + { + new() { Version = "1.5.8", ArpMinVersion = "5001.373.1736.0", ArpMaxVersion = "5001.373.1736.0" }, + new() { Version = "1.5.7", ArpMinVersion = "5001.337.1906.0", ArpMaxVersion = "5001.337.1906.0" }, + }; + + Assert.Null(Repository.MapArpVersionToCatalog(entries, "5001.400.42.0")); + Assert.Null(Repository.LessThanLatestArpAnchoredVersion(entries, "5001.400.42.0")); + Assert.Equal("> 1.5.8", Repository.GreaterThanLatestArpAnchoredVersion(entries, "5001.400.42.0", "1.5.8")); + Assert.Null(Repository.GreaterThanLatestArpAnchoredVersion(entries, "5001.337.1906.0", "1.5.8")); + + var staleNormalAppEntries = new List + { + new() { Version = "2024.1.1", ArpMinVersion = "2024.1.1.0", ArpMaxVersion = "2024.1.1.0" }, + }; + Assert.Null(Repository.GreaterThanLatestArpAnchoredVersion(staleNormalAppEntries, "2026.1.3.0", "2026.2.0.0")); + } + [Fact] public void LatestArpAnchoredVersion_SkipsInternalRows() { @@ -1783,6 +1929,47 @@ public void LatestArpAnchoredVersion_ReturnsNullWhenNoBounds() Assert.Null(Repository.LatestArpAnchoredVersion(entries)); } + [Fact] + public void SuppressDuplicateAvailableVersions_KeepsAvailableOnlyOnNewestInstalledRow() + { + var rows = new List + { + new() + { + Name = "Example 1.0", + Id = "Example.Tool", + LocalId = @"ARP\Machine\X64\old", + InstalledVersion = "1.0.0", + AvailableVersion = "3.0.0", + SourceName = "winget", + }, + new() + { + Name = "Example 2.0", + Id = "Example.Tool", + LocalId = @"ARP\Machine\X64\new", + InstalledVersion = "2.0.0", + AvailableVersion = "3.0.0", + SourceName = "winget", + }, + new() + { + Name = "Other", + Id = "Other.Tool", + LocalId = @"ARP\Machine\X64\other", + InstalledVersion = "1.0.0", + AvailableVersion = "2.0.0", + SourceName = "winget", + }, + }; + + var result = Repository.SuppressDuplicateAvailableVersions(rows); + + Assert.Null(result[0].AvailableVersion); + Assert.Equal("3.0.0", result[1].AvailableVersion); + Assert.Equal("2.0.0", result[2].AvailableVersion); + } + [Fact] public void NormalizePublisher_Microsoft_Corporation_Strips_To_Microsoft() { @@ -2037,6 +2224,43 @@ public void LookupUniqueNormalizedIdentity_MissesWhenPublisherDoesNotMatch() Assert.Null(rowid); } + [Fact] + public void LookupInstalledDbIdentityMatch_ReturnsProductCodeMatch() + { + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); + connection.Open(); + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = @" + CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL); + CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL); + CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL); + CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL); + INSERT INTO ids VALUES (1, 'Git.Git'); + INSERT INTO names VALUES (1, 'Git'); + INSERT INTO manifest VALUES (10, 1, 1); + INSERT INTO productcodes VALUES (20, 'git_is1'); + INSERT INTO productcodes_map VALUES (10, 20);"; + cmd.ExecuteNonQuery(); + } + + var package = new InstalledPackage + { + Name = "Git", + LocalId = @"ARP\Machine\X64\Git_is1", + InstalledVersion = "2.54.0", + ProductCodes = ["git_is1"], + }; + + var hit = Repository.LookupInstalledDbIdentityMatchForTesting(connection, package); + + Assert.NotNull(hit); + Assert.Equal("Git.Git", hit.Value.Id); + Assert.Equal("Git", hit.Value.Name); + Assert.Equal("ProductCode", hit.Value.MatchedBy); + } + [Fact] public void UpgradeFilter_HidesRequireExplicitUpgrade_ByDefault() { @@ -2240,6 +2464,34 @@ public void UnflipPackedGuid_ReversesMsiInstallerPacking() Assert.Null(InstalledPackages.UnflipPackedGuid("ZZZZZZZZ593B4D2488740C8E00C4F652")); } + [Fact] + public void ParseMsixFullName_ReturnsBasePackageMetadata() + { + var parsed = InstalledPackages.ParseMsixFullName( + "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_x64__8wekyb3d8bbwe"); + + Assert.NotNull(parsed); + Assert.Equal("1.97.17002.0", parsed.Value.Version); + Assert.Equal("", parsed.Value.ResourceId); + Assert.Equal("Microsoft.XboxSpeechToTextOverlay_8wekyb3d8bbwe", parsed.Value.FamilyName); + Assert.False(InstalledPackages.IsMsixSplitResourcePackage(parsed.Value.ResourceId)); + } + + [Fact] + public void ParseMsixFullName_ClassifiesSplitResourcePackages() + { + var parsed = InstalledPackages.ParseMsixFullName( + "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe"); + + Assert.NotNull(parsed); + Assert.Equal("1.97.17002.0", parsed.Value.Version); + Assert.Equal("split.scale-125", parsed.Value.ResourceId); + Assert.Equal( + "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe", + parsed.Value.FamilyName); + Assert.True(InstalledPackages.IsMsixSplitResourcePackage(parsed.Value.ResourceId)); + } + [Fact] public void DedupeCorrelatedForUpgrade_PrefersCanonicalRowOverRawArp() { diff --git a/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs b/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs index 7343d30..ed94d3d 100644 --- a/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs +++ b/dotnet/src/Devolutions.Pinget.Core/InstalledPackages.cs @@ -185,6 +185,8 @@ private static void CollectArpPackages( var packageFamilyName = subkey.GetValue("PackageFamilyName") as string; var productCode = subkey.GetValue("ProductCode") as string; var upgradeCode = subkey.GetValue("UpgradeCode") as string; + var wingetPackageIdentifier = subkey.GetValue("WinGetPackageIdentifier") as string; + var wingetSourceIdentifier = subkey.GetValue("WinGetSourceIdentifier") as string; var localId = $@"ARP\{scopeLabel}\{effectiveArch}\{subkeyName}"; // Honor the WinGet ARP signal so uninstall flows through the @@ -246,6 +248,8 @@ private static void CollectArpPackages( Scope = scopeLabel, InstallerCategory = installerCategory, InstallLocation = installLocation, + WinGetPackageIdentifier = wingetPackageIdentifier, + WinGetSourceIdentifier = wingetSourceIdentifier, PackageFamilyNames = packageFamilyNames, ProductCodes = productCodes, UpgradeCodes = upgradeCodes, @@ -279,16 +283,19 @@ private static void CollectAppModelPackages( if (subkey is null) continue; - var displayName = subkey.GetValue("DisplayName") as string; - if (string.IsNullOrWhiteSpace(displayName)) - continue; - var installLocation = subkey.GetValue("PackageRootFolder") as string; if (IsWindowsSystemPath(installLocation)) continue; var parsed = ParseMsixFullName(subkeyName); if (parsed is null) continue; + if (IsMsixSplitResourcePackage(parsed.Value.ResourceId)) continue; + + var displayName = subkey.GetValue("DisplayName") as string; + if (string.IsNullOrWhiteSpace(displayName)) + displayName = GetAppModelDisplayName(subkey); + if (string.IsNullOrWhiteSpace(displayName)) + continue; var localId = $@"MSIX\{subkeyName}"; var dedupKey = $"{localId}|{displayName.ToLowerInvariant()}|{parsed.Value.Version.ToLowerInvariant()}"; @@ -316,7 +323,25 @@ private static bool IsWindowsSystemPath(string? path) return path.Trim().StartsWith(@"C:\Windows\", StringComparison.OrdinalIgnoreCase); } - private static (string Version, string FamilyName)? ParseMsixFullName(string fullName) + [SupportedOSPlatform("windows")] + private static string? GetAppModelDisplayName(Microsoft.Win32.RegistryKey packageKey) + { + using var appKey = packageKey.OpenSubKey(@"App\Capabilities"); + if (appKey is null) return null; + + var description = appKey.GetValue("ApplicationDescription") as string; + if (!string.IsNullOrWhiteSpace(description)) + return description; + + var name = appKey.GetValue("ApplicationName") as string; + return string.IsNullOrWhiteSpace(name) ? null : name; + } + + internal static bool IsMsixSplitResourcePackage(string? resourceId) => + !string.IsNullOrWhiteSpace(resourceId) + && resourceId.StartsWith("split.", StringComparison.OrdinalIgnoreCase); + + internal static MsixPackageFullName? ParseMsixFullName(string fullName) { var parts = fullName.Split('_'); if (parts.Length < 5) return null; @@ -335,6 +360,8 @@ private static (string Version, string FamilyName)? ParseMsixFullName(string ful ? $"{name}_{publisherHash}" : $"{name}_{resourceId}_{publisherHash}"; - return (version, familyName); + return new MsixPackageFullName(version, resourceId, familyName); } + + internal readonly record struct MsixPackageFullName(string Version, string ResourceId, string FamilyName); } diff --git a/dotnet/src/Devolutions.Pinget.Core/Models.cs b/dotnet/src/Devolutions.Pinget.Core/Models.cs index 56e6a5d..50f1059 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Models.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Models.cs @@ -16,6 +16,13 @@ public enum PinType Gating } +public enum SourceMode +{ + Auto, + Private, + SystemWingetMirror +} + public record SourceRecord { public required string Name { get; init; } @@ -37,9 +44,10 @@ public record SourceRecord public record RepositoryOptions { /// - /// Storage root for source state and caches. A null value uses the real Desktop App Installer / WinGet state on Windows. + /// Storage root for Pinget state and caches. /// public string? AppRoot { get; init; } + public SourceMode SourceMode { get; init; } = SourceMode.Auto; public string UserAgent { get; init; } = "pinget-dotnet/0.1"; public Action? Diagnostics { get; init; } @@ -651,6 +659,8 @@ internal record InstalledPackage public string? Scope { get; init; } public string? InstallerCategory { get; init; } public string? InstallLocation { get; init; } + public string? WinGetPackageIdentifier { get; init; } + public string? WinGetSourceIdentifier { get; init; } public List PackageFamilyNames { get; init; } = []; public List ProductCodes { get; init; } = []; public List UpgradeCodes { get; init; } = []; diff --git a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs index 22ca99d..019c092 100644 --- a/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/PreIndexedSource.cs @@ -12,22 +12,22 @@ internal static class PreIndexedSource { private static readonly string[] MsixCandidates = ["source2.msix", "source.msix"]; - public static string IndexPath(SourceRecord source, string? appRoot = null) + public static string IndexPath(SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); return Path.Combine(stateDir, "index.db"); } - public static string Update(HttpClient client, SourceRecord source, string? appRoot = null) + public static string Update(HttpClient client, SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); Directory.CreateDirectory(stateDir); Exception? lastError = null; foreach (var candidate in MsixCandidates) { var url = $"{source.Arg.TrimEnd('/')}/{candidate}"; - var hasLocalIndex = File.Exists(IndexPath(source, appRoot)); + var hasLocalIndex = File.Exists(IndexPath(source, appRoot, identifierKeyed)); try { if (hasLocalIndex && TrySkipUnchangedBySourceVersion(client, url, source, candidate, out var detail)) @@ -59,7 +59,7 @@ public static string Update(HttpClient client, SourceRecord source, string? appR var indexEntry = archive.GetEntry("Public/index.db") ?? throw new InvalidOperationException($"No Public/index.db in {candidate}"); - var indexPath = IndexPath(source, appRoot); + var indexPath = IndexPath(source, appRoot, identifierKeyed); var tempIndexPath = Path.Combine(stateDir, $"index.{Guid.NewGuid():N}.tmp"); try { @@ -571,9 +571,9 @@ public static byte[] GetCachedSourceFile( return bytes; } - public static byte[] GetCachedSourceFileFromMsix(SourceRecord source, string relativePath, string? appRoot = null) + public static byte[] GetCachedSourceFileFromMsix(SourceRecord source, string relativePath, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); foreach (var candidate in MsixCandidates) { var msixPath = Path.Combine(stateDir, candidate); diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index 844fb44..e14e503 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -28,10 +28,12 @@ public class Repository : IDisposable private readonly string _appRoot; private readonly HttpClient _client; - private readonly bool _useSystemWingetSources; private readonly Action? _diagnostics; private readonly TimeSpan? _preIndexedSourceAutoUpdateInterval; private SourceStore _store; + private readonly EffectiveSourceMode _sourceMode; + private bool UsesSystemWingetSources => _sourceMode != EffectiveSourceMode.Private; + private bool MirrorsSystemWingetSources => _sourceMode == EffectiveSourceMode.SystemWingetMirror; private static readonly ConcurrentDictionary s_preIndexedRefreshLocks = new(StringComparer.OrdinalIgnoreCase); private static readonly TimeSpan s_preIndexedRefreshRetryInterval = TimeSpan.FromMinutes(5); private readonly object _preIndexedRefreshAttemptGate = new(); @@ -41,14 +43,14 @@ private Repository( string appRoot, HttpClient client, SourceStore store, - bool useSystemWingetSources, + EffectiveSourceMode sourceMode, Action? diagnostics, TimeSpan? preIndexedSourceAutoUpdateInterval) { _appRoot = appRoot; _client = client; _store = store; - _useSystemWingetSources = useSystemWingetSources; + _sourceMode = sourceMode; _diagnostics = diagnostics; _preIndexedSourceAutoUpdateInterval = preIndexedSourceAutoUpdateInterval; } @@ -60,10 +62,13 @@ public static Repository Open(RepositoryOptions? options = null) { options ??= new RepositoryOptions(); EnsureSqliteNativeLibraryLoaded(); - var appRoot = SourceStoreManager.NormalizeAppRoot(options.AppRoot ?? Environment.GetEnvironmentVariable(AppRootEnvironmentVariable)); + var requestedAppRoot = options.AppRoot ?? Environment.GetEnvironmentVariable(AppRootEnvironmentVariable); + var appRoot = SourceStoreManager.NormalizeAppRoot(requestedAppRoot); SourceStoreManager.EnsureAppDirs(appRoot); - var useSystemWingetSources = SourceStoreManager.UsesSystemWingetSourceCommands(appRoot); - var store = SourceStoreManager.Load(appRoot); + var requestedSourceMode = options.SourceMode == SourceMode.Auto && requestedAppRoot is not null + ? SourceMode.Private + : options.SourceMode; + var (sourceMode, store) = SourceStoreManager.LoadEffective(appRoot, requestedSourceMode); var client = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = System.Net.DecompressionMethods.None, @@ -71,7 +76,7 @@ public static Repository Open(RepositoryOptions? options = null) }); client.Timeout = Timeout.InfiniteTimeSpan; client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent); - return new Repository(appRoot, client, store, useSystemWingetSources, options.Diagnostics, options.PreIndexedSourceAutoUpdateInterval); + return new Repository(appRoot, client, store, sourceMode, options.Diagnostics, options.PreIndexedSourceAutoUpdateInterval); } internal static IEnumerable GetSqliteNativeLibraryCandidates(string assemblyDirectory) @@ -177,7 +182,7 @@ public List ListSources() public void AddSource(string name, string arg, SourceKind kind, string trustLevel = "None", bool explicitSource = false, int priority = 0) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.AddSource(name, arg, kind, NormalizeSourceTrustLevel(trustLevel), explicitSource); RefreshSystemWingetSources(); @@ -204,7 +209,7 @@ public void AddSource(string name, string arg, SourceKind kind, string trustLeve public void EditSource(string name, bool? explicitSource = null, string? trustLevel = null) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) throw new InvalidOperationException("Editing system WinGet sources is not supported by winget source commands."); var source = _store.Sources.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)) @@ -221,7 +226,7 @@ public void EditSource(string name, bool? explicitSource = null, string? trustLe public void RemoveSource(string name) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.RemoveSource(name); RefreshSystemWingetSources(); @@ -232,7 +237,7 @@ public void RemoveSource(string name) ?? throw new InvalidOperationException($"Source '{name}' not found."); _store.Sources.Remove(source); - var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources); if (Directory.Exists(stateDir)) Directory.Delete(stateDir, recursive: true); SourceStoreManager.Save(_store, _appRoot); @@ -240,7 +245,7 @@ public void RemoveSource(string name) public void ResetSource(string name) { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.ResetSource(name); RefreshSystemWingetSources(); @@ -252,7 +257,7 @@ public void ResetSource(string name) throw new InvalidOperationException($"Source '{name}' not found."); var source = _store.Sources[index]; - var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources); if (Directory.Exists(stateDir)) Directory.Delete(stateDir, recursive: true); @@ -276,7 +281,7 @@ public void ResetSource(string name) public void ResetSources() { - if (_useSystemWingetSources) + if (UsesSystemWingetSources) { SystemWingetSourceStore.ResetSources(); RefreshSystemWingetSources(); @@ -285,7 +290,7 @@ public void ResetSources() foreach (var source in _store.Sources) { - var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources); if (Directory.Exists(stateDir)) Directory.Delete(stateDir, recursive: true); } @@ -366,9 +371,12 @@ public void EnsureSettingsFiles() public List UpdateSources(string? sourceName = null) { - if (_useSystemWingetSources) + if (_sourceMode == EffectiveSourceMode.SystemWingetDirect) return UpdateSystemWingetSources(sourceName); + if (MirrorsSystemWingetSources) + RefreshSystemWingetSources(); + var indexes = ResolveSourceIndexes(sourceName); var results = new List(); @@ -377,15 +385,15 @@ public List UpdateSources(string? sourceName = null) var source = _store.Sources[index]; var detail = source.Kind switch { - SourceKind.PreIndexed => PreIndexedSource.Update(_client, source, _appRoot), - SourceKind.Rest => RestSource.UpdateRest(_client, source, _appRoot), + SourceKind.PreIndexed => PreIndexedSource.Update(_client, source, _appRoot, MirrorsSystemWingetSources), + SourceKind.Rest => RestSource.UpdateRest(_client, source, _appRoot, MirrorsSystemWingetSources), _ => "Unknown source kind" }; source.LastUpdate = DateTime.UtcNow; results.Add(new SourceUpdateResult { Name = source.Name, Kind = source.Kind, Detail = detail }); } - SourceStoreManager.Save(_store, _appRoot); + SaveStore(); return results; } @@ -417,7 +425,7 @@ public SearchResponse Search(PackageQuery query) public ShowResult Show(PackageQuery query) { - var (located, warnings, sourceWarnings) = FindSingleMatch(query); + var (located, warnings, sourceWarnings) = FindSingleMatchForShow(query, SearchSemantics.Single); return CreateShowResult(located, query, warnings, sourceWarnings); } @@ -483,7 +491,7 @@ private ShowResult CreateShowResult( public VersionsResult ShowVersions(PackageQuery query) { - var (located, warnings, sourceWarnings) = FindSingleMatch(query); + var (located, warnings, sourceWarnings) = FindSingleMatchForShow(query, SearchSemantics.Single); var versions = VersionsForMatch(located, query); return new VersionsResult { Package = located.Display, Versions = versions, Warnings = warnings, SourceWarnings = sourceWarnings }; } @@ -508,6 +516,8 @@ public CacheWarmResult WarmCache(PackageQuery query) public ListResponse List(ListQuery query) { + RefreshSystemWingetSources(); + if ((query.IncludeUnknown || query.IncludePinned) && !query.UpgradeOnly) throw new InvalidOperationException("--include-unknown and --include-pinned require --upgrade-available"); @@ -525,6 +535,7 @@ public ListResponse List(ListQuery query) // Even plain `list` should canonicalize installed package ids and // sources when the local package can be correlated back to a source // catalog entry. Available-version lookups stay gated below. + warnings.AddRange(CorrelateInstalledViaInstalledDb(installed, query.Source)); warnings.AddRange(CorrelateInstalledViaIndex(installed, query.Source)); warnings.AddRange(CorrelateInstalledByNormalizedIdentity(installed, query.Source)); @@ -585,6 +596,8 @@ public ListResponse List(ListQuery query) .Select(ListMatchFromInstalled) .Where(match => !query.UpgradeOnly || query.IncludePinned || !IsUpgradeBlockedByPin(match, pins)) .ToList(); + if (!query.UpgradeOnly) + listMatches = SuppressDuplicateAvailableVersions(listMatches); bool truncated = false; if (query.Count is int limit) @@ -1504,7 +1517,7 @@ private record SourceSearchFailure(RepositoryWarning Warning, Exception Exceptio RestSource.RestInformation info; try { - info = RestSource.LoadInformation(_client, source, _appRoot); + info = RestSource.LoadInformation(_client, source, _appRoot, MirrorsSystemWingetSources); } catch (Exception ex) { @@ -1574,8 +1587,8 @@ private static string SourceRequestUri(SourceRecord source, string operation) => private string? SourceDiagnosticCachePath(SourceRecord source) => source.Kind switch { - SourceKind.PreIndexed => PreIndexedSource.IndexPath(source, _appRoot), - SourceKind.Rest => Path.Combine(SourceStoreManager.SourceStateDir(source, _appRoot), "rest_info.json"), + SourceKind.PreIndexed => PreIndexedSource.IndexPath(source, _appRoot, MirrorsSystemWingetSources), + SourceKind.Rest => Path.Combine(SourceStoreManager.SourceStateDir(source, _appRoot, MirrorsSystemWingetSources), "rest_info.json"), _ => null, }; @@ -1607,7 +1620,7 @@ private SqliteConnection OpenPreindexedConnection(int sourceIndex) => private SqliteConnection OpenPreindexedConnection(int sourceIndex, out PreIndexedRefreshOutcome refreshOutcome) { var source = _store.Sources[sourceIndex]; - var indexPath = PreIndexedSource.IndexPath(source, _appRoot); + var indexPath = PreIndexedSource.IndexPath(source, _appRoot, MirrorsSystemWingetSources); refreshOutcome = EnsurePreindexedIndex(sourceIndex, indexPath); var conn = new SqliteConnection($"Data Source={indexPath};Mode=ReadOnly;Pooling=False"); @@ -1667,7 +1680,7 @@ private bool IsPreindexedIndexStale(SourceRecord source, string indexPath) private string RefreshPreindexedSource(int sourceIndex, bool force, PreIndexedRefreshKind kind) { var source = _store.Sources[sourceIndex]; - var indexPath = PreIndexedSource.IndexPath(source, _appRoot); + var indexPath = PreIndexedSource.IndexPath(source, _appRoot, MirrorsSystemWingetSources); var lockKey = PreindexedRefreshLockKey(source); var refreshLock = s_preIndexedRefreshLocks.GetOrAdd(lockKey, _ => new object()); @@ -1678,10 +1691,10 @@ private string RefreshPreindexedSource(int sourceIndex, bool force, PreIndexedRe try { - var detail = PreIndexedSource.Update(_client, source, _appRoot); + var detail = PreIndexedSource.Update(_client, source, _appRoot, MirrorsSystemWingetSources); source.LastUpdate = DateTime.UtcNow; - if (!_useSystemWingetSources) - SourceStoreManager.Save(_store, _appRoot); + if (_sourceMode != EffectiveSourceMode.SystemWingetDirect) + SaveStore(); RecordPreindexedRefreshAttempt(lockKey, null, kind); return detail; } @@ -1744,23 +1757,58 @@ private static MissingPackageVersionException WithRefreshFailure(MissingPackageV private (LocatedMatch Match, List Warnings, List SourceWarnings) FindSingleMatch(PackageQuery query) => FindSingleMatchWithSemantics(query, SearchSemantics.Single); + private (LocatedMatch Match, List Warnings, List SourceWarnings) FindSingleMatchForShow( + PackageQuery query, + SearchSemantics semantics) + { + var located = SearchLocated(query, semantics); + if (ShouldRetryShowAcrossSystemWingetSources(query, located.Matches, located.Failures)) + { + var fallbackQuery = query with { Source = null }; + return SingleMatchFromLocated(SearchLocated(fallbackQuery, semantics), reportFirstSourceFailure: false); + } + + return SingleMatchFromLocated(located, reportFirstSourceFailure: query.Source is not null); + } + + private bool ShouldRetryShowAcrossSystemWingetSources( + PackageQuery query, + IReadOnlyCollection matches, + IReadOnlyCollection failures) => + ShouldRetryShowAcrossSystemWingetSources(UsesSystemWingetSources, query.Source, matches.Count, failures.Count); + + internal static bool ShouldRetryShowAcrossSystemWingetSources( + bool usesSystemWingetSources, + string? source, + int matchCount, + int failureCount) => + usesSystemWingetSources && + string.Equals(source, "winget", StringComparison.OrdinalIgnoreCase) && + matchCount == 0 && + failureCount == 0; + private (LocatedMatch Match, List Warnings, List SourceWarnings) FindSingleMatchWithSemantics( PackageQuery query, SearchSemantics semantics) { - var (matches, warnings, failures, _) = SearchLocated(query, semantics); + return SingleMatchFromLocated(SearchLocated(query, semantics), reportFirstSourceFailure: query.Source is not null); + } - if (matches.Count == 0) + private static (LocatedMatch Match, List Warnings, List SourceWarnings) SingleMatchFromLocated( + (List Matches, List Warnings, List Failures, bool Truncated) located, + bool reportFirstSourceFailure) + { + if (located.Matches.Count == 0) { - if (query.Source is not null && failures.Count > 0) - throw new SourceSearchException(failures[0].Warning, failures[0].Exception); + if (reportFirstSourceFailure && located.Failures.Count > 0) + throw new SourceSearchException(located.Failures[0].Warning, located.Failures[0].Exception); throw new InvalidOperationException("no package matched the supplied query"); } - if (matches.Count > 1) - throw new MultiplePackageMatchesException(matches.Select(m => m.Display)); + if (located.Matches.Count > 1) + throw new MultiplePackageMatchesException(located.Matches.Select(m => m.Display)); - return (matches[0], warnings, failures.Select(f => f.Warning).ToList()); + return (located.Matches[0], located.Warnings, located.Failures.Select(f => f.Warning).ToList()); } private List VersionsForMatch(LocatedMatch located, PackageQuery query) @@ -1911,7 +1959,7 @@ private List VersionsFromV2(int sourceIndex, long packageRowid, stri PackageQuery query) { var source = _store.Sources[sourceIndex]; - var info = RestSource.LoadInformation(_client, source, _appRoot); + var info = RestSource.LoadInformation(_client, source, _appRoot, MirrorsSystemWingetSources); var selected = SelectRestVersion(versions, query.Version, query.Channel, display.Id, source.Name); var (manifest, structuredDocument) = RestSource.FetchManifestWithDocuments(_client, source, info, packageId, selected.Version, selected.Channel); return (manifest, structuredDocument, []); @@ -2838,6 +2886,57 @@ private record V2PackageMetadata(long Rowid, string Id, string Name, string? Mon return $"< {latest.Version}"; } + /// + /// Returns "> latest>" when ARP range metadata proves the installed + /// package version is newer than the newest catalog version that has ARP + /// bounds. This is common for inbox MSIX frameworks such as + /// WindowsAppRuntime, where Windows can service a package beyond the + /// latest winget catalog mapping. + /// + internal static string? GreaterThanLatestArpAnchoredVersion( + IReadOnlyList entries, + string arpVersion, + string catalogLatestVersion) + { + if (string.IsNullOrEmpty(arpVersion) || arpVersion.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + return null; + + PreIndexedSource.V2VersionDataEntry? latest = null; + foreach (var entry in entries) + { + if (entry.ArpMinVersion is null || entry.ArpMaxVersion is null) continue; + if (latest is null || RestSource.CompareVersionStrings(entry.Version, latest.Version) > 0) + latest = entry; + } + if (latest?.ArpMaxVersion is null) + return null; + if (!latest.Version.Equals(catalogLatestVersion, StringComparison.OrdinalIgnoreCase)) + return null; + if (!HasDifferentLeadingVersionNumber(latest.ArpMaxVersion, latest.Version)) + return null; + if (RestSource.CompareVersionStrings(arpVersion, latest.ArpMaxVersion) <= 0) + return null; + + return $"> {latest.Version}"; + } + + private static bool HasDifferentLeadingVersionNumber(string left, string right) + { + var leftLeading = LeadingVersionNumber(left); + var rightLeading = LeadingVersionNumber(right); + return leftLeading is not null && rightLeading is not null && + !leftLeading.Equals(rightLeading, StringComparison.OrdinalIgnoreCase); + } + + private static string? LeadingVersionNumber(string value) + { + var trimmed = value.TrimStart(); + int length = 0; + while (length < trimmed.Length && char.IsAsciiDigit(trimmed[length])) + length++; + return length == 0 ? null : trimmed[..length]; + } + /// /// Returns the latest catalog Version that carries ARP-range metadata. /// Packages like `Microsoft.WindowsAppRuntime.1.8` also publish "internal" @@ -2859,6 +2958,151 @@ private record V2PackageMetadata(long Rowid, string Id, string Name, string? Mon return best?.Version; } + /// + /// Uses winget's per-source installed tracking DB when available. This is + /// an exact identity signal recorded by winget itself, and it preserves + /// source order for sources that do not expose a preindexed identity table + /// such as REST sources. + /// + private List CorrelateInstalledViaInstalledDb(List installed, string? requestedSource) + { + var warnings = new List(); + for (int sourceIndex = 0; sourceIndex < _store.Sources.Count; sourceIndex++) + { + var source = _store.Sources[sourceIndex]; + if (requestedSource is not null && !source.Name.Equals(requestedSource, StringComparison.OrdinalIgnoreCase)) + continue; + + var installedDbPath = SourceStoreManager.SourceInstalledDbPath(source, _appRoot, MirrorsSystemWingetSources); + if (!File.Exists(installedDbPath)) + continue; + + using var conn = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={installedDbPath};Mode=ReadOnly;Pooling=False"); + conn.Open(); + if (!InstalledDbIdentityTablesPresent(conn)) + continue; + + for (int i = 0; i < installed.Count; i++) + { + if (installed[i].Correlated is not null) continue; + var hit = LookupInstalledDbIdentityMatch(conn, installed[i]); + if (hit is null) continue; + + string? version = null; + string? moniker = null; + if (source.Kind == SourceKind.PreIndexed) + { + try + { + using var indexConn = OpenPreindexedConnection(sourceIndex); + var meta = LookupV2MetadataById(indexConn, hit.Value.Id); + if (meta is not null) + { + hit = (meta.Id, meta.Name, hit.Value.MatchedBy); + moniker = meta.Moniker; + + var (entries, _) = PreIndexedSource.LoadV2VersionData(_client, indexConn, source, meta.Rowid, meta.PackageHash, _appRoot); + var canonicalInstalled = MapArpVersionToCatalog(entries, installed[i].InstalledVersion) + ?? LessThanLatestArpAnchoredVersion(entries, installed[i].InstalledVersion) + ?? GreaterThanLatestArpAnchoredVersion(entries, installed[i].InstalledVersion, meta.LatestVersion); + var anchoredLatest = LatestArpAnchoredVersion(entries); + version = canonicalInstalled is not null + ? (IsGreaterThanVersionMarker(canonicalInstalled) ? null : anchoredLatest) + : meta.LatestVersion; + + if (canonicalInstalled is not null) + { + installed[i].InstalledVersion = canonicalInstalled; + installed[i].InstalledVersionCanonical = true; + } + } + } + catch { /* version data fetch is best-effort; fall back to the installed DB identity below */ } + } + + installed[i].Correlated = new SearchMatch + { + SourceName = source.Name, + SourceKind = source.Kind, + Id = hit.Value.Id, + Name = hit.Value.Name, + Moniker = moniker, + Version = version, + MatchCriteria = hit.Value.MatchedBy, + }; + } + } + + return warnings; + } + + private static bool InstalledDbIdentityTablesPresent(Microsoft.Data.Sqlite.SqliteConnection conn) => + TableExists(conn, "manifest") && + TableExists(conn, "ids") && + TableExists(conn, "names") && + ((TableExists(conn, "pfns") && TableExists(conn, "pfns_map")) || + (TableExists(conn, "productcodes") && TableExists(conn, "productcodes_map")) || + (TableExists(conn, "upgradecodes") && TableExists(conn, "upgradecodes_map"))); + + private static bool TableExists(Microsoft.Data.Sqlite.SqliteConnection conn, string name) => + QueryOptionalLong(conn, "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @v LIMIT 1", name) is not null; + + private static (string Id, string Name, string MatchedBy)? LookupInstalledDbIdentityMatch( + Microsoft.Data.Sqlite.SqliteConnection conn, + InstalledPackage pkg) + { + foreach (var pfn in pkg.PackageFamilyNames) + { + var hit = LookupInstalledDbIdentityMatch(conn, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName"); + if (hit is not null) return hit; + } + + foreach (var code in pkg.UpgradeCodes) + { + var hit = LookupInstalledDbIdentityMatch(conn, "upgradecodes", "upgradecodes_map", "upgradecode", code, "UpgradeCode"); + if (hit is not null) return hit; + } + + foreach (var code in pkg.ProductCodes) + { + var hit = LookupInstalledDbIdentityMatch(conn, "productcodes", "productcodes_map", "productcode", code, "ProductCode"); + if (hit is not null) return hit; + } + + return null; + } + + internal static (string Id, string Name, string MatchedBy)? LookupInstalledDbIdentityMatchForTesting( + Microsoft.Data.Sqlite.SqliteConnection conn, + InstalledPackage pkg) + => LookupInstalledDbIdentityMatch(conn, pkg); + + private static (string Id, string Name, string MatchedBy)? LookupInstalledDbIdentityMatch( + Microsoft.Data.Sqlite.SqliteConnection conn, + string valueTable, + string mapTable, + string valueColumn, + string value, + string matchedBy) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + SELECT ids.id, names.name + FROM {mapTable} map + JOIN {valueTable} value ON map.{valueColumn} = value.rowid + JOIN manifest ON map.manifest = manifest.rowid + JOIN ids ON manifest.id = ids.rowid + JOIN names ON manifest.name = names.rowid + WHERE value.{valueColumn} = @v + LIMIT 1 + """; + cmd.Parameters.AddWithValue("@v", value.ToLowerInvariant()); + using var reader = cmd.ExecuteReader(); + return reader.Read() + ? (reader.GetString(0), reader.GetString(1), matchedBy) + : null; + } + /// /// Correlates installed packages against the v2 pre-indexed catalog /// using PackageFamilyName / ProductCode / UpgradeCode lookups — winget's @@ -2907,7 +3151,8 @@ private List CorrelateInstalledViaIndex(List installed using var conn2 = OpenPreindexedConnection(sourceIndex); var (entries, _) = PreIndexedSource.LoadV2VersionData(_client, conn2, source, meta.Rowid, meta.PackageHash, _appRoot); canonicalInstalled = MapArpVersionToCatalog(entries, installed[idx].InstalledVersion) - ?? LessThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion); + ?? LessThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion) + ?? GreaterThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion, meta.LatestVersion); anchoredLatest = LatestArpAnchoredVersion(entries); } catch { /* version data fetch is best-effort; fall back below */ } @@ -2918,8 +3163,8 @@ private List CorrelateInstalledViaIndex(List installed // `latest_version`, which can be an internal/MSI build // number that would manufacture a phantom upgrade against // an installed canonical. - var availableVersion = (canonicalInstalled is not null && anchoredLatest is not null) - ? anchoredLatest + var availableVersion = canonicalInstalled is not null + ? (IsGreaterThanVersionMarker(canonicalInstalled) ? null : anchoredLatest) : meta.LatestVersion; installed[idx].Correlated = new SearchMatch @@ -2999,13 +3244,15 @@ private List CorrelateInstalledByNormalizedIdentity(List EnrichCorrelatedViaIndex(List installed, if (!installed[idx].InstalledVersionCanonical) { - var canonicalInstalled = MapArpVersionToCatalog(entries, installed[idx].InstalledVersion); + var canonicalInstalled = MapArpVersionToCatalog(entries, installed[idx].InstalledVersion) + ?? LessThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion) + ?? GreaterThanLatestArpAnchoredVersion(entries, installed[idx].InstalledVersion, meta.LatestVersion); var anchoredLatest = LatestArpAnchoredVersion(entries); if (canonicalInstalled is not null) { installed[idx].InstalledVersion = canonicalInstalled; installed[idx].InstalledVersionCanonical = true; if (installed[idx].Correlated is SearchMatch correlated && anchoredLatest is not null) - installed[idx].Correlated = correlated with { Version = anchoredLatest }; + { + installed[idx].Correlated = correlated with + { + Version = IsGreaterThanVersionMarker(canonicalInstalled) ? null : anchoredLatest + }; + } } } @@ -3448,20 +3702,36 @@ private static int ListSortWeight(InstalledPackage pkg) } private ListMatch ListMatchFromInstalled(InstalledPackage pkg) + => ListMatchFromInstalled(pkg, _store.Sources); + + internal static ListMatch ListMatchFromInstalledForTesting(InstalledPackage pkg, IReadOnlyList sources) + => ListMatchFromInstalled(pkg, sources); + + private static ListMatch ListMatchFromInstalled(InstalledPackage pkg, IReadOnlyList sources) { string? availableVersion = null; if (pkg.Correlated?.Version is string av) { - if (InstalledPackageHasUnknownVersion(pkg) || - RestSource.CompareVersionStrings(av, pkg.InstalledVersion) > 0) + if (!IsGreaterThanVersionMarker(pkg.InstalledVersion) && + (InstalledPackageHasUnknownVersion(pkg) || + RestSource.CompareVersionStrings(av, pkg.InstalledVersion) > 0)) + { availableVersion = av; + } } string packageId = pkg.Correlated?.Id ?? pkg.LocalId; string? sourceName = pkg.Correlated?.SourceName; + if (sourceName is null && !string.IsNullOrWhiteSpace(pkg.WinGetPackageIdentifier)) + { + packageId = pkg.WinGetPackageIdentifier; + if (!string.IsNullOrWhiteSpace(pkg.WinGetSourceIdentifier)) + sourceName = SourceNameFromIdentifier(pkg.WinGetSourceIdentifier, sources); + } + if (sourceName is null && TryGetWinGetPackageIdentityFromLocalId( pkg.LocalId, - _store.Sources, + sources, out string? localPackageId, out string? localSourceName)) { @@ -3487,6 +3757,48 @@ private ListMatch ListMatchFromInstalled(InstalledPackage pkg) }; } + internal static List SuppressDuplicateAvailableVersions(IReadOnlyList matches) + { + var duplicateKeys = matches + .Where(match => !string.IsNullOrWhiteSpace(match.SourceName)) + .GroupBy(match => $"{match.SourceName}\0{match.Id}", StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); + + if (duplicateKeys.Count == 0) + return matches.ToList(); + + var keepLocalIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (key, group) in duplicateKeys) + { + var bestVersion = group + .Select(match => match.InstalledVersion) + .Aggregate((best, version) => RestSource.CompareVersionStrings(version, best) > 0 ? version : best); + foreach (var match in group.Where(match => RestSource.CompareVersionStrings(match.InstalledVersion, bestVersion) == 0)) + keepLocalIds.Add($"{key}\0{match.LocalId}"); + } + + return matches + .Select(match => + { + if (match.AvailableVersion is null || string.IsNullOrWhiteSpace(match.SourceName)) + return match; + + var key = $"{match.SourceName}\0{match.Id}"; + if (!duplicateKeys.ContainsKey(key)) + return match; + + return keepLocalIds.Contains($"{key}\0{match.LocalId}") + ? match + : match with { AvailableVersion = null }; + }) + .ToList(); + } + + private static string SourceNameFromIdentifier(string identifier, IReadOnlyList sources) => + sources.FirstOrDefault(source => source.Identifier.Equals(identifier, StringComparison.OrdinalIgnoreCase))?.Name + ?? identifier; + internal static bool TryGetWinGetPackageIdentityFromLocalId( string localId, IReadOnlyList sources, @@ -3536,9 +3848,13 @@ private static bool InstalledPackageHasUnknownVersion(InstalledPackage pkg) => pkg.InstalledVersion.Equals("Unknown", StringComparison.OrdinalIgnoreCase); private static bool InstalledPackageHasUpgrade(InstalledPackage pkg) => + !IsGreaterThanVersionMarker(pkg.InstalledVersion) && pkg.Correlated?.Version is string availableVersion && RestSource.CompareVersionStrings(availableVersion, pkg.InstalledVersion) > 0; + private static bool IsGreaterThanVersionMarker(string version) => + version.TrimStart().StartsWith('>'); + private static bool InstallerMatchesRequested( Installer installer, string? requestedType, @@ -3730,13 +4046,33 @@ private List ResolveSourceIndexes(string? sourceName) if (_store.Sources[i].Name.Equals(sourceName, StringComparison.OrdinalIgnoreCase)) return [i]; } + + for (int i = 0; i < _store.Sources.Count; i++) + { + if (_store.Sources[i].Identifier.Equals(sourceName, StringComparison.OrdinalIgnoreCase)) + return [i]; + } + throw new InvalidOperationException($"Source '{sourceName}' not found."); } private void RefreshSystemWingetSources() { - if (_useSystemWingetSources) - _store = SystemWingetSourceStore.Load(); + _store = _sourceMode switch + { + EffectiveSourceMode.Private => _store, + EffectiveSourceMode.SystemWingetDirect => SystemWingetSourceStore.Load(), + EffectiveSourceMode.SystemWingetMirror => SourceStoreManager.RefreshSystemWingetMirrorStore(_appRoot), + _ => _store, + }; + } + + private void SaveStore() + { + if (MirrorsSystemWingetSources) + SourceStoreManager.SaveSystemWingetMirrorStore(_appRoot, _store); + else + SourceStoreManager.Save(_store, _appRoot); } private List UpdateSystemWingetSources(string? sourceName) diff --git a/dotnet/src/Devolutions.Pinget.Core/RestSource.cs b/dotnet/src/Devolutions.Pinget.Core/RestSource.cs index 20e8d00..9a00f71 100644 --- a/dotnet/src/Devolutions.Pinget.Core/RestSource.cs +++ b/dotnet/src/Devolutions.Pinget.Core/RestSource.cs @@ -23,9 +23,9 @@ internal record RestInfoCache public RestInformation Value { get; init; } = new(); } - public static RestInformation LoadInformation(HttpClient client, SourceRecord source, string? appRoot = null) + public static RestInformation LoadInformation(HttpClient client, SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var stateDir = SourceStoreManager.SourceStateDir(source, appRoot); + var stateDir = SourceStoreManager.SourceStateDir(source, appRoot, identifierKeyed); Directory.CreateDirectory(stateDir); var cachePath = Path.Combine(stateDir, "rest_info.json"); @@ -148,9 +148,9 @@ public static (Manifest Manifest, object StructuredDocuments) FetchManifestWithD return ParseRestManifest(json, packageId, version, channel); } - public static string UpdateRest(HttpClient client, SourceRecord source, string? appRoot = null) + public static string UpdateRest(HttpClient client, SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { - var info = LoadInformation(client, source, appRoot); + var info = LoadInformation(client, source, appRoot, identifierKeyed); source.SourceVersion = info.ServerSupportedVersions.FirstOrDefault(); return $"REST source up to date (contract: {source.SourceVersion})"; } diff --git a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs index b6ad3e4..fda4b6b 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SourceStore.cs @@ -33,6 +33,13 @@ internal record SourceStore }; } +internal enum EffectiveSourceMode +{ + Private, + SystemWingetDirect, + SystemWingetMirror +} + [JsonSerializable(typeof(SourceStore))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] internal partial class SourceStoreContext : JsonSerializerContext; @@ -43,6 +50,7 @@ internal static class SourceStoreManager internal const string PackagedName = "Microsoft.DesktopAppInstaller"; private const string LegacyStoreFileName = "sources.json"; + private const string SystemWingetMirrorStoreFileName = "system-sources.json"; private const string PackagedSourcesFileName = "user_sources"; private const string PackagedMetadataFileName = "sources_metadata"; private const string LegacyPinsFileName = "pins.db"; @@ -99,7 +107,7 @@ public static void EnsureAppDirs(string? appRoot = null) } } - public static string SourceStateDir(SourceRecord source, string? appRoot = null) + public static string SourceStateDir(SourceRecord source, string? appRoot = null, bool identifierKeyed = false) { var root = NormalizeAppRoot(appRoot); if (UsesPackagedLayout(root)) @@ -110,9 +118,21 @@ public static string SourceStateDir(SourceRecord source, string? appRoot = null) SanitizePathSegment(source.Identifier)); } + if (identifierKeyed) + return Path.Combine(root, "sources", SanitizePathSegment(source.Identifier)); + return Path.Combine(root, "sources", SanitizePathSegment(source.Name)); } + public static string SourceInstalledDbPath(SourceRecord source, string? appRoot = null, bool identifierKeyed = false) + { + var root = NormalizeAppRoot(appRoot); + if (UsesPackagedLayout(root)) + return Path.Combine(root, SanitizePathSegment(source.Identifier), "installed.db"); + + return Path.Combine(SourceStateDir(source, root, identifierKeyed), "installed.db"); + } + public static string PinsDbPath(string? appRoot = null) { var root = NormalizeAppRoot(appRoot); @@ -137,6 +157,84 @@ public static SourceStore Load(string? appRoot = null) return JsonSerializer.Deserialize(json, SourceStoreContext.Default.SourceStore) ?? SourceStore.Default(); } + internal static (EffectiveSourceMode Mode, SourceStore Store) LoadEffective(string appRoot, SourceMode sourceMode) + { + if (UsesSystemWingetSourceCommands(appRoot)) + return (EffectiveSourceMode.SystemWingetDirect, SystemWingetSourceStore.Load()); + + return sourceMode switch + { + SourceMode.Private => (EffectiveSourceMode.Private, Load(appRoot)), + SourceMode.SystemWingetMirror => (EffectiveSourceMode.SystemWingetMirror, LoadOrRefreshSystemWingetMirrorStore(appRoot, forceRefresh: false)), + _ => TryLoadAutoSystemWingetMirror(appRoot), + }; + } + + private static (EffectiveSourceMode Mode, SourceStore Store) TryLoadAutoSystemWingetMirror(string appRoot) + { + try + { + return (EffectiveSourceMode.SystemWingetMirror, LoadOrRefreshSystemWingetMirrorStore(appRoot, forceRefresh: false)); + } + catch + { + return (EffectiveSourceMode.Private, Load(appRoot)); + } + } + + internal static SourceStore RefreshSystemWingetMirrorStore(string appRoot) + { + var prior = LoadSystemWingetMirrorStore(appRoot) ?? SourceStore.Default(); + var exported = SystemWingetSourceStore.Load(); + MergeSourceCacheMetadata(exported, prior); + SaveSystemWingetMirrorStore(appRoot, exported); + return exported; + } + + internal static void SaveSystemWingetMirrorStore(string appRoot, SourceStore store) + { + Directory.CreateDirectory(NormalizeAppRoot(appRoot)); + var json = JsonSerializer.Serialize(store, SourceStoreContext.Default.SourceStore); + File.WriteAllText(SystemWingetMirrorStorePath(appRoot), json); + } + + private static SourceStore LoadOrRefreshSystemWingetMirrorStore(string appRoot, bool forceRefresh) + { + if (!forceRefresh && LoadSystemWingetMirrorStore(appRoot) is { } cached) + return cached; + + return RefreshSystemWingetMirrorStore(appRoot); + } + + private static SourceStore? LoadSystemWingetMirrorStore(string appRoot) + { + var path = SystemWingetMirrorStorePath(appRoot); + if (!File.Exists(path)) + return null; + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, SourceStoreContext.Default.SourceStore) + ?? throw new InvalidOperationException("Failed to parse system WinGet mirror source store."); + } + + private static string SystemWingetMirrorStorePath(string appRoot) => + Path.Combine(NormalizeAppRoot(appRoot), SystemWingetMirrorStoreFileName); + + private static void MergeSourceCacheMetadata(SourceStore target, SourceStore prior) + { + var priorByIdentifier = prior.Sources.ToDictionary(source => source.Identifier, StringComparer.OrdinalIgnoreCase); + foreach (var source in target.Sources) + { + if (!priorByIdentifier.TryGetValue(source.Identifier, out var previous)) + continue; + + source.LastUpdate = previous.LastUpdate; + source.SourceVersion = previous.SourceVersion; + source.ETag = previous.ETag; + source.LastModified = previous.LastModified; + } + } + internal static bool UsesSystemWingetSourceCommands(string? appRoot) { if (!OperatingSystem.IsWindows()) diff --git a/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs b/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs index 5017589..56dfaa2 100644 --- a/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs +++ b/dotnet/src/Devolutions.Pinget.Core/SystemWingetSourceStore.cs @@ -110,7 +110,7 @@ internal static List ParseExport(string output) records.Add(source); } - return OrderSources(records); + return records; } private static WingetCommandResult RunChecked(IReadOnlyList args, string action) @@ -158,7 +158,6 @@ private static bool TryParseJsonSources(string json, out List sour { foreach (var sourceElement in root.EnumerateArray()) sources.Add(ParseSourceElement(sourceElement)); - sources = OrderSources(sources); return true; } @@ -168,7 +167,6 @@ private static bool TryParseJsonSources(string json, out List sour { foreach (var sourceElement in sourcesElement.EnumerateArray()) sources.Add(ParseSourceElement(sourceElement)); - sources = OrderSources(sources); return true; } @@ -301,13 +299,6 @@ JsonValueKind.Number when trustLevel.TryGetInt32(out var value) && value > 0 => private static bool IsTrustedValue(JsonElement value) => value.ValueKind == JsonValueKind.String && value.GetString()?.Equals("Trusted", StringComparison.OrdinalIgnoreCase) == true; - - private static List OrderSources(List sources) => - sources - .OrderBy(source => source.Explicit) - .ThenByDescending(source => source.Priority) - .ThenBy(source => source.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); } internal sealed record WingetCommandResult(int ExitCode, string Stdout, string Stderr); diff --git a/rust/crates/pinget-cli/src/main.rs b/rust/crates/pinget-cli/src/main.rs index 0e0dd4c..fabde27 100644 --- a/rust/crates/pinget-cli/src/main.rs +++ b/rust/crates/pinget-cli/src/main.rs @@ -7,9 +7,10 @@ use std::path::PathBuf; use anyhow::{Result, anyhow, bail}; use clap::{Args, Parser, Subcommand}; use pinget_core::{ - CacheWarmResult, Documentation, InstallRequest, InstallResult, InstallerMode, ListMatch, ListQuery, ListResponse, - PackageQuery, PinRecord, PinType, RepairRequest, Repository, SearchMatch, SearchResponse, ShowResult, SourceKind, - SourceRecord, SourceUpdateResult, UninstallRequest, VersionsResult, + CacheWarmResult, Documentation, InstallProgress, InstallRequest, InstallResult, InstallerMode, ListMatch, + ListQuery, ListResponse, PackageQuery, PinRecord, PinType, RepairRequest, Repository, RepositoryOptions, + SearchMatch, SearchResponse, ShowResult, SourceKind, SourceRecord, SourceUpdateResult, UninstallRequest, + VersionsResult, }; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -597,12 +598,16 @@ fn run() -> Result<()> { } } Commands::Upgrade(args) => { - let mut repository = Repository::open()?; let do_install = args.all || args.query.is_some() || args.query_option.is_some() || args.id.is_some() || args.name.is_some(); + let mut repository = if do_install { + open_repository_with_install_progress()? + } else { + Repository::open()? + }; if do_install && !cfg!(windows) { print_warnings(&[UPGRADE_UNSUPPORTED_WARNING.to_owned()]); println!("No changes were made."); @@ -953,7 +958,7 @@ fn run() -> Result<()> { } } Commands::Install(args) => { - let mut repository = Repository::open()?; + let mut repository = open_repository_with_install_progress()?; let mode = if args.interactive { InstallerMode::Interactive } else if args.silent { @@ -1004,7 +1009,7 @@ fn run() -> Result<()> { print_install_result(&result); } Commands::Repair(args) => { - let mut repository = Repository::open()?; + let mut repository = open_repository_with_install_progress()?; let mode = if args.interactive { InstallerMode::Interactive } else if args.silent { @@ -1476,6 +1481,45 @@ fn print_warnings(warnings: &[String]) { } } +fn open_repository_with_install_progress() -> Result { + Repository::open_with_options(RepositoryOptions::for_current_user()?.with_install_progress(print_install_progress)) +} + +fn print_install_progress(progress: &InstallProgress) { + match progress { + InstallProgress::ResolvingManifest => write_stderr_line(format_args!("Resolving package manifest...")), + InstallProgress::ResolvedManifest { package_id, version } => { + write_stderr_line(format_args!("Resolved {package_id} {version}.")); + } + InstallProgress::CheckingInstalled => write_stderr_line(format_args!("Checking installed package state...")), + InstallProgress::InstallingDependencies => write_stderr_line(format_args!("Checking package dependencies...")), + InstallProgress::SelectingInstaller => { + write_stderr_line(format_args!("Selecting applicable installer for this system...")); + } + InstallProgress::DownloadingInstaller { url, path } => { + write_stderr_line(format_args!( + "Downloading installer from {url} to {}...", + path.display() + )); + } + InstallProgress::VerifyingInstaller { path } => { + write_stderr_line(format_args!("Verifying installer hash for {}...", path.display())); + } + InstallProgress::StartingInstaller { installer_type, path } => { + write_stderr_line(format_args!( + "Starting {installer_type} installer: {}...", + path.display() + )); + } + InstallProgress::WaitingForInstaller { installer_type } => { + write_stderr_line(format_args!("Waiting for {installer_type} installer to finish...")); + } + InstallProgress::InstallerFinished { exit_code } => { + write_stderr_line(format_args!("Installer exited with code {exit_code}.")); + } + } +} + fn write_stderr_line(args: fmt::Arguments<'_>) { let mut stderr = io::stderr().lock(); if writeln!(stderr, "{args}").is_err() {} diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index cd026fb..3727a65 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -49,6 +49,7 @@ const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"]; const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1"; const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15; const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5; +const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json"; #[cfg(windows)] const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe"; #[cfg(windows)] @@ -159,7 +160,23 @@ impl Default for SourceStore { pub struct RepositoryOptions { pub app_root: PathBuf, pub user_agent: String, + pub source_mode: SourceMode, pub pre_indexed_source_auto_update_interval: Option, + pub install_progress: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceMode { + Auto, + Private, + SystemWingetMirror, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EffectiveSourceMode { + Private, + SystemWingetDirect, + SystemWingetMirror, } impl RepositoryOptions { @@ -168,13 +185,20 @@ impl RepositoryOptions { Self { app_root: app_root.into(), user_agent: DEFAULT_USER_AGENT.to_owned(), + source_mode: SourceMode::Private, pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)), + install_progress: None, } } /// Uses the default per-user app-data root that the CLI also uses. pub fn for_current_user() -> Result { - Ok(Self::new(default_app_root()?)) + let source_mode = if std::env::var_os("PINGET_APPROOT").is_some() { + SourceMode::Private + } else { + SourceMode::Auto + }; + Ok(Self::new(default_app_root()?).with_source_mode(source_mode)) } /// Overrides the HTTP user-agent sent to source endpoints. @@ -184,12 +208,27 @@ impl RepositoryOptions { self } + #[must_use] + pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self { + self.source_mode = source_mode; + self + } + /// Overrides automatic freshness checks for existing pre-indexed source indexes. #[must_use] pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option) -> Self { self.pre_indexed_source_auto_update_interval = interval; self } + + /// Receives coarse install/update phase notifications. Intended for CLIs + /// and host applications that need to show progress while downloads or + /// silent installers are running. + #[must_use] + pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self { + self.install_progress = Some(progress); + self + } } #[derive(Debug, Clone, Default)] @@ -670,6 +709,20 @@ pub struct InstallResult { pub warnings: Vec, } +#[derive(Debug, Clone)] +pub enum InstallProgress { + ResolvingManifest, + ResolvedManifest { package_id: String, version: String }, + CheckingInstalled, + InstallingDependencies, + SelectingInstaller, + DownloadingInstaller { url: String, path: PathBuf }, + VerifyingInstaller { path: PathBuf }, + StartingInstaller { installer_type: String, path: PathBuf }, + WaitingForInstaller { installer_type: String }, + InstallerFinished { exit_code: i32 }, +} + impl InstallerSwitches { fn with_fallback(&self, fallback: &Self) -> Self { Self { @@ -724,6 +777,8 @@ struct InstalledPackage { scope: Option, installer_category: Option, install_location: Option, + winget_package_identifier: Option, + winget_source_identifier: Option, package_family_names: Vec, product_codes: Vec, upgrade_codes: Vec, @@ -772,6 +827,50 @@ struct SearchLocatedResult { truncated: bool, } +fn single_match_from_located( + located: SearchLocatedResult, + report_first_source_failure: bool, +) -> Result<(LocatedMatch, Vec)> { + if located.matches.is_empty() { + if report_first_source_failure && let Some(failure) = located.failures.first() { + bail!("failed to search source '{}': {}", failure.source_name, failure.message); + } + bail!("no package matched the supplied query"); + } + + if located.matches.len() > 1 { + let choices = located + .matches + .iter() + .take(10) + .map(|item| { + format!( + "{} [{}] ({})", + item.display.name, item.display.id, item.display.source_name + ) + }) + .collect::>() + .join(", "); + bail!("multiple packages matched: {choices}"); + } + + Ok((located.matches.into_iter().next().expect("one match"), located.warnings)) +} + +fn should_retry_show_across_system_winget_sources( + uses_system_winget_sources: bool, + query: &PackageQuery, + located: &SearchLocatedResult, +) -> bool { + uses_system_winget_sources + && query + .source + .as_deref() + .is_some_and(|source| source.eq_ignore_ascii_case("winget")) + && located.matches.is_empty() + && located.failures.is_empty() +} + #[derive(Debug, Clone)] enum MatchLocator { PreIndexedV1 { @@ -844,7 +943,8 @@ pub struct Repository { user_agent: String, client: Client, store: SourceStore, - use_system_winget_sources: bool, + source_mode: EffectiveSourceMode, + install_progress: Option, pre_indexed_source_auto_update_interval: Option, preindexed_refresh_attempts: HashMap, } @@ -907,8 +1007,7 @@ impl Repository { /// Opens the repository with explicit hosting options for library consumers. pub fn open_with_options(options: RepositoryOptions) -> Result { ensure_app_dirs(&options.app_root)?; - let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root); - let store = load_store(&options.app_root)?; + let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?; let client = Client::builder() .user_agent(&options.user_agent) .timeout(StdDuration::from_secs(30 * 60)) @@ -919,12 +1018,27 @@ impl Repository { user_agent: options.user_agent, client, store, - use_system_winget_sources, + source_mode, + install_progress: options.install_progress, pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval, preindexed_refresh_attempts: HashMap::new(), }) } + fn uses_system_winget_sources(&self) -> bool { + self.source_mode != EffectiveSourceMode::Private + } + + fn mirrors_system_winget_sources(&self) -> bool { + self.source_mode == EffectiveSourceMode::SystemWingetMirror + } + + fn emit_install_progress(&self, progress: InstallProgress) { + if let Some(callback) = self.install_progress { + callback(&progress); + } + } + pub fn app_root(&self) -> &Path { &self.app_root } @@ -946,7 +1060,7 @@ impl Repository { explicit: bool, priority: i32, ) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?; self.refresh_system_winget_sources()?; return Ok(()); @@ -972,12 +1086,14 @@ impl Repository { etag: None, last_modified: None, }); - self.save_store()?; + if self.source_mode != EffectiveSourceMode::SystemWingetDirect { + self.save_store()?; + } Ok(()) } pub fn edit_source(&mut self, name: &str, explicit: Option, trust_level: Option<&str>) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { bail!("Editing system WinGet sources is not supported by winget source commands."); } @@ -1001,7 +1117,7 @@ impl Repository { } pub fn remove_source(&mut self, name: &str) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_remove_source(name)?; self.refresh_system_winget_sources()?; return Ok(()); @@ -1024,7 +1140,7 @@ impl Repository { } pub fn reset_source(&mut self, name: &str) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_reset_source(name)?; self.refresh_system_winget_sources()?; return Ok(()); @@ -1061,7 +1177,7 @@ impl Repository { } pub fn reset_sources(&mut self) -> Result<()> { - if self.use_system_winget_sources { + if self.uses_system_winget_sources() { system_winget_reset_sources()?; self.refresh_system_winget_sources()?; return Ok(()); @@ -1175,10 +1291,14 @@ impl Repository { } pub fn update_sources(&mut self, source_name: Option<&str>) -> Result> { - if self.use_system_winget_sources { + if self.source_mode == EffectiveSourceMode::SystemWingetDirect { return self.update_system_winget_sources(source_name); } + if self.mirrors_system_winget_sources() { + self.refresh_system_winget_sources()?; + } + let indexes = self.resolve_source_indexes(source_name)?; let mut results = Vec::new(); @@ -1235,12 +1355,10 @@ impl Repository { bail!("list --source currently requires a query or explicit filter"); } + self.refresh_system_winget_sources()?; + let has_filter = list_query_needs_available_lookup(query); - // Plain `list` should still correlate installed packages back to their - // catalog identities so callers see canonical package ids/source names, - // matching winget and the fixed C# Pinget behavior. `upgrade_only` - // still adds the available-version specific filtering on top. - let needs_available = true; + let needs_available = has_filter || query.upgrade_only; let mut warnings = Vec::new(); if !installed_package_discovery_supported() { @@ -1248,24 +1366,13 @@ impl Repository { } let mut installed = collect_installed_packages(query.install_scope.as_deref())?; - if needs_available { - // Authoritative correlation via the v2 index's identity tables - // (PackageFamilyName / ProductCode / UpgradeCode). This is winget's - // primary path and resolves cases where display-name matching is - // ambiguous (Microsoft.Teams vs Microsoft.Teams.Free) or impossible - // (MSIX with `ms-resource:` placeholder names). - warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?); - - // ARP entries that lack identity keys (no PFN/PC/UC) still match - // winget's ARP correlation when their (DisplayName, Publisher), - // run through the NameNormalizer, lands on a single package in - // norm_names2 ∩ norm_publishers2. Covers Inno Setup-style - // installers, MSIs whose ARP keys don't include ProductCode, and - // any vendor that publishes a DisplayName that differs from the - // catalog's PackageName but matches an AppsAndFeaturesEntries - // DisplayName. - warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?); - } + // Plain `list` should still correlate installed packages back to their + // catalog identities so callers see canonical package ids/source names. + // Available-version lookups and loose name-only enrichment remain gated + // by `needs_available` below. + warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?); + warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?); + warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?); if needs_available && has_filter { // Filtered lookup: search sources with the user's query for any @@ -1335,9 +1442,12 @@ impl Repository { }; let mut list_matches = matches .into_iter() - .map(list_match_from_installed) + .map(|package| list_match_from_installed(package, &self.store.sources)) .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins)) .collect::>(); + if !query.upgrade_only { + list_matches = suppress_duplicate_available_versions(list_matches); + } let truncated = if let Some(limit) = query.count { let was_truncated = list_matches.len() > limit; list_matches.truncate(limit); @@ -1392,6 +1502,109 @@ impl Repository { Ok(located.warnings) } + /// Uses winget's per-source installed tracking DB when available. This is + /// an exact identity signal recorded by winget itself, and it preserves + /// source order for sources that do not expose a preindexed identity table + /// such as REST sources. + fn correlate_installed_via_installed_db( + &mut self, + installed: &mut [InstalledPackage], + requested_source: Option<&str>, + ) -> Result> { + let source_indices = self + .store + .sources + .iter() + .enumerate() + .filter(|(_, source)| { + requested_source + .map(|name| source.name.eq_ignore_ascii_case(name)) + .unwrap_or(true) + }) + .map(|(index, _)| index) + .collect::>(); + + for source_index in source_indices { + let source = self.source_clone(source_index); + let installed_db_path = self.source_installed_db_path(&source); + if !installed_db_path.exists() { + continue; + } + + let connection = Self::open_sqlite_connection(installed_db_path)?; + if !installed_db_identity_tables_present(&connection)? { + continue; + } + + for package in installed.iter_mut() { + if package.correlated.is_some() { + continue; + } + let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else { + continue; + }; + let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed { + match self + .open_preindexed_connection(source_index) + .and_then(|index_connection| { + let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else { + return Ok((id.clone(), name.clone(), None, None)); + }; + let (canonical_installed, anchored_latest) = + match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) { + Ok((entries, _)) => ( + map_arp_version_to_catalog(&entries, &package.installed_version) + .or_else(|| { + less_than_latest_arp_anchored_version( + &entries, + &package.installed_version, + ) + }) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &package.installed_version, + &meta.latest_version, + ) + }), + latest_arp_anchored_version(&entries), + ), + Err(_) => (None, None), + }; + let version = match (&canonical_installed, anchored_latest) { + (Some(canonical), _) if is_greater_than_version_marker(canonical) => None, + (Some(_), Some(anchored)) => Some(anchored), + (Some(_), None) => None, + _ => Some(meta.latest_version.clone()), + }; + if let Some(version) = canonical_installed { + package.installed_version = version; + package.installed_version_canonical = true; + } + Ok((meta.id, meta.name, meta.moniker, version)) + }) { + Ok(enriched) => enriched, + Err(_) => (id, name, None, None), + } + } else { + (id, name, None, None) + }; + package.correlated = Some(SearchMatch { + source_name: source.name.clone(), + source_kind: source.kind, + id, + name, + moniker, + version, + channel: None, + match_criteria: Some(matched_by.to_owned()), + }); + } + } + + Ok(Vec::new()) + } + /// Correlates installed packages against the v2 pre-indexed catalog using /// PackageFamilyName / ProductCode / UpgradeCode lookups — winget's /// authoritative correlation path. Also rewrites `installed_version` to @@ -1450,9 +1663,17 @@ impl Repository { let (canonical_installed, anchored_latest) = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) { Ok((entries, _)) => ( - map_arp_version_to_catalog(&entries, &installed[idx].installed_version).or_else(|| { - less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version) - }), + map_arp_version_to_catalog(&entries, &installed[idx].installed_version) + .or_else(|| { + less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version) + }) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &installed[idx].installed_version, + &meta.latest_version, + ) + }), latest_arp_anchored_version(&entries), ), Err(_) => (None, None), @@ -1464,8 +1685,10 @@ impl Repository { // 1.8's `8000.836.2153.0`) that would manufacture a phantom // upgrade against an installed canonical like `1.8.6`. let available_version = match (&canonical_installed, anchored_latest) { - (Some(_), Some(anchored)) => anchored, - _ => meta.latest_version, + (Some(canonical), _) if is_greater_than_version_marker(canonical) => None, + (Some(_), Some(anchored)) => Some(anchored), + (Some(_), None) => None, + _ => Some(meta.latest_version), }; let installed_pkg = &mut installed[idx]; installed_pkg.correlated = Some(SearchMatch { @@ -1474,7 +1697,7 @@ impl Repository { id: meta.id, name: meta.name, moniker: meta.moniker, - version: Some(available_version), + version: available_version, channel: None, match_criteria: Some(by.to_owned()), }); @@ -1566,14 +1789,26 @@ impl Repository { let (canonical_installed, anchored_latest) = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) { Ok((entries, _)) => ( - map_arp_version_to_catalog(&entries, &installed[idx].installed_version), + map_arp_version_to_catalog(&entries, &installed[idx].installed_version) + .or_else(|| { + less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version) + }) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &installed[idx].installed_version, + &meta.latest_version, + ) + }), latest_arp_anchored_version(&entries), ), Err(_) => (None, None), }; let available_version = match (&canonical_installed, anchored_latest) { - (Some(_), Some(anchored)) => anchored, - _ => meta.latest_version, + (Some(canonical), _) if is_greater_than_version_marker(canonical) => None, + (Some(_), Some(anchored)) => Some(anchored), + (Some(_), None) => None, + _ => Some(meta.latest_version), }; let installed_pkg = &mut installed[idx]; installed_pkg.correlated = Some(SearchMatch { @@ -1582,7 +1817,7 @@ impl Repository { id: meta.id, name: meta.name, moniker: meta.moniker, - version: Some(available_version), + version: available_version, channel: None, match_criteria: Some("NormalizedNameAndPublisher".to_owned()), }); @@ -1678,15 +1913,21 @@ impl Repository { if !installed[idx].installed_version_canonical { let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version) - .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)); + .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)) + .or_else(|| { + greater_than_latest_arp_anchored_version( + &entries, + &installed[idx].installed_version, + &meta.latest_version, + ) + }); let anchored_latest = latest_arp_anchored_version(&entries); if let Some(canonical) = canonical_installed { + let greater_than_latest = is_greater_than_version_marker(&canonical); installed[idx].installed_version = canonical; installed[idx].installed_version_canonical = true; - if let (Some(correlated), Some(anchored)) = - (installed[idx].correlated.as_mut(), anchored_latest) - { - correlated.version = Some(anchored); + if let Some(correlated) = installed[idx].correlated.as_mut() { + correlated.version = if greater_than_latest { None } else { anchored_latest }; } } } @@ -1728,7 +1969,7 @@ impl Repository { } pub fn show_versions(&mut self, query: &PackageQuery) -> Result { - let (located, warnings) = self.find_single_match(query)?; + let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?; let versions = self.versions_for_match(&located, query)?; Ok(VersionsResult { package: located.display, @@ -1738,7 +1979,7 @@ impl Repository { } pub fn show(&mut self, query: &PackageQuery) -> Result { - let (located, warnings) = self.find_single_match(query)?; + let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?; let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?; let selected_installer = select_installer(&manifest.installers, query); @@ -1878,11 +2119,46 @@ impl Repository { } fn save_store(&self) -> Result<()> { - save_store(&self.app_root, &self.store) + if self.mirrors_system_winget_sources() { + save_system_winget_mirror_store(&self.app_root, &self.store) + } else { + save_store(&self.app_root, &self.store) + } } fn source_state_dir(&self, source: &SourceRecord) -> PathBuf { - source_state_dir(&self.app_root, source) + source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources()) + } + + fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf { + source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources()) + } + + fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf { + self.source_state_dir(source).join("source.msix") + } + + fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf { + self.source_state_dir(source).join("index.db") + } + + fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf { + self.source_state_dir(source).join("rest-information.json") + } + + fn rest_manifest_cache_path( + &self, + source: &SourceRecord, + package_id: &str, + version: &str, + channel: &str, + ) -> PathBuf { + let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel); + let digest = sha256_hex(key.as_bytes()); + cache_root(&self.app_root) + .join("REST_M") + .join(sanitize_path_segment(&source.identifier)) + .join(format!("{digest}.json")) } // ── Install / download ── @@ -1940,8 +2216,13 @@ impl Repository { }); let dest = download_dir.join(filename); + self.emit_install_progress(InstallProgress::DownloadingInstaller { + url: url.to_owned(), + path: dest.clone(), + }); let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?; + self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() }); if let Err(error) = verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash) { @@ -1974,7 +2255,12 @@ impl Repository { } pub fn install_request(&mut self, request: &InstallRequest) -> Result { + self.emit_install_progress(InstallProgress::ResolvingManifest); let manifest = self.resolve_manifest_for_install(request)?; + self.emit_install_progress(InstallProgress::ResolvedManifest { + package_id: manifest.id.clone(), + version: manifest.version.clone(), + }); if !package_actions_supported() { let installer_type = select_installer(&manifest.installers, &request.query) .and_then(|installer| installer.installer_type) @@ -1988,12 +2274,14 @@ impl Repository { )); } + self.emit_install_progress(InstallProgress::CheckingInstalled); let existing_match = self.find_installed_package_for_install(request, &manifest)?; if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) { return Ok(no_op_result); } Self::ensure_package_agreements_accepted(&manifest, request)?; + self.emit_install_progress(InstallProgress::InstallingDependencies); self.install_dependencies(&manifest, request, &mut HashSet::new())?; if request.dependencies_only { @@ -2009,6 +2297,7 @@ impl Repository { }); } + self.emit_install_progress(InstallProgress::SelectingInstaller); let installer = select_installer(&manifest.installers, &request.query) .ok_or_else(|| anyhow!("No applicable installer found"))?; @@ -2033,7 +2322,15 @@ impl Repository { let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase(); + self.emit_install_progress(InstallProgress::StartingInstaller { + installer_type: installer_type.clone(), + path: installer_path.clone(), + }); + self.emit_install_progress(InstallProgress::WaitingForInstaller { + installer_type: installer_type.clone(), + }); let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?; + self.emit_install_progress(InstallProgress::InstallerFinished { exit_code }); Ok(InstallResult { package_id: manifest.id.clone(), @@ -2440,39 +2737,36 @@ impl Repository { self.find_single_match_with_semantics(query, SearchSemantics::Single) } - fn find_single_match_with_semantics( + fn find_single_match_for_show( &mut self, query: &PackageQuery, semantics: SearchSemantics, ) -> Result<(LocatedMatch, Vec)> { let located = self.search_located(query, semantics)?; - - if located.matches.is_empty() { - if query.source.is_some() - && let Some(failure) = located.failures.first() - { - bail!("failed to search source '{}': {}", failure.source_name, failure.message); - } - bail!("no package matched the supplied query"); + if self.should_retry_show_across_system_winget_sources(query, &located) { + let mut fallback_query = query.clone(); + fallback_query.source = None; + return single_match_from_located(self.search_located(&fallback_query, semantics)?, false); } - if located.matches.len() > 1 { - let choices = located - .matches - .iter() - .take(10) - .map(|item| { - format!( - "{} [{}] ({})", - item.display.name, item.display.id, item.display.source_name - ) - }) - .collect::>() - .join(", "); - bail!("multiple packages matched: {choices}"); - } + single_match_from_located(located, query.source.is_some()) + } - Ok((located.matches.into_iter().next().expect("one match"), located.warnings)) + fn should_retry_show_across_system_winget_sources( + &self, + query: &PackageQuery, + located: &SearchLocatedResult, + ) -> bool { + should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located) + } + + fn find_single_match_with_semantics( + &mut self, + query: &PackageQuery, + semantics: SearchSemantics, + ) -> Result<(LocatedMatch, Vec)> { + let located = self.search_located(query, semantics)?; + single_match_from_located(located, query.source.is_some()) } fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result { @@ -2887,7 +3181,7 @@ impl Repository { fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result { let source = self.source_clone(source_index); - let index_path = preindexed_index_path(&self.app_root, &source); + let index_path = self.preindexed_index_path(&source); let mut refresh = PreindexedRefreshOutcome::default(); if !index_path.exists() { let _ = self.refresh_preindexed_source(source_index, true)?; @@ -2984,7 +3278,7 @@ impl Repository { fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result { let source = self.source_clone(source_index); - let index_path = preindexed_index_path(&self.app_root, &source); + let index_path = self.preindexed_index_path(&source); let lock_key = self.preindexed_refresh_lock_key(&source); let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?; let _guard = refresh_lock @@ -2998,9 +3292,7 @@ impl Repository { let result = self.update_preindexed(source_index); self.record_preindexed_refresh_attempt(lock_key, result.is_ok()); let detail = result?; - if !self.use_system_winget_sources { - self.save_store()?; - } + self.save_store()?; Ok(detail) } @@ -3015,9 +3307,9 @@ impl Repository { fn update_preindexed(&mut self, source_index: usize) -> Result { let source_snapshot = self.store.sources[source_index].clone(); - let state_dir = source_state_dir(&self.app_root, &source_snapshot); + let state_dir = self.source_state_dir(&source_snapshot); fs::create_dir_all(&state_dir).context("failed to create source state directory")?; - let index_path = preindexed_index_path(&self.app_root, &source_snapshot); + let index_path = self.preindexed_index_path(&source_snapshot); let has_local_index = index_path.exists(); let mut last_error = None; @@ -3063,7 +3355,7 @@ impl Repository { let index_bytes = extract_zip_member(&payload, "Public/index.db") .context("preindexed package did not contain Public/index.db")?; - fs::write(preindexed_package_path(&self.app_root, &source_snapshot), &payload) + fs::write(self.preindexed_package_path(&source_snapshot), &payload) .context("failed to persist source package")?; let temp_index_path = state_dir.join(format!( "index.{}.tmp", @@ -3136,7 +3428,7 @@ impl Repository { fn load_rest_information(&mut self, source_index: usize) -> Result { let source = self.source_clone(source_index); - let cache_path = rest_information_cache_path(&self.app_root, &source); + let cache_path = self.rest_information_cache_path(&source); if cache_path.exists() { let cache = serde_json::from_slice::( @@ -3178,7 +3470,7 @@ impl Repository { expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)), value: info.clone(), }; - write_json(rest_information_cache_path(&self.app_root, &source), &cache)?; + write_json(self.rest_information_cache_path(&source), &cache)?; } Ok(info) @@ -3190,7 +3482,7 @@ impl Repository { package_rowid: i64, package_hash: &str, ) -> Result<(Vec, PathBuf)> { - let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source)) + let connection = Self::open_sqlite_connection(self.preindexed_index_path(source)) .context("failed to reopen preindexed index for V2 version data")?; let package_hash = package_hash.to_ascii_lowercase(); let package_id = query_optional_value( @@ -3265,7 +3557,7 @@ impl Repository { version: &str, channel: &str, ) -> Result<(Vec, PathBuf)> { - let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel); + let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel); if cache_path.exists() { return Ok(( fs::read(&cache_path).context("failed to read cached REST manifest")?, @@ -3324,11 +3616,20 @@ impl Repository { fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result> { if let Some(name) = source_name { - let index = self + if let Some(index) = self .store .sources .iter() .position(|source| source.name.eq_ignore_ascii_case(name)) + { + return Ok(vec![index]); + } + + let index = self + .store + .sources + .iter() + .position(|source| source.identifier.eq_ignore_ascii_case(name)) .ok_or_else(|| anyhow!("source '{name}' was not configured"))?; return Ok(vec![index]); } @@ -3354,9 +3655,11 @@ impl Repository { } fn refresh_system_winget_sources(&mut self) -> Result<()> { - if self.use_system_winget_sources { - self.store = load_system_winget_source_store()?; - } + self.store = match self.source_mode { + EffectiveSourceMode::Private => self.store.clone(), + EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?, + EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?, + }; Ok(()) } @@ -3465,6 +3768,9 @@ fn allow_loose_list_correlation(query: &ListQuery) -> bool { } fn installed_package_has_upgrade(package: &InstalledPackage) -> bool { + if is_greater_than_version_marker(&package.installed_version) { + return false; + } package .correlated .as_ref() @@ -3472,6 +3778,10 @@ fn installed_package_has_upgrade(package: &InstalledPackage) -> bool { .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater) } +fn is_greater_than_version_marker(version: &str) -> bool { + version.trim_start().starts_with('>') +} + fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool { package.installed_version.eq_ignore_ascii_case("Unknown") } @@ -3598,11 +3908,12 @@ fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool { version.eq_ignore_ascii_case(pattern) } -fn list_match_from_installed(package: InstalledPackage) -> ListMatch { +fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch { let available_version = package.correlated.as_ref().and_then(|candidate| { candidate.version.as_ref().and_then(|candidate_version| { - if installed_package_has_unknown_version(&package) - || compare_version(candidate_version, &package.installed_version) == Ordering::Greater + if !is_greater_than_version_marker(&package.installed_version) + && (installed_package_has_unknown_version(&package) + || compare_version(candidate_version, &package.installed_version) == Ordering::Greater) { Some(candidate_version.clone()) } else { @@ -3610,17 +3921,39 @@ fn list_match_from_installed(package: InstalledPackage) -> ListMatch { } }) }); - let source_name = package + let mut source_name = package .correlated .as_ref() .map(|candidate| candidate.source_name.clone()) .filter(|value| !value.is_empty()); - let id = package + let mut id = package .correlated .as_ref() .map(|candidate| candidate.id.clone()) .unwrap_or_else(|| package.local_id.clone()); + if source_name.is_none() + && let Some(package_id) = package + .winget_package_identifier + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + id = package_id.to_owned(); + source_name = package + .winget_source_identifier + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|identifier| source_name_from_identifier(identifier, sources)); + } + + if source_name.is_none() + && let Some((local_package_id, local_source_name)) = + winget_package_identity_from_local_id(&package.local_id, sources) + { + id = local_package_id; + source_name = Some(local_source_name); + } + ListMatch { name: package.name, id, @@ -3638,6 +3971,87 @@ fn list_match_from_installed(package: InstalledPackage) -> ListMatch { } } +fn suppress_duplicate_available_versions(matches: Vec) -> Vec { + let mut grouped: HashMap<(String, String), Vec> = HashMap::new(); + for (index, item) in matches.iter().enumerate() { + let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else { + continue; + }; + grouped + .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase())) + .or_default() + .push(index); + } + + let mut keep = BTreeSet::new(); + for indexes in grouped.values().filter(|indexes| indexes.len() > 1) { + let Some(best) = indexes + .iter() + .map(|index| matches[*index].installed_version.as_str()) + .max_by(|left, right| compare_version(left, right)) + else { + continue; + }; + for index in indexes { + if compare_version(&matches[*index].installed_version, best) == Ordering::Equal { + keep.insert(*index); + } + } + } + + matches + .into_iter() + .enumerate() + .map(|(index, mut item)| { + if item.available_version.is_some() && !keep.contains(&index) { + let is_duplicate = item + .source_name + .as_ref() + .map(|source_name| { + grouped + .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase())) + .is_some_and(|indexes| indexes.len() > 1) + }) + .unwrap_or(false); + if is_duplicate { + item.available_version = None; + } + } + item + }) + .collect() +} + +fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String { + sources + .iter() + .find(|source| source.identifier.eq_ignore_ascii_case(identifier)) + .map(|source| source.name.clone()) + .unwrap_or_else(|| identifier.to_owned()) +} + +fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> { + let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1); + for source in sources { + let source_suffix = format!("_{}", source.identifier); + let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else { + continue; + }; + if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) { + continue; + } + if source_identifier_start_index <= package_id_start_index { + return None; + } + let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned(); + if package_id.trim().is_empty() { + return None; + } + return Some((package_id, source.name.clone())); + } + None +} + fn list_sort_weight(package: &InstalledPackage) -> usize { if package.local_id.starts_with("ARP\\") { 0 @@ -3893,6 +4307,89 @@ fn lookup_identity_match_v2( Ok(None) } +fn installed_db_identity_tables_present(connection: &Connection) -> Result { + Ok(table_exists(connection, "manifest")? + && table_exists(connection, "ids")? + && table_exists(connection, "names")? + && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?) + || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?) + || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?))) +} + +fn table_exists(connection: &Connection, name: &str) -> Result { + query_optional_value( + connection, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1", + vec![SqlValue::Text(name.to_owned())], + |row| row_i64(row, 0), + ) + .map(|value| value.is_some()) +} + +fn lookup_installed_db_identity_match( + connection: &Connection, + package: &InstalledPackage, +) -> Result> { + for pfn in &package.package_family_names { + if let Some(hit) = + lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")? + { + return Ok(Some(hit)); + } + } + for code in &package.upgrade_codes { + if let Some(hit) = lookup_installed_db_identity_match_value( + connection, + "upgradecodes", + "upgradecodes_map", + "upgradecode", + code, + "UpgradeCode", + )? { + return Ok(Some(hit)); + } + } + for code in &package.product_codes { + if let Some(hit) = lookup_installed_db_identity_match_value( + connection, + "productcodes", + "productcodes_map", + "productcode", + code, + "ProductCode", + )? { + return Ok(Some(hit)); + } + } + Ok(None) +} + +fn lookup_installed_db_identity_match_value( + connection: &Connection, + value_table: &'static str, + map_table: &'static str, + value_column: &'static str, + value: &str, + matched_by: &'static str, +) -> Result> { + let sql = format!( + "SELECT ids.id, names.name \ + FROM {map_table} map \ + JOIN {value_table} value ON map.{value_column} = value.rowid \ + JOIN manifest ON map.manifest = manifest.rowid \ + JOIN ids ON manifest.id = ids.rowid \ + JOIN names ON manifest.name = names.rowid \ + WHERE value.{value_column} = ?1 \ + LIMIT 1" + ); + query_optional_value( + connection, + &sql, + vec![SqlValue::Text(value.to_ascii_lowercase())], + |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)), + ) +} + /// Resolves a v2 package by its catalog id back to its rowid + hash so the /// post-correlation enrichment pass can load versionData for rows that /// correlated through the name-based fallback (which throws the rowid away). @@ -4060,6 +4557,57 @@ fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], ar Some(format!("< {}", latest.version)) } +/// Returns `> latest` when ARP range metadata proves the installed package +/// version is newer than the newest catalog version that has ARP bounds. This +/// happens for inbox MSIX frameworks that Windows services beyond winget's +/// latest user-facing catalog mapping. +fn greater_than_latest_arp_anchored_version( + entries: &[PackageVersionDataEntry], + arp_version: &str, + catalog_latest_version: &str, +) -> Option { + if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") { + return None; + } + + let latest = entries + .iter() + .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some()) + .max_by(|a, b| compare_version(&a.version, &b.version))?; + let latest_max = latest.arp_max_version.as_deref()?; + if !latest.version.eq_ignore_ascii_case(catalog_latest_version) { + return None; + } + if !has_different_leading_version_number(latest_max, &latest.version) { + return None; + } + if compare_version(arp_version, latest_max) != Ordering::Greater { + return None; + } + + Some(format!("> {}", latest.version)) +} + +fn has_different_leading_version_number(left: &str, right: &str) -> bool { + let Some(left) = leading_version_number(left) else { + return false; + }; + let Some(right) = leading_version_number(right) else { + return false; + }; + !left.eq_ignore_ascii_case(right) +} + +fn leading_version_number(value: &str) -> Option<&str> { + let trimmed = value.trim_start(); + let length = trimmed + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .map(char::len_utf8) + .sum(); + (length > 0).then_some(&trimmed[..length]) +} + /// Returns the latest catalog Version that carries ARP-range metadata /// (`aMiV` / `aMaV`). Packages like `Microsoft.WindowsAppRuntime.1.8` also /// publish "internal" version rows whose `v` is an MSI build number @@ -4264,6 +4812,8 @@ fn collect_uninstall_view( let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned()); let publisher = read_reg_string(&subkey, "Publisher"); let install_location = read_reg_string(&subkey, "InstallLocation"); + let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier"); + let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier"); let package_family_names = read_reg_string(&subkey, "PackageFamilyName") .into_iter() .collect::>(); @@ -4320,6 +4870,8 @@ fn collect_uninstall_view( scope: Some(scope.to_owned()), installer_category, install_location, + winget_package_identifier, + winget_source_identifier, package_family_names, product_codes, upgrade_codes, @@ -4355,9 +4907,6 @@ fn collect_appmodel_packages( Err(_) => continue, }; - let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else { - continue; - }; let install_location = read_reg_string(&subkey, "PackageRootFolder"); if install_location.as_deref().is_some_and(is_windows_system_path) { continue; @@ -4366,6 +4915,16 @@ fn collect_appmodel_packages( let Some(metadata) = parse_msix_package_full_name(&key_name) else { continue; }; + if is_msix_split_resource_package(&metadata.resource_id) { + continue; + } + + let Some(name) = read_reg_string(&subkey, "DisplayName") + .or_else(|| read_appmodel_display_name(&subkey)) + .filter(|value| !value.is_empty()) + else { + continue; + }; let local_id = format!(r"MSIX\{key_name}"); let dedupe_key = format!( @@ -4386,6 +4945,8 @@ fn collect_appmodel_packages( scope: Some(scope.to_owned()), installer_category: Some("msix".to_owned()), install_location, + winget_package_identifier: None, + winget_source_identifier: None, package_family_names: vec![metadata.family_name], product_codes: Vec::new(), upgrade_codes: Vec::new(), @@ -4402,6 +4963,7 @@ fn collect_appmodel_packages( #[cfg(windows)] struct ParsedMsixPackageFullName { version: String, + resource_id: String, family_name: String, } @@ -4432,10 +4994,22 @@ fn parse_msix_package_full_name(value: &str) -> Option Option { + let app_key = package_key.open_subkey(r"App\Capabilities").ok()?; + read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName")) +} + +#[cfg(windows)] +fn is_msix_split_resource_package(resource_id: &str) -> bool { + resource_id.trim().to_ascii_lowercase().starts_with("split.") +} + #[cfg(windows)] fn is_windows_system_path(path: &str) -> bool { path.trim().to_ascii_lowercase().starts_with(r"c:\windows\") @@ -4521,20 +5095,36 @@ fn admin_settings_path(app_root: &Path) -> PathBuf { app_root.join("admin-settings.json") } +#[cfg(test)] fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf { + source_state_dir_with_keying(app_root, source, false) +} + +fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf { if uses_packaged_layout(app_root) { return app_root .join(packaged_source_type(source.kind)) .join(sanitize_path_segment(&source.identifier)); } + if identifier_keyed { + return app_root.join("sources").join(sanitize_path_segment(&source.identifier)); + } + app_root.join("sources").join(sanitize_path_segment(&source.name)) } -fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf { - source_state_dir(app_root, source).join("source.msix") +fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf { + if uses_packaged_layout(app_root) { + return app_root + .join(sanitize_path_segment(&source.identifier)) + .join("installed.db"); + } + + source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db") } +#[cfg(test)] fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf { source_state_dir(app_root, source).join("index.db") } @@ -4647,25 +5237,6 @@ fn replace_file(source: &Path, target: &Path) -> Result<()> { .with_context(|| format!("failed to replace {} with {}", target.display(), source.display())) } -fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf { - source_state_dir(app_root, source).join("rest-information.json") -} - -fn rest_manifest_cache_path_with_root( - app_root: &Path, - source: &SourceRecord, - package_id: &str, - version: &str, - channel: &str, -) -> PathBuf { - let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel); - let digest = sha256_hex(key.as_bytes()); - cache_root(app_root) - .join("REST_M") - .join(sanitize_path_segment(&source.identifier)) - .join(format!("{digest}.json")) -} - fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf { cache_root(app_root) .join(bucket) @@ -4690,6 +5261,81 @@ fn load_store(app_root: &Path) -> Result { serde_json::from_slice(&bytes).context("failed to parse source store") } +fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> { + if uses_system_winget_source_commands(app_root) { + return Ok(( + EffectiveSourceMode::SystemWingetDirect, + load_system_winget_source_store()?, + )); + } + + match source_mode { + SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)), + SourceMode::SystemWingetMirror => { + let store = load_or_refresh_system_winget_mirror_store(app_root, false)?; + Ok((EffectiveSourceMode::SystemWingetMirror, store)) + } + SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) { + Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)), + Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)), + }, + } +} + +fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf { + app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME) +} + +fn load_system_winget_mirror_store(app_root: &Path) -> Result> { + let path = system_winget_mirror_store_path(app_root); + if !path.exists() { + return Ok(None); + } + + let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?; + serde_json::from_slice(&bytes) + .context("failed to parse system WinGet mirror source store") + .map(Some) +} + +fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> { + write_json(system_winget_mirror_store_path(app_root), store) +} + +fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result { + if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? { + return Ok(store); + } + + refresh_system_winget_mirror_store(app_root) +} + +fn refresh_system_winget_mirror_store(app_root: &Path) -> Result { + let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default(); + let mut exported = load_system_winget_source_store()?; + merge_source_cache_metadata(&mut exported, &prior); + save_system_winget_mirror_store(app_root, &exported)?; + Ok(exported) +} + +fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) { + let prior_by_identifier: HashMap<_, _> = prior + .sources + .iter() + .map(|source| (source.identifier.to_ascii_lowercase(), source)) + .collect(); + + for source in &mut target.sources { + let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else { + continue; + }; + source.last_update = previous.last_update; + source.source_version = previous.source_version.clone(); + source.etag = previous.etag.clone(); + source.last_modified = previous.last_modified.clone(); + } +} + fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> { if uses_system_winget_source_commands(app_root) { bail!("System WinGet source settings must be modified through winget."); @@ -4950,7 +5596,7 @@ fn parse_system_winget_source_export(output: &str) -> Result> if let Ok(value) = serde_json::from_str::(trimmed) && let Some(sources) = parse_system_winget_source_export_value(&value)? { - return Ok(order_sources(sources)); + return Ok(sources); } let mut sources = Vec::new(); @@ -4967,7 +5613,7 @@ fn parse_system_winget_source_export(output: &str) -> Result> sources.append(&mut parsed); } - Ok(order_sources(sources)) + Ok(sources) } fn parse_system_winget_source_export_value(value: &JsonValue) -> Result>> { @@ -5078,16 +5724,6 @@ fn json_trust_value_is_trusted(value: &JsonValue) -> bool { .is_some_and(|value| value.eq_ignore_ascii_case("Trusted")) } -fn order_sources(mut sources: Vec) -> Vec { - sources.sort_by(|left, right| { - left.explicit - .cmp(&right.explicit) - .then_with(|| right.priority.cmp(&left.priority)) - .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase())) - }); - sources -} - fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option { if user_sources_yaml.is_none() && metadata_yaml.is_none() { return None; @@ -10469,6 +11105,11 @@ mod tests { .join("Packages") .join(PACKAGED_FAMILY_NAME) .join("LocalState"); + let source = SourceStore::default() + .sources + .into_iter() + .find(|source| source.name == "winget") + .expect("winget source"); assert!(uses_packaged_layout(&app_root)); assert!( user_settings_path(&app_root) @@ -10485,6 +11126,24 @@ mod tests { PACKAGED_FAMILY_NAME )) ); + assert!( + source_state_dir(&app_root, &source) + .display() + .to_string() + .ends_with(&format!( + r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe", + PACKAGED_FAMILY_NAME + )) + ); + assert!( + source_installed_db_path_with_keying(&app_root, &source, false) + .display() + .to_string() + .ends_with(&format!( + r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db", + PACKAGED_FAMILY_NAME + )) + ); } #[cfg(windows)] @@ -10543,24 +11202,26 @@ mod tests { #[test] fn system_winget_export_parses_json_lines() { - let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} -{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} + let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"} +{"Arg":"https://cdn.winget.microsoft.com/cache","Data":"Microsoft.Winget.Source_8wekyb3d8bbwe","Explicit":false,"Identifier":"Microsoft.Winget.Source_8wekyb3d8bbwe","Name":"winget","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} +{"Arg":"https://cdn.winget.microsoft.com/fonts","Data":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Name":"winget-font","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"} "#; let sources = parse_system_winget_source_export(output).expect("source export"); assert_eq!( sources.iter().map(|source| source.name.as_str()).collect::>(), - vec!["contoso", "contoso-cache"] + vec!["winget.pro", "msstore", "winget", "winget-font"] ); assert_eq!(sources[0].kind, SourceKind::Rest); - assert_eq!(sources[0].arg, "https://api.contoso.test/feed"); - assert_eq!(sources[0].identifier, "api.contoso.test"); + assert_eq!(sources[0].arg, "https://api.winget.pro/feed"); + assert_eq!(sources[0].identifier, "api.winget.pro"); assert_eq!(sources[0].trust_level, "Trusted"); assert!(!sources[0].explicit); - assert_eq!(sources[1].kind, SourceKind::PreIndexed); - assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe"); - assert!(sources[1].explicit); + assert_eq!(sources[2].kind, SourceKind::PreIndexed); + assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe"); + assert!(!sources[2].explicit); } #[test] @@ -10663,6 +11324,66 @@ mod tests { assert_eq!(source.kind, SourceKind::Rest); } + #[test] + fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() { + let original_runner = { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + let original = *runner; + *runner = fake_system_winget_source_export; + original + }; + + let app_root = temp_app_root("system-winget-mirror"); + let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror); + + { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + *runner = original_runner; + } + + let (mode, mut store) = result.expect("mirror store"); + assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror); + assert!(system_winget_mirror_store_path(&app_root).exists()); + assert!( + !store_path(&app_root).exists(), + "mirror mode must not write private sources.json" + ); + + let source = store.sources.first_mut().expect("source"); + source.last_update = Some(Utc::now()); + source.source_version = Some("cached-contract".to_owned()); + source.etag = Some("\"etag\"".to_owned()); + source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned()); + save_system_winget_mirror_store(&app_root, &store).expect("save mirror"); + + let original_runner = { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + let original = *runner; + *runner = fake_system_winget_source_export; + original + }; + let refreshed = refresh_system_winget_mirror_store(&app_root); + { + let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock"); + *runner = original_runner; + } + + let refreshed = refreshed.expect("refresh mirror"); + let source = refreshed.sources.first().expect("source"); + assert_eq!(source.source_version.as_deref(), Some("cached-contract")); + assert_eq!(source.etag.as_deref(), Some("\"etag\"")); + assert_eq!( + source_state_dir_with_keying(&app_root, source, true), + app_root.join("sources").join("api.contoso.test") + ); + assert_eq!( + source_state_dir_with_keying(&app_root, source, false), + app_root.join("sources").join("contoso") + ); + + let _ = fs::remove_dir_all(&app_root); + } + #[test] fn source_metadata_and_named_reset_round_trip() { let app_root = temp_app_root("source-reset"); @@ -10732,6 +11453,81 @@ mod tests { result.expect("source resolution"); } + #[test] + fn source_resolution_accepts_source_identifier() { + let app_root = temp_app_root("source-identifier-resolution"); + let result = (|| -> Result<()> { + let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?; + repository.add_source_with_metadata( + "winget.pro", + "https://api.winget.pro/feed", + SourceKind::Rest, + Some("trusted"), + false, + 0, + )?; + repository.store.sources[0].identifier = "api.winget.pro".to_owned(); + + let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?; + assert_eq!(indexes, vec![0]); + Ok(()) + })(); + + let _ = fs::remove_dir_all(&app_root); + result.expect("source identifier resolution"); + } + + #[test] + fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() { + let query = PackageQuery { + id: Some("tessl.tessl".to_owned()), + source: Some("winget".to_owned()), + exact: true, + ..PackageQuery::default() + }; + let clean_miss = SearchLocatedResult { + matches: Vec::new(), + warnings: Vec::new(), + failures: Vec::new(), + truncated: false, + }; + assert!(should_retry_show_across_system_winget_sources( + true, + &query, + &clean_miss + )); + + let private_mode = false; + assert!(!should_retry_show_across_system_winget_sources( + private_mode, + &query, + &clean_miss + )); + + let explicit_custom_source = PackageQuery { + source: Some("winget.pro".to_owned()), + ..query.clone() + }; + assert!(!should_retry_show_across_system_winget_sources( + true, + &explicit_custom_source, + &clean_miss + )); + + let failed_source = SearchLocatedResult { + failures: vec![SourceSearchFailure { + source_name: "winget".to_owned(), + message: "network failure".to_owned(), + }], + ..clean_miss + }; + assert!(!should_retry_show_across_system_winget_sources( + true, + &query, + &failed_source + )); + } + #[test] fn user_and_admin_settings_round_trip() { let app_root = temp_app_root("settings"); @@ -11203,6 +11999,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: true, correlated_lacks_compatible_installer: false, @@ -11256,6 +12054,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: true, @@ -11384,6 +12184,51 @@ Installers: assert_eq!(rowid, 100); } + #[test] + fn lookup_installed_db_identity_match_returns_product_code_match() { + let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db"); + execute_batch_sql( + &connection, + "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\ + CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\ + CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\ + CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\ + CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\ + INSERT INTO ids VALUES (1, 'Git.Git');\n\ + INSERT INTO names VALUES (1, 'Git');\n\ + INSERT INTO manifest VALUES (10, 1, 1);\n\ + INSERT INTO productcodes VALUES (20, 'git_is1');\n\ + INSERT INTO productcodes_map VALUES (10, 20);", + ) + .expect("seed schema"); + let package = InstalledPackage { + name: "Git".to_owned(), + local_id: r"ARP\Machine\X64\Git_is1".to_owned(), + installed_version: "2.54.0".to_owned(), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: vec!["git_is1".to_owned()], + upgrade_codes: Vec::new(), + correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + + let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package) + .expect("query") + .expect("product code match"); + + assert_eq!(id, "Git.Git"); + assert_eq!(name, "Git"); + assert_eq!(matched_by, "ProductCode"); + } + #[test] fn lookup_unique_normalized_identity_rejects_ambiguous_match() { // Two distinct packages share the same (norm_name, norm_publisher) @@ -12611,6 +13456,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12654,6 +13501,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12696,6 +13545,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12745,6 +13596,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12807,6 +13660,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12846,6 +13701,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12893,6 +13750,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12925,6 +13784,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12966,6 +13827,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -12997,6 +13860,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13009,7 +13874,7 @@ Installers: assert!(installed_package_matches_upgrade_filter(&package, &query)); assert_eq!( - list_match_from_installed(package).available_version.as_deref(), + list_match_from_installed(package, &[]).available_version.as_deref(), Some("2.0.0") ); } @@ -13266,7 +14131,26 @@ Installers: .expect("package metadata"); assert_eq!(parsed.version, "0.98.1.0"); + assert_eq!(parsed.resource_id, ""); assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe"); + assert!(!is_msix_split_resource_package(&parsed.resource_id)); + } + + #[cfg(windows)] + #[test] + fn classifies_msix_split_resource_package() { + let parsed = parse_msix_package_full_name( + "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe", + ) + .expect("package metadata"); + + assert_eq!(parsed.version, "1.97.17002.0"); + assert_eq!(parsed.resource_id, "split.scale-125"); + assert_eq!( + parsed.family_name, + "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe" + ); + assert!(is_msix_split_resource_package(&parsed.resource_id)); } #[cfg(windows)] @@ -13428,6 +14312,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13444,6 +14330,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -13460,6 +14348,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -14257,6 +15147,30 @@ Installers: assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none()); } + #[test] + fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() { + // WindowsAppRuntime packages can be serviced beyond the latest winget + // catalog versionData range. winget renders those as `> latest` and + // does not report an available downgrade. + let entries = vec![ + version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")), + version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")), + ]; + + assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none()); + assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none()); + assert_eq!( + greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(), + Some("> 1.5.8") + ); + assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none()); + + let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))]; + assert!( + greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none() + ); + } + #[test] fn map_arp_version_skips_entries_missing_arp_bounds() { // Older catalog packages predate AppsAndFeaturesEntries and have no @@ -14358,12 +15272,14 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, }; - let item = list_match_from_installed(package); + let item = list_match_from_installed(package, &[]); assert_eq!(item.id, "Microsoft.Azure.AZCopy.10"); assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy"); @@ -14371,6 +15287,63 @@ Installers: assert_eq!(item.available_version.as_deref(), Some("10.32.3")); } + #[test] + fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() { + let rows = vec![ + ListMatch { + name: "Example 1.0".to_owned(), + id: "Example.Tool".to_owned(), + local_id: r"ARP\Machine\X64\old".to_owned(), + installed_version: "1.0.0".to_owned(), + available_version: Some("3.0.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }, + ListMatch { + name: "Example 2.0".to_owned(), + id: "Example.Tool".to_owned(), + local_id: r"ARP\Machine\X64\new".to_owned(), + installed_version: "2.0.0".to_owned(), + available_version: Some("3.0.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }, + ListMatch { + name: "Other".to_owned(), + id: "Other.Tool".to_owned(), + local_id: r"ARP\Machine\X64\other".to_owned(), + installed_version: "1.0.0".to_owned(), + available_version: Some("2.0.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: None, + scope: None, + installer_category: None, + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }, + ]; + + let result = suppress_duplicate_available_versions(rows); + + assert_eq!(result[0].available_version, None); + assert_eq!(result[1].available_version.as_deref(), Some("3.0.0")); + assert_eq!(result[2].available_version.as_deref(), Some("2.0.0")); + } + #[test] fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() { let package = InstalledPackage { @@ -14394,12 +15367,14 @@ Installers: channel: None, match_criteria: Some("ProductCode".to_owned()), }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, }; - let item = list_match_from_installed(package); + let item = list_match_from_installed(package, &[]); assert_eq!(item.id, "Atlassian.AtlassianCLI"); assert_eq!( @@ -14410,6 +15385,172 @@ Installers: assert_eq!(item.source_name.as_deref(), Some("winget")); } + #[test] + fn list_match_from_installed_derives_winget_portable_identity_from_local_id() { + let package = InstalledPackage { + name: "FFmpeg".to_owned(), + local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(), + installed_version: "8.0".to_owned(), + publisher: Some("Gyan".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + let sources = vec![SourceRecord { + name: "winget".to_owned(), + kind: SourceKind::PreIndexed, + arg: "https://cdn.winget.microsoft.com/cache".to_owned(), + identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 0, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }]; + + let item = list_match_from_installed(package, &sources); + + assert_eq!(item.id, "Gyan.FFmpeg"); + assert_eq!( + item.local_id, + r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe" + ); + assert_eq!(item.source_name.as_deref(), Some("winget")); + } + + #[test] + fn list_match_from_installed_uses_winget_arp_identity_metadata() { + let package = InstalledPackage { + name: "tessl".to_owned(), + local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(), + installed_version: "0.77.0".to_owned(), + publisher: Some("tessl.io".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + winget_package_identifier: Some("tessl.tessl".to_owned()), + winget_source_identifier: Some("api.winget.pro".to_owned()), + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + + let item = list_match_from_installed(package, &[]); + + assert_eq!(item.id, "tessl.tessl"); + assert_eq!(item.source_name.as_deref(), Some("api.winget.pro")); + } + + #[test] + fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() { + let package = InstalledPackage { + name: "tessl".to_owned(), + local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(), + installed_version: "0.77.0".to_owned(), + publisher: Some("tessl.io".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + winget_package_identifier: Some("tessl.tessl".to_owned()), + winget_source_identifier: Some("api.winget.pro".to_owned()), + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + let sources = vec![SourceRecord { + name: "winget.pro".to_owned(), + kind: SourceKind::Rest, + arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(), + identifier: "api.winget.pro".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 0, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }]; + + let item = list_match_from_installed(package, &sources); + + assert_eq!(item.id, "tessl.tessl"); + assert_eq!(item.source_name.as_deref(), Some("winget.pro")); + } + + #[test] + fn list_match_from_installed_keeps_scanning_portable_source_identifiers() { + let package = InstalledPackage { + name: "Example".to_owned(), + local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(), + installed_version: "1.0".to_owned(), + publisher: Some("Vendor".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("portable".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + let sources = vec![ + SourceRecord { + name: "long".to_owned(), + kind: SourceKind::PreIndexed, + arg: "https://example.invalid/long".to_owned(), + identifier: "identifier-longer-than-local-id".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 0, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }, + SourceRecord { + name: "short".to_owned(), + kind: SourceKind::PreIndexed, + arg: "https://example.invalid/short".to_owned(), + identifier: "short".to_owned(), + trust_level: "Trusted".to_owned(), + explicit: false, + priority: 1, + last_update: None, + source_version: None, + etag: None, + last_modified: None, + }, + ]; + + let item = list_match_from_installed(package, &sources); + + assert_eq!(item.id, "Vendor.Example"); + assert_eq!(item.source_name.as_deref(), Some("short")); + } + #[test] fn list_response_serialization_keeps_pascal_case_fields() { let response = ListResponse { @@ -14549,6 +15690,8 @@ Installers: channel: None, match_criteria: Some("PackageFamilyName".to_owned()), }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -14584,6 +15727,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -14619,6 +15764,8 @@ Installers: channel: None, match_criteria: Some("PackageFamilyName".to_owned()), }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -14674,6 +15821,8 @@ Installers: channel: None, match_criteria: None, }), + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: canonical, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, @@ -14724,6 +15873,8 @@ Installers: product_codes: Vec::new(), upgrade_codes: Vec::new(), correlated: None, + winget_package_identifier: None, + winget_source_identifier: None, installed_version_canonical: false, correlated_requires_explicit_upgrade: false, correlated_lacks_compatible_installer: false, diff --git a/scripts/compare-list.ps1 b/scripts/compare-list.ps1 new file mode 100644 index 0000000..1ad2cd7 --- /dev/null +++ b/scripts/compare-list.ps1 @@ -0,0 +1,977 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Compares `winget list` vs Rust and C# `pinget list` metadata on the current machine. + +.DESCRIPTION + Runs `winget list` (text, parsed), C# `pinget list --output json`, and + Rust `pinget list --output json`, normalises them into a common schema, + and reports structured diffs across five classes for each implementation: + + 1. CorrelationMiss — winget shows a catalog Id + source; pinget shows + a raw ARP/MSIX local Id for the same package (matched by display name). + 2. VersionMismatch — same effective Id, different InstalledVersion. + 3. SourceMismatch — same effective Id but pinget reports a different + source name (or none) compared with winget. + 4. AvailableDelta — one tool surfaces an upgrade, the other does not. + 5. DuplicateId — pinget emits the same catalog or local Id more than + once (raw + correlated row for the same install). + Plus counts of: rows matching fully, rows only in winget, rows only + in pinget. + + The script is strictly read-only: it never mutates installs, sources, + or pins. Designed to be re-run on different machines to build a corpus + of correlation classes that pinget gets wrong. + +.PARAMETER Pinget + Backward-compatible alias for -PingetCs. + +.PARAMETER PingetCs + Path to the C# pinget executable. Defaults to the Debug build output from this repo. + +.PARAMETER PingetRust + Path to the Rust pinget executable. Defaults to rust\target\debug\pinget.exe. + +.PARAMETER Winget + Path to the winget executable. Defaults to `winget` on PATH. + +.PARAMETER FixturePath + If set, writes a JSON fixture with the full diff and raw captures. + +.PARAMETER UseSystemWingetAppRoot + Point Pinget at Desktop App Installer's LocalState through PINGET_APPROOT + so Pinget uses the same system WinGet source/pin/settings state as winget.exe. + +.PARAMETER PingetAppRoot + Explicit app root to expose to both Pinget CLIs through PINGET_APPROOT. + +.PARAMETER IncludeUnknown + Kept for upgrade parity with sibling scripts. Plain list does not pass + `--include-unknown` because both winget and pinget reject it unless + `--upgrade-available` is also present. + +.PARAMETER FailOnDiff + Exit non-zero when any non-cosmetic difference is found. + +.PARAMETER UpdateSources + Run `source update` before diffing. + +.EXAMPLE + .\compare-list.ps1 + # Quick interactive check. + +.EXAMPLE + .\compare-list.ps1 -FixturePath ./list-diff.json -FailOnDiff +#> +param( + [string]$Pinget, + [string]$PingetCs = (Join-Path $PSScriptRoot "..\dotnet\src\Devolutions.Pinget.Cli\bin\Debug\net10.0\pinget.exe"), + [string]$PingetRust = (Join-Path $PSScriptRoot "..\rust\target\debug\pinget.exe"), + [string]$Winget = "winget", + [string]$FixturePath, + [switch]$UseSystemWingetAppRoot, + [string]$PingetAppRoot, + [bool]$IncludeUnknown = $true, + [switch]$FailOnDiff, + [switch]$UpdateSources +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$OutputEncoding = [Console]::OutputEncoding + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +function Write-Section { + param([string]$Title) + Write-Host "" + Write-Host ("=" * 80) + Write-Host $Title + Write-Host ("=" * 80) +} + +function Invoke-CaptureLines { + param( + [Parameter(Mandatory = $true)] [string]$Executable, + [Parameter(Mandatory = $true)] [string[]]$Arguments + ) + try { + $lines = & $Executable @Arguments 2>&1 | ForEach-Object { $_.ToString() } + $exit = $LASTEXITCODE + [pscustomobject]@{ ExitCode = $exit; Lines = @($lines) } + } catch { + [pscustomobject]@{ ExitCode = -1; Lines = @($_.Exception.Message) } + } +} + +# --------------------------------------------------------------------------- +# Winget list capture + parser +# --------------------------------------------------------------------------- + +function Get-WingetListRows { + param( + [Parameter(Mandatory = $true)] [string]$Executable, + [bool]$IncludeUnknown + ) + + # Note: winget list --include-unknown is only valid with --upgrade-available + # on this winget build (v1.29.x-preview). Plain list already shows all packages. + $listArgs = @("list", "--accept-source-agreements", "--disable-interactivity") + $captured = Invoke-CaptureLines -Executable $Executable -Arguments $listArgs + + # Locate the separator line (a run of dashes) and derive column offsets + # from the header line immediately above it. winget's list table has + # either 4 or 5 columns (Available is added when any row has an upgrade). + # Name Id Version [Available] Source + $separatorIdx = -1 + for ($i = 0; $i -lt $captured.Lines.Count; $i++) { + if ($captured.Lines[$i] -match '^-{10,}$') { + $separatorIdx = $i + break + } + } + + if ($separatorIdx -lt 1) { + $emptyOk = @($captured.Lines | Where-Object { + $_ -match 'No installed package' + }).Count -gt 0 + return @{ + Rows = @() + ExitCode = $captured.ExitCode + Raw = $captured.Lines + Diagnostic = if ($emptyOk) { $null } else { + "no table header/separator found in winget list output" + } + } + } + + $header = $captured.Lines[$separatorIdx - 1] + # Column tokens in order; Available is optional. + $columnDefs = @( + @{ Key = 'Name'; Token = 'Name' }, + @{ Key = 'Id'; Token = 'Id' }, + @{ Key = 'Version'; Token = 'Version' }, + @{ Key = 'Available'; Token = 'Available' }, + @{ Key = 'Source'; Token = 'Source' } + ) + $offsets = [ordered]@{} + foreach ($col in $columnDefs) { + $pos = $header.IndexOf($col.Token) + if ($pos -ge 0) { $offsets[$col.Key] = $pos } + } + # Name and Id are required; warn if absent. + if (-not $offsets.Contains('Name') -or -not $offsets.Contains('Id')) { + return @{ + Rows = @() + ExitCode = $captured.ExitCode + Raw = $captured.Lines + Diagnostic = "winget list header is missing Name or Id column" + } + } + + function Get-Slice { + param([string]$Line, [int]$Start, [int]$End) + if ($Start -ge $Line.Length) { return "" } + $eff = [Math]::Min($End, $Line.Length) + return $Line.Substring($Start, $eff - $Start).TrimEnd().TrimStart() + } + + $orderedKeys = @($offsets.Keys) + $rows = New-Object System.Collections.Generic.List[object] + + for ($i = $separatorIdx + 1; $i -lt $captured.Lines.Count; $i++) { + $line = $captured.Lines[$i] + if ([string]::IsNullOrWhiteSpace($line)) { continue } + if ($line -match '^\d+ packages? (listed|found)') { break } + if ($line -match '^&1 | Select-Object -First 1).Trim() } catch { "unavailable: $($_.Exception.Message)" } +} + +function Get-SystemWingetAppRoot { + if (-not $IsWindows) { + throw "-UseSystemWingetAppRoot requires Windows." + } + + return Join-Path $env:LOCALAPPDATA "Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState" +} + +function Get-MachineSnapshot { + param([string]$PingetCs, [string]$PingetRust, [string]$Winget, [string]$PingetAppRoot) + [pscustomobject]@{ + CapturedAt = (Get-Date).ToUniversalTime().ToString("o") + OSVersion = [System.Environment]::OSVersion.VersionString + OSArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + WingetVersion = Get-ToolVersion $Winget + PingetCsVersion = Get-ToolVersion $PingetCs + PingetRustVersion = Get-ToolVersion $PingetRust + PingetAppRoot = $PingetAppRoot + } +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if (-not [string]::IsNullOrWhiteSpace($Pinget)) { + $PingetCs = $Pinget +} + +if ($UseSystemWingetAppRoot -and -not [string]::IsNullOrWhiteSpace($PingetAppRoot)) { + throw "Use either -UseSystemWingetAppRoot or -PingetAppRoot, not both." +} + +if ($UseSystemWingetAppRoot) { + $PingetAppRoot = Get-SystemWingetAppRoot +} + +if (-not [string]::IsNullOrWhiteSpace($PingetAppRoot)) { + $env:PINGET_APPROOT = $PingetAppRoot +} + +$machine = Get-MachineSnapshot -PingetCs $PingetCs -PingetRust $PingetRust -Winget $Winget -PingetAppRoot $env:PINGET_APPROOT +Write-Section "Machine" +$machine | Format-List | Out-String | Write-Host + +if ($UpdateSources) { + Write-Host "Updating sources..." + & $Winget source update --accept-source-agreements --disable-interactivity 2>&1 | Out-Null + & $PingetCs source update 2>&1 | Out-Null + & $PingetRust source update 2>&1 | Out-Null +} + +Write-Section "Capturing winget list" +$wingetResult = Get-WingetListRows -Executable $Winget -IncludeUnknown:$IncludeUnknown +Write-Host ("winget exit={0}, parsed {1} row(s)" -f $wingetResult.ExitCode, $wingetResult.Rows.Count) +if ($wingetResult.Diagnostic) { Write-Warning $wingetResult.Diagnostic } + +Write-Section "Capturing C# pinget list" +$csResult = Get-PingetListRows -Executable $PingetCs -IncludeUnknown:$IncludeUnknown +Write-Host ("C# pinget exit={0}, parsed {1} row(s)" -f $csResult.ExitCode, $csResult.Rows.Count) +if ($csResult.Diagnostic) { Write-Warning $csResult.Diagnostic } + +Write-Section "Capturing Rust pinget list" +$rustResult = Get-PingetListRows -Executable $PingetRust -IncludeUnknown:$IncludeUnknown +Write-Host ("Rust pinget exit={0}, parsed {1} row(s)" -f $rustResult.ExitCode, $rustResult.Rows.Count) +if ($rustResult.Diagnostic) { Write-Warning $rustResult.Diagnostic } + +$csDiff = Compare-ListRows -WingetRows $wingetResult.Rows -PingetRows $csResult.Rows +$rustDiff = Compare-ListRows -WingetRows $wingetResult.Rows -PingetRows $rustResult.Rows +$implementationDiff = Compare-PingetImplementations -LeftLabel "Rust" -LeftRows $rustResult.Rows -RightLabel "CSharp" -RightRows $csResult.Rows + +Write-WingetParityReport -Label "CSharp" -Diff $csDiff +Write-WingetParityReport -Label "Rust" -Diff $rustDiff +Write-ImplementationDriftReport -Diff $implementationDiff + +$csFailCount = Get-WingetParityFailureCount $csDiff +$rustFailCount = Get-WingetParityFailureCount $rustDiff +$implementationFailCount = Get-ImplementationDriftFailureCount $implementationDiff +$failCount = $csFailCount + $rustFailCount + $implementationFailCount +$parserBroken = [bool]$wingetResult.Diagnostic -or [bool]$csResult.Diagnostic -or [bool]$rustResult.Diagnostic +$verdict = if ($failCount -eq 0 -and -not $parserBroken) { "PASS" } else { "FAIL" } +Write-Section "Result: $verdict ($failCount non-cosmetic differences)" +Write-Host ("CSharp vs winget : {0}" -f $csFailCount) +Write-Host ("Rust vs winget : {0}" -f $rustFailCount) +Write-Host ("Rust vs CSharp : {0}" -f $implementationFailCount) + +if ($FixturePath) { + $fixture = [ordered]@{ + schema = "pinget-parity/list/v2" + machine = $machine + invocation = [ordered]@{ + pingetCs = $PingetCs + pingetRust = $PingetRust + winget = $Winget + pingetAppRoot = $env:PINGET_APPROOT + useSystemWingetAppRoot = [bool]$UseSystemWingetAppRoot + includeUnknown = [bool]$IncludeUnknown + } + winget = [ordered]@{ exitCode = $wingetResult.ExitCode; rows = $wingetResult.Rows; raw = $wingetResult.Raw } + pingetCs = [ordered]@{ exitCode = $csResult.ExitCode; rows = $csResult.Rows; raw = $csResult.Raw } + pingetRust = [ordered]@{ exitCode = $rustResult.ExitCode; rows = $rustResult.Rows; raw = $rustResult.Raw } + diff = [ordered]@{ + csharpVsWinget = [ordered]@{ + correlationMiss = $csDiff.CorrelationMiss + versionMismatch = $csDiff.VersionMismatch + sourceMismatch = $csDiff.SourceMismatch + availableDelta = $csDiff.AvailableDelta + duplicateId = $csDiff.DuplicateId + sharedDuplicateId = $csDiff.SharedDuplicateId + onlyInWinget = $csDiff.OnlyInWinget + onlyInPinget = $csDiff.OnlyInPinget + matching = $csDiff.Matching.Count + } + rustVsWinget = [ordered]@{ + correlationMiss = $rustDiff.CorrelationMiss + versionMismatch = $rustDiff.VersionMismatch + sourceMismatch = $rustDiff.SourceMismatch + availableDelta = $rustDiff.AvailableDelta + duplicateId = $rustDiff.DuplicateId + sharedDuplicateId = $rustDiff.SharedDuplicateId + onlyInWinget = $rustDiff.OnlyInWinget + onlyInPinget = $rustDiff.OnlyInPinget + matching = $rustDiff.Matching.Count + } + rustVsCsharp = [ordered]@{ + metadataMismatch = $implementationDiff.MetadataMismatch + onlyInRust = $implementationDiff.OnlyInLeft + onlyInCSharp = $implementationDiff.OnlyInRight + duplicateInRust = $implementationDiff.DuplicateLeft + duplicateInCSharp = $implementationDiff.DuplicateRight + matching = $implementationDiff.Matching.Count + } + } + } + $fixture | ConvertTo-Json -Depth 24 | Set-Content -LiteralPath $FixturePath -Encoding UTF8 + Write-Host "Fixture written to: $FixturePath" +} + +if ($FailOnDiff -and ($failCount -gt 0 -or $parserBroken)) { + throw "compare-list: $failCount non-cosmetic differences found." +}