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.Cli/Program.cs b/MSIXplainer.Cli/Program.cs index b21ca75..b11669c 100644 --- a/MSIXplainer.Cli/Program.cs +++ b/MSIXplainer.Cli/Program.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text; using MSIXplainer.Models; using MSIXplainer.Services; @@ -17,6 +18,12 @@ static int Main(string[] args) return 0; } + if (args.Contains("--version") || args.Contains("-v")) + { + AnsiConsole.MarkupLine($"MSIXplainer [cyan]{Markup.Escape(GetVersion())}[/]"); + return 0; + } + // Subcommands handle their own --help. if (string.Equals(args[0], "rules", StringComparison.OrdinalIgnoreCase)) { @@ -561,6 +568,8 @@ static CliOptions ParseArgs(string[] args) static void PrintUsage() { AnsiConsole.Write(new FigletText("MSIXplainer").Color(Color.CornflowerBlue)); + AnsiConsole.MarkupLine($" [grey]Version[/] [cyan]{Markup.Escape(GetVersion())}[/]"); + AnsiConsole.WriteLine(); var table = new Table() .Border(TableBorder.None) @@ -614,6 +623,23 @@ static void PrintUsage() AnsiConsole.Write(table); AnsiConsole.WriteLine(); } + + /// + /// Returns the assembly informational/file version, e.g. "1.0.19.0". Used by + /// the banner and `--version`. Falls back to "unknown" if reflection fails. + /// + static string GetVersion() + { + var asm = typeof(Program).Assembly; + var info = asm.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(info)) + { + // Strip +sha suffix that SDK appends for SourceLink + var plus = info.IndexOf('+'); + return plus > 0 ? info[..plus] : info; + } + return asm.GetName().Version?.ToString() ?? "unknown"; + } } sealed class CliOptions 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.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.Tests/UpdateDiffBundleTests.cs b/MSIXplainer.Core.Tests/UpdateDiffBundleTests.cs index f370c39..ae536f4 100644 --- a/MSIXplainer.Core.Tests/UpdateDiffBundleTests.cs +++ b/MSIXplainer.Core.Tests/UpdateDiffBundleTests.cs @@ -310,15 +310,72 @@ [new SyntheticFile("App.exe", Bytes("a", 100))]), var app = inners.Single(i => i.IsApplication); Assert.Equal("x64", app.Architecture); - Assert.Equal("app|x64", app.MatchKey); + Assert.Equal("app|x64|||", app.MatchKey); var res = inners.Single(i => i.IsResource); Assert.Equal("en-us", res.ResourceId); - Assert.Equal("resource|en-us", res.MatchKey); + Assert.Equal("resource|neutral|en-us||", res.MatchKey); } finally { File.Delete(bundle); } } + + [Fact] + public void CompareBundles_HandlesMultipleInnersWithSameArchitecture() + { + // Some real-world bundles (e.g. Microsoft.DesktopAppInstaller) ship more + // than one application-type inner package for the same architecture + // (main app + companion/asset partitions). Earlier versions of the diff + // service used `app|{arch}` as the key and crashed with + // "An item with the same key has already been added. Key: app|x64". + // This regression test pins the new behavior: we no longer throw. + var oldBundle = CreateBundle( + new InnerSpec("App_x64.msix", "application", "x64", "", "1.0.0.0", + [new SyntheticFile("App.exe", Bytes("a", 100))]), + new InnerSpec("Extras_x64.msix", "application", "x64", "extras", "1.0.0.0", + [new SyntheticFile("Extras.dll", Bytes("b", 100))])); + var newBundle = CreateBundle( + new InnerSpec("App_x64.msix", "application", "x64", "", "1.1.0.0", + [new SyntheticFile("App.exe", Bytes("a", 100))]), + new InnerSpec("Extras_x64.msix", "application", "x64", "extras", "1.1.0.0", + [new SyntheticFile("Extras.dll", Bytes("b", 100))])); + + try + { + var result = UpdateDiffService.CompareBundles(oldBundle, newBundle); + Assert.Equal(2, result.PackageDiffs.Count); + } + finally + { + File.Delete(oldBundle); + File.Delete(newBundle); + } + } + + [Fact] + public void BundleManifestParser_SkipsStubPackages() + { + // Real DesktopAppInstaller bundles include stub packages whose FileName + // attribute is "AppxMetadata\Stub\AppInstaller_x64_stub.msix". These are + // metadata-only placeholders (no real payload), so the parser must skip + // them — otherwise UpdateDiffService later tries to read their block map + // and fails with "Bundle does not contain expected inner package". + var xml = $$""" + + + + + + + + + """; + + var inners = BundleManifestParser.Parse(xml); + + Assert.Single(inners); + Assert.Equal("App_x64.msix", inners[0].FileName); + } } 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/BundleInnerPackage.cs b/MSIXplainer.Core/Models/BundleInnerPackage.cs index 7713c21..59e3e56 100644 --- a/MSIXplainer.Core/Models/BundleInnerPackage.cs +++ b/MSIXplainer.Core/Models/BundleInnerPackage.cs @@ -32,14 +32,21 @@ public sealed class BundleInnerPackage public bool IsResource => string.Equals(Type, "resource", StringComparison.OrdinalIgnoreCase); /// - /// Stable matching key for pairing the same logical package across two bundles. - /// Application packages key on architecture; resource packages key on - /// ResourceId (which typically encodes the qualifier such as language or scale). + /// Stable matching key for pairing the same logical inner package across two bundles. + /// Uses the bundle-manifest identity attributes (type, architecture, resourceId) + /// plus language/scale qualifiers so split resource partitions with the same + /// architecture don't collide. /// - public string MatchKey => - IsApplication - ? $"app|{Architecture.ToLowerInvariant()}" - : $"resource|{ResourceId.ToLowerInvariant()}"; + public string MatchKey + { + get + { + var langs = string.Join(",", Languages.Select(l => l.ToLowerInvariant()).OrderBy(l => l, StringComparer.Ordinal)); + var scales = string.Join(",", Scales.Select(s => s.ToLowerInvariant()).OrderBy(s => s, StringComparer.Ordinal)); + var kind = IsApplication ? "app" : "resource"; + return $"{kind}|{Architecture.ToLowerInvariant()}|{ResourceId.ToLowerInvariant()}|{langs}|{scales}"; + } + } /// Short human-readable label, e.g. "x64", "resources.en-us", "scale-200". public string Label diff --git a/MSIXplainer.Core/Models/InstalledPackage.cs b/MSIXplainer.Core/Models/InstalledPackage.cs new file mode 100644 index 0000000..2cb56a1 --- /dev/null +++ b/MSIXplainer.Core/Models/InstalledPackage.cs @@ -0,0 +1,52 @@ +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; } + + /// + /// 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. + /// + public string? ManifestPath => + string.IsNullOrEmpty(InstallLocation) + ? null + : Path.Combine(InstallLocation, "AppxManifest.xml"); +} diff --git a/MSIXplainer.Core/Services/BundleManifestParser.cs b/MSIXplainer.Core/Services/BundleManifestParser.cs index 0f7a799..f6cc822 100644 --- a/MSIXplainer.Core/Services/BundleManifestParser.cs +++ b/MSIXplainer.Core/Services/BundleManifestParser.cs @@ -62,6 +62,16 @@ public static IReadOnlyList Parse(string xml) var fileName = p.Attribute("FileName")?.Value ?? string.Empty; if (string.IsNullOrEmpty(fileName)) continue; + // Normalize path separator: bundle manifests sometimes use '\' but + // the underlying ZIP entry names always use '/'. + fileName = fileName.Replace('\\', '/'); + + // Skip stub packages (AppxMetadata/Stub/*.msix). Stubs are metadata-only + // placeholders used by the platform for bundle delta servicing — they + // don't carry a real block map or payload, so they have nothing to diff. + if (fileName.StartsWith("AppxMetadata/", StringComparison.OrdinalIgnoreCase)) + continue; + var type = p.Attribute("Type")?.Value ?? "application"; var version = p.Attribute("Version")?.Value ?? string.Empty; var arch = p.Attribute("Architecture")?.Value ?? "neutral"; diff --git a/MSIXplainer.Core/Services/InstalledPackageService.cs b/MSIXplainer.Core/Services/InstalledPackageService.cs new file mode 100644 index 0000000..8653150 --- /dev/null +++ b/MSIXplainer.Core/Services/InstalledPackageService.cs @@ -0,0 +1,178 @@ +using System.Runtime.Versioning; +using MSIXplainer.Models; +using Windows.ApplicationModel; +using Windows.ApplicationModel.Core; +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.IsWindowsVersionAtLeast(10, 0, 19041)) + 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.IsWindowsVersionAtLeast(10, 0, 19041)) + 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.19041.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. 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.19041.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; + + // 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)) + { + 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/ManifestExplainerService.cs b/MSIXplainer.Core/Services/ManifestExplainerService.cs index 7172d0f..1c92369 100644 --- a/MSIXplainer.Core/Services/ManifestExplainerService.cs +++ b/MSIXplainer.Core/Services/ManifestExplainerService.cs @@ -30,32 +30,31 @@ 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") ?? []; - foreach (var app in apps) + var apps = root.Element(Ns + "Applications")?.Elements(Ns + "Application").ToList() ?? []; + if (apps.Count > 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.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.Core/Services/UpdateDiffService.cs b/MSIXplainer.Core/Services/UpdateDiffService.cs index a7a5182..d8ea074 100644 --- a/MSIXplainer.Core/Services/UpdateDiffService.cs +++ b/MSIXplainer.Core/Services/UpdateDiffService.cs @@ -48,8 +48,8 @@ public static UpdateDiffResult CompareBundles(string oldBundlePath, string newBu var oldInners = BundleManifestParser.ExtractFromBundle(oldBundlePath); var newInners = BundleManifestParser.ExtractFromBundle(newBundlePath); - var oldByKey = oldInners.ToDictionary(p => p.MatchKey, StringComparer.Ordinal); - var newByKey = newInners.ToDictionary(p => p.MatchKey, StringComparer.Ordinal); + var oldByKey = BuildInnerLookup(oldInners); + var newByKey = BuildInnerLookup(newInners); var packageDiffs = new List(); var added = new List(); @@ -101,6 +101,23 @@ public static UpdateDiffResult CompareBundles(string oldBundlePath, string newBu }; } + /// + /// Builds a MatchKey -> first BundleInnerPackage dictionary, tolerating duplicates + /// (some bundles ship multiple inner packages that collapse to the same identity + /// attributes — we keep the first and ignore subsequent duplicates rather than + /// crashing). + /// + private static Dictionary BuildInnerLookup( + IReadOnlyList inners) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var p in inners) + { + map.TryAdd(p.MatchKey, p); + } + return map; + } + /// /// Compares two single MSIX/APPX packages (not bundles) and returns the diff. /// Throws if a path points at a bundle or a file missing the block map. diff --git a/MSIXplainer/App.xaml.cs b/MSIXplainer/App.xaml.cs index a4ca7aa..1550b83 100644 --- a/MSIXplainer/App.xaml.cs +++ b/MSIXplainer/App.xaml.cs @@ -68,5 +68,8 @@ protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs ar Window = new MainWindow(); DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); Window.Activate(); + // Apply persisted theme after Window.Content is set so the FrameworkElement + // root exists. ThemeService.Apply no-ops if Content is still null. + MSIXplainer.Services.ThemeService.Apply(MSIXplainer.Services.ThemeService.LoadPreference()); } } diff --git a/MSIXplainer/MainPage.xaml b/MSIXplainer/MainPage.xaml index 3c86104..de58ca5 100644 --- a/MSIXplainer/MainPage.xaml +++ b/MSIXplainer/MainPage.xaml @@ -9,84 +9,235 @@ 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..a6c67bc 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,71 +16,179 @@ public sealed partial class MainPage : Page { public MainPageViewModel ViewModel { get; } = new(); + // The top-level NavView only shows two static entry points (Apps, Compare). + // "Open package from disk…" lives inside the Apps pane as a primary action + // alongside the installed-apps list, since both are ways of picking a + // package to analyze. Settings lives in the footer rail. + private NavigationViewItem? _appsItem; + private NavigationViewItem? _compareItem; + private NavigationViewItem? _settingsItem; + public MainPage() { InitializeComponent(); - ViewModel.SectionsRebuilt += RebuildNavItems; + ViewModel.InstalledPackages.CollectionChanged += InstalledPackages_CollectionChanged; + ViewModel.PropertyChanged += ViewModel_PropertyChanged; + BuildStaticNavItems(); } - private async void RebuildNavItems() + private void BuildStaticNavItems() { - NavView.MenuItems.Clear(); - NavigationViewItem? firstItem = null; + _appsItem = new NavigationViewItem + { + Content = "Apps", + Tag = "apps", + SelectsOnInvoked = false, + Icon = new FontIcon { Glyph = "\uE71D" } // AllApps + }; + AutomationProperties.SetAutomationId(_appsItem, "NavApps"); - foreach (var section in ViewModel.Sections) + _compareItem = new NavigationViewItem { - var item = new NavigationViewItem - { - Content = section.Label, - Tag = section.Tag, - Icon = new FontIcon { Glyph = section.IconGlyph } - }; - AutomationProperties.SetAutomationId(item, $"Nav_{section.Tag}"); + Content = "Compare Versions…", + Tag = "compare", + SelectsOnInvoked = false, + Icon = new FontIcon { Glyph = "\uE8AB" } // Switch + }; + AutomationProperties.SetAutomationId(_compareItem, "NavCompareVersions"); - if (section.Tag != "overview" && section.FindingCount > 0) - { - item.InfoBadge = new InfoBadge { Value = section.FindingCount }; - } + NavView.MenuItems.Add(_appsItem); + NavView.MenuItems.Add(_compareItem); - // Load app icon from package if available - 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 - } - } + _settingsItem = new NavigationViewItem + { + Content = "Settings", + Tag = "settings", + SelectsOnInvoked = false, + Icon = new FontIcon { Glyph = "\uE713" } // Gear + }; + AutomationProperties.SetAutomationId(_settingsItem, "NavSettings"); + NavView.FooterMenuItems.Add(_settingsItem); + } - NavView.MenuItems.Add(item); - firstItem ??= item; + private void InstalledPackages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + // No-op: the ListView in the Apps pane binds directly to ViewModel.InstalledPackages. + } + + private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // 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; } + } - if (firstItem is not null) - NavView.SelectedItem = firstItem; + private async void NavView_Expanding(NavigationView sender, NavigationViewItemExpandingEventArgs args) + { + 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 "compare": + CloseAppsPane(); + ExitSettingsMode(); + EnterCompareMode(); + break; + + case "settings": + CloseAppsPane(); + ExitCompareMode(); + EnterSettingsMode(); + break; + } + } + + private async void OnOpenPackageFromDiskClick(object sender, RoutedEventArgs e) + { + CloseAppsPane(); + ExitCompareMode(); + ExitSettingsMode(); + await ViewModel.OpenPackageCommand.ExecuteAsync(null); } private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) { - if (args.SelectedItem is NavigationViewItem item && item.Tag is string tag) + // The three static items are SelectsOnInvoked="False" so this should not fire + // during normal use. Safety net. + } + + 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 OnRawXmlClick(object sender, RoutedEventArgs e) + { + ViewModel.SelectSection("raw-xml"); + } + + private void OnInstalledAppClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is InstalledPackage pkg) { - ViewModel.SelectSection(tag); + ExitCompareMode(); + ExitSettingsMode(); + ViewModel.OpenInstalledPackage(pkg); + // Close the Apps pane so the Sections pane (which now hosts the loaded + // package's nav + actions) takes over column 0. + CloseAppsPane(); } } + 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 EnterSettingsMode() + { + ViewModel.IsSettingsMode = true; + if (SettingsFrame.Content is null) + SettingsFrame.Navigate(typeof(Pages.SettingsPage)); + } + + internal void ExitSettingsMode() + { + if (!ViewModel.IsSettingsMode) return; + ViewModel.IsSettingsMode = false; + SettingsFrame.Content = null; + } + private void ViewFinding_Click(object sender, RoutedEventArgs e) { if (sender is FrameworkElement fe && fe.Tag is ManifestFinding finding) @@ -87,11 +197,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) { @@ -129,6 +237,47 @@ 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 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 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; + + 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..5f32ded 100644 --- a/MSIXplainer/Package.appxmanifest +++ b/MSIXplainer/Package.appxmanifest @@ -11,7 +11,7 @@ + Version="1.0.24.0" /> diff --git a/MSIXplainer/Pages/ComparePage.xaml b/MSIXplainer/Pages/ComparePage.xaml index 870ff26..cd2af23 100644 --- a/MSIXplainer/Pages/ComparePage.xaml +++ b/MSIXplainer/Pages/ComparePage.xaml @@ -10,51 +10,145 @@ xmlns:models="using:MSIXplainer.Models" mc:Ignorable="d"> - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + - - + + @@ -65,7 +159,7 @@ - @@ -88,7 +182,17 @@ - + + + + @@ -131,6 +235,21 @@ + + + + + + + + + + + + - - - - - - - + + + + + - @@ -383,6 +497,12 @@ + + + + + @@ -451,33 +571,8 @@ - - - - - - - - - - - + + + - + + 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." /> + + + + + + + +