diff --git a/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverExtensionsTest.cs b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverExtensionsTest.cs
new file mode 100644
index 00000000..f23c56c5
--- /dev/null
+++ b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverExtensionsTest.cs
@@ -0,0 +1,137 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PackageUploader.ClientApi.Tools;
+
+namespace PackageUploader.ClientApi.Test;
+
+///
+/// Verifies that the MSIXVC2 tool resolver can be consumed from a non-UI host (for example the
+/// PackageUploader.exe console app) with no UI services registered, and with or without DI at all.
+///
+[TestClass]
+public class Msixvc2ToolResolverExtensionsTest
+{
+ [TestMethod]
+ public void AddMsixvc2ToolResolver_ResolvesFromABareServiceCollection_WithNoLoggingRegistered()
+ {
+ // A bare ServiceCollection is strictly harsher than HostApplicationBuilder, which
+ // pre-registers logging. If this works, a plain host works.
+ var services = new ServiceCollection();
+ services.AddMsixvc2ToolResolver();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ var resolver = provider.GetRequiredService();
+
+ Assert.IsNotNull(resolver);
+ Assert.IsInstanceOfType(resolver);
+ Assert.IsInstanceOfType(provider.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void AddMsixvc2ToolResolver_ResolvesWhenLoggingIsRegistered()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddMsixvc2ToolResolver();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ Assert.IsNotNull(provider.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void AddMsixvc2ToolResolver_IsIdempotent()
+ {
+ var services = new ServiceCollection();
+ services.AddMsixvc2ToolResolver();
+ services.AddMsixvc2ToolResolver();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(IMsixvc2ToolResolver)));
+ Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(IToolProbeRunner)));
+ Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(IToolPathResolver)));
+ Assert.IsNotNull(provider.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void AddMsixvc2ToolResolver_RegistersTheSharedToolPathResolver()
+ {
+ // The desktop app injects this directly for tools that have nothing to do with MSIXVC2,
+ // so registering the MSIXVC2 resolver must make discovery available on its own.
+ var services = new ServiceCollection();
+ services.AddMsixvc2ToolResolver();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ var pathResolver = provider.GetRequiredService();
+
+ Assert.IsInstanceOfType(pathResolver);
+ Assert.AreSame(pathResolver, provider.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void AddMsixvc2ToolResolver_HonoursAPreRegisteredToolPathResolver()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddMsixvc2ToolResolver();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ Assert.IsInstanceOfType(provider.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void AddMsixvc2ToolResolver_HonoursAPreRegisteredProbeRunner()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddMsixvc2ToolResolver();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ Assert.IsInstanceOfType(provider.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void AddMsixvc2ToolResolver_RegistersTheResolverAsASingleton()
+ {
+ var services = new ServiceCollection();
+ services.AddMsixvc2ToolResolver();
+
+ using var provider = services.BuildServiceProvider(validateScopes: true);
+
+ Assert.AreSame(
+ provider.GetRequiredService(),
+ provider.GetRequiredService());
+ }
+
+ [TestMethod]
+ public void Msixvc2ToolResolver_IsConstructibleWithNoDependencyInjectionAtAll()
+ {
+ // Console hosts that don't build a container can just new it up.
+ var resolver = new Msixvc2ToolResolver();
+
+ // Self-discovery must not throw even when no tool is installed on the machine.
+ var tool = resolver.Resolve();
+
+ Assert.AreEqual(tool is not null, resolver.IsMsixvc2Supported());
+ }
+
+ private sealed class StubProbeRunner : IToolProbeRunner
+ {
+ public ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout) =>
+ ToolProbeResult.Failed;
+ }
+
+ private sealed class StubToolPathResolver : IToolPathResolver
+ {
+ public string Find(string fileName) => null;
+ }
+}
diff --git a/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverGdkDiscoveryTest.cs b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverGdkDiscoveryTest.cs
new file mode 100644
index 00000000..9434dbfd
--- /dev/null
+++ b/src/PackageUploader.ClientApi.Test/Msixvc2ToolResolverGdkDiscoveryTest.cs
@@ -0,0 +1,231 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PackageUploader.ClientApi.Tools;
+
+namespace PackageUploader.ClientApi.Test;
+
+///
+/// Covers the probe and fallback chain over GDK-discovered tools. The GDK ships both MakePkg.exe and
+/// makepkg2.exe in its bin directory, so the resolver must probe them in order and pick the capable one.
+///
+///
+/// Discovery itself is covered by ToolPathResolverTest. These tests drive the layer above it:
+/// the GDK root lookup is seamed and PATH is replaced with a controlled directory, so they never
+/// require an installed GDK.
+///
+[TestClass]
+public class Msixvc2ToolResolverGdkDiscoveryTest
+{
+ private string _testRoot;
+ private string _originalPath;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _testRoot = Path.Combine(Path.GetTempPath(), "Msixvc2GdkTest_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_testRoot);
+
+ // Discovery consults the app directory and the current directory before the GDK. A stray real
+ // tool in either would silently pre-empt what these tests are asserting, so refuse to run
+ // rather than produce a misleading pass or failure.
+ foreach (var directory in new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() })
+ {
+ foreach (var fileName in new[] { "MakePkg.exe", "makepkg2.exe" })
+ {
+ if (File.Exists(Path.Combine(directory, fileName)))
+ {
+ Assert.Inconclusive($"{fileName} is present in {directory}, which pre-empts GDK discovery.");
+ }
+ }
+ }
+
+ _originalPath = Environment.GetEnvironmentVariable("PATH");
+ Environment.SetEnvironmentVariable("PATH", Path.Combine(_testRoot, "empty-path"));
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ Environment.SetEnvironmentVariable("PATH", _originalPath);
+
+ try
+ {
+ Directory.Delete(_testRoot, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Temp cleanup is best effort.
+ }
+ }
+
+ [TestMethod]
+ public void Resolve_UsesMakePkgFromTheGdkBinDirectory()
+ {
+ var gdkRoot = CreateGdkRoot("gdk", "MakePkg.exe");
+ var expected = Path.Combine(gdkRoot, "bin", "MakePkg.exe");
+
+ var resolver = CreateResolver(Succeeds(expected), FakeLocator(gdkRoot));
+
+ var tool = resolver.Resolve();
+
+ Assert.IsNotNull(tool);
+ Assert.AreEqual(expected, tool.ExecutablePath);
+ Assert.IsFalse(tool.IsMakePkg2Fallback);
+ }
+
+ [TestMethod]
+ public void Resolve_FallsBackToMakePkg2FromTheGdkBinDirectory()
+ {
+ // Both binaries ship side by side in the GDK; the legacy MakePkg.exe fails the probe and
+ // makepkg2.exe answers it, which is exactly the shape of a current GDK install.
+ var gdkRoot = CreateGdkRoot("gdk", "MakePkg.exe", "makepkg2.exe");
+ var expected = Path.Combine(gdkRoot, "bin", "makepkg2.exe");
+
+ var resolver = CreateResolver(Succeeds(expected), FakeLocator(gdkRoot));
+
+ var tool = resolver.Resolve();
+
+ Assert.IsNotNull(tool);
+ Assert.AreEqual(expected, tool.ExecutablePath);
+ Assert.IsTrue(tool.IsMakePkg2Fallback);
+ }
+
+ [TestMethod]
+ public void Resolve_ReturnsNull_WhenNoGdkIsInstalledAndNothingIsOnPath()
+ {
+ var resolver = CreateResolver(_ => ToolProbeResult.Failed, FakeLocator());
+
+ Assert.IsNull(resolver.Resolve());
+ Assert.IsFalse(resolver.IsMsixvc2Supported());
+ }
+
+ [TestMethod]
+ public void Resolve_UsesTheInjectedToolPathResolverForDiscovery()
+ {
+ // Discovery is delegated, so a host that supplies its own IToolPathResolver controls which
+ // binaries are probed. This is the seam CHANGE 2's command line adapter inherits.
+ var stubPath = Path.Combine(_testRoot, "elsewhere", "MakePkg.exe");
+ Directory.CreateDirectory(Path.GetDirectoryName(stubPath));
+ File.WriteAllText(stubPath, string.Empty);
+
+ var pathResolver = new RecordingToolPathResolver(stubPath);
+ var resolver = new Msixvc2ToolResolver(null, new FakeProbeRunner(Succeeds(stubPath)), TimeSpan.FromSeconds(1), pathResolver);
+
+ var tool = resolver.Resolve();
+
+ Assert.IsNotNull(tool);
+ Assert.AreEqual(stubPath, tool.ExecutablePath);
+ CollectionAssert.Contains(pathResolver.RequestedFileNames, "MakePkg.exe");
+ }
+
+ [TestMethod]
+ public void Resolve_PrefersAnExplicitPathOverDiscovery()
+ {
+ // A non-null hint is authoritative and must suppress discovery entirely, which is how the
+ // desktop app avoids searching twice for a tool it has already located.
+ var explicitPath = Path.Combine(_testRoot, "explicit", "makepkg2.exe");
+ Directory.CreateDirectory(Path.GetDirectoryName(explicitPath));
+ File.WriteAllText(explicitPath, string.Empty);
+
+ var pathResolver = new RecordingToolPathResolver(null);
+ var resolver = new Msixvc2ToolResolver(null, new FakeProbeRunner(Succeeds(explicitPath)), TimeSpan.FromSeconds(1), pathResolver);
+
+ var tool = resolver.Resolve(string.Empty, explicitPath);
+
+ Assert.IsNotNull(tool);
+ Assert.AreEqual(explicitPath, tool.ExecutablePath);
+ Assert.IsTrue(tool.IsMakePkg2Fallback);
+ Assert.AreEqual(0, pathResolver.RequestedFileNames.Count, "An explicit path must not trigger discovery.");
+ }
+
+ [TestMethod]
+ public void Resolver_DoesNotReferenceTheInternalNuGetPackage()
+ {
+ // The makepkg2 NuGet feed is internal only, so nothing may point a customer at it.
+ var assembly = typeof(Msixvc2ToolResolver).Assembly;
+
+ var offendingConstants = assembly
+ .GetTypes()
+ .Where(type => type.Namespace == "PackageUploader.ClientApi.Tools")
+ .SelectMany(type => type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
+ .Where(field => field.IsLiteral && field.FieldType == typeof(string))
+ .Select(field => (string)field.GetRawConstantValue())
+ .Where(value => value != null &&
+ value.Contains("packaging.tools", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ CollectionAssert.AreEqual(new List(), offendingConstants);
+ }
+
+ private static Msixvc2ToolResolver CreateResolver(Func probe, IGdkRootLocator locator) =>
+ new(null, new FakeProbeRunner(probe), TimeSpan.FromSeconds(1), new ToolPathResolver(locator));
+
+ private static Func Succeeds(string supportedPath) =>
+ executablePath => string.Equals(executablePath, supportedPath, StringComparison.OrdinalIgnoreCase)
+ ? new ToolProbeResult(true, 0)
+ : new ToolProbeResult(true, 2);
+
+ private static IGdkRootLocator FakeLocator(params string[] roots) => new StubGdkRootLocator(roots);
+
+ private string CreateGdkRoot(string name, params string[] toolFileNames)
+ {
+ var root = Path.Combine(_testRoot, name);
+ var bin = Path.Combine(root, "bin");
+ Directory.CreateDirectory(bin);
+
+ foreach (var fileName in toolFileNames)
+ {
+ File.WriteAllText(Path.Combine(bin, fileName), string.Empty);
+ }
+
+ return root;
+ }
+
+ private sealed class StubGdkRootLocator : IGdkRootLocator
+ {
+ private readonly IReadOnlyList _roots;
+
+ public StubGdkRootLocator(IReadOnlyList roots) => _roots = roots;
+
+ public IReadOnlyList GetGdkRoots() => _roots;
+ }
+
+ ///
+ /// Returns a fixed path for any request and records what was asked for, so a test can assert
+ /// whether discovery ran at all.
+ ///
+ private sealed class RecordingToolPathResolver : IToolPathResolver
+ {
+ private readonly string _result;
+
+ public RecordingToolPathResolver(string result) => _result = result;
+
+ public List RequestedFileNames { get; } = new();
+
+ public string Find(string fileName)
+ {
+ RequestedFileNames.Add(fileName);
+ return _result;
+ }
+ }
+
+ private sealed class FakeProbeRunner : IToolProbeRunner
+ {
+ private readonly Func _probe;
+
+ public FakeProbeRunner(Func probe) => _probe = probe;
+
+ public ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout)
+ {
+ Assert.AreEqual("supports uploadsource", arguments);
+ return _probe(executablePath);
+ }
+ }
+}
diff --git a/src/PackageUploader.ClientApi.Test/ToolPathResolverTest.cs b/src/PackageUploader.ClientApi.Test/ToolPathResolverTest.cs
new file mode 100644
index 00000000..4127757a
--- /dev/null
+++ b/src/PackageUploader.ClientApi.Test/ToolPathResolverTest.cs
@@ -0,0 +1,364 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using PackageUploader.ClientApi.Tools;
+
+namespace PackageUploader.ClientApi.Test;
+
+///
+/// Covers the shared tool discovery used by every host: application directory, current directory,
+/// GDK bin, then PATH.
+///
+///
+/// These tests never require an installed GDK: the GDK root lookup is seamed, and PATH is replaced
+/// with a controlled directory for the duration of each test.
+///
+[TestClass]
+public class ToolPathResolverTest
+{
+ private const string ToolFileName = "MakePkg.exe";
+
+ private string _testRoot;
+ private string _originalPath;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _testRoot = Path.Combine(Path.GetTempPath(), "ToolPathTest_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_testRoot);
+
+ // Discovery consults the app directory and the current directory before the GDK. A stray real
+ // tool in either would silently pre-empt what these tests are asserting, so refuse to run
+ // rather than produce a misleading pass or failure.
+ foreach (var directory in new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() })
+ {
+ foreach (var fileName in new[] { "MakePkg.exe", "makepkg2.exe" })
+ {
+ if (File.Exists(Path.Combine(directory, fileName)))
+ {
+ Assert.Inconclusive($"{fileName} is present in {directory}, which pre-empts GDK discovery.");
+ }
+ }
+ }
+
+ _originalPath = Environment.GetEnvironmentVariable("PATH");
+ Environment.SetEnvironmentVariable("PATH", Path.Combine(_testRoot, "empty-path"));
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ Environment.SetEnvironmentVariable("PATH", _originalPath);
+
+ try
+ {
+ Directory.Delete(_testRoot, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Temp cleanup is best effort.
+ }
+ }
+
+ [TestMethod]
+ public void Find_ReturnsTheToolFromTheGdkBinDirectory()
+ {
+ var gdkRoot = CreateGdkRoot("gdk", ToolFileName);
+
+ var found = CreateResolver(gdkRoot).Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(gdkRoot, "bin", ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_PrefersTheGdkOverPath()
+ {
+ // An explicitly installed GDK outranks whatever happens to be on PATH. The UI resolved in this
+ // order before discovery was shared, and the command line must agree or the two hosts could
+ // run different binaries on the same machine.
+ var gdkRoot = CreateGdkRoot("gdk", ToolFileName);
+ Environment.SetEnvironmentVariable("PATH", CreateDirectoryWith("on-path", ToolFileName));
+
+ var found = CreateResolver(gdkRoot).Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(gdkRoot, "bin", ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_FallsThroughToPath_WhenTheGdkBinDirectoryDoesNotHaveTheTool()
+ {
+ // A GDK root that exists but predates the tool must not stop the search.
+ var gdkRoot = CreateGdkRoot("gdk");
+ var pathDirectory = CreateDirectoryWith("on-path", ToolFileName);
+ Environment.SetEnvironmentVariable("PATH", pathDirectory);
+
+ var found = CreateResolver(gdkRoot).Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_SearchesEveryGdkRootInOrder()
+ {
+ var firstRoot = CreateGdkRoot("gdk-one");
+ var secondRoot = CreateGdkRoot("gdk-two", ToolFileName);
+
+ var found = CreateResolver(firstRoot, secondRoot).Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(secondRoot, "bin", ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_ReturnsNull_WhenNoGdkIsInstalledAndNothingIsOnPath()
+ {
+ Assert.IsNull(CreateResolver().Find(ToolFileName));
+ }
+
+ [TestMethod]
+ public void Find_ReturnsNull_WhenTheGdkRootDoesNotExistOnDisk()
+ {
+ var found = CreateResolver(Path.Combine(_testRoot, "not-installed")).Find(ToolFileName);
+
+ Assert.IsNull(found);
+ }
+
+ [TestMethod]
+ public void Find_ReturnsNull_ForABlankFileName()
+ {
+ var resolver = CreateResolver();
+
+ Assert.IsNull(resolver.Find(null));
+ Assert.IsNull(resolver.Find(string.Empty));
+ Assert.IsNull(resolver.Find(" "));
+ }
+
+ [TestMethod]
+ public void Find_DoesNotThrow_WhenPathIsMalformed()
+ {
+ // A quoted entry, an empty entry, and characters that are invalid in a path must all be
+ // skipped rather than escaping to the caller.
+ Environment.SetEnvironmentVariable("PATH", "\"C:\\quoted\";;C:\\bad|entry\0;");
+
+ Assert.IsNull(CreateResolver().Find(ToolFileName));
+ }
+
+ [TestMethod]
+ public void Find_StillSearchesPath_WhenTheGdkLookupFails()
+ {
+ // A failing GDK lookup means "no GDK candidates", not "stop searching". Asserting only that
+ // Find does not throw would pass just as well if the search had been abandoned, so this
+ // asserts the stage after the failure still runs.
+ var pathDirectory = CreateDirectoryWith("on-path", ToolFileName);
+ Environment.SetEnvironmentVariable("PATH", pathDirectory);
+
+ var found = new ToolPathResolver(new ThrowingGdkRootLocator()).Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_ReturnsNull_WhenTheGdkLookupFailsAndNothingIsOnPath()
+ {
+ Assert.IsNull(new ToolPathResolver(new ThrowingGdkRootLocator()).Find(ToolFileName));
+ }
+
+ [TestMethod]
+ public void Find_StillSearchesLaterGdkRoots_WhenAnEarlierRootIsUnusable()
+ {
+ // A single malformed root must cost only its own candidate.
+ var goodRoot = CreateGdkRoot("gdk-two", ToolFileName);
+
+ var found = CreateResolver(null, goodRoot).Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(goodRoot, "bin", ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_StillSearchesPath_WhenTheGdkRootsAreUnusable()
+ {
+ var pathDirectory = CreateDirectoryWith("on-path", ToolFileName);
+ Environment.SetEnvironmentVariable("PATH", pathDirectory);
+
+ var found = CreateResolver(new string[] { null }).Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_StillSearchesLaterPathEntries_WhenAnEarlierEntryIsUnusable()
+ {
+ // A quoted entry, an empty entry, and invalid path characters must each cost only their own
+ // candidate. No embedded NUL here: Windows truncates the variable at one, which would drop the
+ // good entry and make this pass for the wrong reason.
+ var pathDirectory = CreateDirectoryWith("on-path", ToolFileName);
+ var malformed = string.Join(
+ Path.PathSeparator.ToString(),
+ "\"C:\\quoted\"",
+ string.Empty,
+ "C:\\bad|entry",
+ " ",
+ pathDirectory);
+ Environment.SetEnvironmentVariable("PATH", malformed);
+
+ var found = CreateResolver().Find(ToolFileName);
+
+ Assert.AreEqual(Path.Combine(pathDirectory, ToolFileName), found);
+ }
+
+ [TestMethod]
+ public void Find_ReturnsNull_WhenTheGdkLocatorReturnsNull()
+ {
+ Assert.IsNull(new ToolPathResolver(new StubGdkRootLocator(null)).Find(ToolFileName));
+ }
+
+ [TestMethod]
+ public void Find_PrefersTheCurrentDirectoryOverTheGdk()
+ {
+ // By design: copying a tool into the working directory is how a developer pins a hotfixed or
+ // otherwise specific version in place of the one their installed GDK ships. A uniquely named
+ // file is used so this never collides with a real tool.
+ var toolName = "PinnedTool_" + Guid.NewGuid().ToString("N") + ".exe";
+ var currentDirectoryTool = Path.Combine(Directory.GetCurrentDirectory(), toolName);
+ var gdkRoot = CreateGdkRoot("gdk", toolName);
+
+ File.WriteAllText(currentDirectoryTool, string.Empty);
+
+ try
+ {
+ var found = CreateResolver(gdkRoot).Find(toolName);
+
+ Assert.AreEqual(currentDirectoryTool, found);
+ }
+ finally
+ {
+ File.Delete(currentDirectoryTool);
+ }
+ }
+
+ [TestMethod]
+ public void Find_UsesTheRealGdkLookup_WhenConstructedWithoutASeam()
+ {
+ // The parameterless constructor is what hosts use. It must work and must not throw whether or
+ // not this machine has a GDK, so only the absence of an exception is asserted.
+ var resolver = new ToolPathResolver();
+
+ resolver.Find("a-tool-that-does-not-exist.exe");
+ }
+
+ [TestMethod]
+ public void GdkRootLocator_PrefersTheEnvironmentVariableOverTheRegistry()
+ {
+ var locator = new GdkRootLocator(
+ _ => @"C:\from-env",
+ key => key == GdkRootLocator.GdkRegistryKey ? @"C:\from-registry" : null);
+
+ var roots = locator.GetGdkRoots();
+
+ Assert.AreEqual(@"C:\from-env", roots[0]);
+ CollectionAssert.Contains(roots.ToList(), @"C:\from-registry");
+ }
+
+ [TestMethod]
+ public void GdkRootLocator_FallsBackToTheRegistry_WhenTheEnvironmentVariableIsNotSet()
+ {
+ var locator = new GdkRootLocator(
+ _ => null,
+ key => key == GdkRootLocator.GdkRegistryKey ? @"C:\from-registry" : null);
+
+ var roots = locator.GetGdkRoots();
+
+ Assert.AreEqual(1, roots.Count);
+ Assert.AreEqual(@"C:\from-registry", roots[0]);
+ }
+
+ [TestMethod]
+ public void GdkRootLocator_FallsBackToTheWow6432NodeMirror()
+ {
+ var locator = new GdkRootLocator(
+ _ => null,
+ key => key == GdkRootLocator.GdkWow6432RegistryKey ? @"C:\from-wow6432" : null);
+
+ var roots = locator.GetGdkRoots();
+
+ Assert.AreEqual(1, roots.Count);
+ Assert.AreEqual(@"C:\from-wow6432", roots[0]);
+ }
+
+ [TestMethod]
+ public void GdkRootLocator_ReturnsEmpty_WhenNoSourceHasAGdk()
+ {
+ var locator = new GdkRootLocator(_ => null, _ => null);
+
+ Assert.AreEqual(0, locator.GetGdkRoots().Count);
+ }
+
+ [TestMethod]
+ public void GdkRootLocator_DoesNotThrow_WhenASourceFails()
+ {
+ // Access-denied or a malformed value must never escape into tool resolution.
+ var locator = new GdkRootLocator(
+ _ => throw new InvalidOperationException("environment blew up"),
+ _ => throw new UnauthorizedAccessException("registry blew up"));
+
+ Assert.AreEqual(0, locator.GetGdkRoots().Count);
+ }
+
+ [TestMethod]
+ public void GdkRootLocator_DefaultSourcesDoNotThrow()
+ {
+ // Exercises the real environment and registry readers. On a non-Windows host the registry
+ // read is skipped by the OperatingSystem.IsWindows() guard rather than throwing.
+ var roots = new GdkRootLocator().GetGdkRoots();
+
+ Assert.IsNotNull(roots);
+ }
+
+ private static ToolPathResolver CreateResolver(params string[] gdkRoots) =>
+ new(new StubGdkRootLocator(gdkRoots));
+
+ private string CreateGdkRoot(string name, params string[] toolFileNames)
+ {
+ var root = Path.Combine(_testRoot, name);
+ var bin = Path.Combine(root, "bin");
+ Directory.CreateDirectory(bin);
+
+ foreach (var fileName in toolFileNames)
+ {
+ File.WriteAllText(Path.Combine(bin, fileName), string.Empty);
+ }
+
+ return root;
+ }
+
+ private string CreateDirectoryWith(string name, params string[] fileNames)
+ {
+ var directory = Path.Combine(_testRoot, name);
+ Directory.CreateDirectory(directory);
+
+ foreach (var fileName in fileNames)
+ {
+ File.WriteAllText(Path.Combine(directory, fileName), string.Empty);
+ }
+
+ return directory;
+ }
+
+ private sealed class StubGdkRootLocator : IGdkRootLocator
+ {
+ private readonly IReadOnlyList _roots;
+
+ public StubGdkRootLocator(IReadOnlyList roots) => _roots = roots;
+
+ public IReadOnlyList GetGdkRoots() => _roots;
+ }
+
+ private sealed class ThrowingGdkRootLocator : IGdkRootLocator
+ {
+ public IReadOnlyList GetGdkRoots() => throw new UnauthorizedAccessException("locator blew up");
+ }
+}
diff --git a/src/PackageUploader.ClientApi/Tools/GdkRootLocator.cs b/src/PackageUploader.ClientApi/Tools/GdkRootLocator.cs
new file mode 100644
index 00000000..555c8e52
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/GdkRootLocator.cs
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Win32;
+
+namespace PackageUploader.ClientApi.Tools;
+
+///
+/// Supplies the installation roots of the Microsoft GDK, under which packaging tools live in bin.
+///
+///
+/// Seamed so can be tested without a GDK installed. This stays internal:
+/// only the path-resolution contract is public, not the environment and registry sources behind it.
+///
+internal interface IGdkRootLocator
+{
+ ///
+ /// Returns candidate GDK installation roots in priority order.
+ ///
+ ///
+ /// The roots, or an empty list when no GDK could be located. Never , never throws.
+ ///
+ IReadOnlyList GetGdkRoots();
+}
+
+///
+/// Locates the GDK via the GameDK environment variable, then the registry.
+///
+///
+/// The registry keys match the ones the WPF host has always used, so command line and UI discovery agree.
+/// Registry access is Windows-only and is guarded accordingly; on other platforms only the environment
+/// variable is consulted.
+///
+internal sealed class GdkRootLocator : IGdkRootLocator
+{
+ internal const string GdkEnvironmentVariableName = "GameDK";
+ internal const string GdkInstallPathValueName = "GDKInstallPath";
+ internal const string GdkRegistryKey = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\GDK\Installed Roots";
+ internal const string GdkWow6432RegistryKey = @"HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\GDK\Installed Roots";
+
+ private readonly Func _environmentVariableReader;
+ private readonly Func _registryValueReader;
+
+ public GdkRootLocator()
+ : this(null, null)
+ {
+ }
+
+ internal GdkRootLocator(Func environmentVariableReader, Func registryValueReader)
+ {
+ _environmentVariableReader = environmentVariableReader ?? Environment.GetEnvironmentVariable;
+ _registryValueReader = registryValueReader ?? ReadGdkInstallPath;
+ }
+
+ ///
+ public IReadOnlyList GetGdkRoots()
+ {
+ var roots = new List(3);
+
+ // The environment variable is the cheapest source and is set by the GDK installer.
+ AddRoot(roots, Read(_environmentVariableReader, GdkEnvironmentVariableName));
+
+ AddRoot(roots, Read(_registryValueReader, GdkRegistryKey));
+ AddRoot(roots, Read(_registryValueReader, GdkWow6432RegistryKey));
+
+ return roots;
+ }
+
+ private static string Read(Func reader, string key)
+ {
+ try
+ {
+ return reader(key);
+ }
+ catch (Exception)
+ {
+ // A denied or malformed source must never break tool resolution.
+ return null;
+ }
+ }
+
+ private static void AddRoot(List roots, string root)
+ {
+ if (!string.IsNullOrWhiteSpace(root) && !roots.Contains(root))
+ {
+ roots.Add(root);
+ }
+ }
+
+ private static string ReadGdkInstallPath(string registryKey)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return null;
+ }
+
+ return Registry.GetValue(registryKey, GdkInstallPathValueName, null) as string;
+ }
+}
diff --git a/src/PackageUploader.ClientApi/Tools/IMsixvc2ToolResolver.cs b/src/PackageUploader.ClientApi/Tools/IMsixvc2ToolResolver.cs
new file mode 100644
index 00000000..ce81f4ac
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/IMsixvc2ToolResolver.cs
@@ -0,0 +1,66 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace PackageUploader.ClientApi.Tools;
+
+///
+/// Determines which packaging tool, if any, can perform an MSIXVC2 upload.
+///
+///
+/// The GDK renamed Makepkg2.exe to MakePkg.exe; the new MakePkg.exe absorbs the
+/// makepkg2 capabilities. Resolution order is therefore:
+///
+/// - MakePkg.exe supports uploadsource (exit code 0 means supported). A legacy
+/// MakePkg.exe fails this, which is the discriminator.
+/// - The standalone makepkg2.exe, which the GDK ships alongside MakePkg.exe, probed
+/// the same way.
+/// - Otherwise MSIXVC2 is unavailable.
+///
+/// Implementations must be thread-safe and must not throw for missing or broken tools.
+///
+/// Contract: every member reports "no MSIXVC2-capable tool" by returning
+/// (or ), never by throwing. Callers branch on the return value and do not
+/// need to guard these calls with try/catch.
+///
+///
+public interface IMsixvc2ToolResolver
+{
+ ///
+ /// Resolves the MSIXVC2-capable tool using self-discovery (application directory, current directory,
+ /// the installed GDK, and PATH).
+ ///
+ ///
+ /// The resolved tool, or when no MSIXVC2-capable tool is available.
+ /// Never throws.
+ ///
+ Msixvc2Tool Resolve();
+
+ ///
+ /// Resolves the MSIXVC2-capable tool, preferring caller-supplied paths before self-discovery.
+ ///
+ /// An already-resolved MakePkg.exe path, or to self-discover.
+ /// An already-resolved makepkg2.exe path, or to self-discover.
+ ///
+ /// The resolved tool, or when no MSIXVC2-capable tool is available.
+ /// Never throws.
+ ///
+ Msixvc2Tool Resolve(string makePkgPath, string makePkg2Path);
+
+ ///
+ /// Convenience wrapper over .
+ ///
+ ///
+ /// when an MSIXVC2-capable tool is available; otherwise .
+ /// Never throws.
+ ///
+ bool IsMsixvc2Supported();
+
+ ///
+ /// Convenience wrapper over .
+ ///
+ ///
+ /// when an MSIXVC2-capable tool is available; otherwise .
+ /// Never throws.
+ ///
+ bool IsMsixvc2Supported(string makePkgPath, string makePkg2Path);
+}
diff --git a/src/PackageUploader.ClientApi/Tools/IToolPathResolver.cs b/src/PackageUploader.ClientApi/Tools/IToolPathResolver.cs
new file mode 100644
index 00000000..21655889
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/IToolPathResolver.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace PackageUploader.ClientApi.Tools;
+
+///
+/// Locates a tool that ships alongside the application or with the Microsoft GDK.
+///
+///
+/// This is the single discovery implementation shared by every host, so the command line and the
+/// desktop app can never disagree about which copy of a tool they will run.
+///
+/// Contract: implementations report "not found" by returning , never by
+/// throwing. An unreadable directory, a denied registry key, or a malformed PATH entry is skipped
+/// and the search continues.
+///
+///
+public interface IToolPathResolver
+{
+ ///
+ /// Searches for in the application directory, the current directory,
+ /// the bin directory of any installed GDK, and finally each directory on PATH, in that order.
+ ///
+ ///
+ /// The application and current directories are searched before the GDK by design: copying a tool
+ /// next to the application or into the working directory is the supported way for a developer to
+ /// pin a hotfixed or otherwise specific version in place of the one their installed GDK ships.
+ ///
+ ///
+ /// A bare file name such as MakePkg.exe, not a path. The search is by exact name.
+ ///
+ ///
+ /// The full path to the first match, or when the file was not found in any
+ /// searched location. Never throws.
+ ///
+ string Find(string fileName);
+}
diff --git a/src/PackageUploader.ClientApi/Tools/IToolProbeRunner.cs b/src/PackageUploader.ClientApi/Tools/IToolProbeRunner.cs
new file mode 100644
index 00000000..b0eb74f1
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/IToolProbeRunner.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Diagnostics;
+
+namespace PackageUploader.ClientApi.Tools;
+
+///
+/// Outcome of a capability probe.
+///
+/// False when the process could not be started or did not exit within the timeout.
+/// Process exit code when is true; otherwise undefined.
+public readonly record struct ToolProbeResult(bool Completed, int ExitCode)
+{
+ public static ToolProbeResult Failed => new(false, -1);
+
+ public bool Succeeded => Completed && ExitCode == 0;
+}
+
+///
+/// Runs a short-lived capability probe. Abstracted so the resolver can be unit tested without spawning processes.
+///
+public interface IToolProbeRunner
+{
+ ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout);
+}
+
+///
+/// Default that launches the tool with no window and no shell execution.
+/// Never throws: process start failures are reported as .
+///
+public sealed class ProcessToolProbeRunner : IToolProbeRunner
+{
+ public ToolProbeResult Run(string executablePath, string arguments, TimeSpan timeout)
+ {
+ try
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = executablePath,
+ Arguments = arguments,
+ UseShellExecute = false,
+ RedirectStandardOutput = false,
+ RedirectStandardError = false,
+ CreateNoWindow = true,
+ }
+ };
+
+ process.Start();
+
+ if (process.WaitForExit((int)timeout.TotalMilliseconds))
+ {
+ return new ToolProbeResult(true, process.ExitCode);
+ }
+
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch
+ {
+ // Best effort.
+ }
+
+ return ToolProbeResult.Failed;
+ }
+ catch
+ {
+ return ToolProbeResult.Failed;
+ }
+ }
+}
diff --git a/src/PackageUploader.ClientApi/Tools/Msixvc2Tool.cs b/src/PackageUploader.ClientApi/Tools/Msixvc2Tool.cs
new file mode 100644
index 00000000..36e832ac
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/Msixvc2Tool.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace PackageUploader.ClientApi.Tools;
+
+///
+/// A packaging tool that has been verified to support MSIXVC2 upload.
+///
+/// Full path to the executable to invoke.
+///
+/// True when the resolved tool is the standalone makepkg2.exe rather than the current GDK's
+/// MakePkg.exe.
+///
+public sealed record Msixvc2Tool(string ExecutablePath, bool IsMakePkg2Fallback);
diff --git a/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolver.cs b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolver.cs
new file mode 100644
index 00000000..3014bfcd
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolver.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.IO;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace PackageUploader.ClientApi.Tools;
+
+///
+/// Default .
+///
+///
+/// Stateless and therefore thread-safe. By design nothing is cached: the tool is re-probed on every
+/// call so in-place binary updates (for example a GDK upgrade while the app is running) are picked up.
+///
+public sealed class Msixvc2ToolResolver : IMsixvc2ToolResolver
+{
+ internal const string MakePkgFileName = "MakePkg.exe";
+ internal const string MakePkg2FileName = "makepkg2.exe";
+ internal const string SupportsUploadSourceArguments = "supports uploadsource";
+
+ private static readonly TimeSpan DefaultProbeTimeout = TimeSpan.FromSeconds(5);
+
+ private readonly IToolProbeRunner _probeRunner;
+ private readonly ILogger _logger;
+ private readonly TimeSpan _probeTimeout;
+ private readonly IToolPathResolver _toolPathResolver;
+
+ public Msixvc2ToolResolver()
+ : this(null, null, null)
+ {
+ }
+
+ public Msixvc2ToolResolver(ILogger logger)
+ : this(logger, null, null)
+ {
+ }
+
+ public Msixvc2ToolResolver(ILogger logger, IToolProbeRunner probeRunner, TimeSpan? probeTimeout)
+ : this(logger, probeRunner, probeTimeout, null)
+ {
+ }
+
+ ///
+ /// Discovery is seamed separately from probing so tests can drive either layer in isolation.
+ ///
+ internal Msixvc2ToolResolver(
+ ILogger logger,
+ IToolProbeRunner probeRunner,
+ TimeSpan? probeTimeout,
+ IToolPathResolver toolPathResolver)
+ {
+ _logger = logger ?? (ILogger)NullLogger.Instance;
+ _probeRunner = probeRunner ?? new ProcessToolProbeRunner();
+ _probeTimeout = probeTimeout is { } timeout && timeout > TimeSpan.Zero ? timeout : DefaultProbeTimeout;
+ _toolPathResolver = toolPathResolver ?? new ToolPathResolver();
+ }
+
+ ///
+ public Msixvc2Tool Resolve() => Resolve(null, null);
+
+ ///
+ ///
+ /// A non-null argument (including an empty string) is treated as authoritative and disables
+ /// self-discovery for that tool, so hosts that already resolve paths get deterministic behavior.
+ ///
+ ///
+ /// The resolved tool, or when no MSIXVC2-capable tool is available.
+ /// Never throws.
+ ///
+ public Msixvc2Tool Resolve(string makePkgPath, string makePkg2Path)
+ {
+ // 1. The current GDK's MakePkg.exe absorbed the makepkg2 capabilities.
+ string makePkgCandidate = makePkgPath is null ? Discover(MakePkgFileName) : NormalizeCandidate(makePkgPath);
+ if (makePkgCandidate is not null && ProbeSupportsUploadSource(makePkgCandidate, MakePkgFileName))
+ {
+ return new Msixvc2Tool(makePkgCandidate, IsMakePkg2Fallback: false);
+ }
+
+ // 2. Fall back to the standalone makepkg2.exe, which the GDK also ships in its bin directory.
+ string makePkg2Candidate = makePkg2Path is null ? Discover(MakePkg2FileName) : NormalizeCandidate(makePkg2Path);
+ if (makePkg2Candidate is not null && ProbeSupportsUploadSource(makePkg2Candidate, MakePkg2FileName))
+ {
+ return new Msixvc2Tool(makePkg2Candidate, IsMakePkg2Fallback: true);
+ }
+
+ _logger.LogInformation("No MSIXVC2-capable packaging tool was found. MSIXVC2 upload is unavailable.");
+ return null;
+ }
+
+ ///
+ public bool IsMsixvc2Supported() => Resolve() is not null;
+
+ ///
+ public bool IsMsixvc2Supported(string makePkgPath, string makePkg2Path) => Resolve(makePkgPath, makePkg2Path) is not null;
+
+ ///
+ /// Accepts a caller-supplied path only when it points at an existing file.
+ ///
+ /// The path, or when it is blank or does not exist.
+ private static string NormalizeCandidate(string path) =>
+ !string.IsNullOrWhiteSpace(path) && File.Exists(path) ? path : null;
+
+ private bool ProbeSupportsUploadSource(string executablePath, string toolDisplayName)
+ {
+ ToolProbeResult result = _probeRunner.Run(executablePath, SupportsUploadSourceArguments, _probeTimeout);
+
+ if (!result.Completed)
+ {
+ _logger.LogInformation(
+ "{Tool} uploadsource probe did not complete (missing tool, launch failure, or timeout after {TimeoutSeconds}s) for {Path}.",
+ toolDisplayName, _probeTimeout.TotalSeconds, executablePath);
+ return false;
+ }
+
+ _logger.LogInformation("{Tool} uploadsource probe: {Result} (exit code {ExitCode}) for {Path}.",
+ toolDisplayName, result.Succeeded ? "supported" : "not supported", result.ExitCode, executablePath);
+
+ return result.Succeeded;
+ }
+
+ ///
+ /// Looks for using the shared tool discovery.
+ ///
+ ///
+ /// Discovery lives in so the desktop app and the command line
+ /// search identically. The GDK ships both MakePkg.exe and makepkg2.exe in its bin
+ /// directory, so one search serves both.
+ ///
+ /// The full path to the tool, or when it was not found. Never throws.
+ private string Discover(string fileName) => _toolPathResolver.Find(fileName);
+}
diff --git a/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolverExtensions.cs b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolverExtensions.cs
new file mode 100644
index 00000000..04f0b660
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/Msixvc2ToolResolverExtensions.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace PackageUploader.ClientApi.Tools;
+
+public static class Msixvc2ToolResolverExtensions
+{
+ ///
+ /// Registers the shared tool path resolver and the MSIXVC2 tool resolver. Safe to call from any
+ /// host; hosts that do not use DI can simply new Msixvc2ToolResolver() instead.
+ ///
+ public static IServiceCollection AddMsixvc2ToolResolver(this IServiceCollection services)
+ {
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton(provider => new Msixvc2ToolResolver(
+ provider.GetService>(),
+ provider.GetRequiredService(),
+ probeTimeout: null,
+ provider.GetRequiredService()));
+
+ return services;
+ }
+}
diff --git a/src/PackageUploader.ClientApi/Tools/ToolPathResolver.cs b/src/PackageUploader.ClientApi/Tools/ToolPathResolver.cs
new file mode 100644
index 00000000..5c74888f
--- /dev/null
+++ b/src/PackageUploader.ClientApi/Tools/ToolPathResolver.cs
@@ -0,0 +1,180 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace PackageUploader.ClientApi.Tools;
+
+///
+/// Default .
+///
+///
+/// Stateless and therefore thread-safe. Nothing is cached, so a tool that appears or is replaced while
+/// the process is running is picked up by the next call.
+///
+public sealed class ToolPathResolver : IToolPathResolver
+{
+ internal const string GdkToolSubdirectory = "bin";
+
+ private readonly IGdkRootLocator _gdkRootLocator;
+
+ public ToolPathResolver()
+ : this(null)
+ {
+ }
+
+ ///
+ /// The GDK lookup is seamed rather than exposed: tests substitute it so they never require an
+ /// installed GDK, but the environment and registry sources stay an implementation detail.
+ ///
+ internal ToolPathResolver(IGdkRootLocator gdkRootLocator)
+ {
+ _gdkRootLocator = gdkRootLocator ?? new GdkRootLocator();
+ }
+
+ ///
+ public string Find(string fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return null;
+ }
+
+ // Every stage is guarded on its own, so a source that fails costs only its own candidates and
+ // never the stages after it: an unreadable working directory must still leave the GDK and PATH
+ // searchable, and a denied registry key must still leave PATH searchable.
+ return FindInApplicationDirectory(fileName)
+ ?? FindInCurrentDirectory(fileName)
+ ?? FindInGdk(fileName)
+ ?? FindOnPath(fileName);
+ }
+
+ ///
+ /// The application directory and the current directory deliberately outrank the installed GDK.
+ /// Dropping a tool next to the application or into the working directory is the supported way to
+ /// pin a hotfix, or a specific version that differs from the one the installed GDK ships, without
+ /// having to change the GDK installation itself.
+ ///
+ private static string FindInApplicationDirectory(string fileName)
+ {
+ try
+ {
+ return MatchIn(AppContext.BaseDirectory, fileName);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ ///
+ private static string FindInCurrentDirectory(string fileName)
+ {
+ try
+ {
+ return MatchIn(Directory.GetCurrentDirectory(), fileName);
+ }
+ catch (Exception)
+ {
+ // The working directory can be denied or deleted out from under the process.
+ return null;
+ }
+ }
+
+ ///
+ /// An explicitly installed GDK outranks whatever happens to be on PATH, so a machine with several
+ /// copies of a tool resolves predictably.
+ ///
+ private string FindInGdk(string fileName)
+ {
+ IReadOnlyList gdkRoots;
+
+ try
+ {
+ gdkRoots = _gdkRootLocator.GetGdkRoots();
+ }
+ catch (Exception)
+ {
+ // A failed GDK lookup means "no GDK candidates", not "stop searching".
+ return null;
+ }
+
+ if (gdkRoots is null)
+ {
+ return null;
+ }
+
+ foreach (string gdkRoot in gdkRoots)
+ {
+ string match = MatchIn(gdkRoot, GdkToolSubdirectory, fileName);
+ if (match is not null)
+ {
+ return match;
+ }
+ }
+
+ return null;
+ }
+
+ private static string FindOnPath(string fileName)
+ {
+ string[] directories;
+
+ try
+ {
+ string pathValue = Environment.GetEnvironmentVariable("PATH");
+ if (string.IsNullOrEmpty(pathValue))
+ {
+ return null;
+ }
+
+ directories = pathValue.Split(Path.PathSeparator);
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ foreach (string directory in directories)
+ {
+ string match = MatchIn(directory, fileName);
+ if (match is not null)
+ {
+ return match;
+ }
+ }
+
+ return null;
+ }
+
+ private static string MatchIn(string directory, string fileName) =>
+ MatchIn(directory, null, fileName);
+
+ ///
+ /// Tests a single candidate. Returns the full path when it exists, otherwise .
+ ///
+ private static string MatchIn(string directory, string subdirectory, string fileName)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(directory))
+ {
+ return null;
+ }
+
+ string candidate = subdirectory is null
+ ? Path.Combine(directory, fileName)
+ : Path.Combine(directory, subdirectory, fileName);
+
+ return File.Exists(candidate) ? candidate : null;
+ }
+ catch (Exception)
+ {
+ // One unusable candidate - invalid characters, an unreachable share, denied access - must
+ // not stop the candidates after it from being tried.
+ return null;
+ }
+ }
+}
diff --git a/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs b/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs
index 40a44aa6..9b5a46c1 100644
--- a/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs
+++ b/src/PackageUploader.UI.Test/ViewModel/ExtractIdInformationFromValidatorLogTest.cs
@@ -3,6 +3,7 @@
using Moq;
using PackageUploader.ClientApi;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
using PackageUploader.UI.ViewModel;
@@ -28,7 +29,7 @@ public TestableValidatorLogViewModel(
UploadingProgressPercentageProvider uploadingProgressPercentageProvider,
ErrorModelProvider errorModelProvider,
string xmlContent)
- : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider())
+ : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider(), new Msixvc2ToolResolver())
{
_xmlContent = xmlContent;
TestSubValFilePath = Path.GetTempFileName();
diff --git a/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs b/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs
index abb3587f..d1f937bb 100644
--- a/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs
+++ b/src/PackageUploader.UI.Test/ViewModel/GenerateUploaderConfigTest.cs
@@ -3,6 +3,7 @@
using Moq;
using PackageUploader.ClientApi;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.ClientApi.Client.Ingestion.Models;
using PackageUploader.UI.Model;
using PackageUploader.UI.Providers;
@@ -30,7 +31,7 @@ public TestableUploaderConfigViewModel(
IWindowService windowService,
UploadingProgressPercentageProvider uploadingProgressPercentageProvider,
ErrorModelProvider errorModelProvider)
- : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider())
+ : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider(), new Msixvc2ToolResolver())
{
}
diff --git a/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs
index 0d50166f..20ea1b89 100644
--- a/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs
+++ b/src/PackageUploader.UI.Test/ViewModel/MainPageViewModelTest.cs
@@ -2,11 +2,17 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using PackageUploader.ClientApi.Client.Ingestion.TokenProvider.Models;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
using PackageUploader.UI.View;
using PackageUploader.UI.ViewModel;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
namespace PackageUploader.UI.Test.ViewModel;
@@ -18,9 +24,28 @@ public class MainPageViewModelTest
private Mock _authenticationService;
private Mock _windowService;
private Mock> _logger;
+ private readonly List _tempDirectories = new();
private MainPageViewModel _mainPageViewModel;
+ [TestCleanup]
+ public void Cleanup()
+ {
+ foreach (var directory in _tempDirectories)
+ {
+ try
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Temp cleanup is best effort.
+ }
+ }
+
+ _tempDirectories.Clear();
+ }
+
[TestInitialize]
public void Initialize()
{
@@ -42,10 +67,93 @@ public void Initialize()
_userLoggedInProvider,
_authenticationService.Object,
_windowService.Object,
+ new Msixvc2ToolResolver(),
+ new ToolPathResolver(),
_logger.Object
);
}
+ [TestMethod]
+ public void TestToolPathsComeFromTheSharedResolver()
+ {
+ // Tool discovery is shared with the command line so both hosts resolve the same binaries.
+ // Injecting a stub proves the view model asks for each tool by name and stores what it gets,
+ // rather than searching for them itself.
+ var pathConfiguration = new Mock();
+ var pathResolver = new StubToolPathResolver
+ {
+ Results =
+ {
+ ["MakePkg.exe"] = CreateTempFile("MakePkg.exe"),
+ ["SubmissionValidator.dll"] = CreateTempFile("SubmissionValidator.dll"),
+ ["makepkg2.exe"] = CreateTempFile("makepkg2.exe"),
+ }
+ };
+
+ var viewModel = new MainPageViewModel(
+ pathConfiguration.Object,
+ new UserLoggedInProvider(),
+ _authenticationService.Object,
+ _windowService.Object,
+ new Msixvc2ToolResolver(),
+ pathResolver,
+ _logger.Object);
+
+ CollectionAssert.AreEquivalent(
+ new[] { "MakePkg.exe", "SubmissionValidator.dll", "makepkg2.exe" },
+ pathResolver.RequestedFileNames.Distinct().ToArray());
+
+ Assert.AreEqual(pathResolver.Results["MakePkg.exe"], pathConfiguration.Object.MakePkgPath);
+ Assert.AreEqual(pathResolver.Results["SubmissionValidator.dll"], pathConfiguration.Object.BaseSubValPath);
+ Assert.AreEqual(pathResolver.Results["makepkg2.exe"], pathConfiguration.Object.MakePkg2Path);
+ Assert.IsTrue(viewModel.IsMakePkgEnabled);
+ }
+
+ [TestMethod]
+ public void TestMissingToolsLeaveMakePkgDisabled()
+ {
+ // The shared resolver reports a miss as null. The view model must treat that as "not found"
+ // rather than storing it or throwing.
+ var pathConfiguration = new Mock();
+ var pathResolver = new StubToolPathResolver();
+
+ var viewModel = new MainPageViewModel(
+ pathConfiguration.Object,
+ new UserLoggedInProvider(),
+ _authenticationService.Object,
+ _windowService.Object,
+ new Msixvc2ToolResolver(),
+ pathResolver,
+ _logger.Object);
+
+ Assert.IsFalse(viewModel.IsMakePkgEnabled);
+ Assert.IsFalse(string.IsNullOrEmpty(viewModel.MakePkgUnavailableErrorMessage));
+ }
+
+ private string CreateTempFile(string fileName)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "MainPageVmTest_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ _tempDirectories.Add(directory);
+
+ var path = Path.Combine(directory, fileName);
+ File.WriteAllText(path, string.Empty);
+ return path;
+ }
+
+ private sealed class StubToolPathResolver : IToolPathResolver
+ {
+ public Dictionary Results { get; } = new();
+
+ public List RequestedFileNames { get; } = new();
+
+ public string Find(string fileName)
+ {
+ RequestedFileNames.Add(fileName);
+ return Results.TryGetValue(fileName, out var path) ? path : null;
+ }
+ }
+
[TestMethod]
public void TestCaptureUserLoggedIn()
{
@@ -129,4 +237,96 @@ public void TestGetTenantsCommand()
_authenticationService.VerifySet(x => x.Tenant = tenant2, Times.Once);
}
+ #region MSIXVC2 capability probe
+
+ private MainPageViewModel CreateViewModel(IMsixvc2ToolResolver resolver) =>
+ new(
+ _pathConfigurationService.Object,
+ _userLoggedInProvider,
+ _authenticationService.Object,
+ _windowService.Object,
+ resolver,
+ new ToolPathResolver(),
+ _logger.Object);
+
+ [TestMethod]
+ public async Task Msixvc2Probe_EnablesMsixvc2_WhenAToolIsResolved()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns(new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false));
+
+ var viewModel = CreateViewModel(resolver.Object);
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsTrue(viewModel.IsMsixvc2Enabled);
+ Assert.AreEqual(string.Empty, viewModel.Msixvc2UnavailableErrorMessage);
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_DisablesMsixvc2AndSetsMessage_WhenNoToolIsResolved()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns((Msixvc2Tool)null);
+
+ var viewModel = CreateViewModel(resolver.Object);
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsFalse(viewModel.IsMsixvc2Enabled);
+ Assert.AreEqual(
+ PackageUploader.UI.Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg,
+ viewModel.Msixvc2UnavailableErrorMessage);
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_DoesNotBlockTheConstructor()
+ {
+ // The probe launches a child process and can block for up to the probe timeout (twice, if
+ // MakePkg.exe fails and we fall back to makepkg2.exe). It must never run inline on the UI
+ // thread during construction.
+ var probeStarted = new ManualResetEventSlim(false);
+ var releaseProbe = new ManualResetEventSlim(false);
+
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns(() =>
+ {
+ probeStarted.Set();
+ releaseProbe.Wait(TimeSpan.FromSeconds(30));
+ return new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false);
+ });
+
+ var stopwatch = Stopwatch.StartNew();
+ var viewModel = CreateViewModel(resolver.Object);
+ stopwatch.Stop();
+
+ Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10)), "The probe should have been started in the background.");
+ Assert.IsTrue(
+ stopwatch.Elapsed < TimeSpan.FromSeconds(5),
+ $"The constructor blocked for {stopwatch.Elapsed.TotalSeconds:F1}s waiting on the capability probe.");
+
+ // The property keeps its safe default until the probe reports back.
+ Assert.IsFalse(viewModel.IsMsixvc2Enabled);
+
+ releaseProbe.Set();
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsTrue(viewModel.IsMsixvc2Enabled);
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_DisablesMsixvc2_WhenTheResolverThrows()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Throws(new InvalidOperationException("boom"));
+
+ var viewModel = CreateViewModel(resolver.Object);
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsFalse(viewModel.IsMsixvc2Enabled);
+ }
+
+ #endregion
}
diff --git a/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs
index 4694cd26..120b3fb5 100644
--- a/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs
+++ b/src/PackageUploader.UI.Test/ViewModel/Msixvc2UploadViewModelTest.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Logging;
using Moq;
using PackageUploader.ClientApi;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
using PackageUploader.UI.View;
@@ -20,6 +21,7 @@ public class Msixvc2UploadViewModelTest
private ErrorModelProvider _errorModelProvider;
private PathConfigurationProvider _pathConfigurationProvider;
private PackageModelProvider _packageModelProvider;
+ private IMsixvc2ToolResolver _msixvc2ToolResolver;
private Msixvc2UploadViewModel _viewModel;
@@ -32,6 +34,7 @@ public void Setup()
_errorModelProvider = new ErrorModelProvider();
_pathConfigurationProvider = new PathConfigurationProvider();
_packageModelProvider = new PackageModelProvider();
+ _msixvc2ToolResolver = new Msixvc2ToolResolver();
_viewModel = new Msixvc2UploadViewModel(
_mockWindowService.Object,
@@ -39,7 +42,8 @@ public void Setup()
_mockLogger.Object,
_errorModelProvider,
_pathConfigurationProvider,
- _packageModelProvider
+ _packageModelProvider,
+ _msixvc2ToolResolver
);
}
@@ -503,4 +507,177 @@ public void BuildUploadArguments_FlightPath_IncludesUploadSource()
}
#endregion
+
+ #region MakePkg.exe / makepkg2.exe resolution order
+
+ private static string WriteTempScript(string name, string content)
+ {
+ string path = Path.Combine(Path.GetTempPath(), $"{name}_{Guid.NewGuid():N}.bat");
+ File.WriteAllText(path, content);
+ return path;
+ }
+
+ [TestMethod]
+ public void ResolveMsixvc2Tool_PrefersMakePkg_WhenItSupportsUploadSource()
+ {
+ string makePkg = WriteTempScript("makepkg_new", "@exit /b 0");
+ string makePkg2 = WriteTempScript("makepkg2_new", "@exit /b 0");
+ try
+ {
+ _pathConfigurationProvider.MakePkgPath = makePkg;
+ _pathConfigurationProvider.MakePkg2Path = makePkg2;
+
+ var tool = _viewModel.ResolveMsixvc2Tool();
+
+ Assert.IsNotNull(tool, "New MakePkg.exe supporting the verb must resolve");
+ Assert.AreEqual(makePkg, tool.ExecutablePath, "MakePkg.exe must win over makepkg2.exe");
+ Assert.IsFalse(tool.IsMakePkg2Fallback, "Must not be flagged as a makepkg2 fallback");
+ }
+ finally
+ {
+ File.Delete(makePkg);
+ File.Delete(makePkg2);
+ }
+ }
+
+ [TestMethod]
+ public void ResolveMsixvc2Tool_FallsBackToMakePkg2_WhenLegacyMakePkgFailsProbe()
+ {
+ // Legacy MakePkg.exe doesn't understand "supports uploadsource" and exits non-zero.
+ string makePkg = WriteTempScript("makepkg_legacy",
+ "@echo Unrecognized command or argument 'supports'. 1>&2\r\n@exit /b 1");
+ string makePkg2 = WriteTempScript("makepkg2_ok", "@exit /b 0");
+ try
+ {
+ _pathConfigurationProvider.MakePkgPath = makePkg;
+ _pathConfigurationProvider.MakePkg2Path = makePkg2;
+
+ var tool = _viewModel.ResolveMsixvc2Tool();
+
+ Assert.IsNotNull(tool, "makepkg2.exe fallback must resolve when MakePkg.exe is legacy");
+ Assert.AreEqual(makePkg2, tool.ExecutablePath);
+ Assert.IsTrue(tool.IsMakePkg2Fallback, "Must be flagged as a makepkg2 fallback");
+ }
+ finally
+ {
+ File.Delete(makePkg);
+ File.Delete(makePkg2);
+ }
+ }
+
+ [TestMethod]
+ public void ResolveMsixvc2Tool_ReturnsNull_WhenBothToolsFailProbe()
+ {
+ string makePkg = WriteTempScript("makepkg_legacy2", "@exit /b 1");
+ string makePkg2 = WriteTempScript("makepkg2_legacy2", "@exit /b 1");
+ try
+ {
+ _pathConfigurationProvider.MakePkgPath = makePkg;
+ _pathConfigurationProvider.MakePkg2Path = makePkg2;
+
+ Assert.IsNull(_viewModel.ResolveMsixvc2Tool(), "MSIXVC2 must be unavailable when both probes fail");
+ Assert.IsFalse(_viewModel.SupportsUploadSourceFlag());
+ }
+ finally
+ {
+ File.Delete(makePkg);
+ File.Delete(makePkg2);
+ }
+ }
+
+ [TestMethod]
+ public void ResolveMsixvc2Tool_ReturnsNull_WhenBothToolsMissing()
+ {
+ _pathConfigurationProvider.MakePkgPath = @"C:\nonexistent\MakePkg.exe";
+ _pathConfigurationProvider.MakePkg2Path = @"C:\nonexistent\makepkg2.exe";
+
+ Assert.IsNull(_viewModel.ResolveMsixvc2Tool());
+ }
+
+ [TestMethod]
+ public void ResolveMsixvc2Tool_FallsBackToMakePkg2_WhenMakePkgTimesOut()
+ {
+ string makePkg = WriteTempScript("makepkg_hang", "@ping -n 30 127.0.0.1 > nul");
+ string makePkg2 = WriteTempScript("makepkg2_after_hang", "@exit /b 0");
+ try
+ {
+ _pathConfigurationProvider.MakePkgPath = makePkg;
+ _pathConfigurationProvider.MakePkg2Path = makePkg2;
+
+ var tool = _viewModel.ResolveMsixvc2Tool();
+
+ Assert.IsNotNull(tool, "A hung MakePkg.exe probe must time out and fall through to makepkg2.exe");
+ Assert.AreEqual(makePkg2, tool.ExecutablePath);
+ Assert.IsTrue(tool.IsMakePkg2Fallback);
+ }
+ finally
+ {
+ File.Delete(makePkg);
+ File.Delete(makePkg2);
+ }
+ }
+
+ [TestMethod]
+ public void ResolveMsixvc2Tool_FallsBackToMakePkg2_WhenMakePkgMissing()
+ {
+ string makePkg2 = WriteTempScript("makepkg2_only", "@exit /b 0");
+ try
+ {
+ _pathConfigurationProvider.MakePkgPath = string.Empty;
+ _pathConfigurationProvider.MakePkg2Path = makePkg2;
+
+ var tool = _viewModel.ResolveMsixvc2Tool();
+
+ Assert.IsNotNull(tool);
+ Assert.AreEqual(makePkg2, tool.ExecutablePath);
+ Assert.IsTrue(tool.IsMakePkg2Fallback);
+ }
+ finally
+ {
+ File.Delete(makePkg2);
+ }
+ }
+
+ [TestMethod]
+ public void ResolveMsixvc2Tool_ReturnsNull_WhenMakePkgPathIsDirectory()
+ {
+ string dirPath = Path.Combine(Path.GetTempPath(), $"makepkg_dir_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dirPath);
+ try
+ {
+ _pathConfigurationProvider.MakePkgPath = dirPath;
+ _pathConfigurationProvider.MakePkg2Path = dirPath;
+
+ Assert.IsNull(_viewModel.ResolveMsixvc2Tool(),
+ "A directory must never be treated as a packaging tool");
+ }
+ finally
+ {
+ Directory.Delete(dirPath);
+ }
+ }
+
+ [TestMethod]
+ public void BuildUploadArguments_IncludesUploadSource_WhenOnlyNewMakePkgAvailable()
+ {
+ string makePkg = WriteTempScript("makepkg_args", "@exit /b 0");
+ try
+ {
+ _pathConfigurationProvider.MakePkgPath = makePkg;
+ _pathConfigurationProvider.MakePkg2Path = string.Empty;
+ _viewModel.ContentPath = @"C:\game\content";
+ _viewModel.BranchOrFlightDisplayName = "Branch: Main";
+ _viewModel.MarketGroupName = "default";
+
+ string args = _viewModel.BuildUploadArguments();
+
+ Assert.IsTrue(args.Contains("/uploadsource XGPM"));
+ }
+ finally
+ {
+ File.Delete(makePkg);
+ }
+ }
+
+ #endregion
}
diff --git a/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs
index f28f1a87..418b703b 100644
--- a/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs
+++ b/src/PackageUploader.UI.Test/ViewModel/PackageCreationViewModelTest.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Model;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
@@ -64,7 +65,8 @@ public void TestInitialize()
_progressProvider,
_mockLogger.Object,
_errorModelProvider,
- _validatorResultsProvider
+ _validatorResultsProvider,
+ new Msixvc2ToolResolver()
);
// mock files
@@ -829,6 +831,96 @@ public void PopulateSubValArgs_NoAutoUpdateWithEmptySubValPath_ReturnsTrue()
Assert.AreEqual(string.Empty, _viewModel.SubValDllError);
}
+ #region MSIXVC2 capability probe
+
+ private PackageCreationViewModel CreateViewModel(IMsixvc2ToolResolver resolver) =>
+ new(
+ _packageModelProvider,
+ _pathConfigProvider,
+ _mockWindowService.Object,
+ _progressProvider,
+ _mockLogger.Object,
+ _errorModelProvider,
+ _validatorResultsProvider,
+ resolver);
+
+ [TestMethod]
+ public async Task Msixvc2Probe_SetsIsMsixvc2Available_WhenAToolIsResolved()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns(new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false));
+
+ var viewModel = CreateViewModel(resolver.Object);
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsTrue(viewModel.IsMsixvc2Available);
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_ClearsIsMsixvc2Available_WhenNoToolIsResolved()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns((Msixvc2Tool)null);
+
+ var viewModel = CreateViewModel(resolver.Object);
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsFalse(viewModel.IsMsixvc2Available);
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_DoesNotBlockTheConstructor()
+ {
+ // The probe launches a child process and can block for up to the probe timeout (twice,
+ // if MakePkg.exe fails and we fall back to makepkg2.exe). It must never run inline on
+ // the UI thread while the packaging page is being constructed.
+ var probeStarted = new ManualResetEventSlim(false);
+ var releaseProbe = new ManualResetEventSlim(false);
+
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns(() =>
+ {
+ probeStarted.Set();
+ releaseProbe.Wait(TimeSpan.FromSeconds(30));
+ return new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false);
+ });
+
+ var stopwatch = Stopwatch.StartNew();
+ var viewModel = CreateViewModel(resolver.Object);
+ stopwatch.Stop();
+
+ Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10)), "The probe should have been started in the background.");
+ Assert.IsTrue(
+ stopwatch.Elapsed < TimeSpan.FromSeconds(5),
+ $"The constructor blocked for {stopwatch.Elapsed.TotalSeconds:F1}s waiting on the capability probe.");
+
+ // The property keeps its safe default until the probe reports back.
+ Assert.IsFalse(viewModel.IsMsixvc2Available);
+
+ releaseProbe.Set();
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsTrue(viewModel.IsMsixvc2Available);
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_ClearsIsMsixvc2Available_WhenTheResolverThrows()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Throws(new InvalidOperationException("boom"));
+
+ var viewModel = CreateViewModel(resolver.Object);
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.IsFalse(viewModel.IsMsixvc2Available);
+ }
+
+ #endregion
+
[TestCleanup]
public void Cleanup()
{
diff --git a/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs b/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs
index 215b2f20..25d61d8b 100644
--- a/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs
+++ b/src/PackageUploader.UI.Test/ViewModel/PackageUploadViewModelTest.cs
@@ -3,13 +3,16 @@
using PackageUploader.ClientApi;
using PackageUploader.ClientApi.Client.Ingestion.Models;
using PackageUploader.ClientApi.Models;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Model;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
using PackageUploader.UI.View;
using PackageUploader.UI.ViewModel;
using System;
+using System.Diagnostics;
using System.IO;
+using System.Reflection;
using System.Threading.Tasks;
using System.Windows.Input;
using PackageUploader.ClientApi.Client.Ingestion;
@@ -64,7 +67,8 @@ public void Setup()
_mockWindowService.Object,
_uploadingProgressPercentageProvider,
_errorModelProvider,
- new PathConfigurationProvider()
+ new PathConfigurationProvider(),
+ new Msixvc2ToolResolver()
);
}
@@ -89,7 +93,8 @@ public void Test_BranchOrFlightDisplayName()
_mockWindowService.Object,
_uploadingProgressPercentageProvider,
_errorModelProvider,
- new PathConfigurationProvider()
+ new PathConfigurationProvider(),
+ new Msixvc2ToolResolver()
);
viewModel2.BranchOrFlightDisplayName = "Test";
Assert.AreEqual("Test", viewModel2.BranchOrFlightDisplayName);
@@ -127,7 +132,8 @@ public void Test_BranchAndFlightNames()
_mockWindowService.Object,
_uploadingProgressPercentageProvider,
_errorModelProvider,
- new PathConfigurationProvider()
+ new PathConfigurationProvider(),
+ new Msixvc2ToolResolver()
);
viewModel2.BranchAndFlightNames = names; // tests the former value is successfully retrieved
@@ -150,7 +156,8 @@ public void Test_MarketGroupNames()
_mockWindowService.Object,
_uploadingProgressPercentageProvider,
_errorModelProvider,
- new PathConfigurationProvider()
+ new PathConfigurationProvider(),
+ new Msixvc2ToolResolver()
);
viewModel2.MarketGroupNames = names; // tests the former value is successfully retrieved
@@ -395,5 +402,200 @@ public void Test_ProcessSelectedPackage()
{
// very important to test this for user input shenanigans
}
+
+ #region MSIXVC2 capability probe on package selection
+
+ private PackageUploadViewModel CreateViewModel(IMsixvc2ToolResolver resolver) =>
+ new(
+ _packageModelProvider,
+ _mockPackageUploaderService.Object,
+ _mockWindowService.Object,
+ _uploadingProgressPercentageProvider,
+ _errorModelProvider,
+ new PathConfigurationProvider(),
+ resolver);
+
+ ///
+ /// Writes a file that XvcFile.IsLikelyMsixvc2Package recognises: a .msixvc at least
+ /// FirstReadSize (4096) bytes long starting with the ZIP local file header.
+ ///
+ private static string WriteFakeMsixvc2Package()
+ {
+ string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".msixvc");
+ var bytes = new byte[8192];
+ bytes[0] = 0x50; bytes[1] = 0x4B; bytes[2] = 0x03; bytes[3] = 0x04;
+ File.WriteAllBytes(path, bytes);
+ return path;
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_DoesNotBlockPackageSelection()
+ {
+ // Selecting a package must not freeze the UI while the capability probe runs. The probe
+ // launches a child process and can block for up to the probe timeout, twice if
+ // MakePkg.exe fails and we fall back to makepkg2.exe.
+ var probeStarted = new ManualResetEventSlim(false);
+ var releaseProbe = new ManualResetEventSlim(false);
+
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns(() =>
+ {
+ probeStarted.Set();
+ releaseProbe.Wait(TimeSpan.FromSeconds(30));
+ return new Msixvc2Tool(@"C:\gdk\MakePkg.exe", IsMakePkg2Fallback: false);
+ });
+
+ var viewModel = CreateViewModel(resolver.Object);
+ string packagePath = WriteFakeMsixvc2Package();
+
+ try
+ {
+ var stopwatch = Stopwatch.StartNew();
+ viewModel.PackageFilePath = packagePath;
+ stopwatch.Stop();
+
+ Assert.IsTrue(viewModel.IsMsixvc2Package, "The fake package should be detected as MSIXVC2.");
+ Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10)), "The probe should have been started in the background.");
+ Assert.IsTrue(
+ stopwatch.Elapsed < TimeSpan.FromSeconds(5),
+ $"Package selection blocked for {stopwatch.Elapsed.TotalSeconds:F1}s waiting on the capability probe.");
+
+ releaseProbe.Set();
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.AreEqual(string.Empty, viewModel.Msixvc2UnavailableMessage);
+ }
+ finally
+ {
+ releaseProbe.Set();
+ File.Delete(packagePath);
+ }
+ }
+
+ [TestMethod]
+ public async Task Msixvc2Probe_SetsUnavailableMessageAndBlocksUpload_WhenNoToolIsResolved()
+ {
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns((Msixvc2Tool)null);
+
+ var viewModel = CreateViewModel(resolver.Object);
+ string packagePath = WriteFakeMsixvc2Package();
+
+ try
+ {
+ bool canExecuteRaised = false;
+ viewModel.UploadPackageCommand.CanExecuteChanged += (s, e) => canExecuteRaised = true;
+
+ viewModel.PackageFilePath = packagePath;
+ await viewModel.Msixvc2ProbeTask;
+
+ Assert.AreEqual(
+ PackageUploader.UI.Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg,
+ viewModel.Msixvc2UnavailableMessage);
+
+ // IsUploadReady() gates on the message, so the probe result must block the upload.
+ //
+ // Note: RelayCommand routes CanExecuteChanged through WPF's
+ // CommandManager.RequerySuggested, which only fires on a running dispatcher, so the
+ // event itself can't be observed in a unit test host. CanExecute is the real
+ // contract, so assert on that; canExecuteRaised is left in only to document that
+ // the event is deliberately not asserted here.
+ _ = canExecuteRaised;
+ Assert.IsFalse(viewModel.UploadPackageCommand.CanExecute(null));
+ }
+ finally
+ {
+ File.Delete(packagePath);
+ }
+ }
+
+ [TestMethod]
+ public void Msixvc2Probe_UploadClickedWhileProbeInFlight_ShowsErrorAndDoesNotLaunchTool()
+ {
+ // A user can click Upload after selecting a package but before the probe completes,
+ // while Msixvc2UnavailableMessage is still empty. StartMsixvc2Upload() re-resolves
+ // synchronously, so that click must produce a clean error rather than launching a
+ // missing tool or crashing.
+ var releaseProbe = new ManualResetEventSlim(false);
+ var probeStarted = new ManualResetEventSlim(false);
+
+ var resolver = new Mock();
+ resolver.Setup(x => x.Resolve(It.IsAny(), It.IsAny()))
+ .Returns(() =>
+ {
+ // Only the background probe blocks; the synchronous re-resolve on the upload
+ // path returns "no tool" immediately.
+ if (!probeStarted.IsSet)
+ {
+ probeStarted.Set();
+ releaseProbe.Wait(TimeSpan.FromSeconds(30));
+ }
+
+ return null;
+ });
+
+ var viewModel = CreateViewModel(resolver.Object);
+ string packagePath = WriteFakeMsixvc2Package();
+
+ try
+ {
+ viewModel.PackageFilePath = packagePath;
+
+ Assert.IsTrue(probeStarted.Wait(TimeSpan.FromSeconds(10)));
+ Assert.IsTrue(viewModel.IsMsixvc2Package);
+
+ // Probe still in flight: the message hasn't been published yet.
+ Assert.AreEqual(string.Empty, viewModel.Msixvc2UnavailableMessage);
+
+ // Put the view model into an otherwise upload-ready state so the command is
+ // genuinely clickable, which is what makes this race reachable in the real app.
+ typeof(PackageUploadViewModel)
+ .GetField("_gameProduct", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .SetValue(viewModel, new GameProduct());
+ viewModel.MarketGroupName = "default";
+
+ Assert.IsTrue(
+ viewModel.UploadPackageCommand.CanExecute(null),
+ "Upload should be clickable while the probe is still in flight - that's the race being tested.");
+
+ // Take the click's synchronous branch directly. UploadPackageProcessAsync is
+ // 'async void', so an exception from its WPF navigation (Application.Current is null
+ // in a unit test host) would be rethrown on the thread pool and kill the test
+ // process rather than surface here. StartMsixvc2Upload is the whole MSIXVC2 branch
+ // of that method, so invoking it directly tests the same path safely.
+ var startMsixvc2Upload = typeof(PackageUploadViewModel)
+ .GetMethod("StartMsixvc2Upload", BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ try
+ {
+ startMsixvc2Upload.Invoke(viewModel, null);
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException is NullReferenceException)
+ {
+ // SetErrorAndGoToErrorPage navigates via System.Windows.Application.Current,
+ // which doesn't exist in a unit test host. The error state it sets beforehand is
+ // still observable, and that's what matters here.
+ }
+
+ // The tool was re-resolved synchronously, came back missing, and the upload was
+ // abandoned: an error was raised, no MSIXVC2 tool path was handed to the uploader,
+ // and we never navigated to the uploading screen.
+ Assert.AreEqual("MSIXVC2 packaging tool not found", _errorModelProvider.Error.MainMessage);
+ Assert.AreEqual(
+ PackageUploader.UI.Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg,
+ _errorModelProvider.Error.DetailMessage);
+ _mockWindowService.Verify(x => x.NavigateTo(typeof(Msixvc2UploadingView)), Times.Never);
+ Assert.IsTrue(string.IsNullOrEmpty(_packageModelProvider.Package.Msixvc2ToolPath));
+ }
+ finally
+ {
+ releaseProbe.Set();
+ File.Delete(packagePath);
+ }
+ }
+
+ #endregion
}
}
\ No newline at end of file
diff --git a/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs b/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs
index 3bb82cc5..32eb38d0 100644
--- a/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs
+++ b/src/PackageUploader.UI.Test/ViewModel/TestablePackageUploadViewModel.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using PackageUploader.ClientApi;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Model;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
@@ -43,7 +44,7 @@ public TestablePackageUploadViewModel(
IWindowService windowService,
UploadingProgressPercentageProvider uploadingProgressPercentageProvider,
ErrorModelProvider errorModelProvider)
- : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider())
+ : base(packageModelProvider, uploaderService, windowService, uploadingProgressPercentageProvider, errorModelProvider, new PathConfigurationProvider(), new Msixvc2ToolResolver())
{
}
diff --git a/src/PackageUploader.UI/App.xaml.cs b/src/PackageUploader.UI/App.xaml.cs
index 7c97f398..52dedbfc 100644
--- a/src/PackageUploader.UI/App.xaml.cs
+++ b/src/PackageUploader.UI/App.xaml.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PackageUploader.ClientApi;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.FileLogger;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
@@ -46,6 +47,7 @@ public App()
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddMsixvc2ToolResolver();
// Register providers
services.AddSingleton();
diff --git a/src/PackageUploader.UI/Model/PackageModel.cs b/src/PackageUploader.UI/Model/PackageModel.cs
index 067bfcbf..47d7ef3e 100644
--- a/src/PackageUploader.UI/Model/PackageModel.cs
+++ b/src/PackageUploader.UI/Model/PackageModel.cs
@@ -26,6 +26,7 @@ public class PackageModel
public string PackageIdentityName { get; set; } = string.Empty;
public string FolderSize { get; set; } = string.Empty;
public string UploadArguments { get; set; } = string.Empty;
- public string MakePkg2Path { get; set; } = string.Empty;
+ /// Resolved MSIXVC2 packaging tool (MakePkg.exe, or makepkg2.exe as fallback).
+ public string Msixvc2ToolPath { get; set; } = string.Empty;
public Type? UploadOriginPage { get; set; } = null;
}
diff --git a/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs b/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs
index 4a0ab522..7b8656e6 100644
--- a/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs
+++ b/src/PackageUploader.UI/Resources/Strings/MainPage.Designer.cs
@@ -178,7 +178,7 @@ public static string MakePackageNotFoundErrorMsg {
}
///
- /// Looks up a localized string similar to MSIXVC2 upload requires makepkg2 tools, available as a preview in the April 2026 GDK. Install the April 2026 GDK to get started..
+ /// Looks up a localized string similar to MSIXVC2 requires a packaging tool that supports it. Install the latest GDK to get MakePkg.exe with MSIXVC2 support..
///
public static string MakePkg2NotFoundErrorMsg {
get {
diff --git a/src/PackageUploader.UI/Resources/Strings/MainPage.resx b/src/PackageUploader.UI/Resources/Strings/MainPage.resx
index fd6e9d53..555d992a 100644
--- a/src/PackageUploader.UI/Resources/Strings/MainPage.resx
+++ b/src/PackageUploader.UI/Resources/Strings/MainPage.resx
@@ -214,6 +214,6 @@
Learn more: https://aka.ms/MSIXVC2
- MSIXVC2 upload requires makepkg2 tools, available as a preview in the April 2026 GDK. Install the April 2026 GDK to get started.
+ MSIXVC2 requires a packaging tool that supports it. Install the latest GDK to get MakePkg.exe with MSIXVC2 support.
\ No newline at end of file
diff --git a/src/PackageUploader.UI/View/MainPageView.xaml b/src/PackageUploader.UI/View/MainPageView.xaml
index 66aec8b6..d06efee7 100644
--- a/src/PackageUploader.UI/View/MainPageView.xaml
+++ b/src/PackageUploader.UI/View/MainPageView.xaml
@@ -273,14 +273,14 @@
-
diff --git a/src/PackageUploader.UI/View/PackageCreationView.xaml b/src/PackageUploader.UI/View/PackageCreationView.xaml
index 9df65ada..9fe51e31 100644
--- a/src/PackageUploader.UI/View/PackageCreationView.xaml
+++ b/src/PackageUploader.UI/View/PackageCreationView.xaml
@@ -512,7 +512,7 @@
Content="{x:Static strings:PackageCreation.UseMsixvc2Text}"
d:Content="Package as MSIXVC2 (.msixvc)"
IsChecked="{Binding UseMsixvc2}"
- Visibility="{Binding IsMakePkg2Available, Converter={StaticResource BooleanToVisibilityConverter}}"
+ Visibility="{Binding IsMsixvc2Available, Converter={StaticResource BooleanToVisibilityConverter}}"
Foreground="{DynamicResource PrimaryTextBrush}"
HorizontalAlignment="Left"
VerticalContentAlignment="Center"
diff --git a/src/PackageUploader.UI/View/PackageUploadView.xaml b/src/PackageUploader.UI/View/PackageUploadView.xaml
index be089404..02b348b9 100644
--- a/src/PackageUploader.UI/View/PackageUploadView.xaml
+++ b/src/PackageUploader.UI/View/PackageUploadView.xaml
@@ -636,10 +636,10 @@
TabIndex="5" />
-
-
+
diff --git a/src/PackageUploader.UI/ViewModel/BaseViewModel.cs b/src/PackageUploader.UI/ViewModel/BaseViewModel.cs
index c06255bf..a9c970c9 100644
--- a/src/PackageUploader.UI/ViewModel/BaseViewModel.cs
+++ b/src/PackageUploader.UI/ViewModel/BaseViewModel.cs
@@ -51,6 +51,32 @@ public BaseViewModel()
public bool IsCompactMode => _compactModeProvider?.IsCompactMode ?? false;
+ ///
+ /// Marshals back onto the UI thread when a WPF dispatcher is
+ /// available, so background work can safely update bound properties. Runs inline when there
+ /// is no dispatcher (unit tests) or when already on the UI thread.
+ ///
+ protected static void RunOnUiThread(Action action)
+ {
+ var dispatcher = System.Windows.Application.Current?.Dispatcher;
+
+ try
+ {
+ if (dispatcher is not null && !dispatcher.CheckAccess())
+ {
+ dispatcher.Invoke(action);
+ }
+ else
+ {
+ action();
+ }
+ }
+ catch (System.Threading.Tasks.TaskCanceledException)
+ {
+ // The dispatcher shut down while we were marshalling (app is closing).
+ }
+ }
+
protected static readonly string _settingsFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"XboxPackageTool");
diff --git a/src/PackageUploader.UI/ViewModel/MainPageViewModel.cs b/src/PackageUploader.UI/ViewModel/MainPageViewModel.cs
index 79dba181..a88ec491 100644
--- a/src/PackageUploader.UI/ViewModel/MainPageViewModel.cs
+++ b/src/PackageUploader.UI/ViewModel/MainPageViewModel.cs
@@ -2,8 +2,8 @@
// Licensed under the MIT License.
using Microsoft.Extensions.Logging;
-using Microsoft.Win32;
using PackageUploader.ClientApi.Client.Ingestion.TokenProvider.Models;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
using PackageUploader.UI.View;
@@ -45,18 +45,20 @@ public string MakePkgUnavailableErrorMessage
set => SetProperty(ref _makePkgUnavailableErrorMessage, value);
}
- private bool _isMakePkg2Enabled = true;
- public bool IsMakePkg2Enabled
+ // Defaults to false until the background capability probe completes, so the user can't enter
+ // the MSIXVC2 flow before we know the tool supports it.
+ private bool _isMsixvc2Enabled = false;
+ public bool IsMsixvc2Enabled
{
- get => _isMakePkg2Enabled;
- set => SetProperty(ref _isMakePkg2Enabled, value);
+ get => _isMsixvc2Enabled;
+ set => SetProperty(ref _isMsixvc2Enabled, value);
}
- private string _makePkg2UnavailableErrorMessage = string.Empty;
- public string MakePkg2UnavailableErrorMessage
+ private string _msixvc2UnavailableErrorMessage = string.Empty;
+ public string Msixvc2UnavailableErrorMessage
{
- get => _makePkg2UnavailableErrorMessage;
- set => SetProperty(ref _makePkg2UnavailableErrorMessage, value);
+ get => _msixvc2UnavailableErrorMessage;
+ set => SetProperty(ref _msixvc2UnavailableErrorMessage, value);
}
public bool IsUserLoggedIn
@@ -139,6 +141,8 @@ public MainPageViewModel(
UserLoggedInProvider userLoggedInProvider,
IAuthenticationService authenticationService,
IWindowService windowService,
+ IMsixvc2ToolResolver msixvc2ToolResolver,
+ IToolPathResolver toolPathResolver,
ILogger logger)
{
_pathConfigurationService = pathConfigurationService;
@@ -222,7 +226,11 @@ public MainPageViewModel(
IsUserLoggedIn = false;
- string makePkgPath = ResolveFilePath("MakePkg.exe");
+ // Tool discovery is shared with the command line so both hosts resolve the same binaries.
+ // Find returns null when a tool is missing; the empty string keeps the historic contract with
+ // the calls below, and tells IMsixvc2ToolResolver that this host has already searched and
+ // found nothing rather than asking it to search again.
+ string makePkgPath = toolPathResolver.Find("MakePkg.exe") ?? string.Empty;
if (File.Exists(makePkgPath))
{
@@ -236,26 +244,27 @@ public MainPageViewModel(
MakePkgUnavailableErrorMessage = PackageUploader.UI.Resources.Strings.MainPage.MakePackageNotFoundErrorMsg;
}
- string subValPath = ResolveFilePath("SubmissionValidator.dll");
+ string subValPath = toolPathResolver.Find("SubmissionValidator.dll") ?? string.Empty;
if (File.Exists(subValPath))
{
_pathConfigurationService.BaseSubValPath = subValPath;
}
- string makePkg2Path = ResolveMakePkg2Path();
+ string makePkg2Path = toolPathResolver.Find("makepkg2.exe") ?? string.Empty;
if (File.Exists(makePkg2Path))
{
_pathConfigurationService.MakePkg2Path = makePkg2Path;
- IsMakePkg2Enabled = true;
- }
- else
- {
- IsMakePkg2Enabled = false;
- MakePkg2UnavailableErrorMessage = Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg;
}
+ // MSIXVC2 capability comes from the current GDK's MakePkg.exe, or from the standalone
+ // makepkg2.exe as a fallback. Both are verified by probing "supports uploadsource", which
+ // launches a child process and can block for up to the probe timeout (twice, if MakePkg.exe
+ // fails and we fall back). That must never run on the UI thread, so the probe is kicked off
+ // in the background and the bound property is updated when it completes.
+ Msixvc2ProbeTask = ProbeMsixvc2SupportAsync(msixvc2ToolResolver, makePkgPath, makePkg2Path);
+
// Log version of the tool
_logger.LogInformation("PackageUploader.UI version {version} is starting from location {location}.", GetVersion(), AppContext.BaseDirectory);
@@ -276,6 +285,56 @@ public MainPageViewModel(
}
}
+ ///
+ /// Tracks the background MSIXVC2 capability probe started during construction.
+ /// Exposed so tests can await the result deterministically.
+ ///
+ internal Task Msixvc2ProbeTask { get; }
+
+ ///
+ /// Probes for an MSIXVC2-capable packaging tool off the UI thread and publishes the result
+ /// to the bound /
+ /// properties.
+ ///
+ private Task ProbeMsixvc2SupportAsync(IMsixvc2ToolResolver msixvc2ToolResolver, string makePkgPath, string makePkg2Path)
+ {
+ return Task.Run(() =>
+ {
+ Msixvc2Tool? msixvc2Tool = null;
+
+ try
+ {
+ msixvc2Tool = msixvc2ToolResolver.Resolve(makePkgPath, makePkg2Path);
+
+ if (msixvc2Tool is not null)
+ {
+ _logger.LogInformation("MSIXVC2 support provided by {tool} at {location}.",
+ msixvc2Tool.IsMakePkg2Fallback ? "makepkg2.exe" : "MakePkg.exe", msixvc2Tool.ExecutablePath);
+ }
+ else
+ {
+ _logger.LogInformation("No MSIXVC2-capable packaging tool was found.");
+ }
+ }
+ catch (Exception ex)
+ {
+ // The resolver already swallows probe failures; this is belt-and-braces so a
+ // background exception can never take down the app at startup.
+ _logger.LogWarning(ex, "Failed to probe for MSIXVC2 packaging tool support.");
+ }
+
+ bool isSupported = msixvc2Tool is not null;
+
+ RunOnUiThread(() =>
+ {
+ IsMsixvc2Enabled = isSupported;
+ Msixvc2UnavailableErrorMessage = isSupported
+ ? string.Empty
+ : Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg;
+ });
+ });
+ }
+
private async void LoadAvailableTenants()
{
try
@@ -335,129 +394,4 @@ public void OnAppearing()
{
OnPropertyChanged(nameof(IsUserLoggedIn));
}
-
- private static string ResolveFilePath(string fileName)
- {
- // We search in several locations in priority order:
- // 1. Next to our current executable
- // 2. In the CurrentDirectory
- // 3. In the GDK if it's installed
- // 4. In the directories specified by the PATH environment variable
-
- // Use AppContext.BaseDirectory instead of Assembly.Location for single-file compatibility
- var assemblyDirectory = AppContext.BaseDirectory;
-
- if (Directory.Exists(assemblyDirectory))
- {
- var nextToExePath = Path.Combine(assemblyDirectory, fileName);
-
- if (File.Exists(nextToExePath))
- {
- return nextToExePath;
- }
- }
-
- var currentDirectory = Directory.GetCurrentDirectory();
-
- var currentDirectoryPath = Path.Combine(currentDirectory, fileName);
-
- if (File.Exists(currentDirectoryPath))
- {
- return currentDirectoryPath;
- }
-
- string GdkRegistryPath = @"SOFTWARE\Microsoft\GDK\Installed Roots";
- string? gdkPath = Registry.GetValue($@"HKEY_LOCAL_MACHINE\{GdkRegistryPath}", "GDKInstallPath", null) as string;
-
- if (!string.IsNullOrEmpty(gdkPath))
- {
- var gdkFilePath = Path.Combine(gdkPath, "bin", fileName);
- if (File.Exists(gdkFilePath))
- {
- return gdkFilePath;
- }
- }
-
- string GdkAltRegistryPath = @"SOFTWARE\WOW6432Node\Microsoft\GDK\Installed Roots";
- string? gdkAltPath = Registry.GetValue($@"HKEY_LOCAL_MACHINE\{GdkAltRegistryPath}", "GDKInstallPath", null) as string;
-
- if (!string.IsNullOrEmpty(gdkAltPath))
- {
- var gdkFilePath = Path.Combine(gdkAltPath, "bin", fileName);
- if (File.Exists(gdkFilePath))
- {
- return gdkFilePath;
- }
- }
-
- string? filePath = FindFileInPath(fileName);
-
- if (File.Exists(filePath))
- {
- return filePath;
- }
-
- return string.Empty;
- }
-
- private static string? FindFileInPath(string fileName)
- {
- var pathValue = Environment.GetEnvironmentVariable("PATH");
-
- if (string.IsNullOrEmpty(pathValue))
- {
- return null;
- }
-
- var paths = pathValue.Split(Path.PathSeparator);
- foreach (var path in paths)
- {
- var filePath = Path.Combine(path, fileName);
- if (File.Exists(filePath))
- {
- return filePath;
- }
- }
- return null;
- }
-
- private static string ResolveMakePkg2Path()
- {
- string localPath = ResolveFilePath("makepkg2.exe");
- if (File.Exists(localPath))
- {
- return localPath;
- }
-
- string nugetPackagesDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- ".nuget", "packages", "microsoft.xbox.packaging.tools.makepkg2");
-
- if (Directory.Exists(nugetPackagesDir))
- {
- string? bestPath = null;
- Version? bestVersion = null;
-
- foreach (var versionDir in Directory.GetDirectories(nugetPackagesDir))
- {
- string dirName = Path.GetFileName(versionDir);
- if (Version.TryParse(dirName, out var version))
- {
- string candidate = Path.Combine(versionDir, "tools", "any", "win-x64", "makepkg2.exe");
- if (File.Exists(candidate) && (bestVersion == null || version > bestVersion))
- {
- bestVersion = version;
- bestPath = candidate;
- }
- }
- }
-
- if (bestPath != null)
- {
- return bestPath;
- }
- }
-
- return string.Empty;
- }
}
diff --git a/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs b/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs
index 72d49977..958d39df 100644
--- a/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs
+++ b/src/PackageUploader.UI/ViewModel/Msixvc2UploadViewModel.cs
@@ -10,6 +10,7 @@
using Microsoft.Extensions.Logging;
using PackageUploader.ClientApi;
using PackageUploader.ClientApi.Client.Ingestion.Models;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Model;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
@@ -25,6 +26,7 @@ public partial class Msixvc2UploadViewModel : BaseViewModel
private readonly ErrorModelProvider _errorModelProvider;
private readonly PathConfigurationProvider _pathConfigurationService;
private readonly PackageModelProvider _packageModelProvider;
+ private readonly IMsixvc2ToolResolver _msixvc2ToolResolver;
private GameProduct? _gameProduct = null;
private IReadOnlyCollection? _branchesAndFlights = null;
@@ -213,7 +215,8 @@ public Msixvc2UploadViewModel(IWindowService windowService,
ILogger logger,
ErrorModelProvider errorModelProvider,
PathConfigurationProvider pathConfigurationService,
- PackageModelProvider packageModelProvider)
+ PackageModelProvider packageModelProvider,
+ IMsixvc2ToolResolver msixvc2ToolResolver)
{
_windowService = windowService;
_uploaderService = uploaderService;
@@ -221,6 +224,7 @@ public Msixvc2UploadViewModel(IWindowService windowService,
_errorModelProvider = errorModelProvider;
_pathConfigurationService = pathConfigurationService;
_packageModelProvider = packageModelProvider;
+ _msixvc2ToolResolver = msixvc2ToolResolver;
BrowseContentPathCommand = new RelayCommand(OnBrowseContentPath);
BrowseMappingDataXmlPathCommand = new RelayCommand(OnBrowseMappingDataXml);
@@ -594,11 +598,11 @@ private void CheckCanExecuteUploadCommand()
private void StartPackAndUploadAsync()
{
- string makePkg2Path = _pathConfigurationService.MakePkg2Path;
- if (string.IsNullOrEmpty(makePkg2Path) || !File.Exists(makePkg2Path))
+ Msixvc2Tool? tool = ResolveMsixvc2Tool();
+ if (tool is null)
{
- SetErrorAndGoToErrorPage("makepkg2 Not Found",
- "makepkg2.exe was not found. Please install the Microsoft.Xbox.Packaging.Tools.makepkg2 NuGet package.");
+ SetErrorAndGoToErrorPage("MSIXVC2 packaging tool not found",
+ Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg);
return;
}
@@ -615,7 +619,7 @@ private void StartPackAndUploadAsync()
_packageModelProvider.Package.PackageIdentityName = PackageIdentityName;
_packageModelProvider.Package.FolderSize = EstimatedFolderSize;
_packageModelProvider.Package.UploadArguments = uploadArgs;
- _packageModelProvider.Package.MakePkg2Path = makePkg2Path;
+ _packageModelProvider.Package.Msixvc2ToolPath = tool.ExecutablePath;
_packageModelProvider.Package.UploadOriginPage = typeof(Msixvc2UploadView);
if (branchOrFlight != null)
{
@@ -626,51 +630,20 @@ private void StartPackAndUploadAsync()
}
///
- /// Probes makepkg2.exe to check if it supports the /uploadsource flag.
- /// Runs: makepkg2 supports uploadsource (exit 0 = supported, non-zero = not)
- /// Probed on every upload to handle in-place binary updates.
+ /// Resolves the tool used for MSIXVC2 pack and upload: the current GDK's MakePkg.exe when it supports
+ /// the uploadsource verb, otherwise the standalone makepkg2.exe.
+ /// Resolved on every call (no caching) to handle in-place binary updates.
///
- internal bool SupportsUploadSourceFlag()
- {
- string makePkg2Path = _pathConfigurationService.MakePkg2Path;
- if (string.IsNullOrEmpty(makePkg2Path))
- {
- return false;
- }
+ internal Msixvc2Tool? ResolveMsixvc2Tool() =>
+ _msixvc2ToolResolver.Resolve(
+ _pathConfigurationService.MakePkgPath ?? string.Empty,
+ _pathConfigurationService.MakePkg2Path ?? string.Empty);
- try
- {
- using var process = new Process();
- process.StartInfo = new ProcessStartInfo
- {
- FileName = makePkg2Path,
- Arguments = "supports uploadsource",
- UseShellExecute = false,
- RedirectStandardOutput = false,
- RedirectStandardError = false,
- CreateNoWindow = true
- };
- process.Start();
- if (process.WaitForExit(5000))
- {
- bool supported = process.ExitCode == 0;
- _logger.LogInformation("makepkg2 /uploadsource probe: {Result} (exit code {ExitCode})",
- supported ? "supported" : "not supported", process.ExitCode);
- return supported;
- }
- else
- {
- try { process.Kill(); } catch { /* best effort */ }
- _logger.LogWarning("makepkg2 uploadsource probe timed out after 5s.");
- }
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to probe makepkg2 for /uploadsource support.");
- }
-
- return false;
- }
+ ///
+ /// True when an MSIXVC2-capable tool supporting the /uploadsource flag is available.
+ /// The tool resolution probe ("supports uploadsource") is itself the capability check.
+ ///
+ internal bool SupportsUploadSourceFlag() => ResolveMsixvc2Tool() is not null;
internal string BuildUploadArguments()
{
diff --git a/src/PackageUploader.UI/ViewModel/Msixvc2UploadingViewModel.cs b/src/PackageUploader.UI/ViewModel/Msixvc2UploadingViewModel.cs
index a64c5bb4..3b762757 100644
--- a/src/PackageUploader.UI/ViewModel/Msixvc2UploadingViewModel.cs
+++ b/src/PackageUploader.UI/ViewModel/Msixvc2UploadingViewModel.cs
@@ -84,12 +84,12 @@ private async void StartUploadAsync()
{
var package = _packageModelProvider.Package;
string uploadArgs = package.UploadArguments;
- string makePkg2Path = package.MakePkg2Path;
+ string msixvc2ToolPath = package.Msixvc2ToolPath;
- if (string.IsNullOrEmpty(uploadArgs) || string.IsNullOrEmpty(makePkg2Path))
+ if (string.IsNullOrEmpty(uploadArgs) || string.IsNullOrEmpty(msixvc2ToolPath))
{
SetErrorAndGoToErrorPage("Upload Error",
- "Upload arguments or makepkg2 path not set. Please try again.");
+ "Upload arguments or MSIXVC2 packaging tool path not set. Please try again.");
return;
}
@@ -100,9 +100,9 @@ private async void StartUploadAsync()
try
{
- _logger.LogInformation("Starting makepkg2 loose upload: {Arguments}", uploadArgs);
+ _logger.LogInformation("Starting MSIXVC2 loose upload: {Arguments}", uploadArgs);
- int exitCode = await RunMakePkg2ProcessAsync(makePkg2Path, uploadArgs, "Upload", line =>
+ int exitCode = await RunMakePkg2ProcessAsync(msixvc2ToolPath, uploadArgs, "Upload", line =>
{
if (UploadStage == Msixvc2UploadStage.Preparing)
{
@@ -203,10 +203,10 @@ private async void StartUploadAsync()
if (exitCode != 0)
{
- _logger.LogError("makepkg2 upload failed with exit code {ExitCode}.", exitCode);
+ _logger.LogError("MSIXVC2 upload failed with exit code {ExitCode}.", exitCode);
string errorDetail = !string.IsNullOrEmpty(lastErrorMessage)
? lastErrorMessage
- : $"makepkg2 upload exited with code {exitCode}.";
+ : $"MSIXVC2 upload exited with code {exitCode}.";
SetErrorAndGoToErrorPage("Upload Failed", errorDetail);
return;
}
diff --git a/src/PackageUploader.UI/ViewModel/PackageCreationViewModel.cs b/src/PackageUploader.UI/ViewModel/PackageCreationViewModel.cs
index 1ccf86fc..7ad5f2d3 100644
--- a/src/PackageUploader.UI/ViewModel/PackageCreationViewModel.cs
+++ b/src/PackageUploader.UI/ViewModel/PackageCreationViewModel.cs
@@ -13,6 +13,7 @@
using System.Xml;
using System.IO;
using PackageUploader.UI.Utility;
+using PackageUploader.ClientApi.Tools;
using Microsoft.Extensions.Logging;
namespace PackageUploader.UI.ViewModel;
@@ -27,6 +28,7 @@ public partial class PackageCreationViewModel : BaseViewModel
private readonly ValidatorResultsProvider _validatorResultsProvider;
private readonly IWindowService _windowService;
private readonly ILogger _logger;
+ private readonly IMsixvc2ToolResolver _msixvc2ToolResolver;
private Process? _makePackageProcess;
@@ -329,11 +331,51 @@ public bool UseMsixvc2
set => SetProperty(ref _useMsixvc2, value);
}
- private bool _isMakePkg2Available = false;
- public bool IsMakePkg2Available
+ private bool _isMsixvc2Available = false;
+ public bool IsMsixvc2Available
{
- get => _isMakePkg2Available;
- set => SetProperty(ref _isMakePkg2Available, value);
+ get => _isMsixvc2Available;
+ set => SetProperty(ref _isMsixvc2Available, value);
+ }
+
+ ///
+ /// Resolves the MSIXVC2 packaging tool (MakePkg.exe preferred, makepkg2.exe fallback).
+ /// Resolved on demand rather than cached so in-place tool updates are picked up.
+ ///
+ private Msixvc2Tool? ResolveMsixvc2Tool() =>
+ _msixvc2ToolResolver.Resolve(
+ _pathConfigurationService.MakePkgPath ?? string.Empty,
+ _pathConfigurationService.MakePkg2Path ?? string.Empty);
+
+ ///
+ /// Tracks the background MSIXVC2 capability probe started during construction.
+ /// Exposed so tests can await the result deterministically.
+ ///
+ internal Task Msixvc2ProbeTask { get; }
+
+ ///
+ /// Probes for an MSIXVC2-capable packaging tool off the UI thread and publishes the result to
+ /// the bound property.
+ ///
+ private Task ProbeMsixvc2AvailabilityAsync()
+ {
+ return Task.Run(() =>
+ {
+ bool isAvailable = false;
+
+ try
+ {
+ isAvailable = ResolveMsixvc2Tool() is not null;
+ }
+ catch (Exception ex)
+ {
+ // The resolver already swallows probe failures; this guards against a background
+ // exception escaping and tearing down the app.
+ _logger.LogWarning(ex, "Failed to probe for MSIXVC2 packaging tool support.");
+ }
+
+ RunOnUiThread(() => IsMsixvc2Available = isAvailable);
+ });
}
public ICommand MakePackageCommand { get; }
@@ -350,7 +392,8 @@ public PackageCreationViewModel(PackageModelProvider packageModelService,
PackingProgressPercentageProvider packingProgressPercentageProvider,
ILogger logger,
ErrorModelProvider errorModelProvider,
- ValidatorResultsProvider validatorResultsProvider)
+ ValidatorResultsProvider validatorResultsProvider,
+ IMsixvc2ToolResolver msixvc2ToolResolver)
{
_packageModelService = packageModelService;
_pathConfigurationService = pathConfigurationService;
@@ -359,6 +402,7 @@ public PackageCreationViewModel(PackageModelProvider packageModelService,
_logger = logger;
_errorModelProvider = errorModelProvider;
_validatorResultsProvider = validatorResultsProvider;
+ _msixvc2ToolResolver = msixvc2ToolResolver;
// Ensure our version of MakePkg supports custom SubVal paths before allowing that option.
var mkgPkgpath = _pathConfigurationService.MakePkgPath;
@@ -385,8 +429,10 @@ public PackageCreationViewModel(PackageModelProvider packageModelService,
// Future options can also be checked here to enable new features.
}
- var makePkg2Path = _pathConfigurationService.MakePkg2Path;
- _isMakePkg2Available = !string.IsNullOrEmpty(makePkg2Path) && File.Exists(makePkg2Path);
+ // MSIXVC2 packaging comes from the current GDK's MakePkg.exe, or the standalone makepkg2.exe
+ // fallback. The capability probe launches a child process and can block for up to the probe
+ // timeout, so it runs off the UI thread and updates the bound property when it completes.
+ Msixvc2ProbeTask = ProbeMsixvc2AvailabilityAsync();
MakePackageCommand = new RelayCommand(StartMakePackageProcess, CanCreatePackage);
GameDataPathDroppedCommand = new RelayCommand(OnGameDataPathDropped);
@@ -672,9 +718,16 @@ private async void StartMakePackageProcess()
if (UseMsixvc2)
{
+ Msixvc2Tool? msixvc2Tool = ResolveMsixvc2Tool();
+ if (msixvc2Tool is null)
+ {
+ LayoutParseError = Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg;
+ return;
+ }
+
string msixvc2CmdFormat = "pack /f \"{0}\" /pd \"{1}\" /d \"{2}\" /msixvc2 /updatesubval /validationpath \"{3}\"";
arguments = string.Format(msixvc2CmdFormat, MappingDataXmlPath, buildPath, GameDataPath, _settingsFolder);
- executablePath = _pathConfigurationService.MakePkg2Path;
+ executablePath = msixvc2Tool.ExecutablePath;
}
else
{
diff --git a/src/PackageUploader.UI/ViewModel/PackageUploadViewModel.cs b/src/PackageUploader.UI/ViewModel/PackageUploadViewModel.cs
index 2067656a..e791672f 100644
--- a/src/PackageUploader.UI/ViewModel/PackageUploadViewModel.cs
+++ b/src/PackageUploader.UI/ViewModel/PackageUploadViewModel.cs
@@ -9,6 +9,7 @@
using PackageUploader.ClientApi.Client.Ingestion.Exceptions;
using PackageUploader.ClientApi.Client.Ingestion.Models;
using PackageUploader.ClientApi.Models;
+using PackageUploader.ClientApi.Tools;
using PackageUploader.UI.Providers;
using PackageUploader.UI.Utility;
using PackageUploader.UI.View;
@@ -34,6 +35,7 @@ public partial class PackageUploadViewModel : BaseViewModel
public readonly UploadingProgressPercentageProvider _uploadingProgressPercentageProvider;
private readonly ErrorModelProvider _errorModelProvider;
private readonly PathConfigurationProvider _pathConfigurationService;
+ private readonly IMsixvc2ToolResolver _msixvc2ToolResolver;
private GameProduct? _gameProduct = null;
private IReadOnlyCollection? _branchesAndFlights = null;
@@ -392,11 +394,20 @@ public bool IsMsixvc2Package
}
}
- private string _makePkg2UnavailableMessage = string.Empty;
- public string MakePkg2UnavailableMessage
+ private string _msixvc2UnavailableMessage = string.Empty;
+ public string Msixvc2UnavailableMessage
{
- get => _makePkg2UnavailableMessage;
- set => SetProperty(ref _makePkg2UnavailableMessage, value);
+ get => _msixvc2UnavailableMessage;
+ set
+ {
+ if (SetProperty(ref _msixvc2UnavailableMessage, value))
+ {
+ // IsUploadReady() gates on this message, so the Upload command's CanExecute must be
+ // re-evaluated whenever it changes - including when the background capability probe
+ // reports back after the package has already been selected.
+ CheckCanExecuteUploadCommand();
+ }
+ }
}
public string PackageIdentityName
@@ -454,7 +465,8 @@ public PackageUploadViewModel(PackageModelProvider packageModelService,
IWindowService windowService,
UploadingProgressPercentageProvider uploadingProgressPercentageProvider,
ErrorModelProvider errorModelProvider,
- PathConfigurationProvider pathConfigurationService)
+ PathConfigurationProvider pathConfigurationService,
+ IMsixvc2ToolResolver msixvc2ToolResolver)
{
_packageModelService = packageModelService;
_uploaderService = uploaderService;
@@ -462,6 +474,7 @@ public PackageUploadViewModel(PackageModelProvider packageModelService,
_uploadingProgressPercentageProvider = uploadingProgressPercentageProvider;
_errorModelProvider = errorModelProvider;
_pathConfigurationService = pathConfigurationService;
+ _msixvc2ToolResolver = msixvc2ToolResolver;
// Initialize commands with RelayCommand
UploadPackageCommand = new RelayCommand(UploadPackageProcessAsync, () => IsUploadReady());
@@ -482,10 +495,63 @@ public PackageUploadViewModel(PackageModelProvider packageModelService,
}
}
+ ///
+ /// Resolves the MSIXVC2 packaging tool (MakePkg.exe preferred, makepkg2.exe fallback).
+ /// Resolved on demand rather than cached so in-place tool updates are picked up.
+ ///
+ private Msixvc2Tool? ResolveMsixvc2Tool() =>
+ _msixvc2ToolResolver.Resolve(
+ _pathConfigurationService.MakePkgPath ?? string.Empty,
+ _pathConfigurationService.MakePkg2Path ?? string.Empty);
+
+ ///
+ /// Tracks the background MSIXVC2 capability probe started when an MSIXVC2 package is selected.
+ /// Exposed so tests can await the result deterministically.
+ ///
+ internal Task Msixvc2ProbeTask { get; private set; } = Task.CompletedTask;
+
+ ///
+ /// Probes for an MSIXVC2-capable packaging tool off the UI thread and publishes the result to
+ /// the bound property, which in turn re-evaluates the
+ /// Upload command's CanExecute state.
+ ///
+ private Task ProbeMsixvc2AvailabilityAsync()
+ {
+ return Task.Run(() =>
+ {
+ bool isAvailable = false;
+
+ try
+ {
+ isAvailable = ResolveMsixvc2Tool() is not null;
+ }
+ catch (Exception)
+ {
+ // The resolver already logs and swallows probe failures; this guards against a
+ // background exception escaping and tearing down the app.
+ isAvailable = false;
+ }
+
+ RunOnUiThread(() =>
+ {
+ // A different package may have been selected while the probe was in flight; don't
+ // publish a stale result over it.
+ if (!IsMsixvc2Package)
+ {
+ return;
+ }
+
+ Msixvc2UnavailableMessage = isAvailable
+ ? string.Empty
+ : Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg;
+ });
+ });
+ }
+
private bool IsUploadReady()
{
- // MSIXVC2 packages require makepkg2 tools
- if (IsMsixvc2Package && !string.IsNullOrEmpty(MakePkg2UnavailableMessage))
+ // MSIXVC2 packages require an MSIXVC2-capable packaging tool
+ if (IsMsixvc2Package && !string.IsNullOrEmpty(Msixvc2UnavailableMessage))
{
return false;
}
@@ -566,14 +632,18 @@ private void ProcessSelectedPackage()
if (XvcFile.IsLikelyMsixvc2Package(PackageFilePath))
{
IsMsixvc2Package = true;
- Msixvc2InfoMessage = "MSIXVC2 package detected. Upload is supported and will use the makepkg2 upload tool.";
-
- // Check if makepkg2 tools are installed
- string makePkg2Path = _pathConfigurationService.MakePkg2Path;
- if (string.IsNullOrEmpty(makePkg2Path) || !File.Exists(makePkg2Path))
- {
- MakePkg2UnavailableMessage = Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg;
- }
+ Msixvc2InfoMessage = "MSIXVC2 package detected. Upload is supported and will use the MSIXVC2 packaging tool.";
+
+ // Check that an MSIXVC2-capable packaging tool is installed. The probe launches a child
+ // process and can block for up to the probe timeout (twice, if MakePkg.exe fails and we
+ // fall back to makepkg2.exe), so it runs off the UI thread rather than freezing the app
+ // immediately after the user picks a file. Msixvc2UnavailableMessage's setter re-raises
+ // the Upload command's CanExecuteChanged when the result arrives.
+ //
+ // Until then the message stays empty and Upload may appear enabled; that is safe because
+ // StartMsixvc2Upload() re-resolves synchronously and routes to the error page if no tool
+ // is available, so a click that races the probe can never launch a missing tool.
+ Msixvc2ProbeTask = ProbeMsixvc2AvailabilityAsync();
try
{
@@ -609,7 +679,7 @@ private void ResetPackage()
PackageErrorMessage = string.Empty;
Msixvc2InfoMessage = string.Empty;
- MakePkg2UnavailableMessage = string.Empty;
+ Msixvc2UnavailableMessage = string.Empty;
IsMsixvc2Package = false;
PackageIdentityName = string.Empty;
@@ -1104,16 +1174,16 @@ private void OnCancelButton()
}
///
- /// Reroutes MSIXVC2 .msixvc package upload to makepkg2 upload tool.
+ /// Reroutes MSIXVC2 .msixvc package upload to the MSIXVC2 packaging tool.
/// Sets PackageModel properties and navigates to the MSIXVC2 uploading progress screen.
///
private void StartMsixvc2Upload()
{
- string makePkg2Path = _pathConfigurationService.MakePkg2Path;
- if (string.IsNullOrEmpty(makePkg2Path) || !File.Exists(makePkg2Path))
+ Msixvc2Tool? tool = ResolveMsixvc2Tool();
+ if (tool is null)
{
- SetErrorAndGoToErrorPage("makepkg2 Not Found",
- "makepkg2.exe was not found. Please install the Microsoft.Xbox.Packaging.Tools.makepkg2 NuGet package.");
+ SetErrorAndGoToErrorPage("MSIXVC2 packaging tool not found",
+ Resources.Strings.MainPage.MakePkg2NotFoundErrorMsg);
return;
}
@@ -1130,7 +1200,7 @@ private void StartMsixvc2Upload()
Package.PackageIdentityName = PackageIdentityName;
Package.FolderSize = PackageSize;
Package.UploadArguments = uploadArgs;
- Package.MakePkg2Path = makePkg2Path;
+ Package.Msixvc2ToolPath = tool.ExecutablePath;
Package.UploadOriginPage = typeof(PackageUploadView);
if (branchOrFlight != null)
{