Repository navigation
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✅ 4218 passed, 5 skipped out of 4223 tests in 878.9s (+51 tests, +219.2s vs. baseline) Test Coverage✅ 93% line coverage, 87.2% branch coverage · CLI Startup Time51ms median (x64, Try This BuildInstalls the MSIX for your architecture, replacing any previously installed build. Needs the GitHub CLI — the command offers to install it and sign you in if it is missing. & ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) 607Switching between builds often?Put the tool on your PATH once: & ([scriptblock]::Create((irm https://raw.githubusercontent.com/microsoft/winappCli/main/scripts/winapp-pr.ps1))) -AddToPathThen this build is just: winapp-pr 607Run Updated 2026-08-04 19:47:48 UTC · commit |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Nikola Metulev (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.
# Conflicts: # docs/npm-usage.md # src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs # src/winapp-VSC/package.json # src/winapp-VSC/src/extension.ts
- embed-identity auto-detect now prefers a sparse manifest so a full Package.appxmanifest alongside appxmanifest.xml is no longer picked and rejected. - Manifest embedding writes temp files under the system temp directory with unique names instead of fixed names beside the exe, avoiding silent deletion of user files. - Strip any existing <msix> from the extracted manifest before the mt.exe merge so re-branding an exe is idempotent instead of failing c1010001. - Remove stray semicolon in the generated assemblyIdentity fusion snippet. - Fix cert filename in sparse guide (devcert.pfx, matching cert generate). - Add regression tests for temp-file safety and <msix> stripping. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de996470-3bd8-45b4-a88d-810d3799467a
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 51 out of 55 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (1)
samples/sparse-app/installer/setup.iss:26
- The installer script lives under
installer/, and Inno resolves relative[Files]sources from the script directory by default. Consequently this path resolves asinstaller/bin/...; compiling the documentedinstaller/setup.isscurrently fails on line 42 with “No files found matching ...\installer\bin\...”. Set the source root to the sample's parent directory so bothPublishDirandMyMsixNameresolve to the artifacts produced by the README commands.
#define PublishDir "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish"
Copilot review: - embed-identity: warn users to re-sign the exe after mt.exe rewrites it (invalidates any existing Authenticode signature). - sparse-app setup.iss: resolve relative [Files] sources from the sample dir (SourceDir=..) so PublishDir and the identity .msix resolve correctly. - sparse-app setup.iss: unregister by exact Identity Name (Get-AppxPackage -Name SparseAppSample) instead of a SparseAppSample* wildcard. - setup skill fragment: document the sparse identity init workflow and outputs; regenerate the plugin/claude skill mirrors. Code quality (CodeQL generic-catch + LINQ/using): - EmbedIdentityCommand: use LINQ over a static readonly candidate array; narrow IsSparseManifest catch; rethrow OperationCanceledException before generic catch. - PackageCommand: narrow IsSparseManifestAsync catch; rethrow cancellation. - InitCommand: rethrow cancellation before the sparse-init generic catch. - ManifestService: using var for the extracted Icon/Bitmap; narrow the FileVersionInfo and cleanup catches; rethrow cancellation in logo extraction. The temp-file data-loss re-flag was already fixed in b18bad3 (temp manifests live under Path.GetTempPath()); no code change needed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de996470-3bd8-45b4-a88d-810d3799467a
- init: reject the sparse-only options (--exe/--name/--publisher/--output-dir) when --sparse is absent, so scripts fail instead of silently discarding input. - ManifestService: reject inferred versions whose components exceed 65535 (MSIX Identity/@Version is 16-bit) so inference falls back to a packable default; add regression cases to NormalizeManifestVersion tests. - embed-identity: document the actual manifest search order (target dir first, then current directory) in the --manifest description and the sparse guide, instead of the inaccurate "./appxmanifest.xml" default. - sparse guide: note that EXE mode invalidates the Authenticode signature. - sparse-app sample: copy Assets/ to build/publish output so the external content location has the logos the manifest references. - sparse-app app.manifest: fix the invalid XML comment (removed the literal "--manifest", which XML comments cannot contain). - Regenerate docs/cli-schema.json, the identity skill, and the npm winapp-commands.ts binding to match (schema version pinned at 0.5.1). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de996470-3bd8-45b4-a88d-810d3799467a
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 56 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (3)
src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs:148
- This sparse branch returns before the normal init path, so the positional
base-directoryand existing options such as--config-dir,--config-only,--setup-sdks,--ignore-config, and--no-gitignoreare silently ignored. For example,winapp init ./identity --exe ./app.exe --sparsesucceeds but writes beside the exe instead of./identity. Reject incompatible arguments with an actionable error, or explicitly map the positional directory to sparse output semantics.
src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs:27 - The help text says the default is
./appxmanifest.xml, but the implementation first searches beside the target, then the current directory, and also considersPackage.appxmanifest. This can select a different identity than the documented default. Describe the actual precedence here so--help, CLI schema, npm docs, and generated skills remain accurate after regeneration.
samples/sparse-app/sparse-app.csproj:18 - The installer copies only the publish directory, but this project does not mark the checked-in
Assets/files for output or publish copying. A normaldotnet publishtherefore omits the external assets referenced byappxmanifest.xml, so the production installer cannot deploy the external-content layout it claims to provide. Add copy metadata forAssets/**and assert the published assets exist in the sample test.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 67 out of 72 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
samples/sparse-app/installer/setup.iss:123
- The fallback unregisters the currently working package after any
Add-AppxPackagefailure, not only a same-version conflict. A bad/untrusted replacement package, locked files, or a transient deployment error therefore removes the existing registration; when the retry fails, Inno rolls back files but does not restore that registration, leaving the previously installed app without identity. Only remove on the specific conflict this fallback is intended to handle, and preserve/restore the prior registration if the retry fails.
The register-sparse.ps1 docs helper and the sparse-app setup.iss inline registration unregistered the existing package on ANY first Add-AppxPackage failure, then retried. An untrusted or corrupt new .msix (or an unsupported OS) would trip the catch, remove a working prior registration, then fail the retry too — leaving the installed app with no identity. Gate the unregister+retry on HRESULT 0x80073CFB (ERROR_PACKAGE_ALREADY_EXISTS, the same-version-already-registered conflict Add-AppxPackage rejects) and re-throw every other failure so a bad package can never strip existing identity. Verified the parsing of 0x80073CFB is identical on Windows PowerShell 5.1 and PowerShell 7, and validated both control-flow paths (conflict -> remove+retry; untrusted -> abort without removing) with mocked cmdlets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de996470-3bd8-45b4-a88d-810d3799467a
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 67 out of 72 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
samples/sparse-app/installer/setup.iss:144
RaiseExceptionfrom this[Files]AfterInstallcallback does not fail Setup. I compiled and ran the installer with an untrusted package certificate:RegisterParamsreturned exit code 1, but Inno logged the exception as a suppressed “Expression error,” continued creating shortcuts/uninstall metadata, exited 0, and left the app installed with noSparseAppSamplepackage registered. This turns a registration failure into an apparently successful identity-less install. Move registration to an Inno execution path whose failure propagates to Setup, and explicitly clean up copied files/registration on failure rather than relying on automatic rollback.
The direct-file sparse pack path staged the input manifest with File.Copy, bypassing the sparse corrections folder packing applies. A manifest from an older template or hand-edit could ship with RuntimeBehavior=packagedClassicApp, a missing ProcessorArchitecture, or a MinVersion below the 10.0.19041.0 that AllowExternalContent requires. Add MsixService.NormalizeSparseIdentityManifest, applied when staging the manifest: forces win32App/mediumIL for an .exe app, removes EntryPoint, defaults a missing ProcessorArchitecture to neutral, and raises any TargetDeviceFamily MinVersion below 10.0.19041.0. Corrections are surfaced as status messages. No-op for a freshly generated (correct) manifest. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de996470-3bd8-45b4-a88d-810d3799467a
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Folder inputs route sparse manifests through UpdateAppxManifestContentAsync, but that rewrite did not raise TargetDeviceFamily/@MinVersion. A folder created from an older sparse template kept 10.0.18362.0; because packing uses MakeAppx /nv, the command could still produce and sign an MSIX deployment rejects, since AllowExternalContent requires 10.0.19041.0. Extract the MinVersion flooring from NormalizeSparseIdentityManifest into a shared RaiseSparseTargetDeviceFamilyMinVersion helper and apply it in the folder-packing sparse block, so both the manifest-file and folder paths enforce the same 10.0.19041.0 floor. Corrections are surfaced as status messages. Add a folder-path regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de996470-3bd8-45b4-a88d-810d3799467a
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 67 out of 72 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/winapp-CLI/WinApp.Cli/Services/MsixService.SparsePackaging.cs:242
- This does not actually prevent an invalid identity package from being produced.
ParseAppxManifestAsynconly checks thatIdentity.Name,Identity.Publisher, andApplication.Idattributes exist; empty values, a missingIdentity.Version, and missing required package sections such asDependencies/TargetDeviceFamilystill pass. Because packaging deliberately uses MakeAppx/nv, such a manifest can be packed and signed successfully but then fail duringAdd-AppxPackage. Please validate the complete deployment-required sparse manifest structure (and non-empty/schema-valid attribute values) before packaging.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 67 out of 72 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/winapp-npm/src/cli-args.ts:94
- This does not match the native parser for repeated occurrences. The built CLI resolves
--sparse=false --sparsetofalse(the explicit value wins over the bare occurrence), while this returnstrue; two valued occurrences are rejected by native parsing rather than using the last value. In the npm wrapper, that mismatch selects the sparse fast path and can reject--add-js-bindingseven though nativeinitis in normal mode. Track explicit values separately and bypass wrapper hooks for combinations the native parser will reject.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 67 out of 71 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
docs/guides/sparse.md:215
- The documented retry removes the existing working registration before the replacement is known to succeed. If the second
Add-AppxPackagefails, the installer receives an error but the old app has already lost package identity. Avoid unregistering for a same-version reinstall, or preserve and restore the prior registration when the retry fails.
Get-AppxPackage -Name $PackageName | Remove-AppxPackage -ErrorAction SilentlyContinue
Add-AppxPackage -Path $MsixPath -ExternalLocation $ExternalLocation -ErrorAction Stop
src/winapp-CLI/WinApp.Cli/Services/MsixService.SparsePackaging.cs:205
- A missing or malformed
MinVersionbypasses this normalization becauseVersion.TryParsereturns false. Since sparse packages are packed with MakeAppx/nv, the command can then report success (and even sign the output) for a package that deployment rejects. Treat an unparseable value like a below-floor value, or fail validation explicitly.
| 'Get-AppxPackage -Name ''' + EscapePSLiteral('{#MyPackageName}') + ''' | Remove-AppxPackage -ErrorAction SilentlyContinue; ' + | ||
| 'Add-AppxPackage -Path ''' + MsixPath + ''' -ExternalLocation ''' + EscapePSLiteral(AppDir) + ''' -ErrorAction Stop } ' + |
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").