From fc804a81563a72d7782a20105765d6b7554cbd77 Mon Sep 17 00:00:00 2001
From: Kai Tao <69313318+vanzue@users.noreply.github.com>
Date: Fri, 25 Apr 2025 09:57:42 +0800
Subject: [PATCH] [Tool] Script to build an installer locally (#39017)
* add script to build a installer
* minor fix
* fix search path for msix file
* fix sign
* fix sign
* fix spelling
* Fix powershell5 can't recognize emoji
* ensure-wix
* bring cmdpal available during local build
* remove early quit
* fix marco
* add logger
* doc
* add a note
* self review
* fix macro def
* add functionality to export cert so that other machine can install it.
* spelling
---
.github/actions/spell-check/expect.txt | 4 +
installer/PowerToysSetup/Product.wxs | 5 +-
src/common/utils/package.h | 1 +
.../CmdPalModuleInterface.vcxproj | 11 +-
.../cmdpal/CmdPalModuleInterface/dllmain.cpp | 19 ++-
tools/build/build-installer.ps1 | 122 ++++++++++++++
tools/build/cert-management.ps1 | 159 ++++++++++++++++++
tools/build/cert-sign-package.ps1 | 29 ++++
tools/build/ensure-wix.ps1 | 71 ++++++++
9 files changed, 411 insertions(+), 10 deletions(-)
create mode 100644 tools/build/build-installer.ps1
create mode 100644 tools/build/cert-management.ps1
create mode 100644 tools/build/cert-sign-package.ps1
create mode 100644 tools/build/ensure-wix.ps1
diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index daa6449a66..52f203a9de 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -198,6 +198,7 @@ CLIPBOARDUPDATE
CLIPCHILDREN
CLIPSIBLINGS
closesocket
+clp
CLSCTX
clsids
Clusion
@@ -1045,6 +1046,7 @@ NOINHERITLAYOUT
NOINTERFACE
NOINVERT
NOLINKINFO
+nologo
NOMCX
NOMINMAX
NOMIRRORBITMAP
@@ -1277,6 +1279,7 @@ pstm
PStr
pstream
pstrm
+pswd
PSYSTEM
psz
ptb
@@ -1423,6 +1426,7 @@ searchterm
SEARCHUI
SECONDARYDISPLAY
secpol
+securestring
SEEMASKINVOKEIDLIST
SELCHANGE
SENDCHANGE
diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs
index 37256bdd68..f15b8a4714 100644
--- a/installer/PowerToysSetup/Product.wxs
+++ b/installer/PowerToysSetup/Product.wxs
@@ -79,10 +79,7 @@
-
-
-
-
+
diff --git a/src/common/utils/package.h b/src/common/utils/package.h
index 60bde7ea53..138f3b8e5b 100644
--- a/src/common/utils/package.h
+++ b/src/common/utils/package.h
@@ -301,6 +301,7 @@ namespace package
if (!std::filesystem::exists(directoryPath))
{
Logger::error(L"The directory '" + directoryPath + L"' does not exist.");
+ return {};
}
const std::regex pattern(R"(^.+\.(appx|msix|msixbundle)$)", std::regex_constants::icase);
diff --git a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj
index 4395e340fa..5bd12f316e 100644
--- a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj
+++ b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj
@@ -2,6 +2,7 @@
+
17.0
Win32Proj
@@ -49,13 +50,21 @@
- EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)
+
+ EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;
+ %(PreprocessorDefinitions);
+ $(CommandPaletteBranding)
+
+
+ IS_DEV_BRANDING;%(PreprocessorDefinitions)
+
..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)
$(OutDir)$(TargetName)$(TargetExt)
+
diff --git a/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp b/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp
index 134249f049..be3eb6a3b7 100644
--- a/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp
+++ b/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp
@@ -208,7 +208,7 @@ public:
try
{
std::wstring packageName = L"Microsoft.CommandPalette";
-#ifdef _DEBUG
+#ifdef IS_DEV_BRANDING
packageName = L"Microsoft.CommandPalette.Dev";
#endif
if (!package::GetRegisteredPackage(packageName, false).has_value())
@@ -245,12 +245,21 @@ public:
errorMessage += e.what();
Logger::error(errorMessage);
}
-
-#if _DEBUG
- LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette.Dev_8wekyb3d8bbwe!App", L"RunFromPT", false);
+ try
+ {
+#ifdef IS_DEV_BRANDING
+ LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette.Dev_8wekyb3d8bbwe!App", L"RunFromPT", false);
#else
- LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette_8wekyb3d8bbwe!App", L"RunFromPT", false);
+ LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette_8wekyb3d8bbwe!App", L"RunFromPT", false);
#endif
+ }
+ catch (std::exception& e)
+ {
+ std::string errorMessage{ "Exception thrown while trying to launch CmdPal: " };
+ errorMessage += e.what();
+ Logger::error(errorMessage);
+ throw;
+ }
}
virtual void disable()
diff --git a/tools/build/build-installer.ps1 b/tools/build/build-installer.ps1
new file mode 100644
index 0000000000..28e6939760
--- /dev/null
+++ b/tools/build/build-installer.ps1
@@ -0,0 +1,122 @@
+<#
+.SYNOPSIS
+Build and package PowerToys (CmdPal and installer) for a specific platform and configuration LOCALLY.
+
+.DESCRIPTION
+This script automates the end-to-end build and packaging process for PowerToys, including:
+- Restoring and building all necessary solutions (CmdPal, BugReportTool, StylesReportTool, etc.)
+- Cleaning up old output
+- Signing generated .msix packages
+- Building the WiX-based MSI and bootstrapper installers
+
+It is designed to work in local development.
+
+.PARAMETER Platform
+Specifies the target platform for the build (e.g., 'arm64', 'x64'). Default is 'arm64'.
+
+.PARAMETER Configuration
+Specifies the build configuration (e.g., 'Debug', 'Release'). Default is 'Release'.
+
+.EXAMPLE
+.\build-installer.ps1
+Runs the installer build pipeline for ARM64 Release (default).
+
+.EXAMPLE
+.\build-installer.ps1 -Platform x64 -Configuration Release
+Runs the pipeline for x64 Debug.
+
+.NOTES
+- Requires MSBuild, WiX Toolset, and Git to be installed and accessible from your environment.
+- Make sure to run this script from a Developer PowerShell (e.g., VS2022 Developer PowerShell).
+- Generated MSIX files will be signed using cert-sign-package.ps1.
+- This script will clean previous outputs under the build directories and installer directory (except *.exe files).
+- First time run need admin permission to trust the certificate.
+- The built installer will be placed under: installer/PowerToysSetup/[Platform]/[Configuration]/UserSetup
+ relative to the solution root directory.
+- The installer can't be run right after the build, I need to copy it to another file before it can be run.
+#>
+
+
+param (
+ [string]$Platform = 'arm64',
+ [string]$Configuration = 'Release'
+)
+
+$repoRoot = Resolve-Path "$PSScriptRoot\..\.."
+Set-Location $repoRoot
+
+function RunMSBuild {
+ param (
+ [string]$Solution,
+ [string]$ExtraArgs
+ )
+
+ $base = @(
+ $Solution
+ "/p:Platform=`"$Platform`""
+ "/p:Configuration=$Configuration"
+ '/verbosity:normal'
+ '/clp:Summary;PerformanceSummary;ErrorsOnly;WarningsOnly'
+ '/nologo'
+ )
+
+ $cmd = $base + ($ExtraArgs -split ' ')
+ Write-Host ("[MSBUILD] {0} {1}" -f $Solution, ($cmd -join ' '))
+ & msbuild.exe @cmd
+
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error ("Build failed: {0} {1}" -f $Solution, $ExtraArgs)
+ exit $LASTEXITCODE
+ }
+
+}
+
+function RestoreThenBuild {
+ param ([string]$Solution)
+
+ # 1) restore
+ RunMSBuild $Solution '/t:restore /p:RestorePackagesConfig=true'
+ # 2) build -------------------------------------------------
+ RunMSBuild $Solution '/m'
+}
+
+Write-Host ("Make sure wix is installed and available")
+& "$PSScriptRoot\ensure-wix.ps1"
+
+Write-Host ("[PIPELINE] Start | Platform={0} Configuration={1}" -f $Platform, $Configuration)
+Write-Host ''
+
+$cmdpalOutputPath = Join-Path $repoRoot "$Platform\$Configuration\WinUI3Apps\CmdPal"
+
+if (Test-Path $cmdpalOutputPath) {
+ Write-Host "[CLEAN] Removing previous output: $cmdpalOutputPath"
+ Remove-Item $cmdpalOutputPath -Recurse -Force -ErrorAction Ignore
+}
+
+RestoreThenBuild '.\PowerToys.sln'
+
+$msixSearchRoot = Join-Path $repoRoot "$Platform\$Configuration"
+$msixFiles = Get-ChildItem -Path $msixSearchRoot -Recurse -Filter *.msix |
+Select-Object -ExpandProperty FullName
+
+if ($msixFiles.Count) {
+ Write-Host ("[SIGN] .msix file(s): {0}" -f ($msixFiles -join '; '))
+ & "$PSScriptRoot\cert-sign-package.ps1" -TargetPaths $msixFiles
+}
+else {
+ Write-Warning "[SIGN] No .msix files found in $msixSearchRoot"
+}
+
+RestoreThenBuild '.\tools\BugReportTool\BugReportTool.sln'
+RestoreThenBuild '.\tools\StylesReportTool\StylesReportTool.sln'
+
+Write-Host '[CLEAN] installer (keep *.exe)'
+git clean -xfd -e '*.exe' -- .\installer\ | Out-Null
+
+RunMSBuild '.\installer\PowerToysSetup.sln' '/t:restore /p:RestorePackagesConfig=true'
+
+RunMSBuild '.\installer\PowerToysSetup.sln' '/m /t:PowerToysInstaller /p:PerUser=true'
+
+RunMSBuild '.\installer\PowerToysSetup.sln' '/m /t:PowerToysBootstrapper /p:PerUser=true'
+
+Write-Host '[PIPELINE] Completed'
\ No newline at end of file
diff --git a/tools/build/cert-management.ps1 b/tools/build/cert-management.ps1
new file mode 100644
index 0000000000..a085a5ca54
--- /dev/null
+++ b/tools/build/cert-management.ps1
@@ -0,0 +1,159 @@
+<#
+.SYNOPSIS
+Ensures a code signing certificate exists and is trusted in all necessary certificate stores.
+
+.DESCRIPTION
+This script provides two functions:
+
+1. EnsureCertificate:
+ - Searches for an existing code signing certificate by subject name.
+ - If not found, creates a new self-signed certificate.
+ - Exports the certificate and attempts to import it into:
+ - CurrentUser\TrustedPeople
+ - CurrentUser\Root
+ - LocalMachine\Root (admin privileges may be required)
+
+2. ImportAndVerifyCertificate:
+ - Imports a `.cer` file into the specified certificate store if not already present.
+ - Verifies the certificate is successfully imported by checking thumbprint.
+
+This is useful in build or signing pipelines to ensure a valid and trusted certificate is available before signing MSIX or executable files.
+
+.PARAMETER certSubject
+The subject name of the certificate to search for or create. Default is:
+"CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
+
+.PARAMETER cerPath
+(ImportAndVerifyCertificate only) The file path to a `.cer` certificate file to import.
+
+.PARAMETER storePath
+(ImportAndVerifyCertificate only) The destination certificate store path (e.g. Cert:\CurrentUser\Root).
+
+.EXAMPLE
+$cert = EnsureCertificate
+
+Ensures the default certificate exists and is trusted, and returns the certificate object.
+
+.EXAMPLE
+ImportAndVerifyCertificate -cerPath "$env:TEMP\temp_cert.cer" -storePath "Cert:\CurrentUser\Root"
+
+Imports a certificate into the CurrentUser Root store and verifies its presence.
+
+.NOTES
+- For full trust, administrative privileges may be needed to import into LocalMachine\Root.
+- Certificates are created using RSA and SHA256 and marked as CodeSigningCert.
+#>
+
+function ImportAndVerifyCertificate {
+ param (
+ [string]$cerPath,
+ [string]$storePath
+ )
+
+ $thumbprint = (Get-PfxCertificate -FilePath $cerPath).Thumbprint
+
+ $existingCert = Get-ChildItem -Path $storePath | Where-Object { $_.Thumbprint -eq $thumbprint }
+ if ($existingCert) {
+ Write-Host "Certificate already exists in $storePath"
+ return $true
+ }
+
+ try {
+ $null = Import-Certificate -FilePath $cerPath -CertStoreLocation $storePath -ErrorAction Stop
+ } catch {
+ Write-Warning "Failed to import certificate to $storePath : $_"
+ return $false
+ }
+
+ $imported = Get-ChildItem -Path $storePath | Where-Object { $_.Thumbprint -eq $thumbprint }
+ if ($imported) {
+ Write-Host "Certificate successfully imported to $storePath"
+ return $true
+ } else {
+ Write-Warning "Certificate not found in $storePath after import"
+ return $false
+ }
+}
+
+function EnsureCertificate {
+ param (
+ [string]$certSubject = "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US"
+ )
+
+ $cert = Get-ChildItem -Path Cert:\CurrentUser\My |
+ Where-Object { $_.Subject -eq $certSubject } |
+ Sort-Object NotAfter -Descending |
+ Select-Object -First 1
+
+ if (-not $cert) {
+ Write-Host "Certificate not found. Creating a new one..."
+
+ $cert = New-SelfSignedCertificate -Subject $certSubject `
+ -CertStoreLocation "Cert:\CurrentUser\My" `
+ -KeyAlgorithm RSA `
+ -Type CodeSigningCert `
+ -HashAlgorithm SHA256
+
+ if (-not $cert) {
+ Write-Error "Failed to create a new certificate."
+ return $null
+ }
+
+ Write-Host "New certificate created with thumbprint: $($cert.Thumbprint)"
+ }
+ else {
+ Write-Host "Using existing certificate with thumbprint: $($cert.Thumbprint)"
+ }
+
+ $cerPath = "$env:TEMP\temp_cert.cer"
+ [void](Export-Certificate -Cert $cert -FilePath $cerPath -Force)
+
+ if (-not (ImportAndVerifyCertificate -cerPath $cerPath -storePath "Cert:\CurrentUser\TrustedPeople")) { return $null }
+ if (-not (ImportAndVerifyCertificate -cerPath $cerPath -storePath "Cert:\CurrentUser\Root")) { return $null }
+ if (-not (ImportAndVerifyCertificate -cerPath $cerPath -storePath "Cert:\LocalMachine\Root")) {
+ Write-Warning "Failed to import to LocalMachine\Root (admin may be required)"
+ return $null
+ }
+
+ return $cert
+}
+
+function Export-CertificateFiles {
+ param (
+ [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
+ [string]$CerPath,
+ [string]$PfxPath,
+ [securestring]$PfxPassword
+ )
+
+ if (-not $Certificate) {
+ Write-Error "No certificate provided to export."
+ return
+ }
+
+ if ($CerPath) {
+ try {
+ Export-Certificate -Cert $Certificate -FilePath $CerPath -Force | Out-Null
+ Write-Host "Exported CER to: $CerPath"
+ } catch {
+ Write-Warning "Failed to export CER file: $_"
+ }
+ }
+
+ if ($PfxPath -and $PfxPassword) {
+ try {
+ Export-PfxCertificate -Cert $Certificate -FilePath $PfxPath -Password $PfxPassword -Force | Out-Null
+ Write-Host "Exported PFX to: $PfxPath"
+ } catch {
+ Write-Warning "Failed to export PFX file: $_"
+ }
+ }
+
+ if (-not $CerPath -and -not $PfxPath) {
+ Write-Warning "No output path specified. Nothing was exported."
+ }
+}
+
+$cert = EnsureCertificate
+$pswd = ConvertTo-SecureString -String "MySecurePassword123!" -AsPlainText -Force
+Export-CertificateFiles -Certificate $cert -CerPath "$env:TEMP\cert.cer" -PfxPath "$env:TEMP\cert.pfx" -PfxPassword $pswd
diff --git a/tools/build/cert-sign-package.ps1 b/tools/build/cert-sign-package.ps1
new file mode 100644
index 0000000000..8bb57762a5
--- /dev/null
+++ b/tools/build/cert-sign-package.ps1
@@ -0,0 +1,29 @@
+param (
+ [string]$certSubject = "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US",
+ [string[]]$TargetPaths = "C:\PowerToys\ARM64\Release\WinUI3Apps\CmdPal\AppPackages\Microsoft.CmdPal.UI_0.0.1.0_Test\Microsoft.CmdPal.UI_0.0.1.0_arm64.msix"
+)
+
+. "$PSScriptRoot\cert-management.ps1"
+$cert = EnsureCertificate -certSubject $certSubject
+
+if (-not $cert) {
+ Write-Error "Failed to prepare certificate."
+ exit 1
+}
+
+Write-Host "Certificate ready: $($cert.Thumbprint)"
+
+if (-not $TargetPaths -or $TargetPaths.Count -eq 0) {
+ Write-Error "No target files provided to sign."
+ exit 1
+}
+
+foreach ($filePath in $TargetPaths) {
+ if (-not (Test-Path $filePath)) {
+ Write-Warning "Skipping: File does not exist - $filePath"
+ continue
+ }
+
+ Write-Host "Signing: $filePath"
+ & signtool sign /sha1 $($cert.Thumbprint) /fd SHA256 /t http://timestamp.digicert.com "$filePath"
+}
\ No newline at end of file
diff --git a/tools/build/ensure-wix.ps1 b/tools/build/ensure-wix.ps1
new file mode 100644
index 0000000000..988d382f07
--- /dev/null
+++ b/tools/build/ensure-wix.ps1
@@ -0,0 +1,71 @@
+<#
+.SYNOPSIS
+ Ensure WiX Toolset 3.14 (build 3141) is installed and ready to use.
+
+.DESCRIPTION
+ - Skips installation if the toolset is already installed (unless -Force is used).
+ - Otherwise downloads the official installer and binaries, verifies SHA-256, installs silently,
+ and copies wix.targets into the installation directory.
+.PARAMETER Force
+ Forces reinstallation even if the toolset is already detected.
+.PARAMETER InstallDir
+ The target installation path. Default is 'C:\Program Files (x86)\WiX Toolset v3.14'.
+.EXAMPLE
+ .\EnsureWix.ps1 # Ensure WiX is installed
+ .\EnsureWix.ps1 -Force # Force reinstall
+#>
+[CmdletBinding()]
+param(
+ [switch]$Force,
+ [string]$InstallDir = 'C:\Program Files (x86)\WiX Toolset v3.14'
+)
+
+$ErrorActionPreference = 'Stop'
+$ProgressPreference = 'SilentlyContinue'
+
+# Download URLs and expected SHA-256 hashes
+$WixDownloadUrl = 'https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe'
+$WixBinariesDownloadUrl = 'https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip'
+$InstallerHashExpected = '6BF6D03D6923D9EF827AE1D943B90B42B8EBB1B0F68EF6D55F868FA34C738A29'
+$BinariesHashExpected = '6AC824E1642D6F7277D0ED7EA09411A508F6116BA6FAE0AA5F2C7DAA2FF43D31'
+
+# Check if WiX is already installed
+$candlePath = Join-Path $InstallDir 'bin\candle.exe'
+if (-not $Force -and (Test-Path $candlePath)) {
+ Write-Host "WiX Toolset is already installed at `"$InstallDir`". Skipping installation."
+ return
+}
+
+# Temp file paths
+$tmpDir = [IO.Path]::GetTempPath()
+$installer = Join-Path $tmpDir 'wix314.exe'
+$binariesZip = Join-Path $tmpDir 'wix314-binaries.zip'
+
+# Download installer and binaries
+Write-Host 'Downloading WiX installer...'
+Invoke-WebRequest -Uri $WixDownloadUrl -OutFile $installer -UseBasicParsing
+Write-Host 'Downloading WiX binaries...'
+Invoke-WebRequest -Uri $WixBinariesDownloadUrl -OutFile $binariesZip -UseBasicParsing
+
+# Verify SHA-256 hashes
+Write-Host 'Verifying installer hash...'
+if ((Get-FileHash -Algorithm SHA256 $installer).Hash -ne $InstallerHashExpected) {
+ throw 'wix314.exe SHA256 hash mismatch'
+}
+Write-Host 'Verifying binaries hash...'
+if ((Get-FileHash -Algorithm SHA256 $binariesZip).Hash -ne $BinariesHashExpected) {
+ throw 'wix314-binaries.zip SHA256 hash mismatch'
+}
+
+# Perform silent installation
+Write-Host 'Installing WiX Toolset silently...'
+Start-Process -FilePath $installer -ArgumentList '/install','/quiet' -Wait
+
+# Extract binaries and copy wix.targets
+$expandDir = Join-Path $tmpDir 'wix-binaries'
+if (Test-Path $expandDir) { Remove-Item $expandDir -Recurse -Force }
+Expand-Archive -Path $binariesZip -DestinationPath $expandDir -Force
+Copy-Item -Path (Join-Path $expandDir 'wix.targets') `
+ -Destination (Join-Path $InstallDir 'wix.targets') -Force
+
+Write-Host "WiX Toolset has been successfully installed at: $InstallDir"