Add support for sparse packaging workflows#607
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds first-class support for the production sparse packaging workflow (identity-only MSIX) to the winapp CLI, implementing steps 1–3 of the Microsoft docs flow. It complements the existing developer-time create-debug-identity helper with a supported path to produce a signed, identity-only .msix for distribution, and to embed that identity into an app's side-by-side manifest.
Changes:
- Adds
winapp init --exe <exe> --sparse(generate a sparse identity manifest + placeholder assets, inferring defaults from the exe'sFileVersionInfo, skipping SDK install), makeswinapp packsparse-aware (auto-detectsAllowExternalContent, accepts a manifest file directly, stages a manifest-only package), and introduces the newwinapp embed-identity <exe|xml>command. - Corrects the sparse template per the MS docs schema (
win32Appruntime behavior,MinVersion 10.0.19041.0,ProcessorArchitecture="neutral"), and surfaces the new command/options through the npm SDK and the VS Code extension. - Adds a WPF
samples/sparse-app/(with Inno Setup installer + Pester test wired into CI), a newdocs/guides/sparse.md, and regenerates docs/schema/skill fragments.
Reviewed changes
Copilot reviewed 50 out of 54 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs |
New CreateSparseIdentityPackageAsync, EmbedIdentityAsync, XML-manifest embedding, and sparse output-path resolution |
src/winapp-CLI/WinApp.Cli/Services/MsixService.cs |
Sparse detection/warning helpers and template runtime-behavior handling (minor indentation regression flagged) |
src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs |
Routes manifest-file input with AllowExternalContent to the sparse packaging path |
src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs |
Adds --exe/--sparse options and sparse init flow with validation |
src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs |
New embed-identity command (EXE/XML modes, non-sparse rejection) |
src/winapp-CLI/WinApp.Cli/Templates/appxmanifest.sparse.xml |
Template corrected to MS docs schema (win32App, MinVersion, neutral arch) |
src/winapp-npm/src/winapp-commands.ts |
npm SDK bindings for embed-identity and new init sparse options |
src/winapp-VSC/src/extension.ts, src/winapp-VSC/package.json |
Registers the winapp.embedIdentity VS Code command |
.github/plugin/agents/winapp.agent.md, .claude/agents/winapp.md |
Agent docs updated for embed-identity (regression: winapp run heading dropped) |
samples/sparse-app/* |
New WPF sample, Inno Setup installer, and Pester test |
docs/*, docs/cli-schema.json |
New sparse guide plus regenerated usage/schema/skill docs |
src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs, FakeMsixService.cs |
Tests for init inference/validation, pack routing, and embed-identity modes |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Build Metrics ReportBinary Sizes
Test Results✅ 1568 passed, 1 skipped out of 1569 tests in 376.8s (+23 tests, -109.4s vs. baseline) Test Coverage❌ 18.3% line coverage, 37.5% branch coverage · ✅ +0.2% vs. baseline CLI Startup Time47ms median (x64, Updated 2026-07-09 22:50:06 UTC · commit |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
nmetulev
left a comment
There was a problem hiding this comment.
Review: sparse packaging workflow
I reviewed this branch across security, correctness, CLI UX, alternative solutions, test coverage, docs/samples, and packaging, then empirically validated the findings by running the real winapp init --sparse -> pack -> embed-identity workflow against three genuine executables: a .NET exe, the actual electron.exe, and a Rust cargo exe.
Bottom line: the feature works end-to-end on all three frameworks — identity embeds correctly, Electron's existing manifest (Common-Controls, trustInfo, dpiAware, supportedOS) is preserved, and quoted publisher DNs (CN="GitHub, Inc.") are XML-escaped. The items below are worth addressing; details are in the inline comments.
Scope note
Some issues live in EmbedMsixIdentityToExeAsync, which pre-exists on main (previously only used internally for debug-identity embedding). This PR doesn't introduce those bugs, but the new embed-identity command routes user-supplied exes into that method for the first time, which materially widens their blast radius. They're flagged at the new call site (MsixService.Identity.cs:181) and labeled accordingly.
Findings
| ID | Sev | Area | Where | Issue | Repro |
|---|---|---|---|---|---|
| H1 | High | security | extension.ts:560/567 (sink :66) | PowerShell injection: file-picker path interpolated unescaped into terminal.sendText |
executed arbitrary code |
| M1 | Med | correctness | Identity.cs (pre-existing, new call :181) | Fixed-name temp manifests deleted from target dir -> silent user-file loss | deleted planted files |
| M2 | Med | cli-ux | EmbedIdentityCommand.cs:51 | Auto-detect prefers Package.appxmanifest over sparse appxmanifest.xml; fails when both present |
exit 1 |
| M3 | Med | docs | sparse.md:68 | --cert ./dev.pfx but cert generate writes devcert.pfx |
verified |
| M8 | Med | correctness | Identity.cs (pre-existing, new call :181) | Re-embedding a changed identity hard-fails with cryptic mt.exe c1010001 | exit 1 |
| L2 | Low | correctness | Identity.cs:655 (pre-existing) | Stray ; in generated fusion manifest (mt.exe strips it -> cosmetic) |
fires, stripped |
Also worth a look (outside the diff hunks, so no inline anchor)
- Test coverage: the new
embed-identityEXE path, the identity-only.msixcontents (CreateSparseIdentityPackageAsync), andinit --sparsewithout--exedon't appear to be asserted inSparsePackagingTests.cs. An idempotency/rerun test would have caught M8. - README: the PR links the new sample in the samples table (
:259) but not the guide in the "Additional guides" section (:130). - File size: this change grows
MsixService.Identity.csto ~1,209 lines (was 850) — over the repo's ~1,000-line guideline; a partial-class split would help.
Reviewed with the pr-review skill + a GPT cross-check, then validated empirically on dotnet/electron/rust repro apps.
| return; | ||
| } | ||
|
|
||
| let command = `embed-identity "${target}"`; |
There was a problem hiding this comment.
Security (high): PowerShell command injection. target here comes from the file picker (selectFile) and is interpolated unescaped into command, which runWinappCommand then sends verbatim to a powershell.exe terminal via terminal.sendText(& "${cliPath}" ${command}) (line 66). PowerShell expands $(...) inside double quotes, so a file literally named $(Set-Content -Path pwned.txt -Value OWNED).exe — a legal Windows filename the picker will happily return — runs arbitrary code the moment it's selected. I reproduced this end-to-end (the payload created pwned.txt). The --manifest value on line 567 has the same issue.
The sink is shared with the other winapp.* commands, so the fix belongs in runWinappCommand: invoke the CLI via an argv array (e.g. child_process.execFile) instead of string-building into sendText, or at minimum single-quote each interpolated value and escape embedded single quotes (PowerShell does not expand inside single quotes).
| if (manifest == null) | ||
| { | ||
| var targetDir = target.Directory?.FullName; | ||
| var besideTarget = targetDir != null ? ManifestHelper.FindManifest(targetDir) : null; |
There was a problem hiding this comment.
Auto-detect can pick the wrong manifest. ManifestHelper.FindManifest returns the first existing of ["Package.appxmanifest", "appxmanifest.xml"]. In a project that has both a full Package.appxmanifest and the sparse appxmanifest.xml (a common state once someone has done both regular and sparse packaging), this returns Package.appxmanifest, and embed-identity then fails with 'Package.appxmanifest' is not a sparse identity manifest even though the correct sparse manifest is right there. Reproduced (exit 1).
Consider having embed-identity's auto-detect prefer appxmanifest.xml (the sparse file init --exe --sparse just wrote), or skip non-sparse candidates rather than hard-failing on the first match.
| } | ||
|
|
||
| taskContext.AddDebugMessage($"Embedding <msix> identity into exe fusion manifest: {target.Name}"); | ||
| await EmbedMsixIdentityToExeAsync(target, identity, taskContext, cancellationToken); |
There was a problem hiding this comment.
This new call newly exposes EmbedMsixIdentityToExeAsync to user-supplied exes. The method itself is pre-existing (previously only used internally for debug-identity embedding), so the issues below aren't introduced here — but embed-identity routes arbitrary user exes into it for the first time, widening their blast radius. All reproduced across dotnet/electron/rust:
- Data loss (medium). The EXE path writes fixed-name temp files next to the target exe —
temp_extracted.manifest,msix_identity_temp.manifest,merged.manifest— and unconditionally deletes them afterward. If the user already has files with those names in that directory, they're silently destroyed and the command still reports success (exit 0). I planted such files and they were deleted. Suggest unique temp names underPath.GetTempPath(). - Re-brand hard-fail (medium). Re-running with a changed identity fails with a cryptic
mt.exe ... error c1010001: Values of attribute "publisher" not equal in different manifest snippets(exit 1), because the existing<msix>isn't removed before the mt.exe merge — unlike the new XML path (EmbedIdentityIntoXmlManifestAsync), which removes-then-re-adds and is idempotent. Same-identity reruns are fine. - Stray
;(low/cosmetic). The generatedassemblyIdentitystring ends with/>;— a stray semicolon in the fusion manifest. Harmless because mt.exe strips it on re-serialization, but worth cleaning up.
| Point `winapp pack` at the sparse manifest (a file, not a folder): | ||
|
|
||
| ```powershell | ||
| winapp pack ./bin/Release/net8.0-windows/appxmanifest.xml --cert ./dev.pfx |
There was a problem hiding this comment.
Cert filename mismatch. This step uses --cert ./dev.pfx, but the guide tells the reader to create the cert with winapp cert generate (line 35), which writes devcert.pfx by default — not dev.pfx. Following the guide verbatim, winapp pack ... --cert ./dev.pfx fails because that file doesn't exist. Either change this to --cert ./devcert.pfx, or show winapp cert generate --output dev.pfx in the prerequisites.
Add sparse packaging support to the winapp CLI
fixes #286
Summary
Adds first-class support for the production sparse packaging workflow described in the
MS docs: Grant package identity to non-packaged apps.
Previously, the only sparse-related capability was
create-debug-identity— a developer-timehelper that requires Developer Mode and registers a raw manifest. There was no supported path to
produce a signed, identity-only
.msixfor distribution. This PR covers steps 1–3 of the MSdocs workflow (manifest → signed
.msix→ embed identity into the exe). Steps 4–5(register/unregister) remain the installer's responsibility.
The sparse packaging flow
A sparse package is an identity-only MSIX: it contains just a manifest (no binaries, no
bundled assets) and grants Windows package identity to an app that installs normally to the file
system. Identity unlocks modern Windows APIs (notifications, background tasks, share target,
startup tasks,
Package.Current, etc.) for otherwise unpackaged Win32/WPF/WinForms apps. Assetsand binaries are resolved from an external content location at runtime, registered via
Add-AppxPackage -ExternalLocation.The three CLI steps map directly to the MS docs:
winapp init --exe <exe> --sparseFileVersionInfowinapp pack <manifest> --cert <pfx>.msix(infers sparse fromAllowExternalContent)winapp embed-identity <exe|xml><msix>identity element into the app's SxS/fusion manifestAdd-AppxPackage -ExternalLocation/Remove-AppxPackage— not the CLI's jobWhat this adds
1.
winapp init --exe <exe> --sparseappxmanifest.xmlfrom the template, plus placeholder assets inAssets/.FileVersionInfo, with sensible fallbacks.--use-defaults/--no-promptfor CI.--exerequires--sparse, with a clear error otherwise..msix.2.
winapp pack— sparse-awareAllowExternalContent="true"in the manifest.appxmanifest.xmldirectly (no folder required): stages a manifest-only directory and packs it.--cert/--generate-cert).3.
winapp embed-identity <exe|xml>(new command)<msix>element into the exe's RT_MANIFEST viamt.exe.<msix>element in an external SxS manifest file.--manifest(default./appxmanifest.xml) and rejects non-sparse manifests with a clear error, since identity embedding only applies to external-location packages.4. Sparse template & code corrections (per MS docs schema)
RuntimeBehavior="packagedClassicApp"→"win32App"(correct for plain Win32, not Desktop Bridge).MinVersionraised to10.0.19041.0(required forAllowExternalContent/uap10).ProcessorArchitecture="neutral"to<Identity>(identity-only packages carry no binaries).Documentation
docs/guides/sparse.md— overview, prerequisites, end-to-end walkthrough, asset handling, installer integration (NSIS/WiX/Inno), and troubleshooting.docs/usage.md,docs/npm-usage.md, README, CLI schema, and the Copilot/Claude skill fragments (identity,package,setup).Sample:
samples/sparse-app/A minimal WPF sample demonstrating the full flow end-to-end, including an Inno Setup
installer (
installer/setup.iss) that installs the app, deploys the.msix, and registers thesparse package on install (and unregisters on uninstall).
MainWindowqueriesPackage.Currentand displays the package family name (or "No package identity" when unregistered).Includes a Pester
test.Tests.ps1and is wired into thetest-samplesCI matrix.What the CLI does not do
Add-AppxPackage -ExternalLocation).create-debug-identity— kept as-is for the debug workflow.Testing
SparsePackagingTests.cs(+426 lines) covering init inference/validation, sparse packrouting (manifest-vs-folder, warnings),
embed-identityEXE/XML modes, non-sparse rejection,and output-path resolution (including dotted-directory and
.msixbundleedge cases).FakeMsixServiceextended for command-level routing tests.dotnet test ... --filter "FullyQualifiedName~SparsePackaging").