From 24e02a4080bfe303cebdd8a45bb686209588d6a1 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Thu, 2 Jul 2026 14:14:38 -0400 Subject: [PATCH 1/5] sparse packaging support draft --- .claude/skills/winapp-identity/SKILL.md | 36 +++ .claude/skills/winapp-package/SKILL.md | 18 +- .claude/skills/winapp-setup/SKILL.md | 5 + .../skills/winapp-cli/identity/SKILL.md | 36 +++ .../plugin/skills/winapp-cli/package/SKILL.md | 18 +- .../plugin/skills/winapp-cli/setup/SKILL.md | 5 + .github/workflows/test-samples.yml | 7 +- docs/cli-schema.json | 124 +++++++++ docs/fragments/skills/winapp-cli/identity.md | 20 ++ docs/fragments/skills/winapp-cli/package.md | 18 +- docs/guides/sparse.md | 181 ++++++++++++ docs/npm-usage.md | 123 ++++++++- docs/usage.md | 72 ++++- samples/sparse-app/.gitignore | 17 ++ samples/sparse-app/App.xaml | 9 + samples/sparse-app/App.xaml.cs | 10 + samples/sparse-app/AssemblyInfo.cs | 10 + .../sparse-app/Assets/Square150x150Logo.png | Bin 0 -> 733 bytes samples/sparse-app/Assets/Square44x44Logo.png | Bin 0 -> 326 bytes samples/sparse-app/Assets/StoreLogo.png | Bin 0 -> 456 bytes samples/sparse-app/Assets/Wide310x150Logo.png | Bin 0 -> 806 bytes samples/sparse-app/MainWindow.xaml | 16 ++ samples/sparse-app/MainWindow.xaml.cs | 40 +++ samples/sparse-app/README.md | 118 ++++++++ samples/sparse-app/app.manifest | 26 ++ samples/sparse-app/appxmanifest.xml | 55 ++++ samples/sparse-app/installer/setup.iss | 63 +++++ samples/sparse-app/sparse-app.csproj | 18 ++ samples/sparse-app/test.Tests.ps1 | 148 ++++++++++ scripts/generate-llm-docs.ps1 | 2 +- .../WinApp.Cli.Tests/FakeMsixService.cs | 37 +++ .../WinApp.Cli.Tests/ManifestCommandTests.cs | 2 +- .../WinApp.Cli.Tests/PackageCommandTests.cs | 2 +- .../WinApp.Cli.Tests/SparsePackagingTests.cs | 261 ++++++++++++++++++ .../Commands/EmbedIdentityCommand.cs | 84 ++++++ .../WinApp.Cli/Commands/InitCommand.cs | 113 ++++++++ .../WinApp.Cli/Commands/PackageCommand.cs | 64 +++++ .../WinApp.Cli/Commands/WinAppRootCommand.cs | 4 +- .../Helpers/HostBuilderExtensions.cs | 1 + .../WinApp.Cli/Services/IManifestService.cs | 21 ++ .../Services/IManifestTemplateService.cs | 2 + .../WinApp.Cli/Services/IMsixService.cs | 30 ++ .../WinApp.Cli/Services/ManifestService.cs | 133 ++++++++- .../Services/ManifestTemplateService.cs | 12 +- .../Services/MsixService.Identity.cs | 174 ++++++++++++ .../WinApp.Cli/Services/MsixService.cs | 44 ++- .../Templates/appxmanifest.sparse.xml | 8 +- src/winapp-npm/src/winapp-commands.ts | 36 +++ 48 files changed, 2198 insertions(+), 25 deletions(-) create mode 100644 docs/guides/sparse.md create mode 100644 samples/sparse-app/.gitignore create mode 100644 samples/sparse-app/App.xaml create mode 100644 samples/sparse-app/App.xaml.cs create mode 100644 samples/sparse-app/AssemblyInfo.cs create mode 100644 samples/sparse-app/Assets/Square150x150Logo.png create mode 100644 samples/sparse-app/Assets/Square44x44Logo.png create mode 100644 samples/sparse-app/Assets/StoreLogo.png create mode 100644 samples/sparse-app/Assets/Wide310x150Logo.png create mode 100644 samples/sparse-app/MainWindow.xaml create mode 100644 samples/sparse-app/MainWindow.xaml.cs create mode 100644 samples/sparse-app/README.md create mode 100644 samples/sparse-app/app.manifest create mode 100644 samples/sparse-app/appxmanifest.xml create mode 100644 samples/sparse-app/installer/setup.iss create mode 100644 samples/sparse-app/sparse-app.csproj create mode 100644 samples/sparse-app/test.Tests.ps1 create mode 100644 src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs create mode 100644 src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs diff --git a/.claude/skills/winapp-identity/SKILL.md b/.claude/skills/winapp-identity/SKILL.md index c818d048..a774f857 100644 --- a/.claude/skills/winapp-identity/SKILL.md +++ b/.claude/skills/winapp-identity/SKILL.md @@ -131,6 +131,26 @@ winapp create-debug-identity .\bin\Debug\myapp.exe For full details including IDE setup examples, see the [Debugging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/debugging.md). +## Production sparse packaging (`init --sparse` / `pack` / `embed-identity`) + +`create-debug-identity` is for **developer-time debugging** (requires Developer Mode, registers a raw manifest). For **production** — shipping identity to an app distributed by an existing installer (Inno Setup, WiX, NSIS) — use the sparse packaging workflow, which produces a signed identity-only `.msix`: + +```powershell +# 1. Create the sparse identity manifest for your exe (skips SDK install) +winapp init --exe ./bin/Release/MyApp.exe --sparse --use-defaults + +# 2. Build and sign the identity-only .msix (just the manifest, no binaries) +winapp pack ./bin/Release/appxmanifest.xml --cert ./dev.pfx + +# 3. Embed the identity element into the exe's fusion manifest +winapp embed-identity ./bin/Release/MyApp.exe +``` + +Then your installer registers the package against the install directory: +`Add-AppxPackage -Path MyApp.identity.msix -ExternalLocation `. + +Assets are resolved from the external (install) location at runtime, **not** bundled into the `.msix`. `winapp embed-identity` also supports an XML mode (`winapp embed-identity ./app.manifest`) for updating a checked-in side-by-side manifest. See the [Sparse Packaging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/sparse.md) and the [sparse-app sample](https://github.com/microsoft/WinAppCli/tree/main/samples/sparse-app). + ## Related skills - Need a manifest? See `winapp-manifest` to generate `Package.appxmanifest` - Need a certificate? See `winapp-signing` — a trusted cert is required for identity registration @@ -165,3 +185,19 @@ Enable package identity for debugging without creating full MSIX. Required for t | `--keep-identity` | Keep the package identity from the manifest as-is, without appending '.debug' to the package name and application ID. | (none) | | `--manifest` | Path to the Package.appxmanifest or appxmanifest.xml | (none) | | `--no-install` | Do not install the package after creation. | (none) | + +### `winapp embed-identity` + +Connect a desktop exe to its sparse identity package by embedding the element. Reads identity (packageName, publisher, applicationId) from a sparse appxmanifest.xml and writes it into the target's side-by-side (fusion) manifest. EXE targets are updated with mt.exe; .xml/.manifest targets are edited directly. Example: winapp embed-identity ./bin/MyApp.exe. This is step 3 of the sparse packaging workflow (after 'winapp init --exe --sparse' and 'winapp pack'). + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--manifest` | Path to the sparse appxmanifest.xml to read identity from (default: ./appxmanifest.xml) | (none) | diff --git a/.claude/skills/winapp-package/SKILL.md b/.claude/skills/winapp-package/SKILL.md index 4ad43928..a4303ffc 100644 --- a/.claude/skills/winapp-package/SKILL.md +++ b/.claude/skills/winapp-package/SKILL.md @@ -175,10 +175,22 @@ Use the `microsoft/setup-winapp` action to install winapp on GitHub-hosted runne - The `--executable` flag overrides the entry point in the manifest — useful when your exe name differs from what's in `Package.appxmanifest` - For production distribution, use a certificate from a trusted CA and add `--timestamp` when signing with `winapp sign` +## Sparse identity packages + +To grant identity to an app distributed by an existing installer (not as MSIX), build an **identity-only** sparse package: pass a sparse `appxmanifest.xml` (one declaring `uap10:AllowExternalContent="true"`) to `winapp pack` instead of a folder. + +```powershell +# 1. Generate the sparse manifest for your exe (skips SDK install) +winapp init --exe ./bin/Release/MyApp.exe --sparse --use-defaults +# 2. Build & sign the identity-only .msix (just the manifest) +winapp pack ./bin/Release/appxmanifest.xml --cert ./dev.pfx +# 3. Embed identity into the exe, then register in your installer +winapp embed-identity ./bin/Release/MyApp.exe +``` + +The `.msix` contains only the manifest — binaries and assets are resolved from the external content location at runtime via `Add-AppxPackage -ExternalLocation`. If you pack a folder whose manifest declares `AllowExternalContent`, `winapp pack` warns about any assets/binaries found. See the [Sparse Packaging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/sparse.md). + ## Related skills -- Need a manifest first? See `winapp-manifest` to generate `Package.appxmanifest` -- Need a certificate? See `winapp-signing` for certificate generation and management -- Having issues? See `winapp-troubleshoot` for a command selection flowchart and error solutions ## Troubleshooting | Error | Cause | Solution | diff --git a/.claude/skills/winapp-setup/SKILL.md b/.claude/skills/winapp-setup/SKILL.md index 00d87db9..ead8f3d7 100644 --- a/.claude/skills/winapp-setup/SKILL.md +++ b/.claude/skills/winapp-setup/SKILL.md @@ -180,9 +180,14 @@ Start here for initializing a Windows app with required setup. Sets up everythin |--------|-------------|---------| | `--config-dir` | Directory to read/store configuration (default: the selected project directory, or current directory if no project is detected) | (none) | | `--config-only` | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | (none) | +| `--exe` | Path to the application executable. Requires --sparse. Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup. | (none) | | `--ignore-config` | Don't use configuration file for version management | (none) | +| `--name` | Override the package name (sparse only; default: inferred from the exe) | (none) | | `--no-gitignore` | Don't update .gitignore file | (none) | +| `--output-dir` | Directory to write the sparse manifest and Assets/ (sparse only; default: the exe's directory) | (none) | +| `--publisher` | Override the publisher CN (sparse only; default: inferred from the exe's company name). Bare names are auto-wrapped as CN=. | (none) | | `--setup-sdks` | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | (none) | +| `--sparse` | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. | (none) | | `--use-defaults` | Do not prompt; requires an explicit project directory (e.g., winapp init . --use-defaults) | (none) | ### `winapp restore` diff --git a/.github/plugin/skills/winapp-cli/identity/SKILL.md b/.github/plugin/skills/winapp-cli/identity/SKILL.md index c818d048..a774f857 100644 --- a/.github/plugin/skills/winapp-cli/identity/SKILL.md +++ b/.github/plugin/skills/winapp-cli/identity/SKILL.md @@ -131,6 +131,26 @@ winapp create-debug-identity .\bin\Debug\myapp.exe For full details including IDE setup examples, see the [Debugging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/debugging.md). +## Production sparse packaging (`init --sparse` / `pack` / `embed-identity`) + +`create-debug-identity` is for **developer-time debugging** (requires Developer Mode, registers a raw manifest). For **production** — shipping identity to an app distributed by an existing installer (Inno Setup, WiX, NSIS) — use the sparse packaging workflow, which produces a signed identity-only `.msix`: + +```powershell +# 1. Create the sparse identity manifest for your exe (skips SDK install) +winapp init --exe ./bin/Release/MyApp.exe --sparse --use-defaults + +# 2. Build and sign the identity-only .msix (just the manifest, no binaries) +winapp pack ./bin/Release/appxmanifest.xml --cert ./dev.pfx + +# 3. Embed the identity element into the exe's fusion manifest +winapp embed-identity ./bin/Release/MyApp.exe +``` + +Then your installer registers the package against the install directory: +`Add-AppxPackage -Path MyApp.identity.msix -ExternalLocation `. + +Assets are resolved from the external (install) location at runtime, **not** bundled into the `.msix`. `winapp embed-identity` also supports an XML mode (`winapp embed-identity ./app.manifest`) for updating a checked-in side-by-side manifest. See the [Sparse Packaging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/sparse.md) and the [sparse-app sample](https://github.com/microsoft/WinAppCli/tree/main/samples/sparse-app). + ## Related skills - Need a manifest? See `winapp-manifest` to generate `Package.appxmanifest` - Need a certificate? See `winapp-signing` — a trusted cert is required for identity registration @@ -165,3 +185,19 @@ Enable package identity for debugging without creating full MSIX. Required for t | `--keep-identity` | Keep the package identity from the manifest as-is, without appending '.debug' to the package name and application ID. | (none) | | `--manifest` | Path to the Package.appxmanifest or appxmanifest.xml | (none) | | `--no-install` | Do not install the package after creation. | (none) | + +### `winapp embed-identity` + +Connect a desktop exe to its sparse identity package by embedding the element. Reads identity (packageName, publisher, applicationId) from a sparse appxmanifest.xml and writes it into the target's side-by-side (fusion) manifest. EXE targets are updated with mt.exe; .xml/.manifest targets are edited directly. Example: winapp embed-identity ./bin/MyApp.exe. This is step 3 of the sparse packaging workflow (after 'winapp init --exe --sparse' and 'winapp pack'). + +#### Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). | + +#### Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--manifest` | Path to the sparse appxmanifest.xml to read identity from (default: ./appxmanifest.xml) | (none) | diff --git a/.github/plugin/skills/winapp-cli/package/SKILL.md b/.github/plugin/skills/winapp-cli/package/SKILL.md index 4ad43928..a4303ffc 100644 --- a/.github/plugin/skills/winapp-cli/package/SKILL.md +++ b/.github/plugin/skills/winapp-cli/package/SKILL.md @@ -175,10 +175,22 @@ Use the `microsoft/setup-winapp` action to install winapp on GitHub-hosted runne - The `--executable` flag overrides the entry point in the manifest — useful when your exe name differs from what's in `Package.appxmanifest` - For production distribution, use a certificate from a trusted CA and add `--timestamp` when signing with `winapp sign` +## Sparse identity packages + +To grant identity to an app distributed by an existing installer (not as MSIX), build an **identity-only** sparse package: pass a sparse `appxmanifest.xml` (one declaring `uap10:AllowExternalContent="true"`) to `winapp pack` instead of a folder. + +```powershell +# 1. Generate the sparse manifest for your exe (skips SDK install) +winapp init --exe ./bin/Release/MyApp.exe --sparse --use-defaults +# 2. Build & sign the identity-only .msix (just the manifest) +winapp pack ./bin/Release/appxmanifest.xml --cert ./dev.pfx +# 3. Embed identity into the exe, then register in your installer +winapp embed-identity ./bin/Release/MyApp.exe +``` + +The `.msix` contains only the manifest — binaries and assets are resolved from the external content location at runtime via `Add-AppxPackage -ExternalLocation`. If you pack a folder whose manifest declares `AllowExternalContent`, `winapp pack` warns about any assets/binaries found. See the [Sparse Packaging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/sparse.md). + ## Related skills -- Need a manifest first? See `winapp-manifest` to generate `Package.appxmanifest` -- Need a certificate? See `winapp-signing` for certificate generation and management -- Having issues? See `winapp-troubleshoot` for a command selection flowchart and error solutions ## Troubleshooting | Error | Cause | Solution | diff --git a/.github/plugin/skills/winapp-cli/setup/SKILL.md b/.github/plugin/skills/winapp-cli/setup/SKILL.md index 00d87db9..ead8f3d7 100644 --- a/.github/plugin/skills/winapp-cli/setup/SKILL.md +++ b/.github/plugin/skills/winapp-cli/setup/SKILL.md @@ -180,9 +180,14 @@ Start here for initializing a Windows app with required setup. Sets up everythin |--------|-------------|---------| | `--config-dir` | Directory to read/store configuration (default: the selected project directory, or current directory if no project is detected) | (none) | | `--config-only` | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | (none) | +| `--exe` | Path to the application executable. Requires --sparse. Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup. | (none) | | `--ignore-config` | Don't use configuration file for version management | (none) | +| `--name` | Override the package name (sparse only; default: inferred from the exe) | (none) | | `--no-gitignore` | Don't update .gitignore file | (none) | +| `--output-dir` | Directory to write the sparse manifest and Assets/ (sparse only; default: the exe's directory) | (none) | +| `--publisher` | Override the publisher CN (sparse only; default: inferred from the exe's company name). Bare names are auto-wrapped as CN=. | (none) | | `--setup-sdks` | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | (none) | +| `--sparse` | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. | (none) | | `--use-defaults` | Do not prompt; requires an explicit project directory (e.g., winapp init . --use-defaults) | (none) | ### `winapp restore` diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index 4e7d3a28..439e752c 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -18,6 +18,7 @@ on: - flutter-app - packaging-cli - rust-app + - sparse-app - tauri-app - wpf-app @@ -57,7 +58,7 @@ jobs: strategy: fail-fast: false matrix: - sample: [cpp-app, dotnet-app, electron, flutter-app, packaging-cli, rust-app, tauri-app, wpf-app] + sample: [cpp-app, dotnet-app, electron, flutter-app, packaging-cli, rust-app, sparse-app, tauri-app, wpf-app] runs-on: windows-latest name: ${{ matrix.sample }} @@ -116,7 +117,7 @@ jobs: - name: Setup .NET if: >- steps.check.outputs.skip != 'true' && - contains(fromJson('["dotnet-app", "wpf-app", "packaging-cli", "electron"]'), matrix.sample) + contains(fromJson('["dotnet-app", "wpf-app", "sparse-app", "packaging-cli", "electron"]'), matrix.sample) uses: actions/setup-dotnet@v5 with: dotnet-version: '10.0.x' @@ -124,7 +125,7 @@ jobs: - name: Setup Node.js if: >- steps.check.outputs.skip != 'true' && - contains(fromJson('["electron", "tauri-app", "cpp-app", "dotnet-app", "wpf-app", "rust-app", "flutter-app", "packaging-cli"]'), matrix.sample) + contains(fromJson('["electron", "tauri-app", "cpp-app", "dotnet-app", "wpf-app", "sparse-app", "rust-app", "flutter-app", "packaging-cli"]'), matrix.sample) uses: actions/setup-node@v5 with: node-version: '22' diff --git a/docs/cli-schema.json b/docs/cli-schema.json index f18bc2c1..1ead065c 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -574,6 +574,69 @@ } } }, + "embed-identity": { + "description": "Connect a desktop exe to its sparse identity package by embedding the element. Reads identity (packageName, publisher, applicationId) from a sparse appxmanifest.xml and writes it into the target's side-by-side (fusion) manifest. EXE targets are updated with mt.exe; .xml/.manifest targets are edited directly. Example: winapp embed-identity ./bin/MyApp.exe. This is step 3 of the sparse packaging workflow (after 'winapp init --exe --sparse' and 'winapp pack').", + "hidden": false, + "arguments": { + "target": { + "description": "Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist).", + "order": 0, + "hidden": false, + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + } + } + }, + "options": { + "--manifest": { + "description": "Path to the sparse appxmanifest.xml to read identity from (default: ./appxmanifest.xml)", + "hidden": false, + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } + }, "get-winapp-path": { "description": "Print the path to the .winapp directory. Use --global for the shared cache location, or omit for the project-local .winapp folder. Useful for build scripts that need to reference installed packages.", "hidden": false, @@ -667,6 +730,18 @@ "required": false, "recursive": false }, + "--exe": { + "description": "Path to the application executable. Requires --sparse. Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup.", + "hidden": false, + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, "--ignore-config": { "description": "Don't use configuration file for version management", "hidden": false, @@ -683,6 +758,18 @@ "required": false, "recursive": false }, + "--name": { + "description": "Override the package name (sparse only; default: inferred from the exe)", + "hidden": false, + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, "--no-gitignore": { "description": "Don't update .gitignore file", "hidden": false, @@ -696,6 +783,30 @@ "required": false, "recursive": false }, + "--output-dir": { + "description": "Directory to write the sparse manifest and Assets/ (sparse only; default: the exe's directory)", + "hidden": false, + "valueType": "System.IO.DirectoryInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--publisher": { + "description": "Override the publisher CN (sparse only; default: inferred from the exe's company name). Bare names are auto-wrapped as CN=.", + "hidden": false, + "valueType": "System.String", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + }, + "required": false, + "recursive": false + }, "--quiet": { "description": "Suppress progress messages", "hidden": false, @@ -725,6 +836,19 @@ "required": false, "recursive": false }, + "--sparse": { + "description": "Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation.", + "hidden": false, + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, "--use-defaults": { "description": "Do not prompt; requires an explicit project directory (e.g., winapp init . --use-defaults)", "hidden": false, diff --git a/docs/fragments/skills/winapp-cli/identity.md b/docs/fragments/skills/winapp-cli/identity.md index 80de594f..252b4798 100644 --- a/docs/fragments/skills/winapp-cli/identity.md +++ b/docs/fragments/skills/winapp-cli/identity.md @@ -126,6 +126,26 @@ winapp create-debug-identity .\bin\Debug\myapp.exe For full details including IDE setup examples, see the [Debugging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/debugging.md). +## Production sparse packaging (`init --sparse` / `pack` / `embed-identity`) + +`create-debug-identity` is for **developer-time debugging** (requires Developer Mode, registers a raw manifest). For **production** — shipping identity to an app distributed by an existing installer (Inno Setup, WiX, NSIS) — use the sparse packaging workflow, which produces a signed identity-only `.msix`: + +```powershell +# 1. Create the sparse identity manifest for your exe (skips SDK install) +winapp init --exe ./bin/Release/MyApp.exe --sparse --use-defaults + +# 2. Build and sign the identity-only .msix (just the manifest, no binaries) +winapp pack ./bin/Release/appxmanifest.xml --cert ./dev.pfx + +# 3. Embed the identity element into the exe's fusion manifest +winapp embed-identity ./bin/Release/MyApp.exe +``` + +Then your installer registers the package against the install directory: +`Add-AppxPackage -Path MyApp.identity.msix -ExternalLocation `. + +Assets are resolved from the external (install) location at runtime, **not** bundled into the `.msix`. `winapp embed-identity` also supports an XML mode (`winapp embed-identity ./app.manifest`) for updating a checked-in side-by-side manifest. See the [Sparse Packaging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/sparse.md) and the [sparse-app sample](https://github.com/microsoft/WinAppCli/tree/main/samples/sparse-app). + ## Related skills - Need a manifest? See `winapp-manifest` to generate `Package.appxmanifest` - Need a certificate? See `winapp-signing` — a trusted cert is required for identity registration diff --git a/docs/fragments/skills/winapp-cli/package.md b/docs/fragments/skills/winapp-cli/package.md index 8fca80e8..b572f44e 100644 --- a/docs/fragments/skills/winapp-cli/package.md +++ b/docs/fragments/skills/winapp-cli/package.md @@ -170,10 +170,22 @@ Use the `microsoft/setup-winapp` action to install winapp on GitHub-hosted runne - The `--executable` flag overrides the entry point in the manifest — useful when your exe name differs from what's in `Package.appxmanifest` - For production distribution, use a certificate from a trusted CA and add `--timestamp` when signing with `winapp sign` +## Sparse identity packages + +To grant identity to an app distributed by an existing installer (not as MSIX), build an **identity-only** sparse package: pass a sparse `appxmanifest.xml` (one declaring `uap10:AllowExternalContent="true"`) to `winapp pack` instead of a folder. + +```powershell +# 1. Generate the sparse manifest for your exe (skips SDK install) +winapp init --exe ./bin/Release/MyApp.exe --sparse --use-defaults +# 2. Build & sign the identity-only .msix (just the manifest) +winapp pack ./bin/Release/appxmanifest.xml --cert ./dev.pfx +# 3. Embed identity into the exe, then register in your installer +winapp embed-identity ./bin/Release/MyApp.exe +``` + +The `.msix` contains only the manifest — binaries and assets are resolved from the external content location at runtime via `Add-AppxPackage -ExternalLocation`. If you pack a folder whose manifest declares `AllowExternalContent`, `winapp pack` warns about any assets/binaries found. See the [Sparse Packaging Guide](https://github.com/microsoft/WinAppCli/blob/main/docs/guides/sparse.md). + ## Related skills -- Need a manifest first? See `winapp-manifest` to generate `Package.appxmanifest` -- Need a certificate? See `winapp-signing` for certificate generation and management -- Having issues? See `winapp-troubleshoot` for a command selection flowchart and error solutions ## Troubleshooting | Error | Cause | Solution | diff --git a/docs/guides/sparse.md b/docs/guides/sparse.md new file mode 100644 index 00000000..167a2255 --- /dev/null +++ b/docs/guides/sparse.md @@ -0,0 +1,181 @@ + +# Sparse packaging: grant identity to an unpackaged app + +> For a working end-to-end example (WPF app + Inno Setup installer), see the [sparse-app](../../samples/sparse-app) sample. + +A standard desktop executable — built with `dotnet build`, MSBuild, CMake, or any other toolchain — has no [package identity](https://learn.microsoft.com/windows/apps/desktop/modernize/package-identity-overview). Without identity, it cannot use many modern Windows APIs (toast notifications, background tasks, share targets, startup tasks, the app data APIs, and more). + +**Sparse packaging** grants identity to an app *without* moving its binaries into an MSIX. You ship a tiny **identity-only** `.msix` (just a manifest) and register it alongside your normally-installed app using an *external location*. Your `.exe` stays exactly where your installer puts it. This is the production counterpart to [`winapp create-debug-identity`](../usage.md#create-debug-identity), which is for developer-time debugging only. + +This guide covers the three CLI steps that map to the first three steps of the official [Grant identity to non-packaged apps](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps) workflow: + +| Step | Command | Result | +|------|---------|--------| +| 1. Create the identity manifest | `winapp init --exe --sparse` | `appxmanifest.xml` + `Assets/` | +| 2. Build & sign the identity package | `winapp pack --cert ` | `.identity.msix` | +| 3. Embed identity into the app | `winapp embed-identity ` | `` element in the exe's fusion manifest | + +Steps 4–5 of the docs (register / unregister the package) are your **installer's** responsibility — see [Installer integration](#installer-integration). + +## When to use sparse packaging + +- You already have a mature installer (Inno Setup, WiX, NSIS, MSI) and don't want to switch to MSIX for distribution, but you need identity-gated Windows APIs. +- Your app must install to a path or with a layout that MSIX doesn't allow. +- You want a minimal, additive change: keep your existing install flow and add one `.msix` registration step. + +If you're starting fresh and can distribute as MSIX, a full packaged app (`winapp init` + `winapp pack `) is simpler. + +## Prerequisites + +1. **Windows 10, version 2004 (build 19041) or later.** Sparse packages rely on `uap10:AllowExternalContent`, which requires 19041+. +2. **winapp CLI** — install via winget (or update if already installed): + ```powershell + winget install Microsoft.WinApp --source winget + ``` +3. **A code-signing certificate** trusted on the target machine. For local testing, generate a development certificate with [`winapp cert generate`](../usage.md#cert) and trust it. Production packages must be signed with a certificate whose subject matches the manifest `Publisher`. + +## Walkthrough + +The examples below assume a built executable at `./bin/Release/net8.0-windows/MyApp.exe`. + +### Step 1 — Create the sparse identity manifest + +```powershell +winapp init --exe ./bin/Release/net8.0-windows/MyApp.exe --sparse +``` + +This infers the package name, publisher, version, and description from the exe (via its file version info) and prompts you to accept or override them. Add `--use-defaults` (or `--no-prompt`) to skip the prompts in CI, and `--name` / `--publisher` to override specific values: + +```powershell +winapp init --exe ./bin/Release/net8.0-windows/MyApp.exe --sparse --use-defaults ` + --name "Contoso.MyApp" --publisher "CN=Contoso" +``` + +It writes, next to the exe (or to `--output-dir`): + +- `appxmanifest.xml` — a sparse manifest with `uap10:AllowExternalContent="true"`, `ProcessorArchitecture="neutral"`, a `win32App` application, and the exe name filled into `Executable`. +- `Assets/` — placeholder visual assets (extracted from the exe's icon when possible). + +> **Note:** The sparse init flow deliberately **skips all SDK/package installation** — identity-only packages have no SDK dependencies. + +Make sure the `Publisher` in the generated manifest matches the certificate you'll sign with. Edit `appxmanifest.xml` if needed, or pass `--publisher` when generating. + +### Step 2 — Build and sign the identity package + +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 +``` + +Because the manifest declares `AllowExternalContent`, `winapp pack` builds an **identity-only** `.msix` containing just the manifest — no binaries, no assets. The output defaults to `.identity.msix` in the current directory; use `--output` to change it. Signing happens only when you pass `--cert` (or `--generate-cert`). + +### Step 3 — Embed identity into your app + +Embed the `` element so Windows connects the running exe to the identity package: + +```powershell +# EXE mode — modify the built binary in place (uses mt.exe) +winapp embed-identity ./bin/Release/net8.0-windows/MyApp.exe +``` + +Or maintain the side-by-side manifest as a checked-in file and rebuild: + +```powershell +# XML mode — update an external SxS manifest, then rebuild your app +winapp embed-identity ./app.manifest +``` + +In XML mode the `` element is inserted into (or replaced in) the target manifest. Reference that manifest from your project (for .NET, set `app.manifest`) and rebuild so the element is embedded in the exe. + +Both modes read identity from `./appxmanifest.xml` by default; use `--manifest` to point elsewhere. + +### Step 4 — Register (for local testing) + +Register the identity package against the folder that contains your exe (the *external location*): + +```powershell +Add-AppxPackage -Path .\MyApp.identity.msix ` + -ExternalLocation (Resolve-Path .\bin\Release\net8.0-windows) +``` + +Launch the app and confirm identity is present — for example, `Windows.ApplicationModel.Package.Current.Id.FamilyName` should return your package family name instead of throwing. + +To clean up: + +```powershell +Remove-AppxPackage +``` + +## Asset handling + +The sparse `.msix` is **identity-only**. The visual assets referenced by the manifest (`Assets\StoreLogo.png`, tiles, etc.) are resolved from the **external content location** at runtime — i.e., from your app's install directory — **not** from inside the `.msix`. + +This means you must **deploy the `Assets/` folder alongside your application** (same layout the manifest expects, relative to the external location). If you pack a *folder* that contains assets or binaries, `winapp pack` will warn you: for sparse packages those files belong at the external location, not in the package. + +## Installer integration + +Registration and unregistration are the installer's job. The pattern is the same across installer tools: + +- **Install:** copy your app binaries, the `Assets/` folder, and the `.msix` to the install directory, then run + `Add-AppxPackage -Path "\MyApp.identity.msix" -ExternalLocation ""`. +- **Uninstall:** run `Remove-AppxPackage ` before deleting files. + +### Inno Setup + +```pascal +[Files] +Source: "dist\*"; DestDir: "{app}"; Flags: recursesubdirs +Source: "MyApp.identity.msix"; DestDir: "{app}" + +[Run] +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Add-AppxPackage -Path '{app}\MyApp.identity.msix' -ExternalLocation '{app}'"""; \ + Flags: runhidden + +[UninstallRun] +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-AppxPackage *MyApp* | Remove-AppxPackage"""; \ + Flags: runhidden +``` + +See the [sparse-app](../../samples/sparse-app) sample for a complete, working `setup.iss`. + +### WiX (v3) + +```xml + +``` + +### NSIS + +```nsis +Section + ExecWait 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Add-AppxPackage -Path \"$INSTDIR\MyApp.identity.msix\" -ExternalLocation \"$INSTDIR\""' +SectionEnd +``` + +## Troubleshooting + +**`Package.Current` throws / "no package identity" at runtime** +- The identity package isn't registered, or the exe's fusion manifest is missing the `` element. Re-run [`winapp embed-identity`](../usage.md#embed-identity) (and rebuild if using XML mode), then re-register with `Add-AppxPackage -ExternalLocation`. +- The `` / `publisher` / `applicationId` in the exe must **exactly** match the registered package's identity. + +**Assets/logos don't appear** +- Ensure the `Assets/` folder is deployed at the external location with the same relative paths the manifest expects. Assets are resolved from the external location, not the `.msix`. + +**`Add-AppxPackage` fails with a signing / trust error** +- The `.msix` must be signed by a certificate that is trusted on the machine and whose subject matches the manifest `Publisher`. For local testing, generate and trust a dev certificate with [`winapp cert generate`](../usage.md#cert), and make sure the manifest `Publisher` matches it. + +**MakeAppx: "Application with RuntimeBehavior value 'win32App' must not declare EntryPoint"** +- A sparse `win32App` application must not declare `EntryPoint`. Manifests generated by `winapp init --sparse` are already correct; remove any `EntryPoint` attribute if you hand-edited the manifest. + +**"Input is a file but not a sparse manifest"** +- `winapp pack ` only accepts a manifest that declares `uap10:AllowExternalContent="true"`. Generate one with `winapp init --exe --sparse`, or pass an input *folder* to build a full MSIX. + +## See also + +- [CLI usage: `init`](../usage.md#init), [`pack`](../usage.md#pack), [`embed-identity`](../usage.md#embed-identity) +- [Grant identity to non-packaged apps (Microsoft Learn)](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps) +- [Debugging with package identity](../debugging.md) diff --git a/docs/npm-usage.md b/docs/npm-usage.md index 51eb67f6..e8068abf 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -169,6 +169,25 @@ function createExternalCatalog(options: CreateExternalCatalogOptions): Promise element. Reads identity (packageName, publisher, applicationId) from a sparse appxmanifest.xml and writes it into the target's side-by-side (fusion) manifest. EXE targets are updated with mt.exe; .xml/.manifest targets are edited directly. Example: winapp embed-identity ./bin/MyApp.exe. This is step 3 of the sparse packaging workflow (after 'winapp init --exe --sparse' and 'winapp pack'). + +```typescript +function embedIdentity(options: EmbedIdentityOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `target` | `string` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). | +| `manifest` | `string \| undefined` | No | Path to the sparse appxmanifest.xml to read identity from (default: ./appxmanifest.xml) | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* + +--- + ### `getWinappPath()` Print the path to the .winapp directory. Use --global for the shared cache location, or omit for the project-local .winapp folder. Useful for build scripts that need to reference installed packages. @@ -202,9 +221,14 @@ function init(options?: InitOptions): Promise | `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace, for consumption or installation. | | `configDir` | `string \| undefined` | No | Directory to read/store configuration (default: the selected project directory, or current directory if no project is detected) | | `configOnly` | `boolean \| undefined` | No | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | +| `exe` | `string \| undefined` | No | Path to the application executable. Requires --sparse. Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup. | | `ignoreConfig` | `boolean \| undefined` | No | Don't use configuration file for version management | +| `name` | `string \| undefined` | No | Override the package name (sparse only; default: inferred from the exe) | | `noGitignore` | `boolean \| undefined` | No | Don't update .gitignore file | +| `outputDir` | `string \| undefined` | No | Directory to write the sparse manifest and Assets/ (sparse only; default: the exe's directory) | +| `publisher` | `string \| undefined` | No | Override the publisher CN (sparse only; default: inferred from the exe's company name). Bare names are auto-wrapped as CN=. | | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | +| `sparse` | `boolean \| undefined` | No | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. | | `useDefaults` | `boolean \| undefined` | No | Do not prompt; requires an explicit project directory (e.g., winapp init . --use-defaults) | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -436,6 +460,31 @@ function uiClick(options?: UiClickOptions): Promise --- +### `uiDrag()` + +Press the mouse button at one point, move to another, then release. 'drag ', where / are each an element selector (uses the element's center) or app-relative x,y coordinates as reported by 'ui inspect'. Useful for reorder/resize/slider gestures and drag-and-drop. Use --right for a right-button drag, --hold-ms for press-and-hold/long-press, and --dwell-ms to settle on a drop target before releasing. + +```typescript +function uiDrag(options?: UiDragOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `from` | `string \| undefined` | No | Start point — an element selector (drags from its center) or app coordinates x,y as reported by 'ui inspect' (e.g. pn-list-d736 or 100,200). | +| `to` | `string \| undefined` | No | End point — an element selector (drops at its center) or app coordinates x,y as reported by 'ui inspect' (e.g. pn-target-d746 or 300,400). | +| `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | +| `dwellMs` | `number \| undefined` | No | Milliseconds to dwell at the destination after moving, before releasing (default: 0). Lets drop targets / merge overlays that arm from a sustained hover latch before release. | +| `holdMs` | `number \| undefined` | No | Milliseconds to hold the button down at the start before moving (default: 0). With == (no movement) this performs a press-and-hold / long-press gesture. | +| `json` | `boolean \| undefined` | No | Format output as JSON | +| `right` | `boolean \| undefined` | No | Drag with the right mouse button instead of the left button | +| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* + +--- + ### `uiFocus()` Move keyboard focus to the specified element using UIA SetFocus. @@ -635,7 +684,7 @@ function uiScreenshot(options?: UiScreenshotOptions): Promise ### `uiScroll()` -Scroll a container element using ScrollPattern. Use --direction to scroll incrementally, or --to to jump to top/bottom. +Scroll a container element using ScrollPattern. Use --direction to scroll incrementally, --to to jump to top/bottom, or --wheel to synthesize mouse-wheel input. ```typescript function uiScroll(options?: UiScrollOptions): Promise @@ -650,6 +699,7 @@ function uiScroll(options?: UiScrollOptions): Promise | `direction` | `string \| undefined` | No | Scroll direction: up, down, left, right | | `json` | `boolean \| undefined` | No | Format output as JSON | | `to` | `string \| undefined` | No | Scroll to position: top, bottom | +| `wheel` | `number \| undefined` | No | Rotate the mouse wheel over the element by this many notches (1 = one notch up, -1 = one notch down). Synthesizes real wheel input instead of using ScrollPattern. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | *Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* @@ -699,6 +749,30 @@ function uiSearch(options?: UiSearchOptions): Promise --- +### `uiSendKeys()` + +Send synthetic keyboard input to a window. Supports named keys (down, enter, tab), modifier combos (ctrl+shift+t), raw virtual keys (vk=0xNN), and literal text. Use --verbatim to type the whole argument literally, or --target to focus an element first. Two transports via --via: post-message (default, HWND-targeted, bypasses UIPI) or send-input (OS-wide). For per-keystroke KeyDown on typed text (e.g. a WinUI 3/WPF TextBox), use --via send-input. + +```typescript +function uiSendKeys(options?: UiSendKeysOptions): Promise +``` + +**Options:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \s \t \n \r \\ are supported (text=a\s\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | +| `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | +| `json` | `boolean \| undefined` | No | Format output as JSON | +| `target` | `string \| undefined` | No | Optional selector (slug or text) to focus before sending keys. | +| `verbatim` | `boolean \| undefined` | No | Type the entire keys argument as literal text — no named-key, combo, or vk= interpretation, and exact whitespace preserved. The whole-argument form of the per-token text= escape: --verbatim "down down enter" types the words instead of pressing Down, Down, Enter. | +| `via` | `string \| undefined` | No | Transport: post-message (default, HWND-targeted, bypasses UIPI; typed text raises TextChanged but not a per-character KeyDown) or send-input (OS-wide; typed text raises a real per-character KeyDown + TextChanged). Named keys and combos raise KeyDown on both, but keyboard accelerators/shortcuts (KeyboardAccelerator, e.g. ctrl+t) only fire via send-input. | +| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | + +*Also accepts [CommonOptions](#commonoptions) (`quiet`, `verbose`, `cwd`).* + +--- + ### `uiSetValue()` Set a value on an element using UIA ValuePattern. Works for TextBox, ComboBox, Slider, and other editable controls. Usage: winapp ui set-value -a @@ -1174,6 +1248,16 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +### `EmbedIdentityOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `target` | `string` | Yes | Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist). | +| `manifest` | `string \| undefined` | No | Path to the sparse appxmanifest.xml to read identity from (default: ./appxmanifest.xml) | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | + ### `GetWinappPathOptions` | Property | Type | Required | Description | @@ -1190,9 +1274,14 @@ type ManifestTemplates = "packaged" | "sparse" | `baseDirectory` | `string \| undefined` | No | Base/root directory for the winapp workspace, for consumption or installation. | | `configDir` | `string \| undefined` | No | Directory to read/store configuration (default: the selected project directory, or current directory if no project is detected) | | `configOnly` | `boolean \| undefined` | No | Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. | +| `exe` | `string \| undefined` | No | Path to the application executable. Requires --sparse. Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup. | | `ignoreConfig` | `boolean \| undefined` | No | Don't use configuration file for version management | +| `name` | `string \| undefined` | No | Override the package name (sparse only; default: inferred from the exe) | | `noGitignore` | `boolean \| undefined` | No | Don't update .gitignore file | +| `outputDir` | `string \| undefined` | No | Directory to write the sparse manifest and Assets/ (sparse only; default: the exe's directory) | +| `publisher` | `string \| undefined` | No | Override the publisher CN (sparse only; default: inferred from the exe's company name). Bare names are auto-wrapped as CN=. | | `setupSdks` | `SdkInstallMode \| undefined` | No | SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) | +| `sparse` | `boolean \| undefined` | No | Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. | | `useDefaults` | `boolean \| undefined` | No | Do not prompt; requires an explicit project directory (e.g., winapp init . --use-defaults) | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | @@ -1333,6 +1422,22 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +### `UiDragOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `from` | `string \| undefined` | No | Start point — an element selector (drags from its center) or app coordinates x,y as reported by 'ui inspect' (e.g. pn-list-d736 or 100,200). | +| `to` | `string \| undefined` | No | End point — an element selector (drops at its center) or app coordinates x,y as reported by 'ui inspect' (e.g. pn-target-d746 or 300,400). | +| `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | +| `dwellMs` | `number \| undefined` | No | Milliseconds to dwell at the destination after moving, before releasing (default: 0). Lets drop targets / merge overlays that arm from a sustained hover latch before release. | +| `holdMs` | `number \| undefined` | No | Milliseconds to hold the button down at the start before moving (default: 0). With == (no movement) this performs a press-and-hold / long-press gesture. | +| `json` | `boolean \| undefined` | No | Format output as JSON | +| `right` | `boolean \| undefined` | No | Drag with the right mouse button instead of the left button | +| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | + ### `UiFocusOptions` | Property | Type | Required | Description | @@ -1458,6 +1563,7 @@ type ManifestTemplates = "packaged" | "sparse" | `direction` | `string \| undefined` | No | Scroll direction: up, down, left, right | | `json` | `boolean \| undefined` | No | Format output as JSON | | `to` | `string \| undefined` | No | Scroll to position: top, bottom | +| `wheel` | `number \| undefined` | No | Rotate the mouse wheel over the element by this many notches (1 = one notch up, -1 = one notch down). Synthesizes real wheel input instead of using ScrollPattern. | | `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | | `quiet` | `boolean \| undefined` | No | Suppress progress messages. | | `verbose` | `boolean \| undefined` | No | Enable verbose output. | @@ -1488,6 +1594,21 @@ type ManifestTemplates = "packaged" | "sparse" | `verbose` | `boolean \| undefined` | No | Enable verbose output. | | `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | +### `UiSendKeysOptions` + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `keys` | `string \| undefined` | No | Keys to send. Whitespace-separated tokens: named keys (down, enter, tab, esc, f5), modifier combos (ctrl+shift+t, alt+f4), raw virtual keys (vk=0x42), or literal text (hello). Use text= to type a single value verbatim when it would otherwise be read as a key name or combo (text=enter types "enter"; text=ctrl+a types "ctrl+a"); backslash escapes \s \t \n \r \\ are supported (text=a\s\sb types "a b"). To type the whole argument literally without escaping each token, pass --verbatim instead. Quote multi-token strings, e.g. "ctrl+a delete". | +| `app` | `string \| undefined` | No | Target app (process name, window title, or PID). Lists windows if ambiguous. | +| `json` | `boolean \| undefined` | No | Format output as JSON | +| `target` | `string \| undefined` | No | Optional selector (slug or text) to focus before sending keys. | +| `verbatim` | `boolean \| undefined` | No | Type the entire keys argument as literal text — no named-key, combo, or vk= interpretation, and exact whitespace preserved. The whole-argument form of the per-token text= escape: --verbatim "down down enter" types the words instead of pressing Down, Down, Enter. | +| `via` | `string \| undefined` | No | Transport: post-message (default, HWND-targeted, bypasses UIPI; typed text raises TextChanged but not a per-character KeyDown) or send-input (OS-wide; typed text raises a real per-character KeyDown + TextChanged). Named keys and combos raise KeyDown on both, but keyboard accelerators/shortcuts (KeyboardAccelerator, e.g. ctrl+t) only fire via send-input. | +| `window` | `number \| undefined` | No | Target window by HWND (stable handle from list output). Takes precedence over --app. | +| `quiet` | `boolean \| undefined` | No | Suppress progress messages. | +| `verbose` | `boolean \| undefined` | No | Enable verbose output. | +| `cwd` | `string \| undefined` | No | Working directory for the CLI process (defaults to process.cwd()). | + ### `UiSetValueOptions` | Property | Type | Required | Description | diff --git a/docs/usage.md b/docs/usage.md index 5ce162ba..ad931a56 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -35,6 +35,11 @@ winapp init [base-directory] [options] - `--no-gitignore` - Don't update .gitignore file - `--use-defaults`, `--no-prompt` - Do not prompt, and use default of all prompts - `--config-only` - Only handle configuration file operations, skip package installation +- `--exe ` - Path to the application executable. **Requires `--sparse`.** Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup. +- `--sparse` - Generate a sparse identity manifest (`appxmanifest.xml`) for an existing desktop exe. Skips SDK/package installation. Use with `--exe`. +- `--name ` - Override the package name (sparse only; default: inferred from the exe) +- `--publisher ` - Override the publisher CN (sparse only; default: inferred from the exe's company name) +- `--output-dir ` - Directory to write the sparse manifest and `Assets/` (sparse only; default: the exe's directory) - `--add-js-bindings` *(npm only)* - Add `winapp.jsBindings` to package.json and generate JS/TypeScript bindings, without prompting (incompatible with `--setup-sdks none`) **What it does:** @@ -79,6 +84,20 @@ When a `.csproj` file is found in the target directory, `init` uses a streamline - Generates `Package.appxmanifest`, assets, and a development certificate - Does **not** create a `winapp.yaml` or download C++ projections (use `dotnet restore` for NuGet packages) +**Sparse identity mode (`--exe` + `--sparse`):** + +Generates an identity-only [sparse package](guides/sparse.md) manifest for an existing desktop executable — the first step of the [sparse packaging workflow](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps). Unlike the full `init` flow, this **skips all SDK/package installation** (sparse identity packages have no SDK dependencies) and only generates a manifest and placeholder assets. + +- Infers the package name, publisher, description, and version from the exe via `FileVersionInfo` (override with `--name`, `--publisher`, or interactively) +- Writes `appxmanifest.xml` (with the exe name substituted into `Executable`) plus an `Assets/` folder to the exe's directory (or `--output-dir`) +- Uses `--use-defaults`/`--no-prompt` to skip the interactive override prompts (CI-friendly) +- `--exe` without `--sparse` is an error + +> **Assets are external.** The sparse `.msix` is identity-only: the generated `Assets/` are resolved from the app's install directory (the external content location) at runtime, **not** bundled into the `.msix`. Deploy them alongside your application. + +Next steps after `winapp init --exe --sparse`: [`winapp pack `](#pack) to build the identity `.msix`, then [`winapp embed-identity `](#embed-identity). See the [Sparse Packaging Guide](guides/sparse.md) for the full walkthrough. + + **Examples:** ```bash @@ -94,6 +113,9 @@ winapp init ./my-project --use-defaults # Initialize a .NET project (auto-detected from .csproj) cd my-dotnet-app winapp init + +# Generate a sparse identity manifest for an existing exe (no SDK install) +winapp init --exe ./bin/Release/net8.0-windows/MyApp.exe --sparse --use-defaults ``` **Tip: Install SDKs after initial setup** @@ -183,7 +205,7 @@ winapp pack [input-folder...] [options] **Arguments:** -- `input-folder` - One or more directories containing the application files to package. Pass multiple folders (e.g., `./publish/x64 ./publish/arm64`) to create an MSIX bundle. +- `input-folder` - 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:** @@ -210,6 +232,22 @@ winapp pack [input-folder...] [options] - Handles self-contained WinAppSDK deployment - Signs package if certificate provided +#### Sparse identity packages + +When the input is a **sparse `appxmanifest.xml` file** (one declaring `uap10:AllowExternalContent="true"`) 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). + +```bash +# Build a signed identity package from a sparse manifest +winapp pack ./appxmanifest.xml --cert ./dev.pfx +``` + +- Output defaults to `.identity.msix` in the current directory (override with `--output`). +- Signing happens only when `--cert` (or `--generate-cert`) is provided. +- If you instead pass a **folder** whose manifest declares `AllowExternalContent`, the existing folder-packaging behavior applies, but `winapp pack` warns if it finds assets (`.png`/`.jpg`/`.ico`) or binaries (`.exe`/`.dll`/`.so`) — for sparse packages these belong at the external location, not inside the `.msix`. + +After packing, run [`winapp embed-identity `](#embed-identity) and register the package in your installer with `Add-AppxPackage -Path -ExternalLocation `. See the [Sparse Packaging Guide](guides/sparse.md). + + #### WinRT component discovery When packaging, `winapp pack` automatically scans NuGet packages defined in the `winapp.yaml` or `*.csproj` for third-party WinRT components (e.g., Win2D). It parses `.winmd` files to extract activatable class names and locates their implementation DLLs. The discovered entries are registered as follows: @@ -325,6 +363,38 @@ winapp create-debug-identity app.py --- +### embed-identity + +Connect a desktop application to its **sparse identity package** by embedding the `` element into the app's side-by-side (fusion) manifest. This is step 3 of the [sparse packaging workflow](guides/sparse.md) — it tells Windows which identity package the running exe belongs to. + +```bash +winapp embed-identity [options] +``` + +**Arguments:** + +- `target` - The file to update. Auto-detected by extension: + - **`.exe`** (EXE mode) — embeds the `` element directly into the exe's side-by-side manifest using `mt.exe`. + - **`.xml` / `.manifest`** (XML mode) — inserts or replaces the `` element in an external SxS manifest file (created if it doesn't exist). Rebuild your app afterward so the updated manifest is embedded in the binary. + +**Options:** + +- `--manifest ` - Path to the sparse `appxmanifest.xml` to read identity (packageName, publisher, applicationId) from (default: `./appxmanifest.xml`) + +**Examples:** + +```bash +# EXE mode — embed identity straight into the built exe +winapp embed-identity ./bin/Release/net8.0-windows/MyApp.exe + +# XML mode — update a checked-in side-by-side manifest, then rebuild +winapp embed-identity ./app.manifest --manifest ./appxmanifest.xml +``` + +> This command is idempotent: re-running it replaces any existing `` element rather than duplicating it. + +--- + ### manifest Generate and manage Package.appxmanifest files. diff --git a/samples/sparse-app/.gitignore b/samples/sparse-app/.gitignore new file mode 100644 index 00000000..d62a9833 --- /dev/null +++ b/samples/sparse-app/.gitignore @@ -0,0 +1,17 @@ + +# Development certificate +devcert.pfx +*.pfx + +# Generated sparse identity package +*.msix + +# Build output +bin/ +obj/ + +# Installer output +installer/Output/ + +# Windows SDK packages and generated files +.winapp diff --git a/samples/sparse-app/App.xaml b/samples/sparse-app/App.xaml new file mode 100644 index 00000000..eb3791fe --- /dev/null +++ b/samples/sparse-app/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/samples/sparse-app/App.xaml.cs b/samples/sparse-app/App.xaml.cs new file mode 100644 index 00000000..4fcdea54 --- /dev/null +++ b/samples/sparse-app/App.xaml.cs @@ -0,0 +1,10 @@ +using System.Windows; + +namespace sparse_app; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : Application +{ +} diff --git a/samples/sparse-app/AssemblyInfo.cs b/samples/sparse-app/AssemblyInfo.cs new file mode 100644 index 00000000..b0ec8275 --- /dev/null +++ b/samples/sparse-app/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/samples/sparse-app/Assets/Square150x150Logo.png b/samples/sparse-app/Assets/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9c81c0fc0fe12b924da8d6319b04b7957a6e3b0f GIT binary patch literal 733 zcmV<30wVp1P)-A$+fw5jbX5||j z)CE?yu|Zs5 z$xohSNqNeHr&&Ux@`t2Zai;d7I9O4t_5wRtVXpfAdRRfS`dxWg%WVC7cd-`f`a0xd z&GU_A$j6!ijOEeCnh4gi?PQGzYddkW28Owtcv(Hf+~vHij`C*izHU})?A5Fa+^qg| zZWRT7R@XJKiWNVrH)a%38LZZrRowj%3w%cMw+G(|@?8bLA@bV>*Bo*!1lMSC?*R8S za<2tz0J1g#YbLUm18YpO_62Kl@~i-!VaT%;c;+L|qTm^sJiCKE1+v!x_F%}~5ZJRK zdud>gkL*2yJyEh(3-*x7-Zt2CCuafRjDnnds`|$#+%Dw(T#oQ%9 ze!&dV&*qE#sjjJ9ekxDB{uogBq^FBxh{pM;ldbs<8Su0%Z(C}Uo!o}p|QcIwa_ zzr75`QswsiYB9gAFEbIgFnHo;HND^!P`-lX%BH~FOg%y&x+t*x!? zg$#_1A1kgsSvO(fw`bOmo;lrJX8byO1j^gf7qohR%mmt z@L)WX;>gqgK|tWJvQ5j;4;=gt4HXVKSMYRv5RhY5vS~TqfK_NAP*r{h!!g^BZ;w4r z7CGdsai)y;fJQc`7{Zc2b==h%o`Op$|bg6a&nL{*m7-=0>k4M4-PXlU;G-?%*(*g>iFt^ U$m#7DfHB12>FVdQ&MBb@0G`#n8vp1KIqEP)`#j%umtt)f(_Qx@)^@hpb;sJa4{hxKS}ERE zEv}h+ly_S)f4r7=+x)z;emj|q|KCb3T*D^XR^2h-YPOS7w(aJH2e)Ob7Q$^;?<}~6 z?Mu4thy_=(Jse866&$#RZ6)8vfos?b`8Ebz%Z6x6!?kRPwj^A~25C#db!?Ef1YFOC zX^X@4Y?!tv+`tBEi@^C^(A^*hawFY{)hr&Spcld2kLJw9SQc*r06=oXdu7v*BDeY?}q=v4Pu6IFAk7 zX2AJu=(Z8gXG6CQa0DB?t%oDn;B6fo#fEQd;V3qITLbq&v1QO!!#z`M8MM`K8;UK5 zwi=FN%b~4-`(9<)Hd$}mPK!L-=7G0d8ML);u9i8r)ofD|4&PQ^67I=EKD@2Hl9EqH z;BD=dlzcjZZtHJAC^&Rme*;3nf!oIWHUbXZHr}@paM(8UcGri)wwbrPJ{+{oJ|%c? z&^G&&;K3o=+{2X%hir2XS1uf|%|DYmaKJYIOzObl+Nh(v4To!^j`B7ftc`pEV#C4O z$R{8+9IB0Gv*A!}G@A_vY74PhaG8n{aQTOJl$_m)e#jmOr#9u#ilemgcRGydjIzg5(>zb)r)iVrgW7F(y& k>2x}sPN&o9bh>}uFDMS%SxTob*#H0l07*qoM6N<$f=8c%l>h($ literal 0 HcmV?d00001 diff --git a/samples/sparse-app/MainWindow.xaml b/samples/sparse-app/MainWindow.xaml new file mode 100644 index 00000000..498a800f --- /dev/null +++ b/samples/sparse-app/MainWindow.xaml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/samples/sparse-app/MainWindow.xaml.cs b/samples/sparse-app/MainWindow.xaml.cs new file mode 100644 index 00000000..9e2d95e5 --- /dev/null +++ b/samples/sparse-app/MainWindow.xaml.cs @@ -0,0 +1,40 @@ +using System.Windows; +using System.Windows.Media; +using Windows.ApplicationModel; + +namespace sparse_app; + +/// +/// Interaction logic for MainWindow.xaml. +/// Queries package identity to demonstrate that the sparse identity package is registered. +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + + try + { + // Package.Current throws InvalidOperationException when the process has no + // package identity (i.e. the sparse identity package isn't registered, or the + // exe's fusion-manifest element is missing/mismatched). + var package = Package.Current; + StatusTextBlock.Text = "✅ Running with package identity"; + StatusTextBlock.Foreground = Brushes.Green; + DetailTextBlock.Text = + $"Family Name: {package.Id.FamilyName}\n" + + $"Full Name: {package.Id.FullName}\n" + + $"Publisher: {package.Id.Publisher}"; + } + catch (InvalidOperationException) + { + StatusTextBlock.Text = "⚠ No package identity"; + StatusTextBlock.Foreground = Brushes.OrangeRed; + DetailTextBlock.Text = + "This exe is running unpackaged. Register the sparse identity package with:\n" + + "Add-AppxPackage -Path sparse-app.identity.msix -ExternalLocation \n" + + "and make sure the exe's manifest was updated with 'winapp embed-identity'."; + } + } +} diff --git a/samples/sparse-app/README.md b/samples/sparse-app/README.md new file mode 100644 index 00000000..f28bcc71 --- /dev/null +++ b/samples/sparse-app/README.md @@ -0,0 +1,118 @@ +# Sparse Packaging Sample (WPF) + +A minimal WPF app that demonstrates the **production sparse packaging** workflow with the `winapp` CLI: grant [package identity](https://learn.microsoft.com/windows/apps/desktop/modernize/package-identity-overview) to an ordinary desktop `.exe` by shipping a tiny identity-only `.msix` and registering it against the app's install directory — without moving your binaries into an MSIX. + +The window queries `Windows.ApplicationModel.Package.Current` and shows the package family name when identity is present, or **"No package identity"** when running unpackaged. + +> Full background and troubleshooting: [Sparse Packaging Guide](../../docs/guides/sparse.md). + +## What's here + +``` +sparse-app/ +├── sparse-app.csproj # WPF project (references app.manifest as the SxS manifest) +├── App.xaml / .cs +├── MainWindow.xaml / .cs # Displays package identity status +├── app.manifest # Side-by-side manifest containing the identity element (XML mode) +├── appxmanifest.xml # Pre-generated sparse manifest (AllowExternalContent, win32App) +├── Assets/ # Visual assets — deployed at the EXTERNAL location, not in the .msix +└── installer/setup.iss # Inno Setup script: install app + register sparse MSIX +``` + +## Prerequisites + +- **Windows 10, version 2004 (build 19041)+** — required for sparse packaging. +- **.NET SDK** (10.0+). +- **winapp CLI** — `winget install Microsoft.WinApp --source winget`. + +## Walkthrough + +Run these from the `sparse-app` directory. + +### 1. Build the app + +```powershell +dotnet build +``` + +### 2. Generate the sparse identity manifest (optional — one is checked in) + +The repo ships a ready-made `appxmanifest.xml`. To regenerate it from the built exe: + +```powershell +winapp init --exe .\bin\Debug\net10.0-windows10.0.19041.0\sparse-app.exe --sparse --use-defaults +``` + +> This **skips SDK installation** — sparse identity packages have no SDK dependencies. It only writes `appxmanifest.xml` and `Assets/`. + +### 3. Generate a development certificate + +```powershell +winapp cert generate +``` + +The certificate subject must match the manifest `Publisher` (`CN=Sparse App Sample`). Pass `--publisher "CN=Sparse App Sample"` if needed, and install/trust the cert for local testing (`winapp cert install .\devcert.pfx`, admin). + +### 4. Pack the identity-only MSIX + +```powershell +winapp pack .\appxmanifest.xml --cert .\devcert.pfx +``` + +Produces `SparseAppSample.identity.msix` — just the manifest, no binaries or assets. + +### 5. Embed identity into the exe + +The checked-in `app.manifest` already contains the `` element (XML mode), so a normal `dotnet build` embeds it. To (re)generate it, or to embed directly into a built exe: + +```powershell +# XML mode — update the checked-in side-by-side manifest, then rebuild +winapp embed-identity .\app.manifest --manifest .\appxmanifest.xml +dotnet build + +# — or — EXE mode: embed straight into an already-built exe +winapp embed-identity .\bin\Debug\net10.0-windows10.0.19041.0\sparse-app.exe --manifest .\appxmanifest.xml +``` + +### 6. Register the package (development) + +```powershell +Add-AppxPackage -Path .\SparseAppSample.identity.msix ` + -ExternalLocation (Resolve-Path .\bin\Debug\net10.0-windows10.0.19041.0) +``` + +### 7. Run and verify + +```powershell +.\bin\Debug\net10.0-windows10.0.19041.0\sparse-app.exe +``` + +The window should show **"Running with package identity"** and the package family name. If it shows "No package identity", re-check steps 5–6. + +### 8. Clean up + +```powershell +Get-AppxPackage SparseAppSample* | Remove-AppxPackage +``` + +## Asset handling + +The `.msix` is **identity-only**. The images in `Assets/` are resolved from the **external location** (the install directory) at runtime — they are **not** bundled into the MSIX. Your installer must deploy `Assets/` alongside the app. + +## (Optional) Build the installer + +The `installer/setup.iss` script produces a `setup.exe` that installs the app, copies the `.msix`, and registers the sparse package automatically — the full production flow. + +```powershell +# 1. Publish the app +dotnet publish -c Release -r win-x64 --self-contained false + +# 2. Build the identity MSIX (signed) +winapp cert generate +winapp pack .\appxmanifest.xml --cert .\devcert.pfx + +# 3. Compile the installer (requires Inno Setup: https://jrsoftware.org/isdl.php) +& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" installer\setup.iss +``` + +The generated installer registers the package on install (`Add-AppxPackage -ExternalLocation`) and unregisters it on uninstall (`Remove-AppxPackage`). diff --git a/samples/sparse-app/app.manifest b/samples/sparse-app/app.manifest new file mode 100644 index 00000000..27143506 --- /dev/null +++ b/samples/sparse-app/app.manifest @@ -0,0 +1,26 @@ + + + + + + + + + + + true + PerMonitorV2 + + + + diff --git a/samples/sparse-app/appxmanifest.xml b/samples/sparse-app/appxmanifest.xml new file mode 100644 index 00000000..998203aa --- /dev/null +++ b/samples/sparse-app/appxmanifest.xml @@ -0,0 +1,55 @@ + + + + + + + + Sparse Packaging Sample + Sparse App Sample + Assets\StoreLogo.png + true + disabled + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/sparse-app/installer/setup.iss b/samples/sparse-app/installer/setup.iss new file mode 100644 index 00000000..fc42b98f --- /dev/null +++ b/samples/sparse-app/installer/setup.iss @@ -0,0 +1,63 @@ +; Inno Setup script for the winapp sparse packaging sample. +; +; Demonstrates the full production sparse-packaging flow: +; build -> package identity MSIX -> install -> register sparse package -> identity available +; +; This installer: +; 1. Installs the WPF app binaries and visual assets to {app}. +; 2. Copies the identity-only .msix alongside the app. +; 3. Registers the sparse package against the install directory (the external +; content location) as a post-install step. +; 4. Unregisters the package on uninstall. +; +; Prerequisites before compiling with the Inno Setup Compiler (ISCC.exe): +; - Publish the app: dotnet publish -c Release -r win-x64 --self-contained false +; - Build the identity: winapp pack appxmanifest.xml --cert devcert.pfx +; - The signing certificate must be trusted on the target machine. +; +; Adjust SourceDir / paths below to match your publish output. + +#define MyAppName "Sparse Packaging Sample" +#define MyAppExeName "sparse-app.exe" +#define MyMsixName "SparseAppSample.identity.msix" +; Wildcard matches the package full name (e.g. SparseAppSample_1.0.0.0_neutral__) +#define MyPackagePattern "SparseAppSample*" +; Path to your published app output (contains sparse-app.exe, Assets\, and the .msix). +#define PublishDir "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish" + +[Setup] +AppId={{7C2E4A1E-9E2B-4C7E-9D1F-SPARSE0000001} +AppName={#MyAppName} +AppVersion=1.0.0.0 +DefaultDirName={autopf}\SparseAppSample +DefaultGroupName={#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} +OutputBaseFilename=SparseAppSampleSetup +Compression=lzma2 +SolidCompression=yes +ArchitecturesInstallIn64BitMode=x64compatible + +[Files] +; App binaries + assets (everything from the publish output). +Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs +; The identity-only MSIX, copied alongside the app so it can be registered from {app}. +Source: "{#MyMsixName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" + +[Run] +; Register the sparse identity package against the install directory (external location). +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Add-AppxPackage -Path '{app}\{#MyMsixName}' -ExternalLocation '{app}'"""; \ + StatusMsg: "Registering package identity..."; \ + Flags: runhidden waituntilterminated +; Launch the app after install (optional). +Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: postinstall nowait skipifsilent + +[UninstallRun] +; Unregister the sparse package on uninstall (before files are removed). +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-AppxPackage '{#MyPackagePattern}' | Remove-AppxPackage"""; \ + RunOnceId: "UnregisterSparse"; \ + Flags: runhidden waituntilterminated diff --git a/samples/sparse-app/sparse-app.csproj b/samples/sparse-app/sparse-app.csproj new file mode 100644 index 00000000..685301a4 --- /dev/null +++ b/samples/sparse-app/sparse-app.csproj @@ -0,0 +1,18 @@ + + + + WinExe + + net10.0-windows10.0.19041.0 + sparse_app + enable + enable + true + + + app.manifest + + + diff --git a/samples/sparse-app/test.Tests.ps1 b/samples/sparse-app/test.Tests.ps1 new file mode 100644 index 00000000..edb48be2 --- /dev/null +++ b/samples/sparse-app/test.Tests.ps1 @@ -0,0 +1,148 @@ +param( + [string]$WinappPath, + [switch]$SkipCleanup +) + +BeforeDiscovery { + $script:skip = $null -eq (Get-Command dotnet -ErrorAction SilentlyContinue) -or $null -eq (Get-Command npm -ErrorAction SilentlyContinue) +} + +Describe 'sparse-app sample' { + + BeforeAll { + Import-Module "$PSScriptRoot\..\SampleTestHelpers.psm1" -Force + $script:skip = $null -eq (Get-Command dotnet -ErrorAction SilentlyContinue) -or $null -eq (Get-Command npm -ErrorAction SilentlyContinue) + + $script:sampleDir = $PSScriptRoot + $script:tempDir = $null + $script:originalLocation = Get-Location + + if (-not $script:skip) { + $resolvedPkg = Resolve-WinappCliPath -WinappPath $WinappPath + Install-WinappGlobal -PackagePath $resolvedPkg + } + } + + AfterAll { + Set-Location $script:sampleDir + + if (-not $SkipCleanup) { + if ($script:tempDir) { Remove-TempTestDirectory -Path $script:tempDir } + Remove-Item -Path (Join-Path $script:sampleDir 'bin') -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path (Join-Path $script:sampleDir 'obj') -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path (Join-Path $script:sampleDir 'devcert.pfx') -Force -ErrorAction SilentlyContinue + Get-ChildItem -Path $script:sampleDir -Filter '*.msix' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue + } + } + + Context 'Phase 1: Sparse Packaging Guide Workflow (from scratch)' { + + BeforeAll { + if (-not $script:skip) { + $script:tempDir = New-TempTestDirectory -Prefix 'sparse-guide' + Push-Location $script:tempDir + + Invoke-Expression 'dotnet new wpf -n test-sparse-app' + $script:dotnetNewExit = $LASTEXITCODE + + if ($script:dotnetNewExit -eq 0) { + Push-Location 'test-sparse-app' + } + } + } + + AfterAll { + if (-not $script:skip) { + # Unwind any Push-Location calls made during this context + Set-Location $script:originalLocation + } + } + + It 'Creates a new WPF project' -Skip:$script:skip { + $script:dotnetNewExit | Should -Be 0 + } + + It 'Builds the app in Debug mode' -Skip:$script:skip { + Invoke-Expression 'dotnet build -c Debug' + $LASTEXITCODE | Should -Be 0 + } + + It 'Step 1: Generates a sparse manifest with winapp init --exe --sparse' -Skip:$script:skip { + $exeFile = Get-ChildItem -Path 'bin\Debug' -Filter 'test-sparse-app.exe' -Recurse | Select-Object -First 1 + $exeFile | Should -Not -BeNullOrEmpty -Because 'Debug build should produce an .exe' + $script:exePath = $exeFile.FullName + + Invoke-WinappCommand -Arguments "init --exe `"$($script:exePath)`" --sparse --use-defaults --name SparseGuideApp --publisher `"CN=Sparse Guide`"" + } + + It 'Generates a sparse appxmanifest.xml next to the exe' -Skip:$script:skip { + $manifest = Join-Path (Split-Path $script:exePath -Parent) 'appxmanifest.xml' + $manifest | Should -Exist + $script:manifestPath = $manifest + $content = Get-Content $manifest -Raw + $content | Should -Match 'AllowExternalContent' + $content | Should -Match 'win32App' + $content | Should -Match 'ProcessorArchitecture="neutral"' + } + + It 'Skips SDK installation (no winapp.yaml created)' -Skip:$script:skip { + 'winapp.yaml' | Should -Not -Exist + } + + It 'Step 2a: Generates a dev certificate' -Skip:$script:skip { + Invoke-WinappCommand -Arguments 'cert generate --publisher "CN=Sparse Guide" --if-exists skip' + 'devcert.pfx' | Should -Exist + } + + It 'Step 2b: Packs the identity-only MSIX with winapp pack' -Skip:$script:skip { + Invoke-WinappCommand -Arguments "pack `"$($script:manifestPath)`" --cert devcert.pfx --output `"$(Join-Path (Get-Location) 'SparseGuideApp.identity.msix')`"" + } + + It 'Produces an identity .msix file' -Skip:$script:skip { + 'SparseGuideApp.identity.msix' | Should -Exist + } + + It 'Step 3: Embeds identity into the exe with winapp embed-identity' -Skip:$script:skip { + Invoke-WinappCommand -Arguments "embed-identity `"$($script:exePath)`" --manifest `"$($script:manifestPath)`"" + } + + It 'Supports embed-identity in XML mode' -Skip:$script:skip { + $xmlManifest = Join-Path (Get-Location) 'app.manifest' + Invoke-WinappCommand -Arguments "embed-identity `"$xmlManifest`" --manifest `"$($script:manifestPath)`"" + $xmlManifest | Should -Exist + (Get-Content $xmlManifest -Raw) | Should -Match 'packageName="SparseGuideApp"' + } + } + + Context 'Phase 2: Sample Build Check' { + + BeforeAll { + if (-not $script:skip) { + Push-Location $script:sampleDir + } + } + + AfterAll { + if (-not $script:skip) { + Set-Location $script:originalLocation + } + } + + It 'Restores NuGet packages' -Skip:$script:skip { + Invoke-Expression 'dotnet restore' + $LASTEXITCODE | Should -Be 0 + } + + It 'Builds the existing sample in Debug mode' -Skip:$script:skip { + Invoke-Expression 'dotnet build -c Debug' + $LASTEXITCODE | Should -Be 0 + } + + It 'Packs the checked-in sparse manifest into an identity MSIX' -Skip:$script:skip { + Invoke-WinappCommand -Arguments 'cert generate --publisher "CN=Sparse App Sample" --if-exists skip' + 'devcert.pfx' | Should -Exist + Invoke-WinappCommand -Arguments 'pack appxmanifest.xml --cert devcert.pfx' + 'SparseAppSample.identity.msix' | Should -Exist + } + } +} diff --git a/scripts/generate-llm-docs.ps1 b/scripts/generate-llm-docs.ps1 index ff2735d4..ea9cb4bc 100644 --- a/scripts/generate-llm-docs.ps1 +++ b/scripts/generate-llm-docs.ps1 @@ -99,7 +99,7 @@ $SkillsDir = $SkillsPath $SkillCommandMap = @{ "setup" = @("init", "restore", "update", "run") "package" = @("package", "create-external-catalog") - "identity" = @("create-debug-identity") + "identity" = @("create-debug-identity", "embed-identity") "signing" = @("cert generate", "cert install", "cert info", "sign") "manifest" = @("manifest generate", "manifest update-assets", "manifest add-alias") "troubleshoot" = @("get-winapp-path", "tool", "store") diff --git a/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs b/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs index c42474cb..1c27b79d 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/FakeMsixService.cs @@ -44,6 +44,43 @@ public Task AddSparseIdentityAsync( return Task.FromResult(FakeIdentityResult); } + public List<(string ManifestPath, bool AutoSign)> CreateSparseIdentityCalls { get; } = []; + public List<(string Target, string ManifestPath)> EmbedIdentityCalls { get; } = []; + + public Task CreateSparseIdentityPackageAsync( + FileInfo manifestPath, + FileSystemInfo? outputPath, + TaskContext taskContext, + bool autoSign = false, + FileInfo? certificatePath = null, + string certificatePassword = "password", + bool generateDevCert = false, + bool installDevCert = false, + string? publisher = null, + CancellationToken cancellationToken = default) + { + CreateSparseIdentityCalls.Add((manifestPath.FullName, autoSign)); + if (ExceptionToThrow != null) + { + throw ExceptionToThrow; + } + return Task.FromResult(new CreateMsixPackageResult(new FileInfo("fake.identity.msix"), autoSign)); + } + + public Task EmbedIdentityAsync( + FileInfo target, + FileInfo manifestPath, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + EmbedIdentityCalls.Add((target.FullName, manifestPath.FullName)); + if (ExceptionToThrow != null) + { + throw ExceptionToThrow; + } + return Task.FromResult(FakeIdentityResult); + } + public Task CreateMsixPackageAsync( DirectoryInfo inputFolder, FileSystemInfo? outputPath, diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ManifestCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ManifestCommandTests.cs index de5dd656..ef6f8ecd 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ManifestCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ManifestCommandTests.cs @@ -126,7 +126,7 @@ public async Task ManifestGenerateCommandWithSparseOptionShouldCreateSparseManif Assert.IsTrue(File.Exists(manifestPath), "Package.appxmanifest should be created"); var manifestContent = await File.ReadAllTextAsync(manifestPath, TestContext.CancellationToken); Assert.Contains("uap10:AllowExternalContent", manifestContent, "Sparse manifest should contain AllowExternalContent"); - Assert.Contains("EntryPoint=\"Windows.FullTrustApplication\"", manifestContent, "Sparse manifest should contain FullTrustApplication entry point"); + Assert.Contains("uap10:RuntimeBehavior=\"win32App\"", manifestContent, "Sparse manifest should declare win32App runtime behavior"); } [TestMethod] diff --git a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandTests.cs index b3ae09cd..a37e951c 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/PackageCommandTests.cs @@ -2489,7 +2489,7 @@ await manifestTemplateService.GenerateCompleteManifestAsync( ManifestTemplates.Packaged, "Test", TestTaskContext, - TestContext.CancellationToken); + cancellationToken: TestContext.CancellationToken); // Generate cert from same publisher input await _certificateService.GenerateDevCertificateAsync( diff --git a/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs new file mode 100644 index 00000000..11bf52f9 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using WinApp.Cli.Commands; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +/// +/// Tests for the sparse identity packaging workflow: init --exe --sparse, embed-identity (XML mode), +/// and version normalization. Uses real services (no build tools required for these paths). +/// +[TestClass] +public class SparsePackagingTests : BaseCommandTests +{ + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + return services.AddSingleton(); + } + + private string CopyTestExe(string fileName = "app.exe") + { + // A real PE file is needed so FileVersionInfo/icon extraction succeed. + var dest = Path.Combine(_tempDirectory.FullName, fileName); + File.Copy(Path.Combine(Environment.SystemDirectory, "notepad.exe"), dest, overwrite: true); + return dest; + } + + [TestMethod] + public async Task InitSparse_WithExe_GeneratesSparseIdentityManifest() + { + // Arrange + var exe = CopyTestExe(); + var initCommand = GetRequiredService(); + var args = new[] { "--exe", exe, "--sparse", "--use-defaults", "--name", "MySparseApp" }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + // Assert + Assert.AreEqual(0, exitCode, "Sparse init should succeed"); + + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + Assert.IsTrue(File.Exists(manifestPath), "appxmanifest.xml should be generated in the exe's directory"); + + var content = await File.ReadAllTextAsync(manifestPath, TestContext.CancellationToken); + Assert.Contains("uap10:AllowExternalContent", content, "Should be a sparse manifest"); + Assert.Contains("ProcessorArchitecture=\"neutral\"", content, "Identity should be neutral arch"); + Assert.Contains("MinVersion=\"10.0.19041.0\"", content, "MinVersion should be 19041"); + Assert.Contains("uap10:RuntimeBehavior=\"win32App\"", content, "Application should use win32App"); + Assert.DoesNotContain("EntryPoint=", content, "win32App must not declare EntryPoint"); + Assert.Contains("Executable=\"app.exe\"", content, "Executable should be substituted with the exe name"); + Assert.Contains("Name=\"MySparseApp\"", content, "Package name override should be applied"); + + Assert.IsTrue(Directory.Exists(Path.Combine(_tempDirectory.FullName, "Assets")), "Assets directory should be generated"); + } + + [TestMethod] + public async Task InitSparse_WithOutputDir_WritesToThatDirectory() + { + // Arrange + var exe = CopyTestExe(); + var outDir = Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, "identity")); + var initCommand = GetRequiredService(); + var args = new[] { "--exe", exe, "--sparse", "--use-defaults", "--output-dir", outDir.FullName }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + // Assert + Assert.AreEqual(0, exitCode, "Sparse init should succeed"); + Assert.IsTrue(File.Exists(Path.Combine(outDir.FullName, "appxmanifest.xml")), "Manifest should be written to --output-dir"); + } + + [TestMethod] + public async Task InitSparse_ExeWithoutSparse_ReturnsError() + { + // Arrange + var exe = CopyTestExe(); + var initCommand = GetRequiredService(); + var args = new[] { "--exe", exe, "--use-defaults" }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(initCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "--exe without --sparse should fail"); + Assert.IsFalse(File.Exists(Path.Combine(_tempDirectory.FullName, "appxmanifest.xml")), "No manifest should be generated"); + } + + [TestMethod] + public async Task EmbedIdentity_XmlMode_InsertsMsixElement() + { + // Arrange: generate a sparse manifest to read identity from + var exe = CopyTestExe(); + var initCommand = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(initCommand, ["--exe", exe, "--sparse", "--use-defaults", "--name", "EmbeddedApp", "--publisher", "CN=Contoso"]); + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var targetManifest = Path.Combine(_tempDirectory.FullName, "app.manifest"); + + var embedCommand = GetRequiredService(); + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(embedCommand, [targetManifest, "--manifest", manifestPath]); + + // Assert + Assert.AreEqual(0, exitCode, "embed-identity XML mode should succeed"); + Assert.IsTrue(File.Exists(targetManifest), "SxS manifest should be created"); + var content = await File.ReadAllTextAsync(targetManifest, TestContext.CancellationToken); + Assert.Contains("packageName=\"EmbeddedApp\"", content, "msix element should carry the package name"); + Assert.Contains("urn:schemas-microsoft-com:msix.v1", content, "msix namespace should be present"); + } + + [TestMethod] + public async Task EmbedIdentity_XmlMode_ReplacesExistingElement() + { + // Arrange + var exe = CopyTestExe(); + var initCommand = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(initCommand, ["--exe", exe, "--sparse", "--use-defaults", "--name", "IdempotentApp"]); + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var targetManifest = Path.Combine(_tempDirectory.FullName, "app.manifest"); + var embedCommand = GetRequiredService(); + + // Act: run twice + await ParseAndInvokeWithCaptureAsync(embedCommand, [targetManifest, "--manifest", manifestPath]); + var exitCode = await ParseAndInvokeWithCaptureAsync(embedCommand, [targetManifest, "--manifest", manifestPath]); + + // Assert: still exactly one element + Assert.AreEqual(0, exitCode); + var content = await File.ReadAllTextAsync(targetManifest, TestContext.CancellationToken); + var occurrences = content.Split(" +/// Tests for sparse-aware routing in the pack command. Uses a fake MSIX service to verify the +/// command dispatches to the sparse identity path without invoking real build tools. +/// +[TestClass] +public class SparsePackRoutingTests : BaseCommandTests +{ + private FakeMsixService _fakeMsixService = null!; + + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + _fakeMsixService = new FakeMsixService(); + return services.AddSingleton(_fakeMsixService); + } + + private const string SparseManifest = """ + + + + + SparsePkg + Test + Assets\StoreLogo.png + true + + + + + + + + + + """; + + private const string PackagedManifest = """ + + + + + FullPkg + Test + Assets\StoreLogo.png + + + + + + + + + + """; + + [TestMethod] + public async Task Pack_SparseManifestFile_RoutesToSparseIdentityPath() + { + // Arrange + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + await File.WriteAllTextAsync(manifestPath, SparseManifest, TestContext.CancellationToken); + var packageCommand = GetRequiredService(); + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(packageCommand, [manifestPath]); + + // Assert + Assert.AreEqual(0, exitCode, "Sparse pack should succeed"); + Assert.AreEqual(1, _fakeMsixService.CreateSparseIdentityCalls.Count, "Should route to CreateSparseIdentityPackageAsync"); + Assert.IsFalse(_fakeMsixService.CreateSparseIdentityCalls[0].AutoSign, "No cert provided means no signing"); + } + + [TestMethod] + public async Task Pack_NonSparseManifestFile_ReturnsError() + { + // Arrange + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + await File.WriteAllTextAsync(manifestPath, PackagedManifest, TestContext.CancellationToken); + var packageCommand = GetRequiredService(); + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(packageCommand, [manifestPath]); + + // Assert + Assert.AreEqual(1, exitCode, "Passing a non-sparse manifest file should fail"); + Assert.AreEqual(0, _fakeMsixService.CreateSparseIdentityCalls.Count, "Should not route to sparse path"); + } + + [TestMethod] + public async Task Pack_SparseManifestFile_WithCert_Signs() + { + // Arrange + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + await File.WriteAllTextAsync(manifestPath, SparseManifest, TestContext.CancellationToken); + var certPath = Path.Combine(_tempDirectory.FullName, "dev.pfx"); + await File.WriteAllTextAsync(certPath, "not-a-real-cert", TestContext.CancellationToken); + var packageCommand = GetRequiredService(); + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(packageCommand, [manifestPath, "--cert", certPath]); + + // Assert + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, _fakeMsixService.CreateSparseIdentityCalls.Count); + Assert.IsTrue(_fakeMsixService.CreateSparseIdentityCalls[0].AutoSign, "Providing --cert should request signing"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs new file mode 100644 index 00000000..b429e660 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.CommandLine; +using System.CommandLine.Invocation; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Commands; + +internal class EmbedIdentityCommand : Command, IShortDescription +{ + public string ShortDescription => "Embed sparse package identity into an app's manifest"; + + public static Argument TargetArgument { get; } + public static Option ManifestOption { get; } + + static EmbedIdentityCommand() + { + TargetArgument = new Argument("target") + { + Description = "Path to the .exe (embeds identity into its side-by-side manifest via mt.exe) or an .xml/.manifest side-by-side manifest file (inserts/replaces the element; created if it doesn't exist)." + }; + ManifestOption = new Option("--manifest") + { + Description = "Path to the sparse appxmanifest.xml to read identity from (default: ./appxmanifest.xml)" + }; + ManifestOption.AcceptExistingOnly(); + } + + public EmbedIdentityCommand() : base("embed-identity", "Connect a desktop exe to its sparse identity package by embedding the element. Reads identity (packageName, publisher, applicationId) from a sparse appxmanifest.xml and writes it into the target's side-by-side (fusion) manifest. EXE targets are updated with mt.exe; .xml/.manifest targets are edited directly. Example: winapp embed-identity ./bin/MyApp.exe. This is step 3 of the sparse packaging workflow (after 'winapp init --exe --sparse' and 'winapp pack').") + { + Arguments.Add(TargetArgument); + Options.Add(ManifestOption); + } + + public class Handler(IMsixService msixService, ICurrentDirectoryProvider currentDirectoryProvider, IStatusService statusService, ILogger logger) : AsynchronousCommandLineAction + { + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var target = parseResult.GetRequiredValue(TargetArgument); + var manifest = parseResult.GetValue(ManifestOption) ?? ManifestHelper.FindManifest(currentDirectoryProvider.GetCurrentDirectory()); + + if (!manifest.Exists) + { + logger.LogError("AppX manifest not found: {Manifest}. Pass --manifest, or generate one with 'winapp init --exe --sparse'.", manifest.FullName); + return 1; + } + + var extension = target.Extension.ToLowerInvariant(); + var isSupported = extension is ".exe" or ".xml" or ".manifest"; + if (!isSupported) + { + logger.LogError("Unsupported target '{Target}'. Provide a .exe (EXE mode) or an .xml/.manifest file (XML mode).", target.Name); + return 1; + } + + return await statusService.ExecuteWithStatusAsync("Embedding package identity...", async (taskContext, ct) => + { + try + { + var identity = await msixService.EmbedIdentityAsync(target, manifest, taskContext, ct); + + taskContext.AddStatusMessage($"{UiSymbols.Package} Package: {identity.PackageName}"); + taskContext.AddStatusMessage($"{UiSymbols.User} Publisher: {identity.Publisher}"); + taskContext.AddStatusMessage($"{UiSymbols.Id} App ID: {identity.ApplicationId}"); + + if (extension is ".xml" or ".manifest") + { + taskContext.AddStatusMessage($"{UiSymbols.Info} Rebuild your app so the updated side-by-side manifest is embedded in the exe."); + } + + return (0, "Package identity embedded successfully."); + } + catch (Exception ex) + { + var baseEx = ex.GetBaseException(); + return (1, $"{UiSymbols.Error} Failed to embed package identity: {baseEx.Message}"); + } + }, cancellationToken); + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index 970bda18..643085e7 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -21,6 +21,11 @@ internal class InitCommand : Command, IShortDescription public static Option NoGitignoreOption { get; } public static Option UseDefaults { get; } public static Option ConfigOnlyOption { get; } + public static Option ExeOption { get; } + public static Option SparseOption { get; } + public static Option NameOption { get; } + public static Option PublisherOption { get; } + public static Option OutputDirOption { get; } static InitCommand() { @@ -56,6 +61,27 @@ static InitCommand() { Description = "Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps." }; + ExeOption = new Option("--exe") + { + Description = "Path to the application executable. Requires --sparse. Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup." + }; + ExeOption.AcceptExistingOnly(); + SparseOption = new Option("--sparse") + { + Description = "Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation." + }; + NameOption = new Option("--name") + { + Description = "Override the package name (sparse only; default: inferred from the exe)" + }; + PublisherOption = new Option("--publisher") + { + Description = "Override the publisher CN (sparse only; default: inferred from the exe's company name). Bare names are auto-wrapped as CN=." + }; + OutputDirOption = new Option("--output-dir") + { + Description = "Directory to write the sparse manifest and Assets/ (sparse only; default: the exe's directory)" + }; } public InitCommand() : base("init", "Start here for initializing a Windows app with required setup. Sets up everything needed for Windows app development: creates Package.appxmanifest with default assets, downloads Windows SDK and Windows App SDK packages, and generates projections. When SDK packages are managed (--setup-sdks stable/preview/experimental), also creates winapp.yaml to pin versions for 'restore'/'update'; with --setup-sdks none (e.g., for Rust/Tauri projects that bring their own SDK bindings), no winapp.yaml is created. Interactive by default; automatically uses defaults in non-interactive environments (use --use-defaults to skip prompts explicitly). Use 'restore' instead if you cloned a repo that already has winapp.yaml. Use 'manifest generate' if you only need a manifest, or 'cert generate' if you need a development certificate for code signing.") @@ -67,12 +93,19 @@ static InitCommand() Options.Add(NoGitignoreOption); Options.Add(UseDefaults); Options.Add(ConfigOnlyOption); + Options.Add(ExeOption); + Options.Add(SparseOption); + Options.Add(NameOption); + Options.Add(PublisherOption); + Options.Add(OutputDirOption); } public class Handler( IWorkspaceSetupService workspaceSetupService, IProjectDetectionService projectDetectionService, ICurrentDirectoryProvider currentDirectoryProvider, + IManifestService manifestService, + IStatusService statusService, IAnsiConsole ansiConsole, ILogger logger) : AsynchronousCommandLineAction { @@ -87,6 +120,18 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var noGitignore = parseResult.GetValue(NoGitignoreOption); var useDefaults = parseResult.GetValue(UseDefaults); var configOnly = parseResult.GetValue(ConfigOnlyOption); + var exe = parseResult.GetValue(ExeOption); + var sparse = parseResult.GetValue(SparseOption); + var name = parseResult.GetValue(NameOption); + var publisher = parseResult.GetValue(PublisherOption); + var outputDir = parseResult.GetValue(OutputDirOption); + + // --exe is only valid together with --sparse. + if (exe != null && !sparse) + { + logger.LogError("--exe requires --sparse. Use 'winapp init' without --exe for full package initialization."); + return 1; + } // Detect non-interactive environments (piped stdin, CI, etc.) and fall back // to --use-defaults behavior to avoid InvalidOperationException from prompts. @@ -96,6 +141,13 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio useDefaults = true; } + // Sparse identity flow: generate an identity-only appxmanifest.xml + assets for an + // existing exe. This intentionally skips all SDK/package installation. + if (sparse) + { + return await RunSparseInitAsync(exe, name, publisher, outputDir, useDefaults, cancellationToken); + } + DirectoryInfo? selectedDirectory; if (baseDirectoryExplicit) @@ -159,6 +211,67 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return result; } + /// + /// Generates an identity-only sparse manifest (appxmanifest.xml) plus placeholder assets + /// for an existing desktop executable. Skips all SDK/package installation because sparse + /// identity packages have no SDK dependencies. + /// + private async Task RunSparseInitAsync( + FileInfo? exe, + string? name, + string? publisher, + DirectoryInfo? outputDir, + bool useDefaults, + CancellationToken cancellationToken) + { + if (exe == null) + { + logger.LogError("--sparse requires --exe . Provide the path to the application executable."); + return 1; + } + + // Default output location is the exe's directory so the identity manifest and its + // Assets/ sit alongside the app that will be registered via -ExternalLocation. + var targetDir = outputDir ?? exe.Directory ?? currentDirectoryProvider.GetCurrentDirectoryInfo(); + + SparseInitResult? sparseResult = null; + var exitCode = await statusService.ExecuteWithStatusAsync("Generating sparse identity manifest...", async (taskContext, ct) => + { + try + { + sparseResult = await manifestService.GenerateSparseIdentityManifestAsync( + targetDir, exe, name, publisher, useDefaults, taskContext, ct); + return (0, "Sparse identity manifest generated."); + } + catch (Exception ex) + { + taskContext.AddDebugMessage($"Stack Trace: {ex.StackTrace}"); + return (1, $"{UiSymbols.Error} Failed to generate sparse manifest: {ex.GetBaseException().Message}"); + } + }, cancellationToken); + + if (exitCode != 0 || sparseResult == null) + { + return exitCode; + } + + // Summary + next steps. + ansiConsole.MarkupLineInterpolated($"{UiSymbols.Check} Generated sparse identity package files:"); + ansiConsole.MarkupLineInterpolated($" {UiSymbols.Files} Manifest: {sparseResult.ManifestPath.FullName}"); + ansiConsole.MarkupLineInterpolated($" {UiSymbols.Files} Assets: {sparseResult.AssetsDirectory.FullName}"); + ansiConsole.WriteLine(); + ansiConsole.MarkupLineInterpolated($"{UiSymbols.Package} Package: {sparseResult.Info.PackageName} Version: {sparseResult.Info.Version}"); + ansiConsole.WriteLine(); + ansiConsole.MarkupLine("[yellow]Note:[/] This is an [bold]identity-only[/] package. The generated assets in [blue]Assets/[/] are resolved from the app's install directory (the external content location) at runtime — they are [bold]not[/] bundled into the .msix. Deploy them alongside your application."); + ansiConsole.WriteLine(); + ansiConsole.MarkupLine("Next steps:"); + ansiConsole.MarkupLineInterpolated($" 1. Run [blue]winapp pack \"{sparseResult.ManifestPath.FullName}\" --cert [/] to create the signed identity .msix"); + ansiConsole.MarkupLineInterpolated($" 2. Run [blue]winapp embed-identity \"{exe.FullName}\"[/] to connect your exe to the identity package"); + ansiConsole.MarkupLine(" 3. Register in your installer with [blue]Add-AppxPackage -Path -ExternalLocation [/]"); + + return 0; + } + /// /// Detects compatible projects in the directory tree and prompts the user to select one. /// Only called when no directory argument was provided and --use-defaults is not set. diff --git a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs index 6d94ffb9..ba3524c0 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs @@ -103,6 +103,32 @@ public PackageCommand() public class Handler(IMsixService msixService, IStatusService statusService) : AsynchronousCommandLineAction { + /// + /// Returns true when the file is an appx manifest that declares + /// uap10:AllowExternalContent="true" (i.e. a sparse identity package manifest). + /// + private static async Task IsSparseManifestAsync(FileInfo file, CancellationToken cancellationToken) + { + var name = file.Name; + var isManifestName = name.EndsWith(".appxmanifest", StringComparison.OrdinalIgnoreCase) + || name.Equals("appxmanifest.xml", StringComparison.OrdinalIgnoreCase); + if (!isManifestName) + { + return false; + } + + try + { + var content = await File.ReadAllTextAsync(file.FullName, cancellationToken); + return content.Contains("AllowExternalContent", StringComparison.OrdinalIgnoreCase) + && MsixService.ManifestHasAllowExternalContent(content); + } + catch + { + return false; + } + } + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) { var inputFolders = parseResult.GetRequiredValue(InputFolderArgument); @@ -118,6 +144,44 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var selfContained = parseResult.GetValue(SelfContainedOption); var executable = parseResult.GetValue(ExecutableOption); + // Sparse identity packaging: when a single manifest FILE is passed (instead of a + // folder) and it declares AllowExternalContent, build an identity-only .msix from + // just the manifest — no input folder or app binaries required. + if (inputFolders.Length == 1 && File.Exists(inputFolders[0].FullName)) + { + var candidateManifest = new FileInfo(inputFolders[0].FullName); + if (await IsSparseManifestAsync(candidateManifest, cancellationToken)) + { + return await statusService.ExecuteWithStatusAsync("Creating sparse identity package...", async (taskContext, ct) => + { + try + { + var autoSign = certPath != null || generateCert; + var result = await msixService.CreateSparseIdentityPackageAsync(candidateManifest, output, taskContext, autoSign, certPath, certPassword, generateCert, installCert, publisher, ct); + + taskContext.AddStatusMessage($"{UiSymbols.Package} Identity package: {result.MsixPath}"); + if (result.Signed) + { + taskContext.AddStatusMessage($"{UiSymbols.Lock} Package has been signed"); + } + taskContext.AddStatusMessage($"{UiSymbols.Info} Next: winapp embed-identity — then register in your installer with Add-AppxPackage -Path -ExternalLocation "); + + return (0, "Sparse identity package creation completed."); + } + catch (Exception ex) + { + taskContext.AddDebugMessage($"Stack Trace: {ex.StackTrace}"); + return (1, $"{UiSymbols.Error} Failed to create sparse identity package: {ex.GetBaseException().Message}"); + } + }, 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'.")); + }, cancellationToken); + } + // Validate all input folders exist (report all missing at once) var missingDirs = inputFolders.Where(d => !d.Exists).ToList(); if (missingDirs.Count > 0) diff --git a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs index 766e777e..878e8aad 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/WinAppRootCommand.cs @@ -61,6 +61,7 @@ public WinAppRootCommand( ManifestCommand manifestCommand, UpdateCommand updateCommand, CreateDebugIdentityCommand createDebugIdentityCommand, + EmbedIdentityCommand embedIdentityCommand, RunCommand runCommand, UnregisterCommand unregisterCommand, GetWinappPathCommand getWinappPathCommand, @@ -79,6 +80,7 @@ public WinAppRootCommand( Subcommands.Add(manifestCommand); Subcommands.Add(updateCommand); Subcommands.Add(createDebugIdentityCommand); + Subcommands.Add(embedIdentityCommand); Subcommands.Add(runCommand); Subcommands.Add(unregisterCommand); Subcommands.Add(getWinappPathCommand); @@ -100,7 +102,7 @@ public WinAppRootCommand( var helpOption = Options.OfType().First(); helpOption.Action = new CustomHelpAction(this, ansiConsole, ("Setup", [typeof(InitCommand), typeof(RestoreCommand), typeof(UpdateCommand)]), - ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(CreateExternalCatalogCommand)]), + ("Packaging & Signing", [typeof(PackageCommand), typeof(SignCommand), typeof(CertCommand), typeof(ManifestCommand), typeof(EmbedIdentityCommand), typeof(CreateExternalCatalogCommand)]), ("Development Tools", [typeof(CreateDebugIdentityCommand), typeof(MSStoreCommand), typeof(ToolCommand), typeof(GetWinappPathCommand), typeof(RunCommand), typeof(UnregisterCommand)]), ("UI Automation", [typeof(UiCommand)]) ); diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index a55b1f2e..28cb26da 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -72,6 +72,7 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() diff --git a/src/winapp-CLI/WinApp.Cli/Services/IManifestService.cs b/src/winapp-CLI/WinApp.Cli/Services/IManifestService.cs index 3f31c29e..5e4a5497 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IManifestService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IManifestService.cs @@ -17,6 +17,11 @@ public record ManifestGenerationInfo( string Version, string Description); +public record SparseInitResult( + FileInfo ManifestPath, + ManifestGenerationInfo Info, + DirectoryInfo AssetsDirectory); + internal interface IManifestService { public Task PromptForManifestInfoAsync( @@ -29,6 +34,22 @@ public Task PromptForManifestInfoAsync( bool useDefaults, CancellationToken cancellationToken = default); + /// + /// Generates a sparse identity appxmanifest.xml (plus placeholder assets) for an + /// existing desktop executable. Infers metadata defaults from the exe via + /// , optionally prompting for overrides. + /// The generated manifest references the external exe by name so it can be packed as an + /// identity-only MSIX with winapp pack. + /// + public Task GenerateSparseIdentityManifestAsync( + DirectoryInfo outputDirectory, + FileInfo executable, + string? packageName, + string? publisherName, + bool useDefaults, + TaskContext taskContext, + CancellationToken cancellationToken = default); + public Task GenerateManifestAsync( DirectoryInfo directory, ManifestGenerationInfo manifestGenerationInfo, diff --git a/src/winapp-CLI/WinApp.Cli/Services/IManifestTemplateService.cs b/src/winapp-CLI/WinApp.Cli/Services/IManifestTemplateService.cs index 57201f67..e2d2a851 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IManifestTemplateService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IManifestTemplateService.cs @@ -16,5 +16,7 @@ Task GenerateCompleteManifestAsync( ManifestTemplates manifestTemplate, string description, TaskContext taskContext, + string manifestFileName = "Package.appxmanifest", + string? executableName = null, CancellationToken cancellationToken = default); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs index b1dce481..1685ca88 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/IMsixService.cs @@ -50,6 +50,36 @@ public Task AddSparseIdentityAsync( TaskContext taskContext, CancellationToken cancellationToken = default); + /// + /// Builds an identity-only sparse MSIX package from a sparse appxmanifest.xml + /// (one that declares uap10:AllowExternalContent). Only the manifest is packaged — + /// application binaries and visual assets are resolved from the external content + /// location at registration time. Optionally signs the resulting package. + /// + public Task CreateSparseIdentityPackageAsync( + FileInfo manifestPath, + FileSystemInfo? outputPath, + TaskContext taskContext, + bool autoSign = false, + FileInfo? certificatePath = null, + string certificatePassword = "password", + bool generateDevCert = false, + bool installDevCert = false, + string? publisher = null, + CancellationToken cancellationToken = default); + + /// + /// Embeds the <msix> identity element (read from a sparse appxmanifest.xml) + /// into a target. When the target is an .exe, the element is embedded into the exe's + /// side-by-side (fusion) manifest via mt.exe. When the target is an .xml/.manifest file, + /// the element is inserted or replaced in that external SxS manifest. + /// + public Task EmbedIdentityAsync( + FileInfo target, + FileInfo manifestPath, + TaskContext taskContext, + CancellationToken cancellationToken = default); + public Task AddLooseLayoutIdentityAsync( FileInfo appxManifestPath, DirectoryInfo inputDirectory, diff --git a/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs b/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs index b869b872..e0ccd590 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs @@ -112,7 +112,7 @@ await manifestTemplateService.GenerateCompleteManifestAsync( manifestTemplate, description, taskContext, - cancellationToken); + cancellationToken: cancellationToken); string? extractedLogoPath = null; @@ -179,8 +179,137 @@ await manifestTemplateService.GenerateCompleteManifestAsync( } } + public async Task GenerateSparseIdentityManifestAsync( + DirectoryInfo outputDirectory, + FileInfo executable, + string? packageName, + string? publisherName, + bool useDefaults, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + outputDirectory.Create(); + + // Infer the package version from the executable's file version, falling back to 1.0.0.0. + var inferredVersion = "1.0.0.0"; + try + { + var fileVersionInfo = FileVersionInfo.GetVersionInfo(executable.FullName); + inferredVersion = NormalizeManifestVersion(fileVersionInfo.FileVersion) ?? "1.0.0.0"; + } + catch + { + // Non-fatal: keep the default version if the exe has no readable version info. + } + + // Infer package name / publisher / description from the exe and (unless --use-defaults) + // prompt the user to accept or override each value. + var info = await PromptForManifestInfoAsync( + outputDirectory, + packageName, + publisherName, + inferredVersion, + description: null, + executable: executable.FullName, + useDefaults, + cancellationToken); + + // Generate the sparse identity manifest as appxmanifest.xml, substituting the concrete + // external exe name for the $targetnametoken$ build token so it can be packed directly. + await manifestTemplateService.GenerateCompleteManifestAsync( + outputDirectory, + info.PackageName, + info.PublisherName, + info.Version, + ManifestTemplates.Sparse, + info.Description, + taskContext, + manifestFileName: "appxmanifest.xml", + executableName: executable.Name, + cancellationToken: cancellationToken); + + var manifestPath = new FileInfo(Path.Combine(outputDirectory.FullName, "appxmanifest.xml")); + var assetsDirectory = new DirectoryInfo(Path.Combine(outputDirectory.FullName, "Assets")); + + // Best-effort: extract the app icon from the exe to replace the placeholder assets. + await TryApplyExtractedLogoAsync(manifestPath, executable, taskContext, cancellationToken); + + return new SparseInitResult(manifestPath, info, assetsDirectory); + } + + /// + /// Extracts the jumbo icon from an executable and applies it to the manifest's assets. + /// Silently no-ops if extraction fails. + /// + private async Task TryApplyExtractedLogoAsync(FileInfo manifestPath, FileInfo executable, TaskContext taskContext, CancellationToken cancellationToken) + { + string? extractedLogoPath = null; + Icon? extractedIcon = null; + try + { + extractedIcon = ShellIcon.GetJumboIcon(executable.FullName); + if (extractedIcon == null) + { + return; + } + + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + extractedLogoPath = Path.Combine(tempDir, "StoreLogo.png"); + using (var stream = new FileStream(extractedLogoPath, FileMode.Create)) + { + extractedIcon.ToBitmap().Save(stream, ImageFormat.Png); + } + + await UpdateManifestAssetsAsync(manifestPath, new FileInfo(extractedLogoPath), taskContext, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + taskContext.AddDebugMessage($"Could not extract logo from executable: {ex.Message}"); + } + finally + { + extractedIcon?.Dispose(); + if (extractedLogoPath != null) + { + try + { + File.Delete(extractedLogoPath); + Directory.Delete(Path.GetDirectoryName(extractedLogoPath)!); + } + catch + { + // best-effort cleanup + } + } + } + } + + /// + /// Normalizes a version string to the 4-part Major.Minor.Build.Revision format required by + /// the MSIX Identity element. Returns null when the input cannot be parsed as a version. + /// + internal static string? NormalizeManifestVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + { + return null; + } + + if (!Version.TryParse(version.Trim(), out var parsed)) + { + return null; + } + + var major = Math.Max(parsed.Major, 0); + var minor = Math.Max(parsed.Minor, 0); + var build = Math.Max(parsed.Build, 0); + var revision = Math.Max(parsed.Revision, 0); + + return $"{major}.{minor}.{build}.{revision}"; + } + /// - /// Cleans and sanitizes a package name to meet MSIX AppxManifest Identity Name schema requirements. /// The Identity Name must match the pattern [-.A-Za-z0-9]+ (only letters, digits, periods, and hyphens). /// /// The package name to clean diff --git a/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs b/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs index 754b17a9..983f6e1f 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs @@ -228,6 +228,8 @@ public async Task GenerateCompleteManifestAsync( ManifestTemplates manifestTemplate, string description, TaskContext taskContext, + string manifestFileName = "Package.appxmanifest", + string? executableName = null, CancellationToken cancellationToken = default) { // Normalize publisher to a valid distinguished name @@ -255,8 +257,16 @@ public async Task GenerateCompleteManifestAsync( version, description); + // When a concrete executable name is provided (e.g. sparse identity packages + // that reference an external exe), replace the $targetnametoken$ build token so + // the manifest is self-contained and can be packed without --executable. + if (!string.IsNullOrWhiteSpace(executableName)) + { + content = content.Replace("$targetnametoken$.exe", executableName); + } + // Write manifest file - var manifestPath = Path.Combine(outputDirectory.FullName, "Package.appxmanifest"); + var manifestPath = Path.Combine(outputDirectory.FullName, manifestFileName); await File.WriteAllTextAsync(manifestPath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); // Generate default assets diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs index 228a0624..8da81bba 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs @@ -3,6 +3,7 @@ using System.Security; using System.Text; +using System.Xml.Linq; using Microsoft.Extensions.Logging; using WinApp.Cli.ConsoleTasks; using WinApp.Cli.Helpers; @@ -13,6 +14,179 @@ namespace WinApp.Cli.Services; internal partial class MsixService { + /// + /// Namespace of the SxS assembly manifest root element. + /// + private static readonly XNamespace AsmV1Ns = "urn:schemas-microsoft-com:asm.v1"; + + /// + /// Namespace of the <msix> package-identity element embedded in a fusion manifest. + /// + private static readonly XNamespace MsixV1Ns = "urn:schemas-microsoft-com:msix.v1"; + + public async Task CreateSparseIdentityPackageAsync( + FileInfo manifestPath, + FileSystemInfo? outputPath, + TaskContext taskContext, + bool autoSign = false, + FileInfo? certificatePath = null, + string certificatePassword = "password", + bool generateDevCert = false, + bool installDevCert = false, + string? publisher = null, + CancellationToken cancellationToken = default) + { + if (!manifestPath.Exists) + { + throw new FileNotFoundException($"Sparse manifest not found at: {manifestPath}"); + } + + var manifestContent = await File.ReadAllTextAsync(manifestPath.FullName, Encoding.UTF8, cancellationToken); + var doc = AppxManifestDocument.Parse(manifestContent); + + var allowExternalContent = doc.Document.Root? + .Element(AppxManifestDocument.DefaultNs + "Properties")? + .Element(AppxManifestDocument.Uap10Ns + "AllowExternalContent"); + if (allowExternalContent == null || !string.Equals(allowExternalContent.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "The manifest does not declare uap10:AllowExternalContent=\"true\", so it is not a sparse identity package. " + + "Generate one with 'winapp init --exe --sparse', or pass an input folder to package a full MSIX."); + } + + var packageName = ManifestService.CleanPackageName(doc.IdentityName ?? "Package"); + var extractedPublisher = publisher ?? doc.IdentityPublisher; + + // Resolve output path: default to .identity.msix in the current directory. + var defaultFileName = $"{packageName}.identity.msix"; + FileInfo outputMsixPath; + DirectoryInfo outputFolder; + if (outputPath == null) + { + outputFolder = currentDirectoryProvider.GetCurrentDirectoryInfo(); + outputMsixPath = new FileInfo(Path.Combine(outputFolder.FullName, defaultFileName)); + } + else if (Path.HasExtension(outputPath.Name) && string.Equals(Path.GetExtension(outputPath.Name), ".msix", StringComparison.OrdinalIgnoreCase)) + { + outputMsixPath = new FileInfo(outputPath.FullName); + outputFolder = outputMsixPath.Directory!; + } + else + { + outputFolder = new DirectoryInfo(outputPath.FullName); + outputMsixPath = new FileInfo(Path.Combine(outputPath.FullName, defaultFileName)); + } + + if (!outputFolder.Exists) + { + outputFolder.Create(); + } + + // Stage a directory containing ONLY the manifest. Sparse identity packages carry no + // binaries or assets — those are resolved from the external content location at runtime. + var stagingDir = new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"winapp-sparse-{Guid.NewGuid():N}")); + stagingDir.Create(); + try + { + var stagedManifest = Path.Combine(stagingDir.FullName, "appxmanifest.xml"); + await File.WriteAllTextAsync(stagedManifest, manifestContent, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); + + taskContext.AddDebugMessage($"{UiSymbols.Package} Packaging sparse identity manifest (external content): {manifestPath.Name}"); + + await CreateMsixPackageFromFolderAsync(stagingDir, outputMsixPath, taskContext, cancellationToken); + + if (autoSign) + { + await SignMsixPackageAsync(outputFolder, certificatePassword, generateDevCert, installDevCert, packageName, extractedPublisher, outputMsixPath, certificatePath, manifestPath, taskContext, cancellationToken); + } + } + finally + { + try + { + if (stagingDir.Exists) + { + stagingDir.Delete(recursive: true); + } + } + catch + { + taskContext.AddDebugMessage($"{UiSymbols.Warning} Could not clean up staging directory: {stagingDir.FullName}"); + } + } + + return new CreateMsixPackageResult(outputMsixPath, autoSign); + } + + public async Task EmbedIdentityAsync( + FileInfo target, + FileInfo manifestPath, + TaskContext taskContext, + CancellationToken cancellationToken = default) + { + if (!manifestPath.Exists) + { + throw new FileNotFoundException( + $"AppX manifest not found at: {manifestPath}. Pass --manifest, or generate one with 'winapp init --exe --sparse'."); + } + + var identity = await ParseAppxManifestFromPathAsync(manifestPath, cancellationToken); + + var extension = target.Extension.ToLowerInvariant(); + if (extension == ".exe") + { + if (!target.Exists) + { + throw new FileNotFoundException($"Executable not found at: {target}"); + } + + taskContext.AddDebugMessage($"Embedding identity into exe fusion manifest: {target.Name}"); + await EmbedMsixIdentityToExeAsync(target, identity, taskContext, cancellationToken); + } + else + { + taskContext.AddDebugMessage($"Inserting identity into external SxS manifest: {target.Name}"); + await EmbedIdentityIntoXmlManifestAsync(target, identity, cancellationToken); + } + + return identity; + } + + /// + /// Inserts or replaces the <msix> identity element in an external side-by-side manifest + /// XML file. Creates a minimal assembly manifest if the target file does not yet exist. + /// + private static async Task EmbedIdentityIntoXmlManifestAsync(FileInfo target, MsixIdentityResult identity, CancellationToken cancellationToken) + { + XDocument xdoc; + if (target.Exists) + { + var existing = await File.ReadAllTextAsync(target.FullName, cancellationToken); + xdoc = XDocument.Parse(existing); + } + else + { + xdoc = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement(AsmV1Ns + "assembly", new XAttribute("manifestVersion", "1.0"))); + } + + var root = xdoc.Root + ?? throw new InvalidOperationException($"The manifest '{target.FullName}' has no root element."); + + // Remove any existing element(s) so re-running the command is idempotent. + root.Elements(MsixV1Ns + "msix").Remove(); + + var msix = new XElement(MsixV1Ns + "msix", + new XAttribute("publisher", identity.Publisher), + new XAttribute("packageName", identity.PackageName), + new XAttribute("applicationId", identity.ApplicationId)); + root.Add(msix); + + await using var stream = new FileStream(target.FullName, FileMode.Create, FileAccess.Write); + await xdoc.SaveAsync(stream, SaveOptions.None, cancellationToken); + } + public async Task AddSparseIdentityAsync(string? entryPointPath, FileInfo appxManifestPath, bool noInstall, bool keepIdentity, TaskContext taskContext, CancellationToken cancellationToken = default) { // Validate inputs diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs index 2c0c85b6..64ca76d4 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs @@ -32,6 +32,26 @@ internal partial class MsixService( ILogger logger, ICurrentDirectoryProvider currentDirectoryProvider) : IMsixService { + /// + /// Returns true when the manifest content declares uap10:AllowExternalContent="true", + /// indicating a sparse identity package whose binaries/assets live at an external location. + /// + public static bool ManifestHasAllowExternalContent(string manifestContent) + { + try + { + var doc = AppxManifestDocument.Parse(manifestContent); + var el = doc.Document.Root? + .Element(AppxManifestDocument.DefaultNs + "Properties")? + .Element(AppxManifestDocument.Uap10Ns + "AllowExternalContent"); + return el != null && string.Equals(el.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + /// /// Parses an AppX manifest file and extracts the package identity information /// @@ -362,6 +382,28 @@ public async Task CreateMsixPackageAsync( // Parse the manifest to extract identity, executable, and architecture info var manifestDoc = AppxManifestDocument.Parse(manifestContent); + // When packaging a FOLDER for a sparse (AllowExternalContent) package, warn about + // content that should live at the external location instead of inside the .msix. + if (ManifestHasAllowExternalContent(manifestContent)) + { + var imageExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".ico" }; + var binaryExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".exe", ".dll", ".so" }; + + var hasImages = inputFolder.EnumerateFiles("*", SearchOption.AllDirectories) + .Any(f => imageExtensions.Contains(f.Extension)); + var hasBinaries = inputFolder.EnumerateFiles("*", SearchOption.AllDirectories) + .Any(f => binaryExtensions.Contains(f.Extension)); + + if (hasImages) + { + taskContext.AddStatusMessage($"{UiSymbols.Warning} Assets found in package folder. For sparse packages, assets should be deployed at the external location alongside your application, not inside the .msix."); + } + if (hasBinaries) + { + taskContext.AddStatusMessage($"{UiSymbols.Warning} Binaries found in package folder. Sparse packages are identity-only — application binaries should not be included in the .msix."); + } + } + try { if (string.IsNullOrWhiteSpace(finalPackageName)) @@ -898,7 +940,7 @@ internal static bool IsRuntimeToolExecutable(string fileName) if (app != null && isExe && app.Attribute(AppxManifestDocument.Uap10Ns + "TrustLevel") == null) { app.SetAttributeValue(AppxManifestDocument.Uap10Ns + "TrustLevel", "mediumIL"); - app.SetAttributeValue(AppxManifestDocument.Uap10Ns + "RuntimeBehavior", "packagedClassicApp"); + app.SetAttributeValue(AppxManifestDocument.Uap10Ns + "RuntimeBehavior", "win32App"); } // Remove EntryPoint if present (not needed for sparse packages) diff --git a/src/winapp-CLI/WinApp.Cli/Templates/appxmanifest.sparse.xml b/src/winapp-CLI/WinApp.Cli/Templates/appxmanifest.sparse.xml index 7bd90512..57a65e7c 100644 --- a/src/winapp-CLI/WinApp.Cli/Templates/appxmanifest.sparse.xml +++ b/src/winapp-CLI/WinApp.Cli/Templates/appxmanifest.sparse.xml @@ -14,7 +14,8 @@ + Version="1.0.0.0" + ProcessorArchitecture="neutral" /> {PackageName} @@ -25,7 +26,7 @@ - + @@ -35,7 +36,8 @@ + uap10:RuntimeBehavior="win32App" + uap10:TrustLevel="mediumIL"> element; created if it doesn't exist). */ + target: string; + /** Path to the sparse appxmanifest.xml to read identity from (default: ./appxmanifest.xml) */ + manifest?: string; +} + +/** + * Connect a desktop exe to its sparse identity package by embedding the element. Reads identity (packageName, publisher, applicationId) from a sparse appxmanifest.xml and writes it into the target's side-by-side (fusion) manifest. EXE targets are updated with mt.exe; .xml/.manifest targets are edited directly. Example: winapp embed-identity ./bin/MyApp.exe. This is step 3 of the sparse packaging workflow (after 'winapp init --exe --sparse' and 'winapp pack'). + */ +export async function embedIdentity(options: EmbedIdentityOptions): Promise { + const args: string[] = ['embed-identity']; + args.push(options.target); + if (options.manifest) args.push('--manifest', options.manifest); + return execCommand(args, options); +} + // --------------------------------------------------------------------------- // get-winapp-path // --------------------------------------------------------------------------- @@ -245,12 +266,22 @@ export interface InitOptions extends CommonOptions { configDir?: string; /** Only handle configuration file operations (create if missing, validate if exists). Skip package installation and other workspace setup steps. */ configOnly?: boolean; + /** Path to the application executable. Requires --sparse. Generates an identity-only sparse manifest for the exe instead of a full package/SDK setup. */ + exe?: string; /** Don't use configuration file for version management */ ignoreConfig?: boolean; + /** Override the package name (sparse only; default: inferred from the exe) */ + name?: string; /** Don't update .gitignore file */ noGitignore?: boolean; + /** Directory to write the sparse manifest and Assets/ (sparse only; default: the exe's directory) */ + outputDir?: string; + /** Override the publisher CN (sparse only; default: inferred from the exe's company name). Bare names are auto-wrapped as CN=. */ + publisher?: string; /** SDK installation mode: 'stable' (default), 'preview', 'experimental', or 'none' (skip SDK installation) */ setupSdks?: SdkInstallMode; + /** Generate a sparse identity manifest (appxmanifest.xml) for an existing desktop exe instead of a full package manifest. Use with --exe. Skips SDK/package installation. */ + sparse?: boolean; /** Do not prompt; requires an explicit project directory (e.g., winapp init . --use-defaults) */ useDefaults?: boolean; } @@ -263,9 +294,14 @@ export async function init(options: InitOptions = {}): Promise { if (options.baseDirectory) args.push(options.baseDirectory); if (options.configDir) args.push('--config-dir', options.configDir); if (options.configOnly) args.push('--config-only'); + if (options.exe) args.push('--exe', options.exe); if (options.ignoreConfig) args.push('--ignore-config'); + if (options.name) args.push('--name', options.name); if (options.noGitignore) args.push('--no-gitignore'); + if (options.outputDir) args.push('--output-dir', options.outputDir); + if (options.publisher) args.push('--publisher', options.publisher); if (options.setupSdks) args.push('--setup-sdks', options.setupSdks); + if (options.sparse) args.push('--sparse'); if (options.useDefaults) args.push('--use-defaults'); return execCommand(args, options); } From e8da9883006617addc1b1be53453f5b002aba1c9 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Sun, 5 Jul 2026 17:37:29 -0400 Subject: [PATCH 2/5] sparse packaging draft --- .claude/agents/winapp.md | 23 ++- .github/plugin/agents/winapp.agent.md | 23 ++- README.md | 1 + docs/guides/sparse.md | 30 +++- samples/sparse-app/installer/setup.iss | 30 +++- .../WinApp.Cli.Tests/SparsePackagingTests.cs | 165 ++++++++++++++++++ .../Commands/EmbedIdentityCommand.cs | 14 +- .../WinApp.Cli/Commands/InitCommand.cs | 2 +- .../WinApp.Cli/Commands/PackageCommand.cs | 2 +- .../Services/AppxManifestDocument.cs | 19 ++ .../WinApp.Cli/Services/ManifestService.cs | 68 ++++---- .../Services/ManifestTemplateService.cs | 4 +- .../Services/MsixService.Identity.cs | 17 +- .../WinApp.Cli/Services/MsixService.cs | 56 +++--- src/winapp-VSC/package.json | 6 + src/winapp-VSC/src/extension.ts | 29 ++- 16 files changed, 415 insertions(+), 74 deletions(-) diff --git a/.claude/agents/winapp.md b/.claude/agents/winapp.md index afa1ff77..2e432632 100644 --- a/.claude/agents/winapp.md +++ b/.claude/agents/winapp.md @@ -41,6 +41,9 @@ Does the project already have an appxmanifest.xml? │ │ └─ winapp run (registers loose layout + launches) │ └─ Is the exe separate from your app code? (Electron, sparse package testing) │ └─ winapp create-debug-identity (registers sparse package) + ├─ Need production sparse packaging (ship identity for an unpackaged app)? + │ └─ winapp init --exe --sparse → winapp pack --cert → winapp embed-identity + │ (build a signed identity-only .msix your installer registers with Add-AppxPackage -ExternalLocation) ├─ Need to sign an existing MSIX or exe? │ └─ winapp sign └─ Need to run a Windows SDK tool directly (makeappx, signtool, makepri)? @@ -88,6 +91,7 @@ Want to inspect or interact with a running app's UI? - `--config-dir` — directory for `winapp.yaml` (default: the selected project directory) - `--config-only` — only create `winapp.yaml`, skip package installation - `--no-gitignore` — don't update `.gitignore` +**Sparse mode (`--exe --sparse`):** generates an identity-only sparse `appxmanifest.xml` (with `AllowExternalContent`) plus placeholder assets for an existing executable, inferring name/publisher/version/description from the exe. Skips all SDK/package installation. `--exe` requires `--sparse`. Additional options: `--name`, `--publisher`, `--output-dir` (default: the exe's directory). This is **step 1** of the production sparse packaging workflow. **Creates:** `winapp.yaml`, `appxmanifest.xml`, `Assets/` folder, `.winapp/` (if SDKs installed) ### `winapp restore [base-directory]` @@ -127,7 +131,15 @@ Want to inspect or interact with a running app's UI? - `--no-install` — create but don't register the package **Requires:** `appxmanifest.xml` + path to your built `.exe` -### `winapp run ` +### `winapp embed-identity ` +**Purpose:** Connect a desktop `.exe` to its sparse identity package by embedding the `` element into the target's side-by-side (fusion) manifest. This is **step 3** of the production sparse packaging workflow (after `winapp init --exe --sparse` and `winapp pack`). +**When to use:** After building a signed identity-only `.msix` for an unpackaged app, to make Windows associate the exe with that package at runtime. +**Modes:** `.exe` target → embeds via `mt.exe`; `.xml`/`.manifest` target → inserts/replaces the `` element in an external side-by-side manifest (rebuild the app afterward). +**Key options:** +- `--manifest ` — sparse `appxmanifest.xml` to read identity from (defaults to one beside the target, then `./appxmanifest.xml`) +**Requires:** a sparse `appxmanifest.xml` + the target `.exe` or `.xml`/`.manifest` + + **Purpose:** Create a loose layout package from a build output folder, register it with Windows via `Add-AppxPackage`, and launch the app — simulating a full MSIX install for debugging. **When to use:** The **preferred command** for iterative development and debugging with package identity. Use this whenever your exe lives inside the build output folder (most .NET, C++, Rust, Flutter, Tauri projects). **Key options:** @@ -294,6 +306,15 @@ winapp create-debug-identity ./myapp.exe # Register sparse package for exe # Launch your exe normally — it now has package identity ``` +### Ship production sparse identity (unpackaged app + installer) +```bash +winapp init --exe ./bin/MyApp.exe --sparse # Step 1: generate identity-only manifest + assets +winapp cert generate # dev/test cert (use a trusted cert for production) +winapp pack ./appxmanifest.xml --cert dev.pfx # Step 2: build + sign the identity .msix +winapp embed-identity ./bin/MyApp.exe # Step 3: embed into the exe fusion manifest +# Your installer registers it: Add-AppxPackage -Path MyApp.identity.msix -ExternalLocation +``` + ### Clone and build existing project ```bash winapp restore # Reinstall packages from winapp.yaml diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index b31c658e..5eb679f0 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -42,6 +42,9 @@ Does the project already have an appxmanifest.xml? │ │ └─ winapp run (registers loose layout + launches) │ └─ Is the exe separate from your app code? (Electron, sparse package testing) │ └─ winapp create-debug-identity (registers sparse package) + ├─ Need production sparse packaging (ship identity for an unpackaged app)? + │ └─ winapp init --exe --sparse → winapp pack --cert → winapp embed-identity + │ (build a signed identity-only .msix your installer registers with Add-AppxPackage -ExternalLocation) ├─ Need to sign an existing MSIX or exe? │ └─ winapp sign └─ Need to run a Windows SDK tool directly (makeappx, signtool, makepri)? @@ -89,6 +92,7 @@ Want to inspect or interact with a running app's UI? - `--config-dir` — directory for `winapp.yaml` (default: the selected project directory) - `--config-only` — only create `winapp.yaml`, skip package installation - `--no-gitignore` — don't update `.gitignore` +**Sparse mode (`--exe --sparse`):** generates an identity-only sparse `appxmanifest.xml` (with `AllowExternalContent`) plus placeholder assets for an existing executable, inferring name/publisher/version/description from the exe. Skips all SDK/package installation. `--exe` requires `--sparse`. Additional options: `--name`, `--publisher`, `--output-dir` (default: the exe's directory). This is **step 1** of the production sparse packaging workflow. **Creates:** `winapp.yaml`, `appxmanifest.xml`, `Assets/` folder, `.winapp/` (if SDKs installed) ### `winapp restore [base-directory]` @@ -128,7 +132,15 @@ Want to inspect or interact with a running app's UI? - `--no-install` — create but don't register the package **Requires:** `appxmanifest.xml` + path to your built `.exe` -### `winapp run ` +### `winapp embed-identity ` +**Purpose:** Connect a desktop `.exe` to its sparse identity package by embedding the `` element into the target's side-by-side (fusion) manifest. This is **step 3** of the production sparse packaging workflow (after `winapp init --exe --sparse` and `winapp pack`). +**When to use:** After building a signed identity-only `.msix` for an unpackaged app, to make Windows associate the exe with that package at runtime. +**Modes:** `.exe` target → embeds via `mt.exe`; `.xml`/`.manifest` target → inserts/replaces the `` element in an external side-by-side manifest (rebuild the app afterward). +**Key options:** +- `--manifest ` — sparse `appxmanifest.xml` to read identity from (defaults to one beside the target, then `./appxmanifest.xml`) +**Requires:** a sparse `appxmanifest.xml` + the target `.exe` or `.xml`/`.manifest` + + **Purpose:** Create a loose layout package from a build output folder, register it with Windows via `Add-AppxPackage`, and launch the app — simulating a full MSIX install for debugging. **When to use:** The **preferred command** for iterative development and debugging with package identity. Use this whenever your exe lives inside the build output folder (most .NET, C++, Rust, Flutter, Tauri projects). **Key options:** @@ -295,6 +307,15 @@ winapp create-debug-identity ./myapp.exe # Register sparse package for exe # Launch your exe normally — it now has package identity ``` +### Ship production sparse identity (unpackaged app + installer) +```bash +winapp init --exe ./bin/MyApp.exe --sparse # Step 1: generate identity-only manifest + assets +winapp cert generate # dev/test cert (use a trusted cert for production) +winapp pack ./appxmanifest.xml --cert dev.pfx # Step 2: build + sign the identity .msix +winapp embed-identity ./bin/MyApp.exe # Step 3: embed into the exe fusion manifest +# Your installer registers it: Add-AppxPackage -Path MyApp.identity.msix -ExternalLocation +``` + ### Clone and build existing project ```bash winapp restore # Reinstall packages from winapp.yaml diff --git a/README.md b/README.md index 8c8589e1..e81014dc 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ This repository includes samples demonstrating how to use the CLI with various f | [Rust App](/samples/rust-app/README.md) | Rust application using Windows APIs | | [Tauri App](/samples/tauri-app/README.md) | Tauri cross-platform app with Rust backend | | [Flutter App](/samples/flutter-app/README.md) | Flutter desktop app with package identity and Windows App SDK | +| [Sparse App](/samples/sparse-app/README.md) | WPF app with production sparse packaging (identity-only MSIX) and an Inno Setup installer | ## 🧩 VS Code Extension diff --git a/docs/guides/sparse.md b/docs/guides/sparse.md index 167a2255..e8316643 100644 --- a/docs/guides/sparse.md +++ b/docs/guides/sparse.md @@ -121,22 +121,46 @@ Registration and unregistration are the installer's job. The pattern is the same `Add-AppxPackage -Path "\MyApp.identity.msix" -ExternalLocation ""`. - **Uninstall:** run `Remove-AppxPackage ` before deleting files. +> **Security:** the install directory is resolved at install time and may contain characters +> (e.g. a single quote) that break out of a PowerShell string literal. Always escape or validate +> the path before interpolating it into a `-Command` string — the WiX and NSIS snippets below +> assume a trusted install path, while the Inno Setup example demonstrates safe escaping. Prefer +> passing paths as arguments to a `-File` script over inline `-Command` interpolation. + ### Inno Setup +Build the PowerShell arguments in a `[Code]` function so the runtime install path is escaped +for the single-quoted PowerShell literal (an install directory containing a `'` must not be able +to inject script): + ```pascal [Files] Source: "dist\*"; DestDir: "{app}"; Flags: recursesubdirs Source: "MyApp.identity.msix"; DestDir: "{app}" [Run] -Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Add-AppxPackage -Path '{app}\MyApp.identity.msix' -ExternalLocation '{app}'"""; \ - Flags: runhidden +Filename: "powershell.exe"; Parameters: "{code:RegisterParams}"; Flags: runhidden [UninstallRun] Filename: "powershell.exe"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-AppxPackage *MyApp* | Remove-AppxPackage"""; \ Flags: runhidden + +[Code] +function EscapePSLiteral(const Value: string): string; +var S: string; +begin + S := Value; StringChange(S, '''', ''''''); Result := S; +end; + +function RegisterParams(Param: string): string; +var AppDir: string; +begin + AppDir := ExpandConstant('{app}'); + Result := '-NoProfile -ExecutionPolicy Bypass -Command "Add-AppxPackage -Path ''' + + EscapePSLiteral(AppDir + '\MyApp.identity.msix') + + ''' -ExternalLocation ''' + EscapePSLiteral(AppDir) + '''"'; +end; ``` See the [sparse-app](../../samples/sparse-app) sample for a complete, working `setup.iss`. diff --git a/samples/sparse-app/installer/setup.iss b/samples/sparse-app/installer/setup.iss index fc42b98f..0ea55696 100644 --- a/samples/sparse-app/installer/setup.iss +++ b/samples/sparse-app/installer/setup.iss @@ -48,8 +48,11 @@ Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" [Run] ; Register the sparse identity package against the install directory (external location). +; The PowerShell arguments are built in [Code] (RegisterParams) so the install path is safely +; escaped for a single-quoted PowerShell literal — an install directory containing a quote +; must not be able to inject additional script. Filename: "powershell.exe"; \ - Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Add-AppxPackage -Path '{app}\{#MyMsixName}' -ExternalLocation '{app}'"""; \ + Parameters: "{code:RegisterParams}"; \ StatusMsg: "Registering package identity..."; \ Flags: runhidden waituntilterminated ; Launch the app after install (optional). @@ -57,7 +60,32 @@ Filename: "{app}\{#MyAppExeName}"; Description: "Launch {#MyAppName}"; Flags: po [UninstallRun] ; Unregister the sparse package on uninstall (before files are removed). +; MyPackagePattern is a compile-time constant (no runtime path), so no escaping is required. Filename: "powershell.exe"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-AppxPackage '{#MyPackagePattern}' | Remove-AppxPackage"""; \ RunOnceId: "UnregisterSparse"; \ Flags: runhidden waituntilterminated + +[Code] +{ Escapes a value for safe embedding inside a PowerShell single-quoted string literal. } +function EscapePSLiteral(const Value: string): string; +var + S: string; +begin + S := Value; + StringChange(S, '''', ''''''); + Result := S; +end; + +{ Builds the full powershell.exe argument string for registering the sparse package, + escaping the runtime-resolved install directory so it cannot break out of the literal. } +function RegisterParams(Param: string): string; +var + AppDir: string; +begin + AppDir := ExpandConstant('{app}'); + Result := + '-NoProfile -ExecutionPolicy Bypass -Command "Add-AppxPackage -Path ''' + + EscapePSLiteral(AppDir + '\{#MyMsixName}') + + ''' -ExternalLocation ''' + EscapePSLiteral(AppDir) + '''"'; +end; diff --git a/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs index 11bf52f9..202af49f 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using WinApp.Cli.Commands; +using WinApp.Cli.Models; using WinApp.Cli.Services; namespace WinApp.Cli.Tests; @@ -27,6 +28,34 @@ private string CopyTestExe(string fileName = "app.exe") return dest; } + private const string MinimalSparseManifest = """ + + + + + SparsePkg + Test + Assets\StoreLogo.png + true + + + """; + + private const string NonSparseManifest = """ + + + + + FullPkg + Test + Assets\StoreLogo.png + + + """; + [TestMethod] public async Task InitSparse_WithExe_GeneratesSparseIdentityManifest() { @@ -134,6 +163,104 @@ public async Task EmbedIdentity_XmlMode_ReplacesExistingElement() Assert.AreEqual(1, occurrences, "Re-running embed-identity should replace, not duplicate, the msix element"); } + [TestMethod] + public async Task EmbedIdentity_UnsupportedExtension_ReturnsError() + { + // Arrange: a valid sparse manifest exists, but the target is an unsupported file type. + var exe = CopyTestExe(); + var initCommand = GetRequiredService(); + await ParseAndInvokeWithCaptureAsync(initCommand, ["--exe", exe, "--sparse", "--use-defaults"]); + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var badTarget = Path.Combine(_tempDirectory.FullName, "notes.txt"); + + var embedCommand = GetRequiredService(); + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(embedCommand, [badTarget, "--manifest", manifestPath]); + + // Assert + Assert.AreEqual(1, exitCode, "An unsupported target extension should fail"); + } + + [TestMethod] + public async Task EmbedIdentity_ManifestNotFound_ReturnsError() + { + // Arrange: target lives in a directory with no manifest, and there is none in cwd either. + var isolated = Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, "isolated")); + var target = Path.Combine(isolated.FullName, "app.manifest"); + var embedCommand = GetRequiredService(); + + // Act (no --manifest, nothing to auto-detect) + var exitCode = await ParseAndInvokeWithCaptureAsync(embedCommand, [target]); + + // Assert + Assert.AreEqual(1, exitCode, "Missing identity manifest should fail"); + Assert.IsFalse(File.Exists(target), "No SxS manifest should be written when identity can't be resolved"); + } + + [TestMethod] + public async Task CreateSparseIdentityPackage_MissingManifest_Throws() + { + var msixService = GetRequiredService(); + var missing = new FileInfo(Path.Combine(_tempDirectory.FullName, "does-not-exist.xml")); + + await Assert.ThrowsExactlyAsync(() => + msixService.CreateSparseIdentityPackageAsync(missing, null, TestTaskContext, cancellationToken: TestContext.CancellationToken)); + } + + [TestMethod] + public async Task CreateSparseIdentityPackage_NonSparseManifest_Throws() + { + var manifestPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "appxmanifest.xml")); + await File.WriteAllTextAsync(manifestPath.FullName, NonSparseManifest, TestContext.CancellationToken); + var msixService = GetRequiredService(); + + await Assert.ThrowsExactlyAsync(() => + msixService.CreateSparseIdentityPackageAsync(manifestPath, null, TestTaskContext, cancellationToken: TestContext.CancellationToken)); + } + + [TestMethod] + public async Task CreateSparseIdentityPackage_MsixbundleOutput_Throws() + { + // A sparse identity package must be a single .msix, never a bundle. Passing a .msixbundle + // output must be rejected rather than silently creating a directory with that name. + var manifestPath = new FileInfo(Path.Combine(_tempDirectory.FullName, "appxmanifest.xml")); + await File.WriteAllTextAsync(manifestPath.FullName, MinimalSparseManifest, TestContext.CancellationToken); + var output = new FileInfo(Path.Combine(_tempDirectory.FullName, "SparsePkg.msixbundle")); + var msixService = GetRequiredService(); + + await Assert.ThrowsExactlyAsync(() => + msixService.CreateSparseIdentityPackageAsync(manifestPath, output, TestTaskContext, cancellationToken: TestContext.CancellationToken)); + Assert.IsFalse(Directory.Exists(output.FullName), "A directory must not be created for a rejected .msixbundle output"); + } + + [TestMethod] + public async Task GenerateCompleteManifest_EscapesSpecialCharsInDescriptionAndExe() + { + // Description is free text inferred from exe metadata; exe names can contain '&'. + // Both must be XML-escaped so the generated manifest stays well-formed. + var templateService = GetRequiredService(); + await templateService.GenerateCompleteManifestAsync( + _tempDirectory, + "EscapeTest", + "CN=Test", + "1.0.0.0", + ManifestTemplates.Sparse, + "Tom & Jerry's \"quoted\"", + TestTaskContext, + manifestFileName: "appxmanifest.xml", + executableName: "a&b.exe", + cancellationToken: TestContext.CancellationToken); + + var content = await File.ReadAllTextAsync(Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"), TestContext.CancellationToken); + + // The manifest must parse as well-formed XML despite the special characters. + var doc = System.Xml.Linq.XDocument.Parse(content); + Assert.IsNotNull(doc.Root); + Assert.Contains("&", content, "Ampersands must be escaped"); + Assert.DoesNotContain("Tom & Jerry", content, "A raw, unescaped ampersand would be invalid XML"); + } + [TestMethod] public void NormalizeManifestVersion_HandlesVariousInputs() { @@ -144,6 +271,44 @@ public void NormalizeManifestVersion_HandlesVariousInputs() Assert.IsNull(ManifestService.NormalizeManifestVersion(null)); Assert.IsNull(ManifestService.NormalizeManifestVersion("")); } + + [TestMethod] + public void GetSparseFolderContentWarnings_SparseManifest_WarnsOnAssetsAndBinaries() + { + var folder = _tempDirectory.CreateSubdirectory("sparse-folder"); + File.WriteAllText(Path.Combine(folder.FullName, "StoreLogo.png"), "png"); + File.WriteAllText(Path.Combine(folder.FullName, "app.exe"), "MZ"); + + var warnings = MsixService.GetSparseFolderContentWarnings(folder, MinimalSparseManifest); + + Assert.HasCount(2, warnings); + Assert.IsTrue(warnings.Any(w => w.Contains("Assets found")), "Expected an assets warning"); + Assert.IsTrue(warnings.Any(w => w.Contains("Binaries found")), "Expected a binaries warning"); + } + + [TestMethod] + public void GetSparseFolderContentWarnings_OnlyManifest_NoWarnings() + { + var folder = _tempDirectory.CreateSubdirectory("manifest-only"); + File.WriteAllText(Path.Combine(folder.FullName, "appxmanifest.xml"), MinimalSparseManifest); + + var warnings = MsixService.GetSparseFolderContentWarnings(folder, MinimalSparseManifest); + + Assert.IsEmpty(warnings); + } + + [TestMethod] + public void GetSparseFolderContentWarnings_NonSparseManifest_NoWarnings() + { + var folder = _tempDirectory.CreateSubdirectory("non-sparse"); + File.WriteAllText(Path.Combine(folder.FullName, "StoreLogo.png"), "png"); + File.WriteAllText(Path.Combine(folder.FullName, "app.exe"), "MZ"); + + // Not a sparse manifest, so folder content warnings must not fire. + var warnings = MsixService.GetSparseFolderContentWarnings(folder, NonSparseManifest); + + Assert.IsEmpty(warnings); + } } /// diff --git a/src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs index b429e660..f57c1d17 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/EmbedIdentityCommand.cs @@ -40,7 +40,19 @@ public class Handler(IMsixService msixService, ICurrentDirectoryProvider current public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) { var target = parseResult.GetRequiredValue(TargetArgument); - var manifest = parseResult.GetValue(ManifestOption) ?? ManifestHelper.FindManifest(currentDirectoryProvider.GetCurrentDirectory()); + + // Resolve the identity manifest: explicit --manifest wins; otherwise look next to the + // target (where 'winapp init --exe --sparse' writes appxmanifest.xml by default) and + // finally fall back to the current directory. + var manifest = parseResult.GetValue(ManifestOption); + if (manifest == null) + { + var targetDir = target.Directory?.FullName; + var besideTarget = targetDir != null ? ManifestHelper.FindManifest(targetDir) : null; + manifest = besideTarget?.Exists == true + ? besideTarget + : ManifestHelper.FindManifest(currentDirectoryProvider.GetCurrentDirectory()); + } if (!manifest.Exists) { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index 643085e7..ac7cbbe3 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -266,7 +266,7 @@ private async Task RunSparseInitAsync( ansiConsole.WriteLine(); ansiConsole.MarkupLine("Next steps:"); ansiConsole.MarkupLineInterpolated($" 1. Run [blue]winapp pack \"{sparseResult.ManifestPath.FullName}\" --cert [/] to create the signed identity .msix"); - ansiConsole.MarkupLineInterpolated($" 2. Run [blue]winapp embed-identity \"{exe.FullName}\"[/] to connect your exe to the identity package"); + ansiConsole.MarkupLineInterpolated($" 2. Run [blue]winapp embed-identity \"{exe.FullName}\" --manifest \"{sparseResult.ManifestPath.FullName}\"[/] to connect your exe to the identity package"); ansiConsole.MarkupLine(" 3. Register in your installer with [blue]Add-AppxPackage -Path -ExternalLocation [/]"); return 0; diff --git a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs index ba3524c0..af2cb538 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/PackageCommand.cs @@ -29,7 +29,7 @@ static PackageCommand() { InputFolderArgument = new Argument("input-folder") { - Description = "One or more input folders with package layout. Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64).", + Description = "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") diff --git a/src/winapp-CLI/WinApp.Cli/Services/AppxManifestDocument.cs b/src/winapp-CLI/WinApp.Cli/Services/AppxManifestDocument.cs index 90f48784..5d5ad339 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/AppxManifestDocument.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/AppxManifestDocument.cs @@ -160,6 +160,25 @@ public string ToXml() public XElement? GetCapabilitiesElement() => _document.Root?.Element(DefaultNs + "Capabilities"); + /// + /// Gets the Properties element. + /// + public XElement? GetPropertiesElement() => + _document.Root?.Element(DefaultNs + "Properties"); + + /// + /// True when the manifest declares uap10:AllowExternalContent=true, which marks it + /// as a sparse identity package whose binaries/assets are resolved from an external location. + /// + public bool AllowsExternalContent + { + get + { + var el = GetPropertiesElement()?.Element(Uap10Ns + "AllowExternalContent"); + return el != null && string.Equals(el.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase); + } + } + #endregion #region Identity Properties diff --git a/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs b/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs index e0ccd590..93685137 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ManifestService.cs @@ -120,32 +120,11 @@ await manifestTemplateService.GenerateCompleteManifestAsync( if (logoPath == null && !string.IsNullOrEmpty(executableAbsolute)) { taskContext.AddDebugMessage($"No logo path provided, attempting to extract from executable: {executableAbsolute}"); - Icon? extractedIcon = null; - try - { - extractedIcon = ShellIcon.GetJumboIcon(executableAbsolute); - // save temporary - if (extractedIcon != null) - { - string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - Directory.CreateDirectory(tempDir); - - extractedLogoPath = Path.Combine(tempDir, "StoreLogo.png"); - using (var stream = new FileStream(extractedLogoPath, FileMode.Create)) - { - extractedIcon.ToBitmap().Save(stream, ImageFormat.Png); - } - - logoPath = new FileInfo(extractedLogoPath); - taskContext.AddDebugMessage($"Extracted logo path: {logoPath.FullName}"); - } - } - finally + extractedLogoPath = ExtractExeIconToTempPng(executableAbsolute); + if (extractedLogoPath != null) { - if (extractedIcon != null) - { - extractedIcon.Dispose(); - } + logoPath = new FileInfo(extractedLogoPath); + taskContext.AddDebugMessage($"Extracted logo path: {logoPath.FullName}"); } } @@ -238,27 +217,47 @@ await manifestTemplateService.GenerateCompleteManifestAsync( } /// - /// Extracts the jumbo icon from an executable and applies it to the manifest's assets. - /// Silently no-ops if extraction fails. + /// Extracts the jumbo icon from an executable and writes it to a temporary StoreLogo.png. + /// Returns the path to the temp PNG (the caller owns cleanup of the file and its directory), + /// or null if the executable has no extractable icon. /// - private async Task TryApplyExtractedLogoAsync(FileInfo manifestPath, FileInfo executable, TaskContext taskContext, CancellationToken cancellationToken) + private static string? ExtractExeIconToTempPng(string executablePath) { - string? extractedLogoPath = null; Icon? extractedIcon = null; try { - extractedIcon = ShellIcon.GetJumboIcon(executable.FullName); + extractedIcon = ShellIcon.GetJumboIcon(executablePath); if (extractedIcon == null) { - return; + return null; } var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); - extractedLogoPath = Path.Combine(tempDir, "StoreLogo.png"); - using (var stream = new FileStream(extractedLogoPath, FileMode.Create)) + var logoPath = Path.Combine(tempDir, "StoreLogo.png"); + using var stream = new FileStream(logoPath, FileMode.Create); + extractedIcon.ToBitmap().Save(stream, ImageFormat.Png); + return logoPath; + } + finally + { + extractedIcon?.Dispose(); + } + } + + /// + /// Extracts the jumbo icon from an executable and applies it to the manifest's assets. + /// Silently no-ops if extraction fails. + /// + private async Task TryApplyExtractedLogoAsync(FileInfo manifestPath, FileInfo executable, TaskContext taskContext, CancellationToken cancellationToken) + { + string? extractedLogoPath = null; + try + { + extractedLogoPath = ExtractExeIconToTempPng(executable.FullName); + if (extractedLogoPath == null) { - extractedIcon.ToBitmap().Save(stream, ImageFormat.Png); + return; } await UpdateManifestAssetsAsync(manifestPath, new FileInfo(extractedLogoPath), taskContext, cancellationToken: cancellationToken); @@ -269,7 +268,6 @@ private async Task TryApplyExtractedLogoAsync(FileInfo manifestPath, FileInfo ex } finally { - extractedIcon?.Dispose(); if (extractedLogoPath != null) { try diff --git a/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs b/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs index 983f6e1f..5f9a64cb 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ManifestTemplateService.cs @@ -121,7 +121,7 @@ private static string ApplyTemplateReplacements( .Replace("{PublisherDN}", PublisherDnHelper.XmlEscape(publisherDN)) .Replace("{PublisherName}", PublisherDnHelper.XmlEscape(publisherName)) .Replace("Version=\"1.0.0.0\"", $"Version=\"{version}\"") - .Replace("{Description}", description); + .Replace("{Description}", PublisherDnHelper.XmlEscape(description)); return result; } @@ -262,7 +262,7 @@ public async Task GenerateCompleteManifestAsync( // the manifest is self-contained and can be packed without --executable. if (!string.IsNullOrWhiteSpace(executableName)) { - content = content.Replace("$targetnametoken$.exe", executableName); + content = content.Replace("$targetnametoken$.exe", PublisherDnHelper.XmlEscape(executableName)); } // Write manifest file diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs index 8da81bba..035675e3 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs @@ -44,10 +44,7 @@ public async Task CreateSparseIdentityPackageAsync( var manifestContent = await File.ReadAllTextAsync(manifestPath.FullName, Encoding.UTF8, cancellationToken); var doc = AppxManifestDocument.Parse(manifestContent); - var allowExternalContent = doc.Document.Root? - .Element(AppxManifestDocument.DefaultNs + "Properties")? - .Element(AppxManifestDocument.Uap10Ns + "AllowExternalContent"); - if (allowExternalContent == null || !string.Equals(allowExternalContent.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase)) + if (!doc.AllowsExternalContent) { throw new InvalidOperationException( "The manifest does not declare uap10:AllowExternalContent=\"true\", so it is not a sparse identity package. " + @@ -66,8 +63,18 @@ public async Task CreateSparseIdentityPackageAsync( outputFolder = currentDirectoryProvider.GetCurrentDirectoryInfo(); outputMsixPath = new FileInfo(Path.Combine(outputFolder.FullName, defaultFileName)); } - else if (Path.HasExtension(outputPath.Name) && string.Equals(Path.GetExtension(outputPath.Name), ".msix", StringComparison.OrdinalIgnoreCase)) + else if (Path.HasExtension(outputPath.Name)) { + // An extension means the caller intends a file. Only .msix is valid for a sparse + // identity package — reject .msixbundle and anything else rather than silently + // creating a directory with that name. + if (!string.Equals(Path.GetExtension(outputPath.Name), ".msix", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Invalid --output '{outputPath.Name}'. Sparse identity packages must be a single '.msix' file " + + "(bundles are not supported). Pass a '.msix' path or a directory."); + } + outputMsixPath = new FileInfo(outputPath.FullName); outputFolder = outputMsixPath.Directory!; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs index 64ca76d4..eae3af2a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs @@ -40,11 +40,7 @@ public static bool ManifestHasAllowExternalContent(string manifestContent) { try { - var doc = AppxManifestDocument.Parse(manifestContent); - var el = doc.Document.Root? - .Element(AppxManifestDocument.DefaultNs + "Properties")? - .Element(AppxManifestDocument.Uap10Ns + "AllowExternalContent"); - return el != null && string.Equals(el.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase); + return AppxManifestDocument.Parse(manifestContent).AllowsExternalContent; } catch { @@ -52,6 +48,37 @@ public static bool ManifestHasAllowExternalContent(string manifestContent) } } + /// + /// Returns warning messages for content found in a folder being packaged as a sparse + /// (AllowExternalContent) identity package. Assets and binaries should be deployed at the + /// external location alongside the application rather than inside the identity-only .msix. + /// Returns an empty list when the manifest is not a sparse manifest. + /// + public static IReadOnlyList GetSparseFolderContentWarnings(DirectoryInfo inputFolder, string manifestContent) + { + var warnings = new List(); + if (!ManifestHasAllowExternalContent(manifestContent)) + { + return warnings; + } + + var imageExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".ico" }; + var binaryExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".exe", ".dll", ".so" }; + + var files = inputFolder.EnumerateFiles("*", SearchOption.AllDirectories).ToList(); + + if (files.Any(f => imageExtensions.Contains(f.Extension))) + { + warnings.Add($"{UiSymbols.Warning} Assets found in package folder. For sparse packages, assets should be deployed at the external location alongside your application, not inside the .msix."); + } + if (files.Any(f => binaryExtensions.Contains(f.Extension))) + { + warnings.Add($"{UiSymbols.Warning} Binaries found in package folder. Sparse packages are identity-only — application binaries should not be included in the .msix."); + } + + return warnings; + } + /// /// Parses an AppX manifest file and extracts the package identity information /// @@ -384,24 +411,9 @@ public async Task CreateMsixPackageAsync( // When packaging a FOLDER for a sparse (AllowExternalContent) package, warn about // content that should live at the external location instead of inside the .msix. - if (ManifestHasAllowExternalContent(manifestContent)) + foreach (var warning in GetSparseFolderContentWarnings(inputFolder, manifestContent)) { - var imageExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".ico" }; - var binaryExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".exe", ".dll", ".so" }; - - var hasImages = inputFolder.EnumerateFiles("*", SearchOption.AllDirectories) - .Any(f => imageExtensions.Contains(f.Extension)); - var hasBinaries = inputFolder.EnumerateFiles("*", SearchOption.AllDirectories) - .Any(f => binaryExtensions.Contains(f.Extension)); - - if (hasImages) - { - taskContext.AddStatusMessage($"{UiSymbols.Warning} Assets found in package folder. For sparse packages, assets should be deployed at the external location alongside your application, not inside the .msix."); - } - if (hasBinaries) - { - taskContext.AddStatusMessage($"{UiSymbols.Warning} Binaries found in package folder. Sparse packages are identity-only — application binaries should not be included in the .msix."); - } + taskContext.AddStatusMessage(warning); } try diff --git a/src/winapp-VSC/package.json b/src/winapp-VSC/package.json index 708bd023..4073e46d 100644 --- a/src/winapp-VSC/package.json +++ b/src/winapp-VSC/package.json @@ -23,6 +23,7 @@ "onCommand:winapp.pack", "onCommand:winapp.run", "onCommand:winapp.createDebugIdentity", + "onCommand:winapp.embedIdentity", "onCommand:winapp.manifestGenerate", "onCommand:winapp.manifestUpdateAssets", "onCommand:winapp.certGenerate", @@ -67,6 +68,11 @@ "title": "Create Debug Identity", "category": "WinApp" }, + { + "command": "winapp.embedIdentity", + "title": "Embed Package Identity", + "category": "WinApp" + }, { "command": "winapp.manifestGenerate", "title": "Generate Manifest", diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index 707a021c..13adb0fd 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -542,7 +542,34 @@ export function activate(context: vscode.ExtensionContext) { }) ); - // Register winapp.manifestGenerate command + // Register winapp.embedIdentity command + context.subscriptions.push( + vscode.commands.registerCommand('winapp.embedIdentity', async () => { + const workspacePath = getWorkspacePath(); + if (!workspacePath) { + return; + } + const target = await selectFile('Select target (.exe or .xml/.manifest)', { + 'App or manifest': ['exe', 'xml', 'manifest'], + 'All files': ['*'] + }); + if (!target) { + return; + } + + let command = `embed-identity "${target}"`; + + const manifest = await selectFile('Select sparse appxmanifest.xml (cancel to auto-detect)', { + 'Manifest': ['xml'], + 'All files': ['*'] + }); + if (manifest) { + command += ` --manifest "${manifest}"`; + } + + await runWinappCommand(extensionPath, command, workspacePath); + }) + ); context.subscriptions.push( vscode.commands.registerCommand('winapp.manifestGenerate', async () => { const workspacePath = getWorkspacePath(); From 920364938c54d0817f5cdf623b46f77660cbc7d0 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Tue, 7 Jul 2026 22:17:38 -0400 Subject: [PATCH 3/5] pr review round 2 --- .claude/skills/winapp-package/SKILL.md | 2 +- .../plugin/skills/winapp-cli/package/SKILL.md | 2 +- docs/cli-schema.json | 2 +- docs/npm-usage.md | 4 +- .../WinApp.Cli.Tests/SparsePackagingTests.cs | 61 +++++++++++++ .../WinApp.Cli/Commands/InitCommand.cs | 29 ++++--- .../Services/MsixService.Identity.cs | 86 +++++++++++++------ src/winapp-npm/src/winapp-commands.ts | 2 +- 8 files changed, 141 insertions(+), 47 deletions(-) diff --git a/.claude/skills/winapp-package/SKILL.md b/.claude/skills/winapp-package/SKILL.md index a4303ffc..8f50ae0d 100644 --- a/.claude/skills/winapp-package/SKILL.md +++ b/.claude/skills/winapp-package/SKILL.md @@ -213,7 +213,7 @@ Create MSIX installer from your built app. Run after building your app. A manife | Argument | Required | Description | |----------|----------|-------------| -| `` | Yes | One or more input folders with package layout. Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64). | +| `` | Yes | 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). | #### Options diff --git a/.github/plugin/skills/winapp-cli/package/SKILL.md b/.github/plugin/skills/winapp-cli/package/SKILL.md index a4303ffc..8f50ae0d 100644 --- a/.github/plugin/skills/winapp-cli/package/SKILL.md +++ b/.github/plugin/skills/winapp-cli/package/SKILL.md @@ -213,7 +213,7 @@ Create MSIX installer from your built app. Run after building your app. A manife | Argument | Required | Description | |----------|----------|-------------| -| `` | Yes | One or more input folders with package layout. Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64). | +| `` | Yes | 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). | #### Options diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 1ead065c..51760311 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -1202,7 +1202,7 @@ ], "arguments": { "input-folder": { - "description": "One or more input folders with package layout. Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64).", + "description": "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).", "order": 0, "hidden": false, "valueType": "System.IO.DirectoryInfo[]", diff --git a/docs/npm-usage.md b/docs/npm-usage.md index e8068abf..ab45f9a3 100644 --- a/docs/npm-usage.md +++ b/docs/npm-usage.md @@ -313,7 +313,7 @@ function packageApp(options: PackageOptions): Promise | Property | Type | Required | Description | |----------|------|----------|-------------| -| `inputFolder` | `string \| string[]` | Yes | One or more input folders with package layout. Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64). | +| `inputFolder` | `string \| string[]` | Yes | 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). | | `cert` | `string \| undefined` | No | Path to signing certificate (will auto-sign if provided) | | `certPassword` | `string \| undefined` | No | Certificate password (default: password) | | `executable` | `string \| undefined` | No | Path to the executable relative to the input folder. | @@ -1330,7 +1330,7 @@ type ManifestTemplates = "packaged" | "sparse" | Property | Type | Required | Description | |----------|------|----------|-------------| -| `inputFolder` | `string \| string[]` | Yes | One or more input folders with package layout. Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64). | +| `inputFolder` | `string \| string[]` | Yes | 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). | | `cert` | `string \| undefined` | No | Path to signing certificate (will auto-sign if provided) | | `certPassword` | `string \| undefined` | No | Certificate password (default: password) | | `executable` | `string \| undefined` | No | Path to the executable relative to the input folder. | diff --git a/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs index 202af49f..2b0963a2 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/SparsePackagingTests.cs @@ -234,6 +234,67 @@ await Assert.ThrowsExactlyAsync(() => Assert.IsFalse(Directory.Exists(output.FullName), "A directory must not be created for a rejected .msixbundle output"); } + [TestMethod] + public async Task EmbedIdentity_NonSparseManifest_ReturnsError() + { + // embed-identity only applies to sparse (AllowExternalContent) packages. A full package + // manifest must be rejected, and no SxS manifest should be written. + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + await File.WriteAllTextAsync(manifestPath, NonSparseManifest, TestContext.CancellationToken); + var target = Path.Combine(_tempDirectory.FullName, "app.manifest"); + var embedCommand = GetRequiredService(); + + var exitCode = await ParseAndInvokeWithCaptureAsync(embedCommand, [target, "--manifest", manifestPath]); + + Assert.AreEqual(1, exitCode, "A non-sparse manifest should be rejected by embed-identity"); + Assert.IsFalse(File.Exists(target), "No SxS manifest should be written for a non-sparse manifest"); + } + + [TestMethod] + public void ResolveSparseOutputPath_ExistingDottedDirectory_TreatedAsFolder() + { + // A directory whose name contains a dot (e.g. 'release.v2') must be treated as the output + // folder, not misread as an invalid file extension. + var dottedDir = Directory.CreateDirectory(Path.Combine(_tempDirectory.FullName, "release.v2")); + + var (outputMsix, outputFolder) = MsixService.ResolveSparseOutputPath( + new DirectoryInfo(dottedDir.FullName), "SparsePkg.identity.msix", _tempDirectory); + + Assert.AreEqual(dottedDir.FullName, outputFolder.FullName); + Assert.AreEqual(Path.Combine(dottedDir.FullName, "SparsePkg.identity.msix"), outputMsix.FullName); + } + + [TestMethod] + public void ResolveSparseOutputPath_Null_UsesCurrentDirectoryDefault() + { + var (outputMsix, outputFolder) = MsixService.ResolveSparseOutputPath( + null, "SparsePkg.identity.msix", _tempDirectory); + + Assert.AreEqual(_tempDirectory.FullName, outputFolder.FullName); + Assert.AreEqual(Path.Combine(_tempDirectory.FullName, "SparsePkg.identity.msix"), outputMsix.FullName); + } + + [TestMethod] + public void ResolveSparseOutputPath_MsixFile_TreatedAsFile() + { + var target = new FileInfo(Path.Combine(_tempDirectory.FullName, "custom.msix")); + + var (outputMsix, outputFolder) = MsixService.ResolveSparseOutputPath( + target, "SparsePkg.identity.msix", _tempDirectory); + + Assert.AreEqual(target.FullName, outputMsix.FullName); + Assert.AreEqual(_tempDirectory.FullName, outputFolder.FullName); + } + + [TestMethod] + public void ResolveSparseOutputPath_MsixbundleFile_Throws() + { + var target = new FileInfo(Path.Combine(_tempDirectory.FullName, "custom.msixbundle")); + + Assert.ThrowsExactly(() => + MsixService.ResolveSparseOutputPath(target, "SparsePkg.identity.msix", _tempDirectory)); + } + [TestMethod] public async Task GenerateCompleteManifest_EscapesSpecialCharsInDescriptionAndExe() { diff --git a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs index ac7cbbe3..7866ce49 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/InitCommand.cs @@ -255,19 +255,22 @@ private async Task RunSparseInitAsync( return exitCode; } - // Summary + next steps. - ansiConsole.MarkupLineInterpolated($"{UiSymbols.Check} Generated sparse identity package files:"); - ansiConsole.MarkupLineInterpolated($" {UiSymbols.Files} Manifest: {sparseResult.ManifestPath.FullName}"); - ansiConsole.MarkupLineInterpolated($" {UiSymbols.Files} Assets: {sparseResult.AssetsDirectory.FullName}"); - ansiConsole.WriteLine(); - ansiConsole.MarkupLineInterpolated($"{UiSymbols.Package} Package: {sparseResult.Info.PackageName} Version: {sparseResult.Info.Version}"); - ansiConsole.WriteLine(); - ansiConsole.MarkupLine("[yellow]Note:[/] This is an [bold]identity-only[/] package. The generated assets in [blue]Assets/[/] are resolved from the app's install directory (the external content location) at runtime — they are [bold]not[/] bundled into the .msix. Deploy them alongside your application."); - ansiConsole.WriteLine(); - ansiConsole.MarkupLine("Next steps:"); - ansiConsole.MarkupLineInterpolated($" 1. Run [blue]winapp pack \"{sparseResult.ManifestPath.FullName}\" --cert [/] to create the signed identity .msix"); - ansiConsole.MarkupLineInterpolated($" 2. Run [blue]winapp embed-identity \"{exe.FullName}\" --manifest \"{sparseResult.ManifestPath.FullName}\"[/] to connect your exe to the identity package"); - ansiConsole.MarkupLine(" 3. Register in your installer with [blue]Add-AppxPackage -Path -ExternalLocation [/]"); + // Summary + next steps. Gated on info logging so --quiet/--json stay script-friendly. + if (logger.IsEnabled(LogLevel.Information)) + { + ansiConsole.MarkupLineInterpolated($"{UiSymbols.Check} Generated sparse identity package files:"); + ansiConsole.MarkupLineInterpolated($" {UiSymbols.Files} Manifest: {sparseResult.ManifestPath.FullName}"); + ansiConsole.MarkupLineInterpolated($" {UiSymbols.Files} Assets: {sparseResult.AssetsDirectory.FullName}"); + ansiConsole.WriteLine(); + ansiConsole.MarkupLineInterpolated($"{UiSymbols.Package} Package: {sparseResult.Info.PackageName} Version: {sparseResult.Info.Version}"); + ansiConsole.WriteLine(); + ansiConsole.MarkupLine("[yellow]Note:[/] This is an [bold]identity-only[/] package. The generated assets in [blue]Assets/[/] are resolved from the app's install directory (the external content location) at runtime — they are [bold]not[/] bundled into the .msix. Deploy them alongside your application."); + ansiConsole.WriteLine(); + ansiConsole.MarkupLine("Next steps:"); + ansiConsole.MarkupLineInterpolated($" 1. Run [blue]winapp pack \"{sparseResult.ManifestPath.FullName}\" --cert [/] to create the signed identity .msix"); + ansiConsole.MarkupLineInterpolated($" 2. Run [blue]winapp embed-identity \"{exe.FullName}\" --manifest \"{sparseResult.ManifestPath.FullName}\"[/] to connect your exe to the identity package"); + ansiConsole.MarkupLine(" 3. Register in your installer with [blue]Add-AppxPackage -Path -ExternalLocation [/]"); + } return 0; } diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs index 035675e3..404d4514 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.Identity.cs @@ -56,33 +56,8 @@ public async Task CreateSparseIdentityPackageAsync( // Resolve output path: default to .identity.msix in the current directory. var defaultFileName = $"{packageName}.identity.msix"; - FileInfo outputMsixPath; - DirectoryInfo outputFolder; - if (outputPath == null) - { - outputFolder = currentDirectoryProvider.GetCurrentDirectoryInfo(); - outputMsixPath = new FileInfo(Path.Combine(outputFolder.FullName, defaultFileName)); - } - else if (Path.HasExtension(outputPath.Name)) - { - // An extension means the caller intends a file. Only .msix is valid for a sparse - // identity package — reject .msixbundle and anything else rather than silently - // creating a directory with that name. - if (!string.Equals(Path.GetExtension(outputPath.Name), ".msix", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Invalid --output '{outputPath.Name}'. Sparse identity packages must be a single '.msix' file " + - "(bundles are not supported). Pass a '.msix' path or a directory."); - } - - outputMsixPath = new FileInfo(outputPath.FullName); - outputFolder = outputMsixPath.Directory!; - } - else - { - outputFolder = new DirectoryInfo(outputPath.FullName); - outputMsixPath = new FileInfo(Path.Combine(outputPath.FullName, defaultFileName)); - } + var (outputMsixPath, outputFolder) = ResolveSparseOutputPath( + outputPath, defaultFileName, currentDirectoryProvider.GetCurrentDirectoryInfo()); if (!outputFolder.Exists) { @@ -125,6 +100,48 @@ public async Task CreateSparseIdentityPackageAsync( return new CreateMsixPackageResult(outputMsixPath, autoSign); } + /// + /// Resolves where a sparse identity .msix should be written. An existing directory (even one + /// whose name contains a dot) is used as the output folder; a non-existent path with an + /// extension is treated as a file and must be '.msix' (bundles are rejected); anything else is + /// treated as a target directory. + /// + internal static (FileInfo OutputMsix, DirectoryInfo OutputFolder) ResolveSparseOutputPath( + FileSystemInfo? outputPath, string defaultFileName, DirectoryInfo currentDirectory) + { + if (outputPath == null) + { + return (new FileInfo(Path.Combine(currentDirectory.FullName, defaultFileName)), currentDirectory); + } + + if (Directory.Exists(outputPath.FullName)) + { + // An existing directory is always treated as the output folder, even if its name + // contains a dot (e.g. './release.v2') that Path.HasExtension would misread as a file. + var dir = new DirectoryInfo(outputPath.FullName); + return (new FileInfo(Path.Combine(dir.FullName, defaultFileName)), dir); + } + + if (Path.HasExtension(outputPath.Name)) + { + // An extension on a non-existent path means the caller intends a file. Only .msix is + // valid for a sparse identity package — reject .msixbundle and anything else rather + // than silently creating a directory with that name. + if (!string.Equals(Path.GetExtension(outputPath.Name), ".msix", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Invalid --output '{outputPath.Name}'. Sparse identity packages must be a single '.msix' file " + + "(bundles are not supported). Pass a '.msix' path or a directory."); + } + + var file = new FileInfo(outputPath.FullName); + return (file, file.Directory!); + } + + var folder = new DirectoryInfo(outputPath.FullName); + return (new FileInfo(Path.Combine(folder.FullName, defaultFileName)), folder); + } + public async Task EmbedIdentityAsync( FileInfo target, FileInfo manifestPath, @@ -137,7 +154,20 @@ public async Task EmbedIdentityAsync( $"AppX manifest not found at: {manifestPath}. Pass --manifest, or generate one with 'winapp init --exe --sparse'."); } - var identity = await ParseAppxManifestFromPathAsync(manifestPath, cancellationToken); + var manifestContent = await File.ReadAllTextAsync(manifestPath.FullName, Encoding.UTF8, cancellationToken); + + // embed-identity connects an app to a sparse identity package (one registered with + // 'Add-AppxPackage -ExternalLocation'), which requires AllowExternalContent. Refuse a + // non-sparse manifest so we don't embed an identity that can never be registered that way. + if (!AppxManifestDocument.Parse(manifestContent).AllowsExternalContent) + { + throw new InvalidOperationException( + $"Manifest '{manifestPath.Name}' is not a sparse identity manifest (missing true). " + + "embed-identity only applies to sparse packages registered with 'Add-AppxPackage -ExternalLocation'. " + + "Generate one with 'winapp init --exe --sparse'."); + } + + var identity = ParseAppxManifestAsync(manifestContent); var extension = target.Extension.ToLowerInvariant(); if (extension == ".exe") diff --git a/src/winapp-npm/src/winapp-commands.ts b/src/winapp-npm/src/winapp-commands.ts index 9b4025db..4359d289 100644 --- a/src/winapp-npm/src/winapp-commands.ts +++ b/src/winapp-npm/src/winapp-commands.ts @@ -401,7 +401,7 @@ export async function manifestUpdateAssets(options: ManifestUpdateAssetsOptions) // --------------------------------------------------------------------------- export interface PackageOptions extends CommonOptions { - /** One or more input folders with package layout. Pass multiple folders to create an MSIX bundle (e.g., winapp pack ./publish/x64 ./publish/arm64). */ + /** 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). */ inputFolder: string | string[]; /** Path to signing certificate (will auto-sign if provided) */ cert?: string; From cac72d7d860ad17a6a521791d045b1a06568169d Mon Sep 17 00:00:00 2001 From: Zach Teutsch <88554871+zateutsch@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:13:33 -0400 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/plugin/agents/winapp.agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/plugin/agents/winapp.agent.md b/.github/plugin/agents/winapp.agent.md index 5eb679f0..5e46ed0f 100644 --- a/.github/plugin/agents/winapp.agent.md +++ b/.github/plugin/agents/winapp.agent.md @@ -140,7 +140,7 @@ Want to inspect or interact with a running app's UI? - `--manifest ` — sparse `appxmanifest.xml` to read identity from (defaults to one beside the target, then `./appxmanifest.xml`) **Requires:** a sparse `appxmanifest.xml` + the target `.exe` or `.xml`/`.manifest` - +### `winapp run ` **Purpose:** Create a loose layout package from a build output folder, register it with Windows via `Add-AppxPackage`, and launch the app — simulating a full MSIX install for debugging. **When to use:** The **preferred command** for iterative development and debugging with package identity. Use this whenever your exe lives inside the build output folder (most .NET, C++, Rust, Flutter, Tauri projects). **Key options:** From 5de6f86aa8b295dcc2bb43229a36bf8e23122db4 Mon Sep 17 00:00:00 2001 From: Zachary Teutsch Date: Thu, 9 Jul 2026 18:32:36 -0400 Subject: [PATCH 5/5] resolve copilot review --- .claude/agents/winapp.md | 2 +- samples/sparse-app/installer/setup.iss | 2 +- src/winapp-CLI/WinApp.Cli/Services/MsixService.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/agents/winapp.md b/.claude/agents/winapp.md index 2e432632..fbbeabf6 100644 --- a/.claude/agents/winapp.md +++ b/.claude/agents/winapp.md @@ -139,7 +139,7 @@ Want to inspect or interact with a running app's UI? - `--manifest ` — sparse `appxmanifest.xml` to read identity from (defaults to one beside the target, then `./appxmanifest.xml`) **Requires:** a sparse `appxmanifest.xml` + the target `.exe` or `.xml`/`.manifest` - +### `winapp run ` **Purpose:** Create a loose layout package from a build output folder, register it with Windows via `Add-AppxPackage`, and launch the app — simulating a full MSIX install for debugging. **When to use:** The **preferred command** for iterative development and debugging with package identity. Use this whenever your exe lives inside the build output folder (most .NET, C++, Rust, Flutter, Tauri projects). **Key options:** diff --git a/samples/sparse-app/installer/setup.iss b/samples/sparse-app/installer/setup.iss index 0ea55696..619d7b1d 100644 --- a/samples/sparse-app/installer/setup.iss +++ b/samples/sparse-app/installer/setup.iss @@ -26,7 +26,7 @@ #define PublishDir "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish" [Setup] -AppId={{7C2E4A1E-9E2B-4C7E-9D1F-SPARSE0000001} +AppId={{7C2E4A1E-9E2B-4C7E-9D1F-0A1B2C3D4E5F} AppName={#MyAppName} AppVersion=1.0.0.0 DefaultDirName={autopf}\SparseAppSample diff --git a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs index eae3af2a..6d510511 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/MsixService.cs @@ -952,7 +952,7 @@ internal static bool IsRuntimeToolExecutable(string fileName) if (app != null && isExe && app.Attribute(AppxManifestDocument.Uap10Ns + "TrustLevel") == null) { app.SetAttributeValue(AppxManifestDocument.Uap10Ns + "TrustLevel", "mediumIL"); - app.SetAttributeValue(AppxManifestDocument.Uap10Ns + "RuntimeBehavior", "win32App"); + app.SetAttributeValue(AppxManifestDocument.Uap10Ns + "RuntimeBehavior", "win32App"); } // Remove EntryPoint if present (not needed for sparse packages)