From c40a42790dfdcf6e193b87c42bae34235f28e76e Mon Sep 17 00:00:00 2001 From: Andrew Clinick <80841394+aclinick@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:03:53 -0700 Subject: [PATCH 01/12] Lazy-load installed app icons for snappier Apps pane Split InstalledPackageService into ListWithoutIcons (fast WinRT enumeration, ~0.4s for 219 packages) and per-package ResolveIcon. The Apps pane now renders the list in <1s and streams icons in afterward instead of blocking several seconds on icon I/O upfront. MainPageViewModel.LoadInstalledAppsAsync does two passes with a CancellationTokenSource so closing the pane mid-resolution stops the background work. Icon updates replace each row via 'pkg with { IconBytes = ... }' so x:Bind picks them up via ObservableCollection's replace notification. +7 tests (147 total, all green). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MSIXplainer.Cli/MSIXplainer.Cli.csproj | 2 +- .../ExtractFromManifestFileTests.cs | 84 ++++ .../InstalledPackageServiceTests.cs | 194 +++++++++ .../MSIXplainer.Core.Tests.csproj | 2 +- MSIXplainer.Core/MSIXplainer.Core.csproj | 5 +- MSIXplainer.Core/Models/InstalledPackage.cs | 43 ++ .../Services/InstalledPackageService.cs | 166 ++++++++ .../Services/ManifestParserService.cs | 96 +++++ MSIXplainer/MainPage.xaml | 373 ++++++++++-------- MSIXplainer/MainPage.xaml.cs | 190 ++++++++- MSIXplainer/Package.appxmanifest | 2 +- MSIXplainer/Pages/ComparePage.xaml.cs | 14 +- MSIXplainer/ViewModels/MainPageViewModel.cs | 147 +++++++ 13 files changed, 1145 insertions(+), 173 deletions(-) create mode 100644 MSIXplainer.Core.Tests/ExtractFromManifestFileTests.cs create mode 100644 MSIXplainer.Core.Tests/InstalledPackageServiceTests.cs create mode 100644 MSIXplainer.Core/Models/InstalledPackage.cs create mode 100644 MSIXplainer.Core/Services/InstalledPackageService.cs diff --git a/MSIXplainer.Cli/MSIXplainer.Cli.csproj b/MSIXplainer.Cli/MSIXplainer.Cli.csproj index 866ca3f..f84c9ff 100644 --- a/MSIXplainer.Cli/MSIXplainer.Cli.csproj +++ b/MSIXplainer.Cli/MSIXplainer.Cli.csproj @@ -1,7 +1,7 @@ Exe - net10.0 + net10.0-windows10.0.26100.0 MSIXplainer MSIXplainer.Cli enable diff --git a/MSIXplainer.Core.Tests/ExtractFromManifestFileTests.cs b/MSIXplainer.Core.Tests/ExtractFromManifestFileTests.cs new file mode 100644 index 0000000..5789ad6 --- /dev/null +++ b/MSIXplainer.Core.Tests/ExtractFromManifestFileTests.cs @@ -0,0 +1,84 @@ +using MSIXplainer.Services; +using Xunit; + +namespace MSIXplainer.Core.Tests; + +public class ExtractFromManifestFileTests +{ + private const string SampleManifest = """ + + + + + Contoso Demo + Contoso + A demo package. + + + + + + + + + """; + + [Fact] + public void ExtractFromManifestFile_ReadsLooseManifest() + { + var dir = Directory.CreateTempSubdirectory("msixplainer-manifest-test-"); + try + { + var path = Path.Combine(dir.FullName, "AppxManifest.xml"); + File.WriteAllText(path, SampleManifest); + + var (doc, raw, info) = ManifestParserService.ExtractFromManifestFile(path); + + Assert.NotNull(doc.Root); + Assert.Equal("Contoso.Demo", info.Name); + Assert.Equal("Contoso Demo", info.DisplayName); + Assert.Equal("2.5.0.0", info.Version); + Assert.Equal("x64", info.Architecture); + Assert.StartsWith("Contoso.Demo_", info.PackageFamilyName); + Assert.Equal(13, info.PackageFamilyName.Split('_')[1].Length); + Assert.Contains("(() => + ManifestParserService.ExtractFromManifestFile( + Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid() + ".xml"))); + } + + [Fact] + public void ExtractFromManifestFile_DtdProcessing_Rejected() + { + var dir = Directory.CreateTempSubdirectory("msixplainer-manifest-dtd-"); + try + { + var path = Path.Combine(dir.FullName, "AppxManifest.xml"); + var malicious = """ + + ]> + + + + """; + File.WriteAllText(path, malicious); + + Assert.ThrowsAny(() => + ManifestParserService.ExtractFromManifestFile(path)); + } + finally + { + dir.Delete(recursive: true); + } + } +} diff --git a/MSIXplainer.Core.Tests/InstalledPackageServiceTests.cs b/MSIXplainer.Core.Tests/InstalledPackageServiceTests.cs new file mode 100644 index 0000000..8102178 --- /dev/null +++ b/MSIXplainer.Core.Tests/InstalledPackageServiceTests.cs @@ -0,0 +1,194 @@ +using MSIXplainer.Models; +using MSIXplainer.Services; +using Xunit; + +namespace MSIXplainer.Core.Tests; + +public class InstalledPackageServiceTests +{ + [Fact] + public void ManifestPath_BuildsFromInstallLocation() + { + var pkg = NewPackage(installLocation: Path.Combine("C:", "apps", "myapp")); + Assert.Equal( + Path.Combine("C:", "apps", "myapp", "AppxManifest.xml"), + pkg.ManifestPath); + } + + [Fact] + public void ManifestPath_EmptyInstallLocation_IsNull() + { + Assert.Null(NewPackage(installLocation: "").ManifestPath); + } + + [Fact] + public void List_OnNonWindows_ReturnsEmpty() + { + if (OperatingSystem.IsWindows()) + return; // Only meaningful off-Windows; PackageManager is unavailable there. + + Assert.Empty(InstalledPackageService.List()); + } + + [Fact] + public void List_OnWindows_ReturnsMainPackagesWithValidIdentity() + { + if (!OperatingSystem.IsWindows()) + return; + + var packages = InstalledPackageService.List(); + + // Don't assert specific packages — CI / dev images vary. Just verify the contract: + // no throw, valid identity fields, every PFN is in Name_PublisherHash form, and + // every row has a non-empty DisplayName (either resolved or fallen back to Name). + Assert.NotNull(packages); + foreach (var pkg in packages) + { + Assert.False(string.IsNullOrEmpty(pkg.Name), $"Name empty for {pkg.PackageFullName}"); + Assert.False(string.IsNullOrEmpty(pkg.DisplayName), $"DisplayName empty for {pkg.PackageFullName}"); + Assert.False(string.IsNullOrEmpty(pkg.PackageFamilyName), $"PFN empty for {pkg.Name}"); + Assert.Contains("_", pkg.PackageFamilyName); + Assert.DoesNotContain("ms-resource:", pkg.DisplayName); // unresolved indirections must fall back + } + } + + [Fact] + public void List_OnWindows_ReturnsSortedByDisplayName() + { + if (!OperatingSystem.IsWindows()) + return; + + var packages = InstalledPackageService.List(); + if (packages.Count < 2) return; + + var displayNames = packages.Select(p => p.DisplayName).ToList(); + var sorted = displayNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(); + Assert.Equal(sorted, displayNames); + } + + [Fact] + public void FindByFamilyName_EmptyOrNull_ReturnsNull() + { + Assert.Null(InstalledPackageService.FindByFamilyName("")); + Assert.Null(InstalledPackageService.FindByFamilyName(" ")); + Assert.Null(InstalledPackageService.FindByFamilyName(null!)); + } + + [Fact] + public void FindByFamilyName_OnWindows_RoundTripsKnownPackage() + { + if (!OperatingSystem.IsWindows()) + return; + + var first = InstalledPackageService.List().FirstOrDefault(); + if (first is null) return; // No packages on this machine — skip. + + var found = InstalledPackageService.FindByFamilyName(first.PackageFamilyName); + Assert.NotNull(found); + Assert.Equal(first.PackageFamilyName, found!.PackageFamilyName); + } + + [Fact] + public void FindByFamilyName_UnknownPackage_ReturnsNull() + { + if (!OperatingSystem.IsWindows()) + return; + + Assert.Null(InstalledPackageService.FindByFamilyName( + "MSIXplainer.NonExistent.Package_0000000000000")); + } + + [Fact] + public void ListWithoutIcons_OnNonWindows_ReturnsEmpty() + { + if (OperatingSystem.IsWindows()) + return; + + Assert.Empty(InstalledPackageService.ListWithoutIcons()); + } + + [Fact] + public void ListWithoutIcons_OnWindows_AllRowsHaveNullIcons() + { + if (!OperatingSystem.IsWindows()) + return; + + var packages = InstalledPackageService.ListWithoutIcons(); + Assert.NotNull(packages); + // Fast path explicitly skips icon resolution — every row must come back with null bytes. + Assert.All(packages, p => Assert.Null(p.IconBytes)); + } + + [Fact] + public void ListWithoutIcons_OnWindows_ReturnsSameIdentitiesAsList() + { + if (!OperatingSystem.IsWindows()) + return; + + var fast = InstalledPackageService.ListWithoutIcons() + .Select(p => p.PackageFullName) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + var full = InstalledPackageService.List() + .Select(p => p.PackageFullName) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + Assert.Equal(full, fast); + } + + [Fact] + public void ResolveIcon_AlreadyHasIcon_ReturnsSameInstance() + { + var pkg = NewPackage(installLocation: "C:\\nope") with { IconBytes = [0x01, 0x02] }; + var resolved = InstalledPackageService.ResolveIcon(pkg); + Assert.Same(pkg, resolved); + } + + [Fact] + public void ResolveIcon_EmptyInstallLocation_ReturnsSameInstance() + { + var pkg = NewPackage(installLocation: ""); + var resolved = InstalledPackageService.ResolveIcon(pkg); + Assert.Same(pkg, resolved); + } + + [Fact] + public void ResolveIcon_NonExistentFolder_ReturnsSameInstanceWithNullBytes() + { + var pkg = NewPackage(installLocation: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + var resolved = InstalledPackageService.ResolveIcon(pkg); + Assert.Null(resolved.IconBytes); + } + + [Fact] + public void ResolveIcon_OnWindows_PopulatesIconForRealPackage() + { + if (!OperatingSystem.IsWindows()) + return; + + // Pick a package whose icon-with full-pass actually resolves, then verify + // ResolveIcon arrives at the same bytes when started from the icon-less version. + var withIcons = InstalledPackageService.List(); + var sample = withIcons.FirstOrDefault(p => p.IconBytes is { Length: > 0 }); + if (sample is null) return; // No package on this box exposed an icon — skip. + + var stripped = sample with { IconBytes = null }; + var resolved = InstalledPackageService.ResolveIcon(stripped); + + Assert.NotNull(resolved.IconBytes); + Assert.Equal(sample.IconBytes!.Length, resolved.IconBytes!.Length); + } + + private static InstalledPackage NewPackage(string installLocation) => new() + { + Name = "X", + DisplayName = "X", + PackageFamilyName = "X_abc", + PackageFullName = "X_1.0.0.0_x64__abc", + Version = "1.0.0.0", + Publisher = "CN=X", + InstallLocation = installLocation, + Architecture = "X64" + }; +} diff --git a/MSIXplainer.Core.Tests/MSIXplainer.Core.Tests.csproj b/MSIXplainer.Core.Tests/MSIXplainer.Core.Tests.csproj index ee7c81e..f344d1b 100644 --- a/MSIXplainer.Core.Tests/MSIXplainer.Core.Tests.csproj +++ b/MSIXplainer.Core.Tests/MSIXplainer.Core.Tests.csproj @@ -1,7 +1,7 @@  - net10.0 + net10.0-windows10.0.26100.0 enable enable false diff --git a/MSIXplainer.Core/MSIXplainer.Core.csproj b/MSIXplainer.Core/MSIXplainer.Core.csproj index 83c3e54..b3d70ec 100644 --- a/MSIXplainer.Core/MSIXplainer.Core.csproj +++ b/MSIXplainer.Core/MSIXplainer.Core.csproj @@ -1,9 +1,12 @@ - net10.0 + net10.0-windows10.0.26100.0 MSIXplainer enable enable AnyCPU;ARM64;x64 + 10.0.17763.0 + + diff --git a/MSIXplainer.Core/Models/InstalledPackage.cs b/MSIXplainer.Core/Models/InstalledPackage.cs new file mode 100644 index 0000000..e1d6745 --- /dev/null +++ b/MSIXplainer.Core/Models/InstalledPackage.cs @@ -0,0 +1,43 @@ +namespace MSIXplainer.Models; + +/// +/// A single MSIX/AppX package installed on the current Windows machine. +/// Returned by . +/// +public sealed record InstalledPackage +{ + /// + /// Package identity name (e.g. Microsoft.WindowsCalculator). Stable id, not user-facing. + /// + public required string Name { get; init; } + + /// + /// Friendly display name resolved from the manifest (handles ms-resource: indirection + /// via the WinRT Package.DisplayName projection). Falls back to + /// if the package's resources can't be resolved. + /// + public required string DisplayName { get; init; } + + public required string PackageFamilyName { get; init; } + public required string PackageFullName { get; init; } + public required string Version { get; init; } + public required string Publisher { get; init; } + public required string InstallLocation { get; init; } + public required string Architecture { get; init; } + + /// + /// Square44x44 logo bytes (typically PNG) read from the package's install folder, + /// or null when the asset is missing or access-denied (system packages + /// under C:\Program Files\WindowsApps may refuse read access). + /// + public byte[]? IconBytes { get; init; } + + /// + /// Path to AppxManifest.xml inside , or + /// null if the install location is missing/inaccessible. + /// + public string? ManifestPath => + string.IsNullOrEmpty(InstallLocation) + ? null + : Path.Combine(InstallLocation, "AppxManifest.xml"); +} diff --git a/MSIXplainer.Core/Services/InstalledPackageService.cs b/MSIXplainer.Core/Services/InstalledPackageService.cs new file mode 100644 index 0000000..6c6a99e --- /dev/null +++ b/MSIXplainer.Core/Services/InstalledPackageService.cs @@ -0,0 +1,166 @@ +using System.Runtime.Versioning; +using MSIXplainer.Models; +using Windows.ApplicationModel; +using Windows.Management.Deployment; + +namespace MSIXplainer.Services; + +/// +/// Enumerates MSIX/AppX packages installed for the current user via the WinRT +/// API. Excludes framework, resource, optional, and bundle +/// packages — only main packages (what users mentally consider "apps"). +/// +/// +/// Windows-only. Callers must guard with +/// or accept a . +/// +/// +/// Two-pass loading: prefer + per-row +/// so the UI can render the list in <1s and stream +/// icons in afterward. remains for headless callers (CLI / tests) +/// that need everything eagerly. +/// +/// +public static class InstalledPackageService +{ + /// + /// Returns installed main packages for the current user, sorted by display name, + /// with icon bytes already resolved. Synchronous + eager — fine for CLI/tests, + /// avoid on UI threads (use instead). + /// On non-Windows platforms returns an empty list (does not throw). + /// + public static IReadOnlyList List() + { + if (!OperatingSystem.IsWindows()) + return Array.Empty(); + + return ListWindows(withIcons: true); + } + + /// + /// Fast enumeration with left null. + /// Returns in ~0.4s for a typical user (vs several seconds for ). + /// Pair with to populate icons lazily per row. + /// + public static IReadOnlyList ListWithoutIcons() + { + if (!OperatingSystem.IsWindows()) + return Array.Empty(); + + return ListWindows(withIcons: false); + } + + /// + /// Returns a copy of with + /// populated (or unchanged null if the asset is missing / access-denied). Pure function — + /// safe to call from any thread. Never throws on icon resolution failure. + /// + public static InstalledPackage ResolveIcon(InstalledPackage package) + { + if (package.IconBytes is { Length: > 0 }) return package; + if (string.IsNullOrEmpty(package.InstallLocation)) return package; + + try + { + var bytes = ManifestParserService.TryGetIconFromInstallFolder(package.InstallLocation); + return bytes is { Length: > 0 } + ? package with { IconBytes = bytes } + : package; + } + catch + { + return package; + } + } + + /// + /// Finds an installed package by Package Family Name (case-insensitive). + /// Returns null if not found or platform is not Windows. + /// Includes icon bytes (uses ). + /// + public static InstalledPackage? FindByFamilyName(string packageFamilyName) + { + if (string.IsNullOrWhiteSpace(packageFamilyName)) + return null; + + return List().FirstOrDefault(p => + string.Equals(p.PackageFamilyName, packageFamilyName, StringComparison.OrdinalIgnoreCase)); + } + + [SupportedOSPlatform("windows10.0.10240.0")] + private static IReadOnlyList ListWindows(bool withIcons) + { + var pm = new PackageManager(); + + // Empty user SID == current user. PackageTypes.Main excludes Framework, + // Resource, Optional, and Bundle packages in one call (no manual filtering). + var packages = pm.FindPackagesForUserWithPackageTypes(string.Empty, PackageTypes.Main); + + var result = new List(); + foreach (var pkg in packages) + { + var mapped = TryMap(pkg, withIcons); + if (mapped is not null) result.Add(mapped); + } + + return result + .OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Maps a WinRT to our DTO. Wraps every property access + /// because the API can throw or access-denied + /// for system / partially-staged packages. + /// + [SupportedOSPlatform("windows10.0.10240.0")] + internal static InstalledPackage? TryMap(Package pkg, bool withIcon = true) + { + try + { + var id = pkg.Id; + if (id is null || string.IsNullOrEmpty(id.Name)) return null; + + // Package.DisplayName auto-resolves ms-resource:// indirection via the + // package's MRT resource map. Fall back to identity Name when resolution + // fails — observed failure modes include returning an empty string, a + // leading "ms-resource:..." literal, OR a longer string containing an + // unresolved "ms-resource:" fragment embedded mid-text. + string displayName; + try { displayName = pkg.DisplayName; } + catch { displayName = string.Empty; } + if (string.IsNullOrWhiteSpace(displayName) + || displayName.Contains("ms-resource:", StringComparison.Ordinal)) + { + displayName = id.Name; + } + + string installLocation = string.Empty; + try { installLocation = pkg.InstalledLocation?.Path ?? string.Empty; } + catch { /* access denied / missing */ } + + var v = id.Version; + var version = $"{v.Major}.{v.Minor}.{v.Build}.{v.Revision}"; + + return new InstalledPackage + { + Name = id.Name, + DisplayName = displayName, + PackageFamilyName = id.FamilyName ?? string.Empty, + PackageFullName = id.FullName ?? string.Empty, + Version = version, + Publisher = id.Publisher ?? string.Empty, + InstallLocation = installLocation, + Architecture = id.Architecture.ToString(), + IconBytes = withIcon + ? ManifestParserService.TryGetIconFromInstallFolder(installLocation) + : null + }; + } + catch + { + // Skip any package we can't read — never let one bad apple drop the list. + return null; + } + } +} diff --git a/MSIXplainer.Core/Services/ManifestParserService.cs b/MSIXplainer.Core/Services/ManifestParserService.cs index 14d606b..8d90f56 100644 --- a/MSIXplainer.Core/Services/ManifestParserService.cs +++ b/MSIXplainer.Core/Services/ManifestParserService.cs @@ -103,6 +103,29 @@ public static (XDocument Manifest, string RawXml, PackageInfo Info) ParseRawXml( return (doc, xml, info); } + /// + /// Parses a loose AppxManifest.xml file from disk — used for analyzing + /// already-installed packages where Windows has expanded the .msix to a + /// folder under %ProgramFiles%\WindowsApps. Resolves the app icon + /// from the same directory when possible. + /// + public static (XDocument Manifest, string RawXml, PackageInfo Info) ExtractFromManifestFile(string manifestPath) + { + if (!File.Exists(manifestPath)) + throw new FileNotFoundException("AppxManifest.xml not found.", manifestPath); + + var fileInfo = new FileInfo(manifestPath); + if (fileInfo.Length > 10 * 1024 * 1024) + throw new InvalidOperationException( + "AppxManifest.xml exceeds 10 MB — this is abnormal for a package manifest."); + + var rawXml = File.ReadAllText(manifestPath); + var doc = ParseXmlSafely(rawXml); + var info = ExtractPackageInfo(doc); + info.AppIconBytes = TryExtractAppIconFromFolder(Path.GetDirectoryName(manifestPath)!, doc); + return (doc, rawXml, info); + } + private static (XDocument Manifest, string RawXml, PackageInfo Info) ExtractFromArchive(ZipArchive archive) { var entry = archive.GetEntry("AppxManifest.xml") @@ -226,4 +249,77 @@ private static PackageInfo ExtractPackageInfo(XDocument doc) stream.CopyTo(ms); return ms.ToArray(); } + + /// + /// Attempts to read the Square44x44 app icon bytes from an installed package's + /// extracted folder. Returns null if the manifest can't be read, no icon + /// is referenced, or file access is denied (common under WindowsApps). + /// + public static byte[]? TryGetIconFromInstallFolder(string installFolder) + { + if (string.IsNullOrEmpty(installFolder)) return null; + + var manifestPath = Path.Combine(installFolder, "AppxManifest.xml"); + if (!File.Exists(manifestPath)) return null; + + try + { + var doc = ParseXmlSafely(File.ReadAllText(manifestPath)); + return TryExtractAppIconFromFolder(installFolder, doc); + } + catch + { + return null; + } + } + + private static byte[]? TryExtractAppIconFromFolder(string folder, XDocument doc) + { + var app = doc.Root!.Element(Ns + "Applications")?.Element(Ns + "Application"); + if (app is null) return null; + + var ve = app.Descendants().FirstOrDefault(e => e.Name.LocalName == "VisualElements"); + var iconPath = ve?.Attribute("Square44x44Logo")?.Value; + if (string.IsNullOrEmpty(iconPath)) return null; + + return TryReadIconFile(folder, iconPath); + } + + private static byte[]? TryReadIconFile(string folder, string relativePath) + { + // Manifest paths use Windows-style separators; normalise for cross-platform. + var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + + var candidates = new List { Path.Combine(folder, normalized) }; + + // Try scale variants — Windows expands install location to include scaled assets. + var dir = Path.GetDirectoryName(normalized) ?? ""; + var baseName = Path.GetFileNameWithoutExtension(normalized); + var ext = Path.GetExtension(normalized); + foreach (var scale in new[] { "scale-200", "scale-150", "scale-125", "scale-100", "scale-400" }) + { + var scaled = string.IsNullOrEmpty(dir) + ? $"{baseName}.{scale}{ext}" + : Path.Combine(dir, $"{baseName}.{scale}{ext}"); + candidates.Add(Path.Combine(folder, scaled)); + } + + foreach (var candidate in candidates) + { + try + { + if (!File.Exists(candidate)) continue; + var info = new FileInfo(candidate); + if (info.Length > 1024 * 1024) continue; + return File.ReadAllBytes(candidate); + } + catch + { + // WindowsApps may deny read access; skip and try next candidate. + } + } + + return null; + } } diff --git a/MSIXplainer/MainPage.xaml b/MSIXplainer/MainPage.xaml index 3c86104..675c7bf 100644 --- a/MSIXplainer/MainPage.xaml +++ b/MSIXplainer/MainPage.xaml @@ -9,84 +9,171 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + MaxWidth="460" /> - - - - - - - - - - + + + + + - - - - - - + + - - + + + + + + - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - + + - - - + + + + diff --git a/MSIXplainer/MainPage.xaml.cs b/MSIXplainer/MainPage.xaml.cs index 09fa94a..080e7a8 100644 --- a/MSIXplainer/MainPage.xaml.cs +++ b/MSIXplainer/MainPage.xaml.cs @@ -1,3 +1,5 @@ +using System.Collections.Specialized; +using System.ComponentModel; using Microsoft.UI; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; @@ -14,16 +16,69 @@ public sealed partial class MainPage : Page { public MainPageViewModel ViewModel { get; } = new(); + // Static top-of-nav items (built once in constructor); dynamic per-package section + // items are inserted between the separator and the pane end and cleared on each rebuild. + private NavigationViewItem? _appsItem; + private NavigationViewItem? _openPackageItem; + private NavigationViewItem? _compareItem; + private NavigationViewItemSeparator? _separator; + public MainPage() { InitializeComponent(); ViewModel.SectionsRebuilt += RebuildNavItems; + ViewModel.InstalledPackages.CollectionChanged += InstalledPackages_CollectionChanged; + ViewModel.PropertyChanged += ViewModel_PropertyChanged; + BuildStaticNavItems(); + } + + private void BuildStaticNavItems() + { + _appsItem = new NavigationViewItem + { + Content = "Apps", + Tag = "apps", + SelectsOnInvoked = false, + Icon = new FontIcon { Glyph = "\uE71D" } // BrowsePhotos / grid-style + }; + AutomationProperties.SetAutomationId(_appsItem, "NavApps"); + + _openPackageItem = new NavigationViewItem + { + Content = "Open Package…", + Tag = "open-package", + SelectsOnInvoked = false, + Icon = new FontIcon { Glyph = "\uE8E5" } // OpenFile + }; + AutomationProperties.SetAutomationId(_openPackageItem, "NavOpenPackage"); + + _compareItem = new NavigationViewItem + { + Content = "Compare Versions…", + Tag = "compare", + SelectsOnInvoked = false, + Icon = new FontIcon { Glyph = "\uE8AB" } // Switch + }; + AutomationProperties.SetAutomationId(_compareItem, "NavCompareVersions"); + + _separator = new NavigationViewItemSeparator(); + + NavView.MenuItems.Add(_appsItem); + NavView.MenuItems.Add(_openPackageItem); + NavView.MenuItems.Add(_compareItem); + NavView.MenuItems.Add(_separator); } private async void RebuildNavItems() { - NavView.MenuItems.Clear(); - NavigationViewItem? firstItem = null; + // Remove only the dynamic section items (everything AFTER our separator). + int separatorIndex = NavView.MenuItems.IndexOf(_separator); + if (separatorIndex < 0) return; + + for (int i = NavView.MenuItems.Count - 1; i > separatorIndex; i--) + NavView.MenuItems.RemoveAt(i); + + NavigationViewItem? firstSection = null; foreach (var section in ViewModel.Sections) { @@ -40,7 +95,6 @@ private async void RebuildNavItems() item.InfoBadge = new InfoBadge { Value = section.FindingCount }; } - // Load app icon from package if available if (section.IconBytes is { Length: > 0 }) { try @@ -64,21 +118,116 @@ private async void RebuildNavItems() } NavView.MenuItems.Add(item); - firstItem ??= item; + firstSection ??= item; + } + + if (firstSection is not null) + NavView.SelectedItem = firstSection; + } + + private void InstalledPackages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // No-op: the ListView in the Apps pane binds directly to ViewModel.InstalledPackages, + // so collection changes propagate without a manual rebuild. Kept hooked for any + // future side-effect (e.g. analytics) without re-wiring the constructor. + } + + private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Reserved for future cross-cutting reactions to VM property changes. + } + + private async void NavView_Expanding(NavigationView sender, NavigationViewItemExpandingEventArgs args) + { + // Apps no longer expands inline (Outlook-style pane handles that). Suppress the + // expansion and instead open the secondary pane. + if (args.ExpandingItemContainer is NavigationViewItem nvi && nvi.Tag is "apps") + { + nvi.IsExpanded = false; + await OpenAppsPaneAsync(); } + } + + private async void NavView_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) + { + if (args.InvokedItemContainer is not NavigationViewItem invoked) return; + + switch (invoked.Tag) + { + case "apps": + await OpenAppsPaneAsync(); + break; + + case "open-package": + CloseAppsPane(); + ExitCompareMode(); + await ViewModel.OpenPackageCommand.ExecuteAsync(null); + break; + + case "compare": + CloseAppsPane(); + EnterCompareMode(); + break; - if (firstItem is not null) - NavView.SelectedItem = firstItem; + case "export-markdown": + await ViewModel.ExportMarkdownCommand.ExecuteAsync(null); + break; + + case "export-json": + await ViewModel.ExportJsonCommand.ExecuteAsync(null); + break; + } } private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) { if (args.SelectedItem is NavigationViewItem item && item.Tag is string tag) { + // Section selection always returns to the home content (welcome/analysis). + CloseAppsPane(); + ExitCompareMode(); ViewModel.SelectSection(tag); } } + private async Task OpenAppsPaneAsync() + { + ViewModel.IsAppsPaneOpen = true; + if (!ViewModel.HasLoadedInstalledApps && !ViewModel.IsLoadingInstalledApps) + await ViewModel.LoadInstalledAppsCommand.ExecuteAsync(null); + } + + private void CloseAppsPane() + { + ViewModel.IsAppsPaneOpen = false; + ViewModel.CancelIconResolution(); + } + + private void OnCloseAppsPaneClick(object sender, RoutedEventArgs e) => CloseAppsPane(); + + private void OnInstalledAppClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is InstalledPackage pkg) + { + ExitCompareMode(); + ViewModel.OpenInstalledPackage(pkg); + } + } + + private void EnterCompareMode() + { + ViewModel.IsCompareMode = true; + if (CompareFrame.Content is null) + CompareFrame.Navigate(typeof(Pages.ComparePage)); + } + + internal void ExitCompareMode() + { + if (!ViewModel.IsCompareMode) return; + ViewModel.IsCompareMode = false; + CompareFrame.Content = null; + } + private void ViewFinding_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.Tag is ManifestFinding finding) @@ -129,6 +278,35 @@ public static Visibility NullToCollapsed(object? value) => public static Visibility StringToVisibility(string? value) => string.IsNullOrWhiteSpace(value) ? Visibility.Collapsed : Visibility.Visible; + public static Visibility NullBytesToVisibility(byte[]? value) => + value is { Length: > 0 } ? Visibility.Collapsed : Visibility.Visible; + + public static Visibility NonNullBytesToVisibility(byte[]? value) => + value is { Length: > 0 } ? Visibility.Visible : Visibility.Collapsed; + + public static BitmapImage? BytesToBitmap(byte[]? bytes) + { + if (bytes is null || bytes.Length == 0) return null; + try + { + var bitmap = new BitmapImage(); + using var stream = new InMemoryRandomAccessStream(); + using (var writer = new DataWriter(stream.GetOutputStreamAt(0))) + { + writer.WriteBytes(bytes); + writer.StoreAsync().GetAwaiter().GetResult(); + writer.DetachStream(); + } + stream.Seek(0); + bitmap.SetSourceAsync(stream).GetAwaiter().GetResult(); + return bitmap; + } + catch + { + return null; + } + } + public static SolidColorBrush SeverityToBrush(FindingSeverity severity) => severity switch { FindingSeverity.Critical => new SolidColorBrush(ColorHelper.FromArgb(255, 196, 43, 28)), diff --git a/MSIXplainer/Package.appxmanifest b/MSIXplainer/Package.appxmanifest index 8951b70..89a795e 100644 --- a/MSIXplainer/Package.appxmanifest +++ b/MSIXplainer/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="1.0.17.0" /> diff --git a/MSIXplainer/Pages/ComparePage.xaml.cs b/MSIXplainer/Pages/ComparePage.xaml.cs index 87052ce..df21c2c 100644 --- a/MSIXplainer/Pages/ComparePage.xaml.cs +++ b/MSIXplainer/Pages/ComparePage.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; using MSIXplainer.ViewModels; namespace MSIXplainer.Pages; @@ -15,10 +16,19 @@ public ComparePage() private void OnBackClick(object sender, RoutedEventArgs e) { - if (Frame.CanGoBack) + // ComparePage is hosted in MainPage's inner CompareFrame; walking up the visual tree + // to find MainPage lets the back button exit Compare mode cleanly without a Frame + // navigation stack. Falls back to the outer Frame.GoBack for any other host. + var parent = VisualTreeHelper.GetParent(this); + while (parent is not null && parent is not MainPage) + parent = VisualTreeHelper.GetParent(parent); + + if (parent is MainPage main) + main.ExitCompareMode(); + else if (Frame is not null && Frame.CanGoBack) Frame.GoBack(); else - Frame.Navigate(typeof(MainPage)); + Frame?.Navigate(typeof(MainPage)); } // ── x:Bind helper functions ── diff --git a/MSIXplainer/ViewModels/MainPageViewModel.cs b/MSIXplainer/ViewModels/MainPageViewModel.cs index 32f0eea..f044449 100644 --- a/MSIXplainer/ViewModels/MainPageViewModel.cs +++ b/MSIXplainer/ViewModels/MainPageViewModel.cs @@ -58,6 +58,32 @@ public partial class MainPageViewModel : ObservableObject public ObservableCollection ReviewFindings { get; } = []; public ObservableCollection InfoFindings { get; } = []; + /// + /// Installed MSIX/AppX packages on this machine (issue #13). Populated lazily + /// the first time the user expands the "Apps" nav item via . + /// + public ObservableCollection InstalledPackages { get; } = []; + + [ObservableProperty] + public partial bool IsLoadingInstalledApps { get; set; } + + [ObservableProperty] + public partial bool HasLoadedInstalledApps { get; set; } + + /// + /// When true, the content area shows the Compare-Versions view (inner Frame) + /// instead of the welcome/analysis content. Toggled by the nav rail. + /// + [ObservableProperty] + public partial bool IsCompareMode { get; set; } + + /// + /// When true, the Apps secondary pane (Outlook-style second column) is visible + /// between the nav rail and the main content area. Toggled by the Apps nav item. + /// + [ObservableProperty] + public partial bool IsAppsPaneOpen { get; set; } + /// Raised when sections are rebuilt so code-behind can refresh NavigationView items. public event Action? SectionsRebuilt; @@ -182,6 +208,127 @@ private void DismissError() [RelayCommand] private void DismissSelectedFinding() => SelectedFinding = null; + /// + /// Loads the list of installed MSIX/AppX packages in two passes for snappy UX: + /// (1) fast WinRT enumeration without icons (~0.4s) → list visible immediately; + /// (2) background icon resolution that streams each row's icon in as it resolves. + /// Cancellable so closing the pane mid-resolution stops the work. + /// + [RelayCommand] + private async Task LoadInstalledAppsAsync() + { + if (IsLoadingInstalledApps) return; + if (HasLoadedInstalledApps && InstalledPackages.Count > 0) return; + + // Replace any in-flight icon-resolution loop from a previous open. + _iconResolveCts?.Cancel(); + _iconResolveCts?.Dispose(); + _iconResolveCts = new CancellationTokenSource(); + var token = _iconResolveCts.Token; + + IsLoadingInstalledApps = true; + try + { + // Pass 1: fast enumeration without icons. Returns in <1s typically. + var packages = await Task.Run(InstalledPackageService.ListWithoutIcons, token); + + InstalledPackages.Clear(); + foreach (var p in packages) + InstalledPackages.Add(p); + + HasLoadedInstalledApps = true; + IsLoadingInstalledApps = false; + + // Pass 2: stream icons in. Fire-and-forget so the UI is responsive + // immediately. Exceptions are swallowed inside ResolveIcon. + _ = ResolveIconsAsync(token); + } + catch (OperationCanceledException) + { + IsLoadingInstalledApps = false; + } + catch (Exception ex) + { + IsLoadingInstalledApps = false; + ShowError($"Failed to list installed packages: {ex.Message}"); + } + } + + private async Task ResolveIconsAsync(CancellationToken token) + { + // Snapshot indices to avoid reacting to user-driven collection edits. + // Walk the collection; for each row, resolve the icon on a background + // thread then marshal the replacement back to the UI thread. + for (int i = 0; i < InstalledPackages.Count; i++) + { + if (token.IsCancellationRequested) return; + + var current = InstalledPackages[i]; + if (current.IconBytes is { Length: > 0 }) continue; + + InstalledPackage resolved; + try + { + resolved = await Task.Run(() => InstalledPackageService.ResolveIcon(current), token); + } + catch (OperationCanceledException) { return; } + catch { continue; } + + if (token.IsCancellationRequested) return; + if (resolved.IconBytes is not { Length: > 0 }) continue; + + // Re-find by family name in case the collection shifted (defensive; + // we don't mutate it during pass 2 today but this keeps the row update safe). + var idx = IndexOfByFamilyName(resolved.PackageFamilyName); + if (idx >= 0) + InstalledPackages[idx] = resolved; + } + } + + private int IndexOfByFamilyName(string pfn) + { + for (int i = 0; i < InstalledPackages.Count; i++) + { + if (string.Equals(InstalledPackages[i].PackageFamilyName, pfn, StringComparison.OrdinalIgnoreCase)) + return i; + } + return -1; + } + + /// Cancels any in-flight icon-resolution loop. Call when the Apps pane closes. + public void CancelIconResolution() + { + _iconResolveCts?.Cancel(); + } + + private CancellationTokenSource? _iconResolveCts; + + /// + /// Loads an installed package's manifest and runs it through the existing + /// analysis pipeline. Wired to the Apps submenu items in MainPage. + /// + public void OpenInstalledPackage(InstalledPackage package) + { + if (package.ManifestPath is null || !File.Exists(package.ManifestPath)) + { + ShowError( + $"AppxManifest.xml not accessible at '{package.InstallLocation}'. " + + "WindowsApps folders may require elevated access for some packages."); + return; + } + + try + { + PackageFilePath = $"Installed: {package.DisplayName} ({package.PackageFamilyName})"; + var (manifest, rawXml, info) = ManifestParserService.ExtractFromManifestFile(package.ManifestPath); + AnalyzeManifest(rawXml, info, manifest); + } + catch (Exception ex) + { + ShowError($"Failed to analyze installed package: {ex.Message}"); + } + } + public void SelectSection(string tag) { SelectedSectionTag = tag; From af519dff218ab744d64d3688fd619c36d4a197f1 Mon Sep 17 00:00:00 2001 From: Andrew Clinick <80841394+aclinick@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:21:32 -0700 Subject: [PATCH 02/12] Apps pane UX: top alignment, start-menu filter, single Applications section - BitmapImage decode moved off the UI thread's blocking path: VM now properly awaits SetSourceAsync, stores the decoded image on InstalledPackage.IconImage, and yields between rows. UI stays responsive while icons stream in. - Top alignment: removed NavView.Header (the empty package-path TextBlock was reserving ~80px above the pane); set AlwaysShowHeader=False. Overview card already shows the package name/version. - Start-menu filter: InstalledPackageService.TryMap now uses Package.GetAppListEntries() to drop background services, media extensions, and other non-app packages (matches what users see in Start). - Single 'Applications' section replaces the per-Application nav-noise (was showing ms-resource:appDisplayName x3 + each EntryPoint name for multi-app packages like WinGet). Content side aggregates all Applications. - Bumped SupportedOSPlatform to 10.0.19041.0 to match GetAppListEntries. 147/147 Core tests passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ManifestExplainerServiceTests.cs | 2 +- MSIXplainer.Core/Models/InstalledPackage.cs | 9 ++++ .../Services/InstalledPackageService.cs | 38 ++++++++++----- .../Services/ManifestExplainerService.cs | 27 +++++++---- MSIXplainer/MainPage.xaml | 18 ++----- MSIXplainer/MainPage.xaml.cs | 9 ++++ MSIXplainer/ViewModels/MainPageViewModel.cs | 47 ++++++++++++++++--- 7 files changed, 107 insertions(+), 43 deletions(-) diff --git a/MSIXplainer.Core.Tests/ManifestExplainerServiceTests.cs b/MSIXplainer.Core.Tests/ManifestExplainerServiceTests.cs index 878d6eb..cc60434 100644 --- a/MSIXplainer.Core.Tests/ManifestExplainerServiceTests.cs +++ b/MSIXplainer.Core.Tests/ManifestExplainerServiceTests.cs @@ -49,7 +49,7 @@ public void BuildSections_IncludesApplicationSection() var (manifest, findings) = LoadSample(); var sections = ManifestExplainerService.BuildSections(manifest, findings); - Assert.Contains(sections, s => s.Tag.StartsWith("app:")); + Assert.Contains(sections, s => s.Tag == "applications"); } [Fact] diff --git a/MSIXplainer.Core/Models/InstalledPackage.cs b/MSIXplainer.Core/Models/InstalledPackage.cs index e1d6745..2cb56a1 100644 --- a/MSIXplainer.Core/Models/InstalledPackage.cs +++ b/MSIXplainer.Core/Models/InstalledPackage.cs @@ -32,6 +32,15 @@ public sealed record InstalledPackage /// public byte[]? IconBytes { get; init; } + /// + /// Pre-decoded image source for UI binding (typically a WinUI BitmapImage). + /// Typed as so Core stays free of WinUI references. + /// Created by the WinUI layer on the UI thread after + /// is resolved — binding directly to bytes via a converter would block the UI + /// thread on each row's image decode. + /// + public object? IconImage { get; init; } + /// /// Path to AppxManifest.xml inside , or /// null if the install location is missing/inaccessible. diff --git a/MSIXplainer.Core/Services/InstalledPackageService.cs b/MSIXplainer.Core/Services/InstalledPackageService.cs index 6c6a99e..52b260a 100644 --- a/MSIXplainer.Core/Services/InstalledPackageService.cs +++ b/MSIXplainer.Core/Services/InstalledPackageService.cs @@ -1,6 +1,7 @@ using System.Runtime.Versioning; using MSIXplainer.Models; using Windows.ApplicationModel; +using Windows.ApplicationModel.Core; using Windows.Management.Deployment; namespace MSIXplainer.Services; @@ -87,7 +88,7 @@ public static InstalledPackage ResolveIcon(InstalledPackage package) string.Equals(p.PackageFamilyName, packageFamilyName, StringComparison.OrdinalIgnoreCase)); } - [SupportedOSPlatform("windows10.0.10240.0")] + [SupportedOSPlatform("windows10.0.19041.0")] private static IReadOnlyList ListWindows(bool withIcons) { var pm = new PackageManager(); @@ -109,11 +110,11 @@ private static IReadOnlyList ListWindows(bool withIcons) } /// - /// Maps a WinRT to our DTO. Wraps every property access - /// because the API can throw or access-denied - /// for system / partially-staged packages. + /// Maps a WinRT to our DTO. Returns null for packages that + /// don't have any Start-menu entry (background services, framework extensions, + /// media codecs, system components) — matching what users mentally consider an "app". /// - [SupportedOSPlatform("windows10.0.10240.0")] + [SupportedOSPlatform("windows10.0.19041.0")] internal static InstalledPackage? TryMap(Package pkg, bool withIcon = true) { try @@ -121,14 +122,25 @@ private static IReadOnlyList ListWindows(bool withIcons) var id = pkg.Id; if (id is null || string.IsNullOrEmpty(id.Name)) return null; - // Package.DisplayName auto-resolves ms-resource:// indirection via the - // package's MRT resource map. Fall back to identity Name when resolution - // fails — observed failure modes include returning an empty string, a - // leading "ms-resource:..." literal, OR a longer string containing an - // unresolved "ms-resource:" fragment embedded mid-text. - string displayName; - try { displayName = pkg.DisplayName; } - catch { displayName = string.Empty; } + // Filter to packages that appear in Start (== "apps" as the user knows them). + // Skips media extensions, background services, dev/test stubs, etc. + IReadOnlyList? entries = null; + try { entries = pkg.GetAppListEntries(); } + catch { /* some packages refuse — treat as "no entry" → skip */ } + if (entries is null || entries.Count == 0) return null; + + // Prefer the Start-menu entry's display name (already resolved via MRT, + // includes any Application-level overrides). Falls back to Package.DisplayName + // and finally to identity Name. + string displayName = string.Empty; + try { displayName = entries[0].DisplayInfo?.DisplayName ?? string.Empty; } + catch { /* fall through */ } + if (string.IsNullOrWhiteSpace(displayName) + || displayName.Contains("ms-resource:", StringComparison.Ordinal)) + { + try { displayName = pkg.DisplayName; } + catch { displayName = string.Empty; } + } if (string.IsNullOrWhiteSpace(displayName) || displayName.Contains("ms-resource:", StringComparison.Ordinal)) { diff --git a/MSIXplainer.Core/Services/ManifestExplainerService.cs b/MSIXplainer.Core/Services/ManifestExplainerService.cs index 7172d0f..6a145c6 100644 --- a/MSIXplainer.Core/Services/ManifestExplainerService.cs +++ b/MSIXplainer.Core/Services/ManifestExplainerService.cs @@ -48,14 +48,13 @@ public static List BuildSections(XDocument manifest, List 0) { - var appId = app.Attribute("Id")?.Value ?? "App"; - var displayName = app.Descendants() - .FirstOrDefault(e => e.Name.LocalName == "VisualElements") - ?.Attribute("DisplayName")?.Value ?? appId; - + // Collapse all elements into a single "Applications" nav entry. + // Previously we created one nav item per Application, which produced 3-5+ entries + // with ms-resource:... labels — pure menu noise. The content side renders one + // PropertyGroup per Application. var appCategories = new[] { FindingCategory.Trust, FindingCategory.Startup, FindingCategory.Protocols, FindingCategory.FileAssociations, FindingCategory.BackgroundTasks, @@ -65,8 +64,8 @@ public static List BuildSections(XDocument manifest, List ExplainSection(string tag, XElement ro if (tag == "dependencies") return ExplainDependencies(root); if (tag == "resources") return ExplainResources(root); if (tag == "capabilities") return ExplainCapabilities(root, findings); + if (tag == "applications") + { + var groups = new List(); + foreach (var app in root.Descendants(Ns + "Application")) + { + groups.AddRange(ExplainApplication(app, findings)); + } + return groups; + } + // Legacy per-app tag still supported in case anything stored it. if (tag.StartsWith("app:")) { var appId = tag[4..]; diff --git a/MSIXplainer/MainPage.xaml b/MSIXplainer/MainPage.xaml index 675c7bf..66f733e 100644 --- a/MSIXplainer/MainPage.xaml +++ b/MSIXplainer/MainPage.xaml @@ -15,6 +15,7 @@ OpenPaneLength="280" IsBackButtonVisible="Collapsed" IsSettingsVisible="False" + AlwaysShowHeader="False" SelectionChanged="NavView_SelectionChanged" ItemInvoked="NavView_ItemInvoked" Expanding="NavView_Expanding"> @@ -58,17 +59,8 @@ - - - - - - + @@ -141,11 +133,11 @@ - + + Visibility="{x:Bind local:MainPage.NonNullObjectToVisibility(IconImage)}" /> diff --git a/MSIXplainer/MainPage.xaml.cs b/MSIXplainer/MainPage.xaml.cs index 080e7a8..9273646 100644 --- a/MSIXplainer/MainPage.xaml.cs +++ b/MSIXplainer/MainPage.xaml.cs @@ -284,6 +284,15 @@ public static Visibility NullBytesToVisibility(byte[]? value) => public static Visibility NonNullBytesToVisibility(byte[]? value) => value is { Length: > 0 } ? Visibility.Visible : Visibility.Collapsed; + public static Visibility NullObjectToVisibility(object? value) => + value is null ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility NonNullObjectToVisibility(object? value) => + value is null ? Visibility.Collapsed : Visibility.Visible; + + public static Microsoft.UI.Xaml.Media.ImageSource? ObjectToImageSource(object? value) => + value as Microsoft.UI.Xaml.Media.ImageSource; + public static BitmapImage? BytesToBitmap(byte[]? bytes) { if (bytes is null || bytes.Length == 0) return null; diff --git a/MSIXplainer/ViewModels/MainPageViewModel.cs b/MSIXplainer/ViewModels/MainPageViewModel.cs index f044449..1a27657 100644 --- a/MSIXplainer/ViewModels/MainPageViewModel.cs +++ b/MSIXplainer/ViewModels/MainPageViewModel.cs @@ -256,15 +256,17 @@ private async Task LoadInstalledAppsAsync() private async Task ResolveIconsAsync(CancellationToken token) { - // Snapshot indices to avoid reacting to user-driven collection edits. - // Walk the collection; for each row, resolve the icon on a background - // thread then marshal the replacement back to the UI thread. + // Walk the collection. For each row: + // 1. Resolve raw icon bytes on background thread (file I/O + manifest parse). + // 2. On UI thread, decode bytes → BitmapImage with PROPER async/await + // (no .GetAwaiter().GetResult() — that froze the UI in the prior version). + // 3. Replace the row with both IconBytes and a decoded IconImage set. for (int i = 0; i < InstalledPackages.Count; i++) { if (token.IsCancellationRequested) return; var current = InstalledPackages[i]; - if (current.IconBytes is { Length: > 0 }) continue; + if (current.IconImage is not null) continue; InstalledPackage resolved; try @@ -277,11 +279,42 @@ private async Task ResolveIconsAsync(CancellationToken token) if (token.IsCancellationRequested) return; if (resolved.IconBytes is not { Length: > 0 }) continue; - // Re-find by family name in case the collection shifted (defensive; - // we don't mutate it during pass 2 today but this keeps the row update safe). + // Decode on UI thread (we're already back on it because we awaited Task.Run). + // Yield briefly between rows so other UI work — input, scrolling, layout — + // gets a turn. Without this, decoding ~200 icons back-to-back can still + // feel sluggish even though each decode is microseconds. + var bitmap = await DecodeBitmapAsync(resolved.IconBytes); + if (bitmap is null) continue; + if (token.IsCancellationRequested) return; + var idx = IndexOfByFamilyName(resolved.PackageFamilyName); if (idx >= 0) - InstalledPackages[idx] = resolved; + InstalledPackages[idx] = resolved with { IconImage = bitmap }; + + // Cooperative yield so the UI thread can service input between rows. + await Task.Yield(); + } + } + + private static async Task DecodeBitmapAsync(byte[] bytes) + { + try + { + var bitmap = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(); + using var stream = new Windows.Storage.Streams.InMemoryRandomAccessStream(); + using (var writer = new Windows.Storage.Streams.DataWriter(stream.GetOutputStreamAt(0))) + { + writer.WriteBytes(bytes); + await writer.StoreAsync(); + writer.DetachStream(); + } + stream.Seek(0); + await bitmap.SetSourceAsync(stream); + return bitmap; + } + catch + { + return null; } } From e3334b4c8167593d57d3a073fe351669816aa4ae Mon Sep 17 00:00:00 2001 From: Andrew Clinick <80841394+aclinick@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:28:09 -0700 Subject: [PATCH 03/12] Silence CA1416 by using IsWindowsVersionAtLeast guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MSIXplainer.Core/Services/InstalledPackageService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MSIXplainer.Core/Services/InstalledPackageService.cs b/MSIXplainer.Core/Services/InstalledPackageService.cs index 52b260a..8653150 100644 --- a/MSIXplainer.Core/Services/InstalledPackageService.cs +++ b/MSIXplainer.Core/Services/InstalledPackageService.cs @@ -32,7 +32,7 @@ public static class InstalledPackageService /// public static IReadOnlyList List() { - if (!OperatingSystem.IsWindows()) + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) return Array.Empty(); return ListWindows(withIcons: true); @@ -45,7 +45,7 @@ public static IReadOnlyList List() /// public static IReadOnlyList ListWithoutIcons() { - if (!OperatingSystem.IsWindows()) + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) return Array.Empty(); return ListWindows(withIcons: false); From 8feae2cf9e9393ee9a8af05b80d3f00fb822972e Mon Sep 17 00:00:00 2001 From: Andrew Clinick <80841394+aclinick@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:36:40 -0700 Subject: [PATCH 04/12] Move per-context actions into secondary panes MainPage: - Sections nav (Overview/Identity/Properties/etc.) moves out of NavView.MenuItems into a dedicated Sections secondary pane in column 0, mirroring the Apps pane. - Export Markdown / Export JSON / Raw XML move out of NavView.FooterMenuItems into action buttons at the bottom of the Sections pane. - Auto-collapse the primary NavView pane when a package loads so attention shifts to the loaded package. - Overview icon changes from Home glyph to Info glyph; Properties from wrench to gear. ComparePage: - Same pattern: a left Actions sidebar (visible when HasResult) hosts the Export Markdown / Export JSON buttons; inline export buttons removed from the body. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/ManifestExplainerService.cs | 10 +- MSIXplainer/MainPage.xaml | 131 ++++++++++++------ MSIXplainer/MainPage.xaml.cs | 114 ++++----------- MSIXplainer/Pages/ComparePage.xaml | 77 ++++++---- MSIXplainer/ViewModels/MainPageViewModel.cs | 41 +++++- 5 files changed, 207 insertions(+), 166 deletions(-) diff --git a/MSIXplainer.Core/Services/ManifestExplainerService.cs b/MSIXplainer.Core/Services/ManifestExplainerService.cs index 6a145c6..1c92369 100644 --- a/MSIXplainer.Core/Services/ManifestExplainerService.cs +++ b/MSIXplainer.Core/Services/ManifestExplainerService.cs @@ -30,23 +30,23 @@ public static List BuildSections(XDocument manifest, List 0 ? findings.Max(f => f.Severity) : FindingSeverity.Info } }; if (root.Element(Ns + "Identity") is not null) - sections.Add(MakeSection("identity", "Identity", "\uE77B", findings, FindingCategory.Identity)); + sections.Add(MakeSection("identity", "Identity", "\uE77B", findings, FindingCategory.Identity)); // Contact if (root.Elements(Ns + "Properties").Any()) - sections.Add(MakeSection("properties", "Properties", "\uE8A1", findings, FindingCategory.Virtualization)); + sections.Add(MakeSection("properties", "Properties", "\uE713", findings, FindingCategory.Virtualization)); // Settings (gear) if (root.Element(Ns + "Dependencies") is not null) - sections.Add(new ManifestSection { Tag = "dependencies", Label = "Dependencies", IconGlyph = "\uE74C" }); + sections.Add(new ManifestSection { Tag = "dependencies", Label = "Dependencies", IconGlyph = "\uE71B" }); // Link (dependencies reference other packages) if (root.Element(Ns + "Resources") is not null) - sections.Add(new ManifestSection { Tag = "resources", Label = "Resources", IconGlyph = "\uE774" }); + sections.Add(new ManifestSection { Tag = "resources", Label = "Resources", IconGlyph = "\uE774" }); // Globe (localized resources) var apps = root.Element(Ns + "Applications")?.Elements(Ns + "Application").ToList() ?? []; if (apps.Count > 0) diff --git a/MSIXplainer/MainPage.xaml b/MSIXplainer/MainPage.xaml index 66f733e..138aef3 100644 --- a/MSIXplainer/MainPage.xaml +++ b/MSIXplainer/MainPage.xaml @@ -20,45 +20,6 @@ ItemInvoked="NavView_ItemInvoked" Expanding="NavView_Expanding"> - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -157,6 +118,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MSIXplainer/MainPage.xaml.cs b/MSIXplainer/MainPage.xaml.cs index 9273646..f34e648 100644 --- a/MSIXplainer/MainPage.xaml.cs +++ b/MSIXplainer/MainPage.xaml.cs @@ -16,17 +16,16 @@ public sealed partial class MainPage : Page { public MainPageViewModel ViewModel { get; } = new(); - // Static top-of-nav items (built once in constructor); dynamic per-package section - // items are inserted between the separator and the pane end and cleared on each rebuild. + // The top-level NavView only shows three static entry points (Apps, Open, + // Compare). Per-package navigation lives in the dedicated Sections + // secondary pane, so there are no dynamic items to track here. private NavigationViewItem? _appsItem; private NavigationViewItem? _openPackageItem; private NavigationViewItem? _compareItem; - private NavigationViewItemSeparator? _separator; public MainPage() { InitializeComponent(); - ViewModel.SectionsRebuilt += RebuildNavItems; ViewModel.InstalledPackages.CollectionChanged += InstalledPackages_CollectionChanged; ViewModel.PropertyChanged += ViewModel_PropertyChanged; BuildStaticNavItems(); @@ -39,7 +38,7 @@ private void BuildStaticNavItems() Content = "Apps", Tag = "apps", SelectsOnInvoked = false, - Icon = new FontIcon { Glyph = "\uE71D" } // BrowsePhotos / grid-style + Icon = new FontIcon { Glyph = "\uE71D" } // AllApps }; AutomationProperties.SetAutomationId(_appsItem, "NavApps"); @@ -61,86 +60,28 @@ private void BuildStaticNavItems() }; AutomationProperties.SetAutomationId(_compareItem, "NavCompareVersions"); - _separator = new NavigationViewItemSeparator(); - NavView.MenuItems.Add(_appsItem); NavView.MenuItems.Add(_openPackageItem); NavView.MenuItems.Add(_compareItem); - NavView.MenuItems.Add(_separator); - } - - private async void RebuildNavItems() - { - // Remove only the dynamic section items (everything AFTER our separator). - int separatorIndex = NavView.MenuItems.IndexOf(_separator); - if (separatorIndex < 0) return; - - for (int i = NavView.MenuItems.Count - 1; i > separatorIndex; i--) - NavView.MenuItems.RemoveAt(i); - - NavigationViewItem? firstSection = null; - - foreach (var section in ViewModel.Sections) - { - var item = new NavigationViewItem - { - Content = section.Label, - Tag = section.Tag, - Icon = new FontIcon { Glyph = section.IconGlyph } - }; - AutomationProperties.SetAutomationId(item, $"Nav_{section.Tag}"); - - if (section.Tag != "overview" && section.FindingCount > 0) - { - item.InfoBadge = new InfoBadge { Value = section.FindingCount }; - } - - if (section.IconBytes is { Length: > 0 }) - { - try - { - var bitmap = new BitmapImage(); - using var stream = new InMemoryRandomAccessStream(); - using (var writer = new DataWriter(stream.GetOutputStreamAt(0))) - { - writer.WriteBytes(section.IconBytes); - await writer.StoreAsync(); - writer.DetachStream(); - } - stream.Seek(0); - await bitmap.SetSourceAsync(stream); - item.Icon = new ImageIcon { Source = bitmap, Width = 16, Height = 16 }; - } - catch - { - // Keep FontIcon fallback - } - } - - NavView.MenuItems.Add(item); - firstSection ??= item; - } - - if (firstSection is not null) - NavView.SelectedItem = firstSection; } private void InstalledPackages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - // No-op: the ListView in the Apps pane binds directly to ViewModel.InstalledPackages, - // so collection changes propagate without a manual rebuild. Kept hooked for any - // future side-effect (e.g. analytics) without re-wiring the constructor. + // No-op: the ListView in the Apps pane binds directly to ViewModel.InstalledPackages. } private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - // Reserved for future cross-cutting reactions to VM property changes. + // When a package is loaded the Sections pane appears in column 0; auto-collapse + // the primary nav rail so the user's attention shifts to the loaded package. + if (e.PropertyName == nameof(MainPageViewModel.IsPackageLoaded) && ViewModel.IsPackageLoaded) + { + NavView.IsPaneOpen = false; + } } private async void NavView_Expanding(NavigationView sender, NavigationViewItemExpandingEventArgs args) { - // Apps no longer expands inline (Outlook-style pane handles that). Suppress the - // expansion and instead open the secondary pane. if (args.ExpandingItemContainer is NavigationViewItem nvi && nvi.Tag is "apps") { nvi.IsExpanded = false; @@ -168,26 +109,13 @@ private async void NavView_ItemInvoked(NavigationView sender, NavigationViewItem CloseAppsPane(); EnterCompareMode(); break; - - case "export-markdown": - await ViewModel.ExportMarkdownCommand.ExecuteAsync(null); - break; - - case "export-json": - await ViewModel.ExportJsonCommand.ExecuteAsync(null); - break; } } private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) { - if (args.SelectedItem is NavigationViewItem item && item.Tag is string tag) - { - // Section selection always returns to the home content (welcome/analysis). - CloseAppsPane(); - ExitCompareMode(); - ViewModel.SelectSection(tag); - } + // The three static items are SelectsOnInvoked="False" so this should not fire + // during normal use. Safety net. } private async Task OpenAppsPaneAsync() @@ -205,6 +133,11 @@ private void CloseAppsPane() private void OnCloseAppsPaneClick(object sender, RoutedEventArgs e) => CloseAppsPane(); + private void OnRawXmlClick(object sender, RoutedEventArgs e) + { + ViewModel.SelectSection("raw-xml"); + } + private void OnInstalledAppClick(object sender, ItemClickEventArgs e) { if (e.ClickedItem is InstalledPackage pkg) @@ -236,11 +169,9 @@ private void ViewFinding_Click(object sender, RoutedEventArgs e) /// /// Copies a manifest property value to the clipboard. Wraps the clipboard - /// call in try/catch so a transient clipboard failure (e.g. another - /// process holding it open) never bubbles up as an unhandled exception. - /// Bug fix for #10 — the built-in TextBlock context-menu Copy was - /// crashing the app on some Windows builds; this gives users a reliable - /// alternative. + /// call in try/catch so a transient clipboard failure never bubbles up as + /// an unhandled exception. Bug fix for #10 — the built-in TextBlock + /// context-menu Copy was crashing the app on some Windows builds. /// private void CopyPropertyValue_Click(object sender, RoutedEventArgs e) { @@ -290,6 +221,9 @@ public static Visibility NullObjectToVisibility(object? value) => public static Visibility NonNullObjectToVisibility(object? value) => value is null ? Visibility.Collapsed : Visibility.Visible; + public static Visibility PositiveIntToVisibility(int value) => + value > 0 ? Visibility.Visible : Visibility.Collapsed; + public static Microsoft.UI.Xaml.Media.ImageSource? ObjectToImageSource(object? value) => value as Microsoft.UI.Xaml.Media.ImageSource; diff --git a/MSIXplainer/Pages/ComparePage.xaml b/MSIXplainer/Pages/ComparePage.xaml index 870ff26..a223b06 100644 --- a/MSIXplainer/Pages/ComparePage.xaml +++ b/MSIXplainer/Pages/ComparePage.xaml @@ -43,7 +43,50 @@ Style="{StaticResource SubtitleTextBlockStyle}" /> - + + + + + + + + + + + + + + + + + - - - - - - - - - - - + - + + \ No newline at end of file diff --git a/MSIXplainer/ViewModels/MainPageViewModel.cs b/MSIXplainer/ViewModels/MainPageViewModel.cs index 1a27657..476ad93 100644 --- a/MSIXplainer/ViewModels/MainPageViewModel.cs +++ b/MSIXplainer/ViewModels/MainPageViewModel.cs @@ -14,6 +14,8 @@ public partial class MainPageViewModel : ObservableObject [ObservableProperty] public partial bool IsPackageLoaded { get; set; } + partial void OnIsPackageLoadedChanged(bool value) => RecomputeSectionsPaneVisibility(); + [ObservableProperty] public partial PackageInfo? PackageInfo { get; set; } @@ -77,6 +79,8 @@ public partial class MainPageViewModel : ObservableObject [ObservableProperty] public partial bool IsCompareMode { get; set; } + partial void OnIsCompareModeChanged(bool value) => RecomputeSectionsPaneVisibility(); + /// /// When true, the Apps secondary pane (Outlook-style second column) is visible /// between the nav rail and the main content area. Toggled by the Apps nav item. @@ -84,8 +88,34 @@ public partial class MainPageViewModel : ObservableObject [ObservableProperty] public partial bool IsAppsPaneOpen { get; set; } - /// Raised when sections are rebuilt so code-behind can refresh NavigationView items. - public event Action? SectionsRebuilt; + partial void OnIsAppsPaneOpenChanged(bool value) => RecomputeSectionsPaneVisibility(); + + /// + /// When true, the Sections secondary pane is visible in column 0 (mutually + /// exclusive with the Apps pane). A package must be loaded and we must not + /// be in Compare mode. + /// + [ObservableProperty] + public partial bool IsSectionsPaneVisible { get; set; } + + /// + /// Two-way bound to the Sections ListView in the secondary pane. Changing this + /// dispatches to ; also + /// writes back here so programmatic selection (e.g. "overview" on package load) + /// highlights the right row. + /// + [ObservableProperty] + public partial ManifestSection? SelectedSection { get; set; } + + partial void OnSelectedSectionChanged(ManifestSection? value) + { + if (value is null) return; + if (value.Tag == SelectedSectionTag) return; + SelectSection(value.Tag); + } + + private void RecomputeSectionsPaneVisibility() => + IsSectionsPaneVisible = IsPackageLoaded && !IsCompareMode && !IsAppsPaneOpen; private List _allFindings = []; private XElement? _manifestRoot; @@ -369,6 +399,12 @@ public void SelectSection(string tag) IsRawXmlSelected = tag == "raw-xml"; IsSectionSelected = !IsOverviewSelected && !IsRawXmlSelected; + // Keep the Sections pane ListView selection in sync. The setter is no-op + // when the tag already matches (see OnSelectedSectionChanged guard). + var match = Sections.FirstOrDefault(s => s.Tag == tag); + if (match is not null && !ReferenceEquals(SelectedSection, match)) + SelectedSection = match; + CurrentGroups.Clear(); CategoryFindings.Clear(); SelectedFinding = null; @@ -403,7 +439,6 @@ private void AnalyzeManifest(string rawXml, PackageInfo info, XDocument manifest BuildSections(); ComputeAssessment(info); - SectionsRebuilt?.Invoke(); } private static RuleSeverityOverrides LoadUserRuleOverrides() From cc7391b94544943455b736732fb3ebe5c717ff7a Mon Sep 17 00:00:00 2001 From: Andrew Clinick <80841394+aclinick@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:54:05 -0700 Subject: [PATCH 05/12] Unify package-picking surface; remove redundant Compare toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainPage: - Apps pane closes automatically when the user picks an app so the Sections pane (app info) takes over the column. - 'Open Package…' removed from the top NavView; opening a .msix from disk is now a primary action button inside the Apps pane, since both paths are ways to pick a package to analyze. - Top NavView is now just Apps + Compare Versions. - Welcome hint text updated to match. ComparePage: - Top toolbar removed (no more 'Back to Analyse' — top NavView provides navigation). The Compare-mode banner is gone, freeing vertical space. - The left sidebar is renamed 'Bandwidth Planner' with title + intro at the top, always visible. Export buttons stay at the bottom, visible after a comparison runs. - OnBackClick handler deleted (no callers). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MSIXplainer/MainPage.xaml | 21 +++- MSIXplainer/MainPage.xaml.cs | 34 +++---- MSIXplainer/Pages/ComparePage.xaml | 135 ++++++++++++-------------- MSIXplainer/Pages/ComparePage.xaml.cs | 17 ---- 4 files changed, 94 insertions(+), 113 deletions(-) diff --git a/MSIXplainer/MainPage.xaml b/MSIXplainer/MainPage.xaml index 138aef3..0cff182 100644 --- a/MSIXplainer/MainPage.xaml +++ b/MSIXplainer/MainPage.xaml @@ -35,6 +35,7 @@ BorderThickness="0,0,1,0"> + @@ -62,8 +63,22 @@ + + + - @@ -72,7 +87,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - + + + + + + + + + + + + - @@ -614,6 +604,5 @@ - \ No newline at end of file diff --git a/MSIXplainer/Pages/ComparePage.xaml.cs b/MSIXplainer/Pages/ComparePage.xaml.cs index df21c2c..ec6ea78 100644 --- a/MSIXplainer/Pages/ComparePage.xaml.cs +++ b/MSIXplainer/Pages/ComparePage.xaml.cs @@ -14,23 +14,6 @@ public ComparePage() InitializeComponent(); } - private void OnBackClick(object sender, RoutedEventArgs e) - { - // ComparePage is hosted in MainPage's inner CompareFrame; walking up the visual tree - // to find MainPage lets the back button exit Compare mode cleanly without a Frame - // navigation stack. Falls back to the outer Frame.GoBack for any other host. - var parent = VisualTreeHelper.GetParent(this); - while (parent is not null && parent is not MainPage) - parent = VisualTreeHelper.GetParent(parent); - - if (parent is MainPage main) - main.ExitCompareMode(); - else if (Frame is not null && Frame.CanGoBack) - Frame.GoBack(); - else - Frame?.Navigate(typeof(MainPage)); - } - // ── x:Bind helper functions ── public static Visibility BoolToVisibility(bool value) => From 648596b238660486c4386852ab88f4948daa3183 Mon Sep 17 00:00:00 2001 From: Andrew Clinick <80841394+aclinick@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:10:37 -0700 Subject: [PATCH 06/12] ComparePage: split into Diff / Bandwidth Planner / Duplicates sidebar views + add swap button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add per-tool nav (Diff, Bandwidth Planner, Duplicates) to ComparePage sidebar, mirroring the Sections pane pattern from MainPage. Diff is the default view after each successful comparison; Bandwidth Planner and Duplicates are accessed by selecting the matching sidebar item. - ListView selection driven from code-behind (Loaded + ViewModel.PropertyChanged) instead of x:Bind IsSelected on items to avoid a UIA re-entrancy crash that happened when accessibility tools probed the tree during SelectionChanged. - InfoBadges on Diff and Duplicates nav items show file-change and duplicate-group counts. - Duplicates view shows a positive empty-state InfoBar when the comparison found no duplicates. - Add Swap button between the Old/New package pickers — flips OldPath ↔ NewPath in one click for users who pick the target version first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MSIXplainer/Pages/ComparePage.xaml | 156 +++++++++++++++--- MSIXplainer/Pages/ComparePage.xaml.cs | 50 ++++++ .../ViewModels/ComparePageViewModel.cs | 45 +++++ 3 files changed, 226 insertions(+), 25 deletions(-) diff --git a/MSIXplainer/Pages/ComparePage.xaml b/MSIXplainer/Pages/ComparePage.xaml index 269f9b1..cd2af23 100644 --- a/MSIXplainer/Pages/ComparePage.xaml +++ b/MSIXplainer/Pages/ComparePage.xaml @@ -36,18 +36,78 @@ - + Text="Pick two MSIX versions and analyze the delta: what's changed, how big the update download will be, and where you can deduplicate." /> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + @@ -98,7 +159,7 @@ - @@ -121,7 +182,17 @@ - + + + + @@ -164,6 +235,21 @@ + + + + + + + + + + + + - - - - - - - + + + + + - @@ -416,6 +497,12 @@ + + + + + @@ -541,17 +628,34 @@ + + - + + Visibility="{x:Bind local:ComparePage.BoolToVisibility(ViewModel.IsDuplicatesView), Mode=OneWay}"> - + TextWrapping="Wrap" + Text="Files with identical content that appear in multiple locations within the new package. Deduplicating these can shrink install footprint and update size." /> + + + + + + + +