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..e583c7786 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs @@ -73,7 +73,9 @@ public sealed record CreatePackageArgs( PackageGraphSource? PackageGraph = null, bool AutoSign = false, string? TimestampUrl = null, - FileInfo? AppxRecipe = null); + FileInfo? AppxRecipe = null, + string? Executable = null, + DirectoryInfo? WindowsAppSdkPackageDirectory = null); /// The most recent call's captured arguments, or null. public CreatePackageArgs? LastCreatePackageArgs { get; private set; } @@ -277,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); + 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..60ea1688e 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.Join(buildSdkDir.FullName, "tools", "MSIX", $"win10-{WorkspaceSetupService.GetSystemArchitecture()}")); + File.WriteAllLines( + Path.Join(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/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..d39b1bc90 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandProjectModeTests.cs @@ -981,4 +981,160 @@ 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"); + 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, WindowsAppSdkPackageDir: sdkDir.FullName), 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"); + Assert.AreEqual(sdkDir.FullName, args.WindowsAppSdkPackageDirectory!.FullName, "Packaging must use the Windows App SDK the build imported"); + } + + [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 + 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..b34974e3c --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.CppMode.cs @@ -0,0 +1,223 @@ +// 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, + windowsAppSdkPackageDirectory: resolution.WindowsAppSdkPackageDir is { } sdkDir ? new DirectoryInfo(sdkDir) : null, + 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'.")); 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)