From cc5d39b1e5a408b020a6f70553e4c4e764befc43 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Sat, 17 Oct 2026 18:29:23 -0400 Subject: [PATCH 1/3] Accept a C++ .vcxproj in winapp package winapp package .\MyApp.vcxproj now builds the project with Visual Studio's MSBuild (the same build winapp run uses) and packages the build output. Packaged WinUI projects use their generated AppxManifest.xml and appxrecipe; other application projects use an appxmanifest.xml from winapp init or --manifest. --framework and multi-arch bundles are rejected for C++. Unsupported file inputs now say which inputs are supported instead of reporting a misleading sparse-manifest error. Fixes #1115 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad679915-8632-4530-8c49-58683247cf14 --- docs/guides/cpp.md | 2 +- docs/usage.md | 23 +- plugins/winapp/skills/winapp-package/SKILL.md | 16 +- samples/cpp-winui-app/README.md | 8 + .../WinApp.Cli.Tests/FakeMsixService.cs | 5 +- .../PackageCommandFakeMsixTests.cs | 2 +- .../PackageCommandProjectModeTests.cs | 154 ++++++++++++ .../Commands/PackageCommand.CppMode.cs | 222 ++++++++++++++++++ .../WinApp.Cli/Commands/PackageCommand.cs | 48 ++-- 9 files changed, 452 insertions(+), 28 deletions(-) create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs diff --git a/docs/guides/cpp.md b/docs/guides/cpp.md index aada391b6..4c6ee0c12 100644 --- a/docs/guides/cpp.md +++ b/docs/guides/cpp.md @@ -9,7 +9,7 @@ Package identity is a core concept in the Windows app model. It allows your appl A standard executable (like one created with `cmake --build`) does not have package identity. This guide shows how to add it for debugging and then package it for distribution. > [!TIP] -> **Using a Visual Studio C++ project (`.vcxproj`) instead of CMake?** Run `winapp run .` in the project folder: winapp builds it with Visual Studio's MSBuild and launches it with package identity. See [C++ projects](../usage.md#c-projects-vcxproj) and the [cpp-winui-app sample](../../samples/cpp-winui-app/). +> **Using a Visual Studio C++ project (`.vcxproj`) instead of CMake?** Run `winapp run .` in the project folder: winapp builds it with Visual Studio's MSBuild and launches it with package identity. Run `winapp package .\MyApp.vcxproj` to build it and create an MSIX. See [C++ projects](../usage.md#c-projects-vcxproj) and the [cpp-winui-app sample](../../samples/cpp-winui-app/). ## Prerequisites diff --git a/docs/usage.md b/docs/usage.md index 33d4042ca..a20b132e3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -319,7 +319,7 @@ winapp update --setup-sdks experimental Create MSIX packages from a project or prepared application directories. Requires a manifest file (`Package.appxmanifest` preferred, `appxmanifest.xml` also supported) to be present in the target directory, in the current directory, or passed with the `--manifest` option. (run `init` or `manifest generate` to create a manifest) -Pass a single `.csproj` to build the project and package its output in one step (**project mode**, see [Packaging a project directly](#packaging-a-project-directly) below). Pass multiple input folders to create an `.msixbundle` for multi-architecture distribution (see [Multi-architecture bundles](#multi-architecture-bundles) below). +Pass a single `.csproj` or C++ `.vcxproj` to build the project and package its output in one step (**project mode**, see [Packaging a project directly](#packaging-a-project-directly) below). Pass multiple input folders to create an `.msixbundle` for multi-architecture distribution (see [Multi-architecture bundles](#multi-architecture-bundles) below). ```bash winapp pack [input-folder...] [options] @@ -327,7 +327,7 @@ winapp pack [input-folder...] [options] **Arguments:** -- `input-folder` - A single `.csproj` to build and package (project mode), or one or more directories containing the application files to package. Pass multiple folders (e.g., `./publish/x64 ./publish/arm64`) to create an MSIX bundle. For **sparse identity packages**, pass a sparse `appxmanifest.xml` file directly instead of a folder (see [Sparse identity packages](#sparse-identity-packages) below). +- `input-folder` - A single `.csproj` or C++ `.vcxproj` to build and package (project mode), or one or more directories containing the application files to package. Pass multiple folders (e.g., `./publish/x64 ./publish/arm64`) to create an MSIX bundle. For **sparse identity packages**, pass a sparse `appxmanifest.xml` file directly instead of a folder (see [Sparse identity packages](#sparse-identity-packages) below). **Options:** @@ -344,11 +344,11 @@ winapp pack [input-folder...] [options] - `--skip-pri` - Skip PRI file generation - `--executable ` - Path to the executable relative to the input folder (also `--exe`). Used to resolve `$targetnametoken$` placeholders in the manifest. -**Project-mode options** (require a `.csproj` input; rejected for folder/bundle/manifest inputs): +**Project-mode options** (require a `.csproj` or `.vcxproj` input; rejected for folder/bundle/manifest inputs): - `--configuration ` (`-c`) - Build configuration (default: `Release`) -- `--arch ` - Target architecture: `x64`, `arm64`, or `x86` (default: the current process architecture) -- `--framework ` (`-f`) - Target framework moniker for multi-targeted projects +- `--arch ` - Target architecture: `x64`, `arm64`, or `x86` (default: the current process architecture). Repeat it to build a multi-architecture bundle from a `.csproj`. +- `--framework ` (`-f`) - Target framework moniker for multi-targeted .NET projects - `--no-build` - Package the existing build output without rebuilding - `--no-restore` - Skip restoring the project before building - `--property ` (`-p`) - MSBuild property, forwarded to build and evaluation (repeatable) @@ -386,8 +386,18 @@ The target architecture comes from `--arch`, or from a lone `-p RuntimeIdentifie The project must build as a packaged app (`EnableMsixTooling=true` with a `Package.appxmanifest`); a project that builds as an unpackaged app (`WindowsPackageType=None`) has no MSIX manifest to package and `winapp pack` reports an actionable error. Folder, bundle, and sparse-manifest inputs are unchanged. -Project mode produces a single `.msix` or an architecture-only `.msixbundle` (see [Multi-architecture bundles](#multi-architecture-bundles)). It does not produce Store-upload archives or resource-split (language/scale) bundles: an explicit `-p UapAppxPackageBuildMode=StoreUpload` or `-p AppxBundleAutoResourcePackageQualifiers=...` is rejected with a note to run the native SDK packaging command directly for those flows. +.NET project mode produces a single `.msix` or an architecture-only `.msixbundle` (see [Multi-architecture bundles](#multi-architecture-bundles)). It does not produce Store-upload archives or resource-split (language/scale) bundles: an explicit `-p UapAppxPackageBuildMode=StoreUpload` or `-p AppxBundleAutoResourcePackageQualifiers=...` is rejected with a note to run the native SDK packaging command directly for those flows. +**C++ projects (`.vcxproj`).** winapp builds the project with Visual Studio's MSBuild, the same build `winapp run` uses (see [C++ projects](#c-projects-vcxproj) for prerequisites), then packages the build output folder: + +```powershell +winapp pack .\MyApp.vcxproj --cert .\devcert.pfx +winapp pack .\MyApp.vcxproj -p Platform=ARM64 --generate-cert +``` + +- Builds `Release` for the current architecture by default. `-c` or `-p Configuration=` selects the configuration, and `--arch` or `-p Platform=x64|ARM64|Win32` selects the architecture. +- A packaged project, such as the **WinUI Blank App (Packaged)** template, is packaged with the `AppxManifest.xml` its build generates. Any other application project needs an `appxmanifest.xml`: run `winapp init` in the project folder once, or pass `--manifest`. The built `.exe` fills a `$targetnametoken$` placeholder in that manifest. +- `--framework` and multiple `--arch` values aren't supported. To build a multi-architecture bundle, build each architecture and pass the output folders: `winapp pack `. #### Sparse identity packages When the input is a **sparse `appxmanifest.xml` file** (one declaring `true` under ``) rather than a folder, `winapp pack` builds an **identity-only** `.msix` — it packages just the manifest, with no application binaries or assets. This is step 2 of the [sparse packaging workflow](guides/sparse.md). @@ -980,6 +990,7 @@ winapp run . --no-build --detach --json - Build output shows MSBuild's warnings and errors only, with a spinner while it builds; `--verbose` shows MSBuild's full output and exact command. - In a folder or solution that also has a runnable C# app, the C# app is selected; use `--project ` to run the C++ app. C# libraries and test projects next to a C++ app don't get in the way. - A C# app that references a C++ project (for example a native DLL) can't be built with `dotnet`. Build it with Visual Studio or `MSBuild.exe`, then run `winapp run .csproj --no-build`, or package its output folder with `winapp package `. +- To create an MSIX from a C++ project, run `winapp package .\MyApp.vcxproj`. See [Packaging a project directly](#packaging-a-project-directly). **Prerequisites:** Visual Studio or Build Tools for Visual Studio 2022 version 17.8 or later, with the MSVC C++ build tools for the target architecture (the **Desktop development with C++** workload) and the Windows SDK the project targets. WinUI 3 apps also need **C++ WinUI app development tools**; with several installs, winapp uses the newest one that can build the project. The .NET SDK is not required. When the build tools, platform toolset (`MSB8020`), or Windows SDK (`MSB8036`) are missing, `winapp run` says what to install, including a `winget` command. A project with a `vcpkg.json` needs vcpkg integrated with MSBuild: run `vcpkg integrate install` once. diff --git a/plugins/winapp/skills/winapp-package/SKILL.md b/plugins/winapp/skills/winapp-package/SKILL.md index 3aa83ae32..bf9033551 100644 --- a/plugins/winapp/skills/winapp-package/SKILL.md +++ b/plugins/winapp/skills/winapp-package/SKILL.md @@ -1,6 +1,6 @@ --- name: winapp-package -description: Build an MSIX installer for a desktop, console, or CLI app (Electron, .NET, C++, Rust, Flutter, Tauri) from build output or a .csproj with winapp package, optionally signed. Use for an installer testers can sideload, app distribution, or MSIX in scripts or CI. Not for signing an existing MSIX. +description: Build an MSIX installer for a desktop, console, or CLI app (Electron, .NET, C++, Rust, Flutter, Tauri) from build output or a project file with winapp package, optionally signed. Use for an installer testers can sideload, app distribution, or MSIX in scripts or CI. Not for signing an existing MSIX. --- **If you can't run `winapp` yourself** (no shell, or the command is denied), give the user the exact `winapp` command(s) for their project instead of only describing the steps. @@ -17,6 +17,7 @@ Use this skill when: What you need depends on the input: - **Project mode** (`winapp package MyApp.csproj`): an explicit `.csproj` for a packaged app (`EnableMsixTooling=true` with a `Package.appxmanifest`). winapp publishes it and packages the output. +- **C++ project mode** (`winapp package MyApp.vcxproj`): winapp builds the project with Visual Studio's MSBuild and packages the build output. A packaged WinUI project uses the manifest its build generates; other projects need an `appxmanifest.xml` from `winapp init` or `--manifest`. - **Folder mode** (`winapp package ./bin/Release`): **built app output** in a folder (e.g., `bin/Release/`, `dist/`, `build/`) plus a **`Package.appxmanifest`** in the current directory, passed via `--manifest`, or in the folder. - **Certificate** (optional, both modes) — `devcert.pfx` from `winapp cert generate` for signing. @@ -45,6 +46,19 @@ repeatable `-p`), defaults to the **Release** configuration, resolves the publis packages it. If the project publishes as an unpackaged app (`WindowsPackageType=None`) there is no manifest to package and the command errors. Folder, bundle, and sparse-manifest inputs are unchanged. +### Package a C++ project (.vcxproj) + +```powershell +# Build with Visual Studio's MSBuild (Release, current architecture) and create an MSIX +winapp package ./MyApp.vcxproj --cert ./devcert.pfx + +# Pick the configuration and architecture the Visual Studio way +winapp package ./MyApp.vcxproj -p Configuration=Debug -p Platform=ARM64 --generate-cert +``` + +One architecture per run: `--framework` and multiple `--arch` values are rejected. For a bundle, build +each architecture and pass the output folders (see Multi-architecture bundles below). + ### Basic packaging (unsigned) ```powershell diff --git a/samples/cpp-winui-app/README.md b/samples/cpp-winui-app/README.md index d70167dc2..253d7f5a4 100644 --- a/samples/cpp-winui-app/README.md +++ b/samples/cpp-winui-app/README.md @@ -28,6 +28,14 @@ winapp run . --no-build Click **Show package identity** to see the package family name the app is running under. +## Packaging + +Build the project and create a signed MSIX in one step: + +```powershell +winapp package .\CppWinUIApp.vcxproj --generate-cert +``` + ## Testing with winapp ui ```powershell diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs index e8ccba79a..a325441cb 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs @@ -73,7 +73,8 @@ public sealed record CreatePackageArgs( PackageGraphSource? PackageGraph = null, bool AutoSign = false, string? TimestampUrl = null, - FileInfo? AppxRecipe = null); + FileInfo? AppxRecipe = null, + string? Executable = null); /// The most recent call's captured arguments, or null. public CreatePackageArgs? LastCreatePackageArgs { get; private set; } @@ -281,7 +282,7 @@ public Task CreateMsixPackageAsync( { CreatePackageCalls.Add(inputFolder); LastCreatePackageArgs = new CreatePackageArgs( - inputFolder, projectFile, framework, noRestore, targetArch, selfContained, runtimeAlreadyBundled, manifestPath, packageGraph, autoSign, timestampUrl, appxRecipe); + inputFolder, projectFile, framework, noRestore, targetArch, selfContained, runtimeAlreadyBundled, manifestPath, packageGraph, autoSign, timestampUrl, appxRecipe, executable); if (PackageExceptionToThrow != null) { throw PackageExceptionToThrow; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandFakeMsixTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandFakeMsixTests.cs index 59ca9c5d5..dd8110ea9 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandFakeMsixTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandFakeMsixTests.cs @@ -68,7 +68,7 @@ public async Task PackageCommand_Folder_ProjectOnlyOption_Rejected() Assert.AreEqual(1, exitCode, "a project-only option must be rejected for a folder input"); Assert.AreEqual(0, _fakeMsixService.CreatePackageCalls.Count, "no packaging must run when a project-only option is used with a folder"); - StringAssert.Contains(ConsoleStdErr.ToString(), "require a .csproj input"); + StringAssert.Contains(ConsoleStdErr.ToString(), "require a .csproj or .vcxproj input"); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs index b489e4e45..e1c328815 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs @@ -981,4 +981,158 @@ public async Task Package_DirectoryNamedLikeCsproj_StaysFolderMode() Assert.AreEqual(1, _fakeMsixService.CreatePackageCalls.Count); Assert.IsNull(_fakeMsixService.LastCreatePackageArgs!.ProjectFile, "Folder mode must not thread a project file"); } + + // ---- C++ (.vcxproj) project mode ------------------------------------------- + + private FileInfo CreateVcxproj(string name = "App.vcxproj") + { + var path = Path.Join(_tempDirectory.FullName, name); + File.WriteAllText(path, ""); + return new FileInfo(path); + } + + [TestMethod] + public async Task CppMode_Packaged_UsesGeneratedManifestAndRecipe() + { + var vcxproj = CreateVcxproj(); + var targetDir = CreateTargetDir(withManifest: false); + var generatedManifest = Path.Join(targetDir.FullName, "AppxManifest.xml"); + File.WriteAllText(generatedManifest, TestManifestContent); + var recipe = Path.Join(_tempDirectory.FullName, "App.build.appxrecipe"); + _fakeProjectRunService.BuildOutcome = new ProjectBuildOutcome( + new ProjectRunResolution(vcxproj, targetDir.FullName, null, ProjectPackaging.Packaged, false, "arm64", + AppxManifestPath: generatedManifest, AppxRecipePath: recipe), 0); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [vcxproj.FullName]); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeProjectRunService.BuildAndResolveCalls.Count, "C++ projects are built with MSBuild, not published"); + Assert.AreEqual(0, _fakeProjectRunService.PublishAndResolveCalls.Count); + Assert.AreEqual(0, _fakeProjectRunService.PreparationOptions.Count, ".NET package preparation must not run for C++"); + var args = _fakeMsixService.LastCreatePackageArgs!; + Assert.AreEqual(targetDir.FullName, args.InputFolder.FullName); + Assert.AreEqual(generatedManifest, args.ManifestPath!.FullName); + Assert.AreEqual(recipe, args.AppxRecipe!.FullName); + Assert.AreEqual("arm64", args.TargetArch); + Assert.IsNull(args.ProjectFile, "The vcxproj must not be passed to the .NET package-list probe"); + } + + [TestMethod] + public async Task CppMode_DefaultsToReleaseAndCurrentArch() + { + var vcxproj = CreateVcxproj(); + var targetDir = CreateTargetDir(withManifest: true); + _fakeProjectRunService.BuildOutcome = new ProjectBuildOutcome( + new ProjectRunResolution(vcxproj, targetDir.FullName, null, ProjectPackaging.Packaged, false, "x64"), 0); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [vcxproj.FullName]); + + Assert.AreEqual(0, exitCode); + var options = _fakeProjectRunService.BuildOptions.Single(); + Assert.AreEqual("Release", options.Configuration); + Assert.IsNull(options.Framework); + Assert.IsFalse(string.IsNullOrEmpty(options.Architecture)); + } + + [TestMethod] + public async Task CppMode_Unpackaged_UsesProjectManifestAndBuiltExe() + { + var vcxproj = CreateVcxproj(); + File.WriteAllText(Path.Join(_tempDirectory.FullName, "appxmanifest.xml"), TestManifestContent); + var targetDir = CreateTargetDir(withManifest: false); + var exe = Path.Join(targetDir.FullName, "App.exe"); + File.WriteAllText(exe, ""); + _fakeProjectRunService.BuildOutcome = new ProjectBuildOutcome( + new ProjectRunResolution(vcxproj, targetDir.FullName, exe, ProjectPackaging.Unpackaged, false, "x64"), 0); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [vcxproj.FullName]); + + Assert.AreEqual(0, exitCode); + var args = _fakeMsixService.LastCreatePackageArgs!; + Assert.AreEqual(Path.Join(_tempDirectory.FullName, "appxmanifest.xml"), args.ManifestPath!.FullName); + Assert.AreEqual("App.exe", args.Executable); + } + + [TestMethod] + public async Task CppMode_Unpackaged_NoManifest_SuggestsInit() + { + var project = _tempDirectory.CreateSubdirectory("proj"); + var vcxproj = new FileInfo(Path.Join(project.FullName, "App.vcxproj")); + File.WriteAllText(vcxproj.FullName, ""); + var targetDir = CreateTargetDir(withManifest: false); + _fakeProjectRunService.BuildOutcome = new ProjectBuildOutcome( + new ProjectRunResolution(vcxproj, targetDir.FullName, Path.Join(targetDir.FullName, "App.exe"), ProjectPackaging.Unpackaged, false, "x64"), 0); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [vcxproj.FullName]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeMsixService.CreatePackageCalls.Count); + StringAssert.Contains(ConsoleStdErr.ToString(), "winapp init"); + } + + [TestMethod] + public async Task CppMode_PropertyConfigurationAndPlatform_SelectBuild() + { + var vcxproj = CreateVcxproj(); + var targetDir = CreateTargetDir(withManifest: true); + _fakeProjectRunService.BuildOutcome = new ProjectBuildOutcome( + new ProjectRunResolution(vcxproj, targetDir.FullName, null, ProjectPackaging.Packaged, false, "arm64"), 0); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), + [vcxproj.FullName, "-p", "Configuration=Debug", "-p", "Platform=ARM64"]); + + Assert.AreEqual(0, exitCode); + var options = _fakeProjectRunService.BuildOptions.Single(); + Assert.AreEqual("Debug", options.Configuration); + Assert.AreEqual("arm64", options.Architecture); + } + + [TestMethod] + public async Task CppMode_ConflictingConfiguration_RejectedBeforeBuilding() + { + var vcxproj = CreateVcxproj(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), + [vcxproj.FullName, "-c", "Release", "-p", "Configuration=Debug"]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeProjectRunService.BuildAndResolveCalls.Count); + } + + [TestMethod] + [DataRow("--framework", "net10.0-windows")] + [DataRow("--arch", "x64", "--arch", "arm64")] + public async Task CppMode_UnsupportedOptions_RejectedBeforeBuilding(params string[] extra) + { + var vcxproj = CreateVcxproj(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [vcxproj.FullName, .. extra]); + + Assert.AreEqual(1, exitCode); + Assert.AreEqual(0, _fakeProjectRunService.BuildAndResolveCalls.Count); + } + + [TestMethod] + public async Task CppMode_BuildFailure_PropagatesExitCode() + { + var vcxproj = CreateVcxproj(); + _fakeProjectRunService.BuildOutcome = new ProjectBuildOutcome(null, 7); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [vcxproj.FullName]); + + Assert.AreEqual(7, exitCode); + Assert.AreEqual(0, _fakeMsixService.CreatePackageCalls.Count); + } + + [TestMethod] + public async Task Package_UnsupportedFile_ReportsSupportedInputs() + { + var file = Path.Join(_tempDirectory.FullName, "App.sln"); + File.WriteAllText(file, ""); + + var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [file]); + + Assert.AreEqual(1, exitCode); + StringAssert.Contains(ConsoleStdErr.ToString() + TestAnsiConsole.Output, "is not a supported input"); + } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs new file mode 100644 index 000000000..47ce1822d --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.CommandLine; +using System.CommandLine.Parsing; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Commands; + +internal partial class PackageCommand +{ + public partial class Handler + { + /// + /// C++ (.vcxproj) project mode: builds with Visual Studio's MSBuild (the same build + /// winapp run uses), then packages the build output folder. A packaged WinUI project supplies + /// its generated AppxManifest.xml and .appxrecipe; any other application project is + /// packaged with an appxmanifest.xml from --manifest, the project folder, the build + /// output, or the current directory, like winapp package <folder>. + /// + private async Task RunCppProjectModeAsync(ParseResult parseResult, FileInfo project, CancellationToken cancellationToken) + { + if (!project.Exists) + { + return Fail($"Project file not found: {project.FullName}"); + } + + if (parseResult.GetResult(FrameworkOption) is { Implicit: false }) + { + return Fail($"--framework applies to .NET projects and can't be used with a C++ project ({project.Name})."); + } + + var archInputs = parseResult.GetValue(ArchOption) ?? []; + if (parseResult.GetResult(ArchOption) is OptionResult archResult && + archResult.IdentifierTokenCount > archResult.Tokens.Count) + { + return Fail("A --arch option was provided without a value. Expected x64, arm64, or x86 (for example: --arch arm64)."); + } + if (archInputs.Length > 1) + { + return Fail($"Multi-architecture bundles aren't supported for C++ projects yet. Package one architecture at a time (for example: winapp package {project.Name} --arch arm64), " + + "or build each architecture with MSBuild and bundle the output folders: winapp package ."); + } + + var properties = parseResult.GetValue(PropertyOption) ?? []; + if (parseResult.GetResult(PropertyOption) is OptionResult propertyResult && + propertyResult.IdentifierTokenCount > propertyResult.Tokens.Count) + { + return Fail("A --property/-p option was provided without a value. Expected Name=Value (for example: -p Platform=ARM64)."); + } + if (MsBuildPropertyValidator.Validate(properties) is { } propertyError) + { + return Fail(propertyError); + } + + // -p Configuration=Release is how Visual Studio users pick the configuration, so treat it like -c. + // Both are allowed only when they name the same configuration. + var configuration = parseResult.GetValue(ConfigurationOption) ?? "Release"; + if (ProjectRunService.CppConfigurationFromProperties(properties) is { } propertyConfiguration) + { + if (parseResult.GetResult(ConfigurationOption) is { Implicit: false } + && !string.Equals(propertyConfiguration, configuration, StringComparison.OrdinalIgnoreCase)) + { + return Fail($"-p Configuration={propertyConfiguration} conflicts with -c {configuration}. Pass only one of them."); + } + configuration = propertyConfiguration; + } + + // --arch wins; otherwise -p Platform=ARM64 selects the architecture, as in Visual Studio. A + // conflicting --arch and -p Platform pair is rejected by the build. + string architecture; + if (archInputs.Length == 0 && ProjectRunService.CppArchitectureFromProperties(properties) is { } platformArch) + { + architecture = platformArch; + } + else if (!RunArchHelper.TryResolveArchitecture(archInputs.FirstOrDefault(), runtimeOption: null, out architecture, out var archError)) + { + return Fail(archError!); + } + + var output = parseResult.GetValue(OutputOption); + if (ValidateOutputExtension(output, isBundle: false) is { } outputExtensionError) + { + logger.LogError("{Message}", outputExtensionError); + return 1; + } + + var certPath = parseResult.GetValue(CertOption); + var generateCert = parseResult.GetValue(GenerateCertOption); + var noSign = parseResult.GetValue(NoSignOption); + if (noSign && (certPath != null || generateCert)) + { + return Fail("--no-sign cannot be combined with --cert or --generate-cert."); + } + + var name = parseResult.GetValue(NameOption); + var skipPri = parseResult.GetValue(SkipPriOption); + var certPassword = parseResult.GetRequiredValue(CertPasswordOption); + var installCert = parseResult.GetValue(InstallCertOption); + var publisher = parseResult.GetValue(PublisherOption); + var manifestPath = parseResult.GetValue(ManifestOption); + var selfContainedFlag = parseResult.GetValue(SelfContainedOption); + var executable = parseResult.GetValue(ExecutableOption); + var noBuild = parseResult.GetValue(NoBuildOption); + var noRestore = parseResult.GetValue(NoRestoreOption); + + if (logger.IsEnabled(LogLevel.Information)) + { + ansiConsole.MarkupLineInterpolated($"{UiSymbols.Search} {project.Name} · {configuration} | {architecture}"); + } + + ProjectBuildOutcome outcome; + try + { + // The owning solution defines $(SolutionDir), which C++ projects commonly use for output paths. + var solution = (await projectRunService.ResolveInputAsync(project, cancellationToken)).Solution; + var buildOptions = new ProjectRunOptions(configuration, architecture, Framework: null, noBuild, noRestore, properties, Json: false, Solution: solution); + outcome = await projectRunService.BuildAndResolveAsync(project, buildOptions, cancellationToken); + } + catch (ProjectRunException ex) + { + return Fail(ex.Message); + } + + if (outcome.Resolution is null) + { + // MSBuild already surfaced its diagnostics. Propagate its exit code. + return outcome.ExitCode == 0 ? 1 : outcome.ExitCode; + } + + var resolution = outcome.Resolution; + if (NetworkOutputDirError(project.Name, resolution.TargetDir) is { } targetDirError) + { + return Fail(targetDirError); + } + + var targetDir = new DirectoryInfo(resolution.TargetDir); + if (!targetDir.Exists) + { + return Fail(noBuild + ? $"The build output folder for '{project.Name}' was not found: {targetDir.FullName}. Remove --no-build so the project is built first." + : $"The build output folder for '{project.Name}' was not found: {targetDir.FullName}."); + } + + FileInfo? effectiveManifest = manifestPath; + FileInfo? appxRecipe = null; + if (resolution.Packaging == ProjectPackaging.Packaged) + { + // A packaged (MSIX) C++ project generates its own manifest and file list during the build. + effectiveManifest ??= resolution.AppxManifestPath is { Length: > 0 } generated && File.Exists(generated) + ? new FileInfo(generated) + : null; + appxRecipe = string.IsNullOrWhiteSpace(resolution.AppxRecipePath) ? null : new FileInfo(resolution.AppxRecipePath); + if (effectiveManifest == null && !ManifestHelper.FindManifest(targetDir.FullName).Exists) + { + return Fail(noBuild + ? $"'{project.Name}' is a packaged (MSIX) project but no AppxManifest.xml was found in its build output ({targetDir.FullName}). Remove --no-build so the project is built first." + : $"'{project.Name}' is a packaged (MSIX) project but the build did not produce an AppxManifest.xml in {targetDir.FullName}."); + } + } + else + { + // An unpackaged app (Win32 / console) has no generated manifest: use the one 'winapp init' + // created next to the project, then the build output and the current directory. + if (effectiveManifest == null && project.Directory is { } projectDirectory + && ManifestHelper.FindManifest(projectDirectory.FullName) is { Exists: true } projectManifest) + { + effectiveManifest = projectManifest; + } + if (effectiveManifest == null + && !ManifestHelper.FindManifest(targetDir.FullName).Exists + && !ManifestHelper.FindManifest(currentDirectoryProvider.GetCurrentDirectory()).Exists) + { + return Fail($"No appxmanifest.xml was found for '{project.Name}'. winapp looked in the project folder, the build output ({targetDir.FullName}), and the current folder. " + + $"Create one by running 'winapp init' in the project folder, or pass --manifest ."); + } + + // The built .exe fills a $targetnametoken$ placeholder in the manifest. + if (executable == null && resolution.RunCommand is { } exePath) + { + executable = Path.GetRelativePath(targetDir.FullName, exePath); + } + } + + var shouldSign = !noSign && (certPath != null || generateCert); + return await statusService.ExecuteWithStatusAsync("Creating MSIX package...", async (taskContext, ct) => + { + try + { + var result = await msixService.CreateMsixPackageAsync( + targetDir, output, taskContext, name, skipPri, shouldSign, certPath, certPassword, + generateCert, installCert, publisher, effectiveManifest, resolution.SelfContained || selfContainedFlag, executable, + targetArch: resolution.Architecture, + runtimeAlreadyBundled: resolution.SelfContained, + appxRecipe: appxRecipe, + cancellationToken: ct); + + taskContext.AddStatusMessage($"{UiSymbols.Package} Package: {result.MsixPath}"); + if (result.Signed) + { + taskContext.AddStatusMessage($"{UiSymbols.Lock} Package has been signed"); + } + + return (0, "MSIX package creation completed."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + taskContext.AddDebugMessage($"Stack Trace: {ex.StackTrace}"); + return (1, $"{UiSymbols.Error} Failed to create MSIX package: {ex.GetBaseException().Message}"); + } + }, cancellationToken); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs index dc688e5e3..a2bcbff5a 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs @@ -17,7 +17,7 @@ internal partial class PackageCommand : Command, IShortDescription, ICompactHelp { public string ShortDescription => "Create MSIX package or bundle"; - string? ICompactHelpCommand.Usage => "winapp package <.csproj | folder>... [options]"; + string? ICompactHelpCommand.Usage => "winapp package ... [options]"; IReadOnlyList<(string Command, string Description)> ICompactHelpCommand.Examples { get; } = [ @@ -34,7 +34,7 @@ internal partial class PackageCommand : Command, IShortDescription, ICompactHelp [ ("Signing", [CertOption, CertPasswordOption, GenerateCertOption, InstallCertOption, PublisherOption, NoSignOption]), ("Package contents", [OutputOption, ManifestOption, NameOption, ExecutableOption, SelfContainedOption, SkipPriOption]), - ("Build (.csproj input only)", [ConfigurationOption, ArchOption, FrameworkOption, PropertyOption, NoBuildOption, NoRestoreOption]), + ("Build (.csproj or .vcxproj input only)", [ConfigurationOption, ArchOption, FrameworkOption, PropertyOption, NoBuildOption, NoRestoreOption]), ]; public static Argument InputFolderArgument { get; } @@ -63,7 +63,7 @@ static PackageCommand() { InputFolderArgument = new Argument("input-folder") { - Description = "A single .csproj to build and package (project mode), one or more input folders with package layout, or a single sparse appxmanifest.xml file (an identity-only package with AllowExternalContent). Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64).", + Description = "A single .csproj or C++ .vcxproj to build and package (project mode), one or more input folders with package layout, or a single sparse appxmanifest.xml file (an identity-only package with AllowExternalContent). Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64).", Arity = ArgumentArity.OneOrMore }; OutputOption = new Option("--output") @@ -128,14 +128,14 @@ static PackageCommand() ConfigurationOption = new Option("--configuration") { HelpName = "name", - Description = "Project mode: build configuration (e.g., Debug, Release). Requires a .csproj input; rejected for folder/bundle/manifest inputs. Default: Release.", + Description = "Project mode: build configuration (e.g., Debug, Release). Requires a .csproj or .vcxproj input; rejected for folder/bundle/manifest inputs. Default: Release.", DefaultValueFactory = _ => "Release", }; ConfigurationOption.Aliases.Add("-c"); ArchOption = new Option("--arch") { - Description = "Project mode: target architecture (x64, arm64, or x86). Repeatable — pass two or more to publish each and produce one architecture .msixbundle. Requires a .csproj input; rejected for folder/bundle/manifest inputs. Default: the current process architecture.", + Description = "Project mode: target architecture (x64, arm64, or x86). Repeatable — pass two or more to publish each and produce one architecture .msixbundle (.csproj only). Requires a .csproj or .vcxproj input; rejected for folder/bundle/manifest inputs. Default: the current process architecture.", Arity = ArgumentArity.ZeroOrMore, AllowMultipleArgumentsPerToken = false, }; @@ -149,18 +149,18 @@ static PackageCommand() NoBuildOption = new Option("--no-build") { - Description = "Project mode: skip building and package the existing build output (still evaluates output properties). Requires a .csproj input; rejected for folder/bundle/manifest inputs." + Description = "Project mode: skip building and package the existing build output (still evaluates output properties). Requires a .csproj or .vcxproj input; rejected for folder/bundle/manifest inputs." }; NoRestoreOption = new Option("--no-restore") { - Description = "Project mode: skip restoring the project before building. Requires a .csproj input; rejected for folder/bundle/manifest inputs." + Description = "Project mode: skip restoring the project before building. Requires a .csproj or .vcxproj input; rejected for folder/bundle/manifest inputs." }; PropertyOption = new Option("--property") { HelpName = "Name=Value", - Description = "Project mode: MSBuild property as Name=Value, forwarded to both build and evaluation. Repeatable (e.g. -p WindowsPackageType=None). Use -c, -f, and --arch instead of -p for configuration, framework, and architecture. Requires a .csproj input; rejected for folder/bundle/manifest inputs.", + Description = "Project mode: MSBuild property as Name=Value, forwarded to both build and evaluation. Repeatable (e.g. -p WindowsPackageType=None). Use -c, -f, and --arch instead of -p for configuration, framework, and architecture. Requires a .csproj or .vcxproj input; rejected for folder/bundle/manifest inputs.", Arity = ArgumentArity.ZeroOrMore, AllowMultipleArgumentsPerToken = false, }; @@ -168,7 +168,7 @@ static PackageCommand() } public PackageCommand() - : base("package", "Create an MSIX installer from a built app folder or directly from a .csproj. Pass a package-layout folder (run after building your app; a manifest must be in the current directory, passed as --manifest, or in the input folder), or pass a .csproj to build and package it in one step (e.g. winapp package ./MyApp.csproj -c Release). Use --cert devcert.pfx to sign for testing.") + : base("package", "Create an MSIX installer from a built app folder or directly from a .csproj or C++ .vcxproj. Pass a package-layout folder (run after building your app; a manifest must be in the current directory, passed as --manifest, or in the input folder), or pass a project file to build and package it in one step (e.g. winapp package ./MyApp.csproj -c Release). Use --cert devcert.pfx to sign for testing.") { Aliases.Add("pack"); Arguments.Add(InputFolderArgument); @@ -291,15 +291,21 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var selfContained = parseResult.GetValue(SelfContainedOption); var executable = parseResult.GetValue(ExecutableOption); - // Project mode: a single explicit .csproj input (not an existing directory that merely - // happens to be named "*.csproj") builds the project and packages its output. Runs before - // the sparse/missing-directory checks below so a non-existent .csproj is reported as a + // Project mode: a single explicit .csproj or .vcxproj input (not an existing directory that + // merely happens to be named like one) builds the project and packages its output. Runs before + // the sparse/missing-directory checks below so a non-existent project is reported as a // missing project, not a missing input folder. Everything else keeps its existing routing. - if (inputFolders.Length == 1 - && inputFolders[0].Name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) - && !Directory.Exists(inputFolders[0].FullName)) + if (inputFolders.Length == 1 && !Directory.Exists(inputFolders[0].FullName)) { - return await RunProjectModeAsync(parseResult, new FileInfo(inputFolders[0].FullName), cancellationToken); + var projectFile = new FileInfo(inputFolders[0].FullName); + if (projectFile.Name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + return await RunProjectModeAsync(parseResult, projectFile, cancellationToken); + } + if (ProjectRunService.IsCppProject(projectFile)) + { + return await RunCppProjectModeAsync(parseResult, projectFile, cancellationToken); + } } // --no-sign forces an unsigned artifact for the remaining folder / sparse / bundle inputs and is @@ -334,7 +340,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var optionList = string.Join(", ", inapplicableProjectOptions); return await statusService.ExecuteWithStatusAsync("Validating input...", (taskContext, _) => { - return Task.FromResult((1, $"{UiSymbols.Error} These option(s) require a .csproj input (project mode) and do not apply to a folder, bundle, or manifest input: {optionList}. Remove them, or pass a .csproj to build and package.")); + return Task.FromResult((1, $"{UiSymbols.Error} These option(s) require a .csproj or .vcxproj input (project mode) and do not apply to a folder, bundle, or manifest input: {optionList}. Remove them, or pass a project file to build and package.")); }, cancellationToken); } @@ -441,6 +447,14 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio }, cancellationToken); } + if (manifestKind == ManifestInputKind.NotManifestName) + { + return await statusService.ExecuteWithStatusAsync("Validating input...", (taskContext, _) => + { + return Task.FromResult((1, $"{UiSymbols.Error} '{candidateManifest.Name}' is not a supported input. Pass a .csproj or .vcxproj project, a build output folder, or a sparse appxmanifest.xml.")); + }, cancellationToken); + } + return await statusService.ExecuteWithStatusAsync("Validating input...", (taskContext, _) => { return Task.FromResult((1, $"{UiSymbols.Error} Input is a file but not a sparse manifest (missing uap10:AllowExternalContent). Pass an input folder, or generate a sparse manifest with 'winapp init --exe --sparse'.")); From 53fe6c982e8ce983a892c5d0a2dae1cbda516e9e Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Tue, 20 Oct 2026 15:48:36 -0400 Subject: [PATCH 2/3] Keep the C++ build's Windows App Runtime dependency when packaging winapp package App.vcxproj now resolves the runtime dependency from the Windows App SDK the build imported, instead of the version winapp.yaml in the current folder names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad679915-8632-4530-8c49-58683247cf14 --- .../WinApp.Cli.Tests/FakeMsixService.cs | 6 +++-- .../MsixServiceRuntimeTests.cs | 26 +++++++++++++++++-- .../WinApp.Cli.Tests/MsixServiceTests.cs | 1 + .../PackageCommandProjectModeTests.cs | 6 +++-- .../Commands/PackageCommand.CppMode.cs | 1 + .../WinApp.Cli/Services/IMsixService.cs | 1 + .../Services/MsixService.Runtime.cs | 15 ++++++++--- .../WinApp.Cli/Services/MsixService.cs | 8 +++--- 8 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs index a325441cb..e583c7786 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs @@ -74,7 +74,8 @@ public sealed record CreatePackageArgs( bool AutoSign = false, string? TimestampUrl = null, FileInfo? AppxRecipe = null, - string? Executable = null); + string? Executable = null, + DirectoryInfo? WindowsAppSdkPackageDirectory = null); /// The most recent call's captured arguments, or null. public CreatePackageArgs? LastCreatePackageArgs { get; private set; } @@ -278,11 +279,12 @@ public Task CreateMsixPackageAsync( bool runtimeAlreadyBundled = false, string? timestampUrl = null, FileInfo? appxRecipe = null, + DirectoryInfo? windowsAppSdkPackageDirectory = null, CancellationToken cancellationToken = default) { CreatePackageCalls.Add(inputFolder); LastCreatePackageArgs = new CreatePackageArgs( - inputFolder, projectFile, framework, noRestore, targetArch, selfContained, runtimeAlreadyBundled, manifestPath, packageGraph, autoSign, timestampUrl, appxRecipe, executable); + inputFolder, projectFile, framework, noRestore, targetArch, selfContained, runtimeAlreadyBundled, manifestPath, packageGraph, autoSign, timestampUrl, appxRecipe, executable, windowsAppSdkPackageDirectory); if (PackageExceptionToThrow != null) { throw PackageExceptionToThrow; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs index 97fe1dc01..4d9962917 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs @@ -346,10 +346,10 @@ private void ArrangeRuntimeMsixCache(string sdkVersion, string runtimeIdentity) [$"Microsoft.WindowsAppRuntime.msix={runtimeIdentity}"]); } - private async Task InvokeUpdateWinAppSdkDependencyAsync(string manifest, DotNetPackageListJson? packageList) + private async Task InvokeUpdateWinAppSdkDependencyAsync(string manifest, DotNetPackageListJson? packageList, DirectoryInfo? windowsAppSdkPackageDirectory = null) { return await (Task)UpdateWinAppSdkDependencyMethod.Invoke( - _msixService, [manifest, packageList, TestTaskContext, CancellationToken.None])!; + _msixService, [manifest, packageList, TestTaskContext, CancellationToken.None, windowsAppSdkPackageDirectory])!; } private static string ManifestWithDependencies(string dependencyElements) => @@ -393,6 +393,28 @@ public async Task UpdateWindowsAppSdkDependency_ExistingRuntimeDependency_Update Assert.DoesNotContain("5000.0.0.0", result); } + [TestMethod] + public async Task UpdateWindowsAppSdkDependency_BuildPackageDirectory_WinsOverConfiguredVersion() + { + // The current folder resolves 1.6, but the C++ build imported a 1.8 Windows App SDK. + // The build's runtime must win so the packaged app depends on the runtime it was built against. + ArrangeRuntimeMsixCache("1.6.0", RuntimeIdentity); + var buildSdkDir = _tempDirectory.CreateSubdirectory("packages").CreateSubdirectory("Microsoft.WindowsAppSDK.1.8.0"); + var msixArchDir = Directory.CreateDirectory( + Path.Combine(buildSdkDir.FullName, "tools", "MSIX", $"win10-{WorkspaceSetupService.GetSystemArchitecture()}")); + File.WriteAllLines( + Path.Combine(msixArchDir.FullName, "msix.inventory"), + ["Microsoft.WindowsAppRuntime.msix=Microsoft.WindowsAppRuntime.1.8_8000.616.304.0_x64__8wekyb3d8bbwe"]); + var manifest = ManifestWithDependencies( + " "); + + var result = await InvokeUpdateWinAppSdkDependencyAsync(manifest, PackageListWith(SdkPackageId, "1.6.0"), buildSdkDir); + + Assert.Contains("Microsoft.WindowsAppRuntime.1.8", result); + Assert.Contains("8000.616.304.0", result); + Assert.DoesNotContain("Microsoft.WindowsAppRuntime.1.6", result); + } + [TestMethod] public async Task UpdateWindowsAppSdkDependency_DependenciesWithoutRuntime_AddsRuntimeDependency() { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs index a449d6c27..a63729403 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceTests.cs @@ -1351,6 +1351,7 @@ private static async Task InvokeUpdateAppxManifestContentAsync(MsixServi null, CreateTestTaskContext(), CancellationToken.None, + null, null ]) as dynamic; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs index e1c328815..d39b1bc90 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs @@ -999,9 +999,10 @@ public async Task CppMode_Packaged_UsesGeneratedManifestAndRecipe() var generatedManifest = Path.Join(targetDir.FullName, "AppxManifest.xml"); File.WriteAllText(generatedManifest, TestManifestContent); var recipe = Path.Join(_tempDirectory.FullName, "App.build.appxrecipe"); + var sdkDir = _tempDirectory.CreateSubdirectory("Microsoft.WindowsAppSDK.1.8.0"); _fakeProjectRunService.BuildOutcome = new ProjectBuildOutcome( new ProjectRunResolution(vcxproj, targetDir.FullName, null, ProjectPackaging.Packaged, false, "arm64", - AppxManifestPath: generatedManifest, AppxRecipePath: recipe), 0); + AppxManifestPath: generatedManifest, AppxRecipePath: recipe, WindowsAppSdkPackageDir: sdkDir.FullName), 0); var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [vcxproj.FullName]); @@ -1015,6 +1016,7 @@ public async Task CppMode_Packaged_UsesGeneratedManifestAndRecipe() Assert.AreEqual(recipe, args.AppxRecipe!.FullName); Assert.AreEqual("arm64", args.TargetArch); Assert.IsNull(args.ProjectFile, "The vcxproj must not be passed to the .NET package-list probe"); + Assert.AreEqual(sdkDir.FullName, args.WindowsAppSdkPackageDirectory!.FullName, "Packaging must use the Windows App SDK the build imported"); } [TestMethod] @@ -1133,6 +1135,6 @@ public async Task Package_UnsupportedFile_ReportsSupportedInputs() var exitCode = await ParseAndInvokeWithCaptureAsync(GetRequiredService(), [file]); Assert.AreEqual(1, exitCode); - StringAssert.Contains(ConsoleStdErr.ToString() + TestAnsiConsole.Output, "is not a supported input"); + StringAssert.Contains(ConsoleStdErr + TestAnsiConsole.Output, "is not a supported input"); } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs index 47ce1822d..b34974e3c 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs @@ -197,6 +197,7 @@ private async Task RunCppProjectModeAsync(ParseResult parseResult, FileInfo targetArch: resolution.Architecture, runtimeAlreadyBundled: resolution.SelfContained, appxRecipe: appxRecipe, + windowsAppSdkPackageDirectory: resolution.WindowsAppSdkPackageDir is { } sdkDir ? new DirectoryInfo(sdkDir) : null, cancellationToken: ct); taskContext.AddStatusMessage($"{UiSymbols.Package} Package: {result.MsixPath}"); diff --git a/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs index bfde012bb..4eeff0479 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs @@ -31,6 +31,7 @@ public Task CreateMsixPackageAsync( bool runtimeAlreadyBundled = false, string? timestampUrl = null, FileInfo? appxRecipe = null, + DirectoryInfo? windowsAppSdkPackageDirectory = null, CancellationToken cancellationToken = default); /// diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Runtime.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Runtime.cs index 65e90d621..83f1e4493 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Runtime.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Runtime.cs @@ -578,9 +578,9 @@ internal static void AppendAppManifestFromAppx( /// /// The manifest content to modify /// The modified manifest content - private async Task UpdateWindowsAppSdkDependencyAsync(string manifestContent, DotNetPackageListJson? dotNetPackageList, TaskContext taskContext, CancellationToken cancellationToken) + private async Task UpdateWindowsAppSdkDependencyAsync(string manifestContent, DotNetPackageListJson? dotNetPackageList, TaskContext taskContext, CancellationToken cancellationToken, DirectoryInfo? windowsAppSdkPackageDirectory = null) { - var winAppSdkInfo = await GetWindowsAppSdkDependencyInfoAsync(dotNetPackageList, taskContext, cancellationToken); + var winAppSdkInfo = await GetWindowsAppSdkDependencyInfoAsync(dotNetPackageList, taskContext, cancellationToken, windowsAppSdkPackageDirectory); if (winAppSdkInfo == null) { @@ -645,11 +645,18 @@ private async Task UpdateWindowsAppSdkDependencyAsync(string manifestCon /// Gets the Windows App SDK dependency information from the locked winapp.yaml config and package source /// /// The dependency information, or null if not found - private async Task GetWindowsAppSdkDependencyInfoAsync(DotNetPackageListJson? dotNetPackageList, TaskContext taskContext, CancellationToken cancellationToken) + private async Task GetWindowsAppSdkDependencyInfoAsync(DotNetPackageListJson? dotNetPackageList, TaskContext taskContext, CancellationToken cancellationToken, DirectoryInfo? windowsAppSdkPackageDirectory = null) { try { - var msixDir = await GetRuntimeMsixDirAsync(dotNetPackageList, taskContext, cancellationToken); + // A C++ build names the Windows App SDK package folder it imported; its runtime is the one the + // app needs, so prefer it over the folder-based (winapp.yaml / cache) lookup. + var importedMsixDir = windowsAppSdkPackageDirectory is null + ? null + : new DirectoryInfo(Path.Join(windowsAppSdkPackageDirectory.FullName, "tools", "MSIX")); + var msixDir = importedMsixDir is { Exists: true } + ? importedMsixDir + : await GetRuntimeMsixDirAsync(dotNetPackageList, taskContext, cancellationToken); if (msixDir == null) { return null; diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs index 20704d4aa..6aca82b48 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs @@ -340,6 +340,7 @@ public async Task CreateMsixPackageAsync( bool runtimeAlreadyBundled = false, string? timestampUrl = null, FileInfo? appxRecipe = null, + DirectoryInfo? windowsAppSdkPackageDirectory = null, CancellationToken cancellationToken = default) { // Validate input folder and manifest @@ -430,7 +431,7 @@ public async Task CreateMsixPackageAsync( } } - (manifestContent, var packageArch) = await UpdateAppxManifestContentAsync(manifestContent, null, null, resolvedExePath, sparse: isSparseManifest, selfContained: selfContained, dotNetPackageList, taskContext, cancellationToken, targetArch); + (manifestContent, var packageArch) = await UpdateAppxManifestContentAsync(manifestContent, null, null, resolvedExePath, sparse: isSparseManifest, selfContained: selfContained, dotNetPackageList, taskContext, cancellationToken, targetArch, windowsAppSdkPackageDirectory); // Parse the manifest to extract identity, executable, and architecture info var manifestDoc = AppxManifestDocument.Parse(manifestContent); @@ -1028,7 +1029,8 @@ internal static bool IsRuntimeToolExecutable(string fileName) DotNetPackageListJson? dotNetPackageList, TaskContext taskContext, CancellationToken cancellationToken, - string? targetArch = null) + string? targetArch = null, + DirectoryInfo? windowsAppSdkPackageDirectory = null) { var doc = AppxManifestDocument.Parse(originalAppxManifestContent); @@ -1124,7 +1126,7 @@ internal static bool IsRuntimeToolExecutable(string fileName) // Update or insert Windows App SDK dependency (skip for self-contained packages) if (!selfContained && (entryPointPath == null || isExe)) { - modifiedContent = await UpdateWindowsAppSdkDependencyAsync(modifiedContent, dotNetPackageList, taskContext, cancellationToken); + modifiedContent = await UpdateWindowsAppSdkDependencyAsync(modifiedContent, dotNetPackageList, taskContext, cancellationToken, windowsAppSdkPackageDirectory); } // Add InProcessServer entries for third-party WinRT components (e.g., Win2D, WebView2) From 4a8f8146b06883c44e3580a6cc2e6d51f48060e4 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Wed, 21 Oct 2026 13:31:33 -0400 Subject: [PATCH 3/3] Use Path.Join in the runtime dependency test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad679915-8632-4530-8c49-58683247cf14 --- src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs index 4d9962917..60ea1688e 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/MsixServiceRuntimeTests.cs @@ -401,9 +401,9 @@ public async Task UpdateWindowsAppSdkDependency_BuildPackageDirectory_WinsOverCo ArrangeRuntimeMsixCache("1.6.0", RuntimeIdentity); var buildSdkDir = _tempDirectory.CreateSubdirectory("packages").CreateSubdirectory("Microsoft.WindowsAppSDK.1.8.0"); var msixArchDir = Directory.CreateDirectory( - Path.Combine(buildSdkDir.FullName, "tools", "MSIX", $"win10-{WorkspaceSetupService.GetSystemArchitecture()}")); + Path.Join(buildSdkDir.FullName, "tools", "MSIX", $"win10-{WorkspaceSetupService.GetSystemArchitecture()}")); File.WriteAllLines( - Path.Combine(msixArchDir.FullName, "msix.inventory"), + Path.Join(msixArchDir.FullName, "msix.inventory"), ["Microsoft.WindowsAppRuntime.msix=Microsoft.WindowsAppRuntime.1.8_8000.616.304.0_x64__8wekyb3d8bbwe"]); var manifest = ManifestWithDependencies( " ");