[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
This commit is contained in:
Kai Tao
2025-04-25 09:57:42 +08:00
committed by GitHub
parent f63fcfd91c
commit fc804a8156
9 changed files with 411 additions and 10 deletions

View File

@@ -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

View File

@@ -79,10 +79,7 @@
<ComponentGroupRef Id="ToolComponentGroup" />
<ComponentGroupRef Id="MonacoSRCHeatGenerated" />
<ComponentGroupRef Id="WorkspacesComponentGroup" />
<?if $(var.CIBuild) = "true" ?>
<ComponentGroupRef Id="CmdPalComponentGroup" />
<?endif?>
<ComponentGroupRef Id="CmdPalComponentGroup" />
</Feature>
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLFOLDER]" After="CostFinalize" />

View File

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

View File

@@ -2,6 +2,7 @@
<Project DefaultTargets="Build"
xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" />
<Import Project="..\Microsoft.CmdPal.UI\CmdPal.pre.props" Condition="Exists('..\Microsoft.CmdPal.UI\CmdPal.pre.prop')" />
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
@@ -49,13 +50,21 @@
</ItemGroup>
<ItemDefinitionGroup>
<ClCompile>
<PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<PreprocessorDefinitions>
EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;
%(PreprocessorDefinitions);
$(CommandPaletteBranding)
</PreprocessorDefinitions>
<PreprocessorDefinitions Condition="'$(CommandPaletteBranding)'=='' or '$(CommandPaletteBranding)'=='Dev'">
IS_DEV_BRANDING;%(PreprocessorDefinitions)
</PreprocessorDefinitions>
<AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="pch.h" />
<ClInclude Include="resource.h" />

View File

@@ -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()

View File

@@ -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'

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"