Skip to content

Commit 6d28e38

Browse files
aclinickCopilot
andauthored
Installed apps + Compare UX + Settings + Bandwidth Planner fixes (#14)
* 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> * 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> * Silence CA1416 by using IsWindowsVersionAtLeast guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * 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> * Unify package-picking surface; remove redundant Compare toolbar 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> * ComparePage: split into Diff / Bandwidth Planner / Duplicates sidebar views + add swap button - 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> * Package.ps1: copy ALL Spectre.Console*.dll, not just Spectre.Console.dll The CLI was crashing at startup with FileNotFoundException for Spectre.Console.Ansi because Spectre now ships as multiple assemblies (Spectre.Console, Spectre.Console.Ansi, ...). The hard-coded copy list only pulled Spectre.Console.dll, so the runtime couldn't resolve Ansi support when 'msixplainer' was launched from PATH. Switch to a glob of Spectre.Console*.dll so any future split is picked up automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add CLI --version + WinUI Settings page (theme + about) - CLI: --version/-v flag; version line in banner; stamp assembly version from manifest in Package.ps1 - WinUI: Settings footer NavView item -> SettingsPage with Light/Dark/System theme selector (persisted in LocalSettings) and About card (version + architecture + GitHub link) - ThemeService applies persisted theme on app launch - MainPageViewModel: IsSettingsMode / IsHomeMode mutually exclusive with IsCompareMode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Package.ps1: stamp CLI InformationalVersion only Stamping assembly Version caused CLI to bind to Core 1.0.20.0 while Core shipped as 1.0.0.0 (built separately by WinUI), giving FileNotFoundException at runtime. InformationalVersion alone gives us the displayed version without breaking the assembly reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Package.ps1: stamp Version/AssemblyVersion/FileVersion on both CLI and WinUI Align all assembly versions with the package manifest version so CLI --version, WinUI Settings 'About', and the .msixbundle all show the same number. Stamping the WinUI build too means Core (built as a project ref by both projects) ends up at the same version everywhere, avoiding the earlier FileNotFoundException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Compare crash on bundles with multiple same-arch inner packages Bundles like Microsoft.DesktopAppInstaller ship more than one application-type inner package for the same architecture (main + companion partitions), which made the old 'app|{arch}' MatchKey collide and UpdateDiffService.CompareBundles throw 'An item with the same key has already been added. Key: app|x64'. - BundleInnerPackage.MatchKey now includes ResourceId and language/scale qualifiers so split partitions don't collide. - UpdateDiffService now builds the lookup with TryAdd to tolerate any remaining duplicates rather than crashing. - New regression test CompareBundles_HandlesMultipleInnersWithSameArchitecture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Skip stub inner packages when parsing bundle manifests Real-world bundles like Microsoft.DesktopAppInstaller include 'stub' inner packages whose FileName attribute is e.g. 'AppxMetadata\Stub\AppInstaller_x64_stub.msix'. These are metadata-only placeholders used by the platform for delta servicing — they don't carry a real block map. Diffing them threw 'Bundle does not contain expected inner package'. - BundleManifestParser now normalizes backslash separators and skips entries under AppxMetadata/. - New test BundleManifestParser_SkipsStubPackages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9a3ffe1 commit 6d28e38

27 files changed

Lines changed: 2007 additions & 337 deletions

MSIXplainer.Cli/MSIXplainer.Cli.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
33
<OutputType>Exe</OutputType>
4-
<TargetFramework>net10.0</TargetFramework>
4+
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
55
<RootNamespace>MSIXplainer</RootNamespace>
66
<AssemblyName>MSIXplainer.Cli</AssemblyName>
77
<ImplicitUsings>enable</ImplicitUsings>

MSIXplainer.Cli/Program.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Reflection;
12
using System.Text;
23
using MSIXplainer.Models;
34
using MSIXplainer.Services;
@@ -17,6 +18,12 @@ static int Main(string[] args)
1718
return 0;
1819
}
1920

21+
if (args.Contains("--version") || args.Contains("-v"))
22+
{
23+
AnsiConsole.MarkupLine($"MSIXplainer [cyan]{Markup.Escape(GetVersion())}[/]");
24+
return 0;
25+
}
26+
2027
// Subcommands handle their own --help.
2128
if (string.Equals(args[0], "rules", StringComparison.OrdinalIgnoreCase))
2229
{
@@ -561,6 +568,8 @@ static CliOptions ParseArgs(string[] args)
561568
static void PrintUsage()
562569
{
563570
AnsiConsole.Write(new FigletText("MSIXplainer").Color(Color.CornflowerBlue));
571+
AnsiConsole.MarkupLine($" [grey]Version[/] [cyan]{Markup.Escape(GetVersion())}[/]");
572+
AnsiConsole.WriteLine();
564573

565574
var table = new Table()
566575
.Border(TableBorder.None)
@@ -614,6 +623,23 @@ static void PrintUsage()
614623
AnsiConsole.Write(table);
615624
AnsiConsole.WriteLine();
616625
}
626+
627+
/// <summary>
628+
/// Returns the assembly informational/file version, e.g. "1.0.19.0". Used by
629+
/// the banner and `--version`. Falls back to "unknown" if reflection fails.
630+
/// </summary>
631+
static string GetVersion()
632+
{
633+
var asm = typeof(Program).Assembly;
634+
var info = asm.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()?.InformationalVersion;
635+
if (!string.IsNullOrWhiteSpace(info))
636+
{
637+
// Strip +sha suffix that SDK appends for SourceLink
638+
var plus = info.IndexOf('+');
639+
return plus > 0 ? info[..plus] : info;
640+
}
641+
return asm.GetName().Version?.ToString() ?? "unknown";
642+
}
617643
}
618644

619645
sealed class CliOptions
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using MSIXplainer.Services;
2+
using Xunit;
3+
4+
namespace MSIXplainer.Core.Tests;
5+
6+
public class ExtractFromManifestFileTests
7+
{
8+
private const string SampleManifest = """
9+
<?xml version="1.0" encoding="utf-8"?>
10+
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
11+
<Identity Name="Contoso.Demo" Publisher="CN=Contoso" Version="2.5.0.0" ProcessorArchitecture="x64" />
12+
<Properties>
13+
<DisplayName>Contoso Demo</DisplayName>
14+
<PublisherDisplayName>Contoso</PublisherDisplayName>
15+
<Description>A demo package.</Description>
16+
</Properties>
17+
<Dependencies>
18+
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19041.0" MaxVersionTested="10.0.22621.0" />
19+
</Dependencies>
20+
<Applications>
21+
<Application Id="App" Executable="Demo.exe" EntryPoint="Windows.FullTrustApplication" />
22+
</Applications>
23+
</Package>
24+
""";
25+
26+
[Fact]
27+
public void ExtractFromManifestFile_ReadsLooseManifest()
28+
{
29+
var dir = Directory.CreateTempSubdirectory("msixplainer-manifest-test-");
30+
try
31+
{
32+
var path = Path.Combine(dir.FullName, "AppxManifest.xml");
33+
File.WriteAllText(path, SampleManifest);
34+
35+
var (doc, raw, info) = ManifestParserService.ExtractFromManifestFile(path);
36+
37+
Assert.NotNull(doc.Root);
38+
Assert.Equal("Contoso.Demo", info.Name);
39+
Assert.Equal("Contoso Demo", info.DisplayName);
40+
Assert.Equal("2.5.0.0", info.Version);
41+
Assert.Equal("x64", info.Architecture);
42+
Assert.StartsWith("Contoso.Demo_", info.PackageFamilyName);
43+
Assert.Equal(13, info.PackageFamilyName.Split('_')[1].Length);
44+
Assert.Contains("<Identity", raw);
45+
}
46+
finally
47+
{
48+
dir.Delete(recursive: true);
49+
}
50+
}
51+
52+
[Fact]
53+
public void ExtractFromManifestFile_MissingFile_Throws()
54+
{
55+
Assert.Throws<FileNotFoundException>(() =>
56+
ManifestParserService.ExtractFromManifestFile(
57+
Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid() + ".xml")));
58+
}
59+
60+
[Fact]
61+
public void ExtractFromManifestFile_DtdProcessing_Rejected()
62+
{
63+
var dir = Directory.CreateTempSubdirectory("msixplainer-manifest-dtd-");
64+
try
65+
{
66+
var path = Path.Combine(dir.FullName, "AppxManifest.xml");
67+
var malicious = """
68+
<?xml version="1.0"?>
69+
<!DOCTYPE Package [<!ENTITY bomb "boom">]>
70+
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
71+
<Identity Name="X" Publisher="CN=X" Version="1.0.0.0" ProcessorArchitecture="x64" />
72+
</Package>
73+
""";
74+
File.WriteAllText(path, malicious);
75+
76+
Assert.ThrowsAny<System.Xml.XmlException>(() =>
77+
ManifestParserService.ExtractFromManifestFile(path));
78+
}
79+
finally
80+
{
81+
dir.Delete(recursive: true);
82+
}
83+
}
84+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
using MSIXplainer.Models;
2+
using MSIXplainer.Services;
3+
using Xunit;
4+
5+
namespace MSIXplainer.Core.Tests;
6+
7+
public class InstalledPackageServiceTests
8+
{
9+
[Fact]
10+
public void ManifestPath_BuildsFromInstallLocation()
11+
{
12+
var pkg = NewPackage(installLocation: Path.Combine("C:", "apps", "myapp"));
13+
Assert.Equal(
14+
Path.Combine("C:", "apps", "myapp", "AppxManifest.xml"),
15+
pkg.ManifestPath);
16+
}
17+
18+
[Fact]
19+
public void ManifestPath_EmptyInstallLocation_IsNull()
20+
{
21+
Assert.Null(NewPackage(installLocation: "").ManifestPath);
22+
}
23+
24+
[Fact]
25+
public void List_OnNonWindows_ReturnsEmpty()
26+
{
27+
if (OperatingSystem.IsWindows())
28+
return; // Only meaningful off-Windows; PackageManager is unavailable there.
29+
30+
Assert.Empty(InstalledPackageService.List());
31+
}
32+
33+
[Fact]
34+
public void List_OnWindows_ReturnsMainPackagesWithValidIdentity()
35+
{
36+
if (!OperatingSystem.IsWindows())
37+
return;
38+
39+
var packages = InstalledPackageService.List();
40+
41+
// Don't assert specific packages — CI / dev images vary. Just verify the contract:
42+
// no throw, valid identity fields, every PFN is in Name_PublisherHash form, and
43+
// every row has a non-empty DisplayName (either resolved or fallen back to Name).
44+
Assert.NotNull(packages);
45+
foreach (var pkg in packages)
46+
{
47+
Assert.False(string.IsNullOrEmpty(pkg.Name), $"Name empty for {pkg.PackageFullName}");
48+
Assert.False(string.IsNullOrEmpty(pkg.DisplayName), $"DisplayName empty for {pkg.PackageFullName}");
49+
Assert.False(string.IsNullOrEmpty(pkg.PackageFamilyName), $"PFN empty for {pkg.Name}");
50+
Assert.Contains("_", pkg.PackageFamilyName);
51+
Assert.DoesNotContain("ms-resource:", pkg.DisplayName); // unresolved indirections must fall back
52+
}
53+
}
54+
55+
[Fact]
56+
public void List_OnWindows_ReturnsSortedByDisplayName()
57+
{
58+
if (!OperatingSystem.IsWindows())
59+
return;
60+
61+
var packages = InstalledPackageService.List();
62+
if (packages.Count < 2) return;
63+
64+
var displayNames = packages.Select(p => p.DisplayName).ToList();
65+
var sorted = displayNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
66+
Assert.Equal(sorted, displayNames);
67+
}
68+
69+
[Fact]
70+
public void FindByFamilyName_EmptyOrNull_ReturnsNull()
71+
{
72+
Assert.Null(InstalledPackageService.FindByFamilyName(""));
73+
Assert.Null(InstalledPackageService.FindByFamilyName(" "));
74+
Assert.Null(InstalledPackageService.FindByFamilyName(null!));
75+
}
76+
77+
[Fact]
78+
public void FindByFamilyName_OnWindows_RoundTripsKnownPackage()
79+
{
80+
if (!OperatingSystem.IsWindows())
81+
return;
82+
83+
var first = InstalledPackageService.List().FirstOrDefault();
84+
if (first is null) return; // No packages on this machine — skip.
85+
86+
var found = InstalledPackageService.FindByFamilyName(first.PackageFamilyName);
87+
Assert.NotNull(found);
88+
Assert.Equal(first.PackageFamilyName, found!.PackageFamilyName);
89+
}
90+
91+
[Fact]
92+
public void FindByFamilyName_UnknownPackage_ReturnsNull()
93+
{
94+
if (!OperatingSystem.IsWindows())
95+
return;
96+
97+
Assert.Null(InstalledPackageService.FindByFamilyName(
98+
"MSIXplainer.NonExistent.Package_0000000000000"));
99+
}
100+
101+
[Fact]
102+
public void ListWithoutIcons_OnNonWindows_ReturnsEmpty()
103+
{
104+
if (OperatingSystem.IsWindows())
105+
return;
106+
107+
Assert.Empty(InstalledPackageService.ListWithoutIcons());
108+
}
109+
110+
[Fact]
111+
public void ListWithoutIcons_OnWindows_AllRowsHaveNullIcons()
112+
{
113+
if (!OperatingSystem.IsWindows())
114+
return;
115+
116+
var packages = InstalledPackageService.ListWithoutIcons();
117+
Assert.NotNull(packages);
118+
// Fast path explicitly skips icon resolution — every row must come back with null bytes.
119+
Assert.All(packages, p => Assert.Null(p.IconBytes));
120+
}
121+
122+
[Fact]
123+
public void ListWithoutIcons_OnWindows_ReturnsSameIdentitiesAsList()
124+
{
125+
if (!OperatingSystem.IsWindows())
126+
return;
127+
128+
var fast = InstalledPackageService.ListWithoutIcons()
129+
.Select(p => p.PackageFullName)
130+
.OrderBy(n => n, StringComparer.Ordinal)
131+
.ToList();
132+
var full = InstalledPackageService.List()
133+
.Select(p => p.PackageFullName)
134+
.OrderBy(n => n, StringComparer.Ordinal)
135+
.ToList();
136+
137+
Assert.Equal(full, fast);
138+
}
139+
140+
[Fact]
141+
public void ResolveIcon_AlreadyHasIcon_ReturnsSameInstance()
142+
{
143+
var pkg = NewPackage(installLocation: "C:\\nope") with { IconBytes = [0x01, 0x02] };
144+
var resolved = InstalledPackageService.ResolveIcon(pkg);
145+
Assert.Same(pkg, resolved);
146+
}
147+
148+
[Fact]
149+
public void ResolveIcon_EmptyInstallLocation_ReturnsSameInstance()
150+
{
151+
var pkg = NewPackage(installLocation: "");
152+
var resolved = InstalledPackageService.ResolveIcon(pkg);
153+
Assert.Same(pkg, resolved);
154+
}
155+
156+
[Fact]
157+
public void ResolveIcon_NonExistentFolder_ReturnsSameInstanceWithNullBytes()
158+
{
159+
var pkg = NewPackage(installLocation: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
160+
var resolved = InstalledPackageService.ResolveIcon(pkg);
161+
Assert.Null(resolved.IconBytes);
162+
}
163+
164+
[Fact]
165+
public void ResolveIcon_OnWindows_PopulatesIconForRealPackage()
166+
{
167+
if (!OperatingSystem.IsWindows())
168+
return;
169+
170+
// Pick a package whose icon-with full-pass actually resolves, then verify
171+
// ResolveIcon arrives at the same bytes when started from the icon-less version.
172+
var withIcons = InstalledPackageService.List();
173+
var sample = withIcons.FirstOrDefault(p => p.IconBytes is { Length: > 0 });
174+
if (sample is null) return; // No package on this box exposed an icon — skip.
175+
176+
var stripped = sample with { IconBytes = null };
177+
var resolved = InstalledPackageService.ResolveIcon(stripped);
178+
179+
Assert.NotNull(resolved.IconBytes);
180+
Assert.Equal(sample.IconBytes!.Length, resolved.IconBytes!.Length);
181+
}
182+
183+
private static InstalledPackage NewPackage(string installLocation) => new()
184+
{
185+
Name = "X",
186+
DisplayName = "X",
187+
PackageFamilyName = "X_abc",
188+
PackageFullName = "X_1.0.0.0_x64__abc",
189+
Version = "1.0.0.0",
190+
Publisher = "CN=X",
191+
InstallLocation = installLocation,
192+
Architecture = "X64"
193+
};
194+
}

MSIXplainer.Core.Tests/MSIXplainer.Core.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFramework>net10.0</TargetFramework>
4+
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<IsPackable>false</IsPackable>

MSIXplainer.Core.Tests/ManifestExplainerServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public void BuildSections_IncludesApplicationSection()
4949
var (manifest, findings) = LoadSample();
5050
var sections = ManifestExplainerService.BuildSections(manifest, findings);
5151

52-
Assert.Contains(sections, s => s.Tag.StartsWith("app:"));
52+
Assert.Contains(sections, s => s.Tag == "applications");
5353
}
5454

5555
[Fact]

0 commit comments

Comments
 (0)