diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index a96b86ef34..9115249393 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -193,6 +193,7 @@ changecursor
CHILDACTIVATE
CHILDWINDOW
CHOOSEFONT
+CIBUILD
cidl
CIELCh
cim
@@ -383,6 +384,7 @@ DISPLAYFREQUENCY
displayname
DISPLAYORIENTATION
divyan
+djwsxzxb
Dlg
DLGFRAME
DLGMODALFRAME
@@ -443,6 +445,7 @@ EDITSHORTCUTS
EDITTEXT
EFile
ekus
+eku
emojis
ENABLEDELAYEDEXPANSION
ENABLEDPOPUP
@@ -816,6 +819,7 @@ keyvault
KILLFOCUS
killrunner
kmph
+ksa
kvp
Kybd
LARGEICON
@@ -922,6 +926,7 @@ LWA
lwin
LZero
MAGTRANSFORM
+makeappx
MAKEINTRESOURCE
MAKEINTRESOURCEA
MAKEINTRESOURCEW
@@ -1254,6 +1259,7 @@ pinvoke
pipename
PKBDLLHOOKSTRUCT
pkgfamily
+PKI
plib
ploc
ploca
@@ -1695,6 +1701,7 @@ syskeydown
SYSKEYUP
SYSLIB
SYSMENU
+systemai
SYSTEMAPPS
SYSTEMMODAL
SYSTEMTIME
diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index 2a1760d94e..d99fabf0eb 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -235,7 +235,9 @@
"*Microsoft.CmdPal.UI_*.msix",
"PowerToys.DSC.dll",
- "PowerToys.DSC.exe"
+ "PowerToys.DSC.exe",
+
+ "PowerToysSparse.msix"
],
"SigningInfo": {
"Operations": [
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3fe8abe8fb..129abadb00 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -61,6 +61,8 @@
+
+
diff --git a/PowerToys.sln b/PowerToys.sln
index f8f26cde91..f4f2e1bd0f 100644
--- a/PowerToys.sln
+++ b/PowerToys.sln
@@ -26,6 +26,7 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "runner", "src\runner\runner
{D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D} = {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}
{D940E07F-532C-4FF3-883F-790DA014F19A} = {D940E07F-532C-4FF3-883F-790DA014F19A}
{DA425894-6E13-404F-8DCB-78584EC0557A} = {DA425894-6E13-404F-8DCB-78584EC0557A}
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D} = {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}
{E364F67B-BB12-4E91-B639-355866EBCD8B} = {E364F67B-BB12-4E91-B639-355866EBCD8B}
{F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} = {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}
EndProjectSection
@@ -50,6 +51,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{1AFB64
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Common.Lib.UnitTests", "src\common\UnitTests-CommonLib\UnitTests-CommonLib.vcxproj", "{1A066C63-64B3-45F8-92FE-664E1CCE8077}"
EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PackageIdentity", "src\PackageIdentity\PackageIdentity.vcxproj", "{E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FancyZonesEditor", "src\modules\fancyzones\editor\FancyZonesEditor\FancyZonesEditor.csproj", "{5CCC8468-DEC8-4D36-99D4-5C891BEBD481}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "powerrename", "powerrename", "{89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3}"
@@ -867,6 +870,14 @@ Global
{1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|ARM64.Build.0 = Release|ARM64
{1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|x64.ActiveCfg = Release|x64
{1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|x64.Build.0 = Release|x64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|ARM64.ActiveCfg = Debug|ARM64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|ARM64.Build.0 = Debug|ARM64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|x64.ActiveCfg = Debug|x64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Debug|x64.Build.0 = Debug|x64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|ARM64.ActiveCfg = Release|ARM64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|ARM64.Build.0 = Release|ARM64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|x64.ActiveCfg = Release|x64
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}.Release|x64.Build.0 = Release|x64
{5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|ARM64.ActiveCfg = Debug|ARM64
{5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|ARM64.Build.0 = Debug|ARM64
{5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|x64.ActiveCfg = Debug|x64
diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp
index 308b304591..0cfc3b1765 100644
--- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp
+++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp
@@ -594,6 +594,216 @@ LExit:
return WcaFinalize(er);
}
+UINT __stdcall InstallPackageIdentityMSIXCA(MSIHANDLE hInstall)
+{
+ HRESULT hr = S_OK;
+ UINT er = ERROR_SUCCESS;
+ LPWSTR customActionData = nullptr;
+ std::wstring installFolderPath;
+ std::wstring installScope;
+ std::wstring msixPath;
+ std::wstring data;
+ size_t delimiterPos;
+ bool isMachineLevel = false;
+
+ hr = WcaInitialize(hInstall, "InstallPackageIdentityMSIXCA");
+ ExitOnFailure(hr, "Failed to initialize");
+
+ hr = WcaGetProperty(L"CustomActionData", &customActionData);
+ ExitOnFailure(hr, "Failed to get CustomActionData property");
+
+ // Parse CustomActionData: "[INSTALLFOLDER];[InstallScope]"
+ data = customActionData;
+ delimiterPos = data.find(L';');
+ installFolderPath = data.substr(0, delimiterPos);
+ installScope = data.substr(delimiterPos + 1);
+
+ // Check if this is a machine-level installation
+ if (installScope == L"perMachine")
+ {
+ isMachineLevel = true;
+ }
+
+ Logger::info(L"Installing PackageIdentity MSIX - perUser: {}", !isMachineLevel);
+
+ // Construct path to PackageIdentity MSIX
+ msixPath = installFolderPath;
+ msixPath += L"PowerToysSparse.msix";
+
+ if (std::filesystem::exists(msixPath))
+ {
+ using namespace winrt::Windows::Management::Deployment;
+ using namespace winrt::Windows::Foundation;
+
+ try
+ {
+
+ std::wstring externalLocation = installFolderPath; // External content location (PowerToys install folder)
+ Uri externalUri{ externalLocation }; // External location URI for sparse package content
+ Uri packageUri{ msixPath }; // The MSIX file URI
+
+ PackageManager packageManager;
+
+ if (isMachineLevel)
+ {
+ // Machine-level installation
+
+ StagePackageOptions stageOptions;
+ stageOptions.ExternalLocationUri(externalUri);
+
+ auto stageResult = packageManager.StagePackageByUriAsync(packageUri, stageOptions).get();
+
+ uint32_t stageErrorCode = static_cast(stageResult.ExtendedErrorCode());
+ if (stageErrorCode == 0)
+ {
+ std::wstring packageFamilyName = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe";
+
+ try
+ {
+ auto provisionResult = packageManager.ProvisionPackageForAllUsersAsync(packageFamilyName).get();
+ uint32_t provisionErrorCode = static_cast(provisionResult.ExtendedErrorCode());
+
+ if (provisionErrorCode != 0)
+ {
+ Logger::error(L"Machine-level provisioning failed: 0x{:08X}", provisionErrorCode);
+ }
+ }
+ catch (const winrt::hresult_error& ex)
+ {
+ Logger::error(L"Provisioning exception: HRESULT 0x{:08X}", static_cast(ex.code()));
+ }
+ }
+ else
+ {
+ Logger::error(L"Package staging failed: 0x{:08X}", stageErrorCode);
+ }
+ }
+ else
+ {
+ AddPackageOptions addOptions;
+ addOptions.ExternalLocationUri(externalUri);
+
+ auto addResult = packageManager.AddPackageByUriAsync(packageUri, addOptions).get();
+
+ if (!addResult.IsRegistered())
+ {
+ uint32_t errorCode = static_cast(addResult.ExtendedErrorCode());
+ Logger::error(L"Per-user installation failed: 0x{:08X}", errorCode);
+ }
+ }
+ }
+ catch (const std::exception& ex)
+ {
+ Logger::error(L"PackageIdentity MSIX installation failed - Exception: {}",
+ winrt::to_hstring(ex.what()).c_str());
+ }
+ }
+ else
+ {
+ Logger::error(L"PackageIdentity MSIX not found: " + msixPath);
+ }
+
+LExit:
+ ReleaseStr(customActionData);
+ er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
+ return WcaFinalize(er);
+}
+
+UINT __stdcall UninstallPackageIdentityMSIXCA(MSIHANDLE hInstall)
+{
+ HRESULT hr = S_OK;
+ UINT er = ERROR_SUCCESS;
+ using namespace winrt::Windows::Management::Deployment;
+ using namespace winrt::Windows::Foundation;
+
+ LPWSTR installScope = nullptr;
+ bool isMachineLevel = false;
+
+ PackageManager pm;
+
+ hr = WcaInitialize(hInstall, "UninstallPackageIdentityMSIXCA");
+ ExitOnFailure(hr, "Failed to initialize");
+
+ // Check if this was a machine-level installation
+ hr = WcaGetProperty(L"InstallScope", &installScope);
+ if (SUCCEEDED(hr) && installScope && wcscmp(installScope, L"perMachine") == 0)
+ {
+ isMachineLevel = true;
+ }
+
+ Logger::info(L"Uninstalling PackageIdentity MSIX - perUser: {}", !isMachineLevel);
+
+ try
+ {
+ std::wstring packageFamilyName = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe";
+
+ if (isMachineLevel)
+ {
+ // Machine-level uninstallation: deprovision + remove for all users
+
+ // First deprovision the package
+ try
+ {
+ auto deprovisionResult = pm.DeprovisionPackageForAllUsersAsync(packageFamilyName).get();
+ if (deprovisionResult.IsRegistered())
+ {
+ Logger::warn(L"Machine-level deprovisioning completed with warnings");
+ }
+ }
+ catch (const winrt::hresult_error& ex)
+ {
+ Logger::warn(L"Machine-level deprovisioning failed: HRESULT 0x{:08X}", static_cast(ex.code()));
+ }
+
+ // Then remove packages for all users
+ auto packages = pm.FindPackagesForUserWithPackageTypes({}, packageFamilyName, PackageTypes::Main);
+ for (const auto& package : packages)
+ {
+ try
+ {
+ auto machineResult = pm.RemovePackageAsync(package.Id().FullName(), RemovalOptions::RemoveForAllUsers).get();
+ if (machineResult.IsRegistered())
+ {
+ uint32_t errorCode = static_cast(machineResult.ExtendedErrorCode());
+ Logger::error(L"Machine-level removal failed: 0x{:08X} - {}", errorCode, machineResult.ErrorText());
+ }
+ }
+ catch (const winrt::hresult_error& ex)
+ {
+ Logger::error(L"Machine-level removal exception: HRESULT 0x{:08X}", static_cast(ex.code()));
+ }
+ }
+ }
+ else
+ {
+ // Per-user uninstallation: standard removal
+
+ auto packages = pm.FindPackagesForUserWithPackageTypes({}, packageFamilyName, PackageTypes::Main);
+ for (const auto& package : packages)
+ {
+ auto userResult = pm.RemovePackageAsync(package.Id().FullName()).get();
+ if (userResult.IsRegistered())
+ {
+ uint32_t errorCode = static_cast(userResult.ExtendedErrorCode());
+ Logger::error(L"Per-user removal failed: 0x{:08X} - {}", errorCode, userResult.ErrorText());
+ }
+ }
+ }
+ }
+ catch (const std::exception& ex)
+ {
+ std::string errorMsg = "Failed to uninstall PackageIdentity MSIX: " + std::string(ex.what());
+ Logger::error(errorMsg);
+ // Don't fail the entire uninstallation if PackageIdentity fails
+ Logger::warn(L"Continuing uninstallation despite PackageIdentity MSIX error");
+ }
+
+LExit:
+ ReleaseStr(installScope);
+ er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
+ return WcaFinalize(er);
+}
+
UINT __stdcall RemoveWindowsServiceByName(std::wstring serviceName)
{
SC_HANDLE hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT);
diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.def b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def
index 931a555953..4bad107f16 100644
--- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.def
+++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def
@@ -33,3 +33,5 @@ EXPORTS
CleanPowerRenameRuntimeRegistryCA
CleanNewPlusRuntimeRegistryCA
SetBundleInstallLocationCA
+ InstallPackageIdentityMSIXCA
+ UninstallPackageIdentityMSIXCA
diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs
index 2505557d77..556fddc7f4 100644
--- a/installer/PowerToysSetupVNext/Product.wxs
+++ b/installer/PowerToysSetupVNext/Product.wxs
@@ -112,6 +112,7 @@
+
@@ -123,6 +124,7 @@
+
@@ -144,6 +146,7 @@
+
+
+
+
+
+ PowerToys.SparseApp
+ PowerToys
+ Images\StoreLogo.png
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PackageIdentity/BuildSparsePackage.cmd b/src/PackageIdentity/BuildSparsePackage.cmd
new file mode 100644
index 0000000000..71a4a6a77c
--- /dev/null
+++ b/src/PackageIdentity/BuildSparsePackage.cmd
@@ -0,0 +1,6 @@
+@echo off
+REM Wrapper to invoke PowerToys sparse package build script.
+REM Pass through all arguments (e.g. Platform=arm64 Configuration=Debug -Clean)
+
+powershell -ExecutionPolicy Bypass -NoLogo -NoProfile -File "%~dp0\BuildSparsePackage.ps1" %*
+exit /b %ERRORLEVEL%
diff --git a/src/PackageIdentity/BuildSparsePackage.ps1 b/src/PackageIdentity/BuildSparsePackage.ps1
new file mode 100644
index 0000000000..1e341c24f5
--- /dev/null
+++ b/src/PackageIdentity/BuildSparsePackage.ps1
@@ -0,0 +1,422 @@
+#Requires -Version 5.1
+
+[CmdletBinding()]
+Param(
+ [Parameter(Mandatory=$false)]
+ [ValidateSet("arm64", "x64")]
+ [string]$Platform = "x64",
+
+ [Parameter(Mandatory=$false)]
+ [ValidateSet("Debug", "Release")]
+ [string]$Configuration = "Release",
+
+ [switch]$Clean,
+ [switch]$ForceCert,
+ [switch]$NoSign,
+ [switch]$CIBuild
+)
+
+# PowerToys sparse packaging helper.
+# Generates a sparse MSIX (no payload) that grants package identity to selected Win32 components.
+# Multiple applications (PowerOCR, Settings UI, etc.) can share this single sparse identity.
+
+$ErrorActionPreference = 'Stop'
+
+$isCIBuild = $false
+if ($CIBuild.IsPresent) {
+ $isCIBuild = $true
+} elseif ($env:CIBuild) {
+ $isCIBuild = $env:CIBuild -ieq 'true'
+}
+
+$currentPublisherHint = $script:Config.CertSubject
+
+# Configuration constants - centralized management
+$script:Config = @{
+ IdentityName = "Microsoft.PowerToys.SparseApp"
+ SparseMsixName = "PowerToysSparse.msix"
+ CertPrefix = "PowerToysSparse"
+ CertSubject = 'CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US'
+ CertValidMonths = 12
+}
+
+#region Helper Functions
+
+function Find-WindowsSDKTool {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$ToolName,
+
+ [Parameter(Mandatory=$false)]
+ [string]$Architecture = "x64"
+ )
+
+ # Simple fallback: check common Windows SDK locations
+ $commonPaths = @(
+ "${env:ProgramFiles}\Windows Kits\10\bin\*\$Architecture\$ToolName",
+ "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\$Architecture\$ToolName",
+ "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x86\$ToolName" # SignTool fallback
+ )
+
+ foreach ($pattern in $commonPaths) {
+ $found = Get-ChildItem $pattern -ErrorAction SilentlyContinue |
+ Sort-Object Name -Descending |
+ Select-Object -First 1
+ if ($found) {
+ Write-BuildLog "Found $ToolName at: $($found.FullName)" -Level Info
+ return $found.FullName
+ }
+ }
+
+ throw "$ToolName not found. Please ensure Windows SDK is installed."
+}
+
+function Test-CertificateValidity {
+ param([string]$ThumbprintFile)
+
+ if (-not (Test-Path $ThumbprintFile)) { return $false }
+
+ try {
+ $thumb = (Get-Content $ThumbprintFile -Raw).Trim()
+ if (-not $thumb) { return $false }
+ $cert = Get-Item "cert:\CurrentUser\My\$thumb" -ErrorAction Stop
+ return $cert.HasPrivateKey -and $cert.NotAfter -gt (Get-Date)
+ } catch {
+ return $false
+ }
+}
+
+function Write-BuildLog {
+ param([string]$Message, [string]$Level = "Info")
+
+ $colors = @{ Error = "Red"; Warning = "Yellow"; Success = "Green"; Info = "Cyan" }
+ $color = if ($colors.ContainsKey($Level)) { $colors[$Level] } else { "White" }
+
+ Write-Host "[$(Get-Date -f 'HH:mm:ss')] $Message" -ForegroundColor $color
+}
+
+function Stop-FileProcesses {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$FilePath
+ )
+
+ # This function is kept for compatibility but simplified since
+ # the staging directory approach resolves the file lock issues
+ Write-Verbose "File process check for: $FilePath"
+}
+
+#endregion
+
+# Environment diagnostics for troubleshooting
+Write-BuildLog "Starting PackageIdentity build process..." -Level Info
+Write-BuildLog "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info
+try {
+ $execPolicy = Get-ExecutionPolicy
+ Write-BuildLog "Execution Policy: $execPolicy" -Level Info
+} catch {
+ Write-BuildLog "Execution Policy: Unable to determine (MSBuild environment)" -Level Info
+}
+Write-BuildLog "Current User: $env:USERNAME" -Level Info
+Write-BuildLog "Build Platform: $Platform, Configuration: $Configuration" -Level Info
+
+# Check for Visual Studio environment
+if ($env:VSINSTALLDIR) {
+ Write-BuildLog "Running in Visual Studio environment: $env:VSINSTALLDIR" -Level Info
+}
+
+# Ensure certificate provider is available
+try {
+ # Force load certificate provider for MSBuild environment
+ if (-not (Get-PSProvider -PSProvider Certificate -ErrorAction SilentlyContinue)) {
+ Write-BuildLog "Loading certificate provider..." -Level Warning
+ Import-Module Microsoft.PowerShell.Security -Force
+ }
+ if (-not (Test-Path 'Cert:\CurrentUser')) {
+ Write-BuildLog "Certificate drive not available, attempting to initialize..." -Level Warning
+ Import-Module PKI -ErrorAction SilentlyContinue
+ # Try to access the certificate store to force initialization
+ Get-ChildItem "Cert:\CurrentUser\My" -ErrorAction SilentlyContinue | Out-Null
+ }
+} catch {
+ Write-BuildLog ("Note: Certificate provider setup may need manual configuration: {0}" -f $_) -Level Warning
+}
+
+# Project root folder (now set to current script folder for local builds)
+$ProjectRoot = $PSScriptRoot
+$UserFolder = Join-Path $ProjectRoot '.user'
+if (-not (Test-Path $UserFolder)) { New-Item -ItemType Directory -Path $UserFolder | Out-Null }
+
+# Certificate file paths using configuration
+$prefix = $script:Config.CertPrefix
+$CertThumbFile, $CertCerFile = @('.thumbprint', '.cer') |
+ ForEach-Object { Join-Path $UserFolder "$prefix.certificate.sample$_" }
+
+# Clean option: remove bin/obj and uninstall existing sparse package if present
+if ($Clean) {
+ Write-BuildLog "Cleaning build artifacts..." -Level Info
+ 'bin','obj' | ForEach-Object {
+ $target = Join-Path $ProjectRoot $_
+ if (Test-Path $target) { Remove-Item $target -Recurse -Force }
+ }
+ Write-BuildLog "Attempting to remove existing sparse package (best effort)" -Level Info
+ try { Get-AppxPackage -Name $script:Config.IdentityName | Remove-AppxPackage } catch {}
+}
+
+# Force certificate regeneration if requested
+if ($ForceCert -and (Test-Path $UserFolder)) {
+ Write-BuildLog "ForceCert specified: removing existing certificate artifacts..." -Level Warning
+ Remove-Item $UserFolder -Recurse -Force
+ New-Item -ItemType Directory -Path $UserFolder | Out-Null
+}
+
+# Ensure dev cert (development only; not for production use) - skip if NoSign specified
+$needNewCert = -not $NoSign -and (-not (Test-Path $CertThumbFile) -or $ForceCert -or -not (Test-CertificateValidity -ThumbprintFile $CertThumbFile))
+
+if ($needNewCert) {
+ Write-BuildLog "Generating development certificate (prefix=$($script:Config.CertPrefix))..." -Level Info
+
+ # Clear stale files in the certificate cache
+ if (Test-Path $UserFolder) {
+ Get-ChildItem -Path $UserFolder | ForEach-Object {
+ if ($_.PSIsContainer) {
+ Remove-Item $_.FullName -Recurse -Force
+ } else {
+ Remove-Item $_.FullName -Force
+ }
+ }
+ }
+ if (-not (Test-Path $UserFolder)) {
+ New-Item -ItemType Directory -Path $UserFolder | Out-Null
+ }
+
+ $now = Get-Date
+ $expiration = $now.AddMonths($script:Config.CertValidMonths)
+ # Subject MUST match inside AppxManifest.xml
+ $friendlyName = "PowerToys Dev Sparse Cert Create=$now"
+ $keyFriendly = "PowerToys Dev Sparse Key Create=$now"
+
+ $certStore = 'cert:\CurrentUser\My'
+ $ekuOid = '2.5.29.37'
+ $ekuValue = '1.3.6.1.5.5.7.3.3,1.3.6.1.4.1.311.10.3.13'
+ $eku = "$ekuOid={text}$ekuValue"
+
+ $cert = New-SelfSignedCertificate -CertStoreLocation $certStore `
+ -NotAfter $expiration `
+ -Subject $script:Config.CertSubject `
+ -FriendlyName $friendlyName `
+ -KeyFriendlyName $keyFriendly `
+ -KeyDescription $keyFriendly `
+ -TextExtension $eku
+
+ # Export certificate files
+ Set-Content -Path $CertThumbFile -Value $cert.Thumbprint -Force
+ Export-Certificate -Cert $cert -FilePath $CertCerFile -Force | Out-Null
+}
+
+# Determine output directory - using PowerToys standard structure
+# Navigate to PowerToys root (two levels up from src/PackageIdentity)
+$PowerToysRoot = Split-Path (Split-Path $ProjectRoot -Parent) -Parent
+$outDir = Join-Path $PowerToysRoot "$Platform\$Configuration"
+
+if (-not (Test-Path $outDir)) {
+ Write-BuildLog "Creating output directory: $outDir" -Level Info
+ New-Item -ItemType Directory -Path $outDir -Force | Out-Null
+}
+
+# PackageIdentity folder (this script location) containing the sparse manifest and assets
+$sparseDir = $PSScriptRoot
+$manifestPath = Join-Path $sparseDir 'AppxManifest.xml'
+if (-not (Test-Path $manifestPath)) { throw "Missing AppxManifest.xml in PackageIdentity folder: $manifestPath" }
+
+$versionPropsPath = Join-Path $PowerToysRoot 'src\Version.props'
+$targetManifestVersion = $null
+$versionCandidate = $null
+if (Test-Path $versionPropsPath) {
+ try {
+ [xml]$propsXml = Get-Content -Path $versionPropsPath -Raw
+ $versionCandidate = $propsXml.Project.PropertyGroup.Version
+ } catch {
+ Write-BuildLog ("Unable to read version from {0}: {1}" -f $versionPropsPath, $_) -Level Warning
+ }
+} else {
+ Write-BuildLog "Version.props not found at $versionPropsPath; manifest version will remain unchanged." -Level Warning
+}
+
+if ($versionCandidate) {
+ $targetManifestVersion = $versionCandidate.Trim()
+ if (($targetManifestVersion -split '\.').Count -lt 4) {
+ $targetManifestVersion = "$targetManifestVersion.0"
+ }
+ Write-BuildLog "Using sparse package version from Version.props: $targetManifestVersion" -Level Info
+} else {
+ Write-BuildLog "No version value provided; manifest version will remain unchanged." -Level Info
+}
+
+# Find MakeAppx.exe from Windows SDK
+try {
+ $hostSdkArchitecture = if ([System.Environment]::Is64BitProcess) { 'x64' } else { 'x86' }
+ $makeAppxPath = Find-WindowsSDKTool -ToolName "makeappx.exe" -Architecture $hostSdkArchitecture
+} catch {
+ Write-Error "MakeAppx.exe not found. Please ensure Windows SDK is installed."
+ exit 1
+}
+
+# Pack sparse MSIX from PackageIdentity folder
+$msixPath = Join-Path $outDir $script:Config.SparseMsixName
+
+# Clean up existing MSIX file
+if (Test-Path $msixPath) {
+ Write-BuildLog "Removing existing MSIX file..." -Level Info
+ try {
+ Remove-Item $msixPath -Force -ErrorAction Stop
+ Write-BuildLog "Successfully removed existing MSIX file" -Level Success
+ } catch {
+ Write-BuildLog ("Warning: Could not remove existing MSIX file: {0}" -f $_) -Level Warning
+ }
+}
+
+# Create a clean staging directory to avoid file lock issues
+$stagingDir = Join-Path $outDir "staging"
+if (Test-Path $stagingDir) {
+ Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
+
+try {
+ Write-BuildLog "Creating clean staging directory for packaging..." -Level Info
+
+ # Copy only essential files to staging directory to avoid file locks
+ $essentialFiles = @(
+ "AppxManifest.xml"
+ "Images\*"
+ )
+
+ foreach ($filePattern in $essentialFiles) {
+ $sourcePath = Join-Path $sparseDir $filePattern
+ $relativePath = $filePattern
+
+ if ($filePattern.Contains('\')) {
+ $targetDir = Join-Path $stagingDir (Split-Path $relativePath -Parent)
+ if (-not (Test-Path $targetDir)) {
+ New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
+ }
+ }
+
+ if ($filePattern.EndsWith('\*')) {
+ # Copy directory contents
+ $sourceDir = $sourcePath.TrimEnd('\*')
+ $targetDir = Join-Path $stagingDir (Split-Path $relativePath.TrimEnd('\*') -Parent)
+ if (Test-Path $sourceDir) {
+ Copy-Item -Path "$sourceDir\*" -Destination $targetDir -Force -ErrorAction SilentlyContinue
+ }
+ } else {
+ # Copy single file
+ $targetPath = Join-Path $stagingDir $relativePath
+ if (Test-Path $sourcePath) {
+ Copy-Item -Path $sourcePath -Destination $targetPath -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+
+ # Ensure publisher matches the dev certificate for local builds
+ $manifestStagingPath = Join-Path $stagingDir 'AppxManifest.xml'
+ $shouldUseDevPublisher = -not $isCIBuild
+ if (Test-Path $manifestStagingPath) {
+ try {
+ [xml]$manifestXml = Get-Content -Path $manifestStagingPath -Raw
+ $identityNode = $manifestXml.Package.Identity
+ $manifestChanged = $false
+ if ($identityNode) {
+ $currentPublisherHint = $identityNode.Publisher
+ }
+
+ if ($identityNode) {
+ if ($targetManifestVersion -and $identityNode.Version -ne $targetManifestVersion) {
+ Write-BuildLog "Updating manifest version to $targetManifestVersion" -Level Info
+ $identityNode.SetAttribute('Version', $targetManifestVersion)
+ $manifestChanged = $true
+ }
+
+ if ($shouldUseDevPublisher -and $identityNode.Publisher -ne $script:Config.CertSubject) {
+ Write-BuildLog "Updating manifest publisher for local build" -Level Warning
+ $identityNode.SetAttribute('Publisher', $script:Config.CertSubject)
+ $manifestChanged = $true
+ }
+ $currentPublisherHint = $identityNode.Publisher
+ }
+
+ if ($manifestChanged) {
+ $manifestXml.Save($manifestStagingPath)
+ }
+ } catch {
+ Write-BuildLog ("Unable to adjust manifest metadata: {0}" -f $_) -Level Warning
+ }
+ }
+
+ Write-BuildLog "Staging directory prepared with essential files only" -Level Success
+
+ # Pack MSIX using staging directory
+ Write-BuildLog "Packing sparse MSIX ($($script:Config.SparseMsixName)) from staging -> $msixPath" -Level Info
+
+ & $makeAppxPath pack /d $stagingDir /p $msixPath /nv /o
+
+ if ($LASTEXITCODE -eq 0 -and (Test-Path $msixPath)) {
+ Write-BuildLog "MSIX packaging completed successfully" -Level Success
+ } else {
+ Write-BuildLog "MakeAppx failed with exit code $LASTEXITCODE" -Level Error
+ exit 1
+ }
+} finally {
+ # Clean up staging directory
+ if (Test-Path $stagingDir) {
+ try {
+ Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue
+ Write-BuildLog "Cleaned up staging directory" -Level Info
+ } catch {
+ Write-BuildLog ("Warning: Could not clean up staging directory: {0}" -f $_) -Level Warning
+ }
+ }
+}
+
+# Sign package (skip if NoSign specified for CI scenarios)
+if ($NoSign) {
+ Write-BuildLog "Skipping signing (NoSign specified for CI build)" -Level Warning
+} else {
+ # Use certificate thumbprint for signing (safer, no password)
+ $certThumbprint = (Get-Content -Path $CertThumbFile -Raw).Trim()
+ try {
+ $signToolPath = Find-WindowsSDKTool -ToolName "signtool.exe"
+ } catch {
+ Write-Error "SignTool.exe not found. Please ensure Windows SDK is installed."
+ exit 1
+ }
+ Write-BuildLog "Signing sparse MSIX using cert thumbprint $certThumbprint..." -Level Info
+ & $signToolPath sign /fd SHA256 /sha1 $certThumbprint $msixPath
+ if ($LASTEXITCODE -ne 0) {
+ Write-Warning "SignTool failed (exit $LASTEXITCODE). Ensure the certificate is in CurrentUser\\My and try -ForceCert if needed."
+ exit $LASTEXITCODE
+ }
+}
+
+$publisherHintFile = Join-Path $UserFolder "$($script:Config.CertPrefix).publisher.txt"
+try {
+ Set-Content -Path $publisherHintFile -Value $currentPublisherHint -Force -NoNewline
+} catch {
+ Write-BuildLog ("Unable to write publisher hint: {0}" -f $_) -Level Warning
+}
+
+Write-BuildLog "`nPackage created: $msixPath" -Level Success
+
+if ($NoSign) {
+ Write-BuildLog "UNSIGNED package created for CI build. Sign before deployment." -Level Warning
+} else {
+ Write-BuildLog "Install the dev certificate (once): $CertCerFile" -Level Info
+ Write-BuildLog "Identity Name: $($script:Config.IdentityName)" -Level Info
+}
+
+Write-BuildLog "Register sparse package:" -Level Info
+Write-BuildLog " Add-AppxPackage -Path `"$msixPath`" -ExternalLocation `"$outDir`"" -Level Warning
+Write-BuildLog "(If already installed and you changed manifest only): Add-AppxPackage -Register `"$manifestPath`" -ExternalLocation `"$outDir`" -ForceApplicationShutdown" -Level Warning
diff --git a/src/PackageIdentity/Check-ProcessIdentity.ps1 b/src/PackageIdentity/Check-ProcessIdentity.ps1
new file mode 100644
index 0000000000..767afe542f
--- /dev/null
+++ b/src/PackageIdentity/Check-ProcessIdentity.ps1
@@ -0,0 +1,43 @@
+<#
+.SYNOPSIS
+ Determine whether a given process (by PID) runs with an MSIX/UWP package identity.
+.DESCRIPTION
+ Calls the Windows API GetPackageFullName to check if the target process executes under an MSIX/Sparse App/UWP package identity.
+ Returns the package full name when identity is present, or "No package identity" otherwise.
+.PARAMETER ProcessId
+ The process ID to inspect.
+.EXAMPLE
+ .\Check-ProcessIdentity.ps1 -pid 12345
+#>
+param(
+ [Parameter(Mandatory=$true)]
+ [int]$ProcessId
+)
+
+Add-Type -TypeDefinition @'
+using System;
+using System.Text;
+using System.Runtime.InteropServices;
+public class P {
+ [DllImport("kernel32.dll", SetLastError=true)]
+ public static extern IntPtr OpenProcess(uint a, bool b, int p);
+ [DllImport("kernel32.dll", SetLastError=true)]
+ public static extern bool CloseHandle(IntPtr h);
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
+ public static extern int GetPackageFullName(IntPtr h, ref int l, StringBuilder b);
+ public static string G(int pid) {
+ IntPtr h = OpenProcess(0x1000, false, pid);
+ if (h == IntPtr.Zero) return "Failed to open process";
+ int len = 0;
+ GetPackageFullName(h, ref len, null);
+ if (len == 0) { CloseHandle(h); return "No package identity"; }
+ var sb = new StringBuilder(len);
+ int r = GetPackageFullName(h, ref len, sb);
+ CloseHandle(h);
+ return r == 0 ? sb.ToString() : "Error:" + r;
+ }
+}
+'@
+
+$result = [P]::G($ProcessId)
+Write-Output $result
diff --git a/src/PackageIdentity/Images/Square150x150Logo.png b/src/PackageIdentity/Images/Square150x150Logo.png
new file mode 100644
index 0000000000..01a45755d7
Binary files /dev/null and b/src/PackageIdentity/Images/Square150x150Logo.png differ
diff --git a/src/PackageIdentity/Images/Square44x44Logo.png b/src/PackageIdentity/Images/Square44x44Logo.png
new file mode 100644
index 0000000000..01a45755d7
Binary files /dev/null and b/src/PackageIdentity/Images/Square44x44Logo.png differ
diff --git a/src/PackageIdentity/Images/StoreLogo.png b/src/PackageIdentity/Images/StoreLogo.png
new file mode 100644
index 0000000000..01a45755d7
Binary files /dev/null and b/src/PackageIdentity/Images/StoreLogo.png differ
diff --git a/src/PackageIdentity/PackageIdentity.vcxproj b/src/PackageIdentity/PackageIdentity.vcxproj
new file mode 100644
index 0000000000..b8bd2dc1f2
--- /dev/null
+++ b/src/PackageIdentity/PackageIdentity.vcxproj
@@ -0,0 +1,120 @@
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+ -NoSign
+
+ -CIBuild
+
+
+
+
+
+
+
+
+ Debug
+ x64
+
+
+ Release
+ x64
+
+
+ Debug
+ ARM64
+
+
+ Release
+ ARM64
+
+
+
+
+ 15.0
+ Win32Proj
+ {E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}
+ PackageIdentity
+ PackageIdentity
+ false
+
+
+
+
+
+ Utility
+ true
+ v143
+
+
+
+ Utility
+ false
+ v143
+ true
+
+
+
+ Utility
+ true
+ v143
+
+
+
+ Utility
+ false
+ v143
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Images
+
+
+ Images
+
+
+ Images
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PackageIdentity/PackageIdentity.vcxproj.filters b/src/PackageIdentity/PackageIdentity.vcxproj.filters
new file mode 100644
index 0000000000..608c80f2b9
--- /dev/null
+++ b/src/PackageIdentity/PackageIdentity.vcxproj.filters
@@ -0,0 +1,25 @@
+
+
+
+
+ {93995380-89BD-4b04-88EB-625FBE52EBFB}
+ png;jpg;jpeg;gif;bmp;ico
+
+
+
+
+
+
+
+
+
+ Images
+
+
+ Images
+
+
+ Images
+
+
+
\ No newline at end of file
diff --git a/src/PackageIdentity/readme.md b/src/PackageIdentity/readme.md
new file mode 100644
index 0000000000..2af2bbb26d
--- /dev/null
+++ b/src/PackageIdentity/readme.md
@@ -0,0 +1,90 @@
+# PowerToys sparse package identity
+
+This document describes how to build, sign, register, and consume the shared sparse MSIX package that grants package identity to select Win32 components of PowerToys.
+
+## Package overview
+
+The sparse package lives under `src/PackageIdentity`. It produces a payload-free MSIX whose `Identity` matches `Microsoft.PowerToys.SparseApp`. The manifest contains one entry per Win32 surface that should run with identity (for example Settings, PowerOCR, Image Resizer).
+
+> The MSIX contains only metadata. When the package is registered you must point `-ExternalLocation` to the output folder that hosts the Win32 binaries (for example `x64\Release`).
+
+## Building the sparse package locally
+
+Two options are available:
+
+- Build the utility project from Visual Studio: `PackageIdentity.vcxproj` defines a `GenerateSparsePackage` target that runs before `PrepareForBuild` and invokes the helper script automatically.
+- Invoke the helper script directly from PowerShell:
+
+```powershell
+$repoRoot = "C:/git/PowerToys"
+pwsh "$repoRoot/src/PackageIdentity/BuildSparsePackage.ps1" -Platform x64 -Configuration Release
+```
+
+Supported switches:
+
+- `-Clean` removes previous `bin`/`obj` outputs and uninstalls existing installation.
+- `-ForceCert` regenerates the local dev certificate (.pfx/.cer/.pwd/.thumbprint) under `src/PackageIdentity/.user`.
+- `-NoSign` skips signing. The MSIX still builds but must be signed before deployment.
+- `-CIBuild` (or setting `$env:CIBuild = 'true'`) keeps the manifest publisher intact and skips the local cert substitution.
+
+The script determines the proper `makeappx.exe` for the host build machine (x64 on typical developer boxes) and creates `PowerToysSparse.msix` in `{repo}\\`.
+
+> After packaging finishes, the helper also emits `src/PackageIdentity/.user/PowerToysSparse.publisher.txt`. This file mirrors the publisher string Windows will see once the sparse package is registered, which downstream projects can read to stay in sync when generating their own manifests.
+
+## Local signing basics
+
+When `-NoSign` is not used the script generates (or reuses) a development certificate and signs the package via `signtool.exe`:
+
+1. Artifacts are stored in `src/PackageIdentity/.user/PowerToysSparse.certificate.sample.*` (`.cer` and `.thumbprint`).
+2. Install the `.cer` into `CurrentUser` → `TrustedPeople` (and `TrustedRoot`, if necessary) so Windows trusts the signature:
+
+ ```powershell
+ $repoRoot = "C:/git/PowerToys"
+ Import-Certificate -FilePath "$repoRoot/src/PackageIdentity/.user/PowerToysSparse.certificate.sample.cer" -CertStoreLocation Cert:\CurrentUser\TrustedPeople
+ ```
+
+3. The private key stays in the current user's personal certificate store.
+
+## Registering or unregistering the package
+
+After `PowerToysSparse.msix` is generated:
+
+```powershell
+# First time registration
+$repoRoot = "C:/git/PowerToys"
+$outputRoot = Join-Path $repoRoot "x64/Release"
+Add-AppxPackage -Path (Join-Path $outputRoot "PowerToysSparse.msix") -ExternalLocation $outputRoot
+
+# Re-register after manifest tweaks only
+Add-AppxPackage -Register (Join-Path $repoRoot "src/PackageIdentity/AppxManifest.xml") -ExternalLocation $outputRoot -ForceApplicationShutdown
+
+# Remove the sparse identity
+Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Remove-AppxPackage
+```
+
+`-ExternalLocation` should match the output folder that contains the Win32 executables declared in the manifest. Re-run registration whenever the manifest or executable layout changes.
+
+## CI-specific guidance
+
+- Pass `-CIBuild` to `BuildSparsePackage.ps1` (or build with `msbuild PackageIdentity.vcxproj /p:CIBuild=true`). This prevents the script from rewriting the manifest publisher to the local dev certificate subject.
+- The project automatically adds `-NoSign` only when `$(CIBuild)` is `true`. Local Debug and Release builds are signed with the development certificate.
+- Make sure the agent trusts whichever certificate signs the package. If the package remains unsigned (`-NoSign`) it cannot be installed on test machines until it is signed.
+
+## Consuming the identity from other components
+
+1. Add a new `` entry inside `src/PackageIdentity/AppxManifest.xml`. Use a unique `Id` (for example `PowerToys.MyModuleUI`) and set `Executable` to the Win32 binary relative to the `-ExternalLocation` root.
+2. Ensure the binary is copied into the platform/configuration output folder (`x64\Release`, `ARM64\Debug`, etc.) so the sparse package can locate it.
+3. Embed a sparse identity manifest in the Win32 binary so it binds to the MSIX identity at runtime. The manifest must declare an `` element with `packageName="Microsoft.PowerToys.SparseApp"`, `applicationId` matching the ``, and a `publisher` that matches the sparse package. Keep the manifest’s publisher in sync with `src/PackageIdentity/.user/PowerToysSparse.publisher.txt` (emitted by `BuildSparsePackage.ps1`). See `src/modules/imageresizer/ui/ImageResizerUI.csproj` for an example that points `ApplicationManifest` to `ImageResizerUI.dev.manifest` for local builds and switches to `ImageResizerUI.prod.manifest` when `$(CIBuild)` is `true`.
+4. Register or re-register the sparse package so Windows learns about the new application Id.
+5. To launch the Win32 surface with identity, use the `shell:AppsFolder` activation form (for example: `shell:AppsFolder\Microsoft.PowerToys.SparseApp_!PowerToys.MyModuleUI`) or activate it via `IApplicationActivationManager::ActivateApplication` using the same AppUserModelID.
+
+ - For locally built packages, resolve the `` with `Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Select-Object -ExpandProperty PackageFamilyName`.
+ - Store-distributed builds use `Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe`. Local developer builds created by this script typically use a different family name derived from the dev certificate.
+
+6. Context menu handlers or other launchers should fall back to the unpackaged executable path for environments where the sparse package is not present.
+
+## Troubleshooting tips
+
+- `Program 'makeappx.exe' failed to run`: make sure you are running an x64 PowerShell host. The script now chooses the appropriate makeappx automatically; update your repo if the log still points to an ARM64 binary.
+- `HRESULT 0x800B0109 (trust failure)`: install the development certificate into both `TrustedPeople` and `TrustedRoot` stores for the current user.
+- Stale registration: remove the package with `Remove-AppxPackage` and re-run the script with `-Clean` to rebuild from scratch.
diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj
index aca9f9d81e..b146db8435 100644
--- a/src/modules/imageresizer/ui/ImageResizerUI.csproj
+++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj
@@ -24,6 +24,14 @@
Resources\ImageResizer.ico
+
+ ImageResizerUI.dev.manifest
+
+
+
+ ImageResizerUI.prod.manifest
+
+
PublicResXFileCodeGenerator
@@ -55,4 +63,14 @@
Resources.resx
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest b/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest
new file mode 100644
index 0000000000..cb91bc2b66
--- /dev/null
+++ b/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest b/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest
new file mode 100644
index 0000000000..bbb50a9ec5
--- /dev/null
+++ b/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest
@@ -0,0 +1,8 @@
+
+
+
+
+