Skip to content

Add support for sparse packaging workflows#607

Open
zateutsch wants to merge 6 commits into
mainfrom
zt/286-sparse-packaging
Open

Add support for sparse packaging workflows#607
zateutsch wants to merge 6 commits into
mainfrom
zt/286-sparse-packaging

Conversation

@zateutsch

Copy link
Copy Markdown
Contributor

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-time
helper that requires Developer Mode and registers a raw manifest. There was no supported path to
produce a signed, identity-only .msix for distribution. This PR covers steps 1–3 of the MS
docs 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. Assets
and 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:

Step Command What it does
1 winapp init --exe <exe> --sparse Generate a sparse identity manifest + placeholder assets, inferring defaults from the exe's FileVersionInfo
2 winapp pack <manifest> --cert <pfx> Build & sign the identity-only .msix (infers sparse from AllowExternalContent)
3 winapp embed-identity <exe|xml> Embed the <msix> identity element into the app's SxS/fusion manifest
4–5 (installer) Add-AppxPackage -ExternalLocation / Remove-AppxPackagenot the CLI's job

What this adds

1. winapp init --exe <exe> --sparse

  • Generates a sparse-specific appxmanifest.xml from the template, plus placeholder assets in Assets/.
  • Infers package name, publisher CN, version, and description from the exe via FileVersionInfo, with sensible fallbacks.
  • Interactive prompts to override inferred values; --use-defaults / --no-prompt for CI.
  • Skips SDK/package installation entirely — identity packages have no SDK dependencies.
  • Validates that --exe requires --sparse, with a clear error otherwise.
  • Output clarifies that generated assets live at the external install location, not inside the .msix.

2. winapp pack — sparse-aware

  • Auto-detects sparse mode by checking for AllowExternalContent="true" in the manifest.
  • Accepts an appxmanifest.xml directly (no folder required): stages a manifest-only directory and packs it.
  • Signs only when a cert is supplied (--cert / --generate-cert).
  • When a folder is passed for a sparse manifest, emits warnings (not errors) for stray assets/binaries that shouldn't be in an identity package.

3. winapp embed-identity <exe|xml> (new command)

  • EXE mode: embeds the <msix> element into the exe's RT_MANIFEST via mt.exe.
  • XML mode: inserts/replaces the <msix> element in an external SxS manifest file.
  • Mode auto-detected from the file extension.
  • Reads identity from --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).
  • MinVersion raised to 10.0.19041.0 (required for AllowExternalContent / uap10).
  • Added ProcessorArchitecture="neutral" to <Identity> (identity-only packages carry no binaries).

Documentation

  • New guide: docs/guides/sparse.md — overview, prerequisites, end-to-end walkthrough, asset handling, installer integration (NSIS/WiX/Inno), and troubleshooting.
  • Updated: 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 the
sparse package on install (and unregisters on uninstall). MainWindow queries
Package.Current and displays the package family name (or "No package identity" when unregistered).
Includes a Pester test.Tests.ps1 and is wired into the test-samples CI matrix.

What the CLI does not do

  • Register/unregister — installer's job (Add-AppxPackage -ExternalLocation).
  • Replace create-debug-identity — kept as-is for the debug workflow.
  • Per-machine vs per-user — installer's concern.

Testing

  • New SparsePackagingTests.cs (+426 lines) covering init inference/validation, sparse pack
    routing (manifest-vs-folder, warnings), embed-identity EXE/XML modes, non-sparse rejection,
    and output-path resolution (including dotted-directory and .msixbundle edge cases).
  • FakeMsixService extended for command-level routing tests.
  • Full sparse suite passes locally (dotnet test ... --filter "FullyQualifiedName~SparsePackaging").

Copilot AI review requested due to automatic review settings July 8, 2026 02:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's FileVersionInfo, skipping SDK install), makes winapp pack sparse-aware (auto-detects AllowExternalContent, accepts a manifest file directly, stages a manifest-only package), and introduces the new winapp embed-identity <exe|xml> command.
  • Corrects the sparse template per the MS docs schema (win32App runtime 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 new docs/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.

Comment thread .github/plugin/agents/winapp.agent.md Outdated
Comment thread .claude/agents/winapp.md Outdated
Comment thread samples/sparse-app/installer/setup.iss Outdated
Comment thread src/winapp-CLI/WinApp.Cli/Services/MsixService.cs Outdated
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 31.71 MB 31.90 MB 📈 +192.5 KB (+0.59%)
CLI (x64) 32.04 MB 32.22 MB 📈 +183.0 KB (+0.56%)
MSIX (ARM64) 13.30 MB 13.37 MB 📈 +67.8 KB (+0.50%)
MSIX (x64) 14.15 MB 14.22 MB 📈 +65.7 KB (+0.45%)
NPM Package 27.77 MB 27.89 MB 📈 +120.6 KB (+0.42%)
NuGet Package 27.79 MB 27.92 MB 📈 +127.8 KB (+0.45%)
VS Code Extension 20.60 MB 20.72 MB 📈 +123.2 KB (+0.58%)

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 Time

47ms median (x64, winapp --version) · ✅ no change vs. baseline


Updated 2026-07-09 22:50:06 UTC · commit 5de6f86 · workflow run

zateutsch and others added 3 commits July 8, 2026 11:53
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

@nmetulev nmetulev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-identity EXE path, the identity-only .msix contents (CreateSparseIdentityPackageAsync), and init --sparse without --exe don't appear to be asserted in SparsePackagingTests.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.cs to ~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}"`;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 under Path.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 generated assemblyIdentity string ends with />; — a stray semicolon in the fusion manifest. Harmless because mt.exe strips it on re-serialization, but worth cleaning up.

Comment thread docs/guides/sparse.md
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Add sparse packaging support (winapp sparse command group)

3 participants