稀疏打包:向未打包的应用授予标识

有关可运行的端到端示例(WPF 应用 + Inno Setup 安装程序),请参阅 sparse-app 示例。

使用、MSBuild、CMake 或任何其他工具链构建 dotnet build的标准桌面可执行文件没有 包标识。 如果没有标识,它无法使用许多新式Windows API(Toast 通知、后台任务、共享目标、启动任务、应用数据 API 等)。

稀疏打包可在将应用的二进制文件移入 MSIX 的情况下,为应用赋予身份。 你会提供一个极小的仅含标识信息的包.msix(仅为清单),并通过外部位置将其与你按常规方式安装的应用一起注册。 您的 .exe 会准确地保留在安装程序放置它的位置。 这是 winapp create-debug-identity 对应的生产环境版本,而 winapp create-debug-identity 仅用于开发阶段调试。

本指南介绍三个 CLI 步骤,这些步骤对应于官方 向非打包应用授予标识 工作流的前三个步骤:

Step 命令 Result
1.创建标识清单 winapp init --exe <exe> --sparse sparse/appxmanifest.xml + sparse/Assets/
2.生成并签名标识包 winapp pack <appxmanifest.xml> --cert <pfx> <PackageName>.identity.msix
3.将标识嵌入应用 winapp embed-identity <exe> EXE 文件的 Fusion 清单中的 <msix> 元素

文档中的第 4–5 步(注册/注销该包)由 安装程序 负责——请参阅 安装程序集成

何时使用稀疏封装

  • 你已经有一个成熟的安装程序(Inno Setup、WiX、NSIS、MSI),并且不想切换到 MSIX 用于分发,但你需要身份标识受限的 Windows API。
  • 你的应用必须安装到某个路径,或者采用 MSIX 不允许的布局方式。
  • 需要最少的附加更改:保留现有安装流并添加一个 .msix 注册步骤。

如果你是从头开始,并且可以采用 MSIX 分发,那么完整打包应用(winapp init + winapp pack <folder>)会更简单。

先决条件

  1. Windows 10 版本 2004(内部版本 19041)或更高版本。 稀疏包依赖 uap10:AllowExternalContent,后者要求版本为 19041 或更高。
  2. winapp CLI - 通过 winget 安装(或更新(如果已安装):
    winget install Microsoft.WinApp --source winget
    
  3. 目标计算机上受信任的代码签名证书。 对于本地测试,请使用 winapp cert generate 生成开发证书并信任该证书。 生产包必须使用主题与清单 Publisher 匹配的证书进行签名。

Walkthrough

以下示例假设在 ./bin/Release/net8.0-windows/MyApp.exe 处已有一个已构建的可执行文件。

步骤 1 — 创建稀疏标识清单

winapp init --exe ./bin/Release/net8.0-windows/MyApp.exe --sparse

这会从 exe(通过其文件版本信息)推断包名称、发布者、版本和说明,并提示你接受或替代它们。 添加 --use-defaults(或 --no-prompt)以跳过 CI 中的提示,以及使用 --name / --publisher 覆盖特定值:

winapp init --exe ./bin/Release/net8.0-windows/MyApp.exe --sparse --use-defaults `
  --name "Contoso.MyApp" --publisher "CN=Contoso"

默认情况下,它会将以下内容写入当前目录中的专用 sparse/ 文件夹(可用 --output-dir 覆盖):

  • appxmanifest.xml — 一个稀疏清单,其中包含 <uap10:AllowExternalContent>true</uap10:AllowExternalContent><Properties> 下的一个元素)、ProcessorArchitecture="neutral"、一个 win32App 应用程序,以及填入 Executable 的 exe 名称。
  • Assets/ — 占位符视觉资源(尽可能从 EXE 文件的图标中提取)。

为什么是一个 sparse/ 文件夹,而不是在 exe 旁边? 清单和 Assets/ 是由 winapp embed-identity 使用的winapp pack——在运行时,没有任何内容会从 exe 旁边读取它们(运行时标识来自嵌入在 exe 中的 <msix> 元素,以及已注册包的外部位置;而且清单通过名称引用 exe,因此其位置独立于 exe 的所在位置)。 将它们放在一个专用且受源代码管理控制的文件夹中,可以避免把它们放到构建输出目录(如 bin/)里,因为该目录会在清理/重新生成时被清除;同时也能确保该文件夹中没有二进制文件,从而让后续步骤保持干净。 winapp packwinapp embed-identity 自动查找 sparse/ ,因此你很少需要命名路径。

注意: 稀疏初始化流程会刻意 跳过所有 SDK/软件包安装——仅身份标识的软件包没有任何 SDK 依赖。

如果目标目录中已经存在一个 appxmanifest.xml,init 将停止运行,而不是覆盖它(以及它的 Assets/)。 使用 --force 重新运行以重新生成。

请确保生成的清单中的 Publisher 与您将用于签名的证书匹配。 根据需要进行编辑 appxmanifest.xml ,或在生成时传递 --publisher

步骤 2 - 生成和签名标识包

winapp pack 指向稀疏清单(文件,而不是文件夹):

winapp pack ./sparse/appxmanifest.xml --cert ./devcert.pfx

由于清单声明了 AllowExternalContentwinapp pack 会构建一个仅标识的 identity-only.msix,其中只包含清单——没有二进制文件,也没有资源文件。 输出默认位于当前目录中的 <PackageName>.identity.msix;使用 --output 可更改此位置。 签名仅在通过 --cert 时发生(或 --generate-cert)。

步骤 3 - 将标识嵌入应用中

嵌入元素<msix>,以便Windows将正在运行的 exe 连接到标识包:

# EXE mode — modify the built binary in place (uses mt.exe)
winapp embed-identity ./bin/Release/net8.0-windows/MyApp.exe

或者将并行清单作为签入文件维护并重新生成:

# XML mode — update an external SxS manifest, then rebuild your app
winapp embed-identity ./app.manifest

在 XML 模式下,元素 <msix> 插入到目标清单(或替换)中。 引用项目中的清单(对于.NET,设置<ApplicationManifest>app.manifest</ApplicationManifest>)并重新生成,以便元素嵌入到 exe 中。

这两种模式都从稀疏 appxmanifest.xml 中读取标识信息。 如果省略 --manifest,winapp 会先在目标旁边的 sparse/ 文件夹中查找(winapp init --exe --sparse 默认将其写入该文件夹),然后在当前目录中查找;如果仍未找到,则回退为在目标旁边和当前目录中查找;传递 --manifest 可将其指向其他位置。

注意: EXE 模式重写二进制 mt.exe文件,这会使任何现有的 Authenticode 签名失效。 在分发 exe 之前重新对 exe 签名(例如 winapp sign ./MyApp.exe <cert.pfx>)。

步骤 4 - 注册(用于本地测试)

清单中的徽标是在运行时从外部位置解析的,而不是从仅含标识信息的 .msix 解析的。 步骤 1 将它们写在 ./sparse/Assets 下,因此请在注册之前先将它们复制到你的 exe 文件旁边(即外部位置)——否则 Windows 注册的将是一个缺少清单中引用的所有徽标的布局:

# Copy the generated assets into the external location (beside your exe)
Copy-Item ./sparse/Assets -Destination .\bin\Release\net8.0-windows\Assets -Recurse -Force

然后,针对该文件夹注册标识包( 外部位置):

Add-AppxPackage -Path .\MyApp.identity.msix `
  -ExternalLocation (Resolve-Path .\bin\Release\net8.0-windows)

启动应用,并确认存在标识信息——例如,Windows.ApplicationModel.Package.Current.Id.FamilyName 应返回你的包系列名称,而不是引发异常。

要清理:

Remove-AppxPackage <full-package-name>

资产处理

稀疏 .msix仅限标识。 清单中引用的视觉资源(Assets\StoreLogo.png、磁贴等)在运行时从 外部内容位置 解析,即从你的应用的安装目录解析,而不是.msix 内部解析。

这意味着你必须Assets/ 文件夹与应用程序一同部署(相对于外部位置,其布局应与清单预期的一致)。

步骤 2 直接打包清单文件winapp pack ./sparse/appxmanifest.xml),仅根据该清单构建纯标识的 .msix——同级文件会被忽略,因此绝不会包含你的资源文件或二进制文件。 (如果你改为将winapp pack指向其清单声明了AllowExternalContent,它会对其找到的任何资产或二进制文件发出警告,因为对于稀疏包,这些内容应位于外部位置,而不是在.msix内部。)

安装程序集成

注册和取消注册是安装程序的作业。 该模式在安装程序工具中是相同的:

  • 安装: 将应用二进制文件、 Assets/ 文件夹和 .msix 安装目录复制到安装目录,然后运行 Add-AppxPackage -Path "<install-dir>\MyApp.identity.msix" -ExternalLocation "<install-dir>"
  • 卸载: 在删除文件之前运行 Remove-AppxPackage <full-package-name>

安全性: 安装目录在安装时解析,可以包含断出 PowerShell 字符串文本的字符(例如单引号)。 在将路径插值到 -Command 字符串之前,请始终先对其进行转义或验证——下面的 WiX 和 NSIS 代码片段均假定安装路径是可信的,而 Inno Setup 示例则演示了如何进行安全转义。 最好通过 -File 将路径作为参数传递给脚本,而不是使用内联 -Command 插值。

Inno Setup

[Code] 函数中构建 PowerShell 参数,以便对运行时安装路径进行转义,使其适用于 PowerShell 中的单引号字符串字面量(包含 ' 的安装目录不得能够注入脚本):

[Files]
Source: "dist\*"; DestDir: "{app}"; Flags: recursesubdirs
Source: "MyApp.identity.msix"; DestDir: "{app}"

[Run]
Filename: "powershell.exe"; Parameters: "{code:RegisterParams}"; Flags: runhidden

[UninstallRun]
Filename: "powershell.exe"; \
  Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-AppxPackage -Name '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}');
  { -ErrorAction Stop + try/catch make a registration failure terminating, so powershell.exe
    exits nonzero and the AfterInstall callback (see the full sample) can abort with rollback. }
  Result := '-NoProfile -ExecutionPolicy Bypass -Command "try { Add-AppxPackage -Path ''' +
    EscapePSLiteral(AppDir + '\MyApp.identity.msix') +
    ''' -ExternalLocation ''' + EscapePSLiteral(AppDir) + ''' -ErrorAction Stop } catch { Write-Error $_; exit 1 }"';
end;

要查看一个完整且可运行的,请参阅setup.iss示例。

下面的 WiX 和 NSIS 示例通过 -File 调用一个小型 register-sparse.ps1,以便将安装路径作为 参数 传递(PowerShell 会将其绑定为数据),而不是将其插入 -Command 字符串中。 这可以避免通过精心构造的安装目录进行脚本注入(例如,包含引号或 $(...) 的文件夹名称):

# register-sparse.ps1 — ship this alongside your installer
param(
  [Parameter(Mandatory)] [string] $MsixPath,
  [Parameter(Mandatory)] [string] $ExternalLocation,
  [Parameter(Mandatory)] [string] $PackageName
)
$ErrorActionPreference = 'Stop'
try {
  # Add-AppxPackage emits NON-terminating errors by default, so a failure would otherwise leave
  # the process exit code at 0 and let the installer complete without identity. Try the add
  # directly first: a fresh install or a version-bumped upgrade registers/updates in place
  # without touching any existing registration. -ErrorAction Stop + the outer trap make a real
  # failure terminating so the installer (WiX Return="check" / NSIS) sees it.
  try {
    Add-AppxPackage -Path $MsixPath -ExternalLocation $ExternalLocation -ErrorAction Stop
  } catch {
    # Only ONE failure is safe to resolve by unregister+retry: the exact same version is already
    # registered (HRESULT 0x80073CFB, ERROR_PACKAGE_ALREADY_EXISTS — "already installed,
    # reinstallation blocked"), which Add-AppxPackage rejects. Re-throw everything else
    # (untrusted/corrupt .msix, unsupported OS, ...) so a bad new package can NEVER unregister a
    # working prior registration and strip the installed app of the identity it already had.
    if ($_.Exception.HResult -ne 0x80073CFB) { throw }
    Get-AppxPackage -Name $PackageName | Remove-AppxPackage -ErrorAction SilentlyContinue
    Add-AppxPackage -Path $MsixPath -ExternalLocation $ExternalLocation -ErrorAction Stop
  }
} catch {
  Write-Error $_
  exit 1
}

WiX (v3)

用户注册(Impersonate="yes"),因为 Add-AppxPackage 会为运行它的账户注册该包。 带有 LocalSystem 的延迟操作以 Impersonate="no" 运行,这样 不会向安装用户授予身份(并且通常会被拒绝)。 对于面向整台计算机的 MSI,请以模拟身份运行注册过程,以使其应用于调用该操作的用户。

延迟执行的自定义操作无法直接读取 INSTALLFOLDER(延迟操作在无法访问属性的上下文中运行),而且仅仅 声明 该操作并不会执行它。 因此,通过 CustomActionData 封送这些路径——这是一个即时 Type 51 操作,其 Property 名称等于延迟操作的 Id——并将 两者 都安排在 InstallFiles 之后:

<!-- Immediate: stash the command line (with the resolved paths) into the deferred action's
     CustomActionData. Windows Installer copies the value of the property named the same as a
     deferred action into that action's CustomActionData. -->
<CustomAction Id="SetRegisterSparseCmd" Property="RegisterSparse" Execute="immediate"
  Value="powershell.exe -NoProfile -ExecutionPolicy Bypass -File &quot;[INSTALLFOLDER]register-sparse.ps1&quot; -MsixPath &quot;[INSTALLFOLDER]MyApp.identity.msix&quot; -ExternalLocation &quot;[INSTALLFOLDER]&quot; -PackageName &quot;MyPackageIdentityName&quot;" />

<!-- Deferred + impersonated: CAQuietExec reads its command line from CustomActionData when run
     deferred, so it registers the package for the invoking user. Return="check" fails the
     install if registration fails. -->
<CustomAction Id="RegisterSparse" BinaryKey="WixCA" DllEntry="CAQuietExec"
  Execute="deferred" Impersonate="yes" Return="check" />

<InstallExecuteSequence>
  <Custom Action="SetRegisterSparseCmd" After="InstallFiles">NOT Installed</Custom>
  <Custom Action="RegisterSparse" After="SetRegisterSparseCmd">NOT Installed</Custom>
</InstallExecuteSequence>

CAQuietExec 包含在 WiX Util 扩展(WixUtilExtension)中;引用它,使 WixCA 二进制文件可用。

单个模拟执行的操作仅会为运行该安装程序的用户注册身份信息。 若要预配每台计算机安装的每个用户,请改为在首次启动时注册(每用户),或使用预配机制,例如 Add-AppxProvisionedPackage

NSIS

Section
  # Capture the PowerShell exit code and abort if registration failed. register-sparse.ps1 exits
  # nonzero on failure (it sets $ErrorActionPreference='Stop' and traps), so without this check the
  # installer would complete even though the app has no identity.
  ExecWait 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\register-sparse.ps1" -MsixPath "$INSTDIR\MyApp.identity.msix" -ExternalLocation "$INSTDIR" -PackageName "MyPackageIdentityName"' $0
  IntCmp $0 0 +2
    Abort "Registering the sparse identity package failed (exit code $0). The app requires package identity."
SectionEnd

Troubleshooting

Package.Current 在运行时抛出 /“没有包标识”错误

  • 标识包尚未注册,或者 exe 的 Fusion 清单中缺少 <msix> 元素。 重新运行 winapp embed-identity(如果使用 XML 模式,则重新构建),然后使用 Add-AppxPackage -ExternalLocation 重新注册。
  • exe <msix packageName> / / publisherapplicationId 中的属性必须与已注册的包的标识完全匹配。

资产/徽标未显示

  • 确保将 Assets/ 文件夹部署到外部位置,并使其相对路径与清单预期的一致。 资产是从外部位置解析的,而不是 .msix.

Add-AppxPackage因签名/信任错误而失败

  • .msix必须使用在该计算机上受信任且其主题与清单Publisher匹配的证书进行签名。 在本地测试时,请使用 winapp cert generate 生成并信任开发证书,并确保清单 Publisher 与该证书匹配。

MakeAppx:“RuntimeBehavior 值为‘win32App’的应用程序不得声明 EntryPoint”

  • 稀疏 win32App 应用程序不得声明 EntryPoint。 由 winapp init --sparse 生成的清单已经是正确的;如果您手动编辑了该清单,请删除任何 EntryPoint 属性。

“输入是文件,但不是稀疏清单”

  • winapp pack <file> 仅接受声明 <uap10:AllowExternalContent>true</uap10:AllowExternalContent> 的清单。 使用 winapp init --exe <exe> --sparse 生成一个,或传入一个输入文件夹来构建完整的 MSIX。

另见