Files
zoitechat/win32/msix/build-msix.ps1
T
2026-09-26 09:39:27 -06:00

389 lines
13 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $SourceDir,
[Parameter(Mandatory = $true)]
[string] $OutputPath,
[Parameter(Mandatory = $true)]
[ValidatePattern('^\d+\.\d+\.\d+\.0$')]
[string] $PackageVersion,
[Parameter(Mandatory = $true)]
[string] $IdentityName,
[Parameter(Mandatory = $true)]
[string] $Publisher,
[Parameter(Mandatory = $true)]
[string] $PublisherDisplayName,
[switch] $SelfSign
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
function Find-WindowsSdkTool {
param(
[Parameter(Mandatory = $true)]
[string] $ToolName
)
$command = Get-Command $ToolName -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$kitsRoot = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
if (-not (Test-Path $kitsRoot)) {
throw "Windows SDK bin directory was not found: $kitsRoot"
}
$tool = Get-ChildItem -Path $kitsRoot -Filter $ToolName -File -Recurse |
Where-Object { $_.FullName -match '\\x64\\' } |
Sort-Object {
$versionDirectory = Split-Path (Split-Path $_.DirectoryName -Parent) -Leaf
try {
[version] $versionDirectory
}
catch {
[version] '0.0'
}
} -Descending |
Select-Object -First 1
if (-not $tool) {
throw "$ToolName was not found in the installed Windows SDK."
}
return $tool.FullName
}
function Escape-Xml {
param(
[Parameter(Mandatory = $true)]
[string] $Value
)
return [System.Security.SecurityElement]::Escape($Value)
}
function New-MsixAsset {
param(
[Parameter(Mandatory = $true)]
[System.Drawing.Image] $SourceImage,
[Parameter(Mandatory = $true)]
[int] $Width,
[Parameter(Mandatory = $true)]
[int] $Height,
[Parameter(Mandatory = $true)]
[string] $Path
)
$bitmap = [System.Drawing.Bitmap]::new($Width, $Height)
try {
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.Clear([System.Drawing.Color]::Transparent)
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$scale = [Math]::Min($Width / $SourceImage.Width, $Height / $SourceImage.Height)
$drawWidth = [Math]::Max(1, [int][Math]::Round($SourceImage.Width * $scale))
$drawHeight = [Math]::Max(1, [int][Math]::Round($SourceImage.Height * $scale))
$x = [int](($Width - $drawWidth) / 2)
$y = [int](($Height - $drawHeight) / 2)
$graphics.DrawImage($SourceImage, $x, $y, $drawWidth, $drawHeight)
}
finally {
$graphics.Dispose()
}
$bitmap.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$bitmap.Dispose()
}
}
$source = (Resolve-Path $SourceDir).Path
if (-not (Test-Path (Join-Path $source 'zoitechat.exe'))) {
throw "Staged ZoiteChat executable is missing from $source"
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$templatePath = Join-Path $PSScriptRoot 'AppxManifest.xml.in'
$iconPath = Join-Path $repoRoot 'data\icons\zoitechat.png'
if (-not (Test-Path $templatePath)) {
throw "MSIX manifest template is missing: $templatePath"
}
if (-not (Test-Path $iconPath)) {
throw "ZoiteChat PNG icon is missing: $iconPath"
}
if ([System.IO.Path]::IsPathRooted($OutputPath)) {
$output = [System.IO.Path]::GetFullPath($OutputPath)
}
else {
$output = [System.IO.Path]::GetFullPath((Join-Path $PWD $OutputPath))
}
$outputDirectory = Split-Path $output -Parent
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
$stagingRoot = Join-Path $env:RUNNER_TEMP 'zoitechat-msix'
$verifyRoot = Join-Path $env:RUNNER_TEMP 'zoitechat-msix-verify'
$pfxPath = Join-Path $env:RUNNER_TEMP 'zoitechat-msix-dev.pfx'
foreach ($path in @($stagingRoot, $verifyRoot)) {
if (Test-Path $path) {
Remove-Item -Path $path -Recurse -Force
}
}
if (Test-Path $pfxPath) {
Remove-Item -Path $pfxPath -Force
}
New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null
$appRoot = Join-Path $stagingRoot 'ZoiteChat'
New-Item -ItemType Directory -Path $appRoot -Force | Out-Null
Copy-Item -Path (Join-Path $source '*') -Destination $appRoot -Recurse -Force
# copy.vcxproj stages everything needed by every Inno component. The Store
# package keeps the useful bundled plugins and scripting bridges while letting
# the Microsoft Store own application updates. The WinSparkle updater is the
# only bundled plugin deliberately removed from the Store package.
$excludeFiles = @(
'portable-mode',
'zoitechat-text.exe',
'plugins\hcupd.dll',
'WinSparkle.dll',
'share\download.png'
)
foreach ($relativePath in $excludeFiles) {
$path = Join-Path $appRoot $relativePath
if (Test-Path $path) {
Remove-Item -Path $path -Force
}
}
foreach ($relativePath in @('share\doc\WinSparkle')) {
$path = Join-Path $appRoot $relativePath
if (Test-Path $path) {
Remove-Item -Path $path -Recurse -Force
}
}
foreach ($requiredPath in @(
'zoitechat.exe',
'plugins\hcnotifications-winrt.dll',
'plugins\hcchecksum.dll',
'plugins\hcexec.dll',
'plugins\hcfishlim.dll',
'plugins\hcsysinfo.dll',
'share\system.png',
'plugins\hclua.dll',
'plugins\hcperl.dll',
'plugins\hcpython3.dll',
'plugins\hcpython38.dll'
)) {
if (-not (Test-Path (Join-Path $appRoot $requiredPath))) {
throw "MSIX staging is missing required file: $requiredPath"
}
}
foreach ($requiredPattern in @('_cffi_backend.cp314*.pyd', '_cffi_backend.cp38*.pyd')) {
$match = Get-ChildItem -Path $appRoot -Filter $requiredPattern -File -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -eq $match) {
throw "MSIX staging is missing required Python support file: $requiredPattern"
}
}
foreach ($requiredPythonFile in @('zoitechat.py', '_zoitechat.py', 'hexchat.py', 'xchat.py')) {
$path = Join-Path (Join-Path $appRoot 'python') $requiredPythonFile
if (-not (Test-Path $path)) {
throw "MSIX staging is missing required Python bridge file: $requiredPythonFile"
}
}
$assets = Join-Path $stagingRoot 'Assets'
New-Item -ItemType Directory -Path $assets -Force | Out-Null
$image = [System.Drawing.Image]::FromFile($iconPath)
try {
New-MsixAsset -SourceImage $image -Width 50 -Height 50 `
-Path (Join-Path $assets 'StoreLogo.png')
New-MsixAsset -SourceImage $image -Width 44 -Height 44 `
-Path (Join-Path $assets 'Square44x44Logo.png')
New-MsixAsset -SourceImage $image -Width 150 -Height 150 `
-Path (Join-Path $assets 'Square150x150Logo.png')
}
finally {
$image.Dispose()
}
$manifest = Get-Content $templatePath -Raw -Encoding UTF8
$manifest = $manifest.Replace('@IDENTITY_NAME@', (Escape-Xml $IdentityName))
$manifest = $manifest.Replace('@PUBLISHER@', (Escape-Xml $Publisher))
$manifest = $manifest.Replace('@PUBLISHER_DISPLAY_NAME@', (Escape-Xml $PublisherDisplayName))
$manifest = $manifest.Replace('@PACKAGE_VERSION@', (Escape-Xml $PackageVersion))
if ($manifest -match '@[A-Z0-9_]+@') {
throw "Unexpanded MSIX manifest placeholder: $($Matches[0])"
}
$manifestPath = Join-Path $stagingRoot 'AppxManifest.xml'
[System.IO.File]::WriteAllText(
$manifestPath,
$manifest,
[System.Text.UTF8Encoding]::new($false)
)
# Catch malformed XML before MakeAppx so CI reports a useful error.
[xml](Get-Content $manifestPath -Raw -Encoding UTF8) | Out-Null
$makeAppx = Find-WindowsSdkTool -ToolName 'MakeAppx.exe'
$signtool = Find-WindowsSdkTool -ToolName 'SignTool.exe'
if (Test-Path $output) {
Remove-Item -Path $output -Force
}
& $makeAppx pack /d $stagingRoot /p $output /o
if ($LASTEXITCODE -ne 0) {
throw "MakeAppx failed with exit code $LASTEXITCODE"
}
$certificate = $null
try {
if ($SelfSign) {
# Partner Center replaces this development signature with Microsoft's
# trusted Store signature. The certificate subject must exactly match
# the manifest Publisher for SignTool to accept the package.
$certificate = New-SelfSignedCertificate `
-Type Custom `
-KeyUsage DigitalSignature `
-Subject $Publisher `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-HashAlgorithm SHA256 `
-TextExtension @(
'2.5.29.37={text}1.3.6.1.5.5.7.3.3',
'2.5.29.19={text}'
) `
-FriendlyName 'ZoiteChat MSIX CI signing certificate'
if ($certificate.Subject -cne $Publisher) {
throw "Generated certificate subject '$($certificate.Subject)' does not exactly match manifest Publisher '$Publisher'."
}
$passwordText = [Guid]::NewGuid().ToString('N')
$password = ConvertTo-SecureString -String $passwordText -AsPlainText -Force
Export-PfxCertificate -Cert $certificate -FilePath $pfxPath -Password $password | Out-Null
& $signtool sign /fd SHA256 /f $pfxPath /p $passwordText $output
if ($LASTEXITCODE -ne 0) {
throw "SignTool failed with exit code $LASTEXITCODE"
}
$signature = Get-AuthenticodeSignature -FilePath $output
if (-not $signature.SignerCertificate -or
$signature.SignerCertificate.Subject -cne $Publisher) {
throw 'MSIX signature does not match the manifest Publisher.'
}
}
New-Item -ItemType Directory -Path $verifyRoot -Force | Out-Null
& $makeAppx unpack /p $output /d $verifyRoot /o
if ($LASTEXITCODE -ne 0) {
throw "MakeAppx validation unpack failed with exit code $LASTEXITCODE"
}
[xml] $verifiedManifest = Get-Content (Join-Path $verifyRoot 'AppxManifest.xml') -Raw -Encoding UTF8
$ns = New-Object System.Xml.XmlNamespaceManager($verifiedManifest.NameTable)
$ns.AddNamespace('f', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10')
$identity = $verifiedManifest.SelectSingleNode('/f:Package/f:Identity', $ns)
if (-not $identity) {
throw 'Validated MSIX does not contain a package Identity.'
}
if ($identity.Name -cne $IdentityName -or
$identity.Publisher -cne $Publisher -or
$identity.Version -cne $PackageVersion) {
throw 'Validated MSIX identity does not match the requested Store identity.'
}
foreach ($requiredPath in @(
'ZoiteChat\zoitechat.exe',
'ZoiteChat\plugins\hcnotifications-winrt.dll',
'ZoiteChat\plugins\hcchecksum.dll',
'ZoiteChat\plugins\hcexec.dll',
'ZoiteChat\plugins\hcfishlim.dll',
'ZoiteChat\plugins\hcsysinfo.dll',
'ZoiteChat\share\system.png',
'ZoiteChat\plugins\hclua.dll',
'ZoiteChat\plugins\hcperl.dll',
'ZoiteChat\plugins\hcpython3.dll',
'ZoiteChat\plugins\hcpython38.dll'
)) {
if (-not (Test-Path (Join-Path $verifyRoot $requiredPath))) {
throw "Validated MSIX is missing required file: $requiredPath"
}
}
foreach ($requiredPattern in @('_cffi_backend.cp314*.pyd', '_cffi_backend.cp38*.pyd')) {
$match = Get-ChildItem -Path (Join-Path $verifyRoot 'ZoiteChat') -Filter $requiredPattern -File -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -eq $match) {
throw "Validated MSIX is missing required Python support file: $requiredPattern"
}
}
foreach ($requiredPythonFile in @('zoitechat.py', '_zoitechat.py', 'hexchat.py', 'xchat.py')) {
$path = Join-Path (Join-Path $verifyRoot 'ZoiteChat\python') $requiredPythonFile
if (-not (Test-Path $path)) {
throw "Validated MSIX is missing required Python bridge file: $requiredPythonFile"
}
}
foreach ($excludedPath in @(
'ZoiteChat\portable-mode',
'ZoiteChat\zoitechat-text.exe',
'ZoiteChat\plugins\hcupd.dll',
'ZoiteChat\WinSparkle.dll',
'ZoiteChat\share\download.png'
)) {
if (Test-Path (Join-Path $verifyRoot $excludedPath)) {
throw "MSIX incorrectly contains excluded file/directory: $excludedPath"
}
}
}
finally {
if ($certificate) {
Remove-Item -Path "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force -ErrorAction SilentlyContinue
}
if (Test-Path $pfxPath) {
Remove-Item -Path $pfxPath -Force -ErrorAction SilentlyContinue
}
foreach ($path in @($stagingRoot, $verifyRoot)) {
if (Test-Path $path) {
Remove-Item -Path $path -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
if (-not (Test-Path $output)) {
throw "Expected MSIX was not created: $output"
}
Write-Host "Created MSIX: $output"